diff --git a/README.md b/README.md index bbe48061a..b7af7e8e3 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/README.zh.md b/README.zh.md index 329fedb86..7df4da69c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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: WeChat: WeChat group QR code - - diff --git a/config/config.example.json b/config/config.example.json index f0cce6d72..cf48769ad 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -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", diff --git a/docs/channels/mattermost/README.md b/docs/channels/mattermost/README.md new file mode 100644 index 000000000..c8d2c1dcb --- /dev/null +++ b/docs/channels/mattermost/README.md @@ -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 diff --git a/docs/channels/mattermost/README.zh.md b/docs/channels/mattermost/README.zh.md new file mode 100644 index 000000000..9174e20a2 --- /dev/null +++ b/docs/channels/mattermost/README.zh.md @@ -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 已加入对应频道并具备发言权限 diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 5d5e6f9f0..9f1cec3b6 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -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 { diff --git a/pkg/channels/mattermost/init.go b/pkg/channels/mattermost/init.go new file mode 100644 index 000000000..f1e00efb0 --- /dev/null +++ b/pkg/channels/mattermost/init.go @@ -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) + }, + ) +} diff --git a/pkg/channels/mattermost/mattermost.go b/pkg/channels/mattermost/mattermost.go new file mode 100644 index 000000000..a9f8c29e0 --- /dev/null +++ b/pkg/channels/mattermost/mattermost.go @@ -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" +} diff --git a/pkg/channels/mattermost/mattermost_test.go b/pkg/channels/mattermost/mattermost_test.go new file mode 100644 index 000000000..52069751b --- /dev/null +++ b/pkg/channels/mattermost/mattermost_test.go @@ -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{} + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index fe259fd23..c3fed5cf5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 diff --git a/pkg/config/config_channel.go b/pkg/config/config_channel.go index 4e87fcc3e..80af6f67f 100644 --- a/pkg/config/config_channel.go +++ b/pkg/config/config_channel.go @@ -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{}), } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 40f7d5d52..aab7498a9 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -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)) diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index a5afb0eb8..a51c48060 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -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"