feat(channels): add Mattermost channel support

Add Mattermost channel using WebSocket API v4 for receiving events and
REST API v4 for sending. No external SDK — uses gorilla/websocket
(existing dependency) and net/http.

Features:
- Thread-aware replies: channel messages auto-thread, DMs stay flat
- Auto-reconnect with exponential backoff (5s-60s)
- Message splitting at 4000 chars via channels.SplitMessage
- Bot @mention stripping via configurable username field
- Proper error classification using ClassifySendError/ClassifyNetError
- MediaSender: file uploads via /api/v4/files
- TypingCapable: typing indicator support
- MessageEditor: edit existing posts
- PlaceholderCapable: "Thinking..." placeholder that gets edited

Config:
  channels.mattermost.enabled: bool
  channels.mattermost.url: server URL
  channels.mattermost.token: bot access token
  channels.mattermost.username: bot username for mention stripping
  channels.mattermost.reply_in_thread: auto-thread in channels (default true)
  channels.mattermost.allow_from: user ID allowlist
  channels.mattermost.group_trigger: group chat trigger config

Includes:
- Unit tests (TestNewMattermostChannel, TestParseChatID, TestBuildWSURL,
  TestStripBotMention)
- README.md updates (chat apps table, collapsible setup guide)
- Chinese docs at docs/channels/mattermost/README.zh.md (AI-generated,
  may need native speaker review)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Les Orchard 2026-03-09 15:07:21 -07:00
parent b89f6445d1
commit 103c9b4ab1
9 changed files with 1306 additions and 1 deletions

View file

@ -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) |
<details>
@ -436,6 +437,47 @@ picoclaw gateway
</details>
<details>
<summary><b>Mattermost</b></summary>
**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.
</details>
<details>
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>

View file

@ -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"

View file

@ -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 接口上传文件
- **输入指示器**:支持显示"正在输入"状态
- **消息编辑**:支持编辑已发送的消息
- **占位消息**:发送"思考中..."占位消息,完成后替换为实际回复

View file

@ -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")
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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"`

View file

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