diff --git a/.env.example b/.env.example
index e899d2adc..e0a07236e 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,10 @@
# ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC...
# DISCORD_BOT_TOKEN=xxx
+# Feishu (飞书)
+# PICOCLAW_CHANNELS_FEISHU_APP_ID=cli_xxx
+# PICOCLAW_CHANNELS_FEISHU_APP_SECRET=xxx
+# PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI=Typing,OneSecond
# ── Web Search (optional) ────────────────
# BRAVE_SEARCH_API_KEY=BSA...
diff --git a/README.md b/README.md
index db127a85f..5cf9f6143 100644
--- a/README.md
+++ b/README.md
@@ -308,7 +308,7 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
-Talk to your picoclaw through Telegram, Discord, WhatsApp, 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.
@@ -317,6 +317,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
| **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
+| **Matrix** | Medium (homeserver + bot access token) |
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
@@ -528,6 +529,40 @@ picoclaw gateway
```
+
+Matrix
+
+**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).
+
+
+
LINE
@@ -952,6 +987,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
+| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
### Model Configuration (model_list)
@@ -979,11 +1015,12 @@ This design also enables **multi-agent support** with flexible provider selectio
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
-| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1 | OpenAI | Your LiteLLM proxy key |
+| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
diff --git a/README.zh.md b/README.zh.md
index d42b3cbb8..c744e0d20 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -299,6 +299,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/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) |
| **钉钉 (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) |
diff --git a/assets/wechat.png b/assets/wechat.png
index 32998c122..cc88186a8 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go
index 4947d6aea..8628afab3 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/app.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/app.go
@@ -423,7 +423,7 @@ func (s *appState) hasEnabledChannel() bool {
c := s.config.Channels
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.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()) {
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go
index 49a6ccc5d..16b7d053b 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/channel.go
@@ -61,6 +61,12 @@ func (s *appState) buildChannelMenuItems() []MenuItem {
s.config.Channels.Slack.Enabled,
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(
"LINE",
"LINE bot settings",
@@ -233,6 +239,28 @@ func (s *appState) lineForm() tview.Primitive {
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 {
cfg := &s.config.Channels.OneBot
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
diff --git a/cmd/picoclaw-launcher/README.md b/cmd/picoclaw-launcher/README.md
index a279eceee..d7985e09d 100644
--- a/cmd/picoclaw-launcher/README.md
+++ b/cmd/picoclaw-launcher/README.md
@@ -9,7 +9,7 @@ A standalone launcher for PicoClaw, providing visual JSON editing, OAuth provide
- 📝 **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
-- 📡 **Channel Configuration** — Form-based settings for 12+ channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, MagicForm, etc.) with documentation links
+- 📡 **Channel Configuration** — Form-based settings for 14+ channel types (Telegram, Discord, Slack, Matrix, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, MagicForm, IRC, etc.) with documentation links
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth with PKCE)
- 🚀 **Gateway Process Control** — Start, stop, and monitor the `picoclaw gateway` process with live log streaming
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
diff --git a/cmd/picoclaw-launcher/internal/ui/index.html b/cmd/picoclaw-launcher/internal/ui/index.html
index d84fd4e6e..e77ef4fea 100644
--- a/cmd/picoclaw-launcher/internal/ui/index.html
+++ b/cmd/picoclaw-launcher/internal/ui/index.html
@@ -538,6 +538,7 @@
+
@@ -606,6 +607,7 @@
+
@@ -1011,6 +1013,16 @@ const channelSchemas = {
{ 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: {
title: 'WeCom (Bot)', configKey: 'wecom', docSlug: 'wecom-bot',
fields: [
diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go
index b8a53a6f3..ee7369ba9 100644
--- a/cmd/picoclaw/internal/gateway/helpers.go
+++ b/cmd/picoclaw/internal/gateway/helpers.go
@@ -16,9 +16,11 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
+ _ "github.com/sipeed/picoclaw/pkg/channels/irc"
_ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/magicform"
_ "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/pico"
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
diff --git a/config/config.example.json b/config/config.example.json
index 3505844b2..7fb75b506 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -99,7 +99,8 @@
"encrypt_key": "",
"verification_token": "",
"allow_from": [],
- "reasoning_channel_id": ""
+ "reasoning_channel_id": "",
+ "random_reaction_emoji": []
},
"dingtalk": {
"enabled": false,
@@ -115,6 +116,23 @@
"allow_from": [],
"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": {
"enabled": false,
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
@@ -174,6 +192,28 @@
"webhook_path": "/hooks/magicform",
"workspace_root": "/data/workspaces",
"allow_from": []
+ },
+ "irc": {
+ "enabled": false,
+ "server": "irc.libera.chat:6697",
+ "tls": true,
+ "nick": "mybot",
+ "user": "",
+ "real_name": "",
+ "password": "",
+ "nickserv_password": "",
+ "sasl_user": "",
+ "sasl_password": "",
+ "channels": ["#mychannel"],
+ "request_caps": ["server-time", "message-tags"],
+ "allow_from": [],
+ "group_trigger": {
+ "mention_only": true
+ },
+ "typing": {
+ "enabled": false
+ },
+ "reasoning_channel_id": ""
}
},
"providers": {
diff --git a/docs/agent-refactor/README.md b/docs/agent-refactor/README.md
new file mode 100644
index 000000000..db8575fc9
--- /dev/null
+++ b/docs/agent-refactor/README.md
@@ -0,0 +1,145 @@
+# Agent Refactor
+
+## What this directory is for
+
+This directory is the working area for the current Agent refactor.
+
+The purpose of this refactor is simple:
+
+the project needs a smaller, clearer, and more stable Agent model before more Agent-related behavior is added.
+
+The codebase already contains meaningful Agent behavior. What it still lacks is a sufficiently explicit and stable semantic boundary around that behavior.
+
+This refactor exists to fix that first.
+
+---
+
+## Refactor stance
+
+This is a maintenance-led consolidation effort.
+
+It is not a general invitation to expand Agent behavior in parallel.
+
+During this refactor window, Agent-related work should converge on the current refactor track instead of branching into new semantics.
+
+That means:
+
+- concept clarification before feature expansion
+- boundary tightening before abstraction growth
+- semantic consolidation before new behavior
+
+---
+
+## Core rule: minimum concepts only
+
+This refactor follows one hard rule:
+
+**do not introduce a new concept unless it is strictly necessary**
+
+More explicitly:
+
+- if an existing concept can be clarified, reuse it
+- if an existing boundary can be made explicit, do that first
+- if a behavior can be expressed without a new abstraction, do not add one
+- "future flexibility" is not enough justification on its own
+
+The goal of this refactor is not to grow the model.
+
+The goal is to reduce ambiguity.
+
+---
+
+## What is being clarified
+
+This refactor is currently concerned with the following questions:
+
+1. what an `Agent` is
+2. what an `AgentLoop` is
+3. what the lifecycle of `AgentLoop` is
+4. what the event surface around `AgentLoop` is
+5. how persona / identity is assembled
+6. how capabilities are represented
+7. how context boundaries and compression work
+8. how subagent coordination works
+
+These are the current working boundaries.
+
+If they need to be adjusted, they should be adjusted explicitly rather than drift implicitly in code.
+
+---
+
+## Status of this directory
+
+The documents here are working materials.
+
+They are not final or immutable.
+
+If current notes are incomplete, incorrectly split, or too broad, they should be revised. This directory should evolve with the refactor rather than pretending the first draft is complete.
+
+---
+
+## Suggested document split
+
+This directory may eventually contain notes such as:
+
+- `agent-overview.md`
+ - what an Agent is
+- `agent-loop.md`
+ - AgentLoop contract, lifecycle, event surface
+- `persona.md`
+ - persona and identity assembly
+- `capability.md`
+ - tools / skills / MCP capability semantics
+- `context.md`
+ - context scope, history, summary, compression
+- `subagent.md`
+ - subagent coordination rules
+
+These files should be added only when they help clarify the current refactor work.
+
+This directory should not turn into a generic architecture dump.
+
+---
+
+## What this directory is not for
+
+This directory is not intended for:
+
+- broad speculative architecture
+- future multi-node protocol design not required by the current refactor
+- parallel feature planning unrelated to Agent consolidation
+- adding new concepts before current ones are made clear
+
+If a topic does not directly help reduce ambiguity in the current Agent model, it probably does not belong here yet.
+
+---
+
+## Relationship to implementation
+
+Implementation changes should not keep redefining Agent semantics implicitly.
+
+If a PR changes or depends on Agent semantics, those semantics should either already exist here or be clarified in a linked issue first.
+
+This directory is here to make implementation narrower and more disciplined.
+
+---
+
+## Relationship to GitHub tracking
+
+The umbrella issue for this refactor should point here.
+
+The issue is the coordination surface.
+
+This directory is the repository-local working surface.
+
+---
+
+## Summary
+
+The main question of this refactor is not:
+
+- what more can Agent do
+
+The main question is:
+
+- what is the smallest stable model that current Agent behavior can be organized around
diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md
index 310827723..3fafffb7d 100644
--- a/docs/channels/feishu/README.zh.md
+++ b/docs/channels/feishu/README.zh.md
@@ -26,7 +26,8 @@
| app_secret | string | 是 | 飞书应用的 App Secret |
| encrypt_key | string | 否 | 事件回调加密密钥 |
| verification_token | string | 否 | 用于Webhook事件验证的Token |
-| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+| allow_from | array | 否 | 用户ID白名单,空表示所有用户 |
+| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
## 设置流程
@@ -35,3 +36,4 @@
3. 配置事件订阅和Webhook URL
4. 设置加密(可选,生产环境建议启用)
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
+6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))
diff --git a/docs/channels/matrix/README.md b/docs/channels/matrix/README.md
new file mode 100644
index 000000000..c213aa80b
--- /dev/null
+++ b/docs/channels/matrix/README.md
@@ -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)
diff --git a/docs/channels/matrix/README.zh.md b/docs/channels/matrix/README.zh.md
new file mode 100644
index 000000000..efbc13093
--- /dev/null
+++ b/docs/channels/matrix/README.zh.md
@@ -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)
diff --git a/go.mod b/go.mod
index 6fa3a900c..f60be046f 100644
--- a/go.mod
+++ b/go.mod
@@ -8,13 +8,14 @@ require (
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.3.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/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1
- github.com/modelcontextprotocol/go-sdk v1.3.0
+ github.com/modelcontextprotocol/go-sdk v1.3.1
github.com/mymmrac/telego v1.6.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
@@ -27,11 +28,12 @@ require (
golang.org/x/oauth2 v0.35.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.11
+ maunium.net/go/mautrix v0.26.3
modernc.org/sqlite v1.46.1
)
require (
- filippo.io/edwards25519 v1.1.0 // indirect
+ filippo.io/edwards25519 v1.1.1 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -48,6 +50,8 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.34.0 // indirect
+ github.com/segmentio/asm v1.1.3 // indirect
+ github.com/segmentio/encoding v0.5.3 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
go.mau.fi/libsignal v0.2.1 // indirect
@@ -86,7 +90,7 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
- golang.org/x/net v0.50.0 // indirect
+ golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
)
diff --git a/go.sum b/go.sum
index 060594d06..4060997f8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,6 @@
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
-filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
-filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
+filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
@@ -48,6 +48,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
+github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
+github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
@@ -134,8 +136,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
-github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
-github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
+github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
+github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw=
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -171,6 +173,10 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
+github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
+github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
@@ -265,6 +271,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
+golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
+golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
@@ -355,6 +363,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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 8a57c6169..c516f6d8d 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -239,7 +239,7 @@ func registerSharedTools(
// Spawn tool with allowlist checker
if cfg.Tools.IsToolEnabled("spawn") {
if cfg.Tools.IsToolEnabled("subagent") {
- subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
+ subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
spawnTool := tools.NewSpawnTool(subagentManager)
currentAgentID := agentID
@@ -797,9 +797,8 @@ func (al *AgentLoop) runAgentLoop(
agent *AgentInstance,
opts processOptions,
) (string, *bus.ResponseMetrics, error) {
- // 0. Record last channel for heartbeat notifications (skip internal channels)
+ // 0. Record last channel for heartbeat notifications (skip internal channels and cli)
if opts.Channel != "" && opts.ChatID != "" {
- // Don't record internal channels (cli, system, subagent)
if !constants.IsInternalChannel(opts.Channel) {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil {
@@ -1269,9 +1268,12 @@ func (al *AgentLoop) runLLMIteration(
metrics.ToolCalls += len(response.ToolCalls)
metrics.Model = activeModel
- // Check if no tool calls - we're done
+ // Check if no tool calls - then check reasoning content if any
if len(response.ToolCalls) == 0 {
finalContent = response.Content
+ if finalContent == "" && response.ReasoningContent != "" {
+ finalContent = response.ReasoningContent
+ }
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
map[string]any{
"agent_id": agent.ID,
@@ -1393,15 +1395,47 @@ func (al *AgentLoop) runLLMIteration(
}
}
- // Create async callback for tools that implement AsyncExecutor
- asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) {
+ // Create async callback for tools that implement AsyncExecutor.
+ // When the background work completes, this publishes the result
+ // as an inbound system message so processSystemMessage routes it
+ // back to the user via the normal agent loop.
+ asyncCallback := func(_ context.Context, result *tools.ToolResult) {
+ // Send ForUser content directly to the user (immediate feedback),
+ // mirroring the synchronous tool execution path.
if !result.Silent && result.ForUser != "" {
- logger.InfoCF("agent", "Async tool completed, agent will handle notification",
- map[string]any{
- "tool": tc.Name,
- "content_len": len(result.ForUser),
- })
+ outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer outCancel()
+ _ = al.bus.PublishOutbound(outCtx, bus.OutboundMessage{
+ Channel: opts.Channel,
+ ChatID: opts.ChatID,
+ Content: result.ForUser,
+ })
}
+
+ // Determine content for the agent loop (ForLLM or error).
+ content := result.ForLLM
+ if content == "" && result.Err != nil {
+ content = result.Err.Error()
+ }
+ if content == "" {
+ return
+ }
+
+ logger.InfoCF("agent", "Async tool completed, publishing result",
+ map[string]any{
+ "tool": tc.Name,
+ "content_len": len(content),
+ "channel": opts.Channel,
+ })
+
+ pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer pubCancel()
+ _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{
+ Channel: "system",
+ SenderID: fmt.Sprintf("async:%s", tc.Name),
+ ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID),
+ Content: content,
+ })
}
toolResult := agent.Tools.ExecuteWithContext(
diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go
index 00f73064d..5217dd4e9 100644
--- a/pkg/channels/feishu/feishu_64.go
+++ b/pkg/channels/feishu/feishu_64.go
@@ -4,9 +4,11 @@ package feishu
import (
"context"
+ "crypto/rand"
"encoding/json"
"fmt"
"io"
+ "math/big"
"net/http"
"os"
"path/filepath"
@@ -195,18 +197,35 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
}
// ReactToMessage implements channels.ReactionCapable.
-// Adds an "Pin" reaction and returns an undo function to remove it.
+// Adds a reaction (randomly chosen from config) and returns an undo function to remove it.
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
+ // Get emoji list from config
+ emojiList := c.config.RandomReactionEmoji
+ if len(emojiList) == 0 {
+ // Default to "Pin" if no config
+ emojiList = []string{"Pin"}
+ }
+
+ // Randomly choose one from the list using crypto/rand for better distribution
+ idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(emojiList))))
+ var chosenEmoji string
+ if err != nil {
+ chosenEmoji = emojiList[0]
+ } else {
+ chosenEmoji = emojiList[idx.Int64()]
+ }
+
req := larkim.NewCreateMessageReactionReqBuilder().
MessageId(messageID).
Body(larkim.NewCreateMessageReactionReqBodyBuilder().
- ReactionType(larkim.NewEmojiBuilder().EmojiType("Pin").Build()).
+ ReactionType(larkim.NewEmojiBuilder().EmojiType(chosenEmoji).Build()).
Build()).
Build()
resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req)
if err != nil {
logger.ErrorCF("feishu", "Failed to add reaction", map[string]any{
+ "emoji": chosenEmoji,
"message_id": messageID,
"error": err.Error(),
})
@@ -214,6 +233,7 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st
}
if !resp.Success() {
logger.ErrorCF("feishu", "Reaction API error", map[string]any{
+ "emoji": chosenEmoji,
"message_id": messageID,
"code": resp.Code,
"msg": resp.Msg,
diff --git a/pkg/channels/irc/handler.go b/pkg/channels/irc/handler.go
new file mode 100644
index 000000000..aca4ddd11
--- /dev/null
+++ b/pkg/channels/irc/handler.go
@@ -0,0 +1,154 @@
+package irc
+
+import (
+ "fmt"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/ergochat/irc-go/ircevent"
+ "github.com/ergochat/irc-go/ircmsg"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/identity"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// onConnect is called after a successful connection (and on reconnect).
+func (c *IRCChannel) onConnect(conn *ircevent.Connection) {
+ // NickServ auth (only if SASL is not configured)
+ if c.config.NickServPassword != "" && c.config.SASLUser == "" {
+ conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword)
+ }
+
+ // Join configured channels
+ for _, ch := range c.config.Channels {
+ conn.Join(ch)
+ logger.InfoCF("irc", "Joined IRC channel", map[string]any{
+ "channel": ch,
+ })
+ }
+}
+
+// onPrivmsg handles incoming PRIVMSG events.
+func (c *IRCChannel) onPrivmsg(conn *ircevent.Connection, e ircmsg.Message) {
+ if len(e.Params) < 2 {
+ return
+ }
+
+ nick := e.Nick()
+ currentNick := conn.CurrentNick()
+
+ // Ignore own messages
+ if strings.EqualFold(nick, currentNick) {
+ return
+ }
+
+ target := e.Params[0] // channel name or bot's nick
+ content := e.Params[1] // message text
+
+ // Determine if this is a DM or channel message
+ isDM := !strings.HasPrefix(target, "#") && !strings.HasPrefix(target, "&")
+
+ var chatID string
+ var peer bus.Peer
+
+ if isDM {
+ chatID = nick
+ peer = bus.Peer{Kind: "direct", ID: nick}
+ } else {
+ chatID = target
+ peer = bus.Peer{Kind: "group", ID: target}
+ }
+
+ sender := bus.SenderInfo{
+ Platform: "irc",
+ PlatformID: nick,
+ CanonicalID: identity.BuildCanonicalID("irc", nick),
+ Username: nick,
+ DisplayName: nick,
+ }
+
+ if !c.IsAllowedSender(sender) {
+ return
+ }
+
+ // For channel messages, check group trigger (mention detection)
+ if !isDM {
+ isMentioned := isBotMentioned(content, currentNick)
+ if isMentioned {
+ content = stripBotMention(content, currentNick)
+ }
+ respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
+ if !respond {
+ return
+ }
+ content = cleaned
+ }
+
+ if strings.TrimSpace(content) == "" {
+ return
+ }
+
+ messageID := fmt.Sprintf("%s-%d", nick, time.Now().UnixNano())
+
+ metadata := map[string]string{
+ "platform": "irc",
+ "server": c.config.Server,
+ }
+ if !isDM {
+ metadata["channel"] = target
+ }
+
+ c.HandleMessage(c.ctx, peer, messageID, nick, chatID, content, nil, metadata, sender)
+}
+
+// nickMentionedAt returns the byte index where botNick is mentioned in content
+// with word-boundary checks, or -1 if not found. Also checks for "nick:" /
+// "nick," prefix convention.
+func nickMentionedAt(content, botNick string) int {
+ lower := strings.ToLower(content)
+ lowerNick := strings.ToLower(botNick)
+
+ // "nick:" or "nick," at start (most common IRC convention)
+ if strings.HasPrefix(lower, lowerNick+":") || strings.HasPrefix(lower, lowerNick+",") {
+ return 0
+ }
+
+ // Word-boundary match anywhere in the message
+ idx := strings.Index(lower, lowerNick)
+ if idx < 0 {
+ return -1
+ }
+ runes := []rune(lower)
+ nickRunes := []rune(lowerNick)
+ endIdx := idx + len(string(nickRunes))
+ before := idx == 0 || !unicode.IsLetter(runes[idx-1]) && !unicode.IsDigit(runes[idx-1])
+ after := endIdx >= len(lower) || !unicode.IsLetter(rune(lower[endIdx])) && !unicode.IsDigit(rune(lower[endIdx]))
+ if before && after {
+ return idx
+ }
+ return -1
+}
+
+// isBotMentioned checks if the bot's nick appears in the message.
+func isBotMentioned(content, botNick string) bool {
+ return nickMentionedAt(content, botNick) >= 0
+}
+
+// stripBotMention removes "nick: " or "nick, " prefix from content.
+func stripBotMention(content, botNick string) string {
+ idx := nickMentionedAt(content, botNick)
+ if idx != 0 {
+ return content
+ }
+ lowerNick := strings.ToLower(botNick)
+ lower := strings.ToLower(content)
+ for _, sep := range []string{":", ","} {
+ prefix := lowerNick + sep
+ if strings.HasPrefix(lower, prefix) {
+ return strings.TrimSpace(content[len(prefix):])
+ }
+ }
+ return content
+}
diff --git a/pkg/channels/irc/init.go b/pkg/channels/irc/init.go
new file mode 100644
index 000000000..221d41b62
--- /dev/null
+++ b/pkg/channels/irc/init.go
@@ -0,0 +1,16 @@
+package irc
+
+import (
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func init() {
+ channels.RegisterFactory("irc", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
+ if !cfg.Channels.IRC.Enabled {
+ return nil, nil
+ }
+ return NewIRCChannel(cfg.Channels.IRC, b)
+ })
+}
diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go
new file mode 100644
index 000000000..28c59b540
--- /dev/null
+++ b/pkg/channels/irc/irc.go
@@ -0,0 +1,194 @@
+package irc
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "strings"
+
+ "github.com/ergochat/irc-go/ircevent"
+ "github.com/ergochat/irc-go/ircmsg"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// IRCChannel implements the Channel interface for IRC servers.
+type IRCChannel struct {
+ *channels.BaseChannel
+ config config.IRCConfig
+ conn *ircevent.Connection
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// NewIRCChannel creates a new IRC channel.
+func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) {
+ if cfg.Server == "" {
+ return nil, fmt.Errorf("irc server is required")
+ }
+ if cfg.Nick == "" {
+ return nil, fmt.Errorf("irc nick is required")
+ }
+
+ base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom,
+ channels.WithMaxMessageLength(400),
+ channels.WithGroupTrigger(cfg.GroupTrigger),
+ channels.WithReasoningChannelID(cfg.ReasoningChannelID),
+ )
+
+ return &IRCChannel{
+ BaseChannel: base,
+ config: cfg,
+ }, nil
+}
+
+// Start connects to the IRC server and begins listening.
+func (c *IRCChannel) Start(ctx context.Context) error {
+ logger.InfoC("irc", "Starting IRC channel")
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ user := c.config.User
+ if user == "" {
+ user = c.config.Nick
+ }
+ realName := c.config.RealName
+ if realName == "" {
+ realName = c.config.Nick
+ }
+ caps := []string(c.config.RequestCaps)
+ if len(caps) == 0 {
+ caps = []string{"server-time", "message-tags"}
+ }
+
+ conn := &ircevent.Connection{
+ Server: c.config.Server,
+ Nick: c.config.Nick,
+ User: user,
+ RealName: realName,
+ Password: c.config.Password,
+ UseTLS: c.config.TLS,
+ RequestCaps: caps,
+ QuitMessage: "Goodbye",
+ Debug: false,
+ Log: nil,
+ }
+
+ if c.config.TLS {
+ conn.TLSConfig = &tls.Config{
+ ServerName: extractHost(c.config.Server),
+ }
+ }
+
+ // SASL auth (takes priority over NickServ)
+ if c.config.SASLUser != "" && c.config.SASLPassword != "" {
+ conn.SASLLogin = c.config.SASLUser
+ conn.SASLPassword = c.config.SASLPassword
+ }
+
+ // Register event handlers
+ conn.AddConnectCallback(func(e ircmsg.Message) {
+ c.onConnect(conn)
+ })
+ conn.AddCallback("PRIVMSG", func(e ircmsg.Message) {
+ c.onPrivmsg(conn, e)
+ })
+
+ if err := conn.Connect(); err != nil {
+ return fmt.Errorf("irc connect failed: %w", err)
+ }
+
+ c.conn = conn
+
+ // ircevent.Connection.Loop() handles reconnection internally.
+ go conn.Loop()
+
+ c.SetRunning(true)
+ logger.InfoCF("irc", "IRC channel started", map[string]any{
+ "server": c.config.Server,
+ "nick": c.config.Nick,
+ })
+ return nil
+}
+
+// Stop disconnects from the IRC server.
+func (c *IRCChannel) Stop(ctx context.Context) error {
+ logger.InfoC("irc", "Stopping IRC channel")
+ c.SetRunning(false)
+
+ if c.conn != nil {
+ c.conn.Quit()
+ }
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ logger.InfoC("irc", "IRC channel stopped")
+ return nil
+}
+
+// Send sends a message to an IRC channel or user.
+func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return channels.ErrNotRunning
+ }
+
+ target := msg.ChatID
+ if target == "" {
+ return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
+ }
+
+ if strings.TrimSpace(msg.Content) == "" {
+ return nil
+ }
+
+ // Send each line separately (IRC is line-oriented)
+ lines := strings.Split(msg.Content, "\n")
+ for _, line := range lines {
+ line = strings.TrimRight(line, "\r")
+ if line == "" {
+ continue
+ }
+ c.conn.Privmsg(target, line)
+ }
+
+ logger.DebugCF("irc", "Message sent", map[string]any{
+ "target": target,
+ "lines": len(lines),
+ })
+ return nil
+}
+
+// StartTyping implements channels.TypingCapable using IRCv3 +typing client tag.
+// Requires typing.enabled in config and server support for message-tags capability.
+func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
+ noop := func() {}
+
+ if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil {
+ return noop, nil
+ }
+
+ // Check if server supports message-tags (required for TAGMSG)
+ if _, ok := c.conn.AcknowledgedCaps()["message-tags"]; !ok {
+ return noop, nil
+ }
+
+ c.conn.SendWithTags(map[string]string{"+typing": "active"}, "TAGMSG", chatID)
+
+ return func() {
+ if c.IsRunning() && c.conn != nil {
+ c.conn.SendWithTags(map[string]string{"+typing": "done"}, "TAGMSG", chatID)
+ }
+ }, nil
+}
+
+// extractHost returns the hostname portion of a host:port string.
+func extractHost(server string) string {
+ host, _, found := strings.Cut(server, ":")
+ if found {
+ return host
+ }
+ return server
+}
diff --git a/pkg/channels/irc/irc_test.go b/pkg/channels/irc/irc_test.go
new file mode 100644
index 000000000..168252a4d
--- /dev/null
+++ b/pkg/channels/irc/irc_test.go
@@ -0,0 +1,145 @@
+package irc
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestNewIRCChannel(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+
+ t.Run("missing server", func(t *testing.T) {
+ cfg := config.IRCConfig{Nick: "bot"}
+ _, err := NewIRCChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing server, got nil")
+ }
+ })
+
+ t.Run("missing nick", func(t *testing.T) {
+ cfg := config.IRCConfig{Server: "irc.example.com:6667"}
+ _, err := NewIRCChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing nick, got nil")
+ }
+ })
+
+ t.Run("valid config", func(t *testing.T) {
+ cfg := config.IRCConfig{
+ Server: "irc.example.com:6667",
+ Nick: "testbot",
+ Channels: []string{"#test"},
+ }
+ ch, err := NewIRCChannel(cfg, msgBus)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ch.Name() != "irc" {
+ t.Errorf("Name() = %q, want %q", ch.Name(), "irc")
+ }
+ if ch.IsRunning() {
+ t.Error("new channel should not be running")
+ }
+ })
+}
+
+func TestExtractHost(t *testing.T) {
+ tests := []struct {
+ server string
+ want string
+ }{
+ {"irc.libera.chat:6697", "irc.libera.chat"},
+ {"localhost:6667", "localhost"},
+ {"irc.example.com", "irc.example.com"},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.server, func(t *testing.T) {
+ got := extractHost(tt.server)
+ if got != tt.want {
+ t.Errorf("extractHost(%q) = %q, want %q", tt.server, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestNickMentionedAt(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ nick string
+ want int
+ }{
+ {"colon prefix", "bot: hello", "bot", 0},
+ {"comma prefix", "bot, hello", "bot", 0},
+ {"case insensitive", "BOT: hello", "bot", 0},
+ {"word boundary mid", "hey bot what's up", "bot", 4},
+ {"no mention", "hello world", "bot", -1},
+ {"substring mismatch", "robotics are cool", "bot", -1},
+ {"nick at end", "hello bot", "bot", 6},
+ {"empty content", "", "bot", -1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := nickMentionedAt(tt.content, tt.nick)
+ if got != tt.want {
+ t.Errorf("nickMentionedAt(%q, %q) = %d, want %d", tt.content, tt.nick, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestIsBotMentioned(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ nick string
+ want bool
+ }{
+ {"colon prefix", "bot: hello", "bot", true},
+ {"comma prefix", "bot, hello", "bot", true},
+ {"case insensitive", "BOT: hello", "bot", true},
+ {"word boundary mid", "hey bot what's up", "bot", true},
+ {"no mention", "hello world", "bot", false},
+ {"substring mismatch", "robotics are cool", "bot", false},
+ {"nick at end", "hello bot", "bot", true},
+ {"empty content", "", "bot", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := isBotMentioned(tt.content, tt.nick)
+ if got != tt.want {
+ t.Errorf("isBotMentioned(%q, %q) = %v, want %v", tt.content, tt.nick, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestStripBotMention(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ nick string
+ want string
+ }{
+ {"colon prefix", "bot: hello there", "bot", "hello there"},
+ {"comma prefix", "bot, help me", "bot", "help me"},
+ {"case insensitive", "BOT: hello", "bot", "hello"},
+ {"no prefix match", "hello bot", "bot", "hello bot"},
+ {"only prefix", "bot:", "bot", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := stripBotMention(tt.content, tt.nick)
+ if got != tt.want {
+ t.Errorf("stripBotMention(%q, %q) = %q, want %q", tt.content, tt.nick, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 8801867ce..5406cb43b 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -61,7 +61,9 @@ var channelRateConfig = map[string]float64{
"telegram": 20,
"discord": 1,
"slack": 1,
+ "matrix": 2,
"line": 10,
+ "irc": 2,
}
type channelWorker struct {
@@ -243,6 +245,13 @@ func (m *Manager) initChannels() error {
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 != "" {
m.initChannel("line", "LINE")
}
@@ -271,6 +280,10 @@ func (m *Manager) initChannels() error {
m.initChannel("magicform", "MagicForm")
}
+ if m.config.Channels.IRC.Enabled && m.config.Channels.IRC.Server != "" {
+ m.initChannel("irc", "IRC")
+ }
+
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})
diff --git a/pkg/channels/matrix/init.go b/pkg/channels/matrix/init.go
new file mode 100644
index 000000000..6677f855e
--- /dev/null
+++ b/pkg/channels/matrix/init.go
@@ -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)
+ })
+}
diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go
new file mode 100644
index 000000000..d51eee8fb
--- /dev/null
+++ b/pkg/channels/matrix/matrix.go
@@ -0,0 +1,1115 @@
+package matrix
+
+import (
+ "context"
+ "fmt"
+ "html"
+ "mime"
+ "net/url"
+ "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
+ roomKindCacheCleanupPeriod = 1 * time.Minute
+ roomKindCacheMaxEntries = 2048
+
+ matrixMediaTempDirName = "picoclaw_media"
+)
+
+var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`)
+
+type roomKindCacheEntry struct {
+ isGroup bool
+ expiresAt time.Time
+ touchedAt time.Time
+}
+
+type roomKindCache struct {
+ mu sync.Mutex
+ entries map[string]roomKindCacheEntry
+ maxEntries int
+ ttl time.Duration
+}
+
+func newRoomKindCache(maxEntries int, ttl time.Duration) *roomKindCache {
+ if maxEntries <= 0 {
+ maxEntries = roomKindCacheMaxEntries
+ }
+ if ttl <= 0 {
+ ttl = roomKindCacheTTL
+ }
+
+ return &roomKindCache{
+ entries: make(map[string]roomKindCacheEntry),
+ maxEntries: maxEntries,
+ ttl: ttl,
+ }
+}
+
+func (c *roomKindCache) get(roomID string, now time.Time) (bool, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ entry, ok := c.entries[roomID]
+ if !ok {
+ return false, false
+ }
+ if !entry.expiresAt.After(now) {
+ delete(c.entries, roomID)
+ return false, false
+ }
+
+ return entry.isGroup, true
+}
+
+func (c *roomKindCache) set(roomID string, isGroup bool, now time.Time) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if entry, ok := c.entries[roomID]; ok {
+ entry.isGroup = isGroup
+ entry.expiresAt = now.Add(c.ttl)
+ entry.touchedAt = now
+ c.entries[roomID] = entry
+ return
+ }
+
+ c.cleanupExpiredLocked(now)
+ for len(c.entries) >= c.maxEntries {
+ if !c.evictOldestLocked() {
+ break
+ }
+ }
+
+ c.entries[roomID] = roomKindCacheEntry{
+ isGroup: isGroup,
+ expiresAt: now.Add(c.ttl),
+ touchedAt: now,
+ }
+}
+
+func (c *roomKindCache) cleanupExpired(now time.Time) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.cleanupExpiredLocked(now)
+}
+
+func (c *roomKindCache) cleanupExpiredLocked(now time.Time) int {
+ removed := 0
+ for roomID, entry := range c.entries {
+ if !entry.expiresAt.After(now) {
+ delete(c.entries, roomID)
+ removed++
+ }
+ }
+ return removed
+}
+
+func (c *roomKindCache) evictOldestLocked() bool {
+ if len(c.entries) == 0 {
+ return false
+ }
+
+ var (
+ oldestRoomID string
+ oldestAt time.Time
+ )
+
+ for roomID, entry := range c.entries {
+ if oldestRoomID == "" || entry.touchedAt.Before(oldestAt) {
+ oldestRoomID = roomID
+ oldestAt = entry.touchedAt
+ }
+ }
+
+ delete(c.entries, oldestRoomID)
+ return true
+}
+
+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 *roomKindCache
+ localpartMentionR *regexp.Regexp
+}
+
+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: newRoomKindCache(roomKindCacheMaxEntries, roomKindCacheTTL),
+ localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)),
+ 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 c.runRoomKindCacheJanitor(c.ctx)
+
+ 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 = c.stripSelfMention(content)
+ }
+ respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
+ if !respond {
+ logger.DebugCF("matrix", "Ignoring group message by trigger rules", map[string]any{
+ "room_id": roomID,
+ "is_mentioned": isMentioned,
+ "mention_only": c.config.GroupTrigger.MentionOnly,
+ "prefixes": c.config.GroupTrigger.Prefixes,
+ })
+ return
+ }
+ content = cleaned
+ } else {
+ content = c.stripSelfMention(content)
+ }
+
+ 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 == "" {
+ err = msgEvt.File.DecryptInPlace(data)
+ if err != nil {
+ return "", fmt.Errorf("decrypt matrix media: %w", err)
+ }
+ }
+
+ label := matrixMediaLabel(msgEvt, mediaKind)
+ ext := matrixMediaExt(label, matrixContentType(msgEvt), mediaKind)
+ mediaDir, err := matrixMediaTempDir()
+ if err != nil {
+ return "", fmt.Errorf("create matrix media directory: %w", err)
+ }
+ tmp, err := os.CreateTemp(mediaDir, "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 isGroup, ok := c.roomKindCache.get(roomID.String(), now); ok {
+ return 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.set(roomID.String(), isGroup, now)
+ 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) {
+ return true
+ }
+ if mentionsUserInFormattedBody(msgEvt.FormattedBody, c.client.UserID) {
+ return true
+ }
+
+ mentionR := c.localpartMentionR
+ if mentionR == nil {
+ mentionR = localpartMentionRegexp(matrixLocalpart(c.client.UserID))
+ }
+ if mentionR == nil {
+ return false
+ }
+
+ // Matrix users are addressed as MXID "@localpart:server", but many clients
+ // emit plain-text mentions as "@localpart". Both forms are handled here.
+ return mentionR.MatchString(msgEvt.Body) || mentionR.MatchString(msgEvt.FormattedBody)
+}
+
+func mentionsUserInFormattedBody(formattedBody string, userID id.UserID) bool {
+ target := strings.ToLower(strings.TrimSpace(userID.String()))
+ if target == "" {
+ return false
+ }
+
+ formattedBody = strings.TrimSpace(formattedBody)
+ if formattedBody == "" {
+ return false
+ }
+
+ if strings.Contains(strings.ToLower(formattedBody), target) {
+ return true
+ }
+
+ matches := matrixMentionHrefRegexp.FindAllStringSubmatch(formattedBody, -1)
+ for _, match := range matches {
+ if len(match) < 2 {
+ continue
+ }
+ decoded := decodeMatrixMentionHref(match[1])
+ if strings.Contains(strings.ToLower(decoded), target) {
+ return true
+ }
+
+ u, err := url.Parse(decoded)
+ if err != nil {
+ continue
+ }
+
+ if strings.Contains(strings.ToLower(u.Path), target) || strings.Contains(strings.ToLower(u.Fragment), target) {
+ return true
+ }
+ if strings.Contains(strings.ToLower(decodeMatrixMentionHref(u.Fragment)), target) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func decodeMatrixMentionHref(v string) string {
+ decoded := html.UnescapeString(strings.TrimSpace(v))
+ if decoded == "" {
+ return ""
+ }
+
+ for i := 0; i < 2; i++ {
+ next, err := url.QueryUnescape(decoded)
+ if err != nil || next == decoded {
+ break
+ }
+ decoded = next
+ }
+ return decoded
+}
+
+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 (c *MatrixChannel) runRoomKindCacheJanitor(ctx context.Context) {
+ ticker := time.NewTicker(roomKindCacheCleanupPeriod)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case now := <-ticker.C:
+ c.roomKindCache.cleanupExpired(now)
+ }
+ }
+}
+
+func (c *MatrixChannel) stripSelfMention(text string) string {
+ return stripUserMentionWithRegexp(text, c.client.UserID, c.localpartMentionR)
+}
+
+func matrixMediaTempDir() (string, error) {
+ mediaDir := filepath.Join(os.TempDir(), matrixMediaTempDirName)
+ if err := os.MkdirAll(mediaDir, 0o700); err != nil {
+ return "", err
+ }
+ return mediaDir, nil
+}
+
+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 {
+ localpart = strings.TrimSpace(localpart)
+ if localpart == "" {
+ return nil
+ }
+
+ // Match Matrix mentions in plain text while avoiding false positives:
+ // "@picoclaw" and "@picoclaw:matrix.org" should match,
+ // "test@example.com" and "hellopicoclawworld" should not.
+ pattern := `(?i)(^|[^[:alnum:]_])@` + regexp.QuoteMeta(localpart) + `(?::[A-Za-z0-9._:-]+)?([^[:alnum:]_]|$)`
+ return regexp.MustCompile(pattern)
+}
+
+func stripUserMention(text string, userID id.UserID) string {
+ return stripUserMentionWithRegexp(text, userID, localpartMentionRegexp(matrixLocalpart(userID)))
+}
+
+func stripUserMentionWithRegexp(text string, userID id.UserID, mentionR *regexp.Regexp) string {
+ cleaned := strings.ReplaceAll(text, userID.String(), "")
+
+ if mentionR != nil {
+ cleaned = mentionR.ReplaceAllString(cleaned, "$1$2")
+ }
+
+ cleaned = strings.TrimSpace(cleaned)
+ cleaned = strings.TrimLeft(cleaned, ",:; ")
+ return strings.TrimSpace(cleaned)
+}
diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go
new file mode 100644
index 000000000..e76db0d3e
--- /dev/null
+++ b/pkg/channels/matrix/matrix_test.go
@@ -0,0 +1,291 @@
+package matrix
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "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: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e",
+ 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: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e",
+ },
+ want: false,
+ },
+ {
+ name: "formatted mention href matrix.to plain",
+ msg: event.MessageEventContent{
+ Body: "hello bot",
+ FormattedBody: `PicoClaw hello`,
+ },
+ want: true,
+ },
+ {
+ name: "formatted mention href matrix.to encoded",
+ msg: event.MessageEventContent{
+ Body: "hello bot",
+ FormattedBody: `PicoClaw hello`,
+ },
+ want: true,
+ },
+ }
+
+ 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 TestRoomKindCache_ExpiresEntries(t *testing.T) {
+ cache := newRoomKindCache(4, 5*time.Second)
+ now := time.Unix(100, 0)
+ cache.set("!room:matrix.org", true, now)
+
+ if got, ok := cache.get("!room:matrix.org", now.Add(2*time.Second)); !ok || !got {
+ t.Fatalf("expected cached group room before ttl, got ok=%v group=%v", ok, got)
+ }
+
+ if _, ok := cache.get("!room:matrix.org", now.Add(6*time.Second)); ok {
+ t.Fatal("expected cache miss after ttl expiry")
+ }
+}
+
+func TestRoomKindCache_EvictsOldestWhenFull(t *testing.T) {
+ cache := newRoomKindCache(2, time.Minute)
+ now := time.Unix(200, 0)
+
+ cache.set("!room1:matrix.org", false, now)
+ cache.set("!room2:matrix.org", false, now.Add(1*time.Second))
+ cache.set("!room3:matrix.org", true, now.Add(2*time.Second))
+
+ if _, ok := cache.get("!room1:matrix.org", now.Add(2*time.Second)); ok {
+ t.Fatal("expected oldest cache entry to be evicted")
+ }
+ if got, ok := cache.get("!room2:matrix.org", now.Add(2*time.Second)); !ok || got {
+ t.Fatalf("expected room2 to remain and be direct, got ok=%v group=%v", ok, got)
+ }
+ if got, ok := cache.get("!room3:matrix.org", now.Add(2*time.Second)); !ok || !got {
+ t.Fatalf("expected room3 to remain and be group, got ok=%v group=%v", ok, got)
+ }
+}
+
+func TestMatrixMediaTempDir(t *testing.T) {
+ dir, err := matrixMediaTempDir()
+ if err != nil {
+ t.Fatalf("matrixMediaTempDir failed: %v", err)
+ }
+ if filepath.Base(dir) != matrixMediaTempDirName {
+ t.Fatalf("unexpected media dir base: %q", filepath.Base(dir))
+ }
+
+ info, err := os.Stat(dir)
+ if err != nil {
+ t.Fatalf("media dir not created: %v", err)
+ }
+ if !info.IsDir() {
+ t.Fatalf("expected directory, got mode=%v", info.Mode())
+ }
+}
+
+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)
+ }
+}
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index a2035853c..0a36247a6 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -88,7 +88,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
telegramCfg,
bus,
telegramCfg.AllowFrom,
- channels.WithMaxMessageLength(4096),
+ channels.WithMaxMessageLength(4000),
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
)
@@ -173,22 +173,57 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
- htmlContent := markdownToTelegramHTML(msg.Content)
+ if msg.Content == "" {
+ return nil
+ }
- // Typing/placeholder handled by Manager.preSend — just send the message
+ // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
+ // so msg.Content is guaranteed to be within that limit. We still need to
+ // check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
+ queue := []string{msg.Content}
+ for len(queue) > 0 {
+ chunk := queue[0]
+ queue = queue[1:]
+
+ htmlContent := markdownToTelegramHTML(chunk)
+
+ if len([]rune(htmlContent)) > 4096 {
+ ratio := float64(len([]rune(chunk))) / float64(len([]rune(htmlContent)))
+ smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin
+ if smallerLen < 100 {
+ smallerLen = 100
+ }
+ // Push sub-chunks back to the front of the queue for
+ // re-validation instead of sending them blindly.
+ subChunks := channels.SplitMessage(chunk, smallerLen)
+ queue = append(subChunks, queue...)
+ continue
+ }
+
+ if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// sendHTMLChunk sends a single HTML message, falling back to the original
+// markdown as plain text on parse failure so users never see raw HTML tags.
+func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error {
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML
- if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
+ if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
"error": err.Error(),
})
+ tgMsg.Text = mdFallback
tgMsg.ParseMode = ""
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}
-
return nil
}
diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go
new file mode 100644
index 000000000..3a2f1aa66
--- /dev/null
+++ b/pkg/channels/telegram/telegram_test.go
@@ -0,0 +1,273 @@
+package telegram
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/mymmrac/telego"
+ ta "github.com/mymmrac/telego/telegoapi"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+)
+
+const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc"
+
+// stubCaller implements ta.Caller for testing.
+type stubCaller struct {
+ calls []stubCall
+ callFn func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error)
+}
+
+type stubCall struct {
+ URL string
+ Data *ta.RequestData
+}
+
+func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ s.calls = append(s.calls, stubCall{URL: url, Data: data})
+ return s.callFn(ctx, url, data)
+}
+
+// stubConstructor implements ta.RequestConstructor for testing.
+type stubConstructor struct{}
+
+func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) {
+ return &ta.RequestData{}, nil
+}
+
+func (s *stubConstructor) MultipartRequest(
+ parameters map[string]string,
+ files map[string]ta.NamedReader,
+) (*ta.RequestData, error) {
+ return &ta.RequestData{}, nil
+}
+
+// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
+func successResponse(t *testing.T) *ta.Response {
+ t.Helper()
+ msg := &telego.Message{MessageID: 1}
+ b, err := json.Marshal(msg)
+ require.NoError(t, err)
+ return &ta.Response{Ok: true, Result: b}
+}
+
+// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
+func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
+ t.Helper()
+
+ bot, err := telego.NewBot(testToken,
+ telego.WithAPICaller(caller),
+ telego.WithRequestConstructor(&stubConstructor{}),
+ telego.WithDiscardLogger(),
+ )
+ require.NoError(t, err)
+
+ base := channels.NewBaseChannel("telegram", nil, nil, nil,
+ channels.WithMaxMessageLength(4000),
+ )
+ base.SetRunning(true)
+
+ return &TelegramChannel{
+ BaseChannel: base,
+ bot: bot,
+ chatIDs: make(map[string]int64),
+ }
+}
+
+func TestSend_EmptyContent(t *testing.T) {
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ t.Fatal("SendMessage should not be called for empty content")
+ return nil, nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+
+ err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "12345",
+ Content: "",
+ })
+
+ assert.NoError(t, err)
+ assert.Empty(t, caller.calls, "no API calls should be made for empty content")
+}
+
+func TestSend_ShortMessage_SingleCall(t *testing.T) {
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ return successResponse(t), nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+
+ err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "12345",
+ Content: "Hello, world!",
+ })
+
+ assert.NoError(t, err)
+ assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
+}
+
+func TestSend_LongMessage_SingleCall(t *testing.T) {
+ // With WithMaxMessageLength(4000), the Manager pre-splits messages before
+ // they reach Send(). A message at exactly 4000 chars should go through
+ // as a single SendMessage call (no re-split needed since HTML expansion
+ // won't exceed 4096 for plain text).
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ return successResponse(t), nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+
+ longContent := strings.Repeat("a", 4000)
+
+ err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "12345",
+ Content: longContent,
+ })
+
+ assert.NoError(t, err)
+ assert.Len(t, caller.calls, 1, "pre-split message within limit should result in one SendMessage call")
+}
+
+func TestSend_HTMLFallback_PerChunk(t *testing.T) {
+ callCount := 0
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ callCount++
+ // Fail on odd calls (HTML attempt), succeed on even calls (plain text fallback)
+ if callCount%2 == 1 {
+ return nil, errors.New("Bad Request: can't parse entities")
+ }
+ return successResponse(t), nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+
+ err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "12345",
+ Content: "Hello **world**",
+ })
+
+ assert.NoError(t, err)
+ // One short message → 1 HTML attempt (fail) + 1 plain text fallback (success) = 2 calls
+ assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text fallback")
+}
+
+func TestSend_HTMLFallback_BothFail(t *testing.T) {
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ return nil, errors.New("send failed")
+ },
+ }
+ ch := newTestChannel(t, caller)
+
+ err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "12345",
+ Content: "Hello",
+ })
+
+ assert.Error(t, err)
+ assert.True(t, errors.Is(err, channels.ErrTemporary), "error should wrap ErrTemporary")
+ assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text attempt")
+}
+
+func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
+ // With a long message that gets split into 2 chunks, if both HTML and
+ // plain text fail on the first chunk, Send should return early.
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ return nil, errors.New("send failed")
+ },
+ }
+ ch := newTestChannel(t, caller)
+
+ longContent := strings.Repeat("x", 4001)
+
+ err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "12345",
+ Content: longContent,
+ })
+
+ assert.Error(t, err)
+ // Should fail on the first chunk (2 calls: HTML + fallback), never reaching the second chunk.
+ assert.Equal(t, 2, len(caller.calls), "should stop after first chunk fails both HTML and plain text")
+}
+
+func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ return successResponse(t), nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+
+ // Create markdown whose length is <= 4000 but whose HTML expansion is much longer.
+ // "**a** " (6 chars) becomes "a " (9 chars) in HTML, so repeating it many times
+ // yields HTML that exceeds Telegram's limit while markdown stays within it.
+ markdownContent := strings.Repeat("**a** ", 600) // 3600 chars markdown, HTML ~5400+ chars
+ assert.LessOrEqual(t, len([]rune(markdownContent)), 4000, "markdown content must not exceed chunk size")
+
+ htmlExpanded := markdownToTelegramHTML(markdownContent)
+ assert.Greater(
+ t, len([]rune(htmlExpanded)), 4096,
+ "HTML expansion must exceed Telegram limit for this test to be meaningful",
+ )
+
+ err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "12345",
+ Content: markdownContent,
+ })
+
+ assert.NoError(t, err)
+ assert.Greater(
+ t, len(caller.calls), 1,
+ "markdown-short but HTML-long message should be split into multiple SendMessage calls",
+ )
+}
+
+func TestSend_NotRunning(t *testing.T) {
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ t.Fatal("should not be called")
+ return nil, nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+ ch.SetRunning(false)
+
+ err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "12345",
+ Content: "Hello",
+ })
+
+ assert.ErrorIs(t, err, channels.ErrNotRunning)
+ assert.Empty(t, caller.calls)
+}
+
+func TestSend_InvalidChatID(t *testing.T) {
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ t.Fatal("should not be called")
+ return nil, nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+
+ err := ch.Send(context.Background(), bus.OutboundMessage{
+ ChatID: "not-a-number",
+ Content: "Hello",
+ })
+
+ assert.Error(t, err)
+ assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
+ assert.Empty(t, caller.calls)
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 79ed9a012..334245be5 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -228,6 +228,7 @@ type ChannelsConfig struct {
QQ QQConfig `json:"qq"`
DingTalk DingTalkConfig `json:"dingtalk"`
Slack SlackConfig `json:"slack"`
+ Matrix MatrixConfig `json:"matrix"`
LINE LINEConfig `json:"line"`
OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"`
@@ -235,6 +236,7 @@ type ChannelsConfig struct {
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Pico PicoConfig `json:"pico"`
MagicForm MagicFormConfig `json:"magicform"`
+ IRC IRCConfig `json:"irc"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
@@ -276,15 +278,16 @@ type TelegramConfig struct {
}
type FeishuConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
- AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
- AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
- EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
- VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
+ AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
+ AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
+ EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
+ VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
+ RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
}
type DiscordConfig struct {
@@ -336,6 +339,19 @@ type SlackConfig struct {
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 {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
@@ -427,6 +443,25 @@ type MagicFormConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAGICFORM_ALLOW_FROM"`
}
+type IRCConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
+ Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
+ TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"`
+ Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"`
+ User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"`
+ RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"`
+ Password string `json:"password" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
+ NickServPassword string `json:"nickserv_password" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
+ SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"`
+ SASLPassword string `json:"sasl_password" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
+ Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"`
+ RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"`
+ GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
+ Typing TypingConfig `json:"typing,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_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
@@ -452,6 +487,7 @@ type ProvidersConfig struct {
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
DeepSeek ProviderConfig `json:"deepseek"`
Cerebras ProviderConfig `json:"cerebras"`
+ Vivgrid ProviderConfig `json:"vivgrid"`
VolcEngine ProviderConfig `json:"volcengine"`
GitHubCopilot ProviderConfig `json:"github_copilot"`
Antigravity ProviderConfig `json:"antigravity"`
@@ -477,6 +513,7 @@ func (p ProvidersConfig) IsEmpty() bool {
p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
+ p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" &&
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 10ebc7c90..47f79c6f0 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -283,6 +283,9 @@ func TestDefaultConfig_Channels(t *testing.T) {
if cfg.Channels.Slack.Enabled {
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
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 1902480c5..7fb3daa48 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -97,6 +97,22 @@ func DefaultConfig() *Config {
AppToken: "",
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{
Enabled: false,
ChannelSecret: "",
@@ -261,6 +277,14 @@ func DefaultConfig() *Config {
APIKey: "",
},
+ // Vivgrid - https://vivgrid.com
+ {
+ ModelName: "vivgrid-auto",
+ Model: "vivgrid/auto",
+ APIBase: "https://api.vivgrid.com/v1",
+ APIKey: "",
+ },
+
// Volcengine (火山引擎) - https://console.volcengine.com/ark
{
ModelName: "doubao-pro",
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index 4a17dd6c9..51f21e4f4 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -292,6 +292,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true
},
},
+ {
+ providerNames: []string{"vivgrid"},
+ protocol: "vivgrid",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Vivgrid.APIKey == "" && p.Vivgrid.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "vivgrid",
+ Model: "vivgrid/auto",
+ APIKey: p.Vivgrid.APIKey,
+ APIBase: p.Vivgrid.APIBase,
+ Proxy: p.Vivgrid.Proxy,
+ RequestTimeout: p.Vivgrid.RequestTimeout,
+ }, true
+ },
+ },
{
providerNames: []string{"volcengine", "doubao"},
protocol: "volcengine",
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index 67ad73db9..d3019aab0 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -155,7 +155,8 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
ShengSuanYun: ProviderConfig{APIKey: "key11"},
DeepSeek: ProviderConfig{APIKey: "key12"},
Cerebras: ProviderConfig{APIKey: "key13"},
- VolcEngine: ProviderConfig{APIKey: "key14"},
+ Vivgrid: ProviderConfig{APIKey: "key14"},
+ VolcEngine: ProviderConfig{APIKey: "key15"},
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
Antigravity: ProviderConfig{AuthMethod: "oauth"},
Qwen: ProviderConfig{APIKey: "key17"},
@@ -166,9 +167,9 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
result := ConvertProvidersToModelList(cfg)
- // All 20 providers should be converted
- if len(result) != 20 {
- t.Errorf("len(result) = %d, want 20", len(result))
+ // All 21 providers should be converted
+ if len(result) != 21 {
+ t.Errorf("len(result) = %d, want 21", len(result))
}
}
diff --git a/pkg/migrate/sources/openclaw/common.go b/pkg/migrate/sources/openclaw/common.go
index dddd98089..d57dbe34f 100644
--- a/pkg/migrate/sources/openclaw/common.go
+++ b/pkg/migrate/sources/openclaw/common.go
@@ -22,6 +22,7 @@ var supportedChannels = map[string]bool{
"qq": true,
"dingtalk": true,
"slack": true,
+ "matrix": true,
"line": true,
"onebot": true,
"wecom": true,
diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go
index 39ad48fad..19d63bb77 100644
--- a/pkg/migrate/sources/openclaw/openclaw_config.go
+++ b/pkg/migrate/sources/openclaw/openclaw_config.go
@@ -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
case "slack":
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":
return c.Channels.WhatsApp == nil || c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled
case "feishu":
@@ -397,6 +399,11 @@ func GetChannelAllowFrom(ch any) []string {
return nil
}
return c.AllowFrom
+ case *OpenClawMatrixConfig:
+ if c == nil {
+ return nil
+ }
+ return c.AllowFrom
case *OpenClawWhatsAppConfig:
if c == nil {
return nil
@@ -627,6 +634,7 @@ type ChannelsConfig struct {
QQ QQConfig `json:"qq"`
DingTalk DingTalkConfig `json:"dingtalk"`
Slack SlackConfig `json:"slack"`
+ Matrix MatrixConfig `json:"matrix"`
LINE LINEConfig `json:"line"`
}
@@ -687,6 +695,14 @@ type SlackConfig struct {
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 {
Enabled bool `json:"enabled"`
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 {
*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 {
*warnings = append(*warnings, "Channel 'irc': No PicoClaw adapter available")
}
@@ -1020,6 +1050,14 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
BotToken: c.Slack.BotToken,
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{
Enabled: c.LINE.Enabled,
ChannelSecret: c.LINE.ChannelSecret,
diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go
index 7d884522c..3a7d0c686 100644
--- a/pkg/migrate/sources/openclaw/openclaw_config_test.go
+++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "strings"
"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) {
model := &OpenClawAgentModel{
Primary: strPtr("anthropic/claude-3-opus"),
@@ -425,6 +516,9 @@ func TestChannelEnabled(t *testing.T) {
if !cfg.IsChannelEnabled("slack") {
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") {
t.Error("line should return false (not in switch cases)")
}
diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go
index a0d09a835..25916ad03 100644
--- a/pkg/providers/factory.go
+++ b/pkg/providers/factory.go
@@ -153,6 +153,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.apiBase = "https://integrate.api.nvidia.com/v1"
}
}
+ case "vivgrid":
+ if cfg.Providers.Vivgrid.APIKey != "" {
+ sel.apiKey = cfg.Providers.Vivgrid.APIKey
+ sel.apiBase = cfg.Providers.Vivgrid.APIBase
+ sel.proxy = cfg.Providers.Vivgrid.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.vivgrid.com/v1"
+ }
+ }
case "claude-cli", "claude-code", "claudecode":
workspace := cfg.WorkspacePath()
if workspace == "" {
@@ -295,6 +304,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
if sel.apiBase == "" {
sel.apiBase = "https://integrate.api.nvidia.com/v1"
}
+ case strings.HasPrefix(model, "vivgrid/") && cfg.Providers.Vivgrid.APIKey != "":
+ sel.apiKey = cfg.Providers.Vivgrid.APIKey
+ sel.apiBase = cfg.Providers.Vivgrid.APIBase
+ sel.proxy = cfg.Providers.Vivgrid.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.vivgrid.com/v1"
+ }
case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
sel.apiKey = cfg.Providers.Ollama.APIKey
sel.apiBase = cfg.Providers.Ollama.APIBase
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index c05fb0ad4..941985964 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -94,7 +94,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
- "volcengine", "vllm", "qwen", "mistral", "avian":
+ "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@@ -200,6 +200,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://api.deepseek.com/v1"
case "cerebras":
return "https://api.cerebras.ai/v1"
+ case "vivgrid":
+ return "https://api.vivgrid.com/v1"
case "volcengine":
return "https://ark.cn-beijing.volces.com/api/v3"
case "qwen":
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index 78389f331..17bc55d25 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -108,6 +108,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"groq", "groq"},
{"openrouter", "openrouter"},
{"cerebras", "cerebras"},
+ {"vivgrid", "vivgrid"},
{"qwen", "qwen"},
{"vllm", "vllm"},
{"deepseek", "deepseek"},
diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go
index f7a916d9e..36ccda4a1 100644
--- a/pkg/providers/factory_test.go
+++ b/pkg/providers/factory_test.go
@@ -88,6 +88,17 @@ func TestResolveProviderSelection(t *testing.T) {
wantAPIBase: "https://integrate.api.nvidia.com/v1",
wantProxy: "http://127.0.0.1:7890",
},
+ {
+ name: "explicit vivgrid provider uses defaults",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "vivgrid"
+ cfg.Providers.Vivgrid.APIKey = "vivgrid-key"
+ cfg.Providers.Vivgrid.Proxy = "http://127.0.0.1:7890"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://api.vivgrid.com/v1",
+ wantProxy: "http://127.0.0.1:7890",
+ },
{
name: "openrouter model uses openrouter defaults",
setup: func(cfg *config.Config) {
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 1904ee153..5c868626a 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -1,6 +1,7 @@
package openai_compat
import (
+ "bufio"
"bytes"
"context"
"encoding/json"
@@ -183,19 +184,94 @@ func (p *Provider) Chat(
}
defer resp.Body.Close()
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response: %w", err)
- }
+ contentType := resp.Header.Get("Content-Type")
+ // Non-200: read a prefix to tell HTML error page apart from JSON error body.
if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
+ body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256))
+ if readErr != nil {
+ return nil, fmt.Errorf("failed to read response: %w", readErr)
+ }
+ if looksLikeHTML(body, contentType) {
+ return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase)
+ }
+ return nil, fmt.Errorf(
+ "API request failed:\n Status: %d\n Body: %s",
+ resp.StatusCode,
+ responsePreview(body, 128),
+ )
}
- return parseResponse(body)
+ // Peek without consuming so the full stream reaches the JSON decoder.
+ reader := bufio.NewReader(resp.Body)
+ prefix, err := reader.Peek(256) // io.EOF/ErrBufferFull are normal; only real errors abort
+ if err != nil && err != io.EOF && err != bufio.ErrBufferFull {
+ return nil, fmt.Errorf("failed to inspect response: %w", err)
+ }
+ if looksLikeHTML(prefix, contentType) {
+ return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase)
+ }
+
+ out, err := parseResponse(reader)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse JSON response: %w", err)
+ }
+
+ return out, nil
}
-func parseResponse(body []byte) (*LLMResponse, error) {
+func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error {
+ respPreview := responsePreview(body, 128)
+ return fmt.Errorf(
+ "API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s",
+ apiBase,
+ contentType,
+ statusCode,
+ respPreview,
+ )
+}
+
+func looksLikeHTML(body []byte, contentType string) bool {
+ contentType = strings.ToLower(strings.TrimSpace(contentType))
+ if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") {
+ return true
+ }
+ prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128))
+ return bytes.HasPrefix(prefix, []byte(" len(body) {
+ end = len(body)
+ }
+ return body[i:end]
+ }
+ }
+ return nil
+}
+
+func responsePreview(body []byte, maxLen int) string {
+ trimmed := bytes.TrimSpace(body)
+ if len(trimmed) == 0 {
+ return ""
+ }
+ if len(trimmed) <= maxLen {
+ return string(trimmed)
+ }
+ return string(trimmed[:maxLen]) + "..."
+}
+
+func parseResponse(body io.Reader) (*LLMResponse, error) {
var apiResponse struct {
Choices []struct {
Message struct {
@@ -222,8 +298,8 @@ func parseResponse(body []byte) (*LLMResponse, error) {
Usage *UsageInfo `json:"usage"`
}
- if err := json.Unmarshal(body, &apiResponse); err != nil {
- return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+ if err := json.NewDecoder(body).Decode(&apiResponse); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
}
if len(apiResponse.Choices) == 0 {
@@ -363,7 +439,8 @@ func normalizeModel(model, apiBase string) string {
prefix := strings.ToLower(before)
switch prefix {
- case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
+ case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
+ "openrouter", "zhipu", "mistral", "vivgrid":
return after
default:
return model
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index 174bcf00d..9a3a7acc5 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -1,7 +1,10 @@
package openai_compat
import (
+ "bytes"
"encoding/json"
+ "fmt"
+ "io"
"net/http"
"net/http/httptest"
"net/url"
@@ -212,6 +215,132 @@ func TestProviderChat_HTTPError(t *testing.T) {
}
}
+func TestProviderChat_JSONHTTPErrorDoesNotReportHTML(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"bad request"}`))
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "Status: 400") {
+ t.Fatalf("expected status code in error, got %v", err)
+ }
+ if strings.Contains(err.Error(), "returned HTML instead of JSON") {
+ t.Fatalf("expected non-HTML http error, got %v", err)
+ }
+}
+
+func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) {
+ tests := []struct {
+ name string
+ contentType string
+ statusCode int
+ body string
+ }{
+ {
+ name: "html success response",
+ contentType: "text/html; charset=utf-8",
+ statusCode: http.StatusOK,
+ body: "gateway login",
+ },
+ {
+ name: "html error response",
+ contentType: "text/html; charset=utf-8",
+ statusCode: http.StatusBadGateway,
+ body: "bad gateway",
+ },
+ {
+ name: "mislabeled html success response",
+ contentType: "application/json",
+ statusCode: http.StatusOK,
+ body: " \r\n\tgateway login",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", tt.contentType)
+ w.WriteHeader(tt.statusCode)
+ _, _ = w.Write([]byte(tt.body))
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), fmt.Sprintf("Status: %d", tt.statusCode)) {
+ t.Fatalf("expected status code in error, got %v", err)
+ }
+ if !strings.Contains(err.Error(), "returned HTML instead of JSON") {
+ t.Fatalf("expected helpful HTML error, got %v", err)
+ }
+ if !strings.Contains(err.Error(), "check api_base or proxy configuration") {
+ t.Fatalf("expected configuration hint, got %v", err)
+ }
+ })
+ }
+}
+
+func TestProviderChat_SuccessResponseUsesStreamingDecoder(t *testing.T) {
+ content := strings.Repeat("a", 1024)
+ body := `{"choices":[{"message":{"content":"` + content + `"},"finish_reason":"stop"}]}`
+
+ p := NewProvider("key", "https://example.com/v1", "")
+ p.httpClient = &http.Client{
+ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: &errAfterDataReadCloser{
+ data: []byte(body),
+ chunkSize: 64,
+ },
+ }, nil
+ }),
+ }
+
+ out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if out.Content != content {
+ t.Fatalf("Content = %q, want %q", out.Content, content)
+ }
+}
+
+func TestProviderChat_LargeHTMLResponsePreviewIsTruncated(t *testing.T) {
+ body := append([]byte(""), bytes.Repeat([]byte("A"), 2048)...)
+ body = append(body, []byte("")...)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusBadGateway)
+ _, _ = w.Write(body)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !strings.Contains(err.Error(), "Body: ") {
+ t.Fatalf("expected html preview in error, got %v", err)
+ }
+ if !strings.Contains(err.Error(), "...") {
+ t.Fatalf("expected truncated preview, got %v", err)
+ }
+}
+
func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {
var requestBody map[string]any
@@ -253,7 +382,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin
}
}
-func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) {
+func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) {
tests := []struct {
name string
input string
@@ -279,6 +408,11 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) {
input: "deepseek/deepseek-chat",
wantModel: "deepseek-chat",
},
+ {
+ name: "strips vivgrid prefix",
+ input: "vivgrid/auto",
+ wantModel: "auto",
+ },
}
for _, tt := range tests {
@@ -383,6 +517,12 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
}
+ if got := normalizeModel("vivgrid/managed", "https://api.vivgrid.com/v1"); got != "managed" {
+ t.Fatalf("normalizeModel(vivgrid) = %q, want %q", got, "managed")
+ }
+ if got := normalizeModel("vivgrid/auto", "https://api.vivgrid.com/v1"); got != "auto" {
+ t.Fatalf("normalizeModel(vivgrid auto) = %q, want %q", got, "auto")
+ }
}
func TestProvider_RequestTimeoutDefault(t *testing.T) {
@@ -399,6 +539,40 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) {
}
}
+type roundTripperFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
+ return f(r)
+}
+
+type errAfterDataReadCloser struct {
+ data []byte
+ chunkSize int
+ offset int
+}
+
+func (r *errAfterDataReadCloser) Read(p []byte) (int, error) {
+ if r.offset >= len(r.data) {
+ return 0, io.ErrUnexpectedEOF
+ }
+
+ n := r.chunkSize
+ if n <= 0 || n > len(p) {
+ n = len(p)
+ }
+ remaining := len(r.data) - r.offset
+ if n > remaining {
+ n = remaining
+ }
+ copy(p, r.data[r.offset:r.offset+n])
+ r.offset += n
+ return n, nil
+}
+
+func (r *errAfterDataReadCloser) Close() error {
+ return nil
+}
+
func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) {
p := NewProvider("key", "https://example.com/v1", "", WithMaxTokensField("max_completion_tokens"))
if p.maxTokensField != "max_completion_tokens" {
diff --git a/pkg/tools/spawn_test.go b/pkg/tools/spawn_test.go
index 0646c82a9..43223b8db 100644
--- a/pkg/tools/spawn_test.go
+++ b/pkg/tools/spawn_test.go
@@ -8,7 +8,7 @@ import (
func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
provider := &MockLLMProvider{}
- manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSpawnTool(manager)
ctx := context.Background()
@@ -42,7 +42,7 @@ func TestSpawnTool_Execute_EmptyTask(t *testing.T) {
func TestSpawnTool_Execute_ValidTask(t *testing.T) {
provider := &MockLLMProvider{}
- manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSpawnTool(manager)
ctx := context.Background()
diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go
index 429340047..e51cbaafa 100644
--- a/pkg/tools/subagent.go
+++ b/pkg/tools/subagent.go
@@ -6,7 +6,6 @@ import (
"sync"
"time"
- "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/providers"
)
@@ -27,7 +26,6 @@ type SubagentManager struct {
mu sync.RWMutex
provider providers.LLMProvider
defaultModel string
- bus *bus.MessageBus
workspace string
tools *ToolRegistry
maxIterations int
@@ -41,13 +39,11 @@ type SubagentManager struct {
func NewSubagentManager(
provider providers.LLMProvider,
defaultModel, workspace string,
- bus *bus.MessageBus,
) *SubagentManager {
return &SubagentManager{
tasks: make(map[string]*SubagentTask),
provider: provider,
defaultModel: defaultModel,
- bus: bus,
workspace: workspace,
tools: NewToolRegistry(),
maxIterations: 10,
@@ -214,20 +210,6 @@ After completing the task, provide a clear summary of what was done.`
Async: false,
}
}
-
- // Send announce message back to main agent
- if sm.bus != nil {
- announceContent := fmt.Sprintf("Task '%s' completed.\n\nResult:\n%s", task.Label, task.Result)
- pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer pubCancel()
- sm.bus.PublishInbound(pubCtx, bus.InboundMessage{
- Channel: "system",
- SenderID: fmt.Sprintf("subagent:%s", task.ID),
- // Format: "original_channel:original_chat_id" for routing back
- ChatID: fmt.Sprintf("%s:%s", task.OriginChannel, task.OriginChatID),
- Content: announceContent,
- })
- }
}
func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go
index a1450410a..4b6f130a5 100644
--- a/pkg/tools/subagent_tool_test.go
+++ b/pkg/tools/subagent_tool_test.go
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
- "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/providers"
)
@@ -47,7 +46,7 @@ func (m *MockLLMProvider) GetContextWindow() int {
func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
provider := &MockLLMProvider{}
- manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test")
manager.SetLLMOptions(2048, 0.6)
tool := NewSubagentTool(manager)
@@ -73,7 +72,7 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
// TestSubagentTool_Name verifies tool name
func TestSubagentTool_Name(t *testing.T) {
provider := &MockLLMProvider{}
- manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
if tool.Name() != "subagent" {
@@ -84,7 +83,7 @@ func TestSubagentTool_Name(t *testing.T) {
// TestSubagentTool_Description verifies tool description
func TestSubagentTool_Description(t *testing.T) {
provider := &MockLLMProvider{}
- manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
desc := tool.Description()
@@ -99,7 +98,7 @@ func TestSubagentTool_Description(t *testing.T) {
// TestSubagentTool_Parameters verifies tool parameters schema
func TestSubagentTool_Parameters(t *testing.T) {
provider := &MockLLMProvider{}
- manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
params := tool.Parameters()
@@ -149,8 +148,7 @@ func TestSubagentTool_Parameters(t *testing.T) {
// TestSubagentTool_Execute_Success tests successful execution
func TestSubagentTool_Execute_Success(t *testing.T) {
provider := &MockLLMProvider{}
- msgBus := bus.NewMessageBus()
- manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
ctx := WithToolContext(context.Background(), "telegram", "chat-123")
@@ -204,8 +202,7 @@ func TestSubagentTool_Execute_Success(t *testing.T) {
// TestSubagentTool_Execute_NoLabel tests execution without label
func TestSubagentTool_Execute_NoLabel(t *testing.T) {
provider := &MockLLMProvider{}
- msgBus := bus.NewMessageBus()
- manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
ctx := context.Background()
@@ -228,7 +225,7 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) {
// TestSubagentTool_Execute_MissingTask tests error handling for missing task
func TestSubagentTool_Execute_MissingTask(t *testing.T) {
provider := &MockLLMProvider{}
- manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
ctx := context.Background()
@@ -278,8 +275,7 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) {
// TestSubagentTool_Execute_ContextPassing verifies context is properly used
func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
provider := &MockLLMProvider{}
- msgBus := bus.NewMessageBus()
- manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
channel := "test-channel"
@@ -304,8 +300,7 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) {
func TestSubagentTool_ForUserTruncation(t *testing.T) {
// Create a mock provider that returns very long content
provider := &MockLLMProvider{}
- msgBus := bus.NewMessageBus()
- manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus)
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test")
tool := NewSubagentTool(manager)
ctx := context.Background()
diff --git a/scripts/test-irc.sh b/scripts/test-irc.sh
new file mode 100755
index 000000000..40db01756
--- /dev/null
+++ b/scripts/test-irc.sh
@@ -0,0 +1,56 @@
+#!/bin/sh
+# Starts a local Ergo IRC server for testing the IRC channel.
+#
+# Requirements: docker
+# Usage: ./scripts/test-irc.sh
+
+set -e
+
+CONTAINER_NAME="picoclaw-test-ergo"
+IRC_PORT=6667
+
+# Clean up any previous instance
+docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
+
+echo "Starting Ergo IRC server on port $IRC_PORT..."
+docker run -d \
+ --name "$CONTAINER_NAME" \
+ -p "$IRC_PORT:6667" \
+ ghcr.io/ergochat/ergo:stable
+
+for i in $(seq 1 10); do
+ if nc -z localhost "$IRC_PORT" 2>/dev/null; then
+ break
+ fi
+ if [ "$i" -eq 10 ]; then
+ echo "ERROR: Server did not start within 10s"
+ exit 1
+ fi
+ sleep 1
+done
+
+echo ""
+echo "IRC server ready on localhost:$IRC_PORT"
+echo ""
+echo "Add this to your ~/.picoclaw/config.json under \"channels\":"
+echo ""
+echo ' "irc": {'
+echo ' "enabled": true,'
+echo ' "server": "localhost:6667",'
+echo ' "tls": false,'
+echo ' "nick": "picobot",'
+echo ' "channels": ["#test"],'
+echo ' "allow_from": [],'
+echo ' "group_trigger": { "mention_only": true }'
+echo ' }'
+echo ""
+echo "Then run picoclaw:"
+echo " cd packages/picoclaw && go run ./cmd/picoclaw gateway"
+echo ""
+echo "Connect with an IRC client:"
+echo " irssi: /connect localhost $IRC_PORT"
+echo " weechat: /server add test localhost/$IRC_PORT && /connect test"
+echo " Join #test, then: picobot: hello"
+echo ""
+echo "To stop the IRC server:"
+echo " docker rm -f $CONTAINER_NAME"