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"