feat: add Matrix channel support
This commit is contained in:
parent
66e6fb6c79
commit
a66eac42c4
22 changed files with 1523 additions and 7 deletions
37
README.md
37
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, DingTalk, LINE, or WeCom
|
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, 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.
|
||||||
|
|
||||||
|
|
@ -317,6 +317,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
|
||||||
| **Telegram** | Easy (just a token) |
|
| **Telegram** | Easy (just a token) |
|
||||||
| **Discord** | Easy (bot token + intents) |
|
| **Discord** | Easy (bot token + intents) |
|
||||||
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
|
||||||
|
| **Matrix** | Medium (homeserver + bot access token) |
|
||||||
| **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) |
|
||||||
|
|
@ -528,6 +529,40 @@ picoclaw gateway
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Matrix</b></summary>
|
||||||
|
|
||||||
|
**1. Prepare bot account**
|
||||||
|
|
||||||
|
* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)
|
||||||
|
* Create a bot user and obtain its access token
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picoclaw gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>LINE</b></summary>
|
<summary><b>LINE</b></summary>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||||
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
|
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
|
||||||
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
|
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
|
||||||
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
|
||||||
|
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) |
|
||||||
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
|
||||||
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
|
||||||
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) |
|
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) |
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,7 @@ func (s *appState) hasEnabledChannel() bool {
|
||||||
c := s.config.Channels
|
c := s.config.Channels
|
||||||
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
|
||||||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
|
||||||
c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,12 @@ func (s *appState) buildChannelMenuItems() []MenuItem {
|
||||||
s.config.Channels.Slack.Enabled,
|
s.config.Channels.Slack.Enabled,
|
||||||
func() { s.push("channel-slack", s.slackForm()) },
|
func() { s.push("channel-slack", s.slackForm()) },
|
||||||
),
|
),
|
||||||
|
channelItem(
|
||||||
|
"Matrix",
|
||||||
|
"Matrix bot settings",
|
||||||
|
s.config.Channels.Matrix.Enabled,
|
||||||
|
func() { s.push("channel-matrix", s.matrixForm()) },
|
||||||
|
),
|
||||||
channelItem(
|
channelItem(
|
||||||
"LINE",
|
"LINE",
|
||||||
"LINE bot settings",
|
"LINE bot settings",
|
||||||
|
|
@ -233,6 +239,28 @@ func (s *appState) lineForm() tview.Primitive {
|
||||||
return wrapWithBack(form, s)
|
return wrapWithBack(form, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *appState) matrixForm() tview.Primitive {
|
||||||
|
cfg := &s.config.Channels.Matrix
|
||||||
|
form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
|
||||||
|
cfg.Homeserver = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
|
||||||
|
cfg.UserID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
|
||||||
|
cfg.AccessToken = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
|
||||||
|
cfg.DeviceID = strings.TrimSpace(text)
|
||||||
|
})
|
||||||
|
form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
|
||||||
|
cfg.JoinOnInvite = checked
|
||||||
|
})
|
||||||
|
addAllowFromField(form, &cfg.AllowFrom)
|
||||||
|
return wrapWithBack(form, s)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *appState) onebotForm() tview.Primitive {
|
func (s *appState) onebotForm() tview.Primitive {
|
||||||
cfg := &s.config.Channels.OneBot
|
cfg := &s.config.Channels.OneBot
|
||||||
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ A standalone launcher for PicoClaw, providing visual JSON editing and OAuth prov
|
||||||
|
|
||||||
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
|
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
|
||||||
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
|
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
|
||||||
- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
|
- 📡 **Channel Configuration** — Form-based settings for 13 channel types (Telegram, Discord, Slack, Matrix, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
|
||||||
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
|
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
|
||||||
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
|
||||||
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
|
||||||
|
|
|
||||||
|
|
@ -538,6 +538,7 @@
|
||||||
<div class="sidebar-item" data-panel="panelCh_telegram">Telegram</div>
|
<div class="sidebar-item" data-panel="panelCh_telegram">Telegram</div>
|
||||||
<div class="sidebar-item" data-panel="panelCh_discord">Discord</div>
|
<div class="sidebar-item" data-panel="panelCh_discord">Discord</div>
|
||||||
<div class="sidebar-item" data-panel="panelCh_slack">Slack</div>
|
<div class="sidebar-item" data-panel="panelCh_slack">Slack</div>
|
||||||
|
<div class="sidebar-item" data-panel="panelCh_matrix">Matrix</div>
|
||||||
<div class="sidebar-item" data-panel="panelCh_wecom">WeCom</div>
|
<div class="sidebar-item" data-panel="panelCh_wecom">WeCom</div>
|
||||||
<div class="sidebar-item" data-panel="panelCh_wecom_app">WeCom App</div>
|
<div class="sidebar-item" data-panel="panelCh_wecom_app">WeCom App</div>
|
||||||
<div class="sidebar-item" data-panel="panelCh_dingtalk">DingTalk</div>
|
<div class="sidebar-item" data-panel="panelCh_dingtalk">DingTalk</div>
|
||||||
|
|
@ -606,6 +607,7 @@
|
||||||
<div class="content-panel" id="panelCh_telegram"></div>
|
<div class="content-panel" id="panelCh_telegram"></div>
|
||||||
<div class="content-panel" id="panelCh_discord"></div>
|
<div class="content-panel" id="panelCh_discord"></div>
|
||||||
<div class="content-panel" id="panelCh_slack"></div>
|
<div class="content-panel" id="panelCh_slack"></div>
|
||||||
|
<div class="content-panel" id="panelCh_matrix"></div>
|
||||||
<div class="content-panel" id="panelCh_wecom"></div>
|
<div class="content-panel" id="panelCh_wecom"></div>
|
||||||
<div class="content-panel" id="panelCh_wecom_app"></div>
|
<div class="content-panel" id="panelCh_wecom_app"></div>
|
||||||
<div class="content-panel" id="panelCh_dingtalk"></div>
|
<div class="content-panel" id="panelCh_dingtalk"></div>
|
||||||
|
|
@ -1011,6 +1013,16 @@ const channelSchemas = {
|
||||||
{ key: 'app_token', label: 'App Token', type: 'password', placeholder: 'xapp-...' },
|
{ key: 'app_token', label: 'App Token', type: 'password', placeholder: 'xapp-...' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
matrix: {
|
||||||
|
title: 'Matrix', configKey: 'matrix', docSlug: null,
|
||||||
|
fields: [
|
||||||
|
{ key: 'homeserver', label: 'Homeserver', type: 'text', placeholder: 'https://matrix.org' },
|
||||||
|
{ key: 'user_id', label: 'User ID', type: 'text', placeholder: '@bot:matrix.org' },
|
||||||
|
{ key: 'access_token', label: 'Access Token', type: 'password', placeholder: 'syt_...' },
|
||||||
|
{ key: 'device_id', label: 'Device ID', type: 'text', placeholder: 'Optional device ID' },
|
||||||
|
{ key: 'join_on_invite', label: 'Join On Invite', type: 'toggle' },
|
||||||
|
]
|
||||||
|
},
|
||||||
wecom: {
|
wecom: {
|
||||||
title: 'WeCom (Bot)', configKey: 'wecom', docSlug: 'wecom-bot',
|
title: 'WeCom (Bot)', configKey: 'wecom', docSlug: 'wecom-bot',
|
||||||
fields: [
|
fields: [
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
|
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
|
||||||
_ "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/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"
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,23 @@
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
|
"matrix": {
|
||||||
|
"enabled": false,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"device_id": "",
|
||||||
|
"join_on_invite": true,
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": "Thinking... 💭"
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
|
},
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||||
|
|
|
||||||
59
docs/channels/matrix/README.md
Normal file
59
docs/channels/matrix/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Matrix Channel Configuration Guide
|
||||||
|
|
||||||
|
## 1. Example Configuration
|
||||||
|
|
||||||
|
Add this to `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"device_id": "",
|
||||||
|
"join_on_invite": true,
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": "Thinking..."
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Field Reference
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|----------------------|----------|----------|-------------|
|
||||||
|
| enabled | bool | Yes | Enable or disable the Matrix channel |
|
||||||
|
| homeserver | string | Yes | Matrix homeserver URL (for example `https://matrix.org`) |
|
||||||
|
| user_id | string | Yes | Bot Matrix user ID (for example `@bot:matrix.org`) |
|
||||||
|
| access_token | string | Yes | Bot access token |
|
||||||
|
| device_id | string | No | Optional Matrix device ID |
|
||||||
|
| join_on_invite | bool | No | Auto-join invited rooms |
|
||||||
|
| allow_from | []string | No | User whitelist (Matrix user IDs) |
|
||||||
|
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
|
||||||
|
| placeholder | object | No | Placeholder message config |
|
||||||
|
| reasoning_channel_id | string | No | Target channel for reasoning output |
|
||||||
|
|
||||||
|
## 3. Currently Supported
|
||||||
|
|
||||||
|
- Text message send/receive
|
||||||
|
- Incoming image/audio/video/file download (MediaStore first, local path fallback)
|
||||||
|
- Incoming audio normalization into existing transcription flow (`[audio: ...]`)
|
||||||
|
- Outgoing image/audio/video/file upload and send
|
||||||
|
- Group trigger rules (including mention-only mode)
|
||||||
|
- Typing state (`m.typing`)
|
||||||
|
- Placeholder message + final reply replacement
|
||||||
|
- Auto-join invited rooms (can be disabled)
|
||||||
|
|
||||||
|
## 4. TODO
|
||||||
|
|
||||||
|
- Rich media metadata improvements (for example image/video size and thumbnails)
|
||||||
59
docs/channels/matrix/README.zh.md
Normal file
59
docs/channels/matrix/README.zh.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Matrix 通道配置指南
|
||||||
|
|
||||||
|
## 1. 配置示例
|
||||||
|
|
||||||
|
在 `config.json` 中添加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.org",
|
||||||
|
"user_id": "@your-bot:matrix.org",
|
||||||
|
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
|
||||||
|
"device_id": "",
|
||||||
|
"join_on_invite": true,
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"enabled": true,
|
||||||
|
"text": "Thinking... 💭"
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 参数说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|----------------------|----------|------|------|
|
||||||
|
| enabled | bool | 是 | 是否启用 Matrix 通道 |
|
||||||
|
| homeserver | string | 是 | Matrix 服务器地址(例如 `https://matrix.org`) |
|
||||||
|
| user_id | string | 是 | 机器人 Matrix 用户 ID(例如 `@bot:matrix.org`) |
|
||||||
|
| access_token | string | 是 | 机器人 access token |
|
||||||
|
| device_id | string | 否 | 设备 ID(可选) |
|
||||||
|
| join_on_invite | bool | 否 | 是否自动加入邀请房间 |
|
||||||
|
| allow_from | []string | 否 | 白名单用户(Matrix 用户 ID) |
|
||||||
|
| group_trigger | object | 否 | 群聊触发策略(支持 `mention_only` / `prefixes`) |
|
||||||
|
| placeholder | object | 否 | 占位消息配置 |
|
||||||
|
| reasoning_channel_id | string | 否 | 思维链输出目标通道 |
|
||||||
|
|
||||||
|
## 3. 当前支持
|
||||||
|
|
||||||
|
- 文本消息收发
|
||||||
|
- 图片/音频/视频/文件消息入站下载(写入 MediaStore / 本地路径回退)
|
||||||
|
- 音频消息按统一标记进入现有转写流程(`[audio: ...]`)
|
||||||
|
- 图片/音频/视频/文件消息出站发送(上传到 Matrix 媒体库后发送)
|
||||||
|
- 群聊触发规则(支持仅 @ 提及时响应)
|
||||||
|
- Typing 状态(`m.typing`)
|
||||||
|
- 占位消息(`Thinking... 💭`)+ 最终回复替换
|
||||||
|
- 自动加入邀请房间(可关闭)
|
||||||
|
|
||||||
|
## 4. TODO
|
||||||
|
|
||||||
|
- 富媒体细节增强(如 image/video 的尺寸、缩略图等 metadata)
|
||||||
3
go.mod
3
go.mod
|
|
@ -8,6 +8,7 @@ require (
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
github.com/chzyer/readline v1.5.1
|
github.com/chzyer/readline v1.5.1
|
||||||
|
github.com/ergochat/irc-go v0.5.0
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
|
@ -27,6 +28,7 @@ require (
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
|
maunium.net/go/mautrix v0.26.3
|
||||||
modernc.org/sqlite v1.46.1
|
modernc.org/sqlite v1.46.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,7 +39,6 @@ require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/ergochat/irc-go v0.5.0 // indirect
|
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -361,6 +361,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
|
||||||
|
maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ var channelRateConfig = map[string]float64{
|
||||||
"telegram": 20,
|
"telegram": 20,
|
||||||
"discord": 1,
|
"discord": 1,
|
||||||
"slack": 1,
|
"slack": 1,
|
||||||
|
"matrix": 2,
|
||||||
"line": 10,
|
"line": 10,
|
||||||
"irc": 2,
|
"irc": 2,
|
||||||
}
|
}
|
||||||
|
|
@ -244,6 +245,13 @@ func (m *Manager) initChannels() error {
|
||||||
m.initChannel("slack", "Slack")
|
m.initChannel("slack", "Slack")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.Matrix.Enabled &&
|
||||||
|
m.config.Channels.Matrix.Homeserver != "" &&
|
||||||
|
m.config.Channels.Matrix.UserID != "" &&
|
||||||
|
m.config.Channels.Matrix.AccessToken != "" {
|
||||||
|
m.initChannel("matrix", "Matrix")
|
||||||
|
}
|
||||||
|
|
||||||
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/matrix/init.go
Normal file
13
pkg/channels/matrix/init.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewMatrixChannel(cfg.Channels.Matrix, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
896
pkg/channels/matrix/matrix.go
Normal file
896
pkg/channels/matrix/matrix.go
Normal file
|
|
@ -0,0 +1,896 @@
|
||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
typingRefreshInterval = 20 * time.Second
|
||||||
|
typingServerTTL = 30 * time.Second
|
||||||
|
roomKindCacheTTL = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type roomKindCacheEntry struct {
|
||||||
|
isGroup bool
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type typingSession struct {
|
||||||
|
stopCh chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTypingSession() *typingSession {
|
||||||
|
return &typingSession{
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *typingSession) stop() {
|
||||||
|
s.once.Do(func() {
|
||||||
|
close(s.stopCh)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatrixChannel implements the Channel interface for Matrix.
|
||||||
|
type MatrixChannel struct {
|
||||||
|
*channels.BaseChannel
|
||||||
|
|
||||||
|
client *mautrix.Client
|
||||||
|
config config.MatrixConfig
|
||||||
|
syncer *mautrix.DefaultSyncer
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
startTime time.Time
|
||||||
|
|
||||||
|
typingMu sync.Mutex
|
||||||
|
typingSessions map[string]*typingSession // roomID -> session
|
||||||
|
|
||||||
|
roomKindCache sync.Map // roomID -> roomKindCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMatrixChannel(cfg config.MatrixConfig, messageBus *bus.MessageBus) (*MatrixChannel, error) {
|
||||||
|
homeserver := strings.TrimSpace(cfg.Homeserver)
|
||||||
|
userID := strings.TrimSpace(cfg.UserID)
|
||||||
|
accessToken := strings.TrimSpace(cfg.AccessToken)
|
||||||
|
if homeserver == "" {
|
||||||
|
return nil, fmt.Errorf("matrix homeserver is required")
|
||||||
|
}
|
||||||
|
if userID == "" {
|
||||||
|
return nil, fmt.Errorf("matrix user_id is required")
|
||||||
|
}
|
||||||
|
if accessToken == "" {
|
||||||
|
return nil, fmt.Errorf("matrix access_token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := mautrix.NewClient(homeserver, id.UserID(userID), accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create matrix client: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.DeviceID != "" {
|
||||||
|
client.DeviceID = id.DeviceID(cfg.DeviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncer, ok := client.Syncer.(*mautrix.DefaultSyncer)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("matrix syncer is not *mautrix.DefaultSyncer")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := channels.NewBaseChannel(
|
||||||
|
"matrix",
|
||||||
|
cfg,
|
||||||
|
messageBus,
|
||||||
|
cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(65536),
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &MatrixChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
client: client,
|
||||||
|
config: cfg,
|
||||||
|
syncer: syncer,
|
||||||
|
typingSessions: make(map[string]*typingSession),
|
||||||
|
startTime: time.Now(),
|
||||||
|
roomKindCache: sync.Map{},
|
||||||
|
typingMu: sync.Mutex{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoC("matrix", "Starting Matrix channel")
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
c.startTime = time.Now()
|
||||||
|
|
||||||
|
c.syncer.OnEventType(event.EventMessage, c.handleMessageEvent)
|
||||||
|
c.syncer.OnEventType(event.StateMember, c.handleMemberEvent)
|
||||||
|
|
||||||
|
c.SetRunning(true)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := c.client.SyncWithContext(c.ctx); err != nil && c.ctx.Err() == nil {
|
||||||
|
logger.ErrorCF("matrix", "Matrix sync stopped unexpectedly", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
logger.InfoC("matrix", "Matrix channel started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("matrix", "Stopping Matrix channel")
|
||||||
|
c.SetRunning(false)
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
c.stopTypingSessions(ctx)
|
||||||
|
|
||||||
|
logger.InfoC("matrix", "Matrix channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
roomID := id.RoomID(strings.TrimSpace(msg.ChatID))
|
||||||
|
if roomID == "" {
|
||||||
|
return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.TrimSpace(msg.Content)
|
||||||
|
if content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgText,
|
||||||
|
Body: content,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("matrix send: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMedia implements channels.MediaSender.
|
||||||
|
func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
sendCtx := ctx
|
||||||
|
if sendCtx == nil {
|
||||||
|
sendCtx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
roomID := id.RoomID(strings.TrimSpace(msg.ChatID))
|
||||||
|
if roomID == "" {
|
||||||
|
return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range msg.Parts {
|
||||||
|
if err := sendCtx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath, meta, err := store.ResolveWithMeta(part.Ref)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("matrix", "Failed to resolve media ref", map[string]any{
|
||||||
|
"ref": part.Ref,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo, err := os.Stat(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("matrix", "Failed to stat media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Open(localPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("matrix", "Failed to open media file", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := strings.TrimSpace(part.Filename)
|
||||||
|
if filename == "" {
|
||||||
|
filename = strings.TrimSpace(meta.Filename)
|
||||||
|
}
|
||||||
|
if filename == "" {
|
||||||
|
filename = filepath.Base(localPath)
|
||||||
|
}
|
||||||
|
if filename == "" {
|
||||||
|
filename = "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := strings.TrimSpace(part.ContentType)
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = strings.TrimSpace(meta.ContentType)
|
||||||
|
}
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
|
||||||
|
}
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadResp, err := c.client.UploadMedia(sendCtx, mautrix.ReqUploadMedia{
|
||||||
|
Content: file,
|
||||||
|
ContentLength: fileInfo.Size(),
|
||||||
|
ContentType: contentType,
|
||||||
|
FileName: filename,
|
||||||
|
})
|
||||||
|
file.Close()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("matrix", "Failed to upload media", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"type": part.Type,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("matrix upload media: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgType := matrixOutboundMsgType(part.Type, filename, contentType)
|
||||||
|
content := matrixOutboundContent(
|
||||||
|
part.Caption,
|
||||||
|
filename,
|
||||||
|
msgType,
|
||||||
|
contentType,
|
||||||
|
fileInfo.Size(),
|
||||||
|
uploadResp.ContentURI.CUString(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := c.client.SendMessageEvent(sendCtx, roomID, event.EventMessage, content); err != nil {
|
||||||
|
logger.ErrorCF("matrix", "Failed to send media message", map[string]any{
|
||||||
|
"room_id": roomID.String(),
|
||||||
|
"type": msgType,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return fmt.Errorf("matrix send media: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTyping implements channels.TypingCapable.
|
||||||
|
func (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
roomID := id.RoomID(strings.TrimSpace(chatID))
|
||||||
|
if roomID == "" {
|
||||||
|
return func() {}, fmt.Errorf("matrix room ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
session := newTypingSession()
|
||||||
|
|
||||||
|
c.typingMu.Lock()
|
||||||
|
if prev := c.typingSessions[chatID]; prev != nil {
|
||||||
|
prev.stop()
|
||||||
|
}
|
||||||
|
c.typingSessions[chatID] = session
|
||||||
|
c.typingMu.Unlock()
|
||||||
|
|
||||||
|
parent := c.baseContext()
|
||||||
|
go c.typingLoop(parent, roomID, session)
|
||||||
|
|
||||||
|
var once sync.Once
|
||||||
|
stop := func() {
|
||||||
|
once.Do(func() {
|
||||||
|
session.stop()
|
||||||
|
c.typingMu.Lock()
|
||||||
|
if current := c.typingSessions[chatID]; current == session {
|
||||||
|
delete(c.typingSessions, chatID)
|
||||||
|
}
|
||||||
|
c.typingMu.Unlock()
|
||||||
|
_, _ = c.client.UserTyping(context.Background(), roomID, false, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return stop, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
||||||
|
func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||||
|
if !c.config.Placeholder.Enabled {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
roomID := id.RoomID(strings.TrimSpace(chatID))
|
||||||
|
if roomID == "" {
|
||||||
|
return "", fmt.Errorf("matrix room ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
text := strings.TrimSpace(c.config.Placeholder.Text)
|
||||||
|
if text == "" {
|
||||||
|
text = "Thinking... 💭"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgNotice,
|
||||||
|
Body: text,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.EventID.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMessage implements channels.MessageEditor.
|
||||||
|
func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||||
|
roomID := id.RoomID(strings.TrimSpace(chatID))
|
||||||
|
if roomID == "" {
|
||||||
|
return fmt.Errorf("matrix room ID is empty")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(messageID) == "" {
|
||||||
|
return fmt.Errorf("matrix message ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
editContent := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgText,
|
||||||
|
Body: content,
|
||||||
|
}
|
||||||
|
editContent.SetEdit(id.EventID(messageID))
|
||||||
|
|
||||||
|
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) handleMemberEvent(ctx context.Context, evt *event.Event) {
|
||||||
|
if !c.config.JoinOnInvite {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if evt == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
member := evt.Content.AsMember()
|
||||||
|
if member.Membership != event.MembershipInvite {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if evt.GetStateKey() != c.client.UserID.String() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.client.JoinRoomByID(c.baseContext(), evt.RoomID)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("matrix", "Failed to auto-join invited room", map[string]any{
|
||||||
|
"room_id": evt.RoomID.String(),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("matrix", "Joined room after invite", map[string]any{
|
||||||
|
"room_id": evt.RoomID.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event) {
|
||||||
|
if evt == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore our own messages.
|
||||||
|
if evt.Sender == c.client.UserID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore historical events on first sync.
|
||||||
|
if time.UnixMilli(evt.Timestamp).Before(c.startTime) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msgEvt := evt.Content.AsMessage()
|
||||||
|
if msgEvt == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore edits.
|
||||||
|
if msgEvt.RelatesTo != nil && msgEvt.RelatesTo.GetReplaceID() != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roomID := evt.RoomID.String()
|
||||||
|
scope := channels.BuildMediaScope("matrix", roomID, evt.ID.String())
|
||||||
|
|
||||||
|
content, mediaPaths, ok := c.extractInboundContent(ctx, msgEvt, scope)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if content == "" && len(mediaPaths) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
senderID := evt.Sender.String()
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "matrix",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: identity.BuildCanonicalID("matrix", senderID),
|
||||||
|
Username: senderID,
|
||||||
|
DisplayName: senderID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
logger.DebugCF("matrix", "Message rejected by allowlist", map[string]any{
|
||||||
|
"sender_id": senderID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isGroup := c.isGroupRoom(ctx, evt.RoomID)
|
||||||
|
if isGroup {
|
||||||
|
isMentioned := c.isBotMentioned(msgEvt)
|
||||||
|
if isMentioned {
|
||||||
|
content = stripUserMention(content, c.client.UserID)
|
||||||
|
}
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||||
|
if !respond {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
|
} else {
|
||||||
|
content = stripUserMention(content, c.client.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if content == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
peerKind := "direct"
|
||||||
|
peerID := senderID
|
||||||
|
if isGroup {
|
||||||
|
peerKind = "group"
|
||||||
|
peerID = roomID
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"room_id": roomID,
|
||||||
|
"timestamp": fmt.Sprintf("%d", evt.Timestamp),
|
||||||
|
"is_group": fmt.Sprintf("%t", isGroup),
|
||||||
|
"sender_raw": senderID,
|
||||||
|
}
|
||||||
|
if replyTo := msgEvt.GetRelatesTo().GetReplyTo(); replyTo != "" {
|
||||||
|
metadata["reply_to_msg_id"] = replyTo.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(
|
||||||
|
c.baseContext(),
|
||||||
|
bus.Peer{Kind: peerKind, ID: peerID},
|
||||||
|
evt.ID.String(),
|
||||||
|
senderID,
|
||||||
|
roomID,
|
||||||
|
content,
|
||||||
|
mediaPaths,
|
||||||
|
metadata,
|
||||||
|
sender,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) extractInboundContent(
|
||||||
|
ctx context.Context,
|
||||||
|
msgEvt *event.MessageEventContent,
|
||||||
|
scope string,
|
||||||
|
) (string, []string, bool) {
|
||||||
|
switch msgEvt.MsgType {
|
||||||
|
case event.MsgText, event.MsgNotice:
|
||||||
|
return msgEvt.Body, nil, true
|
||||||
|
case event.MsgImage, event.MsgAudio, event.MsgVideo, event.MsgFile:
|
||||||
|
return c.extractInboundMedia(ctx, msgEvt, scope)
|
||||||
|
default:
|
||||||
|
logger.DebugCF("matrix", "Ignoring unsupported matrix msgtype", map[string]any{
|
||||||
|
"msgtype": msgEvt.MsgType,
|
||||||
|
})
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) extractInboundMedia(
|
||||||
|
ctx context.Context,
|
||||||
|
msgEvt *event.MessageEventContent,
|
||||||
|
scope string,
|
||||||
|
) (string, []string, bool) {
|
||||||
|
mediaKind := matrixMediaKind(msgEvt.MsgType)
|
||||||
|
label := matrixMediaLabel(msgEvt, mediaKind)
|
||||||
|
content := fmt.Sprintf("[%s: %s]", mediaKind, label)
|
||||||
|
if caption := strings.TrimSpace(msgEvt.GetCaption()); caption != "" {
|
||||||
|
content = caption + "\n" + content
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath, err := c.downloadMedia(ctx, msgEvt, mediaKind)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("matrix", "Failed to download media; forwarding as text-only marker", map[string]any{
|
||||||
|
"msgtype": msgEvt.MsgType,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return content, nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := matrixMediaFilename(label, mediaKind, matrixContentType(msgEvt))
|
||||||
|
ref := c.storeMedia(localPath, media.MediaMeta{
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: matrixContentType(msgEvt),
|
||||||
|
Source: "matrix",
|
||||||
|
}, scope)
|
||||||
|
return content, []string{ref}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) storeMedia(localPath string, meta media.MediaMeta, scope string) string {
|
||||||
|
if store := c.GetMediaStore(); store != nil {
|
||||||
|
ref, err := store.Store(localPath, meta, scope)
|
||||||
|
if err == nil {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
logger.WarnCF("matrix", "Failed to store media in MediaStore, falling back to local path", map[string]any{
|
||||||
|
"path": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return localPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) downloadMedia(
|
||||||
|
ctx context.Context,
|
||||||
|
msgEvt *event.MessageEventContent,
|
||||||
|
mediaKind string,
|
||||||
|
) (string, error) {
|
||||||
|
uri := matrixMediaURI(msgEvt)
|
||||||
|
if uri == "" {
|
||||||
|
return "", fmt.Errorf("empty matrix media URL")
|
||||||
|
}
|
||||||
|
parsed := uri.ParseOrIgnore()
|
||||||
|
if parsed.IsEmpty() {
|
||||||
|
return "", fmt.Errorf("invalid matrix media URL: %s", uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
dlCtx := c.baseContext()
|
||||||
|
if ctx != nil {
|
||||||
|
dlCtx = ctx
|
||||||
|
}
|
||||||
|
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
data, err := c.client.DownloadBytes(reqCtx, parsed)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypted attachments put URL in msgEvt.File and require client-side decryption.
|
||||||
|
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
|
||||||
|
if err := msgEvt.File.DecryptInPlace(data); err != nil {
|
||||||
|
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label := matrixMediaLabel(msgEvt, mediaKind)
|
||||||
|
ext := matrixMediaExt(label, matrixContentType(msgEvt), mediaKind)
|
||||||
|
tmp, err := os.CreateTemp("", "matrix-media-*"+ext)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer tmp.Close()
|
||||||
|
|
||||||
|
if _, err = tmp.Write(data); err != nil {
|
||||||
|
_ = os.Remove(tmp.Name())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmp.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matrixContentType(msgEvt *event.MessageEventContent) string {
|
||||||
|
if msgEvt != nil && msgEvt.Info != nil {
|
||||||
|
return strings.TrimSpace(msgEvt.Info.MimeType)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func matrixMediaURI(msgEvt *event.MessageEventContent) id.ContentURIString {
|
||||||
|
if msgEvt == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if msgEvt.URL != "" {
|
||||||
|
return msgEvt.URL
|
||||||
|
}
|
||||||
|
if msgEvt.File != nil {
|
||||||
|
return msgEvt.File.URL
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func matrixMediaKind(msgType event.MessageType) string {
|
||||||
|
switch msgType {
|
||||||
|
case event.MsgAudio:
|
||||||
|
return "audio"
|
||||||
|
case event.MsgVideo:
|
||||||
|
return "video"
|
||||||
|
case event.MsgFile:
|
||||||
|
return "file"
|
||||||
|
default:
|
||||||
|
return "image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matrixOutboundMsgType(partType, filename, contentType string) event.MessageType {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(partType)) {
|
||||||
|
case "image":
|
||||||
|
return event.MsgImage
|
||||||
|
case "audio", "voice":
|
||||||
|
return event.MsgAudio
|
||||||
|
case "video":
|
||||||
|
return event.MsgVideo
|
||||||
|
case "file", "document":
|
||||||
|
return event.MsgFile
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := strings.ToLower(strings.TrimSpace(contentType))
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(ct, "image/"):
|
||||||
|
return event.MsgImage
|
||||||
|
case strings.HasPrefix(ct, "audio/"), ct == "application/ogg", ct == "application/x-ogg":
|
||||||
|
return event.MsgAudio
|
||||||
|
case strings.HasPrefix(ct, "video/"):
|
||||||
|
return event.MsgVideo
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(strings.TrimSpace(filepath.Ext(filename))) {
|
||||||
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
||||||
|
return event.MsgImage
|
||||||
|
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
|
||||||
|
return event.MsgAudio
|
||||||
|
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
||||||
|
return event.MsgVideo
|
||||||
|
default:
|
||||||
|
return event.MsgFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matrixOutboundContent(
|
||||||
|
caption, filename string,
|
||||||
|
msgType event.MessageType,
|
||||||
|
contentType string,
|
||||||
|
size int64,
|
||||||
|
uri id.ContentURIString,
|
||||||
|
) *event.MessageEventContent {
|
||||||
|
body := strings.TrimSpace(caption)
|
||||||
|
if body == "" {
|
||||||
|
body = filename
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
body = matrixMediaKind(msgType)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &event.FileInfo{MimeType: strings.TrimSpace(contentType)}
|
||||||
|
if size > 0 && size <= int64(int(^uint(0)>>1)) {
|
||||||
|
info.Size = int(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := &event.MessageEventContent{
|
||||||
|
MsgType: msgType,
|
||||||
|
Body: body,
|
||||||
|
URL: uri,
|
||||||
|
FileName: filename,
|
||||||
|
Info: info,
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func matrixMediaLabel(msgEvt *event.MessageEventContent, fallback string) string {
|
||||||
|
if msgEvt == nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(msgEvt.FileName); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(msgEvt.Body); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func matrixMediaFilename(label, mediaKind, contentType string) string {
|
||||||
|
filename := strings.TrimSpace(label)
|
||||||
|
if filename == "" {
|
||||||
|
filename = mediaKind
|
||||||
|
}
|
||||||
|
if filepath.Ext(filename) == "" {
|
||||||
|
filename += matrixMediaExt("", contentType, mediaKind)
|
||||||
|
}
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func matrixMediaExt(filename, contentType, mediaKind string) string {
|
||||||
|
if ext := strings.TrimSpace(filepath.Ext(filename)); ext != "" {
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
if contentType != "" {
|
||||||
|
if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 {
|
||||||
|
return exts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch mediaKind {
|
||||||
|
case "audio":
|
||||||
|
return ".ogg"
|
||||||
|
case "video":
|
||||||
|
return ".mp4"
|
||||||
|
case "file":
|
||||||
|
return ".bin"
|
||||||
|
default:
|
||||||
|
return ".jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) isGroupRoom(ctx context.Context, roomID id.RoomID) bool {
|
||||||
|
now := time.Now()
|
||||||
|
if cached, ok := c.roomKindCache.Load(roomID.String()); ok {
|
||||||
|
entry := cached.(roomKindCacheEntry)
|
||||||
|
if now.Before(entry.expiresAt) {
|
||||||
|
return entry.isGroup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qctx := c.baseContext()
|
||||||
|
if ctx != nil {
|
||||||
|
qctx = ctx
|
||||||
|
}
|
||||||
|
reqCtx, cancel := context.WithTimeout(qctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := c.client.JoinedMembers(reqCtx, roomID)
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("matrix", "Failed to query room members; assume direct", map[string]any{
|
||||||
|
"room_id": roomID.String(),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
isGroup := len(resp.Joined) > 2
|
||||||
|
c.roomKindCache.Store(roomID.String(), roomKindCacheEntry{
|
||||||
|
isGroup: isGroup,
|
||||||
|
expiresAt: now.Add(roomKindCacheTTL),
|
||||||
|
})
|
||||||
|
return isGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) isBotMentioned(msgEvt *event.MessageEventContent) bool {
|
||||||
|
if msgEvt == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if msgEvt.Mentions != nil && msgEvt.Mentions.Has(c.client.UserID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.client.UserID.String()
|
||||||
|
if userID != "" && (strings.Contains(msgEvt.Body, userID) || strings.Contains(msgEvt.FormattedBody, userID)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
localpart := matrixLocalpart(c.client.UserID)
|
||||||
|
if localpart == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
re := localpartMentionRegexp(localpart)
|
||||||
|
return re.MatchString(msgEvt.Body) || re.MatchString(msgEvt.FormattedBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) typingLoop(ctx context.Context, roomID id.RoomID, session *typingSession) {
|
||||||
|
sendTyping := func() {
|
||||||
|
_, err := c.client.UserTyping(ctx, roomID, true, typingServerTTL)
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("matrix", "Failed to send typing status", map[string]any{
|
||||||
|
"room_id": roomID.String(),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTyping()
|
||||||
|
ticker := time.NewTicker(typingRefreshInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-session.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
sendTyping()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) stopTypingSessions(ctx context.Context) {
|
||||||
|
c.typingMu.Lock()
|
||||||
|
sessions := c.typingSessions
|
||||||
|
c.typingSessions = make(map[string]*typingSession)
|
||||||
|
c.typingMu.Unlock()
|
||||||
|
|
||||||
|
stopCtx := ctx
|
||||||
|
if stopCtx == nil {
|
||||||
|
stopCtx = context.Background()
|
||||||
|
}
|
||||||
|
for roomID, session := range sessions {
|
||||||
|
session.stop()
|
||||||
|
_, _ = c.client.UserTyping(stopCtx, id.RoomID(roomID), false, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MatrixChannel) baseContext() context.Context {
|
||||||
|
if c.ctx != nil {
|
||||||
|
return c.ctx
|
||||||
|
}
|
||||||
|
return context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
func matrixLocalpart(userID id.UserID) string {
|
||||||
|
s := strings.TrimPrefix(userID.String(), "@")
|
||||||
|
localpart, _, _ := strings.Cut(s, ":")
|
||||||
|
return strings.TrimSpace(localpart)
|
||||||
|
}
|
||||||
|
|
||||||
|
func localpartMentionRegexp(localpart string) *regexp.Regexp {
|
||||||
|
pattern := `(?i)(^|[^[:alnum:]_])@` + regexp.QuoteMeta(localpart) + `(?::[A-Za-z0-9._:-]+)?([^[:alnum:]_]|$)`
|
||||||
|
return regexp.MustCompile(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripUserMention(text string, userID id.UserID) string {
|
||||||
|
cleaned := strings.ReplaceAll(text, userID.String(), "")
|
||||||
|
|
||||||
|
localpart := matrixLocalpart(userID)
|
||||||
|
if localpart != "" {
|
||||||
|
re := localpartMentionRegexp(localpart)
|
||||||
|
cleaned = re.ReplaceAllString(cleaned, "$1$2")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned = strings.TrimSpace(cleaned)
|
||||||
|
cleaned = strings.TrimLeft(cleaned, ",:; ")
|
||||||
|
return strings.TrimSpace(cleaned)
|
||||||
|
}
|
||||||
218
pkg/channels/matrix/matrix_test.go
Normal file
218
pkg/channels/matrix/matrix_test.go
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
package matrix
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatrixLocalpartMentionRegexp(t *testing.T) {
|
||||||
|
re := localpartMentionRegexp("picoclaw")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{text: "@picoclaw hello", want: true},
|
||||||
|
{text: "hi @picoclaw:matrix.org", want: true},
|
||||||
|
{text: "欢迎一下picoclaw小龙虾", want: false}, // historical false-positive case in PR #356
|
||||||
|
{text: "mail test@example.com", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := re.MatchString(tc.text); got != tc.want {
|
||||||
|
t.Fatalf("text=%q match=%v want=%v", tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripUserMention(t *testing.T) {
|
||||||
|
userID := id.UserID("@picoclaw:matrix.org")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{in: "@picoclaw:matrix.org hello", want: "hello"},
|
||||||
|
{in: "@picoclaw, hello", want: "hello"},
|
||||||
|
{in: "no mention here", want: "no mention here"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := stripUserMention(tc.in, userID); got != tc.want {
|
||||||
|
t.Fatalf("stripUserMention(%q)=%q want=%q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsBotMentioned(t *testing.T) {
|
||||||
|
ch := &MatrixChannel{
|
||||||
|
client: &mautrix.Client{
|
||||||
|
UserID: id.UserID("@picoclaw:matrix.org"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg event.MessageEventContent
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "mentions field",
|
||||||
|
msg: event.MessageEventContent{
|
||||||
|
Body: "hello",
|
||||||
|
Mentions: &event.Mentions{
|
||||||
|
UserIDs: []id.UserID{id.UserID("@picoclaw:matrix.org")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full user id in body",
|
||||||
|
msg: event.MessageEventContent{
|
||||||
|
Body: "@picoclaw:matrix.org hello",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "localpart with at sign",
|
||||||
|
msg: event.MessageEventContent{
|
||||||
|
Body: "@picoclaw hello",
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "localpart without at sign should not match",
|
||||||
|
msg: event.MessageEventContent{
|
||||||
|
Body: "欢迎一下picoclaw小龙虾",
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := ch.isBotMentioned(&tc.msg); got != tc.want {
|
||||||
|
t.Fatalf("%s: got=%v want=%v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrixMediaExt(t *testing.T) {
|
||||||
|
if got := matrixMediaExt("photo.png", "", "image"); got != ".png" {
|
||||||
|
t.Fatalf("filename extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
if got := matrixMediaExt("", "image/webp", "image"); got != ".webp" {
|
||||||
|
t.Fatalf("content-type extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
if got := matrixMediaExt("", "", "image"); got != ".jpg" {
|
||||||
|
t.Fatalf("default image extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
if got := matrixMediaExt("", "", "audio"); got != ".ogg" {
|
||||||
|
t.Fatalf("default audio extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
if got := matrixMediaExt("", "", "video"); got != ".mp4" {
|
||||||
|
t.Fatalf("default video extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
if got := matrixMediaExt("", "", "file"); got != ".bin" {
|
||||||
|
t.Fatalf("default file extension mismatch: got=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
|
||||||
|
ch := &MatrixChannel{}
|
||||||
|
msg := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgImage,
|
||||||
|
Body: "test.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok for image fallback")
|
||||||
|
}
|
||||||
|
if content != "[image: test.png]" {
|
||||||
|
t.Fatalf("unexpected content: %q", content)
|
||||||
|
}
|
||||||
|
if len(mediaRefs) != 0 {
|
||||||
|
t.Fatalf("expected no media refs, got %d", len(mediaRefs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractInboundContent_AudioNoURLFallback(t *testing.T) {
|
||||||
|
ch := &MatrixChannel{}
|
||||||
|
msg := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgAudio,
|
||||||
|
FileName: "voice.ogg",
|
||||||
|
Body: "please transcribe",
|
||||||
|
}
|
||||||
|
|
||||||
|
content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok for audio fallback")
|
||||||
|
}
|
||||||
|
if content != "please transcribe\n[audio: voice.ogg]" {
|
||||||
|
t.Fatalf("unexpected content: %q", content)
|
||||||
|
}
|
||||||
|
if len(mediaRefs) != 0 {
|
||||||
|
t.Fatalf("expected no media refs, got %d", len(mediaRefs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrixOutboundMsgType(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
partType string
|
||||||
|
filename string
|
||||||
|
contentType string
|
||||||
|
want event.MessageType
|
||||||
|
}{
|
||||||
|
{name: "explicit image", partType: "image", want: event.MsgImage},
|
||||||
|
{name: "explicit audio", partType: "audio", want: event.MsgAudio},
|
||||||
|
{name: "mime fallback video", contentType: "video/mp4", want: event.MsgVideo},
|
||||||
|
{name: "extension fallback audio", filename: "voice.ogg", want: event.MsgAudio},
|
||||||
|
{name: "unknown defaults file", filename: "report.txt", want: event.MsgFile},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := matrixOutboundMsgType(tc.partType, tc.filename, tc.contentType); got != tc.want {
|
||||||
|
t.Fatalf("%s: got=%q want=%q", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatrixOutboundContent(t *testing.T) {
|
||||||
|
content := matrixOutboundContent(
|
||||||
|
"please review",
|
||||||
|
"voice.ogg",
|
||||||
|
event.MsgAudio,
|
||||||
|
"audio/ogg",
|
||||||
|
1234,
|
||||||
|
id.ContentURIString("mxc://matrix.org/abc"),
|
||||||
|
)
|
||||||
|
if content.Body != "please review" {
|
||||||
|
t.Fatalf("unexpected body: %q", content.Body)
|
||||||
|
}
|
||||||
|
if content.FileName != "voice.ogg" {
|
||||||
|
t.Fatalf("unexpected filename: %q", content.FileName)
|
||||||
|
}
|
||||||
|
if content.Info == nil || content.Info.MimeType != "audio/ogg" {
|
||||||
|
t.Fatalf("unexpected content type: %+v", content.Info)
|
||||||
|
}
|
||||||
|
if content.Info == nil || content.Info.Size != 1234 {
|
||||||
|
t.Fatalf("unexpected size: %+v", content.Info)
|
||||||
|
}
|
||||||
|
|
||||||
|
noCaption := matrixOutboundContent(
|
||||||
|
"",
|
||||||
|
"image.png",
|
||||||
|
event.MsgImage,
|
||||||
|
"image/png",
|
||||||
|
0,
|
||||||
|
id.ContentURIString("mxc://matrix.org/def"),
|
||||||
|
)
|
||||||
|
if noCaption.Body != "image.png" {
|
||||||
|
t.Fatalf("unexpected fallback body: %q", noCaption.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -225,6 +225,7 @@ type ChannelsConfig struct {
|
||||||
QQ QQConfig `json:"qq"`
|
QQ QQConfig `json:"qq"`
|
||||||
DingTalk DingTalkConfig `json:"dingtalk"`
|
DingTalk DingTalkConfig `json:"dingtalk"`
|
||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
|
Matrix MatrixConfig `json:"matrix"`
|
||||||
LINE LINEConfig `json:"line"`
|
LINE LINEConfig `json:"line"`
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
WeCom WeComConfig `json:"wecom"`
|
WeCom WeComConfig `json:"wecom"`
|
||||||
|
|
@ -333,6 +334,19 @@ type SlackConfig struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MatrixConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
|
||||||
|
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
|
||||||
|
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
|
||||||
|
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
|
||||||
|
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
|
||||||
|
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_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"`
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,9 @@ func TestDefaultConfig_Channels(t *testing.T) {
|
||||||
if cfg.Channels.Slack.Enabled {
|
if cfg.Channels.Slack.Enabled {
|
||||||
t.Error("Slack should be disabled by default")
|
t.Error("Slack should be disabled by default")
|
||||||
}
|
}
|
||||||
|
if cfg.Channels.Matrix.Enabled {
|
||||||
|
t.Error("Matrix should be disabled by default")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultConfig_WebTools verifies web tools config
|
// TestDefaultConfig_WebTools verifies web tools config
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,22 @@ func DefaultConfig() *Config {
|
||||||
AppToken: "",
|
AppToken: "",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
|
Matrix: MatrixConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Homeserver: "https://matrix.org",
|
||||||
|
UserID: "",
|
||||||
|
AccessToken: "",
|
||||||
|
DeviceID: "",
|
||||||
|
JoinOnInvite: true,
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
GroupTrigger: GroupTriggerConfig{
|
||||||
|
MentionOnly: true,
|
||||||
|
},
|
||||||
|
Placeholder: PlaceholderConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Text: "Thinking... 💭",
|
||||||
|
},
|
||||||
|
},
|
||||||
LINE: LINEConfig{
|
LINE: LINEConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
ChannelSecret: "",
|
ChannelSecret: "",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ var supportedChannels = map[string]bool{
|
||||||
"qq": true,
|
"qq": true,
|
||||||
"dingtalk": true,
|
"dingtalk": true,
|
||||||
"slack": true,
|
"slack": true,
|
||||||
|
"matrix": true,
|
||||||
"line": true,
|
"line": true,
|
||||||
"onebot": true,
|
"onebot": true,
|
||||||
"wecom": true,
|
"wecom": true,
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,8 @@ func (c *OpenClawConfig) IsChannelEnabled(name string) bool {
|
||||||
return c.Channels.Discord == nil || c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled
|
return c.Channels.Discord == nil || c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled
|
||||||
case "slack":
|
case "slack":
|
||||||
return c.Channels.Slack == nil || c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled
|
return c.Channels.Slack == nil || c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled
|
||||||
|
case "matrix":
|
||||||
|
return c.Channels.Matrix == nil || c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled
|
||||||
case "whatsapp":
|
case "whatsapp":
|
||||||
return c.Channels.WhatsApp == nil || c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled
|
return c.Channels.WhatsApp == nil || c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled
|
||||||
case "feishu":
|
case "feishu":
|
||||||
|
|
@ -397,6 +399,11 @@ func GetChannelAllowFrom(ch any) []string {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return c.AllowFrom
|
return c.AllowFrom
|
||||||
|
case *OpenClawMatrixConfig:
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.AllowFrom
|
||||||
case *OpenClawWhatsAppConfig:
|
case *OpenClawWhatsAppConfig:
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -627,6 +634,7 @@ type ChannelsConfig struct {
|
||||||
QQ QQConfig `json:"qq"`
|
QQ QQConfig `json:"qq"`
|
||||||
DingTalk DingTalkConfig `json:"dingtalk"`
|
DingTalk DingTalkConfig `json:"dingtalk"`
|
||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
|
Matrix MatrixConfig `json:"matrix"`
|
||||||
LINE LINEConfig `json:"line"`
|
LINE LINEConfig `json:"line"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -687,6 +695,14 @@ type SlackConfig struct {
|
||||||
AllowFrom []string `json:"allow_from"`
|
AllowFrom []string `json:"allow_from"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MatrixConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Homeserver string `json:"homeserver"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
AllowFrom []string `json:"allow_from"`
|
||||||
|
}
|
||||||
|
|
||||||
type LINEConfig struct {
|
type LINEConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
ChannelSecret string `json:"channel_secret"`
|
ChannelSecret string `json:"channel_secret"`
|
||||||
|
|
@ -862,12 +878,26 @@ func (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Channels.Matrix != nil && supportedChannels["matrix"] {
|
||||||
|
enabled := c.Channels.Matrix.Enabled == nil || *c.Channels.Matrix.Enabled
|
||||||
|
channels.Matrix = MatrixConfig{
|
||||||
|
Enabled: enabled,
|
||||||
|
AllowFrom: c.Channels.Matrix.AllowFrom,
|
||||||
|
}
|
||||||
|
if c.Channels.Matrix.Homeserver != nil {
|
||||||
|
channels.Matrix.Homeserver = *c.Channels.Matrix.Homeserver
|
||||||
|
}
|
||||||
|
if c.Channels.Matrix.UserID != nil {
|
||||||
|
channels.Matrix.UserID = *c.Channels.Matrix.UserID
|
||||||
|
}
|
||||||
|
if c.Channels.Matrix.AccessToken != nil {
|
||||||
|
channels.Matrix.AccessToken = *c.Channels.Matrix.AccessToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if c.Channels.Signal != nil {
|
if c.Channels.Signal != nil {
|
||||||
*warnings = append(*warnings, "Channel 'signal': No PicoClaw adapter available")
|
*warnings = append(*warnings, "Channel 'signal': No PicoClaw adapter available")
|
||||||
}
|
}
|
||||||
if c.Channels.Matrix != nil {
|
|
||||||
*warnings = append(*warnings, "Channel 'matrix': No PicoClaw adapter available")
|
|
||||||
}
|
|
||||||
if c.Channels.IRC != nil {
|
if c.Channels.IRC != nil {
|
||||||
*warnings = append(*warnings, "Channel 'irc': No PicoClaw adapter available")
|
*warnings = append(*warnings, "Channel 'irc': No PicoClaw adapter available")
|
||||||
}
|
}
|
||||||
|
|
@ -1020,6 +1050,14 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
|
||||||
BotToken: c.Slack.BotToken,
|
BotToken: c.Slack.BotToken,
|
||||||
AppToken: c.Slack.AppToken,
|
AppToken: c.Slack.AppToken,
|
||||||
},
|
},
|
||||||
|
Matrix: config.MatrixConfig{
|
||||||
|
Enabled: c.Matrix.Enabled,
|
||||||
|
Homeserver: c.Matrix.Homeserver,
|
||||||
|
UserID: c.Matrix.UserID,
|
||||||
|
AccessToken: c.Matrix.AccessToken,
|
||||||
|
AllowFrom: c.Matrix.AllowFrom,
|
||||||
|
JoinOnInvite: true,
|
||||||
|
},
|
||||||
LINE: config.LINEConfig{
|
LINE: config.LINEConfig{
|
||||||
Enabled: c.LINE.Enabled,
|
Enabled: c.LINE.Enabled,
|
||||||
ChannelSecret: c.LINE.ChannelSecret,
|
ChannelSecret: c.LINE.ChannelSecret,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -375,6 +376,96 @@ func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertToPicoClawWithMatrix(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
|
||||||
|
testConfig := `{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": true,
|
||||||
|
"homeserver": "https://matrix.example.com",
|
||||||
|
"userId": "@bot:matrix.example.com",
|
||||||
|
"accessToken": "syt_test_token",
|
||||||
|
"allowFrom": ["@alice:matrix.example.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadOpenClawConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
picoCfg, warnings, err := cfg.ConvertToPicoClaw("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to convert config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !picoCfg.Channels.Matrix.Enabled {
|
||||||
|
t.Error("matrix should be enabled")
|
||||||
|
}
|
||||||
|
if picoCfg.Channels.Matrix.Homeserver != "https://matrix.example.com" {
|
||||||
|
t.Errorf("expected matrix homeserver, got %q", picoCfg.Channels.Matrix.Homeserver)
|
||||||
|
}
|
||||||
|
if picoCfg.Channels.Matrix.UserID != "@bot:matrix.example.com" {
|
||||||
|
t.Errorf("expected matrix user_id, got %q", picoCfg.Channels.Matrix.UserID)
|
||||||
|
}
|
||||||
|
if picoCfg.Channels.Matrix.AccessToken != "syt_test_token" {
|
||||||
|
t.Errorf("expected matrix access_token, got %q", picoCfg.Channels.Matrix.AccessToken)
|
||||||
|
}
|
||||||
|
if len(picoCfg.Channels.Matrix.AllowFrom) != 1 ||
|
||||||
|
picoCfg.Channels.Matrix.AllowFrom[0] != "@alice:matrix.example.com" {
|
||||||
|
t.Errorf("unexpected matrix allow_from: %#v", picoCfg.Channels.Matrix.AllowFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, w := range warnings {
|
||||||
|
if strings.Contains(w, "Channel 'matrix'") {
|
||||||
|
t.Fatalf("matrix should no longer be reported as unsupported, warning=%q", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertToPicoClawWithMatrixDisabled(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, "openclaw.json")
|
||||||
|
|
||||||
|
testConfig := `{
|
||||||
|
"channels": {
|
||||||
|
"matrix": {
|
||||||
|
"enabled": false,
|
||||||
|
"homeserver": "https://matrix.example.com",
|
||||||
|
"userId": "@bot:matrix.example.com",
|
||||||
|
"accessToken": "syt_test_token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(testConfig), 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write test config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadOpenClawConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
picoCfg, _, err := cfg.ConvertToPicoClaw("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to convert config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if picoCfg.Channels.Matrix.Enabled {
|
||||||
|
t.Error("matrix should respect enabled=false from source config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenClawAgentModel(t *testing.T) {
|
func TestOpenClawAgentModel(t *testing.T) {
|
||||||
model := &OpenClawAgentModel{
|
model := &OpenClawAgentModel{
|
||||||
Primary: strPtr("anthropic/claude-3-opus"),
|
Primary: strPtr("anthropic/claude-3-opus"),
|
||||||
|
|
@ -425,6 +516,9 @@ func TestChannelEnabled(t *testing.T) {
|
||||||
if !cfg.IsChannelEnabled("slack") {
|
if !cfg.IsChannelEnabled("slack") {
|
||||||
t.Error("slack should be enabled (explicitly set)")
|
t.Error("slack should be enabled (explicitly set)")
|
||||||
}
|
}
|
||||||
|
if !cfg.IsChannelEnabled("matrix") {
|
||||||
|
t.Error("matrix should be enabled (nil config defaults to enabled)")
|
||||||
|
}
|
||||||
if cfg.IsChannelEnabled("line") {
|
if cfg.IsChannelEnabled("line") {
|
||||||
t.Error("line should return false (not in switch cases)")
|
t.Error("line should return false (not in switch cases)")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue