diff --git a/README.md b/README.md index 5cf9f6143..d4b493fbb 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ That's it! You have a working AI assistant in 2 minutes. ## 💬 Chat Apps -Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom +Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, Mattermost, QQ, DingTalk, LINE, or WeCom > **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. @@ -321,6 +321,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, | **QQ** | Easy (AppID + AppSecret) | | **DingTalk** | Medium (app credentials) | | **LINE** | Medium (credentials + webhook URL) | +| **Mattermost** | Easy (bot token) | | **WeCom AI Bot** | Medium (Token + AES key) |
@@ -436,6 +437,47 @@ picoclaw gateway
+
+Mattermost + +**1. Create a bot account** + +* Go to your Mattermost instance → Integrations → Bot Accounts → Add Bot Account +* Copy the bot token + +**2. Configure** + +```json +{ + "channels": { + "mattermost": { + "enabled": true, + "url": "https://your-mattermost-server.com", + "token": "YOUR_BOT_TOKEN", + "username": "picoclaw", + "reply_in_thread": true, + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +| Field | Type | Required | Description | +| ---------------- | ------ | -------- | ----------------------------------------------- | +| enabled | bool | Yes | Enable Mattermost channel | +| url | string | Yes | Mattermost server URL | +| token | string | Yes | Bot access token | +| username | string | No | Bot username for @mention stripping | +| reply_in_thread | bool | No | Auto-thread replies in channels (default: true) | +| allow_from | array | No | User ID allowlist (empty = allow all) | +| group_trigger | object | No | Group trigger config (mention_only, etc.) | +| typing | object | No | Typing indicator config (enabled, etc.) | +| placeholder | object | No | Placeholder config (enabled, text; default: "Thinking... 💭") | + +**3. Add the bot to channels** in Mattermost, then DM it or @mention it. + +
+
WhatsApp (native via whatsmeow) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 4f93b858a..b3599d08f 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -20,6 +20,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/matrix" + _ "github.com/sipeed/picoclaw/pkg/channels/mattermost" _ "github.com/sipeed/picoclaw/pkg/channels/onebot" _ "github.com/sipeed/picoclaw/pkg/channels/pico" _ "github.com/sipeed/picoclaw/pkg/channels/qq" diff --git a/docs/channels/mattermost/README.zh.md b/docs/channels/mattermost/README.zh.md new file mode 100644 index 000000000..b00e8019c --- /dev/null +++ b/docs/channels/mattermost/README.zh.md @@ -0,0 +1,50 @@ +# Mattermost + +Mattermost 是一个开源的团队协作平台。PicoClaw 通过 WebSocket API v4 和 REST API v4 连接到 Mattermost 服务器,支持接收和发送消息、文件上传、线程回复和输入指示器。 + +## 配置 + +```json +{ + "channels": { + "mattermost": { + "enabled": true, + "url": "https://your-mattermost-server.com", + "token": "YOUR_BOT_TOKEN", + "username": "picoclaw", + "reply_in_thread": true, + "allow_from": ["YOUR_USER_ID"] + } + } +} +``` + +| 字段 | 类型 | 必填 | 描述 | +| ---------------- | ------ | ---- | ---------------------------------------- | +| enabled | bool | 是 | 是否启用 Mattermost 频道 | +| url | string | 是 | Mattermost 服务器地址 | +| token | string | 是 | 机器人访问令牌 | +| username | string | 否 | 机器人用户名(用于去除 @提及) | +| reply_in_thread | bool | 否 | 在频道中自动使用线程回复(默认:true) | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | +| group_trigger | object | 否 | 群组触发设置 | +| typing | object | 否 | 输入指示器设置 | +| placeholder | object | 否 | 占位消息设置(默认启用,文本:"Thinking... 💭")| + +## 设置流程 + +1. 前往 Mattermost 管理后台 → 集成 → 机器人帐户 → 添加机器人帐户 +2. 复制机器人令牌 +3. 将令牌填入配置文件中 +4. 将机器人添加到需要的频道 +5. 通过私信或 @提及 与机器人交互 + +## 功能 + +- **线程回复**:频道消息自动使用线程,私信保持平面结构 +- **自动重连**:WebSocket 断开后自动重连(指数退避 5s-60s) +- **消息分割**:超长消息自动分割(上限 4000 字符) +- **文件上传**:支持通过 MediaSender 接口上传文件 +- **输入指示器**:支持显示"正在输入"状态 +- **消息编辑**:支持编辑已发送的消息 +- **占位消息**:发送"思考中..."占位消息,完成后替换为实际回复 diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index cdd49538f..44869580d 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -252,6 +252,12 @@ func (m *Manager) initChannels() error { m.initChannel("matrix", "Matrix") } + if m.config.Channels.Mattermost.Enabled && + m.config.Channels.Mattermost.Token != "" && + m.config.Channels.Mattermost.URL != "" { + m.initChannel("mattermost", "Mattermost") + } + if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" { m.initChannel("line", "LINE") } diff --git a/pkg/channels/mattermost/init.go b/pkg/channels/mattermost/init.go new file mode 100644 index 000000000..4f770b562 --- /dev/null +++ b/pkg/channels/mattermost/init.go @@ -0,0 +1,13 @@ +package mattermost + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("mattermost", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewMattermostChannel(cfg.Channels.Mattermost, b) + }) +} diff --git a/pkg/channels/mattermost/mattermost.go b/pkg/channels/mattermost/mattermost.go new file mode 100644 index 000000000..6b3f95b76 --- /dev/null +++ b/pkg/channels/mattermost/mattermost.go @@ -0,0 +1,833 @@ +// Package mattermost implements a Mattermost channel for picoclaw. +// +// Uses WebSocket API v4 for receiving events and REST API v4 for sending +// messages and uploading files. Supports threading, typing indicators, +// message editing, and placeholder messages. +// +// No external Mattermost SDK — only gorilla/websocket (already a project +// dependency) and net/http. +package mattermost + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +const ( + maxMessageLen = 4000 // Mattermost display limit + wsReadTimeout = 90 * time.Second // read deadline for WebSocket +) + +// Package-level compiled regexes to avoid recompilation per message. +var multiSpaceRe = regexp.MustCompile(`\s{2,}`) + +// MattermostChannel connects to a Mattermost server via WebSocket + REST API v4. +// It supports threaded replies, typing indicators, message editing, placeholder +// messages, and file uploads. +type MattermostChannel struct { + *channels.BaseChannel + config config.MattermostConfig + baseURL string // normalized base URL (no trailing slash) + httpClient *http.Client + ws *websocket.Conn + wsMu sync.Mutex + botUserID string + botUsername string + mentionRe *regexp.Regexp // compiled at start, nil if no username + ctx context.Context + cancel context.CancelFunc +} + +// NewMattermostChannel creates a new Mattermost channel. Both URL and Token +// must be provided in the config. +func NewMattermostChannel(cfg config.MattermostConfig, messageBus *bus.MessageBus) (*MattermostChannel, error) { + if cfg.URL == "" || cfg.Token == "" { + return nil, fmt.Errorf("mattermost url and token are required") + } + + parsed, err := url.Parse(cfg.URL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return nil, fmt.Errorf("mattermost url must be a valid http:// or https:// URL") + } + normalizedURL := parsed.Scheme + "://" + parsed.Host + strings.TrimRight(parsed.Path, "/") + + base := channels.NewBaseChannel("mattermost", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(maxMessageLen), + channels.WithGroupTrigger(cfg.GroupTrigger), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), + ) + + return &MattermostChannel{ + BaseChannel: base, + config: cfg, + baseURL: normalizedURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + }, nil +} + +// Start connects to Mattermost and begins listening for events. +func (c *MattermostChannel) Start(ctx context.Context) error { + logger.InfoC("mattermost", "Starting Mattermost channel") + + c.ctx, c.cancel = context.WithCancel(ctx) + + // Verify credentials and get bot info. + me, err := c.apiGet(c.ctx, "/api/v4/users/me") + if err != nil { + c.cancel() + return fmt.Errorf("mattermost auth failed: %w", err) + } + + c.botUserID, _ = me["id"].(string) + c.botUsername, _ = me["username"].(string) + + logger.InfoCF("mattermost", "Bot authenticated", map[string]any{ + "username": c.botUsername, + "user_id": c.botUserID, + }) + + // Compile mention regex now that we know the bot username. + c.compileMentionPattern() + + // Connect WebSocket. + if err := c.connectWS(); err != nil { + c.cancel() + return fmt.Errorf("mattermost websocket connect: %w", err) + } + + // Spawn listener + reconnect loop. + go c.listenLoop() + + c.SetRunning(true) + logger.InfoC("mattermost", "Mattermost channel started") + return nil +} + +// Stop disconnects from Mattermost. +func (c *MattermostChannel) Stop(ctx context.Context) error { + logger.InfoC("mattermost", "Stopping Mattermost channel") + + if c.cancel != nil { + c.cancel() + } + + c.wsMu.Lock() + if c.ws != nil { + c.ws.Close() + c.ws = nil + } + c.wsMu.Unlock() + + c.SetRunning(false) + logger.InfoC("mattermost", "Mattermost channel stopped") + return nil +} + +// Send sends a text message to Mattermost. The Manager handles message +// splitting via MaxMessageLength, so this sends a single chunk. +func (c *MattermostChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + channelID, rootID := parseChatID(msg.ChatID) + if channelID == "" { + return fmt.Errorf("%w: invalid mattermost chat ID: %s", channels.ErrSendFailed, msg.ChatID) + } + + body := map[string]string{ + "channel_id": channelID, + "message": msg.Content, + } + if rootID != "" { + body["root_id"] = rootID + } + + if err := c.apiPost(ctx, "/api/v4/posts", body); err != nil { + return err + } + + logger.DebugCF("mattermost", "Message sent", map[string]any{ + "channel_id": channelID, + "root_id": rootID, + }) + + return nil +} + +// SendMedia implements channels.MediaSender. It uploads files to Mattermost +// and attaches them to a post. +func (c *MattermostChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + channelID, rootID := parseChatID(msg.ChatID) + if channelID == "" { + return fmt.Errorf("%w: invalid mattermost chat ID: %s", channels.ErrSendFailed, msg.ChatID) + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("%w: no media store available", channels.ErrSendFailed) + } + + var fileIDs []string + var captions []string + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("mattermost", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + filename := part.Filename + if filename == "" { + filename = "file" + } + + fileID, err := c.uploadFile(ctx, channelID, localPath, filename) + if err != nil { + logger.ErrorCF("mattermost", "Failed to upload file", map[string]any{ + "filename": filename, + "error": err.Error(), + }) + return fmt.Errorf("upload failed: %w", err) + } + + fileIDs = append(fileIDs, fileID) + if part.Caption != "" { + captions = append(captions, part.Caption) + } + } + + if len(fileIDs) == 0 { + return nil + } + + // Create a post with the uploaded file IDs. + postBody := map[string]any{ + "channel_id": channelID, + "message": strings.Join(captions, "\n"), + "file_ids": fileIDs, + } + if rootID != "" { + postBody["root_id"] = rootID + } + + return c.apiPost(ctx, "/api/v4/posts", postBody) +} + +// StartTyping implements channels.TypingCapable. It sends a typing indicator +// and returns a stop function (no-op since Mattermost typing expires automatically). +func (c *MattermostChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + if !c.IsRunning() || !c.config.Typing.Enabled { + return func() {}, nil + } + + channelID, _ := parseChatID(chatID) + if channelID == "" { + return func() {}, nil + } + + if err := c.apiPost(ctx, "/api/v4/users/me/typing", map[string]string{ + "channel_id": channelID, + }); err != nil { + logger.WarnCF("mattermost", "Failed to send typing indicator", map[string]any{ + "channel_id": channelID, + "error": err.Error(), + }) + } + + return func() {}, nil +} + +// EditMessage implements channels.MessageEditor. It updates the content of +// an existing Mattermost post. +func (c *MattermostChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + if messageID == "" { + return fmt.Errorf("%w: empty message ID", channels.ErrSendFailed) + } + + body := map[string]string{ + "id": messageID, + "message": content, + } + + return c.apiPut(ctx, "/api/v4/posts/"+messageID+"/patch", body) +} + +// SendPlaceholder implements channels.PlaceholderCapable. It sends a +// placeholder message that will later be edited with the actual response. +// The placeholder text is configurable via channels.mattermost.placeholder.text. +func (c *MattermostChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + if !c.IsRunning() { + return "", channels.ErrNotRunning + } + + if !c.config.Placeholder.Enabled { + return "", nil + } + + channelID, rootID := parseChatID(chatID) + if channelID == "" { + return "", fmt.Errorf("%w: invalid chat ID", channels.ErrSendFailed) + } + + text := c.config.Placeholder.Text + if text == "" { + text = "Thinking... 💭" + } + + body := map[string]string{ + "channel_id": channelID, + "message": text, + } + if rootID != "" { + body["root_id"] = rootID + } + + resp, err := c.apiPostJSON(ctx, "/api/v4/posts", body) + if err != nil { + return "", err + } + + postID, _ := resp["id"].(string) + return postID, nil +} + +// -- WebSocket connection --------------------------------------------------- + +func (c *MattermostChannel) connectWS() error { + wsURL := c.buildWSURL() + logger.InfoCF("mattermost", "Connecting WebSocket", map[string]any{"url": wsURL}) + + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + } + conn, resp, err := dialer.DialContext(c.ctx, wsURL, nil) + if resp != nil { + resp.Body.Close() + } + if err != nil { + return err + } + + // Set read deadline so ReadMessage doesn't block forever on half-open connections. + // The server sends pings; gorilla auto-replies with pongs. We reset the read + // deadline on each ping to keep the connection alive. + conn.SetReadDeadline(time.Now().Add(wsReadTimeout)) + conn.SetPingHandler(func(appData string) error { + conn.SetReadDeadline(time.Now().Add(wsReadTimeout)) + // Write pong back (gorilla default behavior, but we need to do it + // explicitly when overriding the ping handler). + return conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(5*time.Second)) + }) + + // Authenticate via the WebSocket authentication challenge. + authMsg := map[string]any{ + "seq": 1, + "action": "authentication_challenge", + "data": map[string]string{"token": c.config.Token}, + } + if err := conn.WriteJSON(authMsg); err != nil { + conn.Close() + return fmt.Errorf("ws auth: %w", err) + } + + c.wsMu.Lock() + c.ws = conn + c.wsMu.Unlock() + + logger.InfoC("mattermost", "WebSocket connected") + return nil +} + +func (c *MattermostChannel) listenLoop() { + backoff := 5 * time.Second + maxBackoff := 60 * time.Second + + for { + if c.ctx.Err() != nil { + return + } + + c.wsMu.Lock() + ws := c.ws + c.wsMu.Unlock() + + if ws == nil { + logger.InfoC("mattermost", "Attempting WebSocket reconnect...") + if err := c.connectWS(); err != nil { + logger.ErrorCF("mattermost", "WebSocket reconnect failed", map[string]any{ + "error": err.Error(), + "backoff": backoff.String(), + }) + select { + case <-time.After(backoff): + case <-c.ctx.Done(): + return + } + backoff = min(backoff*2, maxBackoff) + continue + } + + c.wsMu.Lock() + ws = c.ws + c.wsMu.Unlock() + } + + // Reset read deadline on each successful read to keep the + // connection alive as long as the server is sending events. + ws.SetReadDeadline(time.Now().Add(wsReadTimeout)) + _, raw, err := ws.ReadMessage() + if err != nil { + if c.ctx.Err() != nil { + return + } + logger.WarnCF("mattermost", "WebSocket read error", map[string]any{ + "error": err.Error(), + "backoff": backoff.String(), + }) + c.wsMu.Lock() + if c.ws != nil { + c.ws.Close() + c.ws = nil + } + c.wsMu.Unlock() + // Apply backoff before reconnecting to avoid tight loops when + // connections succeed but immediately drop (server restart, etc.). + select { + case <-time.After(backoff): + case <-c.ctx.Done(): + return + } + backoff = min(backoff*2, maxBackoff) + continue + } + + // Successful read — reset backoff. + backoff = 5 * time.Second + + c.handleWSMessage(raw) + } +} + +func (c *MattermostChannel) handleWSMessage(raw []byte) { + var evt struct { + Event string `json:"event"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(raw, &evt); err != nil { + return + } + + switch evt.Event { + case "posted": + c.handlePosted(evt.Data) + case "hello": + logger.InfoC("mattermost", "WebSocket hello (server ready)") + case "": + // Acknowledgement frame, ignore. + default: + logger.DebugCF("mattermost", "Unhandled WS event", map[string]any{ + "event": evt.Event, + }) + } +} + +func (c *MattermostChannel) handlePosted(data json.RawMessage) { + var d struct { + Post string `json:"post"` + ChannelType string `json:"channel_type"` + SenderName string `json:"sender_name"` + TeamID string `json:"team_id"` + } + if err := json.Unmarshal(data, &d); err != nil { + logger.WarnC("mattermost", "Failed to parse posted event data") + return + } + + // The post field is double-encoded JSON. + var post struct { + ID string `json:"id"` + Type string `json:"type"` + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + RootID string `json:"root_id"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(d.Post), &post); err != nil { + logger.WarnC("mattermost", "Failed to parse post JSON") + return + } + + // Ignore system-generated messages (joins, leaves, header changes, etc.). + // Regular user messages have an empty type field. + if post.Type != "" { + return + } + + // Ignore own messages. + if post.UserID == c.botUserID { + return + } + + content := strings.TrimSpace(post.Message) + + // Detect whether the bot was @mentioned before stripping. + isMentioned := c.hasBotMention(content) + content = c.stripBotMention(content) + + if content == "" { + return + } + + sender := bus.SenderInfo{ + Platform: "mattermost", + PlatformID: post.UserID, + CanonicalID: identity.BuildCanonicalID("mattermost", post.UserID), + Username: d.SenderName, + } + + if !c.IsAllowedSender(sender) { + logger.DebugCF("mattermost", "Message rejected by allowlist", map[string]any{ + "user_id": post.UserID, + }) + return + } + + // "D" = direct message, "G" = group direct message; both bypass group-trigger + // filtering and threading, matching the intended "DMs stay flat" behavior. + isDM := d.ChannelType == "D" || d.ChannelType == "G" + + // In non-DM channels, apply group trigger filtering. + if !isDM { + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) + if !respond { + return + } + content = cleaned + } + + // Build chatID with threading context: + // DMs: just channelID (no threading) + // Existing thread: channelID/rootID (continue thread) + // Channel + reply_in_thread: channelID/postID (start new thread) + // Channel + no threading: just channelID (flat reply) + chatID := post.ChannelID + if post.RootID != "" { + chatID = post.ChannelID + "/" + post.RootID + } else if !isDM && c.config.ReplyInThread { + chatID = post.ChannelID + "/" + post.ID + } + + peerKind := "channel" + peerID := post.ChannelID + if isDM { + peerKind = "direct" + peerID = post.UserID + } + + peer := bus.Peer{Kind: peerKind, ID: peerID} + + metadata := map[string]string{ + "post_id": post.ID, + "channel_id": post.ChannelID, + "root_id": post.RootID, + "channel_type": d.ChannelType, + "sender_name": d.SenderName, + "team_id": d.TeamID, + "platform": "mattermost", + } + + logger.DebugCF("mattermost", "Received message", map[string]any{ + "sender": d.SenderName, + "chat_id": chatID, + "preview": utils.Truncate(content, 50), + "is_dm": isDM, + }) + + c.HandleMessage(c.ctx, peer, post.ID, post.UserID, chatID, content, nil, metadata, sender) +} + +// -- HTTP helpers ----------------------------------------------------------- + +// apiGet performs a GET request against the Mattermost REST API v4 and +// returns the parsed JSON response. +func (c *MattermostChannel) apiGet(ctx context.Context, path string) (map[string]any, error) { + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.config.Token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, channels.ClassifyNetError(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading error response: %w", readErr)) + } + return nil, channels.ClassifySendError(resp.StatusCode, fmt.Errorf("%s", string(body))) + } + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result, nil +} + +// apiDo performs an HTTP request with a JSON body and discards the response body. +// Errors are classified as ErrRateLimit, ErrTemporary, or ErrSendFailed +// based on the HTTP status code. +func (c *MattermostChannel) apiDo(ctx context.Context, method, path string, body any) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.config.Token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return channels.ClassifyNetError(err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading error response: %w", readErr)) + } + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("%s", string(respBody))) + } + return nil +} + +// apiPost performs a POST request against the Mattermost REST API v4. +func (c *MattermostChannel) apiPost(ctx context.Context, path string, body any) error { + return c.apiDo(ctx, "POST", path, body) +} + +// apiPostJSON performs a POST and returns the parsed JSON response body. +func (c *MattermostChannel) apiPostJSON(ctx context.Context, path string, body any) (map[string]any, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.config.Token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, channels.ClassifyNetError(err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading error response: %w", readErr)) + } + return nil, channels.ClassifySendError(resp.StatusCode, fmt.Errorf("%s", string(respBody))) + } + + var result map[string]any + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return result, nil +} + +// apiPut performs a PUT request against the Mattermost REST API v4. +func (c *MattermostChannel) apiPut(ctx context.Context, path string, body any) error { + return c.apiDo(ctx, "PUT", path, body) +} + +// uploadFile uploads a local file to Mattermost using streaming multipart +// upload and returns the file ID. +func (c *MattermostChannel) uploadFile(ctx context.Context, channelID, localPath, filename string) (string, error) { + file, err := os.Open(localPath) + if err != nil { + return "", fmt.Errorf("%w: %w", channels.ErrSendFailed, err) + } + defer file.Close() + + // Use io.Pipe for streaming multipart upload to avoid buffering large + // files entirely in memory. We create the writer (for content type) and + // request before starting the goroutine to avoid leaking it on early errors. + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + contentType := writer.FormDataContentType() + + uploadURL := c.baseURL + "/api/v4/files" + req, err := http.NewRequestWithContext(ctx, "POST", uploadURL, pr) + if err != nil { + pw.Close() + return "", err + } + req.Header.Set("Authorization", "Bearer "+c.config.Token) + req.Header.Set("Content-Type", contentType) + + // Start the pipe writer goroutine only after the request is created. + go func() { + var werr error + defer pw.Close() + if werr = writer.WriteField("channel_id", channelID); werr != nil { + pw.CloseWithError(werr) + return + } + var part io.Writer + if part, werr = writer.CreateFormFile("files", filename); werr != nil { + pw.CloseWithError(werr) + return + } + if _, werr = io.Copy(part, file); werr != nil { + pw.CloseWithError(werr) + return + } + if werr = writer.Close(); werr != nil { + pw.CloseWithError(werr) + } + }() + + resp, err := c.httpClient.Do(req) + if err != nil { + // Close the pipe reader so the writer goroutine unblocks and exits. + pr.Close() + return "", channels.ClassifyNetError(err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading error response: %w", readErr)) + } + return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("%s", string(respBody))) + } + + var result struct { + FileInfos []struct { + ID string `json:"id"` + } `json:"file_infos"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + if len(result.FileInfos) == 0 { + return "", fmt.Errorf("%w: no file ID returned from upload", channels.ErrSendFailed) + } + return result.FileInfos[0].ID, nil +} + +// -- Utility helpers -------------------------------------------------------- + +// buildWSURL converts the Mattermost server URL to a WebSocket URL +// (https → wss, http → ws) and appends the WebSocket API path. +// Preserves any base path prefix from the original URL. +func (c *MattermostChannel) buildWSURL() string { + parsed, err := url.Parse(c.baseURL) + if err != nil { + return strings.Replace(c.baseURL, "https://", "wss://", 1) + "/api/v4/websocket" + } + scheme := "wss" + if parsed.Scheme == "http" { + scheme = "ws" + } + parsed.Scheme = scheme + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/api/v4/websocket" + return parsed.String() +} + +// compileMentionPattern builds and caches the bot mention regex. Called once +// after authentication when the bot username is known. +func (c *MattermostChannel) compileMentionPattern() { + username := c.config.Username + if username == "" { + username = c.botUsername + } + if username == "" { + return + } + // Match @username followed by a word boundary (not as a substring of + // another word like @mybotany). Case-insensitive. + c.mentionRe = regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(username) + `\b`) +} + +// hasBotMention checks whether the message text contains a whole-word @mention +// of the bot, before stripping. Used to pass isMentioned to ShouldRespondInGroup. +func (c *MattermostChannel) hasBotMention(text string) bool { + if c.mentionRe == nil { + return false + } + return c.mentionRe.MatchString(text) +} + +// stripBotMention removes whole-word @botusername mentions from message text +// so the agent receives clean content without its own mention. +func (c *MattermostChannel) stripBotMention(text string) string { + if c.mentionRe == nil { + return text + } + text = c.mentionRe.ReplaceAllString(text, " ") + text = multiSpaceRe.ReplaceAllString(text, " ") + return strings.TrimSpace(text) +} + +// parseChatID splits a chat ID into channelID and optional rootID. +// Format: "channelID" or "channelID/rootID" for threaded conversations. +// SplitN(..., 2) is intentional: post IDs contain no slashes, so any extra +// slashes would be part of a malformed input and are folded into rootID. +func parseChatID(chatID string) (channelID, rootID string) { + parts := strings.SplitN(chatID, "/", 2) + channelID = parts[0] + if len(parts) > 1 { + rootID = parts[1] + } + return channelID, rootID +} diff --git a/pkg/channels/mattermost/mattermost_test.go b/pkg/channels/mattermost/mattermost_test.go new file mode 100644 index 000000000..c81ce6af1 --- /dev/null +++ b/pkg/channels/mattermost/mattermost_test.go @@ -0,0 +1,334 @@ +package mattermost + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewMattermostChannel(t *testing.T) { + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + t.Run("valid config", func(t *testing.T) { + cfg := config.MattermostConfig{ + URL: "https://mattermost.example.com", + Token: "test-token", + } + ch, err := NewMattermostChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ch.Name() != "mattermost" { + t.Errorf("expected name 'mattermost', got %q", ch.Name()) + } + }) + + t.Run("missing url", func(t *testing.T) { + cfg := config.MattermostConfig{ + URL: "", + Token: "test-token", + } + _, err := NewMattermostChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for missing URL") + } + }) + + t.Run("missing token", func(t *testing.T) { + cfg := config.MattermostConfig{ + URL: "https://mattermost.example.com", + Token: "", + } + _, err := NewMattermostChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for missing token") + } + }) + + t.Run("invalid url scheme", func(t *testing.T) { + cfg := config.MattermostConfig{ + URL: "mattermost.example.com", + Token: "test-token", + } + _, err := NewMattermostChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for URL without scheme") + } + }) + + t.Run("both missing", func(t *testing.T) { + cfg := config.MattermostConfig{} + _, err := NewMattermostChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for empty config") + } + }) +} + +func TestParseChatID(t *testing.T) { + tests := []struct { + name string + chatID string + channelID string + rootID string + }{ + { + name: "channel only", + chatID: "abc123", + channelID: "abc123", + rootID: "", + }, + { + name: "channel with thread", + chatID: "abc123/post456", + channelID: "abc123", + rootID: "post456", + }, + { + name: "empty string", + chatID: "", + channelID: "", + rootID: "", + }, + { + name: "multiple slashes", + chatID: "abc/def/ghi", + channelID: "abc", + rootID: "def/ghi", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + channelID, rootID := parseChatID(tt.chatID) + if channelID != tt.channelID { + t.Errorf("channelID: got %q, want %q", channelID, tt.channelID) + } + if rootID != tt.rootID { + t.Errorf("rootID: got %q, want %q", rootID, tt.rootID) + } + }) + } +} + +func TestBuildWSURL(t *testing.T) { + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + tests := []struct { + name string + url string + expected string + }{ + { + name: "https to wss", + url: "https://mattermost.example.com", + expected: "wss://mattermost.example.com/api/v4/websocket", + }, + { + name: "http to ws", + url: "http://localhost:8065", + expected: "ws://localhost:8065/api/v4/websocket", + }, + { + name: "https with trailing slash", + url: "https://mattermost.example.com/", + expected: "wss://mattermost.example.com/api/v4/websocket", + }, + { + name: "https with base path (reverse proxy)", + url: "https://mattermost.example.com/some/path", + expected: "wss://mattermost.example.com/some/path/api/v4/websocket", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.MattermostConfig{URL: tt.url, Token: "test"} + ch, err := NewMattermostChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := ch.buildWSURL() + if got != tt.expected { + t.Errorf("buildWSURL(%q) = %q, want %q", tt.url, got, tt.expected) + } + }) + } +} + +func TestStripBotMention(t *testing.T) { + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + t.Run("with configured username", func(t *testing.T) { + cfg := config.MattermostConfig{URL: "https://x", Token: "t", Username: "mybot"} + ch, err := NewMattermostChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ch.compileMentionPattern() + + tests := []struct { + input string + expected string + }{ + {"@mybot hello", "hello"}, + {"hello @mybot world", "hello world"}, + {"hello", "hello"}, + {"@mybot", ""}, + {"", ""}, + {"@mybotany should not strip", "@mybotany should not strip"}, + {"@mybot, thanks", ", thanks"}, + } + + for _, tt := range tests { + got := ch.stripBotMention(tt.input) + if got != tt.expected { + t.Errorf("stripBotMention(%q) = %q, want %q", tt.input, got, tt.expected) + } + } + }) + + t.Run("with bot username from auth", func(t *testing.T) { + cfg := config.MattermostConfig{URL: "https://x", Token: "t"} + ch, err := NewMattermostChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ch.botUsername = "authbot" + ch.compileMentionPattern() + + got := ch.stripBotMention("@authbot hello") + if got != "hello" { + t.Errorf("expected 'hello', got %q", got) + } + }) + + t.Run("no username configured", func(t *testing.T) { + cfg := config.MattermostConfig{URL: "https://x", Token: "t"} + ch, err := NewMattermostChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := ch.stripBotMention("@someone hello") + if got != "@someone hello" { + t.Errorf("expected no stripping, got %q", got) + } + }) +} + +func TestHasBotMention(t *testing.T) { + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + cfg := config.MattermostConfig{URL: "https://x", Token: "t", Username: "mybot"} + ch, err := NewMattermostChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ch.compileMentionPattern() + + tests := []struct { + input string + expected bool + }{ + {"@mybot hello", true}, + {"hello @mybot", true}, + {"@mybot, thanks", true}, + {"hello world", false}, + {"@otherbot hello", false}, + {"@mybotany hello", false}, + {"", false}, + } + + for _, tt := range tests { + got := ch.hasBotMention(tt.input) + if got != tt.expected { + t.Errorf("hasBotMention(%q) = %v, want %v", tt.input, got, tt.expected) + } + } +} + +// makePostedEventData builds a "posted" event data payload for testing handlePosted. +func makePostedEventData(postType, userID, channelID, message, channelType string) json.RawMessage { + post := map[string]string{ + "id": "post123", + "type": postType, + "user_id": userID, + "channel_id": channelID, + "root_id": "", + "message": message, + } + postJSON, _ := json.Marshal(post) + data := map[string]string{ + "post": string(postJSON), + "channel_type": channelType, + "sender_name": "testuser", + "team_id": "team1", + } + raw, _ := json.Marshal(data) + return raw +} + +func TestHandlePostedIgnoresSystemMessages(t *testing.T) { + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + cfg := config.MattermostConfig{URL: "https://x", Token: "t"} + ch, err := NewMattermostChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ch.botUserID = "bot123" + ch.ctx, ch.cancel = context.WithCancel(context.Background()) + defer ch.cancel() + + // System message (e.g. user joined channel) — should be ignored. + ch.handlePosted(makePostedEventData("system_join_channel", "user1", "chan1", "user1 joined", "O")) + + // Regular user message — should be processed. + ch.handlePosted(makePostedEventData("", "user1", "chan1", "hello bot", "D")) + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + msg, ok := msgBus.ConsumeInbound(ctx) + if !ok { + t.Fatal("expected a message from the regular post, got none") + } + if msg.Content != "hello bot" { + t.Errorf("expected 'hello bot', got %q", msg.Content) + } +} + +func TestHandlePostedIgnoresOwnMessages(t *testing.T) { + msgBus := bus.NewMessageBus() + defer msgBus.Close() + + cfg := config.MattermostConfig{URL: "https://x", Token: "t"} + ch, err := NewMattermostChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ch.botUserID = "bot123" + ch.ctx, ch.cancel = context.WithCancel(context.Background()) + defer ch.cancel() + + // Message from the bot itself — should be ignored. + ch.handlePosted(makePostedEventData("", "bot123", "chan1", "I am the bot", "D")) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, ok := msgBus.ConsumeInbound(ctx) + if ok { + t.Error("expected no message, but got one") + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a47ab3091..49a1d9760 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -233,6 +233,7 @@ type ChannelsConfig struct { WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` Pico PicoConfig `json:"pico"` IRC IRCConfig `json:"irc"` + Mattermost MattermostConfig `json:"mattermost"` } // GroupTriggerConfig controls when the bot responds in group chats. @@ -348,6 +349,19 @@ type MatrixConfig struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` } +type MattermostConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATTERMOST_ENABLED"` + URL string `json:"url" env:"PICOCLAW_CHANNELS_MATTERMOST_URL"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_MATTERMOST_TOKEN"` + Username string `json:"username" env:"PICOCLAW_CHANNELS_MATTERMOST_USERNAME"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATTERMOST_ALLOW_FROM"` + ReplyInThread bool `json:"reply_in_thread" env:"PICOCLAW_CHANNELS_MATTERMOST_REPLY_IN_THREAD"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATTERMOST_REASONING_CHANNEL_ID"` +} + type LINEConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index e64baa720..a50879bc3 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -113,6 +113,18 @@ func DefaultConfig() *Config { Text: "Thinking... 💭", }, }, + Mattermost: MattermostConfig{ + Enabled: false, + URL: "", + Token: "", + Username: "", + AllowFrom: FlexibleStringSlice{}, + ReplyInThread: true, + Placeholder: PlaceholderConfig{ + Enabled: true, + Text: "Thinking... 💭", + }, + }, LINE: LINEConfig{ Enabled: false, ChannelSecret: "",