feat(mattermost): add Mattermost channel integration

This commit is contained in:
2niuhe 2026-04-14 10:32:31 +08:00
parent 04e99a1264
commit 066a3dbebf
13 changed files with 1557 additions and 9 deletions

View file

@ -470,6 +470,7 @@ Talk to your PicoClaw through 18+ messaging platforms:
|---------|-------|----------|------|
| **Telegram** | Easy (bot token) | Long polling | [Guide](docs/channels/telegram/README.md) |
| **Discord** | Easy (bot token + intents) | WebSocket | [Guide](docs/channels/discord/README.md) |
| **Mattermost** | Easy (bot token) | WebSocket + REST | [Guide](docs/channels/mattermost/README.md) |
| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/chat-apps.md#whatsapp) |
| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/chat-apps.md#weixin) |
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |

View file

@ -462,6 +462,7 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider使用 `协议/模
|---------|----------|------|------|
| **Telegram** | 简单bot token | 长轮询 | [指南](docs/channels/telegram/README.zh.md) |
| **Discord** | 简单bot token + intents | WebSocket | [指南](docs/channels/discord/README.zh.md) |
| **Mattermost** | 简单bot token | WebSocket + REST | [指南](docs/channels/mattermost/README.zh.md) |
| **WhatsApp** | 简单(扫码或 bridge URL | 原生 / Bridge | [指南](docs/zh/chat-apps.md#whatsapp) |
| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](docs/zh/chat-apps.md#weixin) |
| **QQ** | 简单AppID + AppSecret | WebSocket | [指南](docs/channels/qq/README.zh.md) |
@ -624,5 +625,3 @@ Discord: <https://discord.gg/V4sAZ9XWpN>
WeChat:
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">

View file

@ -161,6 +161,23 @@
"allow_from": [],
"reasoning_channel_id": ""
},
"mattermost": {
"enabled": false,
"url": "https://your-mattermost.example.com",
"bot_token": "YOUR_MATTERMOST_BOT_TOKEN",
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"typing": {
"enabled": true
},
"placeholder": {
"enabled": false,
"text": "Thinking..."
},
"reasoning_channel_id": ""
},
"matrix": {
"enabled": false,
"homeserver": "https://matrix.org",

View file

@ -0,0 +1,68 @@
# Mattermost
Mattermost is a popular team collaboration platform. PicoClaw integrates with Mattermost via WebSocket + REST API to provide real-time messaging, typing indicators, placeholder messages, and attachment handling.
## Configuration
```json
{
"channels": {
"mattermost": {
"enabled": true,
"url": "https://your-mattermost.example.com",
"bot_token": "YOUR_MATTERMOST_BOT_TOKEN",
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"typing": {
"enabled": true
},
"placeholder": {
"enabled": false,
"text": "Thinking..."
},
"reasoning_channel_id": ""
}
}
}
```
| Field | Type | Required | Description |
| -------------------- | ------ | -------- | ----------- |
| enabled | bool | Yes | Whether to enable the Mattermost channel |
| url | string | Yes | Mattermost server URL (e.g. `https://chat.example.com`) |
| bot_token | string | Yes | Bot Access Token |
| allow_from | array | No | User allowlist (Mattermost user IDs); empty means all users are allowed |
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
| typing | object | No | Typing indicator configuration |
| placeholder | object | No | Placeholder message configuration (sends a placeholder first, then edits it with the final reply) |
| reasoning_channel_id | string | No | Target channel ID for reasoning/thinking output |
## Setup
1. Enable Bot Accounts in the Mattermost System Console (if not already enabled)
2. Create a Bot account and copy the access token
3. Add the Bot to the channels/groups where it should respond
4. Set `url` and `bot_token` in `config.json`
5. Start `picoclaw gateway`
## Behavior
- Direct messages (DMs) are responded to by default
- Group/channel messages are responded to by default; set `group_trigger.mention_only=true` to only trigger on @mentions
- Automatically reconnects on connection failure and resumes messaging after reconnection
## FAQ
1. `no channels enabled` error on startup
- Verify that `channels.mattermost.enabled=true`
- Verify that `url` and `bot_token` are not empty
- Verify that the correct config file is being loaded (check if `PICOCLAW_CONFIG` / `PICOCLAW_HOME` overrides are in effect)
2. Bot does not respond in a channel
- Check if `allow_from` is restricting the user
- If `mention_only` is enabled, make sure the message contains an @mention of the bot
- Verify that the bot has been added to the channel and has permission to post

View file

@ -0,0 +1,68 @@
# Mattermost
Mattermost 是常见的团队协作平台。PicoClaw 使用 Mattermost 的 WebSocket + REST API 实现实时消息收发、输入中状态、占位消息与附件处理。
## 配置
```json
{
"channels": {
"mattermost": {
"enabled": true,
"url": "https://your-mattermost.example.com",
"bot_token": "YOUR_MATTERMOST_BOT_TOKEN",
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"typing": {
"enabled": true
},
"placeholder": {
"enabled": false,
"text": "Thinking..."
},
"reasoning_channel_id": ""
}
}
}
```
| 字段 | 类型 | 必填 | 描述 |
| -------------------- | ------ | ---- | ---- |
| enabled | bool | 是 | 是否启用 Mattermost 渠道 |
| url | string | 是 | Mattermost 服务地址(例如 `https://chat.example.com` |
| bot_token | string | 是 | Bot Access Token |
| allow_from | array | 否 | 用户白名单Mattermost 用户 ID空表示允许所有用户 |
| group_trigger | object | 否 | 群组触发策略(`mention_only` / `prefixes` |
| typing | object | 否 | 输入中状态配置 |
| placeholder | object | 否 | 占位消息配置(先发占位,后编辑为最终回复) |
| reasoning_channel_id | string | 否 | 思考/推理输出目标频道 ID |
## 设置流程
1. 在 Mattermost 系统控制台中启用 Bot Accounts 功能(如果尚未启用)
2. 创建 Bot 账号并复制访问 Token
3. 把 Bot 添加到需要响应的频道/群组
4. 在 `config.json` 中填写 `url``bot_token`
5. 启动 `picoclaw gateway`
## 行为说明
- 私聊DM默认会响应
- 群组/频道消息默认可响应;可通过 `group_trigger.mention_only=true` 改为仅 @ 触发
- 连接异常时会自动重连,重连成功后继续收发消息
## 常见问题
1. 启动时报 `no channels enabled`
- 确认 `channels.mattermost.enabled=true`
- 确认 `url``bot_token` 非空
- 确认实际加载的是目标配置文件(`PICOCLAW_CONFIG` / `PICOCLAW_HOME` 是否覆盖)
2. 频道内不响应
- 检查 `allow_from` 是否限制了用户
- 如果启用了 `mention_only`,请确认消息中包含对 bot 的 @ 提及
- 确认 bot 已加入对应频道并具备发言权限

View file

@ -59,13 +59,14 @@ type placeholderEntry struct {
// channelRateConfig maps channel name to per-second rate limit.
var channelRateConfig = map[string]float64{
"telegram": 20,
"discord": 1,
"slack": 1,
"matrix": 2,
"line": 10,
"qq": 5,
"irc": 2,
"telegram": 20,
"discord": 1,
"slack": 1,
"matrix": 2,
"line": 10,
"qq": 5,
"irc": 2,
"mattermost": 5,
}
type channelWorker struct {

View file

@ -0,0 +1,34 @@
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(
config.ChannelMattermost,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.MattermostSettings)
if !ok {
return nil, channels.ErrSendFailed
}
return NewMattermostChannel(config.MattermostConfig{
Enabled: bc.Enabled,
URL: c.URL,
BotToken: c.BotToken,
AllowFrom: bc.AllowFrom,
GroupTrigger: bc.GroupTrigger,
Typing: bc.Typing,
Placeholder: bc.Placeholder,
ReasoningChannelID: bc.ReasoningChannelID,
}, b)
},
)
}

View file

@ -0,0 +1,828 @@
package mattermost
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"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/media"
"github.com/sipeed/picoclaw/pkg/utils"
)
const (
sendTimeout = 10 * time.Second
maxPostChars = 16383
typingInterval = 4 * time.Second
typingTimeout = 3 * time.Minute
wsReconnectMax = 60 * time.Second
wsPingInterval = 20 * time.Second
wsPingTimeout = 10 * time.Second
)
// MattermostChannel implements the PicoClaw Channel interface for Mattermost.
type MattermostChannel struct {
*channels.BaseChannel
config config.MattermostConfig
httpClient *http.Client
botUserID string
botUsername string
ctx context.Context
cancel context.CancelFunc
typingMu sync.Mutex
typingStop map[string]chan struct{}
}
var _ channels.MediaSender = (*MattermostChannel)(nil)
func NewMattermostChannel(cfg config.MattermostConfig, messageBus *bus.MessageBus) (*MattermostChannel, error) {
if cfg.URL == "" {
return nil, fmt.Errorf("mattermost url is required")
}
if cfg.BotToken.String() == "" {
return nil, fmt.Errorf("mattermost bot_token is required")
}
base := channels.NewBaseChannel("mattermost", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(maxPostChars),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &MattermostChannel{
BaseChannel: base,
config: cfg,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
typingStop: make(map[string]chan struct{}),
}, nil
}
// --- Lifecycle ---
func (c *MattermostChannel) Start(ctx context.Context) error {
logger.InfoC("mattermost", "Starting Mattermost channel")
c.ctx, c.cancel = context.WithCancel(ctx)
if err := c.fetchBotInfo(); err != nil {
return fmt.Errorf("mattermost: failed to fetch bot info: %w", err)
}
go c.websocketLoop()
c.SetRunning(true)
logger.InfoCF("mattermost", "Mattermost bot connected", map[string]any{
"username": c.botUsername,
"user_id": c.botUserID,
})
return nil
}
func (c *MattermostChannel) Stop(ctx context.Context) error {
logger.InfoC("mattermost", "Stopping Mattermost channel")
c.SetRunning(false)
c.typingMu.Lock()
for chatID, stop := range c.typingStop {
close(stop)
delete(c.typingStop, chatID)
}
c.typingMu.Unlock()
if c.cancel != nil {
c.cancel()
}
return nil
}
// --- REST API helpers ---
func (c *MattermostChannel) apiURL(path string) string {
return strings.TrimRight(c.config.URL, "/") + path
}
func (c *MattermostChannel) doJSON(method, path string, body any) (*http.Response, error) {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(c.ctx, method, c.apiURL(path), reader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.config.BotToken.String())
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return c.httpClient.Do(req)
}
func (c *MattermostChannel) fetchBotInfo() error {
resp, err := c.doJSON(http.MethodGet, "/api/v4/users/me", nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET /users/me returned %d", resp.StatusCode)
}
var user struct {
ID string `json:"id"`
Username string `json:"username"`
}
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return err
}
c.botUserID = user.ID
c.botUsername = user.Username
return nil
}
// --- WebSocket loop ---
func (c *MattermostChannel) websocketLoop() {
wsURL := buildWSURL(c.config.URL)
reconnectDelay := time.Second
for {
select {
case <-c.ctx.Done():
return
default:
}
connected, err := c.runWebSocket(wsURL)
if err != nil {
if c.ctx.Err() != nil {
return
}
logger.WarnCF("mattermost", "WebSocket error, reconnecting", map[string]any{
"error": err.Error(),
"delay": reconnectDelay.String(),
})
}
if connected {
// Reset backoff after at least one successful connection so future
// disconnects recover quickly instead of inheriting stale high delays.
reconnectDelay = time.Second
}
select {
case <-c.ctx.Done():
return
case <-time.After(reconnectDelay):
}
reconnectDelay = min(reconnectDelay*2, wsReconnectMax)
}
}
func (c *MattermostChannel) runWebSocket(wsURL string) (bool, error) {
dialer := websocket.DefaultDialer
conn, resp, err := dialer.DialContext(c.ctx, wsURL, nil)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if err != nil {
return false, fmt.Errorf("dial: %w", err)
}
defer func() {
_ = conn.Close()
}()
// Authenticate
authMsg := map[string]any{
"seq": 1,
"action": "authentication_challenge",
"data": map[string]any{"token": c.config.BotToken.String()},
}
authData, _ := json.Marshal(authMsg)
_ = conn.SetWriteDeadline(time.Now().Add(wsPingTimeout))
if err := conn.WriteMessage(websocket.TextMessage, authData); err != nil {
return false, fmt.Errorf("auth: %w", err)
}
logger.InfoC("mattermost", "WebSocket connected and authenticated")
wsCtx, wsCancel := context.WithCancel(c.ctx)
defer wsCancel()
connCloseDone := make(chan struct{})
defer close(connCloseDone)
go func() {
select {
case <-wsCtx.Done():
_ = conn.Close()
case <-connCloseDone:
}
}()
// Keepalive: detect half-open connections and force reconnect.
pingErrCh := make(chan error, 1)
go func() {
ticker := time.NewTicker(wsPingInterval)
defer ticker.Stop()
for {
select {
case <-wsCtx.Done():
return
case <-ticker.C:
err := conn.WriteControl(
websocket.PingMessage,
nil,
time.Now().Add(wsPingTimeout),
)
if err != nil {
select {
case pingErrCh <- fmt.Errorf("ping: %w", err):
default:
}
_ = conn.Close()
return
}
}
}
}()
for {
_, data, err := conn.ReadMessage()
if err != nil {
if c.ctx.Err() != nil {
return true, nil
}
select {
case pingErr := <-pingErrCh:
return true, pingErr
default:
}
return true, fmt.Errorf("read: %w", err)
}
var event wsEvent
if err := json.Unmarshal(data, &event); err != nil {
continue
}
if event.Event == "posted" {
go c.handlePostedEvent(event)
}
}
}
type wsEvent struct {
Event string `json:"event"`
Data json.RawMessage `json:"data"`
}
type wsPostedData struct {
Post string `json:"post"`
ChannelType string `json:"channel_type"`
}
type mmPost struct {
ID string `json:"id"`
Type string `json:"type"`
ChannelID string `json:"channel_id"`
UserID string `json:"user_id"`
RootID string `json:"root_id"`
Message string `json:"message"`
FileIDs []string `json:"file_ids"`
Metadata struct {
Files []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"files"`
} `json:"metadata"`
}
// --- Message handler ---
func (c *MattermostChannel) handlePostedEvent(event wsEvent) {
var posted wsPostedData
if err := json.Unmarshal(event.Data, &posted); err != nil {
return
}
var post mmPost
if err := json.Unmarshal([]byte(posted.Post), &post); err != nil {
return
}
// Ignore own messages
if post.UserID == c.botUserID {
return
}
// Ignore Mattermost system posts (e.g., system_join_channel)
if strings.HasPrefix(post.Type, "system_") {
return
}
isDM := posted.ChannelType == "D"
// Build sender info for allowlist check
sender := bus.SenderInfo{
Platform: "mattermost",
PlatformID: post.UserID,
CanonicalID: identity.BuildCanonicalID("mattermost", post.UserID),
}
if !c.IsAllowedSender(sender) {
logger.DebugCF("mattermost", "Message rejected by allowlist", map[string]any{
"user_id": post.UserID,
})
return
}
content := post.Message
// Group trigger filtering
if !isDM {
isMentioned := c.hasBotMention(content)
content = c.stripBotMention(content)
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
return
}
content = cleaned
} else {
content = c.stripBotMention(content)
}
// Determine chatID and peer
peerKind := "channel"
chatID := post.ChannelID
if isDM {
peerKind = "direct"
}
peer := bus.Peer{Kind: peerKind, ID: chatID}
// Thread root_id: for replies, use existing root_id; for new channel messages, use post.ID
rootID := post.RootID
if !isDM && rootID == "" {
rootID = post.ID
}
// Download attachments
mediaPaths := make([]string, 0, len(post.FileIDs))
scope := channels.BuildMediaScope("mattermost", chatID, post.ID)
hintMap := make(map[string]string)
for _, fi := range post.Metadata.Files {
if fi.ID != "" {
hintMap[fi.ID] = fi.Name
}
}
for _, fid := range post.FileIDs {
filename := hintMap[fid]
localPath := c.downloadFile(fid, filename)
if localPath == "" {
continue
}
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
Source: "mattermost",
}, scope)
if err == nil {
mediaPaths = append(mediaPaths, ref)
continue
}
}
mediaPaths = append(mediaPaths, localPath)
}
if content == "" && len(mediaPaths) == 0 {
return
}
if content == "" {
content = "[media only]"
}
metadata := map[string]string{
"user_id": post.UserID,
"channel_id": post.ChannelID,
"channel_type": posted.ChannelType,
"post_id": post.ID,
"root_id": rootID,
"is_dm": fmt.Sprintf("%t", isDM),
}
logger.DebugCF("mattermost", "Received message", map[string]any{
"sender_id": post.UserID,
"preview": utils.Truncate(content, 50),
"is_dm": isDM,
})
c.HandleMessage(c.ctx, peer, post.ID, post.UserID, chatID, content, mediaPaths, metadata, sender)
}
func (c *MattermostChannel) stripBotMention(text string) string {
if c.botUsername == "" {
return text
}
// Case-insensitive replace of standalone @botname mentions.
mention := "@" + strings.ToLower(c.botUsername)
lower := strings.ToLower(text)
var out strings.Builder
out.Grow(len(text))
cursor := 0
searchFrom := 0
removed := false
for {
idxRel := strings.Index(lower[searchFrom:], mention)
if idxRel < 0 {
break
}
start := searchFrom + idxRel
end := start + len(mention)
if !isMentionBoundary(text, start, end) {
searchFrom = end
continue
}
out.WriteString(text[cursor:start])
cursor = end
searchFrom = end
removed = true
}
if !removed {
return strings.TrimSpace(text)
}
out.WriteString(text[cursor:])
return strings.TrimSpace(out.String())
}
func (c *MattermostChannel) hasBotMention(text string) bool {
if c.botUsername == "" {
return false
}
mention := "@" + strings.ToLower(c.botUsername)
lower := strings.ToLower(text)
searchFrom := 0
for {
idxRel := strings.Index(lower[searchFrom:], mention)
if idxRel < 0 {
return false
}
start := searchFrom + idxRel
end := start + len(mention)
if isMentionBoundary(text, start, end) {
return true
}
searchFrom = end
}
}
func isMentionBoundary(text string, start, end int) bool {
if start > 0 && isMattermostUsernameChar(text[start-1]) {
return false
}
if end < len(text) && isMattermostUsernameChar(text[end]) {
return false
}
return true
}
func isMattermostUsernameChar(b byte) bool {
return (b >= 'a' && b <= 'z') ||
(b >= 'A' && b <= 'Z') ||
(b >= '0' && b <= '9') ||
b == '_' || b == '-' || b == '.'
}
// --- Send ---
func (c *MattermostChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
if msg.ChatID == "" {
return nil, fmt.Errorf("mattermost: chatID is empty")
}
if len([]rune(msg.Content)) == 0 {
return nil, nil
}
rootID := msg.ReplyToMessageID
return c.postMessage(msg.ChatID, msg.Content, rootID)
}
func (c *MattermostChannel) postMessage(channelID, text, rootID string) ([]string, error) {
payload := map[string]any{
"channel_id": channelID,
"message": text,
}
if rootID != "" {
payload["root_id"] = rootID
}
resp, err := c.doJSON(http.MethodPost, "/api/v4/posts", payload)
if err != nil {
return nil, channels.ClassifyNetError(fmt.Errorf("mattermost post: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
logger.ErrorCF("mattermost", "Post failed", map[string]any{
"status": resp.StatusCode,
"body": string(body),
})
return nil, channels.ClassifySendError(resp.StatusCode, fmt.Errorf("mattermost post %d", resp.StatusCode))
}
var created mmPost
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
return nil, fmt.Errorf("mattermost decode post response: %w", err)
}
if created.ID == "" {
return nil, nil
}
return []string{created.ID}, nil
}
// --- EditMessage (MessageEditor) ---
func (c *MattermostChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
payload := map[string]any{
"id": messageID,
"message": content,
}
resp, err := c.doJSON(http.MethodPut, "/api/v4/posts/"+messageID, payload)
if err != nil {
return channels.ClassifyNetError(fmt.Errorf("mattermost edit: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("mattermost edit %d", resp.StatusCode))
}
return nil
}
// --- SendPlaceholder (PlaceholderCapable) ---
func (c *MattermostChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
if !c.config.Placeholder.Enabled {
return "", nil
}
payload := map[string]any{
"channel_id": chatID,
"message": c.config.Placeholder.GetRandomText(),
}
resp, err := c.doJSON(http.MethodPost, "/api/v4/posts", payload)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("placeholder post %d", resp.StatusCode)
}
var result struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.ID, nil
}
// --- Typing (TypingCapable) ---
func (c *MattermostChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
if !c.config.Typing.Enabled {
return func() {}, nil
}
c.startTypingLoop(chatID)
return func() { c.stopTypingLoop(chatID) }, nil
}
func (c *MattermostChannel) startTypingLoop(chatID string) {
c.typingMu.Lock()
if stop, ok := c.typingStop[chatID]; ok {
close(stop)
}
stop := make(chan struct{})
c.typingStop[chatID] = stop
c.typingMu.Unlock()
go func() {
defer c.cleanupTypingLoop(chatID, stop)
c.sendTyping(chatID)
ticker := time.NewTicker(typingInterval)
defer ticker.Stop()
timeout := time.After(typingTimeout)
for {
select {
case <-stop:
return
case <-timeout:
return
case <-c.ctx.Done():
return
case <-ticker.C:
c.sendTyping(chatID)
}
}
}()
}
func (c *MattermostChannel) cleanupTypingLoop(chatID string, stop chan struct{}) {
c.typingMu.Lock()
defer c.typingMu.Unlock()
if current, ok := c.typingStop[chatID]; ok && current == stop {
delete(c.typingStop, chatID)
}
}
func (c *MattermostChannel) stopTypingLoop(chatID string) {
c.typingMu.Lock()
defer c.typingMu.Unlock()
if stop, ok := c.typingStop[chatID]; ok {
close(stop)
delete(c.typingStop, chatID)
}
}
func (c *MattermostChannel) sendTyping(chatID string) {
payload := map[string]any{"channel_id": chatID}
resp, err := c.doJSON(http.MethodPost, "/api/v4/users/"+c.botUserID+"/typing", payload)
if err != nil {
logger.DebugCF("mattermost", "Typing indicator failed", map[string]any{"error": err.Error()})
return
}
if resp == nil || resp.Body == nil {
return
}
defer resp.Body.Close()
}
// --- SendMedia (MediaSender) ---
func (c *MattermostChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
if msg.ChatID == "" {
return nil, fmt.Errorf("mattermost: chatID is empty")
}
store := c.GetMediaStore()
if store == nil {
return nil, fmt.Errorf("no media store: %w", channels.ErrSendFailed)
}
var postIDs []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
}
fileID, err := c.uploadFile(msg.ChatID, localPath, part.Filename)
if err != nil {
logger.ErrorCF("mattermost", "Upload failed", map[string]any{
"path": localPath,
"error": err.Error(),
})
// Fallback: send path as text
_, _ = c.postMessage(msg.ChatID, fmt.Sprintf("[Attachment: %s]", part.Filename), "")
continue
}
payload := map[string]any{
"channel_id": msg.ChatID,
"message": part.Caption,
"file_ids": []string{fileID},
}
resp, err := c.doJSON(http.MethodPost, "/api/v4/posts", payload)
if err != nil {
return nil, channels.ClassifyNetError(fmt.Errorf("mattermost media post: %w", err))
}
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
resp.Body.Close()
logger.ErrorCF("mattermost", "Media post failed", map[string]any{
"status": resp.StatusCode,
"body": string(body),
})
return nil, channels.ClassifySendError(
resp.StatusCode,
fmt.Errorf("mattermost media post %d", resp.StatusCode),
)
}
var created mmPost
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
resp.Body.Close()
return nil, fmt.Errorf("mattermost decode media post response: %w", err)
}
resp.Body.Close()
if created.ID != "" {
postIDs = append(postIDs, created.ID)
}
}
return postIDs, nil
}
// --- File helpers ---
func (c *MattermostChannel) downloadFile(fileID, filename string) string {
url := c.apiURL("/api/v4/files/" + fileID)
return utils.DownloadFile(url, filename, utils.DownloadOptions{
LoggerPrefix: "mattermost",
ExtraHeaders: map[string]string{"Authorization": "Bearer " + c.config.BotToken.String()},
})
}
func (c *MattermostChannel) uploadFile(channelID, localPath, filename string) (string, error) {
f, err := os.Open(localPath)
if err != nil {
return "", err
}
defer f.Close()
if filename == "" {
filename = filepath.Base(localPath)
}
bodyReader, bodyWriter := io.Pipe()
mpWriter := multipart.NewWriter(bodyWriter)
go func() {
defer bodyWriter.Close()
if writeErr := mpWriter.WriteField("channel_id", channelID); writeErr != nil {
_ = bodyWriter.CloseWithError(writeErr)
return
}
part, createErr := mpWriter.CreateFormFile("files", filename)
if createErr != nil {
_ = bodyWriter.CloseWithError(createErr)
return
}
if _, copyErr := io.Copy(part, f); copyErr != nil {
_ = bodyWriter.CloseWithError(copyErr)
return
}
if closeErr := mpWriter.Close(); closeErr != nil {
_ = bodyWriter.CloseWithError(closeErr)
}
}()
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, c.apiURL("/api/v4/files"), bodyReader)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+c.config.BotToken.String())
req.Header.Set("Content-Type", mpWriter.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
_ = bodyReader.CloseWithError(err)
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("upload returned %d", resp.StatusCode)
}
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("upload returned no file_infos")
}
return result.FileInfos[0].ID, nil
}
func buildWSURL(baseURL string) string {
wsURL := strings.Replace(baseURL, "https://", "wss://", 1)
wsURL = strings.Replace(wsURL, "http://", "ws://", 1)
return strings.TrimRight(wsURL, "/") + "/api/v4/websocket"
}

View file

@ -0,0 +1,510 @@
package mattermost
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
)
func TestNewMattermostChannel(t *testing.T) {
msgBus := bus.NewMessageBus()
t.Run("missing url", func(t *testing.T) {
cfg := config.MattermostConfig{
URL: "",
BotToken: *config.NewSecureString("token"),
}
_, err := NewMattermostChannel(cfg, msgBus)
if err == nil {
t.Fatal("expected error for missing url, got nil")
}
})
t.Run("missing bot token", func(t *testing.T) {
cfg := config.MattermostConfig{
URL: "https://mattermost.example.com",
}
_, err := NewMattermostChannel(cfg, msgBus)
if err == nil {
t.Fatal("expected error for missing bot token, got nil")
}
})
t.Run("valid config", func(t *testing.T) {
cfg := config.MattermostConfig{
URL: "https://mattermost.example.com",
BotToken: *config.NewSecureString("token"),
}
ch, err := NewMattermostChannel(cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ch.Name() != "mattermost" {
t.Fatalf("Name() = %q, want %q", ch.Name(), "mattermost")
}
if ch.IsRunning() {
t.Fatal("new channel should not be running")
}
if ch.typingStop == nil {
t.Fatal("typingStop map should be initialized")
}
})
}
func TestBuildWSURL(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "https without trailing slash",
in: "https://mm.example.com",
want: "wss://mm.example.com/api/v4/websocket",
},
{
name: "https with trailing slash",
in: "https://mm.example.com/",
want: "wss://mm.example.com/api/v4/websocket",
},
{
name: "http",
in: "http://localhost:8065",
want: "ws://localhost:8065/api/v4/websocket",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildWSURL(tt.in)
if got != tt.want {
t.Fatalf("buildWSURL(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestStripBotMention(t *testing.T) {
ch := &MattermostChannel{
botUsername: "PicoclawBot",
}
tests := []struct {
name string
in string
want string
}{
{
name: "mention at start",
in: "@PicoclawBot hello",
want: "hello",
},
{
name: "case insensitive mention",
in: "@picoclawbot hello",
want: "hello",
},
{
name: "multiple mentions",
in: "hi @PicoclawBot and @PICOCLAWBOT",
want: "hi and",
},
{
name: "no mention",
in: "hello world",
want: "hello world",
},
{
name: "substring mention should not strip",
in: "@PicoclawBotany should stay",
want: "@PicoclawBotany should stay",
},
{
name: "email should not strip",
in: "foo@PicoclawBot.com",
want: "foo@PicoclawBot.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ch.stripBotMention(tt.in)
if got != tt.want {
t.Fatalf("stripBotMention(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestHasBotMention(t *testing.T) {
ch := &MattermostChannel{
botUsername: "PicoclawBot",
}
tests := []struct {
name string
in string
want bool
}{
{
name: "exact mention",
in: "@PicoclawBot hello",
want: true,
},
{
name: "mention with punctuation",
in: "@PicoclawBot, hi",
want: true,
},
{
name: "substring should not count",
in: "@PicoclawBotany hi",
want: false,
},
{
name: "email should not count",
in: "foo@PicoclawBot.com",
want: false,
},
{
name: "no mention",
in: "hello world",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ch.hasBotMention(tt.in)
if got != tt.want {
t.Fatalf("hasBotMention(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
func TestCleanupTypingLoop(t *testing.T) {
ch := &MattermostChannel{
typingStop: make(map[string]chan struct{}),
}
stop := make(chan struct{})
ch.typingStop["chat-1"] = stop
ch.cleanupTypingLoop("chat-1", stop)
if _, ok := ch.typingStop["chat-1"]; ok {
t.Fatal("expected cleanupTypingLoop to remove current typing entry")
}
oldStop := make(chan struct{})
newStop := make(chan struct{})
ch.typingStop["chat-1"] = newStop
ch.cleanupTypingLoop("chat-1", oldStop)
if got := ch.typingStop["chat-1"]; got != newStop {
t.Fatal("cleanupTypingLoop should not remove newer typing entry")
}
}
func TestUploadFile(t *testing.T) {
const (
wantToken = "test-token"
wantChatID = "channel-1"
wantFileID = "file-123"
wantContent = "mattermost upload stream test"
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s, want %s", r.Method, http.MethodPost)
}
if r.URL.Path != "/api/v4/files" {
t.Fatalf("path = %s, want /api/v4/files", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer "+wantToken {
t.Fatalf("Authorization = %q, want %q", got, "Bearer "+wantToken)
}
if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "multipart/form-data; boundary=") {
t.Fatalf("Content-Type = %q, want multipart/form-data", got)
}
mr, err := r.MultipartReader()
if err != nil {
t.Fatalf("MultipartReader() error: %v", err)
}
var gotChatID string
var gotFilename string
var gotContent string
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("NextPart() error: %v", err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatalf("ReadAll(part) error: %v", err)
}
switch part.FormName() {
case "channel_id":
gotChatID = string(data)
case "files":
gotFilename = part.FileName()
gotContent = string(data)
}
}
if gotChatID != wantChatID {
t.Fatalf("channel_id = %q, want %q", gotChatID, wantChatID)
}
if gotFilename != "upload.txt" {
t.Fatalf("filename = %q, want %q", gotFilename, "upload.txt")
}
if gotContent != wantContent {
t.Fatalf("file content = %q, want %q", gotContent, wantContent)
}
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"file_infos":[{"id":"` + wantFileID + `"}]}`))
}))
defer server.Close()
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "upload.txt")
if err := os.WriteFile(filePath, []byte(wantContent), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg := config.MattermostConfig{
URL: server.URL,
BotToken: *config.NewSecureString(wantToken),
}
ch, err := NewMattermostChannel(cfg, bus.NewMessageBus())
if err != nil {
t.Fatalf("NewMattermostChannel() error: %v", err)
}
ch.ctx = context.Background()
gotFileID, err := ch.uploadFile(wantChatID, filePath, "")
if err != nil {
t.Fatalf("uploadFile() error: %v", err)
}
if gotFileID != wantFileID {
t.Fatalf("uploadFile() fileID = %q, want %q", gotFileID, wantFileID)
}
}
func TestPostMessageClassifiesHTTPStatus(t *testing.T) {
tests := []struct {
name string
statusCode int
wantErr error
}{
{name: "404 send failed", statusCode: http.StatusNotFound, wantErr: channels.ErrSendFailed},
{name: "429 rate limited", statusCode: http.StatusTooManyRequests, wantErr: channels.ErrRateLimit},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v4/posts" {
t.Fatalf("path = %s, want /api/v4/posts", r.URL.Path)
}
w.WriteHeader(tt.statusCode)
_, _ = w.Write([]byte(`{"error":"boom"}`))
}))
defer server.Close()
ch, err := NewMattermostChannel(config.MattermostConfig{
URL: server.URL,
BotToken: *config.NewSecureString("token"),
}, bus.NewMessageBus())
if err != nil {
t.Fatalf("NewMattermostChannel() error: %v", err)
}
ch.ctx = context.Background()
_, err = ch.postMessage("channel-1", "hello", "")
if !errors.Is(err, tt.wantErr) {
t.Fatalf("postMessage() error = %v, want %v", err, tt.wantErr)
}
})
}
}
func TestEditMessageClassifiesHTTPStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Fatalf("method = %s, want %s", r.Method, http.MethodPut)
}
if r.URL.Path != "/api/v4/posts/post-1" {
t.Fatalf("path = %s, want /api/v4/posts/post-1", r.URL.Path)
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
ch, err := NewMattermostChannel(config.MattermostConfig{
URL: server.URL,
BotToken: *config.NewSecureString("token"),
}, bus.NewMessageBus())
if err != nil {
t.Fatalf("NewMattermostChannel() error: %v", err)
}
ch.ctx = context.Background()
err = ch.EditMessage(context.Background(), "channel-1", "post-1", "updated")
if !errors.Is(err, channels.ErrSendFailed) {
t.Fatalf("EditMessage() error = %v, want %v", err, channels.ErrSendFailed)
}
}
func TestSendMediaClassifiesHTTPStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v4/files":
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"file_infos":[{"id":"file-1"}]}`))
case "/api/v4/posts":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"channel missing"}`))
default:
t.Fatalf("unexpected path: %s", r.URL.Path)
}
}))
defer server.Close()
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "upload.txt")
if err := os.WriteFile(filePath, []byte("payload"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
store := media.NewFileMediaStore()
ref, err := store.Store(filePath, media.MediaMeta{Filename: "upload.txt"}, "scope-1")
if err != nil {
t.Fatalf("Store() error: %v", err)
}
ch, err := NewMattermostChannel(config.MattermostConfig{
URL: server.URL,
BotToken: *config.NewSecureString("token"),
}, bus.NewMessageBus())
if err != nil {
t.Fatalf("NewMattermostChannel() error: %v", err)
}
ch.ctx = context.Background()
ch.SetMediaStore(store)
ch.SetRunning(true)
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "channel-1",
Parts: []bus.MediaPart{{
Ref: ref,
Filename: "upload.txt",
}},
})
if !errors.Is(err, channels.ErrSendFailed) {
t.Fatalf("SendMedia() error = %v, want %v", err, channels.ErrSendFailed)
}
}
func TestHandlePostedEventIgnoresSystemMessages(t *testing.T) {
msgBus := bus.NewMessageBus()
ch, err := NewMattermostChannel(config.MattermostConfig{
URL: "https://mattermost.example.com",
BotToken: *config.NewSecureString("token"),
}, msgBus)
if err != nil {
t.Fatalf("NewMattermostChannel() error: %v", err)
}
ch.ctx = context.Background()
ch.botUserID = "bot-user"
ch.handlePostedEvent(makePostedEvent("system_join_channel", "user-1", "chan-1", "user joined", "O"))
expectNoInbound(t, msgBus, 100*time.Millisecond)
}
func TestHandlePostedEventGroupMentionBoundary(t *testing.T) {
msgBus := bus.NewMessageBus()
cfg := config.MattermostConfig{
URL: "https://mattermost.example.com",
BotToken: *config.NewSecureString("token"),
GroupTrigger: config.GroupTriggerConfig{
MentionOnly: true,
},
}
ch, err := NewMattermostChannel(cfg, msgBus)
if err != nil {
t.Fatalf("NewMattermostChannel() error: %v", err)
}
ch.ctx = context.Background()
ch.botUserID = "bot-user"
ch.botUsername = "mybot"
ch.handlePostedEvent(makePostedEvent("", "user-1", "chan-1", "@mybotany hi", "O"))
expectNoInbound(t, msgBus, 100*time.Millisecond)
ch.handlePostedEvent(makePostedEvent("", "user-1", "chan-1", "@mybot hi", "O"))
msg := mustRecvInbound(t, msgBus, 500*time.Millisecond)
if msg.Content != "hi" {
t.Fatalf("Content = %q, want %q", msg.Content, "hi")
}
}
func makePostedEvent(postType, userID, channelID, message, channelType string) wsEvent {
post := map[string]any{
"id": "post-1",
"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,
}
raw, _ := json.Marshal(data)
return wsEvent{
Event: "posted",
Data: raw,
}
}
func expectNoInbound(t *testing.T, mb *bus.MessageBus, timeout time.Duration) {
t.Helper()
select {
case msg := <-mb.InboundChan():
t.Fatalf("expected no inbound message, got %+v", msg)
case <-time.After(timeout):
}
}
func mustRecvInbound(t *testing.T, mb *bus.MessageBus, timeout time.Duration) bus.InboundMessage {
t.Helper()
select {
case msg := <-mb.InboundChan():
return msg
case <-time.After(timeout):
t.Fatal("expected inbound message, got none")
return bus.InboundMessage{}
}
}

View file

@ -498,6 +498,22 @@ type TeamsWebhookTarget struct {
Title string `json:"title,omitempty" yaml:"-"`
}
type MattermostSettings struct {
URL string `json:"url" yaml:"-" env:"PICOCLAW_CHANNELS_MATTERMOST_URL"`
BotToken SecureString `json:"bot_token,omitzero" yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_MATTERMOST_BOT_TOKEN"`
}
type MattermostConfig struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
BotToken SecureString `json:"bot_token,omitzero"`
AllowFrom FlexibleStringSlice `json:"allow_from"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id"`
}
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5

View file

@ -32,6 +32,7 @@ const (
ChannelMaixCam = "maixcam"
ChannelWhatsApp = "whatsapp"
ChannelWhatsAppNative = "whatsapp_native"
ChannelMattermost = "mattermost"
ChannelTeamsWebHook = "teams_webhook"
)
@ -639,6 +640,7 @@ var channelSettingsFactory = map[string]any{
ChannelMaixCam: (MaixCamSettings{}),
ChannelWhatsApp: (WhatsAppSettings{}),
ChannelWhatsAppNative: (WhatsAppSettings{}),
ChannelMattermost: (MattermostSettings{}),
ChannelTeamsWebHook: (TeamsWebhookSettings{}),
}

View file

@ -500,6 +500,9 @@ func defaultChannels() ChannelsConfig {
"max_connections": 100,
},
},
"mattermost": map[string]any{
"typing": map[string]any{"enabled": true},
},
}
channels := make(ChannelsConfig, len(defs))

View file

@ -24,6 +24,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
_ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
_ "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"