feat(mattermost): add Mattermost channel integration
This commit is contained in:
parent
04e99a1264
commit
066a3dbebf
13 changed files with 1557 additions and 9 deletions
|
|
@ -470,6 +470,7 @@ Talk to your PicoClaw through 18+ messaging platforms:
|
|||
|---------|-------|----------|------|
|
||||
| **Telegram** | Easy (bot token) | Long polling | [Guide](docs/channels/telegram/README.md) |
|
||||
| **Discord** | Easy (bot token + intents) | WebSocket | [Guide](docs/channels/discord/README.md) |
|
||||
| **Mattermost** | Easy (bot token) | WebSocket + REST | [Guide](docs/channels/mattermost/README.md) |
|
||||
| **WhatsApp** | Easy (QR scan or bridge URL) | Native / Bridge | [Guide](docs/chat-apps.md#whatsapp) |
|
||||
| **Weixin** | Easy (Native QR scan) | iLink API | [Guide](docs/chat-apps.md#weixin) |
|
||||
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
|
||||
|
|
|
|||
|
|
@ -462,6 +462,7 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider,使用 `协议/模
|
|||
|---------|----------|------|------|
|
||||
| **Telegram** | 简单(bot token) | 长轮询 | [指南](docs/channels/telegram/README.zh.md) |
|
||||
| **Discord** | 简单(bot token + intents) | WebSocket | [指南](docs/channels/discord/README.zh.md) |
|
||||
| **Mattermost** | 简单(bot token) | WebSocket + REST | [指南](docs/channels/mattermost/README.zh.md) |
|
||||
| **WhatsApp** | 简单(扫码或 bridge URL) | 原生 / Bridge | [指南](docs/zh/chat-apps.md#whatsapp) |
|
||||
| **微信 (Weixin)** | 简单(扫码登录) | iLink API | [指南](docs/zh/chat-apps.md#weixin) |
|
||||
| **QQ** | 简单(AppID + AppSecret) | WebSocket | [指南](docs/channels/qq/README.zh.md) |
|
||||
|
|
@ -624,5 +625,3 @@ Discord: <https://discord.gg/V4sAZ9XWpN>
|
|||
|
||||
WeChat:
|
||||
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -161,6 +161,23 @@
|
|||
"allow_from": [],
|
||||
"reasoning_channel_id": ""
|
||||
},
|
||||
"mattermost": {
|
||||
"enabled": false,
|
||||
"url": "https://your-mattermost.example.com",
|
||||
"bot_token": "YOUR_MATTERMOST_BOT_TOKEN",
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
},
|
||||
"typing": {
|
||||
"enabled": true
|
||||
},
|
||||
"placeholder": {
|
||||
"enabled": false,
|
||||
"text": "Thinking..."
|
||||
},
|
||||
"reasoning_channel_id": ""
|
||||
},
|
||||
"matrix": {
|
||||
"enabled": false,
|
||||
"homeserver": "https://matrix.org",
|
||||
|
|
|
|||
68
docs/channels/mattermost/README.md
Normal file
68
docs/channels/mattermost/README.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Mattermost
|
||||
|
||||
Mattermost is a popular team collaboration platform. PicoClaw integrates with Mattermost via WebSocket + REST API to provide real-time messaging, typing indicators, placeholder messages, and attachment handling.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"mattermost": {
|
||||
"enabled": true,
|
||||
"url": "https://your-mattermost.example.com",
|
||||
"bot_token": "YOUR_MATTERMOST_BOT_TOKEN",
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
},
|
||||
"typing": {
|
||||
"enabled": true
|
||||
},
|
||||
"placeholder": {
|
||||
"enabled": false,
|
||||
"text": "Thinking..."
|
||||
},
|
||||
"reasoning_channel_id": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| -------------------- | ------ | -------- | ----------- |
|
||||
| enabled | bool | Yes | Whether to enable the Mattermost channel |
|
||||
| url | string | Yes | Mattermost server URL (e.g. `https://chat.example.com`) |
|
||||
| bot_token | string | Yes | Bot Access Token |
|
||||
| allow_from | array | No | User allowlist (Mattermost user IDs); empty means all users are allowed |
|
||||
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
|
||||
| typing | object | No | Typing indicator configuration |
|
||||
| placeholder | object | No | Placeholder message configuration (sends a placeholder first, then edits it with the final reply) |
|
||||
| reasoning_channel_id | string | No | Target channel ID for reasoning/thinking output |
|
||||
|
||||
## Setup
|
||||
|
||||
1. Enable Bot Accounts in the Mattermost System Console (if not already enabled)
|
||||
2. Create a Bot account and copy the access token
|
||||
3. Add the Bot to the channels/groups where it should respond
|
||||
4. Set `url` and `bot_token` in `config.json`
|
||||
5. Start `picoclaw gateway`
|
||||
|
||||
## Behavior
|
||||
|
||||
- Direct messages (DMs) are responded to by default
|
||||
- Group/channel messages are responded to by default; set `group_trigger.mention_only=true` to only trigger on @mentions
|
||||
- Automatically reconnects on connection failure and resumes messaging after reconnection
|
||||
|
||||
## FAQ
|
||||
|
||||
1. `no channels enabled` error on startup
|
||||
|
||||
- Verify that `channels.mattermost.enabled=true`
|
||||
- Verify that `url` and `bot_token` are not empty
|
||||
- Verify that the correct config file is being loaded (check if `PICOCLAW_CONFIG` / `PICOCLAW_HOME` overrides are in effect)
|
||||
|
||||
2. Bot does not respond in a channel
|
||||
|
||||
- Check if `allow_from` is restricting the user
|
||||
- If `mention_only` is enabled, make sure the message contains an @mention of the bot
|
||||
- Verify that the bot has been added to the channel and has permission to post
|
||||
68
docs/channels/mattermost/README.zh.md
Normal file
68
docs/channels/mattermost/README.zh.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Mattermost
|
||||
|
||||
Mattermost 是常见的团队协作平台。PicoClaw 使用 Mattermost 的 WebSocket + REST API 实现实时消息收发、输入中状态、占位消息与附件处理。
|
||||
|
||||
## 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"mattermost": {
|
||||
"enabled": true,
|
||||
"url": "https://your-mattermost.example.com",
|
||||
"bot_token": "YOUR_MATTERMOST_BOT_TOKEN",
|
||||
"allow_from": [],
|
||||
"group_trigger": {
|
||||
"mention_only": true
|
||||
},
|
||||
"typing": {
|
||||
"enabled": true
|
||||
},
|
||||
"placeholder": {
|
||||
"enabled": false,
|
||||
"text": "Thinking..."
|
||||
},
|
||||
"reasoning_channel_id": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| -------------------- | ------ | ---- | ---- |
|
||||
| enabled | bool | 是 | 是否启用 Mattermost 渠道 |
|
||||
| url | string | 是 | Mattermost 服务地址(例如 `https://chat.example.com`) |
|
||||
| bot_token | string | 是 | Bot Access Token |
|
||||
| allow_from | array | 否 | 用户白名单(Mattermost 用户 ID),空表示允许所有用户 |
|
||||
| group_trigger | object | 否 | 群组触发策略(`mention_only` / `prefixes`) |
|
||||
| typing | object | 否 | 输入中状态配置 |
|
||||
| placeholder | object | 否 | 占位消息配置(先发占位,后编辑为最终回复) |
|
||||
| reasoning_channel_id | string | 否 | 思考/推理输出目标频道 ID |
|
||||
|
||||
## 设置流程
|
||||
|
||||
1. 在 Mattermost 系统控制台中启用 Bot Accounts 功能(如果尚未启用)
|
||||
2. 创建 Bot 账号并复制访问 Token
|
||||
3. 把 Bot 添加到需要响应的频道/群组
|
||||
4. 在 `config.json` 中填写 `url` 与 `bot_token`
|
||||
5. 启动 `picoclaw gateway`
|
||||
|
||||
## 行为说明
|
||||
|
||||
- 私聊(DM)默认会响应
|
||||
- 群组/频道消息默认可响应;可通过 `group_trigger.mention_only=true` 改为仅 @ 触发
|
||||
- 连接异常时会自动重连,重连成功后继续收发消息
|
||||
|
||||
## 常见问题
|
||||
|
||||
1. 启动时报 `no channels enabled`
|
||||
|
||||
- 确认 `channels.mattermost.enabled=true`
|
||||
- 确认 `url` 与 `bot_token` 非空
|
||||
- 确认实际加载的是目标配置文件(`PICOCLAW_CONFIG` / `PICOCLAW_HOME` 是否覆盖)
|
||||
|
||||
2. 频道内不响应
|
||||
|
||||
- 检查 `allow_from` 是否限制了用户
|
||||
- 如果启用了 `mention_only`,请确认消息中包含对 bot 的 @ 提及
|
||||
- 确认 bot 已加入对应频道并具备发言权限
|
||||
|
|
@ -59,13 +59,14 @@ type placeholderEntry struct {
|
|||
|
||||
// channelRateConfig maps channel name to per-second rate limit.
|
||||
var channelRateConfig = map[string]float64{
|
||||
"telegram": 20,
|
||||
"discord": 1,
|
||||
"slack": 1,
|
||||
"matrix": 2,
|
||||
"line": 10,
|
||||
"qq": 5,
|
||||
"irc": 2,
|
||||
"telegram": 20,
|
||||
"discord": 1,
|
||||
"slack": 1,
|
||||
"matrix": 2,
|
||||
"line": 10,
|
||||
"qq": 5,
|
||||
"irc": 2,
|
||||
"mattermost": 5,
|
||||
}
|
||||
|
||||
type channelWorker struct {
|
||||
|
|
|
|||
34
pkg/channels/mattermost/init.go
Normal file
34
pkg/channels/mattermost/init.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package mattermost
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func init() {
|
||||
channels.RegisterFactory(
|
||||
config.ChannelMattermost,
|
||||
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||
bc := cfg.Channels[channelName]
|
||||
decoded, err := bc.GetDecoded()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, ok := decoded.(*config.MattermostSettings)
|
||||
if !ok {
|
||||
return nil, channels.ErrSendFailed
|
||||
}
|
||||
return NewMattermostChannel(config.MattermostConfig{
|
||||
Enabled: bc.Enabled,
|
||||
URL: c.URL,
|
||||
BotToken: c.BotToken,
|
||||
AllowFrom: bc.AllowFrom,
|
||||
GroupTrigger: bc.GroupTrigger,
|
||||
Typing: bc.Typing,
|
||||
Placeholder: bc.Placeholder,
|
||||
ReasoningChannelID: bc.ReasoningChannelID,
|
||||
}, b)
|
||||
},
|
||||
)
|
||||
}
|
||||
828
pkg/channels/mattermost/mattermost.go
Normal file
828
pkg/channels/mattermost/mattermost.go
Normal file
|
|
@ -0,0 +1,828 @@
|
|||
package mattermost
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/identity"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
sendTimeout = 10 * time.Second
|
||||
maxPostChars = 16383
|
||||
typingInterval = 4 * time.Second
|
||||
typingTimeout = 3 * time.Minute
|
||||
wsReconnectMax = 60 * time.Second
|
||||
wsPingInterval = 20 * time.Second
|
||||
wsPingTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// MattermostChannel implements the PicoClaw Channel interface for Mattermost.
|
||||
type MattermostChannel struct {
|
||||
*channels.BaseChannel
|
||||
config config.MattermostConfig
|
||||
httpClient *http.Client
|
||||
botUserID string
|
||||
botUsername string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
typingMu sync.Mutex
|
||||
typingStop map[string]chan struct{}
|
||||
}
|
||||
|
||||
var _ channels.MediaSender = (*MattermostChannel)(nil)
|
||||
|
||||
func NewMattermostChannel(cfg config.MattermostConfig, messageBus *bus.MessageBus) (*MattermostChannel, error) {
|
||||
if cfg.URL == "" {
|
||||
return nil, fmt.Errorf("mattermost url is required")
|
||||
}
|
||||
if cfg.BotToken.String() == "" {
|
||||
return nil, fmt.Errorf("mattermost bot_token is required")
|
||||
}
|
||||
|
||||
base := channels.NewBaseChannel("mattermost", cfg, messageBus, cfg.AllowFrom,
|
||||
channels.WithMaxMessageLength(maxPostChars),
|
||||
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||
)
|
||||
|
||||
return &MattermostChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
typingStop: make(map[string]chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
func (c *MattermostChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("mattermost", "Starting Mattermost channel")
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
if err := c.fetchBotInfo(); err != nil {
|
||||
return fmt.Errorf("mattermost: failed to fetch bot info: %w", err)
|
||||
}
|
||||
|
||||
go c.websocketLoop()
|
||||
|
||||
c.SetRunning(true)
|
||||
logger.InfoCF("mattermost", "Mattermost bot connected", map[string]any{
|
||||
"username": c.botUsername,
|
||||
"user_id": c.botUserID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("mattermost", "Stopping Mattermost channel")
|
||||
c.SetRunning(false)
|
||||
|
||||
c.typingMu.Lock()
|
||||
for chatID, stop := range c.typingStop {
|
||||
close(stop)
|
||||
delete(c.typingStop, chatID)
|
||||
}
|
||||
c.typingMu.Unlock()
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- REST API helpers ---
|
||||
|
||||
func (c *MattermostChannel) apiURL(path string) string {
|
||||
return strings.TrimRight(c.config.URL, "/") + path
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) doJSON(method, path string, body any) (*http.Response, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(c.ctx, method, c.apiURL(path), reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.BotToken.String())
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return c.httpClient.Do(req)
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) fetchBotInfo() error {
|
||||
resp, err := c.doJSON(http.MethodGet, "/api/v4/users/me", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET /users/me returned %d", resp.StatusCode)
|
||||
}
|
||||
var user struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
return err
|
||||
}
|
||||
c.botUserID = user.ID
|
||||
c.botUsername = user.Username
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- WebSocket loop ---
|
||||
|
||||
func (c *MattermostChannel) websocketLoop() {
|
||||
wsURL := buildWSURL(c.config.URL)
|
||||
|
||||
reconnectDelay := time.Second
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
connected, err := c.runWebSocket(wsURL)
|
||||
if err != nil {
|
||||
if c.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logger.WarnCF("mattermost", "WebSocket error, reconnecting", map[string]any{
|
||||
"error": err.Error(),
|
||||
"delay": reconnectDelay.String(),
|
||||
})
|
||||
}
|
||||
if connected {
|
||||
// Reset backoff after at least one successful connection so future
|
||||
// disconnects recover quickly instead of inheriting stale high delays.
|
||||
reconnectDelay = time.Second
|
||||
}
|
||||
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-time.After(reconnectDelay):
|
||||
}
|
||||
reconnectDelay = min(reconnectDelay*2, wsReconnectMax)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) runWebSocket(wsURL string) (bool, error) {
|
||||
dialer := websocket.DefaultDialer
|
||||
conn, resp, err := dialer.DialContext(c.ctx, wsURL, nil)
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
// Authenticate
|
||||
authMsg := map[string]any{
|
||||
"seq": 1,
|
||||
"action": "authentication_challenge",
|
||||
"data": map[string]any{"token": c.config.BotToken.String()},
|
||||
}
|
||||
authData, _ := json.Marshal(authMsg)
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(wsPingTimeout))
|
||||
if err := conn.WriteMessage(websocket.TextMessage, authData); err != nil {
|
||||
return false, fmt.Errorf("auth: %w", err)
|
||||
}
|
||||
|
||||
logger.InfoC("mattermost", "WebSocket connected and authenticated")
|
||||
|
||||
wsCtx, wsCancel := context.WithCancel(c.ctx)
|
||||
defer wsCancel()
|
||||
|
||||
connCloseDone := make(chan struct{})
|
||||
defer close(connCloseDone)
|
||||
go func() {
|
||||
select {
|
||||
case <-wsCtx.Done():
|
||||
_ = conn.Close()
|
||||
case <-connCloseDone:
|
||||
}
|
||||
}()
|
||||
|
||||
// Keepalive: detect half-open connections and force reconnect.
|
||||
pingErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
ticker := time.NewTicker(wsPingInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-wsCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
err := conn.WriteControl(
|
||||
websocket.PingMessage,
|
||||
nil,
|
||||
time.Now().Add(wsPingTimeout),
|
||||
)
|
||||
if err != nil {
|
||||
select {
|
||||
case pingErrCh <- fmt.Errorf("ping: %w", err):
|
||||
default:
|
||||
}
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if c.ctx.Err() != nil {
|
||||
return true, nil
|
||||
}
|
||||
select {
|
||||
case pingErr := <-pingErrCh:
|
||||
return true, pingErr
|
||||
default:
|
||||
}
|
||||
return true, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
|
||||
var event wsEvent
|
||||
if err := json.Unmarshal(data, &event); err != nil {
|
||||
continue
|
||||
}
|
||||
if event.Event == "posted" {
|
||||
go c.handlePostedEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type wsEvent struct {
|
||||
Event string `json:"event"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type wsPostedData struct {
|
||||
Post string `json:"post"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
}
|
||||
|
||||
type mmPost struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
UserID string `json:"user_id"`
|
||||
RootID string `json:"root_id"`
|
||||
Message string `json:"message"`
|
||||
FileIDs []string `json:"file_ids"`
|
||||
Metadata struct {
|
||||
Files []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"files"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
|
||||
// --- Message handler ---
|
||||
|
||||
func (c *MattermostChannel) handlePostedEvent(event wsEvent) {
|
||||
var posted wsPostedData
|
||||
if err := json.Unmarshal(event.Data, &posted); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var post mmPost
|
||||
if err := json.Unmarshal([]byte(posted.Post), &post); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore own messages
|
||||
if post.UserID == c.botUserID {
|
||||
return
|
||||
}
|
||||
// Ignore Mattermost system posts (e.g., system_join_channel)
|
||||
if strings.HasPrefix(post.Type, "system_") {
|
||||
return
|
||||
}
|
||||
|
||||
isDM := posted.ChannelType == "D"
|
||||
|
||||
// Build sender info for allowlist check
|
||||
sender := bus.SenderInfo{
|
||||
Platform: "mattermost",
|
||||
PlatformID: post.UserID,
|
||||
CanonicalID: identity.BuildCanonicalID("mattermost", post.UserID),
|
||||
}
|
||||
|
||||
if !c.IsAllowedSender(sender) {
|
||||
logger.DebugCF("mattermost", "Message rejected by allowlist", map[string]any{
|
||||
"user_id": post.UserID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
content := post.Message
|
||||
|
||||
// Group trigger filtering
|
||||
if !isDM {
|
||||
isMentioned := c.hasBotMention(content)
|
||||
content = c.stripBotMention(content)
|
||||
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||
if !respond {
|
||||
return
|
||||
}
|
||||
content = cleaned
|
||||
} else {
|
||||
content = c.stripBotMention(content)
|
||||
}
|
||||
|
||||
// Determine chatID and peer
|
||||
peerKind := "channel"
|
||||
chatID := post.ChannelID
|
||||
if isDM {
|
||||
peerKind = "direct"
|
||||
}
|
||||
peer := bus.Peer{Kind: peerKind, ID: chatID}
|
||||
|
||||
// Thread root_id: for replies, use existing root_id; for new channel messages, use post.ID
|
||||
rootID := post.RootID
|
||||
if !isDM && rootID == "" {
|
||||
rootID = post.ID
|
||||
}
|
||||
|
||||
// Download attachments
|
||||
mediaPaths := make([]string, 0, len(post.FileIDs))
|
||||
scope := channels.BuildMediaScope("mattermost", chatID, post.ID)
|
||||
|
||||
hintMap := make(map[string]string)
|
||||
for _, fi := range post.Metadata.Files {
|
||||
if fi.ID != "" {
|
||||
hintMap[fi.ID] = fi.Name
|
||||
}
|
||||
}
|
||||
|
||||
for _, fid := range post.FileIDs {
|
||||
filename := hintMap[fid]
|
||||
localPath := c.downloadFile(fid, filename)
|
||||
if localPath == "" {
|
||||
continue
|
||||
}
|
||||
if store := c.GetMediaStore(); store != nil {
|
||||
ref, err := store.Store(localPath, media.MediaMeta{
|
||||
Filename: filename,
|
||||
Source: "mattermost",
|
||||
}, scope)
|
||||
if err == nil {
|
||||
mediaPaths = append(mediaPaths, ref)
|
||||
continue
|
||||
}
|
||||
}
|
||||
mediaPaths = append(mediaPaths, localPath)
|
||||
}
|
||||
|
||||
if content == "" && len(mediaPaths) == 0 {
|
||||
return
|
||||
}
|
||||
if content == "" {
|
||||
content = "[media only]"
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"user_id": post.UserID,
|
||||
"channel_id": post.ChannelID,
|
||||
"channel_type": posted.ChannelType,
|
||||
"post_id": post.ID,
|
||||
"root_id": rootID,
|
||||
"is_dm": fmt.Sprintf("%t", isDM),
|
||||
}
|
||||
|
||||
logger.DebugCF("mattermost", "Received message", map[string]any{
|
||||
"sender_id": post.UserID,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
"is_dm": isDM,
|
||||
})
|
||||
|
||||
c.HandleMessage(c.ctx, peer, post.ID, post.UserID, chatID, content, mediaPaths, metadata, sender)
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) stripBotMention(text string) string {
|
||||
if c.botUsername == "" {
|
||||
return text
|
||||
}
|
||||
// Case-insensitive replace of standalone @botname mentions.
|
||||
mention := "@" + strings.ToLower(c.botUsername)
|
||||
lower := strings.ToLower(text)
|
||||
var out strings.Builder
|
||||
out.Grow(len(text))
|
||||
|
||||
cursor := 0
|
||||
searchFrom := 0
|
||||
removed := false
|
||||
for {
|
||||
idxRel := strings.Index(lower[searchFrom:], mention)
|
||||
if idxRel < 0 {
|
||||
break
|
||||
}
|
||||
start := searchFrom + idxRel
|
||||
end := start + len(mention)
|
||||
if !isMentionBoundary(text, start, end) {
|
||||
searchFrom = end
|
||||
continue
|
||||
}
|
||||
out.WriteString(text[cursor:start])
|
||||
cursor = end
|
||||
searchFrom = end
|
||||
removed = true
|
||||
}
|
||||
if !removed {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
out.WriteString(text[cursor:])
|
||||
return strings.TrimSpace(out.String())
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) hasBotMention(text string) bool {
|
||||
if c.botUsername == "" {
|
||||
return false
|
||||
}
|
||||
mention := "@" + strings.ToLower(c.botUsername)
|
||||
lower := strings.ToLower(text)
|
||||
searchFrom := 0
|
||||
for {
|
||||
idxRel := strings.Index(lower[searchFrom:], mention)
|
||||
if idxRel < 0 {
|
||||
return false
|
||||
}
|
||||
start := searchFrom + idxRel
|
||||
end := start + len(mention)
|
||||
if isMentionBoundary(text, start, end) {
|
||||
return true
|
||||
}
|
||||
searchFrom = end
|
||||
}
|
||||
}
|
||||
|
||||
func isMentionBoundary(text string, start, end int) bool {
|
||||
if start > 0 && isMattermostUsernameChar(text[start-1]) {
|
||||
return false
|
||||
}
|
||||
if end < len(text) && isMattermostUsernameChar(text[end]) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isMattermostUsernameChar(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') ||
|
||||
(b >= 'A' && b <= 'Z') ||
|
||||
(b >= '0' && b <= '9') ||
|
||||
b == '_' || b == '-' || b == '.'
|
||||
}
|
||||
|
||||
// --- Send ---
|
||||
|
||||
func (c *MattermostChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
||||
if !c.IsRunning() {
|
||||
return nil, channels.ErrNotRunning
|
||||
}
|
||||
if msg.ChatID == "" {
|
||||
return nil, fmt.Errorf("mattermost: chatID is empty")
|
||||
}
|
||||
if len([]rune(msg.Content)) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rootID := msg.ReplyToMessageID
|
||||
return c.postMessage(msg.ChatID, msg.Content, rootID)
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) postMessage(channelID, text, rootID string) ([]string, error) {
|
||||
payload := map[string]any{
|
||||
"channel_id": channelID,
|
||||
"message": text,
|
||||
}
|
||||
if rootID != "" {
|
||||
payload["root_id"] = rootID
|
||||
}
|
||||
resp, err := c.doJSON(http.MethodPost, "/api/v4/posts", payload)
|
||||
if err != nil {
|
||||
return nil, channels.ClassifyNetError(fmt.Errorf("mattermost post: %w", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
|
||||
logger.ErrorCF("mattermost", "Post failed", map[string]any{
|
||||
"status": resp.StatusCode,
|
||||
"body": string(body),
|
||||
})
|
||||
return nil, channels.ClassifySendError(resp.StatusCode, fmt.Errorf("mattermost post %d", resp.StatusCode))
|
||||
}
|
||||
var created mmPost
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
return nil, fmt.Errorf("mattermost decode post response: %w", err)
|
||||
}
|
||||
if created.ID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []string{created.ID}, nil
|
||||
}
|
||||
|
||||
// --- EditMessage (MessageEditor) ---
|
||||
|
||||
func (c *MattermostChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error {
|
||||
payload := map[string]any{
|
||||
"id": messageID,
|
||||
"message": content,
|
||||
}
|
||||
resp, err := c.doJSON(http.MethodPut, "/api/v4/posts/"+messageID, payload)
|
||||
if err != nil {
|
||||
return channels.ClassifyNetError(fmt.Errorf("mattermost edit: %w", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("mattermost edit %d", resp.StatusCode))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- SendPlaceholder (PlaceholderCapable) ---
|
||||
|
||||
func (c *MattermostChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||
if !c.config.Placeholder.Enabled {
|
||||
return "", nil
|
||||
}
|
||||
payload := map[string]any{
|
||||
"channel_id": chatID,
|
||||
"message": c.config.Placeholder.GetRandomText(),
|
||||
}
|
||||
resp, err := c.doJSON(http.MethodPost, "/api/v4/posts", payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return "", fmt.Errorf("placeholder post %d", resp.StatusCode)
|
||||
}
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.ID, nil
|
||||
}
|
||||
|
||||
// --- Typing (TypingCapable) ---
|
||||
|
||||
func (c *MattermostChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||
if !c.config.Typing.Enabled {
|
||||
return func() {}, nil
|
||||
}
|
||||
c.startTypingLoop(chatID)
|
||||
return func() { c.stopTypingLoop(chatID) }, nil
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) startTypingLoop(chatID string) {
|
||||
c.typingMu.Lock()
|
||||
if stop, ok := c.typingStop[chatID]; ok {
|
||||
close(stop)
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
c.typingStop[chatID] = stop
|
||||
c.typingMu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer c.cleanupTypingLoop(chatID, stop)
|
||||
c.sendTyping(chatID)
|
||||
ticker := time.NewTicker(typingInterval)
|
||||
defer ticker.Stop()
|
||||
timeout := time.After(typingTimeout)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-timeout:
|
||||
return
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.sendTyping(chatID)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) cleanupTypingLoop(chatID string, stop chan struct{}) {
|
||||
c.typingMu.Lock()
|
||||
defer c.typingMu.Unlock()
|
||||
if current, ok := c.typingStop[chatID]; ok && current == stop {
|
||||
delete(c.typingStop, chatID)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) stopTypingLoop(chatID string) {
|
||||
c.typingMu.Lock()
|
||||
defer c.typingMu.Unlock()
|
||||
if stop, ok := c.typingStop[chatID]; ok {
|
||||
close(stop)
|
||||
delete(c.typingStop, chatID)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) sendTyping(chatID string) {
|
||||
payload := map[string]any{"channel_id": chatID}
|
||||
resp, err := c.doJSON(http.MethodPost, "/api/v4/users/"+c.botUserID+"/typing", payload)
|
||||
if err != nil {
|
||||
logger.DebugCF("mattermost", "Typing indicator failed", map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
// --- SendMedia (MediaSender) ---
|
||||
|
||||
func (c *MattermostChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
||||
if !c.IsRunning() {
|
||||
return nil, channels.ErrNotRunning
|
||||
}
|
||||
if msg.ChatID == "" {
|
||||
return nil, fmt.Errorf("mattermost: chatID is empty")
|
||||
}
|
||||
|
||||
store := c.GetMediaStore()
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("no media store: %w", channels.ErrSendFailed)
|
||||
}
|
||||
|
||||
var postIDs []string
|
||||
for _, part := range msg.Parts {
|
||||
localPath, err := store.Resolve(part.Ref)
|
||||
if err != nil {
|
||||
logger.ErrorCF("mattermost", "Failed to resolve media ref", map[string]any{
|
||||
"ref": part.Ref,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
fileID, err := c.uploadFile(msg.ChatID, localPath, part.Filename)
|
||||
if err != nil {
|
||||
logger.ErrorCF("mattermost", "Upload failed", map[string]any{
|
||||
"path": localPath,
|
||||
"error": err.Error(),
|
||||
})
|
||||
// Fallback: send path as text
|
||||
_, _ = c.postMessage(msg.ChatID, fmt.Sprintf("[Attachment: %s]", part.Filename), "")
|
||||
continue
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"channel_id": msg.ChatID,
|
||||
"message": part.Caption,
|
||||
"file_ids": []string{fileID},
|
||||
}
|
||||
resp, err := c.doJSON(http.MethodPost, "/api/v4/posts", payload)
|
||||
if err != nil {
|
||||
return nil, channels.ClassifyNetError(fmt.Errorf("mattermost media post: %w", err))
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
|
||||
resp.Body.Close()
|
||||
logger.ErrorCF("mattermost", "Media post failed", map[string]any{
|
||||
"status": resp.StatusCode,
|
||||
"body": string(body),
|
||||
})
|
||||
return nil, channels.ClassifySendError(
|
||||
resp.StatusCode,
|
||||
fmt.Errorf("mattermost media post %d", resp.StatusCode),
|
||||
)
|
||||
}
|
||||
var created mmPost
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("mattermost decode media post response: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if created.ID != "" {
|
||||
postIDs = append(postIDs, created.ID)
|
||||
}
|
||||
}
|
||||
return postIDs, nil
|
||||
}
|
||||
|
||||
// --- File helpers ---
|
||||
|
||||
func (c *MattermostChannel) downloadFile(fileID, filename string) string {
|
||||
url := c.apiURL("/api/v4/files/" + fileID)
|
||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||
LoggerPrefix: "mattermost",
|
||||
ExtraHeaders: map[string]string{"Authorization": "Bearer " + c.config.BotToken.String()},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *MattermostChannel) uploadFile(channelID, localPath, filename string) (string, error) {
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if filename == "" {
|
||||
filename = filepath.Base(localPath)
|
||||
}
|
||||
|
||||
bodyReader, bodyWriter := io.Pipe()
|
||||
mpWriter := multipart.NewWriter(bodyWriter)
|
||||
|
||||
go func() {
|
||||
defer bodyWriter.Close()
|
||||
if writeErr := mpWriter.WriteField("channel_id", channelID); writeErr != nil {
|
||||
_ = bodyWriter.CloseWithError(writeErr)
|
||||
return
|
||||
}
|
||||
part, createErr := mpWriter.CreateFormFile("files", filename)
|
||||
if createErr != nil {
|
||||
_ = bodyWriter.CloseWithError(createErr)
|
||||
return
|
||||
}
|
||||
if _, copyErr := io.Copy(part, f); copyErr != nil {
|
||||
_ = bodyWriter.CloseWithError(copyErr)
|
||||
return
|
||||
}
|
||||
if closeErr := mpWriter.Close(); closeErr != nil {
|
||||
_ = bodyWriter.CloseWithError(closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
req, err := http.NewRequestWithContext(c.ctx, http.MethodPost, c.apiURL("/api/v4/files"), bodyReader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.BotToken.String())
|
||||
req.Header.Set("Content-Type", mpWriter.FormDataContentType())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
_ = bodyReader.CloseWithError(err)
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return "", fmt.Errorf("upload returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
FileInfos []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"file_infos"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(result.FileInfos) == 0 {
|
||||
return "", fmt.Errorf("upload returned no file_infos")
|
||||
}
|
||||
return result.FileInfos[0].ID, nil
|
||||
}
|
||||
|
||||
func buildWSURL(baseURL string) string {
|
||||
wsURL := strings.Replace(baseURL, "https://", "wss://", 1)
|
||||
wsURL = strings.Replace(wsURL, "http://", "ws://", 1)
|
||||
return strings.TrimRight(wsURL, "/") + "/api/v4/websocket"
|
||||
}
|
||||
510
pkg/channels/mattermost/mattermost_test.go
Normal file
510
pkg/channels/mattermost/mattermost_test.go
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
package mattermost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
)
|
||||
|
||||
func TestNewMattermostChannel(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
|
||||
t.Run("missing url", func(t *testing.T) {
|
||||
cfg := config.MattermostConfig{
|
||||
URL: "",
|
||||
BotToken: *config.NewSecureString("token"),
|
||||
}
|
||||
_, err := NewMattermostChannel(cfg, msgBus)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing url, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing bot token", func(t *testing.T) {
|
||||
cfg := config.MattermostConfig{
|
||||
URL: "https://mattermost.example.com",
|
||||
}
|
||||
_, err := NewMattermostChannel(cfg, msgBus)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing bot token, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
cfg := config.MattermostConfig{
|
||||
URL: "https://mattermost.example.com",
|
||||
BotToken: *config.NewSecureString("token"),
|
||||
}
|
||||
ch, err := NewMattermostChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ch.Name() != "mattermost" {
|
||||
t.Fatalf("Name() = %q, want %q", ch.Name(), "mattermost")
|
||||
}
|
||||
if ch.IsRunning() {
|
||||
t.Fatal("new channel should not be running")
|
||||
}
|
||||
if ch.typingStop == nil {
|
||||
t.Fatal("typingStop map should be initialized")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildWSURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "https without trailing slash",
|
||||
in: "https://mm.example.com",
|
||||
want: "wss://mm.example.com/api/v4/websocket",
|
||||
},
|
||||
{
|
||||
name: "https with trailing slash",
|
||||
in: "https://mm.example.com/",
|
||||
want: "wss://mm.example.com/api/v4/websocket",
|
||||
},
|
||||
{
|
||||
name: "http",
|
||||
in: "http://localhost:8065",
|
||||
want: "ws://localhost:8065/api/v4/websocket",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildWSURL(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("buildWSURL(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripBotMention(t *testing.T) {
|
||||
ch := &MattermostChannel{
|
||||
botUsername: "PicoclawBot",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "mention at start",
|
||||
in: "@PicoclawBot hello",
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "case insensitive mention",
|
||||
in: "@picoclawbot hello",
|
||||
want: "hello",
|
||||
},
|
||||
{
|
||||
name: "multiple mentions",
|
||||
in: "hi @PicoclawBot and @PICOCLAWBOT",
|
||||
want: "hi and",
|
||||
},
|
||||
{
|
||||
name: "no mention",
|
||||
in: "hello world",
|
||||
want: "hello world",
|
||||
},
|
||||
{
|
||||
name: "substring mention should not strip",
|
||||
in: "@PicoclawBotany should stay",
|
||||
want: "@PicoclawBotany should stay",
|
||||
},
|
||||
{
|
||||
name: "email should not strip",
|
||||
in: "foo@PicoclawBot.com",
|
||||
want: "foo@PicoclawBot.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ch.stripBotMention(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("stripBotMention(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasBotMention(t *testing.T) {
|
||||
ch := &MattermostChannel{
|
||||
botUsername: "PicoclawBot",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact mention",
|
||||
in: "@PicoclawBot hello",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "mention with punctuation",
|
||||
in: "@PicoclawBot, hi",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "substring should not count",
|
||||
in: "@PicoclawBotany hi",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "email should not count",
|
||||
in: "foo@PicoclawBot.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no mention",
|
||||
in: "hello world",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ch.hasBotMention(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("hasBotMention(%q) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupTypingLoop(t *testing.T) {
|
||||
ch := &MattermostChannel{
|
||||
typingStop: make(map[string]chan struct{}),
|
||||
}
|
||||
|
||||
stop := make(chan struct{})
|
||||
ch.typingStop["chat-1"] = stop
|
||||
ch.cleanupTypingLoop("chat-1", stop)
|
||||
if _, ok := ch.typingStop["chat-1"]; ok {
|
||||
t.Fatal("expected cleanupTypingLoop to remove current typing entry")
|
||||
}
|
||||
|
||||
oldStop := make(chan struct{})
|
||||
newStop := make(chan struct{})
|
||||
ch.typingStop["chat-1"] = newStop
|
||||
ch.cleanupTypingLoop("chat-1", oldStop)
|
||||
if got := ch.typingStop["chat-1"]; got != newStop {
|
||||
t.Fatal("cleanupTypingLoop should not remove newer typing entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFile(t *testing.T) {
|
||||
const (
|
||||
wantToken = "test-token"
|
||||
wantChatID = "channel-1"
|
||||
wantFileID = "file-123"
|
||||
wantContent = "mattermost upload stream test"
|
||||
)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want %s", r.Method, http.MethodPost)
|
||||
}
|
||||
if r.URL.Path != "/api/v4/files" {
|
||||
t.Fatalf("path = %s, want /api/v4/files", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer "+wantToken {
|
||||
t.Fatalf("Authorization = %q, want %q", got, "Bearer "+wantToken)
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "multipart/form-data; boundary=") {
|
||||
t.Fatalf("Content-Type = %q, want multipart/form-data", got)
|
||||
}
|
||||
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
t.Fatalf("MultipartReader() error: %v", err)
|
||||
}
|
||||
|
||||
var gotChatID string
|
||||
var gotFilename string
|
||||
var gotContent string
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NextPart() error: %v", err)
|
||||
}
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll(part) error: %v", err)
|
||||
}
|
||||
|
||||
switch part.FormName() {
|
||||
case "channel_id":
|
||||
gotChatID = string(data)
|
||||
case "files":
|
||||
gotFilename = part.FileName()
|
||||
gotContent = string(data)
|
||||
}
|
||||
}
|
||||
|
||||
if gotChatID != wantChatID {
|
||||
t.Fatalf("channel_id = %q, want %q", gotChatID, wantChatID)
|
||||
}
|
||||
if gotFilename != "upload.txt" {
|
||||
t.Fatalf("filename = %q, want %q", gotFilename, "upload.txt")
|
||||
}
|
||||
if gotContent != wantContent {
|
||||
t.Fatalf("file content = %q, want %q", gotContent, wantContent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"file_infos":[{"id":"` + wantFileID + `"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "upload.txt")
|
||||
if err := os.WriteFile(filePath, []byte(wantContent), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.MattermostConfig{
|
||||
URL: server.URL,
|
||||
BotToken: *config.NewSecureString(wantToken),
|
||||
}
|
||||
ch, err := NewMattermostChannel(cfg, bus.NewMessageBus())
|
||||
if err != nil {
|
||||
t.Fatalf("NewMattermostChannel() error: %v", err)
|
||||
}
|
||||
ch.ctx = context.Background()
|
||||
|
||||
gotFileID, err := ch.uploadFile(wantChatID, filePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("uploadFile() error: %v", err)
|
||||
}
|
||||
if gotFileID != wantFileID {
|
||||
t.Fatalf("uploadFile() fileID = %q, want %q", gotFileID, wantFileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostMessageClassifiesHTTPStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
wantErr error
|
||||
}{
|
||||
{name: "404 send failed", statusCode: http.StatusNotFound, wantErr: channels.ErrSendFailed},
|
||||
{name: "429 rate limited", statusCode: http.StatusTooManyRequests, wantErr: channels.ErrRateLimit},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v4/posts" {
|
||||
t.Fatalf("path = %s, want /api/v4/posts", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(tt.statusCode)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ch, err := NewMattermostChannel(config.MattermostConfig{
|
||||
URL: server.URL,
|
||||
BotToken: *config.NewSecureString("token"),
|
||||
}, bus.NewMessageBus())
|
||||
if err != nil {
|
||||
t.Fatalf("NewMattermostChannel() error: %v", err)
|
||||
}
|
||||
ch.ctx = context.Background()
|
||||
|
||||
_, err = ch.postMessage("channel-1", "hello", "")
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("postMessage() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditMessageClassifiesHTTPStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
t.Fatalf("method = %s, want %s", r.Method, http.MethodPut)
|
||||
}
|
||||
if r.URL.Path != "/api/v4/posts/post-1" {
|
||||
t.Fatalf("path = %s, want /api/v4/posts/post-1", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ch, err := NewMattermostChannel(config.MattermostConfig{
|
||||
URL: server.URL,
|
||||
BotToken: *config.NewSecureString("token"),
|
||||
}, bus.NewMessageBus())
|
||||
if err != nil {
|
||||
t.Fatalf("NewMattermostChannel() error: %v", err)
|
||||
}
|
||||
ch.ctx = context.Background()
|
||||
|
||||
err = ch.EditMessage(context.Background(), "channel-1", "post-1", "updated")
|
||||
if !errors.Is(err, channels.ErrSendFailed) {
|
||||
t.Fatalf("EditMessage() error = %v, want %v", err, channels.ErrSendFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMediaClassifiesHTTPStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v4/files":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"file_infos":[{"id":"file-1"}]}`))
|
||||
case "/api/v4/posts":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"channel missing"}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "upload.txt")
|
||||
if err := os.WriteFile(filePath, []byte("payload"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
ref, err := store.Store(filePath, media.MediaMeta{Filename: "upload.txt"}, "scope-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Store() error: %v", err)
|
||||
}
|
||||
|
||||
ch, err := NewMattermostChannel(config.MattermostConfig{
|
||||
URL: server.URL,
|
||||
BotToken: *config.NewSecureString("token"),
|
||||
}, bus.NewMessageBus())
|
||||
if err != nil {
|
||||
t.Fatalf("NewMattermostChannel() error: %v", err)
|
||||
}
|
||||
ch.ctx = context.Background()
|
||||
ch.SetMediaStore(store)
|
||||
ch.SetRunning(true)
|
||||
|
||||
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
|
||||
ChatID: "channel-1",
|
||||
Parts: []bus.MediaPart{{
|
||||
Ref: ref,
|
||||
Filename: "upload.txt",
|
||||
}},
|
||||
})
|
||||
if !errors.Is(err, channels.ErrSendFailed) {
|
||||
t.Fatalf("SendMedia() error = %v, want %v", err, channels.ErrSendFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePostedEventIgnoresSystemMessages(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
ch, err := NewMattermostChannel(config.MattermostConfig{
|
||||
URL: "https://mattermost.example.com",
|
||||
BotToken: *config.NewSecureString("token"),
|
||||
}, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMattermostChannel() error: %v", err)
|
||||
}
|
||||
ch.ctx = context.Background()
|
||||
ch.botUserID = "bot-user"
|
||||
|
||||
ch.handlePostedEvent(makePostedEvent("system_join_channel", "user-1", "chan-1", "user joined", "O"))
|
||||
|
||||
expectNoInbound(t, msgBus, 100*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestHandlePostedEventGroupMentionBoundary(t *testing.T) {
|
||||
msgBus := bus.NewMessageBus()
|
||||
cfg := config.MattermostConfig{
|
||||
URL: "https://mattermost.example.com",
|
||||
BotToken: *config.NewSecureString("token"),
|
||||
GroupTrigger: config.GroupTriggerConfig{
|
||||
MentionOnly: true,
|
||||
},
|
||||
}
|
||||
ch, err := NewMattermostChannel(cfg, msgBus)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMattermostChannel() error: %v", err)
|
||||
}
|
||||
ch.ctx = context.Background()
|
||||
ch.botUserID = "bot-user"
|
||||
ch.botUsername = "mybot"
|
||||
|
||||
ch.handlePostedEvent(makePostedEvent("", "user-1", "chan-1", "@mybotany hi", "O"))
|
||||
expectNoInbound(t, msgBus, 100*time.Millisecond)
|
||||
|
||||
ch.handlePostedEvent(makePostedEvent("", "user-1", "chan-1", "@mybot hi", "O"))
|
||||
msg := mustRecvInbound(t, msgBus, 500*time.Millisecond)
|
||||
if msg.Content != "hi" {
|
||||
t.Fatalf("Content = %q, want %q", msg.Content, "hi")
|
||||
}
|
||||
}
|
||||
|
||||
func makePostedEvent(postType, userID, channelID, message, channelType string) wsEvent {
|
||||
post := map[string]any{
|
||||
"id": "post-1",
|
||||
"type": postType,
|
||||
"user_id": userID,
|
||||
"channel_id": channelID,
|
||||
"root_id": "",
|
||||
"message": message,
|
||||
}
|
||||
postJSON, _ := json.Marshal(post)
|
||||
data := map[string]string{
|
||||
"post": string(postJSON),
|
||||
"channel_type": channelType,
|
||||
}
|
||||
raw, _ := json.Marshal(data)
|
||||
return wsEvent{
|
||||
Event: "posted",
|
||||
Data: raw,
|
||||
}
|
||||
}
|
||||
|
||||
func expectNoInbound(t *testing.T, mb *bus.MessageBus, timeout time.Duration) {
|
||||
t.Helper()
|
||||
select {
|
||||
case msg := <-mb.InboundChan():
|
||||
t.Fatalf("expected no inbound message, got %+v", msg)
|
||||
case <-time.After(timeout):
|
||||
}
|
||||
}
|
||||
|
||||
func mustRecvInbound(t *testing.T, mb *bus.MessageBus, timeout time.Duration) bus.InboundMessage {
|
||||
t.Helper()
|
||||
select {
|
||||
case msg := <-mb.InboundChan():
|
||||
return msg
|
||||
case <-time.After(timeout):
|
||||
t.Fatal("expected inbound message, got none")
|
||||
return bus.InboundMessage{}
|
||||
}
|
||||
}
|
||||
|
|
@ -498,6 +498,22 @@ type TeamsWebhookTarget struct {
|
|||
Title string `json:"title,omitempty" yaml:"-"`
|
||||
}
|
||||
|
||||
type MattermostSettings struct {
|
||||
URL string `json:"url" yaml:"-" env:"PICOCLAW_CHANNELS_MATTERMOST_URL"`
|
||||
BotToken SecureString `json:"bot_token,omitzero" yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_MATTERMOST_BOT_TOKEN"`
|
||||
}
|
||||
|
||||
type MattermostConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
URL string `json:"url"`
|
||||
BotToken SecureString `json:"bot_token,omitzero"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from"`
|
||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||
Typing TypingConfig `json:"typing,omitempty"`
|
||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||
ReasoningChannelID string `json:"reasoning_channel_id"`
|
||||
}
|
||||
|
||||
type HeartbeatConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const (
|
|||
ChannelMaixCam = "maixcam"
|
||||
ChannelWhatsApp = "whatsapp"
|
||||
ChannelWhatsAppNative = "whatsapp_native"
|
||||
ChannelMattermost = "mattermost"
|
||||
ChannelTeamsWebHook = "teams_webhook"
|
||||
)
|
||||
|
||||
|
|
@ -639,6 +640,7 @@ var channelSettingsFactory = map[string]any{
|
|||
ChannelMaixCam: (MaixCamSettings{}),
|
||||
ChannelWhatsApp: (WhatsAppSettings{}),
|
||||
ChannelWhatsAppNative: (WhatsAppSettings{}),
|
||||
ChannelMattermost: (MattermostSettings{}),
|
||||
ChannelTeamsWebHook: (TeamsWebhookSettings{}),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -500,6 +500,9 @@ func defaultChannels() ChannelsConfig {
|
|||
"max_connections": 100,
|
||||
},
|
||||
},
|
||||
"mattermost": map[string]any{
|
||||
"typing": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
|
||||
channels := make(ChannelsConfig, len(defs))
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import (
|
|||
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/mattermost"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||
"github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue