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
|
## 💬 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.
|
> **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) |
|
| **QQ** | Easy (AppID + AppSecret) |
|
||||||
| **DingTalk** | Medium (app credentials) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
| **LINE** | Medium (credentials + webhook URL) |
|
| **LINE** | Medium (credentials + webhook URL) |
|
||||||
|
| **Mattermost** | Easy (bot token) |
|
||||||
| **WeCom AI Bot** | Medium (Token + AES key) |
|
| **WeCom AI Bot** | Medium (Token + AES key) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -436,6 +437,47 @@ picoclaw gateway
|
||||||
|
|
||||||
</details>
|
</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>
|
<details>
|
||||||
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
|
<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/line"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
|
_ "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/onebot"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
_ "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")
|
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 != "" {
|
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
|
||||||
m.initChannel("line", "LINE")
|
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"`
|
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||||
Pico PicoConfig `json:"pico"`
|
Pico PicoConfig `json:"pico"`
|
||||||
IRC IRCConfig `json:"irc"`
|
IRC IRCConfig `json:"irc"`
|
||||||
|
Mattermost MattermostConfig `json:"mattermost"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
// 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"`
|
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 {
|
type LINEConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
|
||||||
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,18 @@ func DefaultConfig() *Config {
|
||||||
Text: "Thinking... 💭",
|
Text: "Thinking... 💭",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Mattermost: MattermostConfig{
|
||||||
|
Enabled: false,
|
||||||
|
URL: "",
|
||||||
|
Token: "",
|
||||||
|
Username: "",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
ReplyInThread: true,
|
||||||
|
Placeholder: PlaceholderConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Text: "Thinking... 💭",
|
||||||
|
},
|
||||||
|
},
|
||||||
LINE: LINEConfig{
|
LINE: LINEConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
ChannelSecret: "",
|
ChannelSecret: "",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue