feat: add WebSocket server channel for APK integration
Add a WebSocket channel that accepts connections from clients (e.g. Google Assistant replacement APK). Text + base64 images are received via JSON, images are saved locally following the existing media pattern. Enabled by default on 127.0.0.1:18793/ws. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
add20a4454
commit
a639d0ecdc
4 changed files with 335 additions and 10 deletions
|
|
@ -68,6 +68,13 @@
|
||||||
"reconnect_interval": 5,
|
"reconnect_interval": 5,
|
||||||
"group_trigger_prefix": [],
|
"group_trigger_prefix": [],
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
},
|
||||||
|
"websocket": {
|
||||||
|
"enabled": false,
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 18793,
|
||||||
|
"path": "/ws",
|
||||||
|
"allow_from": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,19 @@ func (m *Manager) initChannels() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.WebSocket.Enabled {
|
||||||
|
logger.DebugC("channels", "Attempting to initialize WebSocket channel")
|
||||||
|
ws, err := NewWebSocketChannel(m.config.Channels.WebSocket, m.bus)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to initialize WebSocket channel", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.channels["websocket"] = ws
|
||||||
|
logger.InfoC("channels", "WebSocket channel enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
289
pkg/channels/websocket.go
Normal file
289
pkg/channels/websocket.go
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wsIncoming is the JSON message sent from APK to picoclaw.
|
||||||
|
type wsIncoming struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
SenderID string `json:"sender_id,omitempty"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsOutgoing is the JSON message sent from picoclaw to APK.
|
||||||
|
type wsOutgoing struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocketChannel is a server-side WebSocket channel that accepts
|
||||||
|
// connections from clients (e.g. a Google Assistant replacement APK).
|
||||||
|
type WebSocketChannel struct {
|
||||||
|
*BaseChannel
|
||||||
|
config config.WebSocketConfig
|
||||||
|
server *http.Server
|
||||||
|
upgrader websocket.Upgrader
|
||||||
|
clients map[*websocket.Conn]string // conn → clientID
|
||||||
|
chatConns map[string]*websocket.Conn // chatID → conn
|
||||||
|
mu sync.RWMutex
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebSocketChannel(cfg config.WebSocketConfig, msgBus *bus.MessageBus) (*WebSocketChannel, error) {
|
||||||
|
base := NewBaseChannel("websocket", cfg, msgBus, cfg.AllowFrom)
|
||||||
|
|
||||||
|
return &WebSocketChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
upgrader: websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
},
|
||||||
|
clients: make(map[*websocket.Conn]string),
|
||||||
|
chatConns: make(map[string]*websocket.Conn),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WebSocketChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoC("websocket", "Starting WebSocket channel server")
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc(c.config.Path, c.handleWS)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
|
||||||
|
c.server = &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(true)
|
||||||
|
|
||||||
|
logger.InfoCF("websocket", "WebSocket server listening", map[string]interface{}{
|
||||||
|
"host": c.config.Host,
|
||||||
|
"port": c.config.Port,
|
||||||
|
"path": c.config.Path,
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.ErrorCF("websocket", "Server error", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WebSocketChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("websocket", "Stopping WebSocket channel")
|
||||||
|
c.setRunning(false)
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
for conn, clientID := range c.clients {
|
||||||
|
logger.DebugCF("websocket", "Closing client connection", map[string]interface{}{
|
||||||
|
"client_id": clientID,
|
||||||
|
})
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
c.clients = make(map[*websocket.Conn]string)
|
||||||
|
c.chatConns = make(map[string]*websocket.Conn)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.server != nil {
|
||||||
|
if err := c.server.Shutdown(ctx); err != nil {
|
||||||
|
logger.ErrorCF("websocket", "Server shutdown error", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoC("websocket", "WebSocket channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WebSocketChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return fmt.Errorf("websocket channel not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
conn, ok := c.chatConns[msg.ChatID]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no connection for chat %s", msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := wsOutgoing{Content: msg.Content}
|
||||||
|
data, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
// Verify connection still exists (may have been removed during cleanup).
|
||||||
|
if _, exists := c.clients[conn]; !exists {
|
||||||
|
return fmt.Errorf("connection for chat %s no longer active", msg.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||||
|
logger.ErrorCF("websocket", "Failed to send message", map[string]interface{}{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := c.upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("websocket", "Upgrade failed", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID := uuid.New().String()
|
||||||
|
|
||||||
|
logger.InfoCF("websocket", "New WebSocket connection", map[string]interface{}{
|
||||||
|
"client_id": clientID,
|
||||||
|
"remote_addr": r.RemoteAddr,
|
||||||
|
})
|
||||||
|
|
||||||
|
chatID := fmt.Sprintf("ws:%s", clientID)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.clients[conn] = clientID
|
||||||
|
c.chatConns[chatID] = conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
go c.readPump(conn, clientID, chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WebSocketChannel) readPump(conn *websocket.Conn, clientID, chatID string) {
|
||||||
|
defer func() {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.clients, conn)
|
||||||
|
delete(c.chatConns, chatID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
conn.Close()
|
||||||
|
|
||||||
|
logger.InfoCF("websocket", "Client disconnected", map[string]interface{}{
|
||||||
|
"client_id": clientID,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
_, message, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||||
|
logger.ErrorCF("websocket", "Read error", map[string]interface{}{
|
||||||
|
"client_id": clientID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var incoming wsIncoming
|
||||||
|
if err := json.Unmarshal(message, &incoming); err != nil {
|
||||||
|
logger.ErrorCF("websocket", "Invalid JSON message", map[string]interface{}{
|
||||||
|
"client_id": clientID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use sender_id from message if provided, otherwise use clientID.
|
||||||
|
senderID := clientID
|
||||||
|
if incoming.SenderID != "" {
|
||||||
|
senderID = incoming.SenderID
|
||||||
|
}
|
||||||
|
|
||||||
|
content := incoming.Content
|
||||||
|
var media []string
|
||||||
|
|
||||||
|
// Save images to temp files (same pattern as Telegram).
|
||||||
|
for i, imgData := range incoming.Images {
|
||||||
|
path, err := c.saveImage(imgData)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("websocket", "Failed to save image", map[string]interface{}{
|
||||||
|
"client_id": clientID,
|
||||||
|
"index": i,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
media = append(media, path)
|
||||||
|
content += fmt.Sprintf("\n[image: photo_%d]", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("websocket", "Received message", map[string]interface{}{
|
||||||
|
"client_id": clientID,
|
||||||
|
"content": incoming.Content,
|
||||||
|
"images": len(incoming.Images),
|
||||||
|
})
|
||||||
|
|
||||||
|
c.HandleMessage(senderID, chatID, content, media, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WebSocketChannel) saveImage(base64Data string) (string, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||||
|
if err != nil {
|
||||||
|
// Try URL-safe base64.
|
||||||
|
data, err = base64.URLEncoding.DecodeString(base64Data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode base64: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := filepath.Join(os.TempDir(), "picoclaw", "ws_images")
|
||||||
|
if err := os.MkdirAll(tmpDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.CreateTemp(tmpDir, "ws_img_*.png")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Name(), nil
|
||||||
|
}
|
||||||
|
|
@ -71,16 +71,17 @@ type AgentDefaults struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelsConfig struct {
|
type ChannelsConfig struct {
|
||||||
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
WhatsApp WhatsAppConfig `json:"whatsapp"`
|
||||||
Telegram TelegramConfig `json:"telegram"`
|
Telegram TelegramConfig `json:"telegram"`
|
||||||
Feishu FeishuConfig `json:"feishu"`
|
Feishu FeishuConfig `json:"feishu"`
|
||||||
Discord DiscordConfig `json:"discord"`
|
Discord DiscordConfig `json:"discord"`
|
||||||
MaixCam MaixCamConfig `json:"maixcam"`
|
MaixCam MaixCamConfig `json:"maixcam"`
|
||||||
QQ QQConfig `json:"qq"`
|
QQ QQConfig `json:"qq"`
|
||||||
DingTalk DingTalkConfig `json:"dingtalk"`
|
DingTalk DingTalkConfig `json:"dingtalk"`
|
||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
LINE LINEConfig `json:"line"`
|
LINE LINEConfig `json:"line"`
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
|
WebSocket WebSocketConfig `json:"websocket"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
|
|
@ -158,6 +159,14 @@ type OneBotConfig struct {
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WebSocketConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEBSOCKET_ENABLED"`
|
||||||
|
Host string `json:"host" env:"PICOCLAW_CHANNELS_WEBSOCKET_HOST"`
|
||||||
|
Port int `json:"port" env:"PICOCLAW_CHANNELS_WEBSOCKET_PORT"`
|
||||||
|
Path string `json:"path" env:"PICOCLAW_CHANNELS_WEBSOCKET_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEBSOCKET_ALLOW_FROM"`
|
||||||
|
}
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|
@ -316,6 +325,13 @@ func DefaultConfig() *Config {
|
||||||
GroupTriggerPrefix: []string{},
|
GroupTriggerPrefix: []string{},
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
|
WebSocket: WebSocketConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
Port: 18793,
|
||||||
|
Path: "/ws",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
Anthropic: ProviderConfig{},
|
Anthropic: ProviderConfig{},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue