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:
parent
b89f6445d1
commit
103c9b4ab1
9 changed files with 1306 additions and 1 deletions
44
README.md
44
README.md
|
|
@ -308,7 +308,7 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||
|
||||
## 💬 Chat Apps
|
||||
|
||||
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
|
||||
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, Mattermost, QQ, DingTalk, LINE, or WeCom
|
||||
|
||||
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
|
||||
|
||||
|
|
@ -321,6 +321,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
|
|||
| **QQ** | Easy (AppID + AppSecret) |
|
||||
| **DingTalk** | Medium (app credentials) |
|
||||
| **LINE** | Medium (credentials + webhook URL) |
|
||||
| **Mattermost** | Easy (bot token) |
|
||||
| **WeCom AI Bot** | Medium (Token + AES key) |
|
||||
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
50
docs/channels/mattermost/README.zh.md
Normal file
50
docs/channels/mattermost/README.zh.md
Normal 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 接口上传文件
|
||||
- **输入指示器**:支持显示"正在输入"状态
|
||||
- **消息编辑**:支持编辑已发送的消息
|
||||
- **占位消息**:发送"思考中..."占位消息,完成后替换为实际回复
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
13
pkg/channels/mattermost/init.go
Normal file
13
pkg/channels/mattermost/init.go
Normal 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)
|
||||
})
|
||||
}
|
||||
833
pkg/channels/mattermost/mattermost.go
Normal file
833
pkg/channels/mattermost/mattermost.go
Normal 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
|
||||
}
|
||||
334
pkg/channels/mattermost/mattermost_test.go
Normal file
334
pkg/channels/mattermost/mattermost_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue