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: "",