chore: resolve conflicts with upstream/main

This commit is contained in:
Petrichor 2026-02-28 12:21:54 +08:00
commit 62bdece7f5
No known key found for this signature in database
96 changed files with 11203 additions and 1610 deletions

View file

@ -87,11 +87,46 @@ build: generate
@echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
build-whatsapp-native: generate
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
@echo "Build complete"
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)
build-linux-arm: generate
@echo "Building for linux/arm (GOARM=7)..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
build-linux-arm64: generate
@echo "Building for linux/arm64..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
build-pi-zero: build-linux-arm build-linux-arm64
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
## build-all: Build picoclaw for all platforms
build-all: generate
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)

View file

@ -154,10 +154,15 @@ make build
# Build for multiple platforms
make build-all
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
make build-pi-zero
# Build And Install
make install
```
**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both.
## 🐳 Docker Compose
You can also run PicoClaw using Docker Compose without installing anything locally.
@ -288,12 +293,13 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom
| Channel | Setup |
| ------------ | ---------------------------------- |
| **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) |
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
@ -384,6 +390,33 @@ picoclaw gateway
</details>
<details>
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
PicoClaw can connect to WhatsApp in two ways:
- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
**Configure (native)**
```json
{
"channels": {
"whatsapp": {
"enabled": true,
"use_native": true,
"session_store_path": "",
"allow_from": []
}
}
}
```
If `session_store_path` is empty, the session is stored in `&lt;workspace&gt;/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
</details>
<details>
<summary><b>QQ</b></summary>
@ -1070,7 +1103,11 @@ picoclaw agent -m "Hello"
"allow_from": [""]
},
"whatsapp": {
"enabled": false
"enabled": false,
"bridge_url": "ws://localhost:3001",
"use_native": false,
"session_store_path": "",
"allow_from": []
},
"feishu": {
"enabled": false,

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 366 KiB

View file

@ -48,6 +48,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
}
msgBus := bus.NewMessageBus()
defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
// Print agent startup info (only for interactive mode)

View file

@ -2,29 +2,39 @@ package gateway
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
_ "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/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/devices"
"github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/voice"
)
func gatewayCmd(debug bool) error {
@ -105,49 +115,23 @@ func gatewayCmd(debug bool) error {
return tools.SilentResult(response)
})
channelManager, err := channels.NewManager(cfg, msgBus)
// Create media store for file lifecycle management with TTL cleanup
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
})
mediaStore.Start()
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
if err != nil {
mediaStore.Stop()
return fmt.Errorf("error creating channel manager: %w", err)
}
// Inject channel manager into agent loop for command handling
// Inject channel manager and media store into agent loop
agentLoop.SetChannelManager(channelManager)
var transcriber *voice.GroqTranscriber
groqAPIKey := cfg.Providers.Groq.APIKey
if groqAPIKey == "" {
for _, mc := range cfg.ModelList {
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
groqAPIKey = mc.APIKey
break
}
}
}
if groqAPIKey != "" {
transcriber = voice.NewGroqTranscriber(groqAPIKey)
logger.InfoC("voice", "Groq voice transcription enabled")
}
if transcriber != nil {
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
tc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
}
}
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
dc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Discord channel")
}
}
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
sc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Slack channel")
}
}
}
agentLoop.SetMediaStore(mediaStore)
enabledChannels := channelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
@ -184,16 +168,15 @@ func gatewayCmd(debug bool) error {
fmt.Println("✓ Device event service started")
}
// Setup shared HTTP server with health endpoints and webhook handlers
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
channelManager.SetupHTTPServer(addr, healthServer)
if err := channelManager.StartAll(ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err)
}
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
go func() {
if err := healthServer.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
}
}()
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
go agentLoop.Run(ctx)
@ -207,12 +190,19 @@ func gatewayCmd(debug bool) error {
cp.Close()
}
cancel()
healthServer.Stop(context.Background())
msgBus.Close()
// Use a fresh context with timeout for graceful shutdown,
// since the original ctx is already canceled.
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
channelManager.StopAll(shutdownCtx)
deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
mediaStore.Stop()
agentLoop.Stop()
channelManager.StopAll(ctx)
fmt.Println("✓ Gateway stopped")
return nil

View file

@ -52,30 +52,37 @@
"proxy": "",
"allow_from": [
"YOUR_USER_ID"
]
],
"reasoning_channel_id": ""
},
"discord": {
"enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN",
"allow_from": [],
"mention_only": false
"mention_only": false,
"reasoning_channel_id": ""
},
"qq": {
"enabled": false,
"app_id": "YOUR_QQ_APP_ID",
"app_secret": "YOUR_QQ_APP_SECRET",
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"maixcam": {
"enabled": false,
"host": "0.0.0.0",
"port": 18790,
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"whatsapp": {
"enabled": false,
"bridge_url": "ws://localhost:3001",
"allow_from": []
"use_native": false,
"session_store_path": "",
"allow_from": [],
"reasoning_channel_id": ""
},
"feishu": {
"enabled": false,
@ -83,19 +90,22 @@
"app_secret": "",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"dingtalk": {
"enabled": false,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"slack": {
"enabled": false,
"bot_token": "xoxb-YOUR-BOT-TOKEN",
"app_token": "xapp-YOUR-APP-TOKEN",
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"line": {
"enabled": false,
@ -104,7 +114,8 @@
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line",
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"onebot": {
"enabled": false,
@ -112,7 +123,8 @@
"access_token": "",
"reconnect_interval": 5,
"group_trigger_prefix": [],
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"wecom": {
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
@ -124,7 +136,8 @@
"webhook_port": 18793,
"webhook_path": "/webhook/wecom",
"allow_from": [],
"reply_timeout": 5
"reply_timeout": 5,
"reasoning_channel_id": ""
},
"wecom_app": {
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
@ -138,7 +151,8 @@
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app",
"allow_from": [],
"reply_timeout": 5
"reply_timeout": 5,
"reasoning_channel_id": ""
}
},
"providers": {

View file

@ -0,0 +1,61 @@
# Issue #783 调研与修复执行文档
## 1. 问题澄清(已确认)
- 现象:当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名(如 `step-3.5-flash`fallback 链路将别名当作真实 `provider/model` 解析,导致 `provider` 可能为空、`model` 可能错误。
- 根因:`ResolveCandidates` 仅对字符串做 `ParseModelRef`,未先通过 `model_list` 将别名映射到真实 `model` 字段。
- 影响:
- fallback 执行可能把别名直接发给 OpenAI-compatible provider触发 `Unknown Model`
- `defaults.provider` 为空时,日志出现 `provider=` 空值。
## 2. 本次目标
- 修复 fallback 候选解析:优先通过 `model_list` 解析别名。
- 兼容旧行为:若未命中 `model_list`,继续走原有 `ParseModelRef` 兜底。
- 补充测试:覆盖别名、嵌套路径模型(如 `openrouter/stepfun/...`)、空默认 provider。
- 验证代码风格:与当前仓库风格保持一致(命名、错误处理、测试结构)。
## 3. 联网最佳实践调研结论(已完成)
- [x] 查阅 OpenAI-compatible 网关(如 OpenRouter`model` 字段的推荐处理。
- [x] 查阅多 provider/fallback 设计最佳实践(候选解析、日志可观测性)。
- [x] 将外部建议映射为本仓库可执行约束。
外部参考要点(来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档):
- 优先显式配置,不依赖字符串切分推断 provider。
- 对网关模型标识应保留完整路径语义,避免截断导致 Unknown Model。
- fallback 与 primary 应复用同一解析策略,避免“主路径正确、降级路径错误”。
参考链接:
- OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection
- OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks
- OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion
- LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture
- Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/
与本仓库对应的可执行约束:
- 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。
- 未命中映射时保留旧解析行为,保证兼容性。
- 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。
## 4. 实施步骤(顺序执行)
- [x] Step 1: 对齐现有代码模式,定位最小改动点(`pkg/agent` + `pkg/providers`)。
- [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。
- [x] Step 3: 增加/更新单元测试,覆盖 issue 场景。
- [x] Step 4: 代码风格一致性复核(与现有文件风格对照)。
- [x] Step 5: 运行质量门禁LSP + `make check`)。
## 5. 执行记录
- 状态:已完成
- 已完成改动:
- `pkg/providers/fallback.go`:新增 `ResolveCandidatesWithLookup`,并保持 `ResolveCandidates` 向后兼容。
- `pkg/agent/instance.go`:在构建 fallback candidates 前,优先通过 `model_list` 解析别名,并对无协议模型补齐默认 `openai/` 前缀后再解析。
- `pkg/providers/fallback_test.go`:新增别名解析与去重测试。
- `pkg/agent/instance_test.go`:新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。
- 风格对齐检查(完成):与 `pkg/providers/fallback_test.go``pkg/providers/model_ref_test.go` 现有模式一致。
- 质量验证(完成):先 `make generate`,后 `make check` 全量通过。

43
docs/troubleshooting.md Normal file
View file

@ -0,0 +1,43 @@
# Troubleshooting
## "model ... not found in model_list" or OpenRouter "free is not a valid model ID"
**Symptom:** You see either:
- `Error creating provider: model "openrouter/free" not found in model_list`
- OpenRouter returns 400: `"free is not a valid model ID"`
**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand.
- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it.
- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing).
**Fix:** In `~/.picoclaw/config.json` (or your config path):
1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`).
2. That entrys **model** must be a valid OpenRouter model ID, for example:
- `"openrouter/free"` auto free-tier
- `"google/gemini-2.0-flash-exp:free"`
- `"meta-llama/llama-3.1-8b-instruct:free"`
Example snippet:
```json
{
"agents": {
"defaults": {
"model": "openrouter-free"
}
},
"model_list": [
{
"model_name": "openrouter-free",
"model": "openrouter/free",
"api_key": "sk-or-v1-YOUR_OPENROUTER_KEY",
"api_base": "https://openrouter.ai/api/v1"
}
]
}
```
Get your key at [OpenRouter Keys](https://openrouter.ai/keys).

26
go.mod
View file

@ -11,6 +11,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.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
@ -18,15 +19,40 @@ require (
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.35.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.46.1
)
require (
filippo.io/edwards25519 v1.1.0 // 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
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/zerolog v1.34.0 // 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
go.mau.fi/util v0.9.6 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
rsc.io/qr v0.2.0 // indirect
)
require (

102
go.sum
View file

@ -1,10 +1,20 @@
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=
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=
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
@ -25,12 +35,19 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
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/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/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
@ -42,6 +59,7 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -63,6 +81,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -72,6 +92,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
@ -91,8 +113,21 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
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/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=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@ -105,13 +140,23 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
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/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=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@ -153,11 +198,19 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
@ -171,10 +224,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -217,8 +274,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
@ -227,6 +287,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -234,8 +296,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@ -243,6 +307,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -255,6 +321,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@ -269,3 +337,33 @@ 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=
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=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View file

@ -92,7 +92,47 @@ func NewAgentInstance(
Primary: model,
Fallbacks: fallbacks,
}
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
resolveFromModelList := func(raw string) (string, bool) {
ensureProtocol := func(model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
if strings.Contains(model, "/") {
return model
}
return "openai/" + model
}
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false
}
if cfg != nil {
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
return ensureProtocol(mc.Model), true
}
for i := range cfg.ModelList {
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
if fullModel == "" {
continue
}
if fullModel == raw {
return ensureProtocol(fullModel), true
}
_, modelID := providers.ExtractProtocol(fullModel)
if modelID == raw {
return ensureProtocol(fullModel), true
}
}
}
return "", false
}
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
return &AgentInstance{
ID: agentID,

View file

@ -93,3 +93,77 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
}
}
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "step-3.5-flash",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "step-3.5-flash",
Model: "openrouter/stepfun/step-3.5-flash:free",
APIBase: "https://openrouter.ai/api/v1",
},
},
}
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
if agent.Candidates[0].Provider != "openrouter" {
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter")
}
if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free")
}
}
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "glm-5",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "glm-5",
Model: "glm-5",
APIBase: "https://api.z.ai/api/coding/paas/v4",
},
},
}
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
if agent.Candidates[0].Provider != "openai" {
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
}
if agent.Candidates[0].Model != "glm-5" {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
}
}

View file

@ -10,6 +10,7 @@ import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"sync"
"sync/atomic"
@ -21,6 +22,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/skills"
@ -38,6 +40,7 @@ type AgentLoop struct {
summarizing sync.Map
fallback *providers.FallbackChain
channelManager *channels.Manager
mediaStore media.MediaStore
}
// processOptions configures how a message is processed
@ -52,6 +55,8 @@ type processOptions struct {
NoHistory bool // If true, don't load session history (for heartbeat)
}
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
registry := NewAgentRegistry(cfg, provider)
@ -119,12 +124,13 @@ func registerSharedTools(
// Message tool
messageTool := tools.NewMessageTool()
messageTool.SetSendCallback(func(channel, chatID, content string) error {
msgBus.PublishOutbound(bus.OutboundMessage{
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: content,
})
return nil
})
agent.Tools.Register(messageTool)
@ -165,6 +171,21 @@ func (al *AgentLoop) Run(ctx context.Context) error {
continue
}
// Process message
func() {
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
// Currently disabled because files are deleted before the LLM can access their content.
// defer func() {
// if al.mediaStore != nil && msg.MediaScope != "" {
// if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil {
// logger.WarnCF("agent", "Failed to release media", map[string]any{
// "scope": msg.MediaScope,
// "error": releaseErr.Error(),
// })
// }
// }
// }()
response, err := al.processMessage(ctx, msg)
if err != nil {
response = fmt.Sprintf("Error processing message: %v", err)
@ -185,13 +206,26 @@ func (al *AgentLoop) Run(ctx context.Context) error {
}
if !alreadySent {
al.bus.PublishOutbound(bus.OutboundMessage{
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: response,
})
logger.InfoCF("agent", "Published outbound response",
map[string]any{
"channel": msg.Channel,
"chat_id": msg.ChatID,
"content_len": len(response),
})
} else {
logger.DebugCF(
"agent",
"Skipped outbound (message tool already sent)",
map[string]any{"channel": msg.Channel},
)
}
}
}()
}
}
@ -214,6 +248,41 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
al.channelManager = cm
}
// SetMediaStore injects a MediaStore for media lifecycle management.
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
al.mediaStore = s
}
// inferMediaType determines the media type ("image", "audio", "video", "file")
// from a filename and MIME content type.
func inferMediaType(filename, contentType string) string {
ct := strings.ToLower(contentType)
fn := strings.ToLower(filename)
if strings.HasPrefix(ct, "image/") {
return "image"
}
if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" {
return "audio"
}
if strings.HasPrefix(ct, "video/") {
return "video"
}
// Fallback: infer from extension
ext := filepath.Ext(fn)
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
return "image"
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
return "audio"
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
return "video"
}
return "file"
}
// RecordLastChannel records the last active channel for this workspace.
// This uses the atomic state save mechanism to prevent data loss on crash.
func (al *AgentLoop) RecordLastChannel(channel string) error {
@ -255,12 +324,15 @@ func (al *AgentLoop) ProcessDirectWithChannel(
// Each heartbeat is independent and doesn't accumulate context.
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "", fmt.Errorf("no default agent for heartbeat")
}
return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: "heartbeat",
Channel: channel,
ChatID: chatID,
UserMessage: content,
DefaultResponse: "I've completed processing but have no response to give.",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
NoHistory: true, // Don't load session history for heartbeat
@ -307,6 +379,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
if !ok {
agent = al.registry.GetDefaultAgent()
}
if agent == nil {
return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
}
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok {
mt.SetContext(msg.Channel, msg.ChatID)
}
}
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
sessionKey := route.SessionKey
@ -326,7 +408,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: msg.Content,
DefaultResponse: "I've completed processing but have no response to give.",
DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
})
@ -373,6 +455,9 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
// Use default agent for system messages
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "", fmt.Errorf("no default agent for system message")
}
// Use the origin session for context
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
@ -448,7 +533,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 8. Optional: send response via bus
if opts.SendResponse {
al.bus.PublishOutbound(bus.OutboundMessage{
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: finalContent,
@ -468,6 +553,34 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
return finalContent, nil
}
func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
if al.channelManager == nil {
return ""
}
if ch, ok := al.channelManager.GetChannel(channelName); ok {
return ch.ReasoningChannelID()
}
return ""
}
func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) {
if reasoningContent == "" || channelName == "" || channelID == "" {
return
}
// Check context cancellation before attempting to publish,
// since PublishOutbound's select may race between send and ctx.Done().
if ctx.Err() != nil {
return
}
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: channelName,
ChatID: channelID,
Content: reasoningContent,
})
}
// runLLMIteration executes the LLM call loop with tool handling.
func (al *AgentLoop) runLLMIteration(
ctx context.Context,
@ -565,7 +678,7 @@ func (al *AgentLoop) runLLMIteration(
})
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
al.bus.PublishOutbound(bus.OutboundMessage{
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: "Context window exceeded. Compressing history and retrying...",
@ -594,6 +707,18 @@ func (al *AgentLoop) runLLMIteration(
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
}
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
logger.DebugCF("agent", "LLM response",
map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(response.Content),
"tool_calls": len(response.ToolCalls),
"reasoning": response.Reasoning,
"target_channel": al.targetReasoningChannelID(opts.Channel),
"channel": opts.Channel,
})
// Check if no tool calls - we're done
if len(response.ToolCalls) == 0 {
finalContent = response.Content
@ -695,7 +820,7 @@ func (al *AgentLoop) runLLMIteration(
// Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
al.bus.PublishOutbound(bus.OutboundMessage{
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: toolResult.ForUser,
@ -707,6 +832,28 @@ func (al *AgentLoop) runLLMIteration(
})
}
// If tool returned media refs, publish them as outbound media
if len(toolResult.Media) > 0 && opts.SendResponse {
parts := make([]bus.MediaPart, 0, len(toolResult.Media))
for _, ref := range toolResult.Media {
part := bus.MediaPart{Ref: ref}
// Populate metadata from MediaStore when available
if al.mediaStore != nil {
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
part.Filename = meta.Filename
part.ContentType = meta.ContentType
part.Type = inferMediaType(meta.Filename, meta.ContentType)
}
}
parts = append(parts, part)
}
al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Parts: parts,
})
}
// Determine content for LLM based on tool result
contentForLLM := toolResult.ForLLM
if contentForLLM == "" && toolResult.Err != nil {
@ -1119,21 +1266,20 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
return "", false
}
// extractPeer extracts the routing peer from inbound message metadata.
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
peerKind := msg.Metadata["peer_kind"]
if peerKind == "" {
if msg.Peer.Kind == "" {
return nil
}
peerID := msg.Metadata["peer_id"]
peerID := msg.Peer.ID
if peerID == "" {
if peerKind == "direct" {
if msg.Peer.Kind == "direct" {
peerID = msg.SenderID
} else {
peerID = msg.ChatID
}
}
return &routing.RoutePeer{Kind: peerKind, ID: peerID}
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
}
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.

View file

@ -10,11 +10,23 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools"
)
type fakeChannel struct{ id string }
func (f *fakeChannel) Name() string { return "fake" }
func (f *fakeChannel) Start(ctx context.Context) error { return nil }
func (f *fakeChannel) Stop(ctx context.Context) error { return nil }
func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil }
func (f *fakeChannel) IsRunning() bool { return true }
func (f *fakeChannel) IsAllowed(string) bool { return true }
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
func TestRecordLastChannel(t *testing.T) {
// Create temp workspace
tmpDir, err := os.MkdirTemp("", "agent-test-*")
@ -620,3 +632,158 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
}
}
func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)
if err != nil {
t.Fatalf("Failed to create channel manager: %v", err)
}
for name, id := range map[string]string{
"whatsapp": "rid-whatsapp",
"telegram": "rid-telegram",
"feishu": "rid-feishu",
"discord": "rid-discord",
"maixcam": "rid-maixcam",
"qq": "rid-qq",
"dingtalk": "rid-dingtalk",
"slack": "rid-slack",
"line": "rid-line",
"onebot": "rid-onebot",
"wecom": "rid-wecom",
"wecom_app": "rid-wecom-app",
} {
chManager.RegisterChannel(name, &fakeChannel{id: id})
}
al.SetChannelManager(chManager)
tests := []struct {
channel string
wantID string
}{
{channel: "whatsapp", wantID: "rid-whatsapp"},
{channel: "telegram", wantID: "rid-telegram"},
{channel: "feishu", wantID: "rid-feishu"},
{channel: "discord", wantID: "rid-discord"},
{channel: "maixcam", wantID: "rid-maixcam"},
{channel: "qq", wantID: "rid-qq"},
{channel: "dingtalk", wantID: "rid-dingtalk"},
{channel: "slack", wantID: "rid-slack"},
{channel: "line", wantID: "rid-line"},
{channel: "onebot", wantID: "rid-onebot"},
{channel: "wecom", wantID: "rid-wecom"},
{channel: "wecom_app", wantID: "rid-wecom-app"},
{channel: "unknown", wantID: ""},
}
for _, tt := range tests {
t.Run(tt.channel, func(t *testing.T) {
got := al.targetReasoningChannelID(tt.channel)
if got != tt.wantID {
t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID)
}
})
}
}
func TestHandleReasoning(t *testing.T) {
newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus
}
t.Run("skips when any required field is empty", func(t *testing.T) {
al, msgBus := newLoop(t)
al.handleReasoning(context.Background(), "reasoning", "telegram", "")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if msg, ok := msgBus.SubscribeOutbound(ctx); ok {
t.Fatalf("expected no outbound message, got %+v", msg)
}
})
t.Run("publishes one message for non telegram", func(t *testing.T) {
al, msgBus := newLoop(t)
al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
msg, ok := msgBus.SubscribeOutbound(ctx)
if !ok {
t.Fatal("expected an outbound message")
}
if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" {
t.Fatalf("unexpected outbound message: %+v", msg)
}
})
t.Run("publishes one message for telegram", func(t *testing.T) {
al, msgBus := newLoop(t)
reasoning := "hello telegram reasoning"
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
msg, ok := msgBus.SubscribeOutbound(ctx)
if !ok {
t.Fatal("expected outbound message")
}
if msg.Channel != "telegram" {
t.Fatalf("expected telegram channel message, got %+v", msg)
}
if msg.ChatID != "tg-chat" {
t.Fatalf("expected chatID tg-chat, got %+v", msg)
}
if msg.Content != reasoning {
t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
}
})
t.Run("expired ctx", func(t *testing.T) {
al, msgBus := newLoop(t)
reasoning := "hello telegram reasoning"
ctx, cancel := context.WithCancel(context.Background())
cancel()
al.handleReasoning(ctx, reasoning, "telegram", "tg-chat")
ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
msg, ok := msgBus.SubscribeOutbound(ctx)
if ok {
t.Fatalf("expected no outbound message, got %+v", msg)
}
})
}

View file

@ -2,81 +2,156 @@ package bus
import (
"context"
"sync"
"errors"
"sync/atomic"
"github.com/sipeed/picoclaw/pkg/logger"
)
// ErrBusClosed is returned when publishing to a closed MessageBus.
var ErrBusClosed = errors.New("message bus closed")
const defaultBusBufferSize = 64
type MessageBus struct {
inbound chan InboundMessage
outbound chan OutboundMessage
handlers map[string]MessageHandler
closed bool
mu sync.RWMutex
outboundMedia chan OutboundMediaMessage
done chan struct{}
closed atomic.Bool
}
func NewMessageBus() *MessageBus {
return &MessageBus{
inbound: make(chan InboundMessage, 100),
outbound: make(chan OutboundMessage, 100),
handlers: make(map[string]MessageHandler),
inbound: make(chan InboundMessage, defaultBusBufferSize),
outbound: make(chan OutboundMessage, defaultBusBufferSize),
outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize),
done: make(chan struct{}),
}
}
func (mb *MessageBus) PublishInbound(msg InboundMessage) {
mb.mu.RLock()
defer mb.mu.RUnlock()
if mb.closed {
return
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
if mb.closed.Load() {
return ErrBusClosed
}
if err := ctx.Err(); err != nil {
return err
}
select {
case mb.inbound <- msg:
return nil
case <-mb.done:
return ErrBusClosed
case <-ctx.Done():
return ctx.Err()
}
mb.inbound <- msg
}
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
select {
case msg := <-mb.inbound:
return msg, true
case msg, ok := <-mb.inbound:
return msg, ok
case <-mb.done:
return InboundMessage{}, false
case <-ctx.Done():
return InboundMessage{}, false
}
}
func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
mb.mu.RLock()
defer mb.mu.RUnlock()
if mb.closed {
return
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
if mb.closed.Load() {
return ErrBusClosed
}
if err := ctx.Err(); err != nil {
return err
}
select {
case mb.outbound <- msg:
return nil
case <-mb.done:
return ErrBusClosed
case <-ctx.Done():
return ctx.Err()
}
mb.outbound <- msg
}
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
select {
case msg := <-mb.outbound:
return msg, true
case msg, ok := <-mb.outbound:
return msg, ok
case <-mb.done:
return OutboundMessage{}, false
case <-ctx.Done():
return OutboundMessage{}, false
}
}
func (mb *MessageBus) RegisterHandler(channel string, handler MessageHandler) {
mb.mu.Lock()
defer mb.mu.Unlock()
mb.handlers[channel] = handler
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
if mb.closed.Load() {
return ErrBusClosed
}
if err := ctx.Err(); err != nil {
return err
}
select {
case mb.outboundMedia <- msg:
return nil
case <-mb.done:
return ErrBusClosed
case <-ctx.Done():
return ctx.Err()
}
}
func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) {
mb.mu.RLock()
defer mb.mu.RUnlock()
handler, ok := mb.handlers[channel]
return handler, ok
func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) {
select {
case msg, ok := <-mb.outboundMedia:
return msg, ok
case <-mb.done:
return OutboundMediaMessage{}, false
case <-ctx.Done():
return OutboundMediaMessage{}, false
}
}
func (mb *MessageBus) Close() {
mb.mu.Lock()
defer mb.mu.Unlock()
if mb.closed {
return
if mb.closed.CompareAndSwap(false, true) {
close(mb.done)
// Drain buffered channels so messages aren't silently lost.
// Channels are NOT closed to avoid send-on-closed panics from concurrent publishers.
drained := 0
for {
select {
case <-mb.inbound:
drained++
default:
goto doneInbound
}
}
doneInbound:
for {
select {
case <-mb.outbound:
drained++
default:
goto doneOutbound
}
}
doneOutbound:
for {
select {
case <-mb.outboundMedia:
drained++
default:
goto doneMedia
}
}
doneMedia:
if drained > 0 {
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
"count": drained,
})
}
}
mb.closed = true
close(mb.inbound)
close(mb.outbound)
}

229
pkg/bus/bus_test.go Normal file
View file

@ -0,0 +1,229 @@
package bus
import (
"context"
"sync"
"testing"
"time"
)
func TestPublishConsume(t *testing.T) {
mb := NewMessageBus()
defer mb.Close()
ctx := context.Background()
msg := InboundMessage{
Channel: "test",
SenderID: "user1",
ChatID: "chat1",
Content: "hello",
}
if err := mb.PublishInbound(ctx, msg); err != nil {
t.Fatalf("PublishInbound failed: %v", err)
}
got, ok := mb.ConsumeInbound(ctx)
if !ok {
t.Fatal("ConsumeInbound returned ok=false")
}
if got.Content != "hello" {
t.Fatalf("expected content 'hello', got %q", got.Content)
}
if got.Channel != "test" {
t.Fatalf("expected channel 'test', got %q", got.Channel)
}
}
func TestPublishOutboundSubscribe(t *testing.T) {
mb := NewMessageBus()
defer mb.Close()
ctx := context.Background()
msg := OutboundMessage{
Channel: "telegram",
ChatID: "123",
Content: "world",
}
if err := mb.PublishOutbound(ctx, msg); err != nil {
t.Fatalf("PublishOutbound failed: %v", err)
}
got, ok := mb.SubscribeOutbound(ctx)
if !ok {
t.Fatal("SubscribeOutbound returned ok=false")
}
if got.Content != "world" {
t.Fatalf("expected content 'world', got %q", got.Content)
}
}
func TestPublishInbound_ContextCancel(t *testing.T) {
mb := NewMessageBus()
defer mb.Close()
// Fill the buffer
ctx := context.Background()
for i := range defaultBusBufferSize {
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
t.Fatalf("fill failed at %d: %v", i, err)
}
}
// Now buffer is full; publish with a canceled context
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"})
if err == nil {
t.Fatal("expected error from canceled context, got nil")
}
if err != context.Canceled {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
func TestPublishInbound_BusClosed(t *testing.T) {
mb := NewMessageBus()
mb.Close()
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
if err != ErrBusClosed {
t.Fatalf("expected ErrBusClosed, got %v", err)
}
}
func TestPublishOutbound_BusClosed(t *testing.T) {
mb := NewMessageBus()
mb.Close()
err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"})
if err != ErrBusClosed {
t.Fatalf("expected ErrBusClosed, got %v", err)
}
}
func TestConsumeInbound_ContextCancel(t *testing.T) {
mb := NewMessageBus()
defer mb.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, ok := mb.ConsumeInbound(ctx)
if ok {
t.Fatal("expected ok=false when context is canceled")
}
}
func TestConsumeInbound_BusClosed(t *testing.T) {
mb := NewMessageBus()
mb.Close()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, ok := mb.ConsumeInbound(ctx)
if ok {
t.Fatal("expected ok=false when bus is closed")
}
}
func TestSubscribeOutbound_BusClosed(t *testing.T) {
mb := NewMessageBus()
mb.Close()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, ok := mb.SubscribeOutbound(ctx)
if ok {
t.Fatal("expected ok=false when bus is closed")
}
}
func TestConcurrentPublishClose(t *testing.T) {
mb := NewMessageBus()
ctx := context.Background()
const numGoroutines = 100
var wg sync.WaitGroup
wg.Add(numGoroutines + 1)
// Spawn many goroutines trying to publish
for range numGoroutines {
go func() {
defer wg.Done()
// Use a short timeout context so we don't block forever after close
publishCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
// Errors are expected; we just must not panic or deadlock
_ = mb.PublishInbound(publishCtx, InboundMessage{Content: "concurrent"})
}()
}
// Close from another goroutine
go func() {
defer wg.Done()
time.Sleep(5 * time.Millisecond)
mb.Close()
}()
// Must complete without deadlock
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// success
case <-time.After(5 * time.Second):
t.Fatal("test timed out - possible deadlock")
}
}
func TestPublishInbound_FullBuffer(t *testing.T) {
mb := NewMessageBus()
defer mb.Close()
ctx := context.Background()
// Fill the buffer
for i := range defaultBusBufferSize {
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
t.Fatalf("fill failed at %d: %v", i, err)
}
}
// Buffer is full; publish with short timeout
timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"})
if err == nil {
t.Fatal("expected error when buffer is full and context times out")
}
if err != context.DeadlineExceeded {
t.Fatalf("expected context.DeadlineExceeded, got %v", err)
}
}
func TestCloseIdempotent(t *testing.T) {
mb := NewMessageBus()
// Multiple Close calls must not panic
mb.Close()
mb.Close()
mb.Close()
// After close, publish should return ErrBusClosed
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
if err != ErrBusClosed {
t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err)
}
}

View file

@ -1,11 +1,30 @@
package bus
// Peer identifies the routing peer for a message (direct, group, channel, etc.)
type Peer struct {
Kind string `json:"kind"` // "direct" | "group" | "channel" | ""
ID string `json:"id"`
}
// SenderInfo provides structured sender identity information.
type SenderInfo struct {
Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ...
PlatformID string `json:"platform_id,omitempty"` // raw platform ID, e.g. "123456"
CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" format
Username string `json:"username,omitempty"` // username (e.g. @alice)
DisplayName string `json:"display_name,omitempty"` // display name
}
type InboundMessage struct {
Channel string `json:"channel"`
SenderID string `json:"sender_id"`
Sender SenderInfo `json:"sender"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
Peer Peer `json:"peer"` // routing peer
MessageID string `json:"message_id,omitempty"` // platform message ID
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
SessionKey string `json:"session_key"`
Metadata map[string]string `json:"metadata,omitempty"`
}
@ -16,4 +35,18 @@ type OutboundMessage struct {
Content string `json:"content"`
}
type MessageHandler func(InboundMessage) error
// MediaPart describes a single media attachment to send.
type MediaPart struct {
Type string `json:"type"` // "image" | "audio" | "video" | "file"
Ref string `json:"ref"` // media store ref, e.g. "media://abc123"
Caption string `json:"caption,omitempty"` // optional caption text
Filename string `json:"filename,omitempty"` // original filename hint
ContentType string `json:"content_type,omitempty"` // MIME type hint
}
// OutboundMediaMessage carries media attachments from Agent to channels via the bus.
type OutboundMediaMessage struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Parts []MediaPart `json:"parts"`
}

1331
pkg/channels/README.md Normal file

File diff suppressed because it is too large Load diff

1331
pkg/channels/README.zh.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,44 @@ package channels
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
)
var (
uniqueIDCounter uint64
uniqueIDPrefix string
)
func init() {
// One-time read from crypto/rand for a unique prefix (single syscall).
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// fallback to time-based prefix
binary.BigEndian.PutUint64(b[:], uint64(time.Now().UnixNano()))
}
uniqueIDPrefix = hex.EncodeToString(b[:])
}
// uniqueID generates a process-unique ID using a random prefix and an atomic counter.
// This ID is intended for internal correlation (e.g. media scope keys) and is NOT
// cryptographically secure — it must not be used in contexts where unpredictability matters.
func uniqueID() string {
n := atomic.AddUint64(&uniqueIDCounter, 1)
return uniqueIDPrefix + strconv.FormatUint(n, 16)
}
type Channel interface {
Name() string
Start(ctx context.Context) error
@ -14,32 +47,126 @@ type Channel interface {
Send(ctx context.Context, msg bus.OutboundMessage) error
IsRunning() bool
IsAllowed(senderID string) bool
IsAllowedSender(sender bus.SenderInfo) bool
ReasoningChannelID() string
}
// BaseChannelOption is a functional option for configuring a BaseChannel.
type BaseChannelOption func(*BaseChannel)
// WithMaxMessageLength sets the maximum message length (in runes) for a channel.
// Messages exceeding this limit will be automatically split by the Manager.
// A value of 0 means no limit.
func WithMaxMessageLength(n int) BaseChannelOption {
return func(c *BaseChannel) { c.maxMessageLength = n }
}
// WithGroupTrigger sets the group trigger configuration for a channel.
func WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption {
return func(c *BaseChannel) { c.groupTrigger = gt }
}
// WithReasoningChannelID sets the reasoning channel ID where thoughts should be sent.
func WithReasoningChannelID(id string) BaseChannelOption {
return func(c *BaseChannel) { c.reasoningChannelID = id }
}
// MessageLengthProvider is an opt-in interface that channels implement
// to advertise their maximum message length. The Manager uses this via
// type assertion to decide whether to split outbound messages.
type MessageLengthProvider interface {
MaxMessageLength() int
}
type BaseChannel struct {
config any
bus *bus.MessageBus
running bool
running atomic.Bool
name string
allowList []string
maxMessageLength int
groupTrigger config.GroupTriggerConfig
mediaStore media.MediaStore
placeholderRecorder PlaceholderRecorder
owner Channel // the concrete channel that embeds this BaseChannel
reasoningChannelID string
}
func NewBaseChannel(name string, config any, bus *bus.MessageBus, allowList []string) *BaseChannel {
return &BaseChannel{
func NewBaseChannel(
name string,
config any,
bus *bus.MessageBus,
allowList []string,
opts ...BaseChannelOption,
) *BaseChannel {
bc := &BaseChannel{
config: config,
bus: bus,
name: name,
allowList: allowList,
running: false,
}
for _, opt := range opts {
opt(bc)
}
return bc
}
// MaxMessageLength returns the maximum message length (in runes) for this channel.
// A value of 0 means no limit.
func (c *BaseChannel) MaxMessageLength() int {
return c.maxMessageLength
}
// ShouldRespondInGroup determines whether the bot should respond in a group chat.
// Each channel is responsible for:
// 1. Detecting isMentioned (platform-specific)
// 2. Stripping bot mention from content (platform-specific)
// 3. Calling this method to get the group response decision
//
// Logic:
// - If isMentioned → always respond
// - If mention_only configured and not mentioned → ignore
// - If prefixes configured → respond if content starts with any prefix (strip it)
// - If prefixes configured but no match and not mentioned → ignore
// - Otherwise (no group_trigger configured) → respond to all (permissive default)
func (c *BaseChannel) ShouldRespondInGroup(isMentioned bool, content string) (bool, string) {
gt := c.groupTrigger
// Mentioned → always respond
if isMentioned {
return true, strings.TrimSpace(content)
}
// mention_only → require mention
if gt.MentionOnly {
return false, content
}
// Prefix matching
if len(gt.Prefixes) > 0 {
for _, prefix := range gt.Prefixes {
if prefix != "" && strings.HasPrefix(content, prefix) {
return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
}
}
// Prefixes configured but none matched and not mentioned → ignore
return false, content
}
// No group_trigger configured → permissive (respond to all)
return true, strings.TrimSpace(content)
}
func (c *BaseChannel) Name() string {
return c.name
}
func (c *BaseChannel) ReasoningChannelID() string {
return c.reasoningChannelID
}
func (c *BaseChannel) IsRunning() bool {
return c.running
return c.running.Load()
}
func (c *BaseChannel) IsAllowed(senderID string) bool {
@ -81,23 +208,130 @@ func (c *BaseChannel) IsAllowed(senderID string) bool {
return false
}
func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []string, metadata map[string]string) {
// IsAllowedSender checks whether a structured SenderInfo is permitted by the allow-list.
// It delegates to identity.MatchAllowed for each entry, providing unified matching
// across all legacy formats and the new canonical "platform:id" format.
func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool {
if len(c.allowList) == 0 {
return true
}
for _, allowed := range c.allowList {
if identity.MatchAllowed(sender, allowed) {
return true
}
}
return false
}
func (c *BaseChannel) HandleMessage(
ctx context.Context,
peer bus.Peer,
messageID, senderID, chatID, content string,
media []string,
metadata map[string]string,
senderOpts ...bus.SenderInfo,
) {
// Use SenderInfo-based allow check when available, else fall back to string
var sender bus.SenderInfo
if len(senderOpts) > 0 {
sender = senderOpts[0]
}
if sender.CanonicalID != "" || sender.PlatformID != "" {
if !c.IsAllowedSender(sender) {
return
}
} else {
if !c.IsAllowed(senderID) {
return
}
}
// Set SenderID to canonical if available, otherwise keep the raw senderID
resolvedSenderID := senderID
if sender.CanonicalID != "" {
resolvedSenderID = sender.CanonicalID
}
scope := BuildMediaScope(c.name, chatID, messageID)
msg := bus.InboundMessage{
Channel: c.name,
SenderID: senderID,
SenderID: resolvedSenderID,
Sender: sender,
ChatID: chatID,
Content: content,
Media: media,
Peer: peer,
MessageID: messageID,
MediaScope: scope,
Metadata: metadata,
}
c.bus.PublishInbound(msg)
// Auto-trigger typing indicator, message reaction, and placeholder before publishing.
// Each capability is independent — all three may fire for the same message.
if c.owner != nil && c.placeholderRecorder != nil {
// Typing — independent pipeline
if tc, ok := c.owner.(TypingCapable); ok {
if stop, err := tc.StartTyping(ctx, chatID); err == nil {
c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop)
}
}
// Reaction — independent pipeline
if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" {
if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil {
c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
}
}
// Placeholder — independent pipeline
if pc, ok := c.owner.(PlaceholderCapable); ok {
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
}
}
}
if err := c.bus.PublishInbound(ctx, msg); err != nil {
logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{
"channel": c.name,
"chat_id": chatID,
"error": err.Error(),
})
}
}
func (c *BaseChannel) setRunning(running bool) {
c.running = running
func (c *BaseChannel) SetRunning(running bool) {
c.running.Store(running)
}
// SetMediaStore injects a MediaStore into the channel.
func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s }
// GetMediaStore returns the injected MediaStore (may be nil).
func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore }
// SetPlaceholderRecorder injects a PlaceholderRecorder into the channel.
func (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) {
c.placeholderRecorder = r
}
// GetPlaceholderRecorder returns the injected PlaceholderRecorder (may be nil).
func (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder {
return c.placeholderRecorder
}
// SetOwner injects the concrete channel that embeds this BaseChannel.
// This allows HandleMessage to auto-trigger TypingCapable / ReactionCapable / PlaceholderCapable.
func (c *BaseChannel) SetOwner(ch Channel) {
c.owner = ch
}
// BuildMediaScope constructs a scope key for media lifecycle tracking.
func BuildMediaScope(channel, chatID, messageID string) string {
id := messageID
if id == "" {
id = uniqueID()
}
return channel + ":" + chatID + ":" + id
}

View file

@ -1,6 +1,11 @@
package channels
import "testing"
import (
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestBaseChannelIsAllowed(t *testing.T) {
tests := []struct {
@ -50,3 +55,211 @@ func TestBaseChannelIsAllowed(t *testing.T) {
})
}
}
func TestShouldRespondInGroup(t *testing.T) {
tests := []struct {
name string
gt config.GroupTriggerConfig
isMentioned bool
content string
wantRespond bool
wantContent string
}{
{
name: "no config - permissive default",
gt: config.GroupTriggerConfig{},
isMentioned: false,
content: "hello world",
wantRespond: true,
wantContent: "hello world",
},
{
name: "no config - mentioned",
gt: config.GroupTriggerConfig{},
isMentioned: true,
content: "hello world",
wantRespond: true,
wantContent: "hello world",
},
{
name: "mention_only - not mentioned",
gt: config.GroupTriggerConfig{MentionOnly: true},
isMentioned: false,
content: "hello world",
wantRespond: false,
wantContent: "hello world",
},
{
name: "mention_only - mentioned",
gt: config.GroupTriggerConfig{MentionOnly: true},
isMentioned: true,
content: "hello world",
wantRespond: true,
wantContent: "hello world",
},
{
name: "prefix match",
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
isMentioned: false,
content: "/ask hello",
wantRespond: true,
wantContent: "hello",
},
{
name: "prefix no match - not mentioned",
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
isMentioned: false,
content: "hello world",
wantRespond: false,
wantContent: "hello world",
},
{
name: "prefix no match - but mentioned",
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}},
isMentioned: true,
content: "hello world",
wantRespond: true,
wantContent: "hello world",
},
{
name: "multiple prefixes - second matches",
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask", "/bot"}},
isMentioned: false,
content: "/bot help me",
wantRespond: true,
wantContent: "help me",
},
{
name: "mention_only with prefixes - mentioned overrides",
gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}},
isMentioned: true,
content: "hello",
wantRespond: true,
wantContent: "hello",
},
{
name: "mention_only with prefixes - not mentioned, no prefix",
gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}},
isMentioned: false,
content: "hello",
wantRespond: false,
wantContent: "hello",
},
{
name: "empty prefix in list is skipped",
gt: config.GroupTriggerConfig{Prefixes: []string{"", "/ask"}},
isMentioned: false,
content: "/ask test",
wantRespond: true,
wantContent: "test",
},
{
name: "prefix strips leading whitespace after prefix",
gt: config.GroupTriggerConfig{Prefixes: []string{"/ask "}},
isMentioned: false,
content: "/ask hello",
wantRespond: true,
wantContent: "hello",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ch := NewBaseChannel("test", nil, nil, nil, WithGroupTrigger(tt.gt))
gotRespond, gotContent := ch.ShouldRespondInGroup(tt.isMentioned, tt.content)
if gotRespond != tt.wantRespond {
t.Errorf("ShouldRespondInGroup() respond = %v, want %v", gotRespond, tt.wantRespond)
}
if gotContent != tt.wantContent {
t.Errorf("ShouldRespondInGroup() content = %q, want %q", gotContent, tt.wantContent)
}
})
}
}
func TestIsAllowedSender(t *testing.T) {
tests := []struct {
name string
allowList []string
sender bus.SenderInfo
want bool
}{
{
name: "empty allowlist allows all",
allowList: nil,
sender: bus.SenderInfo{PlatformID: "anyone"},
want: true,
},
{
name: "numeric ID matches PlatformID",
allowList: []string{"123456"},
sender: bus.SenderInfo{
Platform: "telegram",
PlatformID: "123456",
CanonicalID: "telegram:123456",
},
want: true,
},
{
name: "canonical format matches",
allowList: []string{"telegram:123456"},
sender: bus.SenderInfo{
Platform: "telegram",
PlatformID: "123456",
CanonicalID: "telegram:123456",
},
want: true,
},
{
name: "canonical format wrong platform",
allowList: []string{"discord:123456"},
sender: bus.SenderInfo{
Platform: "telegram",
PlatformID: "123456",
CanonicalID: "telegram:123456",
},
want: false,
},
{
name: "@username matches",
allowList: []string{"@alice"},
sender: bus.SenderInfo{
Platform: "telegram",
PlatformID: "123456",
CanonicalID: "telegram:123456",
Username: "alice",
},
want: true,
},
{
name: "compound id|username matches by ID",
allowList: []string{"123456|alice"},
sender: bus.SenderInfo{
Platform: "telegram",
PlatformID: "123456",
CanonicalID: "telegram:123456",
Username: "alice",
},
want: true,
},
{
name: "non matching sender denied",
allowList: []string{"654321"},
sender: bus.SenderInfo{
Platform: "telegram",
PlatformID: "123456",
CanonicalID: "telegram:123456",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ch := NewBaseChannel("test", nil, nil, tt.allowList)
if got := ch.IsAllowedSender(tt.sender); got != tt.want {
t.Fatalf("IsAllowedSender(%+v) = %v, want %v", tt.sender, got, tt.want)
}
})
}
}

View file

@ -1,7 +1,7 @@
// PicoClaw - Ultra-lightweight personal AI agent
// DingTalk channel implementation using Stream Mode
package channels
package dingtalk
import (
"context"
@ -12,7 +12,9 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
@ -20,7 +22,7 @@ import (
// DingTalkChannel implements the Channel interface for DingTalk (钉钉)
// It uses WebSocket for receiving messages via stream mode and API for sending
type DingTalkChannel struct {
*BaseChannel
*channels.BaseChannel
config config.DingTalkConfig
clientID string
clientSecret string
@ -37,7 +39,11 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
}
base := NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom)
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &DingTalkChannel{
BaseChannel: base,
@ -70,7 +76,7 @@ func (c *DingTalkChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to start stream client: %w", err)
}
c.setRunning(true)
c.SetRunning(true)
logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)")
return nil
}
@ -87,7 +93,7 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error {
c.streamClient.Close()
}
c.setRunning(false)
c.SetRunning(false)
logger.InfoC("dingtalk", "DingTalk channel stopped")
return nil
}
@ -95,7 +101,7 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error {
// Send sends a message to DingTalk via the chatbot reply API
func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("dingtalk channel not running")
return channels.ErrNotRunning
}
// Get session webhook from storage
@ -159,12 +165,17 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
"session_webhook": data.SessionWebhook,
}
var peer bus.Peer
if data.ConversationType == "1" {
metadata["peer_kind"] = "direct"
metadata["peer_id"] = senderID
peer = bus.Peer{Kind: "direct", ID: senderID}
} else {
metadata["peer_kind"] = "group"
metadata["peer_id"] = data.ConversationId
peer = bus.Peer{Kind: "group", ID: data.ConversationId}
// In group chats, apply unified group trigger filtering
respond, cleaned := c.ShouldRespondInGroup(false, content)
if !respond {
return nil, nil
}
content = cleaned
}
logger.DebugCF("dingtalk", "Received message", map[string]any{
@ -173,8 +184,20 @@ func (c *DingTalkChannel) onChatBotMessageReceived(
"preview": utils.Truncate(content, 50),
})
// Build sender info
sender := bus.SenderInfo{
Platform: "dingtalk",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("dingtalk", senderID),
DisplayName: senderNick,
}
if !c.IsAllowedSender(sender) {
return nil, nil
}
// Handle the message through the base channel
c.HandleMessage(senderID, chatID, content, nil, metadata)
c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata, sender)
// Return nil to indicate we've handled the message asynchronously
// The response will be sent through the message bus
@ -197,7 +220,7 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
contentBytes,
)
if err != nil {
return fmt.Errorf("failed to send reply: %w", err)
return fmt.Errorf("dingtalk send: %w", channels.ErrTemporary)
}
return nil

View file

@ -0,0 +1,13 @@
package dingtalk
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("dingtalk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewDingTalkChannel(cfg.Channels.DingTalk, b)
})
}

View file

@ -1,4 +1,4 @@
package channels
package discord
import (
"context"
@ -11,23 +11,24 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
"github.com/sipeed/picoclaw/pkg/voice"
)
const (
transcriptionTimeout = 30 * time.Second
sendTimeout = 10 * time.Second
)
type DiscordChannel struct {
*BaseChannel
*channels.BaseChannel
session *discordgo.Session
config config.DiscordConfig
transcriber *voice.GroqTranscriber
ctx context.Context
cancel context.CancelFunc
typingMu sync.Mutex
typingStop map[string]chan struct{} // chatID → stop signal
botUserID string // stored for mention checking
@ -39,33 +40,25 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
return nil, fmt.Errorf("failed to create discord session: %w", err)
}
base := NewBaseChannel("discord", cfg, bus, cfg.AllowFrom)
base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom,
channels.WithMaxMessageLength(2000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &DiscordChannel{
BaseChannel: base,
session: session,
config: cfg,
transcriber: nil,
ctx: context.Background(),
typingStop: make(map[string]chan struct{}),
}, nil
}
func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
c.transcriber = transcriber
}
func (c *DiscordChannel) getContext() context.Context {
if c.ctx == nil {
return context.Background()
}
return c.ctx
}
func (c *DiscordChannel) Start(ctx context.Context) error {
logger.InfoC("discord", "Starting Discord bot")
c.ctx = ctx
c.ctx, c.cancel = context.WithCancel(ctx)
// Get bot user ID before opening session to avoid race condition
botUser, err := c.session.User("@me")
@ -80,7 +73,7 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to open discord session: %w", err)
}
c.setRunning(true)
c.SetRunning(true)
logger.InfoCF("discord", "Discord bot connected", map[string]any{
"username": botUser.Username,
@ -92,7 +85,7 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
func (c *DiscordChannel) Stop(ctx context.Context) error {
logger.InfoC("discord", "Stopping Discord bot")
c.setRunning(false)
c.SetRunning(false)
// Stop all typing goroutines before closing session
c.typingMu.Lock()
@ -102,6 +95,11 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
}
c.typingMu.Unlock()
// Cancel our context so typing goroutines using c.ctx.Done() exit
if c.cancel != nil {
c.cancel()
}
if err := c.session.Close(); err != nil {
return fmt.Errorf("failed to close discord session: %w", err)
}
@ -110,10 +108,8 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
}
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
c.stopTyping(msg.ChatID)
if !c.IsRunning() {
return fmt.Errorf("discord bot not running")
return channels.ErrNotRunning
}
channelID := msg.ChatID
@ -121,20 +117,133 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return fmt.Errorf("channel ID is empty")
}
runes := []rune(msg.Content)
if len(runes) == 0 {
if len([]rune(msg.Content)) == 0 {
return nil
}
chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars
return c.sendChunk(ctx, channelID, msg.Content)
}
for _, chunk := range chunks {
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
// SendMedia implements the channels.MediaSender interface.
func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
channelID := msg.ChatID
if channelID == "" {
return fmt.Errorf("channel ID is empty")
}
store := c.GetMediaStore()
if store == nil {
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
// Collect all files into a single ChannelMessageSendComplex call
files := make([]*discordgo.File, 0, len(msg.Parts))
var caption string
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
logger.ErrorCF("discord", "Failed to resolve media ref", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
continue
}
file, err := os.Open(localPath)
if err != nil {
logger.ErrorCF("discord", "Failed to open media file", map[string]any{
"path": localPath,
"error": err.Error(),
})
continue
}
// Note: discordgo reads from the Reader and we can't close it before send
filename := part.Filename
if filename == "" {
filename = "file"
}
files = append(files, &discordgo.File{
Name: filename,
ContentType: part.ContentType,
Reader: file,
})
if part.Caption != "" && caption == "" {
caption = part.Caption
}
}
if len(files) == 0 {
return nil
}
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Content: caption,
Files: files,
})
done <- err
}()
select {
case err := <-done:
// Close all file readers
for _, f := range files {
if closer, ok := f.Reader.(*os.File); ok {
closer.Close()
}
}
if err != nil {
return fmt.Errorf("discord send media: %w", channels.ErrTemporary)
}
return nil
case <-sendCtx.Done():
// Close all file readers
for _, f := range files {
if closer, ok := f.Reader.(*os.File); ok {
closer.Close()
}
}
return sendCtx.Err()
}
}
// EditMessage implements channels.MessageEditor.
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
_, err := c.session.ChannelMessageEdit(chatID, messageID, content)
return err
}
}
// SendPlaceholder implements channels.PlaceholderCapable.
// It sends a placeholder message that will later be edited to the actual
// response via EditMessage (channels.MessageEditor).
func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
if !c.config.Placeholder.Enabled {
return "", nil
}
return nil
text := c.config.Placeholder.Text
if text == "" {
text = "Thinking... 💭"
}
msg, err := c.session.ChannelMessageSend(chatID, text)
if err != nil {
return "", err
}
return msg.ID, nil
}
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
@ -151,11 +260,11 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin
select {
case err := <-done:
if err != nil {
return fmt.Errorf("failed to send discord message: %w", err)
return fmt.Errorf("discord send: %w", channels.ErrTemporary)
}
return nil
case <-sendCtx.Done():
return fmt.Errorf("send message timeout: %w", sendCtx.Err())
return sendCtx.Err()
}
}
@ -176,17 +285,32 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return
}
// Check allowlist first to avoid downloading attachments and transcribing for rejected users
if !c.IsAllowed(m.Author.ID) {
// Check allowlist first to avoid downloading attachments for rejected users
sender := bus.SenderInfo{
Platform: "discord",
PlatformID: m.Author.ID,
CanonicalID: identity.BuildCanonicalID("discord", m.Author.ID),
Username: m.Author.Username,
}
// Build display name
displayName := m.Author.Username
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
displayName += "#" + m.Author.Discriminator
}
sender.DisplayName = displayName
if !c.IsAllowedSender(sender) {
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
"user_id": m.Author.ID,
})
return
}
// If configured to only respond to mentions, check if bot is mentioned
// Skip this check for DMs (GuildID is empty) - DMs should always be responded to
if c.config.MentionOnly && m.GuildID != "" {
content := m.Content
// In guild (group) channels, apply unified group trigger filtering
// DMs (GuildID is empty) always get a response
if m.GuildID != "" {
isMentioned := false
for _, mention := range m.Mentions {
if mention.ID == c.botUserID {
@ -194,36 +318,39 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
break
}
}
if !isMentioned {
logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{
content = c.stripBotMention(content)
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
logger.DebugCF("discord", "Group message ignored by group trigger", map[string]any{
"user_id": m.Author.ID,
})
return
}
content = cleaned
} else {
// DMs: just strip bot mention without filtering
content = c.stripBotMention(content)
}
senderID := m.Author.ID
senderName := m.Author.Username
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
senderName += "#" + m.Author.Discriminator
}
content := m.Content
content = c.stripBotMention(content)
mediaPaths := make([]string, 0, len(m.Attachments))
localFiles := make([]string, 0, len(m.Attachments))
// Ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)
// Helper to register a local file with the media store
storeMedia := func(localPath, filename string) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
Source: "discord",
}, scope)
if err == nil {
return ref
}
}
}()
return localPath // fallback
}
for _, attachment := range m.Attachments {
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
@ -231,30 +358,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
if isAudio {
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
if localPath != "" {
localFiles = append(localFiles, localPath)
var transcribedText string
if c.transcriber != nil && c.transcriber.IsAvailable() {
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
result, err := c.transcriber.Transcribe(ctx, localPath)
cancel() // Release context resources immediately to avoid leaks in for loop
if err != nil {
logger.ErrorCF("discord", "Voice transcription failed", map[string]any{
"error": err.Error(),
})
transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename)
} else {
transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text)
logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{
"text": result.Text,
})
}
} else {
transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename)
}
content = appendContent(content, transcribedText)
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename))
content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename))
} else {
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
"url": attachment.URL,
@ -277,11 +382,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
content = "[media only]"
}
// Start typing after all early returns — guaranteed to have a matching Send()
c.startTyping(m.ChannelID)
logger.DebugCF("discord", "Received message", map[string]any{
"sender_name": senderName,
"sender_name": sender.DisplayName,
"sender_id": senderID,
"preview": utils.Truncate(content, 50),
})
@ -293,19 +395,18 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
peerID = senderID
}
peer := bus.Peer{Kind: peerKind, ID: peerID}
metadata := map[string]string{
"message_id": m.ID,
"user_id": senderID,
"username": m.Author.Username,
"display_name": senderName,
"display_name": sender.DisplayName,
"guild_id": m.GuildID,
"channel_id": m.ChannelID,
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
"peer_kind": peerKind,
"peer_id": peerID,
}
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender)
}
// startTyping starts a continuous typing indicator loop for the given chatID.
@ -354,6 +455,13 @@ func (c *DiscordChannel) stopTyping(chatID string) {
}
}
// StartTyping implements channels.TypingCapable.
// It starts a continuous typing indicator and returns an idempotent stop function.
func (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
c.startTyping(chatID)
return func() { c.stopTyping(chatID) }, nil
}
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
return utils.DownloadFile(url, filename, utils.DownloadOptions{
LoggerPrefix: "discord",

View file

@ -0,0 +1,13 @@
package discord
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewDiscordChannel(cfg.Channels.Discord, b)
})
}

21
pkg/channels/errors.go Normal file
View file

@ -0,0 +1,21 @@
package channels
import "errors"
var (
// ErrNotRunning indicates the channel is not running.
// Manager will not retry.
ErrNotRunning = errors.New("channel not running")
// ErrRateLimit indicates the platform returned a rate-limit response (e.g. HTTP 429).
// Manager will wait a fixed delay and retry.
ErrRateLimit = errors.New("rate limited")
// ErrTemporary indicates a transient failure (e.g. network timeout, 5xx).
// Manager will use exponential backoff and retry.
ErrTemporary = errors.New("temporary failure")
// ErrSendFailed indicates a permanent failure (e.g. invalid chat ID, 4xx non-429).
// Manager will not retry.
ErrSendFailed = errors.New("send failed")
)

View file

@ -0,0 +1,56 @@
package channels
import (
"errors"
"fmt"
"testing"
)
func TestErrorsIs(t *testing.T) {
wrapped := fmt.Errorf("telegram API: %w", ErrRateLimit)
if !errors.Is(wrapped, ErrRateLimit) {
t.Error("wrapped ErrRateLimit should match")
}
if errors.Is(wrapped, ErrTemporary) {
t.Error("wrapped ErrRateLimit should not match ErrTemporary")
}
}
func TestErrorsIsAllTypes(t *testing.T) {
sentinels := []error{ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed}
for _, sentinel := range sentinels {
wrapped := fmt.Errorf("context: %w", sentinel)
if !errors.Is(wrapped, sentinel) {
t.Errorf("wrapped %v should match itself", sentinel)
}
// Verify it doesn't match other sentinel errors
for _, other := range sentinels {
if other == sentinel {
continue
}
if errors.Is(wrapped, other) {
t.Errorf("wrapped %v should not match %v", sentinel, other)
}
}
}
}
func TestErrorMessages(t *testing.T) {
tests := []struct {
err error
want string
}{
{ErrNotRunning, "channel not running"},
{ErrRateLimit, "rate limited"},
{ErrTemporary, "temporary failure"},
{ErrSendFailed, "send failed"},
}
for _, tt := range tests {
if got := tt.err.Error(); got != tt.want {
t.Errorf("error message = %q, want %q", got, tt.want)
}
}
}

30
pkg/channels/errutil.go Normal file
View file

@ -0,0 +1,30 @@
package channels
import (
"fmt"
"net/http"
)
// ClassifySendError wraps a raw error with the appropriate sentinel based on
// an HTTP status code. Channels that perform HTTP API calls should use this
// in their Send path.
func ClassifySendError(statusCode int, rawErr error) error {
switch {
case statusCode == http.StatusTooManyRequests:
return fmt.Errorf("%w: %v", ErrRateLimit, rawErr)
case statusCode >= 500:
return fmt.Errorf("%w: %v", ErrTemporary, rawErr)
case statusCode >= 400:
return fmt.Errorf("%w: %v", ErrSendFailed, rawErr)
default:
return rawErr
}
}
// ClassifyNetError wraps a network/timeout error as ErrTemporary.
func ClassifyNetError(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%w: %v", ErrTemporary, err)
}

View file

@ -0,0 +1,97 @@
package channels
import (
"errors"
"fmt"
"testing"
)
func TestClassifySendError(t *testing.T) {
raw := fmt.Errorf("some API error")
tests := []struct {
name string
statusCode int
wantIs error
wantNil bool
}{
{"429 -> ErrRateLimit", 429, ErrRateLimit, false},
{"500 -> ErrTemporary", 500, ErrTemporary, false},
{"502 -> ErrTemporary", 502, ErrTemporary, false},
{"503 -> ErrTemporary", 503, ErrTemporary, false},
{"400 -> ErrSendFailed", 400, ErrSendFailed, false},
{"403 -> ErrSendFailed", 403, ErrSendFailed, false},
{"404 -> ErrSendFailed", 404, ErrSendFailed, false},
{"200 -> raw error", 200, nil, false},
{"201 -> raw error", 201, nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ClassifySendError(tt.statusCode, raw)
if err == nil {
t.Fatal("expected non-nil error")
}
if tt.wantIs != nil {
if !errors.Is(err, tt.wantIs) {
t.Errorf("errors.Is(err, %v) = false, want true; err = %v", tt.wantIs, err)
}
} else {
// Should return the raw error unchanged
if err != raw {
t.Errorf("expected raw error to be returned unchanged for status %d, got %v", tt.statusCode, err)
}
}
})
}
}
func TestClassifySendErrorNoFalsePositive(t *testing.T) {
raw := fmt.Errorf("some error")
// 429 should NOT match ErrTemporary or ErrSendFailed
err := ClassifySendError(429, raw)
if errors.Is(err, ErrTemporary) {
t.Error("429 should not match ErrTemporary")
}
if errors.Is(err, ErrSendFailed) {
t.Error("429 should not match ErrSendFailed")
}
// 500 should NOT match ErrRateLimit or ErrSendFailed
err = ClassifySendError(500, raw)
if errors.Is(err, ErrRateLimit) {
t.Error("500 should not match ErrRateLimit")
}
if errors.Is(err, ErrSendFailed) {
t.Error("500 should not match ErrSendFailed")
}
// 400 should NOT match ErrRateLimit or ErrTemporary
err = ClassifySendError(400, raw)
if errors.Is(err, ErrRateLimit) {
t.Error("400 should not match ErrRateLimit")
}
if errors.Is(err, ErrTemporary) {
t.Error("400 should not match ErrTemporary")
}
}
func TestClassifyNetError(t *testing.T) {
t.Run("nil error returns nil", func(t *testing.T) {
if err := ClassifyNetError(nil); err != nil {
t.Errorf("expected nil, got %v", err)
}
})
t.Run("non-nil error wraps as ErrTemporary", func(t *testing.T) {
raw := fmt.Errorf("connection refused")
err := ClassifyNetError(raw)
if err == nil {
t.Fatal("expected non-nil error")
}
if !errors.Is(err, ErrTemporary) {
t.Errorf("errors.Is(err, ErrTemporary) = false, want true; err = %v", err)
}
})
}

View file

@ -0,0 +1,9 @@
package feishu
// stringValue safely dereferences a *string pointer.
func stringValue(v *string) string {
if v == nil {
return ""
}
return *v
}

View file

@ -1,18 +1,19 @@
//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64
package channels
package feishu
import (
"context"
"errors"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
// FeishuChannel is a stub implementation for 32-bit architectures
type FeishuChannel struct {
*BaseChannel
*channels.BaseChannel
}
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported

View file

@ -1,6 +1,6 @@
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
package channels
package feishu
import (
"context"
@ -15,13 +15,15 @@ import (
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
type FeishuChannel struct {
*BaseChannel
*channels.BaseChannel
config config.FeishuConfig
client *lark.Client
wsClient *larkws.Client
@ -31,7 +33,10 @@ type FeishuChannel struct {
}
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
base := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom)
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &FeishuChannel{
BaseChannel: base,
@ -60,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
wsClient := c.wsClient
c.mu.Unlock()
c.setRunning(true)
c.SetRunning(true)
logger.InfoC("feishu", "Feishu channel started (websocket mode)")
go func() {
@ -83,14 +88,14 @@ func (c *FeishuChannel) Stop(ctx context.Context) error {
c.wsClient = nil
c.mu.Unlock()
c.setRunning(false)
c.SetRunning(false)
logger.InfoC("feishu", "Feishu channel stopped")
return nil
}
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("feishu channel not running")
return channels.ErrNotRunning
}
if msg.ChatID == "" {
@ -114,11 +119,11 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
resp, err := c.client.Im.V1.Message.Create(ctx, req)
if err != nil {
return fmt.Errorf("failed to send feishu message: %w", err)
return fmt.Errorf("feishu send: %w", channels.ErrTemporary)
}
if !resp.Success() {
return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
}
logger.DebugCF("feishu", "Feishu message sent", map[string]any{
@ -128,7 +133,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return nil
}
func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2MessageReceiveV1) error {
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
if event == nil || event.Event == nil || event.Event.Message == nil {
return nil
}
@ -152,8 +157,9 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
}
metadata := map[string]string{}
if messageID := stringValue(message.MessageId); messageID != "" {
metadata["message_id"] = messageID
messageID := ""
if mid := stringValue(message.MessageId); mid != "" {
messageID = mid
}
if messageType := stringValue(message.MessageType); messageType != "" {
metadata["message_type"] = messageType
@ -166,12 +172,17 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
}
chatType := stringValue(message.ChatType)
var peer bus.Peer
if chatType == "p2p" {
metadata["peer_kind"] = "direct"
metadata["peer_id"] = senderID
peer = bus.Peer{Kind: "direct", ID: senderID}
} else {
metadata["peer_kind"] = "group"
metadata["peer_id"] = chatID
peer = bus.Peer{Kind: "group", ID: chatID}
// In group chats, apply unified group trigger filtering
respond, cleaned := c.ShouldRespondInGroup(false, content)
if !respond {
return nil
}
content = cleaned
}
logger.InfoCF("feishu", "Feishu message received", map[string]any{
@ -180,7 +191,17 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
"preview": utils.Truncate(content, 80),
})
c.HandleMessage(senderID, chatID, content, nil, metadata)
senderInfo := bus.SenderInfo{
Platform: "feishu",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("feishu", senderID),
}
if !c.IsAllowedSender(senderInfo) {
return nil
}
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, senderInfo)
return nil
}
@ -218,10 +239,3 @@ func extractFeishuMessageContent(message *larkim.EventMessage) string {
return *message.Content
}
func stringValue(v *string) string {
if v == nil {
return ""
}
return *v
}

View file

@ -0,0 +1,13 @@
package feishu
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewFeishuChannel(cfg.Channels.Feishu, b)
})
}

View file

@ -0,0 +1,41 @@
package channels
import "context"
// TypingCapable — channels that can show a typing/thinking indicator.
// StartTyping begins the indicator and returns a stop function.
// The stop function MUST be idempotent and safe to call multiple times.
type TypingCapable interface {
StartTyping(ctx context.Context, chatID string) (stop func(), err error)
}
// MessageEditor — channels that can edit an existing message.
// messageID is always string; channels convert platform-specific types internally.
type MessageEditor interface {
EditMessage(ctx context.Context, chatID string, messageID string, content string) error
}
// ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message.
// ReactToMessage adds a reaction and returns an undo function to remove it.
// The undo function MUST be idempotent and safe to call multiple times.
type ReactionCapable interface {
ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)
}
// PlaceholderCapable — channels that can send a placeholder message
// (e.g. "Thinking... 💭") that will later be edited to the actual response.
// The channel MUST also implement MessageEditor for the placeholder to be useful.
// SendPlaceholder returns the platform message ID of the placeholder so that
// Manager.preSend can later edit it via MessageEditor.EditMessage.
type PlaceholderCapable interface {
SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)
}
// PlaceholderRecorder is injected into channels by Manager.
// Channels call these methods on inbound to register typing/placeholder state.
// Manager uses the registered state on outbound to stop typing and edit placeholders.
type PlaceholderRecorder interface {
RecordPlaceholder(channel, chatID, placeholderID string)
RecordTypingStop(channel, chatID string, stop func())
RecordReactionUndo(channel, chatID string, undo func())
}

13
pkg/channels/line/init.go Normal file
View file

@ -0,0 +1,13 @@
package line
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("line", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewLINEChannel(cfg.Channels.LINE, b)
})
}

View file

@ -1,4 +1,4 @@
package channels
package line
import (
"bytes"
@ -10,14 +10,16 @@ import (
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
)
@ -41,9 +43,8 @@ type replyTokenEntry struct {
// using the LINE Messaging API with HTTP webhook for receiving messages
// and REST API for sending messages.
type LINEChannel struct {
*BaseChannel
*channels.BaseChannel
config config.LINEConfig
httpServer *http.Server
botUserID string // Bot's user ID
botBasicID string // Bot's basic ID (e.g. @216ru...)
botDisplayName string // Bot's display name for text-based mention detection
@ -59,7 +60,11 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
}
base := NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom)
base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(5000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &LINEChannel{
BaseChannel: base,
@ -67,7 +72,7 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
}, nil
}
// Start launches the HTTP webhook server.
// Start initializes the LINE channel.
func (c *LINEChannel) Start(ctx context.Context) error {
logger.InfoC("line", "Starting LINE channel (Webhook Mode)")
@ -86,32 +91,7 @@ func (c *LINEChannel) Start(ctx context.Context) error {
})
}
mux := http.NewServeMux()
path := c.config.WebhookPath
if path == "" {
path = "/webhook/line"
}
mux.HandleFunc(path, c.webhookHandler)
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
c.httpServer = &http.Server{
Addr: addr,
Handler: mux,
}
go func() {
logger.InfoCF("line", "LINE webhook server listening", map[string]any{
"addr": addr,
"path": path,
})
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("line", "Webhook server error", map[string]any{
"error": err.Error(),
})
}
}()
c.setRunning(true)
c.SetRunning(true)
logger.InfoC("line", "LINE channel started (Webhook Mode)")
return nil
}
@ -150,7 +130,7 @@ func (c *LINEChannel) fetchBotInfo() error {
return nil
}
// Stop gracefully shuts down the HTTP server.
// Stop gracefully stops the LINE channel.
func (c *LINEChannel) Stop(ctx context.Context) error {
logger.InfoC("line", "Stopping LINE channel")
@ -158,21 +138,24 @@ func (c *LINEChannel) Stop(ctx context.Context) error {
c.cancel()
}
if c.httpServer != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{
"error": err.Error(),
})
}
}
c.setRunning(false)
c.SetRunning(false)
logger.InfoC("line", "LINE channel stopped")
return nil
}
// WebhookPath returns the path for registering on the shared HTTP server.
func (c *LINEChannel) WebhookPath() string {
if c.config.WebhookPath != "" {
return c.config.WebhookPath
}
return "/webhook/line"
}
// ServeHTTP implements http.Handler for the shared HTTP server.
func (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.webhookHandler(w, r)
}
// webhookHandler handles incoming LINE webhook requests.
func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@ -284,14 +267,6 @@ func (c *LINEChannel) processEvent(event lineEvent) {
return
}
// In group chats, only respond when the bot is mentioned
if isGroup && !c.isBotMentioned(msg) {
logger.DebugCF("line", "Ignoring group message without mention", map[string]any{
"chat_id": chatID,
})
return
}
// Store reply token for later use
if event.ReplyToken != "" {
c.replyTokens.Store(chatID, replyTokenEntry{
@ -307,18 +282,22 @@ func (c *LINEChannel) processEvent(event lineEvent) {
var content string
var mediaPaths []string
localFiles := []string{}
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
scope := channels.BuildMediaScope("line", chatID, msg.ID)
// Helper to register a local file with the media store
storeMedia := func(localPath, filename string) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
Source: "line",
}, scope)
if err == nil {
return ref
}
}
}()
return localPath // fallback
}
switch msg.Type {
case "text":
@ -330,22 +309,19 @@ func (c *LINEChannel) processEvent(event lineEvent) {
case "image":
localPath := c.downloadContent(msg.ID, "image.jpg")
if localPath != "" {
localFiles = append(localFiles, localPath)
mediaPaths = append(mediaPaths, localPath)
mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg"))
content = "[image]"
}
case "audio":
localPath := c.downloadContent(msg.ID, "audio.m4a")
if localPath != "" {
localFiles = append(localFiles, localPath)
mediaPaths = append(mediaPaths, localPath)
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a"))
content = "[audio]"
}
case "video":
localPath := c.downloadContent(msg.ID, "video.mp4")
if localPath != "" {
localFiles = append(localFiles, localPath)
mediaPaths = append(mediaPaths, localPath)
mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4"))
content = "[video]"
}
case "file":
@ -360,18 +336,29 @@ func (c *LINEChannel) processEvent(event lineEvent) {
return
}
// In group chats, apply unified group trigger filtering
if isGroup {
isMentioned := c.isBotMentioned(msg)
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{
"chat_id": chatID,
})
return
}
content = cleaned
}
metadata := map[string]string{
"platform": "line",
"source_type": event.Source.Type,
"message_id": msg.ID,
}
var peer bus.Peer
if isGroup {
metadata["peer_kind"] = "group"
metadata["peer_id"] = chatID
peer = bus.Peer{Kind: "group", ID: chatID}
} else {
metadata["peer_kind"] = "direct"
metadata["peer_id"] = senderID
peer = bus.Peer{Kind: "direct", ID: senderID}
}
logger.DebugCF("line", "Received message", map[string]any{
@ -382,10 +369,17 @@ func (c *LINEChannel) processEvent(event lineEvent) {
"preview": utils.Truncate(content, 50),
})
// Show typing/loading indicator (requires user ID, not group ID)
c.sendLoading(senderID)
sender := bus.SenderInfo{
Platform: "line",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("line", senderID),
}
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
if !c.IsAllowedSender(sender) {
return
}
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender)
}
// isBotMentioned checks if the bot is mentioned in the message.
@ -491,7 +485,7 @@ func (c *LINEChannel) resolveChatID(source lineSource) string {
// using a cached reply token, then falls back to the Push API.
func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("line channel not running")
return channels.ErrNotRunning
}
// Load and consume quote token for this chat
@ -519,6 +513,36 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
}
// SendMedia implements the channels.MediaSender interface.
// LINE requires media to be accessible via public URL; since we only have local files,
// we fall back to sending a text message with the filename/caption.
// For full support, an external file hosting service would be needed.
func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
store := c.GetMediaStore()
if store == nil {
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
// LINE Messaging API requires publicly accessible URLs for media messages.
// Since we only have local file paths, send caption text as fallback.
for _, part := range msg.Parts {
caption := part.Caption
if caption == "" {
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
}
if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil {
return err
}
}
return nil
}
// buildTextMessage creates a text message object, optionally with quoteToken.
func buildTextMessage(content, quoteToken string) map[string]string {
msg := map[string]string{
@ -551,17 +575,58 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri
return c.callAPI(ctx, linePushEndpoint, payload)
}
// StartTyping implements channels.TypingCapable using LINE's loading animation.
//
// NOTE: The LINE loading animation API only works for 1:1 chats.
// Group/room chat IDs (starting with "C" or "R") are detected automatically;
// for these, a no-op stop function is returned without calling the API.
func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
if chatID == "" {
return func() {}, nil
}
// Group/room chats: LINE loading animation is 1:1 only.
if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") {
return func() {}, nil
}
typingCtx, cancel := context.WithCancel(ctx)
var once sync.Once
stop := func() { once.Do(cancel) }
// Send immediately, then refresh periodically for long-running tasks.
if err := c.sendLoading(typingCtx, chatID); err != nil {
stop()
return stop, err
}
ticker := time.NewTicker(50 * time.Second)
go func() {
defer ticker.Stop()
for {
select {
case <-typingCtx.Done():
return
case <-ticker.C:
if err := c.sendLoading(typingCtx, chatID); err != nil {
logger.DebugCF("line", "Failed to refresh loading indicator", map[string]any{
"error": err.Error(),
})
}
}
}
}()
return stop, nil
}
// sendLoading sends a loading animation indicator to the chat.
func (c *LINEChannel) sendLoading(chatID string) {
func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
payload := map[string]any{
"chatId": chatID,
"loadingSeconds": 60,
}
if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
logger.DebugCF("line", "Failed to send loading indicator", map[string]any{
"error": err.Error(),
})
}
return c.callAPI(ctx, lineLoadingEndpoint, payload)
}
// callAPI makes an authenticated POST request to the LINE API.
@ -582,13 +647,13 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("API request failed: %w", err)
return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody))
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
}
return nil

View file

@ -0,0 +1,13 @@
package maixcam
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("maixcam", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewMaixCamChannel(cfg.Channels.MaixCam, b)
})
}

View file

@ -1,4 +1,4 @@
package channels
package maixcam
import (
"context"
@ -6,16 +6,21 @@ import (
"fmt"
"net"
"sync"
"time"
"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"
)
type MaixCamChannel struct {
*BaseChannel
*channels.BaseChannel
config config.MaixCamConfig
listener net.Listener
ctx context.Context
cancel context.CancelFunc
clients map[net.Conn]bool
clientsMux sync.RWMutex
}
@ -28,7 +33,13 @@ type MaixCamMessage struct {
}
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
base := NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom)
base := channels.NewBaseChannel(
"maixcam",
cfg,
bus,
cfg.AllowFrom,
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &MaixCamChannel{
BaseChannel: base,
@ -40,37 +51,40 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC
func (c *MaixCamChannel) Start(ctx context.Context) error {
logger.InfoC("maixcam", "Starting MaixCam channel server")
c.ctx, c.cancel = context.WithCancel(ctx)
addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
listener, err := net.Listen("tcp", addr)
if err != nil {
c.cancel()
return fmt.Errorf("failed to listen on %s: %w", addr, err)
}
c.listener = listener
c.setRunning(true)
c.SetRunning(true)
logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{
"host": c.config.Host,
"port": c.config.Port,
})
go c.acceptConnections(ctx)
go c.acceptConnections()
return nil
}
func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
func (c *MaixCamChannel) acceptConnections() {
logger.DebugC("maixcam", "Starting connection acceptor")
for {
select {
case <-ctx.Done():
case <-c.ctx.Done():
logger.InfoC("maixcam", "Stopping connection acceptor")
return
default:
conn, err := c.listener.Accept()
if err != nil {
if c.running {
if c.IsRunning() {
logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{
"error": err.Error(),
})
@ -86,12 +100,12 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
c.clients[conn] = true
c.clientsMux.Unlock()
go c.handleConnection(conn, ctx)
go c.handleConnection(conn)
}
}
}
func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
func (c *MaixCamChannel) handleConnection(conn net.Conn) {
logger.DebugC("maixcam", "Handling MaixCam connection")
defer func() {
@ -106,7 +120,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
for {
select {
case <-ctx.Done():
case <-c.ctx.Done():
return
default:
var msg MaixCamMessage
@ -170,11 +184,29 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
"y": fmt.Sprintf("%.0f", y),
"w": fmt.Sprintf("%.0f", w),
"h": fmt.Sprintf("%.0f", h),
"peer_kind": "channel",
"peer_id": "default",
}
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
sender := bus.SenderInfo{
Platform: "maixcam",
PlatformID: "maixcam",
CanonicalID: identity.BuildCanonicalID("maixcam", "maixcam"),
}
if !c.IsAllowedSender(sender) {
return
}
c.HandleMessage(
c.ctx,
bus.Peer{Kind: "channel", ID: "default"},
"",
senderID,
chatID,
content,
[]string{},
metadata,
sender,
)
}
func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
@ -185,7 +217,12 @@ func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
func (c *MaixCamChannel) Stop(ctx context.Context) error {
logger.InfoC("maixcam", "Stopping MaixCam channel")
c.setRunning(false)
c.SetRunning(false)
// Cancel context first to signal goroutines to exit
if c.cancel != nil {
c.cancel()
}
if c.listener != nil {
c.listener.Close()
@ -205,7 +242,14 @@ func (c *MaixCamChannel) Stop(ctx context.Context) error {
func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("maixcam channel not running")
return channels.ErrNotRunning
}
// Check ctx before entering write path
select {
case <-ctx.Done():
return ctx.Err()
default:
}
c.clientsMux.RLock()
@ -230,13 +274,15 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
var sendErr error
for conn := range c.clients {
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if _, err := conn.Write(data); err != nil {
logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{
"client": conn.RemoteAddr().String(),
"error": err.Error(),
})
sendErr = err
sendErr = fmt.Errorf("maixcam send: %w", channels.ErrTemporary)
}
_ = conn.SetWriteDeadline(time.Time{})
}
return sendErr

View file

@ -8,32 +8,152 @@ package channels
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
)
const (
defaultChannelQueueSize = 16
defaultRateLimit = 10 // default 10 msg/s
maxRetries = 3
rateLimitDelay = 1 * time.Second
baseBackoff = 500 * time.Millisecond
maxBackoff = 8 * time.Second
janitorInterval = 10 * time.Second
typingStopTTL = 5 * time.Minute
placeholderTTL = 10 * time.Minute
)
// typingEntry wraps a typing stop function with a creation timestamp for TTL eviction.
type typingEntry struct {
stop func()
createdAt time.Time
}
// reactionEntry wraps a reaction undo function with a creation timestamp for TTL eviction.
type reactionEntry struct {
undo func()
createdAt time.Time
}
// placeholderEntry wraps a placeholder ID with a creation timestamp for TTL eviction.
type placeholderEntry struct {
id string
createdAt time.Time
}
// channelRateConfig maps channel name to per-second rate limit.
var channelRateConfig = map[string]float64{
"telegram": 20,
"discord": 1,
"slack": 1,
"line": 10,
}
type channelWorker struct {
ch Channel
queue chan bus.OutboundMessage
mediaQueue chan bus.OutboundMediaMessage
done chan struct{}
mediaDone chan struct{}
limiter *rate.Limiter
}
type Manager struct {
channels map[string]Channel
workers map[string]*channelWorker
bus *bus.MessageBus
config *config.Config
mediaStore media.MediaStore
dispatchTask *asyncTask
mux *http.ServeMux
httpServer *http.Server
mu sync.RWMutex
placeholders sync.Map // "channel:chatID" → placeholderID (string)
typingStops sync.Map // "channel:chatID" → func()
reactionUndos sync.Map // "channel:chatID" → reactionEntry
}
type asyncTask struct {
cancel context.CancelFunc
}
func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error) {
// RecordPlaceholder registers a placeholder message for later editing.
// Implements PlaceholderRecorder.
func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
key := channel + ":" + chatID
m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})
}
// RecordTypingStop registers a typing stop function for later invocation.
// Implements PlaceholderRecorder.
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
key := channel + ":" + chatID
m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()})
}
// RecordReactionUndo registers a reaction undo function for later invocation.
// Implements PlaceholderRecorder.
func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
key := channel + ":" + chatID
m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()})
}
// preSend handles typing stop, reaction undo, and placeholder editing before sending a message.
// Returns true if the message was edited into a placeholder (skip Send).
func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool {
key := name + ":" + msg.ChatID
// 1. Stop typing
if v, loaded := m.typingStops.LoadAndDelete(key); loaded {
if entry, ok := v.(typingEntry); ok {
entry.stop() // idempotent, safe
}
}
// 2. Undo reaction
if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
if entry, ok := v.(reactionEntry); ok {
entry.undo() // idempotent, safe
}
}
// 3. Try editing placeholder
if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if editor, ok := ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil {
return true // edited successfully, skip Send
}
// edit failed → fall through to normal Send
}
}
}
return false
}
func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) {
m := &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
bus: messageBus,
config: cfg,
mediaStore: store,
}
if err := m.initChannels(); err != nil {
@ -43,163 +163,104 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error
return m, nil
}
// initChannel is a helper that looks up a factory by name and creates the channel.
func (m *Manager) initChannel(name, displayName string) {
f, ok := getFactory(name)
if !ok {
logger.WarnCF("channels", "Factory not registered", map[string]any{
"channel": displayName,
})
return
}
logger.DebugCF("channels", "Attempting to initialize channel", map[string]any{
"channel": displayName,
})
ch, err := f(m.config, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize channel", map[string]any{
"channel": displayName,
"error": err.Error(),
})
} else {
// Inject MediaStore if channel supports it
if m.mediaStore != nil {
if setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok {
setter.SetMediaStore(m.mediaStore)
}
}
// Inject PlaceholderRecorder if channel supports it
if setter, ok := ch.(interface{ SetPlaceholderRecorder(r PlaceholderRecorder) }); ok {
setter.SetPlaceholderRecorder(m)
}
// Inject owner reference so BaseChannel.HandleMessage can auto-trigger typing/reaction
if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok {
setter.SetOwner(ch)
}
m.channels[name] = ch
logger.InfoCF("channels", "Channel enabled successfully", map[string]any{
"channel": displayName,
})
}
}
func (m *Manager) initChannels() error {
logger.InfoC("channels", "Initializing channel manager")
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
logger.DebugC("channels", "Attempting to initialize Telegram channel")
telegram, err := NewTelegramChannel(m.config, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["telegram"] = telegram
logger.InfoC("channels", "Telegram channel enabled successfully")
}
m.initChannel("telegram", "Telegram")
}
if m.config.Channels.WhatsApp.Enabled && m.config.Channels.WhatsApp.BridgeURL != "" {
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["whatsapp"] = whatsapp
logger.InfoC("channels", "WhatsApp channel enabled successfully")
if m.config.Channels.WhatsApp.Enabled {
waCfg := m.config.Channels.WhatsApp
if waCfg.UseNative {
m.initChannel("whatsapp_native", "WhatsApp Native")
} else if waCfg.BridgeURL != "" {
m.initChannel("whatsapp", "WhatsApp")
}
}
if m.config.Channels.Feishu.Enabled {
logger.DebugC("channels", "Attempting to initialize Feishu channel")
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["feishu"] = feishu
logger.InfoC("channels", "Feishu channel enabled successfully")
}
m.initChannel("feishu", "Feishu")
}
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
logger.DebugC("channels", "Attempting to initialize Discord channel")
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["discord"] = discord
logger.InfoC("channels", "Discord channel enabled successfully")
}
m.initChannel("discord", "Discord")
}
if m.config.Channels.MaixCam.Enabled {
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["maixcam"] = maixcam
logger.InfoC("channels", "MaixCam channel enabled successfully")
}
m.initChannel("maixcam", "MaixCam")
}
if m.config.Channels.QQ.Enabled {
logger.DebugC("channels", "Attempting to initialize QQ channel")
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["qq"] = qq
logger.InfoC("channels", "QQ channel enabled successfully")
}
m.initChannel("qq", "QQ")
}
if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" {
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["dingtalk"] = dingtalk
logger.InfoC("channels", "DingTalk channel enabled successfully")
}
m.initChannel("dingtalk", "DingTalk")
}
if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" {
logger.DebugC("channels", "Attempting to initialize Slack channel")
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["slack"] = slackCh
logger.InfoC("channels", "Slack channel enabled successfully")
}
m.initChannel("slack", "Slack")
}
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
logger.DebugC("channels", "Attempting to initialize LINE channel")
line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["line"] = line
logger.InfoC("channels", "LINE channel enabled successfully")
}
m.initChannel("line", "LINE")
}
if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
logger.DebugC("channels", "Attempting to initialize OneBot channel")
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["onebot"] = onebot
logger.InfoC("channels", "OneBot channel enabled successfully")
}
m.initChannel("onebot", "OneBot")
}
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
logger.DebugC("channels", "Attempting to initialize WeCom channel")
wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["wecom"] = wecom
logger.InfoC("channels", "WeCom channel enabled successfully")
}
m.initChannel("wecom", "WeCom")
}
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
logger.DebugC("channels", "Attempting to initialize WeCom App channel")
wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]any{
"error": err.Error(),
})
} else {
m.channels["wecom_app"] = wecomApp
logger.InfoC("channels", "WeCom App channel enabled successfully")
m.initChannel("wecom_app", "WeCom App")
}
if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token != "" {
m.initChannel("pico", "Pico")
}
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
@ -209,6 +270,43 @@ func (m *Manager) initChannels() error {
return nil
}
// SetupHTTPServer creates a shared HTTP server with the given listen address.
// It registers health endpoints from the health server and discovers channels
// that implement WebhookHandler and/or HealthChecker to register their handlers.
func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) {
m.mux = http.NewServeMux()
// Register health endpoints
if healthServer != nil {
healthServer.RegisterOnMux(m.mux)
}
// Discover and register webhook handlers and health checkers
for name, ch := range m.channels {
if wh, ok := ch.(WebhookHandler); ok {
m.mux.Handle(wh.WebhookPath(), wh)
logger.InfoCF("channels", "Webhook handler registered", map[string]any{
"channel": name,
"path": wh.WebhookPath(),
})
}
if hc, ok := ch.(HealthChecker); ok {
m.mux.HandleFunc(hc.HealthPath(), hc.HealthHandler)
logger.InfoCF("channels", "Health endpoint registered", map[string]any{
"channel": name,
"path": hc.HealthPath(),
})
}
}
m.httpServer = &http.Server{
Addr: addr,
Handler: m.mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
}
func (m *Manager) StartAll(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
@ -223,8 +321,6 @@ func (m *Manager) StartAll(ctx context.Context) error {
dispatchCtx, cancel := context.WithCancel(ctx)
m.dispatchTask = &asyncTask{cancel: cancel}
go m.dispatchOutbound(dispatchCtx)
for name, channel := range m.channels {
logger.InfoCF("channels", "Starting channel", map[string]any{
"channel": name,
@ -234,7 +330,34 @@ func (m *Manager) StartAll(ctx context.Context) error {
"channel": name,
"error": err.Error(),
})
continue
}
// Lazily create worker only after channel starts successfully
w := newChannelWorker(name, channel)
m.workers[name] = w
go m.runWorker(dispatchCtx, name, w)
go m.runMediaWorker(dispatchCtx, name, w)
}
// Start the dispatcher that reads from the bus and routes to workers
go m.dispatchOutbound(dispatchCtx)
go m.dispatchOutboundMedia(dispatchCtx)
// Start the TTL janitor that cleans up stale typing/placeholder entries
go m.runTTLJanitor(dispatchCtx)
// Start shared HTTP server if configured
if m.httpServer != nil {
go func() {
logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{
"addr": m.httpServer.Addr,
})
if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("channels", "Shared HTTP server error", map[string]any{
"error": err.Error(),
})
}
}()
}
logger.InfoC("channels", "All channels started")
@ -247,11 +370,48 @@ func (m *Manager) StopAll(ctx context.Context) error {
logger.InfoC("channels", "Stopping all channels")
// Shutdown shared HTTP server first
if m.httpServer != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := m.httpServer.Shutdown(shutdownCtx); err != nil {
logger.ErrorCF("channels", "Shared HTTP server shutdown error", map[string]any{
"error": err.Error(),
})
}
m.httpServer = nil
}
// Cancel dispatcher
if m.dispatchTask != nil {
m.dispatchTask.cancel()
m.dispatchTask = nil
}
// Close all worker queues and wait for them to drain
for _, w := range m.workers {
if w != nil {
close(w.queue)
}
}
for _, w := range m.workers {
if w != nil {
<-w.done
}
}
// Close all media worker queues and wait for them to drain
for _, w := range m.workers {
if w != nil {
close(w.mediaQueue)
}
}
for _, w := range m.workers {
if w != nil {
<-w.mediaDone
}
}
// Stop all channels
for name, channel := range m.channels {
logger.InfoCF("channels", "Stopping channel", map[string]any{
"channel": name,
@ -268,18 +428,125 @@ func (m *Manager) StopAll(ctx context.Context) error {
return nil
}
// newChannelWorker creates a channelWorker with a rate limiter configured
// for the given channel name.
func newChannelWorker(name string, ch Channel) *channelWorker {
rateVal := float64(defaultRateLimit)
if r, ok := channelRateConfig[name]; ok {
rateVal = r
}
burst := int(math.Max(1, math.Ceil(rateVal/2)))
return &channelWorker{
ch: ch,
queue: make(chan bus.OutboundMessage, defaultChannelQueueSize),
mediaQueue: make(chan bus.OutboundMediaMessage, defaultChannelQueueSize),
done: make(chan struct{}),
mediaDone: make(chan struct{}),
limiter: rate.NewLimiter(rate.Limit(rateVal), burst),
}
}
// runWorker processes outbound messages for a single channel, splitting
// messages that exceed the channel's maximum message length.
func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) {
defer close(w.done)
for {
select {
case msg, ok := <-w.queue:
if !ok {
return
}
maxLen := 0
if mlp, ok := w.ch.(MessageLengthProvider); ok {
maxLen = mlp.MaxMessageLength()
}
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
chunks := SplitMessage(msg.Content, maxLen)
for _, chunk := range chunks {
chunkMsg := msg
chunkMsg.Content = chunk
m.sendWithRetry(ctx, name, w, chunkMsg)
}
} else {
m.sendWithRetry(ctx, name, w, msg)
}
case <-ctx.Done():
return
}
}
}
// sendWithRetry sends a message through the channel with rate limiting and
// retry logic. It classifies errors to determine the retry strategy:
// - ErrNotRunning / ErrSendFailed: permanent, no retry
// - ErrRateLimit: fixed delay retry
// - ErrTemporary / unknown: exponential backoff retry
func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) {
// Rate limit: wait for token
if err := w.limiter.Wait(ctx); err != nil {
// ctx canceled, shutting down
return
}
// Pre-send: stop typing and try to edit placeholder
if m.preSend(ctx, name, msg, w.ch) {
return // placeholder was edited successfully, skip Send
}
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
lastErr = w.ch.Send(ctx, msg)
if lastErr == nil {
return
}
// Permanent failures — don't retry
if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) {
break
}
// Last attempt exhausted — don't sleep
if attempt == maxRetries {
break
}
// Rate limit error — fixed delay
if errors.Is(lastErr, ErrRateLimit) {
select {
case <-time.After(rateLimitDelay):
continue
case <-ctx.Done():
return
}
}
// ErrTemporary or unknown error — exponential backoff
backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff)
select {
case <-time.After(backoff):
case <-ctx.Done():
return
}
}
// All retries exhausted or permanent failure
logger.ErrorCF("channels", "Send failed", map[string]any{
"channel": name,
"chat_id": msg.ChatID,
"error": lastErr.Error(),
"retries": maxRetries,
})
}
func (m *Manager) dispatchOutbound(ctx context.Context) {
logger.InfoC("channels", "Outbound dispatcher started")
for {
select {
case <-ctx.Done():
logger.InfoC("channels", "Outbound dispatcher stopped")
return
default:
msg, ok := m.bus.SubscribeOutbound(ctx)
if !ok {
continue
logger.InfoC("channels", "Outbound dispatcher stopped")
return
}
// Silently skip internal channels
@ -288,7 +555,8 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
}
m.mu.RLock()
channel, exists := m.channels[msg.Channel]
_, exists := m.channels[msg.Channel]
w, wExists := m.workers[msg.Channel]
m.mu.RUnlock()
if !exists {
@ -298,13 +566,179 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
continue
}
if err := channel.Send(ctx, msg); err != nil {
logger.ErrorCF("channels", "Error sending message to channel", map[string]any{
if wExists && w != nil {
select {
case w.queue <- msg:
case <-ctx.Done():
return
}
} else if exists {
logger.WarnCF("channels", "Channel has no active worker, skipping message", map[string]any{
"channel": msg.Channel,
"error": err.Error(),
})
}
}
}
func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
logger.InfoC("channels", "Outbound media dispatcher started")
for {
msg, ok := m.bus.SubscribeOutboundMedia(ctx)
if !ok {
logger.InfoC("channels", "Outbound media dispatcher stopped")
return
}
// Silently skip internal channels
if constants.IsInternalChannel(msg.Channel) {
continue
}
m.mu.RLock()
_, exists := m.channels[msg.Channel]
w, wExists := m.workers[msg.Channel]
m.mu.RUnlock()
if !exists {
logger.WarnCF("channels", "Unknown channel for outbound media message", map[string]any{
"channel": msg.Channel,
})
continue
}
if wExists && w != nil {
select {
case w.mediaQueue <- msg:
case <-ctx.Done():
return
}
} else if exists {
logger.WarnCF("channels", "Channel has no active worker, skipping media message", map[string]any{
"channel": msg.Channel,
})
}
}
}
// runMediaWorker processes outbound media messages for a single channel.
func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWorker) {
defer close(w.mediaDone)
for {
select {
case msg, ok := <-w.mediaQueue:
if !ok {
return
}
m.sendMediaWithRetry(ctx, name, w, msg)
case <-ctx.Done():
return
}
}
}
// sendMediaWithRetry sends a media message through the channel with rate limiting and
// retry logic. If the channel does not implement MediaSender, it silently skips.
func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMediaMessage) {
ms, ok := w.ch.(MediaSender)
if !ok {
logger.DebugCF("channels", "Channel does not support MediaSender, skipping media", map[string]any{
"channel": name,
})
return
}
// Rate limit: wait for token
if err := w.limiter.Wait(ctx); err != nil {
return
}
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
lastErr = ms.SendMedia(ctx, msg)
if lastErr == nil {
return
}
// Permanent failures — don't retry
if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) {
break
}
// Last attempt exhausted — don't sleep
if attempt == maxRetries {
break
}
// Rate limit error — fixed delay
if errors.Is(lastErr, ErrRateLimit) {
select {
case <-time.After(rateLimitDelay):
continue
case <-ctx.Done():
return
}
}
// ErrTemporary or unknown error — exponential backoff
backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff)
select {
case <-time.After(backoff):
case <-ctx.Done():
return
}
}
// All retries exhausted or permanent failure
logger.ErrorCF("channels", "SendMedia failed", map[string]any{
"channel": name,
"chat_id": msg.ChatID,
"error": lastErr.Error(),
"retries": maxRetries,
})
}
// runTTLJanitor periodically scans the typingStops and placeholders maps
// and evicts entries that have exceeded their TTL. This prevents memory
// accumulation when outbound paths fail to trigger preSend (e.g. LLM errors).
func (m *Manager) runTTLJanitor(ctx context.Context) {
ticker := time.NewTicker(janitorInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
m.typingStops.Range(func(key, value any) bool {
if entry, ok := value.(typingEntry); ok {
if now.Sub(entry.createdAt) > typingStopTTL {
if _, loaded := m.typingStops.LoadAndDelete(key); loaded {
entry.stop() // idempotent, safe
}
}
}
return true
})
m.reactionUndos.Range(func(key, value any) bool {
if entry, ok := value.(reactionEntry); ok {
if now.Sub(entry.createdAt) > typingStopTTL {
if _, loaded := m.reactionUndos.LoadAndDelete(key); loaded {
entry.undo() // idempotent, safe
}
}
}
return true
})
m.placeholders.Range(func(key, value any) bool {
if entry, ok := value.(placeholderEntry); ok {
if now.Sub(entry.createdAt) > placeholderTTL {
m.placeholders.Delete(key)
}
}
return true
})
}
}
}
@ -349,12 +783,20 @@ func (m *Manager) RegisterChannel(name string, channel Channel) {
func (m *Manager) UnregisterChannel(name string) {
m.mu.Lock()
defer m.mu.Unlock()
if w, ok := m.workers[name]; ok && w != nil {
close(w.queue)
<-w.done
close(w.mediaQueue)
<-w.mediaDone
}
delete(m.workers, name)
delete(m.channels, name)
}
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
m.mu.RLock()
channel, exists := m.channels[channelName]
_, exists := m.channels[channelName]
w, wExists := m.workers[channelName]
m.mu.RUnlock()
if !exists {
@ -367,5 +809,16 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten
Content: content,
}
if wExists && w != nil {
select {
case w.queue <- msg:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Fallback: direct send (should not happen)
channel, _ := m.channels[channelName]
return channel.Send(ctx, msg)
}

View file

@ -0,0 +1,862 @@
package channels
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"golang.org/x/time/rate"
"github.com/sipeed/picoclaw/pkg/bus"
)
// mockChannel is a test double that delegates Send to a configurable function.
type mockChannel struct {
BaseChannel
sendFn func(ctx context.Context, msg bus.OutboundMessage) error
}
func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return m.sendFn(ctx, msg)
}
func (m *mockChannel) Start(ctx context.Context) error { return nil }
func (m *mockChannel) Stop(ctx context.Context) error { return nil }
// newTestManager creates a minimal Manager suitable for unit tests.
func newTestManager() *Manager {
return &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
}
}
func TestSendWithRetry_Success(t *testing.T) {
m := newTestManager()
var callCount int
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
m.sendWithRetry(ctx, "test", w, msg)
if callCount != 1 {
t.Fatalf("expected 1 Send call, got %d", callCount)
}
}
func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) {
m := newTestManager()
var callCount int
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
if callCount <= 2 {
return fmt.Errorf("network error: %w", ErrTemporary)
}
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
m.sendWithRetry(ctx, "test", w, msg)
if callCount != 3 {
t.Fatalf("expected 3 Send calls (2 failures + 1 success), got %d", callCount)
}
}
func TestSendWithRetry_PermanentFailure(t *testing.T) {
m := newTestManager()
var callCount int
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
return fmt.Errorf("bad chat ID: %w", ErrSendFailed)
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
m.sendWithRetry(ctx, "test", w, msg)
if callCount != 1 {
t.Fatalf("expected 1 Send call (no retry for permanent failure), got %d", callCount)
}
}
func TestSendWithRetry_NotRunning(t *testing.T) {
m := newTestManager()
var callCount int
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
return ErrNotRunning
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
m.sendWithRetry(ctx, "test", w, msg)
if callCount != 1 {
t.Fatalf("expected 1 Send call (no retry for ErrNotRunning), got %d", callCount)
}
}
func TestSendWithRetry_RateLimitRetry(t *testing.T) {
m := newTestManager()
var callCount int
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
if callCount == 1 {
return fmt.Errorf("429: %w", ErrRateLimit)
}
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
start := time.Now()
m.sendWithRetry(ctx, "test", w, msg)
elapsed := time.Since(start)
if callCount != 2 {
t.Fatalf("expected 2 Send calls (1 rate limit + 1 success), got %d", callCount)
}
// Should have waited at least rateLimitDelay (1s) but allow some slack
if elapsed < 900*time.Millisecond {
t.Fatalf("expected at least ~1s delay for rate limit retry, got %v", elapsed)
}
}
func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) {
m := newTestManager()
var callCount int
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
return fmt.Errorf("timeout: %w", ErrTemporary)
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
m.sendWithRetry(ctx, "test", w, msg)
expected := maxRetries + 1 // initial attempt + maxRetries retries
if callCount != expected {
t.Fatalf("expected %d Send calls, got %d", expected, callCount)
}
}
func TestSendWithRetry_UnknownError(t *testing.T) {
m := newTestManager()
var callCount int
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
if callCount == 1 {
return errors.New("random unexpected error")
}
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
m.sendWithRetry(ctx, "test", w, msg)
if callCount != 2 {
t.Fatalf("expected 2 Send calls (unknown error treated as temporary), got %d", callCount)
}
}
func TestSendWithRetry_ContextCancelled(t *testing.T) {
m := newTestManager()
var callCount int
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
return fmt.Errorf("timeout: %w", ErrTemporary)
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx, cancel := context.WithCancel(context.Background())
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
// Cancel context after first Send attempt returns
ch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
cancel()
return fmt.Errorf("timeout: %w", ErrTemporary)
}
m.sendWithRetry(ctx, "test", w, msg)
// Should have called Send once, then noticed ctx canceled during backoff
if callCount != 1 {
t.Fatalf("expected 1 Send call before context cancellation, got %d", callCount)
}
}
func TestWorkerRateLimiter(t *testing.T) {
m := newTestManager()
var mu sync.Mutex
var sendTimes []time.Time
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
mu.Lock()
sendTimes = append(sendTimes, time.Now())
mu.Unlock()
return nil
},
}
// Create a worker with a low rate: 2 msg/s, burst 1
w := &channelWorker{
ch: ch,
queue: make(chan bus.OutboundMessage, 10),
done: make(chan struct{}),
limiter: rate.NewLimiter(2, 1),
}
ctx := t.Context()
go m.runWorker(ctx, "test", w)
// Enqueue 4 messages
for i := range 4 {
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)}
}
// Wait enough time for all messages to be sent (4 msgs at 2/s = ~2s, give extra margin)
time.Sleep(3 * time.Second)
mu.Lock()
times := make([]time.Time, len(sendTimes))
copy(times, sendTimes)
mu.Unlock()
if len(times) != 4 {
t.Fatalf("expected 4 sends, got %d", len(times))
}
// Verify rate limiting: total duration should be at least 1s
// (first message immediate, then ~500ms between each subsequent one at 2/s)
totalDuration := times[len(times)-1].Sub(times[0])
if totalDuration < 1*time.Second {
t.Fatalf("expected total duration >= 1s for 4 msgs at 2/s rate, got %v", totalDuration)
}
}
func TestNewChannelWorker_DefaultRate(t *testing.T) {
ch := &mockChannel{}
w := newChannelWorker("unknown_channel", ch)
if w.limiter == nil {
t.Fatal("expected limiter to be non-nil")
}
if w.limiter.Limit() != rate.Limit(defaultRateLimit) {
t.Fatalf("expected rate limit %v, got %v", rate.Limit(defaultRateLimit), w.limiter.Limit())
}
}
func TestNewChannelWorker_ConfiguredRate(t *testing.T) {
ch := &mockChannel{}
for name, expectedRate := range channelRateConfig {
w := newChannelWorker(name, ch)
if w.limiter.Limit() != rate.Limit(expectedRate) {
t.Fatalf("channel %s: expected rate %v, got %v", name, expectedRate, w.limiter.Limit())
}
}
}
func TestRunWorker_MessageSplitting(t *testing.T) {
m := newTestManager()
var mu sync.Mutex
var received []string
ch := &mockChannelWithLength{
mockChannel: mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
mu.Lock()
received = append(received, msg.Content)
mu.Unlock()
return nil
},
},
maxLen: 5,
}
w := &channelWorker{
ch: ch,
queue: make(chan bus.OutboundMessage, 10),
done: make(chan struct{}),
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx := t.Context()
go m.runWorker(ctx, "test", w)
// Send a message that should be split
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"}
time.Sleep(100 * time.Millisecond)
mu.Lock()
count := len(received)
mu.Unlock()
if count < 2 {
t.Fatalf("expected message to be split into at least 2 chunks, got %d", count)
}
}
// mockChannelWithLength implements MessageLengthProvider.
type mockChannelWithLength struct {
mockChannel
maxLen int
}
func (m *mockChannelWithLength) MaxMessageLength() int {
return m.maxLen
}
func TestSendWithRetry_ExponentialBackoff(t *testing.T) {
m := newTestManager()
var callTimes []time.Time
var callCount atomic.Int32
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callTimes = append(callTimes, time.Now())
callCount.Add(1)
return fmt.Errorf("timeout: %w", ErrTemporary)
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx := context.Background()
msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"}
start := time.Now()
m.sendWithRetry(ctx, "test", w, msg)
totalElapsed := time.Since(start)
// With maxRetries=3: attempts at 0, ~500ms, ~1.5s, ~3.5s
// Total backoff: 500ms + 1s + 2s = 3.5s
// Allow some margin
if totalElapsed < 3*time.Second {
t.Fatalf("expected total elapsed >= 3s for exponential backoff, got %v", totalElapsed)
}
if int(callCount.Load()) != maxRetries+1 {
t.Fatalf("expected %d calls, got %d", maxRetries+1, callCount.Load())
}
}
// --- Phase 10: preSend orchestration tests ---
// mockMessageEditor is a channel that supports MessageEditor.
type mockMessageEditor struct {
mockChannel
editFn func(ctx context.Context, chatID, messageID, content string) error
}
func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error {
return m.editFn(ctx, chatID, messageID, content)
}
func TestPreSend_PlaceholderEditSuccess(t *testing.T) {
m := newTestManager()
var sendCalled bool
var editCalled bool
ch := &mockMessageEditor{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
sendCalled = true
return nil
},
},
editFn: func(_ context.Context, chatID, messageID, content string) error {
editCalled = true
if chatID != "123" {
t.Fatalf("expected chatID 123, got %s", chatID)
}
if messageID != "456" {
t.Fatalf("expected messageID 456, got %s", messageID)
}
if content != "hello" {
t.Fatalf("expected content 'hello', got %s", content)
}
return nil
},
}
// Register placeholder
m.RecordPlaceholder("test", "123", "456")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
edited := m.preSend(context.Background(), "test", msg, ch)
if !edited {
t.Fatal("expected preSend to return true (placeholder edited)")
}
if !editCalled {
t.Fatal("expected EditMessage to be called")
}
if sendCalled {
t.Fatal("expected Send to NOT be called when placeholder edited")
}
}
func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) {
m := newTestManager()
ch := &mockMessageEditor{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
return nil
},
},
editFn: func(_ context.Context, _, _, _ string) error {
return fmt.Errorf("edit failed")
},
}
m.RecordPlaceholder("test", "123", "456")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
edited := m.preSend(context.Background(), "test", msg, ch)
if edited {
t.Fatal("expected preSend to return false when edit fails")
}
}
func TestPreSend_TypingStopCalled(t *testing.T) {
m := newTestManager()
var stopCalled bool
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
return nil
},
}
m.RecordTypingStop("test", "123", func() {
stopCalled = true
})
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
m.preSend(context.Background(), "test", msg, ch)
if !stopCalled {
t.Fatal("expected typing stop func to be called")
}
}
func TestPreSend_NoRegisteredState(t *testing.T) {
m := newTestManager()
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
return nil
},
}
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
edited := m.preSend(context.Background(), "test", msg, ch)
if edited {
t.Fatal("expected preSend to return false with no registered state")
}
}
func TestPreSend_TypingAndPlaceholder(t *testing.T) {
m := newTestManager()
var stopCalled bool
var editCalled bool
ch := &mockMessageEditor{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
return nil
},
},
editFn: func(_ context.Context, _, _, _ string) error {
editCalled = true
return nil
},
}
m.RecordTypingStop("test", "123", func() {
stopCalled = true
})
m.RecordPlaceholder("test", "123", "456")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
edited := m.preSend(context.Background(), "test", msg, ch)
if !stopCalled {
t.Fatal("expected typing stop to be called")
}
if !editCalled {
t.Fatal("expected EditMessage to be called")
}
if !edited {
t.Fatal("expected preSend to return true")
}
}
func TestRecordPlaceholder_ConcurrentSafe(t *testing.T) {
m := newTestManager()
var wg sync.WaitGroup
for i := range 100 {
wg.Add(1)
go func(i int) {
defer wg.Done()
chatID := fmt.Sprintf("chat_%d", i%10)
m.RecordPlaceholder("test", chatID, fmt.Sprintf("msg_%d", i))
}(i)
}
wg.Wait()
}
func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
m := newTestManager()
var wg sync.WaitGroup
for i := range 100 {
wg.Add(1)
go func(i int) {
defer wg.Done()
chatID := fmt.Sprintf("chat_%d", i%10)
m.RecordTypingStop("test", chatID, func() {})
}(i)
}
wg.Wait()
}
func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {
m := newTestManager()
var sendCalled bool
ch := &mockMessageEditor{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
sendCalled = true
return nil
},
},
editFn: func(_ context.Context, _, _, _ string) error {
return nil // edit succeeds
},
}
m.RecordPlaceholder("test", "123", "456")
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"}
m.sendWithRetry(context.Background(), "test", w, msg)
if sendCalled {
t.Fatal("expected Send to NOT be called when placeholder was edited")
}
}
// --- Dispatcher exit tests (Step 1) ---
func TestDispatcherExitsOnCancel(t *testing.T) {
mb := bus.NewMessageBus()
defer mb.Close()
m := &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
bus: mb,
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
m.dispatchOutbound(ctx)
close(done)
}()
// Cancel context and verify the dispatcher exits quickly
cancel()
select {
case <-done:
// success
case <-time.After(2 * time.Second):
t.Fatal("dispatchOutbound did not exit within 2s after context cancel")
}
}
func TestDispatcherMediaExitsOnCancel(t *testing.T) {
mb := bus.NewMessageBus()
defer mb.Close()
m := &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
bus: mb,
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
m.dispatchOutboundMedia(ctx)
close(done)
}()
cancel()
select {
case <-done:
// success
case <-time.After(2 * time.Second):
t.Fatal("dispatchOutboundMedia did not exit within 2s after context cancel")
}
}
// --- TTL Janitor tests (Step 2) ---
func TestTypingStopJanitorEviction(t *testing.T) {
m := newTestManager()
var stopCalled atomic.Bool
// Store a typing entry with a creation time far in the past
m.typingStops.Store("test:123", typingEntry{
stop: func() { stopCalled.Store(true) },
createdAt: time.Now().Add(-10 * time.Minute), // well past typingStopTTL
})
// Run janitor with a short-lived context
ctx, cancel := context.WithCancel(context.Background())
// Manually trigger the janitor logic once by simulating a tick
go func() {
// Override janitor to run immediately
now := time.Now()
m.typingStops.Range(func(key, value any) bool {
if entry, ok := value.(typingEntry); ok {
if now.Sub(entry.createdAt) > typingStopTTL {
if _, loaded := m.typingStops.LoadAndDelete(key); loaded {
entry.stop()
}
}
}
return true
})
cancel()
}()
<-ctx.Done()
if !stopCalled.Load() {
t.Fatal("expected typing stop function to be called by janitor eviction")
}
// Verify entry was deleted
if _, loaded := m.typingStops.Load("test:123"); loaded {
t.Fatal("expected typing entry to be deleted after eviction")
}
}
func TestPlaceholderJanitorEviction(t *testing.T) {
m := newTestManager()
// Store a placeholder entry with a creation time far in the past
m.placeholders.Store("test:456", placeholderEntry{
id: "msg_old",
createdAt: time.Now().Add(-20 * time.Minute), // well past placeholderTTL
})
// Simulate janitor logic
now := time.Now()
m.placeholders.Range(func(key, value any) bool {
if entry, ok := value.(placeholderEntry); ok {
if now.Sub(entry.createdAt) > placeholderTTL {
m.placeholders.Delete(key)
}
}
return true
})
// Verify entry was deleted
if _, loaded := m.placeholders.Load("test:456"); loaded {
t.Fatal("expected placeholder entry to be deleted after eviction")
}
}
func TestPreSendStillWorksWithWrappedTypes(t *testing.T) {
m := newTestManager()
var stopCalled bool
var editCalled bool
ch := &mockMessageEditor{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
return nil
},
},
editFn: func(_ context.Context, chatID, messageID, content string) error {
editCalled = true
if messageID != "ph_id" {
t.Fatalf("expected messageID ph_id, got %s", messageID)
}
return nil
},
}
// Use the new wrapped types via the public API
m.RecordTypingStop("test", "chat1", func() {
stopCalled = true
})
m.RecordPlaceholder("test", "chat1", "ph_id")
msg := bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"}
edited := m.preSend(context.Background(), "test", msg, ch)
if !stopCalled {
t.Fatal("expected typing stop to be called via wrapped type")
}
if !editCalled {
t.Fatal("expected EditMessage to be called via wrapped type")
}
if !edited {
t.Fatal("expected preSend to return true")
}
}
// --- Lazy worker creation tests (Step 6) ---
func TestLazyWorkerCreation(t *testing.T) {
m := newTestManager()
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
return nil
},
}
// RegisterChannel should NOT create a worker
m.RegisterChannel("lazy", ch)
m.mu.RLock()
_, chExists := m.channels["lazy"]
_, wExists := m.workers["lazy"]
m.mu.RUnlock()
if !chExists {
t.Fatal("expected channel to be registered")
}
if wExists {
t.Fatal("expected worker to NOT be created by RegisterChannel (lazy creation)")
}
}
// --- FastID uniqueness test (Step 5) ---
func TestBuildMediaScope_FastIDUniqueness(t *testing.T) {
seen := make(map[string]bool)
for range 1000 {
scope := BuildMediaScope("test", "chat1", "")
if seen[scope] {
t.Fatalf("duplicate scope generated: %s", scope)
}
seen[scope] = true
}
// Verify format: "channel:chatID:id"
scope := BuildMediaScope("telegram", "42", "")
parts := 0
for _, c := range scope {
if c == ':' {
parts++
}
}
if parts != 2 {
t.Fatalf("expected scope to have 2 colons (channel:chatID:id), got: %s", scope)
}
}
func TestBuildMediaScope_WithMessageID(t *testing.T) {
scope := BuildMediaScope("discord", "chat99", "msg123")
expected := "discord:chat99:msg123"
if scope != expected {
t.Fatalf("expected %s, got %s", expected, scope)
}
}

15
pkg/channels/media.go Normal file
View file

@ -0,0 +1,15 @@
package channels
import (
"context"
"github.com/sipeed/picoclaw/pkg/bus"
)
// MediaSender is an optional interface for channels that can send
// media attachments (images, files, audio, video).
// Manager discovers channels implementing this interface via type
// assertion and routes OutboundMediaMessage to them.
type MediaSender interface {
SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error
}

View file

@ -0,0 +1,13 @@
package onebot
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("onebot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewOneBotChannel(cfg.Channels.OneBot, b)
})
}

View file

@ -1,10 +1,9 @@
package channels
package onebot
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"sync"
@ -14,14 +13,16 @@ import (
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
"github.com/sipeed/picoclaw/pkg/voice"
)
type OneBotChannel struct {
*BaseChannel
*channels.BaseChannel
config config.OneBotConfig
conn *websocket.Conn
ctx context.Context
@ -35,9 +36,7 @@ type OneBotChannel struct {
selfID int64
pending map[string]chan json.RawMessage
pendingMu sync.Mutex
transcriber *voice.GroqTranscriber
lastMessageID sync.Map
pendingEmojiMsg sync.Map
}
type oneBotRawEvent struct {
@ -98,7 +97,10 @@ type oneBotMessageSegment struct {
}
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom)
base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
const dedupSize = 1024
return &OneBotChannel{
@ -111,10 +113,6 @@ func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*One
}, nil
}
func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
c.transcriber = transcriber
}
func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
go func() {
_, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{
@ -131,6 +129,22 @@ func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool)
}()
}
// ReactToMessage implements channels.ReactionCapable.
// It adds an emoji reaction (ID 289) to group messages and returns an undo function.
// Private messages return a no-op since reactions are only meaningful in groups.
func (c *OneBotChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
// Only react in group chats
if !strings.HasPrefix(chatID, "group:") {
return func() {}, nil
}
c.setMsgEmojiLike(messageID, 289, true)
return func() {
c.setMsgEmojiLike(messageID, 289, false)
}, nil
}
func (c *OneBotChannel) Start(ctx context.Context) error {
if c.config.WSUrl == "" {
return fmt.Errorf("OneBot ws_url not configured")
@ -159,7 +173,7 @@ func (c *OneBotChannel) Start(ctx context.Context) error {
}
}
c.setRunning(true)
c.SetRunning(true)
logger.InfoC("onebot", "OneBot channel started successfully")
return nil
@ -300,7 +314,9 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D
}
c.writeMu.Lock()
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
err = conn.WriteMessage(websocket.TextMessage, data)
_ = conn.SetWriteDeadline(time.Time{})
c.writeMu.Unlock()
if err != nil {
@ -309,6 +325,9 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D
select {
case resp := <-ch:
if resp == nil {
return nil, fmt.Errorf("API request %s: channel stopped", action)
}
return resp, nil
case <-time.After(timeout):
return nil, fmt.Errorf("API request %s timed out after %v", action, timeout)
@ -346,7 +365,7 @@ func (c *OneBotChannel) reconnectLoop() {
func (c *OneBotChannel) Stop(ctx context.Context) error {
logger.InfoC("onebot", "Stopping OneBot channel")
c.setRunning(false)
c.SetRunning(false)
if c.cancel != nil {
c.cancel()
@ -354,7 +373,10 @@ func (c *OneBotChannel) Stop(ctx context.Context) error {
c.pendingMu.Lock()
for echo, ch := range c.pending {
close(ch)
select {
case ch <- nil: // non-blocking wake for blocked sendAPIRequest goroutines
default:
}
delete(c.pending, echo)
}
c.pendingMu.Unlock()
@ -371,7 +393,14 @@ func (c *OneBotChannel) Stop(ctx context.Context) error {
func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("OneBot channel not running")
return channels.ErrNotRunning
}
// Check ctx before entering write path
select {
case <-ctx.Done():
return ctx.Err()
default:
}
c.mu.Lock()
@ -401,20 +430,127 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
}
c.writeMu.Lock()
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
err = conn.WriteMessage(websocket.TextMessage, data)
_ = conn.SetWriteDeadline(time.Time{})
c.writeMu.Unlock()
if err != nil {
logger.ErrorCF("onebot", "Failed to send message", map[string]any{
"error": err.Error(),
})
return err
return fmt.Errorf("onebot send: %w", channels.ErrTemporary)
}
if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok {
if mid, ok := msgID.(string); ok && mid != "" {
c.setMsgEmojiLike(mid, 289, false)
return nil
}
// SendMedia implements the channels.MediaSender interface.
func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
return fmt.Errorf("OneBot WebSocket not connected")
}
store := c.GetMediaStore()
if store == nil {
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
// Build media segments
var segments []oneBotMessageSegment
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
logger.ErrorCF("onebot", "Failed to resolve media ref", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
continue
}
var segType string
switch part.Type {
case "image":
segType = "image"
case "video":
segType = "video"
case "audio":
segType = "record"
default:
segType = "file"
}
segments = append(segments, oneBotMessageSegment{
Type: segType,
Data: map[string]any{"file": "file://" + localPath},
})
if part.Caption != "" {
segments = append(segments, oneBotMessageSegment{
Type: "text",
Data: map[string]any{"text": part.Caption},
})
}
}
if len(segments) == 0 {
return nil
}
chatID := msg.ChatID
var action, idKey string
var rawID string
if rest, ok := strings.CutPrefix(chatID, "group:"); ok {
action, idKey, rawID = "send_group_msg", "group_id", rest
} else if rest, ok := strings.CutPrefix(chatID, "private:"); ok {
action, idKey, rawID = "send_private_msg", "user_id", rest
} else {
action, idKey, rawID = "send_private_msg", "user_id", chatID
}
id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil {
return fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed)
}
echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1))
req := oneBotAPIRequest{
Action: action,
Params: map[string]any{idKey: id, "message": segments},
Echo: echo,
}
data, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal OneBot request: %w", err)
}
c.writeMu.Lock()
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
err = conn.WriteMessage(websocket.TextMessage, data)
_ = conn.SetWriteDeadline(time.Time{})
c.writeMu.Unlock()
if err != nil {
logger.ErrorCF("onebot", "Failed to send media message", map[string]any{
"error": err.Error(),
})
return fmt.Errorf("onebot send media: %w", channels.ErrTemporary)
}
return nil
@ -571,11 +707,15 @@ type parseMessageResult struct {
Text string
IsBotMentioned bool
Media []string
LocalFiles []string
ReplyTo string
}
func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) parseMessageResult {
func (c *OneBotChannel) parseMessageSegments(
raw json.RawMessage,
selfID int64,
store media.MediaStore,
scope string,
) parseMessageResult {
if len(raw) == 0 {
return parseMessageResult{}
}
@ -602,10 +742,23 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
var textParts []string
mentioned := false
selfIDStr := strconv.FormatInt(selfID, 10)
var media []string
var localFiles []string
var mediaRefs []string
var replyTo string
// Helper to register a local file with the media store
storeFile := func(localPath, filename string) string {
if store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
Source: "onebot",
}, scope)
if err == nil {
return ref
}
}
return localPath // fallback
}
for _, seg := range segments {
segType, _ := seg["type"].(string)
data, _ := seg["data"].(map[string]any)
@ -641,8 +794,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
LoggerPrefix: "onebot",
})
if localPath != "" {
media = append(media, localPath)
localFiles = append(localFiles, localPath)
mediaRefs = append(mediaRefs, storeFile(localPath, filename))
textParts = append(textParts, fmt.Sprintf("[%s]", segType))
}
}
@ -656,24 +808,8 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
LoggerPrefix: "onebot",
})
if localPath != "" {
localFiles = append(localFiles, localPath)
if c.transcriber != nil && c.transcriber.IsAvailable() {
tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second)
result, err := c.transcriber.Transcribe(tctx, localPath)
tcancel()
if err != nil {
logger.WarnCF("onebot", "Voice transcription failed", map[string]any{
"error": err.Error(),
})
textParts = append(textParts, "[voice (transcription failed)]")
media = append(media, localPath)
} else {
textParts = append(textParts, fmt.Sprintf("[voice transcription: %s]", result.Text))
}
} else {
textParts = append(textParts, "[voice]")
media = append(media, localPath)
}
mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr"))
}
}
}
@ -701,8 +837,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
return parseMessageResult{
Text: strings.TrimSpace(strings.Join(textParts, "")),
IsBotMentioned: mentioned,
Media: media,
LocalFiles: localFiles,
Media: mediaRefs,
ReplyTo: replyTo,
}
}
@ -711,7 +846,13 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
switch raw.PostType {
case "message":
if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
if !c.IsAllowed(strconv.FormatInt(userID, 10)) {
// Build minimal sender for allowlist check
sender := bus.SenderInfo{
Platform: "onebot",
PlatformID: strconv.FormatInt(userID, 10),
CanonicalID: identity.BuildCanonicalID("onebot", strconv.FormatInt(userID, 10)),
}
if !c.IsAllowedSender(sender) {
logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{
"user_id": userID,
})
@ -794,7 +935,17 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
selfID = atomic.LoadInt64(&c.selfID)
}
parsed := c.parseMessageSegments(raw.Message, selfID)
// Compute scope for media store before parsing (parsing may download files)
var chatIDForScope string
switch raw.MessageType {
case "group":
chatIDForScope = "group:" + strconv.FormatInt(groupID, 10)
default:
chatIDForScope = "private:" + strconv.FormatInt(userID, 10)
}
scope := channels.BuildMediaScope("onebot", chatIDForScope, messageID)
parsed := c.parseMessageSegments(raw.Message, selfID, c.GetMediaStore(), scope)
isBotMentioned := parsed.IsBotMentioned
content := raw.RawMessage
@ -823,20 +974,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
}
}
// Clean up temp files when done
if len(parsed.LocalFiles) > 0 {
defer func() {
for _, f := range parsed.LocalFiles {
if err := os.Remove(f); err != nil {
logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{
"path": f,
"error": err.Error(),
})
}
}
}()
}
if c.isDuplicate(messageID) {
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{
"message_id": messageID,
@ -854,9 +991,9 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
senderID := strconv.FormatInt(userID, 10)
var chatID string
metadata := map[string]string{
"message_id": messageID,
}
var peer bus.Peer
metadata := map[string]string{}
if parsed.ReplyTo != "" {
metadata["reply_to_message_id"] = parsed.ReplyTo
@ -865,14 +1002,12 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
switch raw.MessageType {
case "private":
chatID = "private:" + senderID
metadata["peer_kind"] = "direct"
metadata["peer_id"] = senderID
peer = bus.Peer{Kind: "direct", ID: senderID}
case "group":
groupIDStr := strconv.FormatInt(groupID, 10)
chatID = "group:" + groupIDStr
metadata["peer_kind"] = "group"
metadata["peer_id"] = groupIDStr
peer = bus.Peer{Kind: "group", ID: groupIDStr}
metadata["group_id"] = groupIDStr
senderUserID, _ := parseJSONInt64(sender.UserID)
@ -886,8 +1021,8 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
metadata["sender_name"] = sender.Nickname
}
triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned)
if !triggered {
respond, strippedContent := c.ShouldRespondInGroup(isBotMentioned, content)
if !respond {
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{
"sender": senderID,
"group": groupIDStr,
@ -922,12 +1057,21 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
c.lastMessageID.Store(chatID, messageID)
if raw.MessageType == "group" && messageID != "" && messageID != "0" {
c.setMsgEmojiLike(messageID, 289, true)
c.pendingEmojiMsg.Store(chatID, messageID)
senderInfo := bus.SenderInfo{
Platform: "onebot",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("onebot", senderID),
DisplayName: sender.Nickname,
}
c.HandleMessage(senderID, chatID, content, parsed.Media, metadata)
if !c.IsAllowedSender(senderInfo) {
logger.DebugCF("onebot", "Message rejected by allowlist (senderInfo)", map[string]any{
"sender": senderID,
})
return
}
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata, senderInfo)
}
func (c *OneBotChannel) isDuplicate(messageID string) bool {
@ -959,23 +1103,3 @@ func truncate(s string, n int) string {
}
return string(runes[:n]) + "..."
}
func (c *OneBotChannel) checkGroupTrigger(
content string,
isBotMentioned bool,
) (triggered bool, strippedContent string) {
if isBotMentioned {
return true, strings.TrimSpace(content)
}
for _, prefix := range c.config.GroupTriggerPrefix {
if prefix == "" {
continue
}
if after, ok := strings.CutPrefix(content, prefix); ok {
return true, strings.TrimSpace(after)
}
}
return false, content
}

13
pkg/channels/pico/init.go Normal file
View file

@ -0,0 +1,13 @@
package pico
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewPicoChannel(cfg.Channels.Pico, b)
})
}

462
pkg/channels/pico/pico.go Normal file
View file

@ -0,0 +1,462 @@
package pico
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
)
// picoConn represents a single WebSocket connection.
type picoConn struct {
id string
conn *websocket.Conn
sessionID string
writeMu sync.Mutex
closed atomic.Bool
}
// writeJSON sends a JSON message to the connection with write locking.
func (pc *picoConn) writeJSON(v any) error {
if pc.closed.Load() {
return fmt.Errorf("connection closed")
}
pc.writeMu.Lock()
defer pc.writeMu.Unlock()
return pc.conn.WriteJSON(v)
}
// close closes the connection.
func (pc *picoConn) close() {
if pc.closed.CompareAndSwap(false, true) {
pc.conn.Close()
}
}
// PicoChannel implements the native Pico Protocol WebSocket channel.
// It serves as the reference implementation for all optional capability interfaces.
type PicoChannel struct {
*channels.BaseChannel
config config.PicoConfig
upgrader websocket.Upgrader
connections sync.Map // connID → *picoConn
connCount atomic.Int32
ctx context.Context
cancel context.CancelFunc
}
// NewPicoChannel creates a new Pico Protocol channel.
func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
if cfg.Token == "" {
return nil, fmt.Errorf("pico token is required")
}
base := channels.NewBaseChannel("pico", cfg, messageBus, cfg.AllowFrom)
allowOrigins := cfg.AllowOrigins
checkOrigin := func(r *http.Request) bool {
if len(allowOrigins) == 0 {
return true // allow all if not configured
}
origin := r.Header.Get("Origin")
for _, allowed := range allowOrigins {
if allowed == "*" || allowed == origin {
return true
}
}
return false
}
return &PicoChannel{
BaseChannel: base,
config: cfg,
upgrader: websocket.Upgrader{
CheckOrigin: checkOrigin,
ReadBufferSize: 1024,
WriteBufferSize: 1024,
},
}, nil
}
// Start implements Channel.
func (c *PicoChannel) Start(ctx context.Context) error {
logger.InfoC("pico", "Starting Pico Protocol channel")
c.ctx, c.cancel = context.WithCancel(ctx)
c.SetRunning(true)
logger.InfoC("pico", "Pico Protocol channel started")
return nil
}
// Stop implements Channel.
func (c *PicoChannel) Stop(ctx context.Context) error {
logger.InfoC("pico", "Stopping Pico Protocol channel")
c.SetRunning(false)
// Close all connections
c.connections.Range(func(key, value any) bool {
if pc, ok := value.(*picoConn); ok {
pc.close()
}
c.connections.Delete(key)
return true
})
if c.cancel != nil {
c.cancel()
}
logger.InfoC("pico", "Pico Protocol channel stopped")
return nil
}
// WebhookPath implements channels.WebhookHandler.
func (c *PicoChannel) WebhookPath() string { return "/pico/" }
// ServeHTTP implements http.Handler for the shared HTTP server.
func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/pico")
switch {
case path == "/ws" || path == "/ws/":
c.handleWebSocket(w, r)
default:
http.NotFound(w, r)
}
}
// Send implements Channel — sends a message to the appropriate WebSocket connection.
func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
outMsg := newMessage(TypeMessageCreate, map[string]any{
"content": msg.Content,
})
return c.broadcastToSession(msg.ChatID, outMsg)
}
// EditMessage implements channels.MessageEditor.
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
outMsg := newMessage(TypeMessageUpdate, map[string]any{
"message_id": messageID,
"content": content,
})
return c.broadcastToSession(chatID, outMsg)
}
// StartTyping implements channels.TypingCapable.
func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
startMsg := newMessage(TypeTypingStart, nil)
if err := c.broadcastToSession(chatID, startMsg); err != nil {
return func() {}, err
}
return func() {
stopMsg := newMessage(TypeTypingStop, nil)
c.broadcastToSession(chatID, stopMsg)
}, nil
}
// SendPlaceholder implements channels.PlaceholderCapable.
// It sends a placeholder message via the Pico Protocol that will later be
// edited to the actual response via EditMessage (channels.MessageEditor).
func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
if !c.config.Placeholder.Enabled {
return "", nil
}
text := c.config.Placeholder.Text
if text == "" {
text = "Thinking... 💭"
}
msgID := uuid.New().String()
outMsg := newMessage(TypeMessageCreate, map[string]any{
"content": text,
"message_id": msgID,
})
if err := c.broadcastToSession(chatID, outMsg); err != nil {
return "", err
}
return msgID, nil
}
// broadcastToSession sends a message to all connections with a matching session.
func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
// chatID format: "pico:<sessionID>"
sessionID := strings.TrimPrefix(chatID, "pico:")
msg.SessionID = sessionID
var sent bool
c.connections.Range(func(key, value any) bool {
pc, ok := value.(*picoConn)
if !ok {
return true
}
if pc.sessionID == sessionID {
if err := pc.writeJSON(msg); err != nil {
logger.DebugCF("pico", "Write to connection failed", map[string]any{
"conn_id": pc.id,
"error": err.Error(),
})
} else {
sent = true
}
}
return true
})
if !sent {
return fmt.Errorf("no active connections for session %s: %w", sessionID, channels.ErrSendFailed)
}
return nil
}
// handleWebSocket upgrades the HTTP connection and manages the WebSocket lifecycle.
func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
if !c.IsRunning() {
http.Error(w, "channel not running", http.StatusServiceUnavailable)
return
}
// Authenticate
if !c.authenticate(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Check connection limit
maxConns := c.config.MaxConnections
if maxConns <= 0 {
maxConns = 100
}
if int(c.connCount.Load()) >= maxConns {
http.Error(w, "too many connections", http.StatusServiceUnavailable)
return
}
conn, err := c.upgrader.Upgrade(w, r, nil)
if err != nil {
logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{
"error": err.Error(),
})
return
}
// Determine session ID from query param or generate one
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
sessionID = uuid.New().String()
}
pc := &picoConn{
id: uuid.New().String(),
conn: conn,
sessionID: sessionID,
}
c.connections.Store(pc.id, pc)
c.connCount.Add(1)
logger.InfoCF("pico", "WebSocket client connected", map[string]any{
"conn_id": pc.id,
"session_id": sessionID,
})
go c.readLoop(pc)
}
// authenticate checks the Bearer token from the Authorization header.
// Query parameter authentication is only allowed when AllowTokenQuery is explicitly enabled.
func (c *PicoChannel) authenticate(r *http.Request) bool {
token := c.config.Token
if token == "" {
return false
}
// Check Authorization header
auth := r.Header.Get("Authorization")
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
if after == token {
return true
}
}
// Check query parameter only when explicitly allowed
if c.config.AllowTokenQuery {
if r.URL.Query().Get("token") == token {
return true
}
}
return false
}
// readLoop reads messages from a WebSocket connection.
func (c *PicoChannel) readLoop(pc *picoConn) {
defer func() {
pc.close()
c.connections.Delete(pc.id)
c.connCount.Add(-1)
logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{
"conn_id": pc.id,
"session_id": pc.sessionID,
})
}()
readTimeout := time.Duration(c.config.ReadTimeout) * time.Second
if readTimeout <= 0 {
readTimeout = 60 * time.Second
}
_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
pc.conn.SetPongHandler(func(appData string) error {
_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
return nil
})
// Start ping ticker
pingInterval := time.Duration(c.config.PingInterval) * time.Second
if pingInterval <= 0 {
pingInterval = 30 * time.Second
}
go c.pingLoop(pc, pingInterval)
for {
select {
case <-c.ctx.Done():
return
default:
}
_, rawMsg, err := pc.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
logger.DebugCF("pico", "WebSocket read error", map[string]any{
"conn_id": pc.id,
"error": err.Error(),
})
}
return
}
_ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout))
var msg PicoMessage
if err := json.Unmarshal(rawMsg, &msg); err != nil {
errMsg := newError("invalid_message", "failed to parse message")
pc.writeJSON(errMsg)
continue
}
c.handleMessage(pc, msg)
}
}
// pingLoop sends periodic ping frames to keep the connection alive.
func (c *PicoChannel) pingLoop(pc *picoConn, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ticker.C:
if pc.closed.Load() {
return
}
pc.writeMu.Lock()
err := pc.conn.WriteMessage(websocket.PingMessage, nil)
pc.writeMu.Unlock()
if err != nil {
return
}
}
}
}
// handleMessage processes an inbound Pico Protocol message.
func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) {
switch msg.Type {
case TypePing:
pong := newMessage(TypePong, nil)
pong.ID = msg.ID
pc.writeJSON(pong)
case TypeMessageSend:
c.handleMessageSend(pc, msg)
default:
errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type))
pc.writeJSON(errMsg)
}
}
// handleMessageSend processes an inbound message.send from a client.
func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) {
content, _ := msg.Payload["content"].(string)
if strings.TrimSpace(content) == "" {
errMsg := newError("empty_content", "message content is empty")
pc.writeJSON(errMsg)
return
}
sessionID := msg.SessionID
if sessionID == "" {
sessionID = pc.sessionID
}
chatID := "pico:" + sessionID
senderID := "pico-user"
peer := bus.Peer{Kind: "direct", ID: "pico:" + sessionID}
metadata := map[string]string{
"platform": "pico",
"session_id": sessionID,
"conn_id": pc.id,
}
logger.DebugCF("pico", "Received message", map[string]any{
"session_id": sessionID,
"preview": truncate(content, 50),
})
sender := bus.SenderInfo{
Platform: "pico",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("pico", senderID),
}
if !c.IsAllowedSender(sender) {
return
}
c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender)
}
// truncate truncates a string to maxLen runes.
func truncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "..."
}

View file

@ -0,0 +1,46 @@
package pico
import "time"
// Protocol message types.
const (
// TypeMessageSend is sent from client to server.
TypeMessageSend = "message.send"
TypeMediaSend = "media.send"
TypePing = "ping"
// TypeMessageCreate is sent from server to client.
TypeMessageCreate = "message.create"
TypeMessageUpdate = "message.update"
TypeMediaCreate = "media.create"
TypeTypingStart = "typing.start"
TypeTypingStop = "typing.stop"
TypeError = "error"
TypePong = "pong"
)
// PicoMessage is the wire format for all Pico Protocol messages.
type PicoMessage struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
SessionID string `json:"session_id,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
// newMessage creates a PicoMessage with the given type and payload.
func newMessage(msgType string, payload map[string]any) PicoMessage {
return PicoMessage{
Type: msgType,
Timestamp: time.Now().UnixMilli(),
Payload: payload,
}
}
// newError creates an error PicoMessage.
func newError(code, message string) PicoMessage {
return newMessage(TypeError, map[string]any{
"code": code,
"message": message,
})
}

13
pkg/channels/qq/init.go Normal file
View file

@ -0,0 +1,13 @@
package qq
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("qq", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewQQChannel(cfg.Channels.QQ, b)
})
}

View file

@ -1,4 +1,4 @@
package channels
package qq
import (
"context"
@ -14,12 +14,14 @@ import (
"golang.org/x/oauth2"
"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"
)
type QQChannel struct {
*BaseChannel
*channels.BaseChannel
config config.QQConfig
api openapi.OpenAPI
tokenSource oauth2.TokenSource
@ -31,7 +33,10 @@ type QQChannel struct {
}
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom)
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &QQChannel{
BaseChannel: base,
@ -90,11 +95,11 @@ func (c *QQChannel) Start(ctx context.Context) error {
logger.ErrorCF("qq", "WebSocket session error", map[string]any{
"error": err.Error(),
})
c.setRunning(false)
c.SetRunning(false)
}
}()
c.setRunning(true)
c.SetRunning(true)
logger.InfoC("qq", "QQ bot started successfully")
return nil
@ -102,7 +107,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
func (c *QQChannel) Stop(ctx context.Context) error {
logger.InfoC("qq", "Stopping QQ bot")
c.setRunning(false)
c.SetRunning(false)
if c.cancel != nil {
c.cancel()
@ -113,7 +118,7 @@ func (c *QQChannel) Stop(ctx context.Context) error {
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("QQ bot not running")
return channels.ErrNotRunning
}
// construct message
@ -127,7 +132,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
"error": err.Error(),
})
return err
return fmt.Errorf("qq send: %w", channels.ErrTemporary)
}
return nil
@ -162,20 +167,35 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
"length": len(content),
})
// forward to message bus
metadata := map[string]string{
"message_id": data.ID,
"peer_kind": "direct",
"peer_id": senderID,
// 转发到消息总线
metadata := map[string]string{}
sender := bus.SenderInfo{
Platform: "qq",
PlatformID: data.Author.ID,
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
}
c.HandleMessage(senderID, senderID, content, []string{}, metadata)
if !c.IsAllowedSender(sender) {
return nil
}
c.HandleMessage(c.ctx,
bus.Peer{Kind: "direct", ID: senderID},
data.ID,
senderID,
senderID,
content,
[]string{},
metadata,
sender,
)
return nil
}
}
// handleGroupATMessage handles group @messages
// handleGroupATMessage handles QQ group @ messages
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
// deduplication check
@ -192,34 +212,57 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return nil
}
// extract message content (remove @bot part)
// extract message content (remove @ bot part)
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty group message, ignoring")
return nil
}
// GroupAT event means bot is always mentioned; apply group trigger filtering
respond, cleaned := c.ShouldRespondInGroup(true, content)
if !respond {
return nil
}
content = cleaned
logger.InfoCF("qq", "Received group AT message", map[string]any{
"sender": senderID,
"group": data.GroupID,
"length": len(content),
})
// forward to message bus (use GroupID as ChatID)
// 转发到消息总线(使用 GroupID 作为 ChatID
metadata := map[string]string{
"message_id": data.ID,
"group_id": data.GroupID,
"peer_kind": "group",
"peer_id": data.GroupID,
}
c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)
sender := bus.SenderInfo{
Platform: "qq",
PlatformID: data.Author.ID,
CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
}
if !c.IsAllowedSender(sender) {
return nil
}
c.HandleMessage(c.ctx,
bus.Peer{Kind: "group", ID: data.GroupID},
data.ID,
senderID,
data.GroupID,
content,
[]string{},
metadata,
sender,
)
return nil
}
}
// isDuplicate checks if message is duplicate
// isDuplicate 检查消息是否重复
func (c *QQChannel) isDuplicate(messageID string) bool {
c.mu.Lock()
defer c.mu.Unlock()
@ -230,9 +273,9 @@ func (c *QQChannel) isDuplicate(messageID string) bool {
c.processedIDs[messageID] = true
// simple cleanup: limit map size
// 简单清理:限制 map 大小
if len(c.processedIDs) > 10000 {
// clear half
// 清空一半
count := 0
for id := range c.processedIDs {
if count >= 5000 {

32
pkg/channels/registry.go Normal file
View file

@ -0,0 +1,32 @@
package channels
import (
"sync"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
// ChannelFactory is a constructor function that creates a Channel from config and message bus.
// Each channel subpackage registers one or more factories via init().
type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error)
var (
factoriesMu sync.RWMutex
factories = map[string]ChannelFactory{}
)
// RegisterFactory registers a named channel factory. Called from subpackage init() functions.
func RegisterFactory(name string, f ChannelFactory) {
factoriesMu.Lock()
defer factoriesMu.Unlock()
factories[name] = f
}
// getFactory looks up a channel factory by name.
func getFactory(name string) (ChannelFactory, bool) {
factoriesMu.RLock()
defer factoriesMu.RUnlock()
f, ok := factories[name]
return f, ok
}

View file

@ -0,0 +1,13 @@
package slack
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("slack", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewSlackChannel(cfg.Channels.Slack, b)
})
}

View file

@ -1,32 +1,31 @@
package channels
package slack
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
"github.com/sipeed/picoclaw/pkg/voice"
)
type SlackChannel struct {
*BaseChannel
*channels.BaseChannel
config config.SlackConfig
api *slack.Client
socketClient *socketmode.Client
botUserID string
teamID string
transcriber *voice.GroqTranscriber
ctx context.Context
cancel context.CancelFunc
pendingAcks sync.Map
@ -49,7 +48,11 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
socketClient := socketmode.New(api)
base := NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom)
base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(40000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &SlackChannel{
BaseChannel: base,
@ -59,10 +62,6 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
}, nil
}
func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
c.transcriber = transcriber
}
func (c *SlackChannel) Start(ctx context.Context) error {
logger.InfoC("slack", "Starting Slack channel (Socket Mode)")
@ -92,7 +91,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
}
}()
c.setRunning(true)
c.SetRunning(true)
logger.InfoC("slack", "Slack channel started (Socket Mode)")
return nil
}
@ -104,14 +103,14 @@ func (c *SlackChannel) Stop(ctx context.Context) error {
c.cancel()
}
c.setRunning(false)
c.SetRunning(false)
logger.InfoC("slack", "Slack channel stopped")
return nil
}
func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("slack channel not running")
return channels.ErrNotRunning
}
channelID, threadTS := parseSlackChatID(msg.ChatID)
@ -129,7 +128,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
_, _, err := c.api.PostMessageContext(ctx, channelID, opts...)
if err != nil {
return fmt.Errorf("failed to send slack message: %w", err)
return fmt.Errorf("slack send: %w", channels.ErrTemporary)
}
if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok {
@ -148,6 +147,82 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return nil
}
// SendMedia implements the channels.MediaSender interface.
func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
channelID, _ := parseSlackChatID(msg.ChatID)
if channelID == "" {
return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
}
store := c.GetMediaStore()
if store == nil {
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
logger.ErrorCF("slack", "Failed to resolve media ref", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
continue
}
filename := part.Filename
if filename == "" {
filename = "file"
}
title := part.Caption
if title == "" {
title = filename
}
_, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
Channel: channelID,
File: localPath,
Filename: filename,
Title: title,
})
if err != nil {
logger.ErrorCF("slack", "Failed to upload media", map[string]any{
"filename": filename,
"error": err.Error(),
})
return fmt.Errorf("slack send media: %w", channels.ErrTemporary)
}
}
return nil
}
// ReactToMessage implements channels.ReactionCapable.
// It adds an "eyes" (👀) reaction to the inbound message and returns an undo function
// that removes the reaction.
func (c *SlackChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
channelID, _ := parseSlackChatID(chatID)
if channelID == "" {
return func() {}, nil
}
c.api.AddReaction("eyes", slack.ItemRef{
Channel: channelID,
Timestamp: messageID,
})
return func() {
c.api.RemoveReaction("eyes", slack.ItemRef{
Channel: channelID,
Timestamp: messageID,
})
}, nil
}
func (c *SlackChannel) eventLoop() {
for {
select {
@ -201,7 +276,12 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
}
// check allowlist to avoid downloading attachments for rejected users
if !c.IsAllowed(ev.User) {
sender := bus.SenderInfo{
Platform: "slack",
PlatformID: ev.User,
CanonicalID: identity.BuildCanonicalID("slack", ev.User),
}
if !c.IsAllowedSender(sender) {
logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
"user_id": ev.User,
})
@ -218,11 +298,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
chatID = channelID + "/" + threadTS
}
c.api.AddReaction("eyes", slack.ItemRef{
Channel: channelID,
Timestamp: messageTS,
})
c.pendingAcks.Store(chatID, slackMessageRef{
ChannelID: channelID,
Timestamp: messageTS,
@ -231,20 +306,32 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
content := ev.Text
content = c.stripBotMention(content)
var mediaPaths []string
localFiles := []string{} // track local files that need cleanup
// In non-DM channels, apply group trigger filtering
if !strings.HasPrefix(channelID, "D") {
respond, cleaned := c.ShouldRespondInGroup(false, content)
if !respond {
return
}
content = cleaned
}
// ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
var mediaPaths []string
scope := channels.BuildMediaScope("slack", chatID, messageTS)
// Helper to register a local file with the media store
storeMedia := func(localPath, filename string) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
Source: "slack",
}, scope)
if err == nil {
return ref
}
}
}()
return localPath // fallback
}
if ev.Message != nil && len(ev.Message.Files) > 0 {
for _, file := range ev.Message.Files {
@ -252,25 +339,10 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
if localPath == "" {
continue
}
localFiles = append(localFiles, localPath)
mediaPaths = append(mediaPaths, localPath)
if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() {
ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second)
defer cancel()
result, err := c.transcriber.Transcribe(ctx, localPath)
if err != nil {
logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()})
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
} else {
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
}
} else {
mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
content += fmt.Sprintf("\n[file: %s]", file.Name)
}
}
}
if strings.TrimSpace(content) == "" {
return
@ -283,13 +355,13 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
peerID = senderID
}
peer := bus.Peer{Kind: peerKind, ID: peerID}
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
"peer_kind": peerKind,
"peer_id": peerID,
"team_id": c.teamID,
}
@ -300,7 +372,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
"has_thread": threadTS != "",
})
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata, sender)
}
func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
@ -308,7 +380,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
return
}
if !c.IsAllowed(ev.User) {
if !c.IsAllowedSender(bus.SenderInfo{
Platform: "slack",
PlatformID: ev.User,
CanonicalID: identity.BuildCanonicalID("slack", ev.User),
}) {
logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
"user_id": ev.User,
})
@ -316,6 +392,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
}
senderID := ev.User
mentionSender := bus.SenderInfo{
Platform: "slack",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("slack", senderID),
}
channelID := ev.Channel
threadTS := ev.ThreadTimeStamp
messageTS := ev.TimeStamp
@ -327,11 +408,6 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
chatID = channelID + "/" + messageTS
}
c.api.AddReaction("eyes", slack.ItemRef{
Channel: channelID,
Timestamp: messageTS,
})
c.pendingAcks.Store(chatID, slackMessageRef{
ChannelID: channelID,
Timestamp: messageTS,
@ -350,18 +426,18 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
mentionPeerID = senderID
}
mentionPeer := bus.Peer{Kind: mentionPeerKind, ID: mentionPeerID}
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
"is_mention": "true",
"peer_kind": mentionPeerKind,
"peer_id": mentionPeerID,
"team_id": c.teamID,
}
c.HandleMessage(senderID, chatID, content, nil, metadata)
c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender)
}
func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
@ -374,7 +450,12 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
c.socketClient.Ack(*event.Request)
}
if !c.IsAllowed(cmd.UserID) {
cmdSender := bus.SenderInfo{
Platform: "slack",
PlatformID: cmd.UserID,
CanonicalID: identity.BuildCanonicalID("slack", cmd.UserID),
}
if !c.IsAllowedSender(cmdSender) {
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
"user_id": cmd.UserID,
})
@ -395,8 +476,6 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
"platform": "slack",
"is_command": "true",
"trigger_id": cmd.TriggerID,
"peer_kind": "channel",
"peer_id": channelID,
"team_id": c.teamID,
}
@ -406,7 +485,17 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
"text": utils.Truncate(content, 50),
})
c.HandleMessage(senderID, chatID, content, nil, metadata)
c.HandleMessage(
c.ctx,
bus.Peer{Kind: "channel", ID: channelID},
"",
senderID,
chatID,
content,
nil,
metadata,
cmdSender,
)
}
func (c *SlackChannel) downloadSlackFile(file slack.File) string {

View file

@ -1,4 +1,4 @@
package channels
package slack
import (
"testing"

208
pkg/channels/split.go Normal file
View file

@ -0,0 +1,208 @@
package channels
import (
"strings"
)
// SplitMessage splits long messages into chunks, preserving code block integrity.
// The maxLen parameter is measured in runes (Unicode characters), not bytes.
// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
// but may extend to maxLen when needed.
// Call SplitMessage with the full text content and the maximum allowed length of a single message;
// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.
func SplitMessage(content string, maxLen int) []string {
if maxLen <= 0 {
if content == "" {
return nil
}
return []string{content}
}
runes := []rune(content)
totalLen := len(runes)
var messages []string
// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
codeBlockBuffer := max(maxLen/10, 50)
if codeBlockBuffer > maxLen/2 {
codeBlockBuffer = maxLen / 2
}
start := 0
for start < totalLen {
remaining := totalLen - start
if remaining <= maxLen {
messages = append(messages, string(runes[start:totalLen]))
break
}
// Effective split point: maxLen minus buffer, to leave room for code blocks
effectiveLimit := max(maxLen-codeBlockBuffer, maxLen/2)
end := start + effectiveLimit
// Find natural split point within the effective limit
msgEnd := findLastNewlineInRange(runes, start, end, 200)
if msgEnd <= start {
msgEnd = findLastSpaceInRange(runes, start, end, 100)
}
if msgEnd <= start {
msgEnd = end
}
// Check if this would end with an incomplete code block
unclosedIdx := findLastUnclosedCodeBlockInRange(runes, start, msgEnd)
if unclosedIdx >= 0 {
// Message would end with incomplete code block
// Try to extend up to maxLen to include the closing ```
if totalLen > msgEnd {
closingIdx := findNextClosingCodeBlockInRange(runes, msgEnd, totalLen)
if closingIdx > 0 && closingIdx-start <= maxLen {
// Extend to include the closing ```
msgEnd = closingIdx
} else {
// Code block is too long to fit in one chunk or missing closing fence.
// Try to split inside by injecting closing and reopening fences.
headerEnd := findNewlineFrom(runes, unclosedIdx)
var header string
if headerEnd == -1 {
header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+3]))
} else {
header = strings.TrimSpace(string(runes[unclosedIdx:headerEnd]))
}
headerEndIdx := unclosedIdx + len([]rune(header))
if headerEnd != -1 {
headerEndIdx = headerEnd
}
// If we have a reasonable amount of content after the header, split inside
if msgEnd > headerEndIdx+20 {
// Find a better split point closer to maxLen
innerLimit := min(
// Leave room for "\n```"
start+maxLen-5, totalLen)
betterEnd := findLastNewlineInRange(runes, start, innerLimit, 200)
if betterEnd > headerEndIdx {
msgEnd = betterEnd
} else {
msgEnd = innerLimit
}
chunk := strings.TrimRight(string(runes[start:msgEnd]), " \t\n\r") + "\n```"
messages = append(messages, chunk)
remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:totalLen]))
// Replace the tail of runes with the reconstructed remaining
runes = []rune(remaining)
totalLen = len(runes)
start = 0
continue
}
// Otherwise, try to split before the code block starts
newEnd := findLastNewlineInRange(runes, start, unclosedIdx, 200)
if newEnd <= start {
newEnd = findLastSpaceInRange(runes, start, unclosedIdx, 100)
}
if newEnd > start {
msgEnd = newEnd
} else {
// If we can't split before, we MUST split inside (last resort)
if unclosedIdx-start > 20 {
msgEnd = unclosedIdx
} else {
splitAt := min(start+maxLen-5, totalLen)
chunk := strings.TrimRight(string(runes[start:splitAt]), " \t\n\r") + "\n```"
messages = append(messages, chunk)
remaining := strings.TrimSpace(header + "\n" + string(runes[splitAt:totalLen]))
runes = []rune(remaining)
totalLen = len(runes)
start = 0
continue
}
}
}
}
}
if msgEnd <= start {
msgEnd = start + effectiveLimit
}
messages = append(messages, string(runes[start:msgEnd]))
// Advance start, skipping leading whitespace of next chunk
start = msgEnd
for start < totalLen && (runes[start] == ' ' || runes[start] == '\t' || runes[start] == '\n' || runes[start] == '\r') {
start++
}
}
return messages
}
// findLastUnclosedCodeBlockInRange finds the last opening ``` that doesn't have a closing ```
// within runes[start:end]. Returns the absolute rune index or -1.
func findLastUnclosedCodeBlockInRange(runes []rune, start, end int) int {
inCodeBlock := false
lastOpenIdx := -1
for i := start; i < end; i++ {
if i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
if !inCodeBlock {
lastOpenIdx = i
}
inCodeBlock = !inCodeBlock
i += 2
}
}
if inCodeBlock {
return lastOpenIdx
}
return -1
}
// findNextClosingCodeBlockInRange finds the next closing ``` starting from startIdx
// within runes[startIdx:end]. Returns the absolute index after the closing ``` or -1.
func findNextClosingCodeBlockInRange(runes []rune, startIdx, end int) int {
for i := startIdx; i < end; i++ {
if i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' {
return i + 3
}
}
return -1
}
// findNewlineFrom finds the first newline character starting from the given index.
// Returns the absolute index or -1 if not found.
func findNewlineFrom(runes []rune, from int) int {
for i := from; i < len(runes); i++ {
if runes[i] == '\n' {
return i
}
}
return -1
}
// findLastNewlineInRange finds the last newline within the last searchWindow runes
// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
func findLastNewlineInRange(runes []rune, start, end, searchWindow int) int {
searchStart := max(end-searchWindow, start)
for i := end - 1; i >= searchStart; i-- {
if runes[i] == '\n' {
return i
}
}
return start - 1
}
// findLastSpaceInRange finds the last space/tab within the last searchWindow runes
// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found).
func findLastSpaceInRange(runes []rune, start, end, searchWindow int) int {
searchStart := max(end-searchWindow, start)
for i := end - 1; i >= searchStart; i-- {
if runes[i] == ' ' || runes[i] == '\t' {
return i
}
}
return start - 1
}

362
pkg/channels/split_test.go Normal file
View file

@ -0,0 +1,362 @@
package channels
import (
"strings"
"testing"
)
func TestSplitMessage(t *testing.T) {
longText := strings.Repeat("a", 2500)
longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars
tests := []struct {
name string
content string
maxLen int
expectChunks int // Check number of chunks
checkContent func(t *testing.T, chunks []string) // Custom validation
}{
{
name: "Empty message",
content: "",
maxLen: 2000,
expectChunks: 0,
},
{
name: "Short message fits in one chunk",
content: "Hello world",
maxLen: 2000,
expectChunks: 1,
},
{
name: "Simple split regular text",
content: longText,
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
if len([]rune(chunks[0])) > 2000 {
t.Errorf("Chunk 0 too large: %d runes", len([]rune(chunks[0])))
}
if len([]rune(chunks[0]))+len([]rune(chunks[1])) != len([]rune(longText)) {
t.Errorf(
"Total rune length mismatch. Got %d, want %d",
len([]rune(chunks[0]))+len([]rune(chunks[1])),
len([]rune(longText)),
)
}
},
},
{
name: "Split at newline",
// 1750 chars then newline, then more chars.
// Dynamic buffer: 2000 / 10 = 200.
// Effective limit: 2000 - 200 = 1800.
// Split should happen at newline because it's at 1750 (< 1800).
// Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051.
content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300),
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
if len([]rune(chunks[0])) != 1750 {
t.Errorf("Expected chunk 0 to be 1750 runes (split at newline), got %d", len([]rune(chunks[0])))
}
if chunks[1] != strings.Repeat("b", 300) {
t.Errorf("Chunk 1 content mismatch. Len: %d", len([]rune(chunks[1])))
}
},
},
{
name: "Long code block split",
content: "Prefix\n" + longCode,
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
// Check that first chunk ends with closing fence
if !strings.HasSuffix(chunks[0], "\n```") {
t.Error("First chunk should end with injected closing fence")
}
// Check that second chunk starts with execution header
if !strings.HasPrefix(chunks[1], "```go") {
t.Error("Second chunk should start with injected code block header")
}
},
},
{
name: "Preserve Unicode characters (rune-aware)",
content: strings.Repeat("\u4e16", 2500), // 2500 runes, 7500 bytes
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
// Verify chunks contain valid unicode and don't split mid-rune
for i, chunk := range chunks {
runeCount := len([]rune(chunk))
if runeCount > 2000 {
t.Errorf("Chunk %d has %d runes, exceeds maxLen 2000", i, runeCount)
}
if !strings.Contains(chunk, "\u4e16") {
t.Errorf("Chunk %d should contain unicode characters", i)
}
}
// Verify total rune count is preserved
totalRunes := 0
for _, chunk := range chunks {
totalRunes += len([]rune(chunk))
}
if totalRunes != 2500 {
t.Errorf("Total rune count mismatch. Got %d, want 2500", totalRunes)
}
},
},
{
name: "Zero maxLen returns single chunk",
content: "Hello world",
maxLen: 0,
expectChunks: 1,
checkContent: func(t *testing.T, chunks []string) {
if chunks[0] != "Hello world" {
t.Errorf("Expected original content, got %q", chunks[0])
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SplitMessage(tc.content, tc.maxLen)
if tc.expectChunks == 0 {
if len(got) != 0 {
t.Errorf("Expected 0 chunks, got %d", len(got))
}
return
}
if len(got) != tc.expectChunks {
t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got))
// Log sizes for debugging
for i, c := range got {
t.Logf("Chunk %d length: %d", i, len(c))
}
return // Stop further checks if count assumes specific split
}
if tc.checkContent != nil {
tc.checkContent(t, got)
}
})
}
}
// --- Helper function tests for index-based rune operations ---
func TestFindLastNewlineInRange(t *testing.T) {
runes := []rune("aaa\nbbb\nccc")
// Indices: 0123 4567 89 10
tests := []struct {
name string
start, end int
searchWindow int
want int
}{
{"finds last newline in full range", 0, 11, 200, 7},
{"finds newline within search window", 0, 11, 4, 7},
{"narrow window misses newline outside window", 4, 11, 3, 3}, // returns start-1 (not found)
{"no newline in range", 0, 3, 200, -1}, // start-1 = -1
{"range limited to first segment", 0, 4, 200, 3},
{"search window of 1 at newline", 0, 8, 1, 7},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := findLastNewlineInRange(runes, tc.start, tc.end, tc.searchWindow)
if got != tc.want {
t.Errorf("findLastNewlineInRange(runes, %d, %d, %d) = %d, want %d",
tc.start, tc.end, tc.searchWindow, got, tc.want)
}
})
}
}
func TestFindLastSpaceInRange(t *testing.T) {
runes := []rune("abc def\tghi")
// Indices: 0123 4567 89 10
tests := []struct {
name string
start, end int
searchWindow int
want int
}{
{"finds tab as last space/tab", 0, 11, 200, 7},
{"finds space when tab out of window", 0, 7, 200, 3},
{"no space in range", 0, 3, 200, -1},
{"narrow window finds tab", 5, 11, 4, 7},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := findLastSpaceInRange(runes, tc.start, tc.end, tc.searchWindow)
if got != tc.want {
t.Errorf("findLastSpaceInRange(runes, %d, %d, %d) = %d, want %d",
tc.start, tc.end, tc.searchWindow, got, tc.want)
}
})
}
}
func TestFindNewlineFrom(t *testing.T) {
runes := []rune("hello\nworld\n")
tests := []struct {
name string
from int
want int
}{
{"from start", 0, 5},
{"from after first newline", 6, 11},
{"from past all newlines", 12, -1},
{"from newline itself", 5, 5},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := findNewlineFrom(runes, tc.from)
if got != tc.want {
t.Errorf("findNewlineFrom(runes, %d) = %d, want %d", tc.from, got, tc.want)
}
})
}
}
func TestFindLastUnclosedCodeBlockInRange(t *testing.T) {
tests := []struct {
name string
content string
start, end int
want int
}{
{
name: "no code blocks",
content: "hello world",
start: 0, end: 11,
want: -1,
},
{
name: "complete code block",
content: "```go\ncode\n```",
start: 0, end: 14,
want: -1,
},
{
name: "unclosed code block",
content: "text\n```go\ncode here",
start: 0, end: 20,
want: 5,
},
{
name: "closed then unclosed",
content: "```a\n```\n```b\ncode",
start: 0, end: 17,
want: 9,
},
{
name: "search within subrange",
content: "```a\n```\n```b\ncode",
start: 9, end: 17,
want: 9,
},
{
name: "subrange with no code blocks",
content: "```a\n```\nhello",
start: 9, end: 14,
want: -1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
runes := []rune(tc.content)
got := findLastUnclosedCodeBlockInRange(runes, tc.start, tc.end)
if got != tc.want {
t.Errorf("findLastUnclosedCodeBlockInRange(%q, %d, %d) = %d, want %d",
tc.content, tc.start, tc.end, got, tc.want)
}
})
}
}
func TestFindNextClosingCodeBlockInRange(t *testing.T) {
tests := []struct {
name string
content string
startIdx int
end int
want int
}{
{
name: "finds closing fence",
content: "code\n```\nmore",
startIdx: 0, end: 13,
want: 8, // position after ```
},
{
name: "no closing fence",
content: "just code here",
startIdx: 0, end: 14,
want: -1,
},
{
name: "fence at start of search",
content: "```end",
startIdx: 0, end: 6,
want: 3,
},
{
name: "fence outside range",
content: "code\n```",
startIdx: 0, end: 4,
want: -1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
runes := []rune(tc.content)
got := findNextClosingCodeBlockInRange(runes, tc.startIdx, tc.end)
if got != tc.want {
t.Errorf("findNextClosingCodeBlockInRange(%q, %d, %d) = %d, want %d",
tc.content, tc.startIdx, tc.end, got, tc.want)
}
})
}
}
func TestSplitMessage_CodeBlockIntegrity(t *testing.T) {
// Focused test for the core requirement: splitting inside a code block preserves syntax highlighting
// 60 chars total approximately
content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```"
maxLen := 40
chunks := SplitMessage(content, maxLen)
if len(chunks) != 2 {
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
}
// First chunk must end with "\n```"
if !strings.HasSuffix(chunks[0], "\n```") {
t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0])
}
// Second chunk must start with the header "```go"
if !strings.HasPrefix(chunks[1], "```go") {
t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1])
}
// First chunk should contain meaningful content
if len([]rune(chunks[0])) > 40 {
t.Errorf("First chunk exceeded maxLen: length %d runes", len([]rune(chunks[0])))
}
}

View file

@ -0,0 +1,13 @@
package telegram
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewTelegramChannel(cfg, b)
})
}

View file

@ -1,4 +1,4 @@
package channels
package telegram
import (
"context"
@ -7,8 +7,8 @@ import (
"net/url"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/mymmrac/telego"
@ -17,10 +17,12 @@ import (
tu "github.com/mymmrac/telego/telegoutil"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
"github.com/sipeed/picoclaw/pkg/voice"
)
var (
@ -37,24 +39,14 @@ var (
)
type TelegramChannel struct {
*BaseChannel
*channels.BaseChannel
bot *telego.Bot
bh *telegohandler.BotHandler
commands TelegramCommander
config *config.Config
chatIDs map[string]int64
transcriber *voice.GroqTranscriber
placeholders sync.Map // chatID -> messageID
stopThinking sync.Map // chatID -> thinkingCancel
}
type thinkingCancel struct {
fn context.CancelFunc
}
func (c *thinkingCancel) Cancel() {
if c != nil && c.fn != nil {
c.fn()
}
ctx context.Context
cancel context.CancelFunc
}
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
@ -85,7 +77,15 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom)
base := channels.NewBaseChannel(
"telegram",
telegramCfg,
bus,
telegramCfg.AllowFrom,
channels.WithMaxMessageLength(4096),
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
)
return &TelegramChannel{
BaseChannel: base,
@ -93,30 +93,28 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
bot: bot,
config: cfg,
chatIDs: make(map[string]int64),
transcriber: nil,
placeholders: sync.Map{},
stopThinking: sync.Map{},
}, nil
}
func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
c.transcriber = transcriber
}
func (c *TelegramChannel) Start(ctx context.Context) error {
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
updates, err := c.bot.UpdatesViaLongPolling(ctx, &telego.GetUpdatesParams{
c.ctx, c.cancel = context.WithCancel(ctx)
updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
Timeout: 30,
})
if err != nil {
c.cancel()
return fmt.Errorf("failed to start long polling: %w", err)
}
bh, err := telegohandler.NewBotHandler(c.bot, updates)
if err != nil {
c.cancel()
return fmt.Errorf("failed to create bot handler: %w", err)
}
c.bh = bh
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
c.commands.Help(ctx, message)
@ -138,59 +136,46 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return c.handleMessage(ctx, &message)
}, th.AnyMessage())
c.setRunning(true)
c.SetRunning(true)
logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
"username": c.bot.Username(),
})
go bh.Start()
go func() {
<-ctx.Done()
bh.Stop()
}()
return nil
}
func (c *TelegramChannel) Stop(ctx context.Context) error {
logger.InfoC("telegram", "Stopping Telegram bot...")
c.setRunning(false)
c.SetRunning(false)
// Stop the bot handler
if c.bh != nil {
c.bh.Stop()
}
// Cancel our context (stops long polling)
if c.cancel != nil {
c.cancel()
}
return nil
}
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("telegram bot not running")
return channels.ErrNotRunning
}
chatID, err := parseChatID(msg.ChatID)
if err != nil {
return fmt.Errorf("invalid chat ID: %w", err)
}
// Stop thinking animation
if stop, ok := c.stopThinking.Load(msg.ChatID); ok {
if cf, ok := stop.(*thinkingCancel); ok && cf != nil {
cf.Cancel()
}
c.stopThinking.Delete(msg.ChatID)
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
htmlContent := markdownToTelegramHTML(msg.Content)
// Try to edit placeholder
if pID, ok := c.placeholders.Load(msg.ChatID); ok {
c.placeholders.Delete(msg.ChatID)
editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent)
editMsg.ParseMode = telego.ModeHTML
if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil {
return nil
}
// Fallback to new message if edit fails
}
// Typing/placeholder handled by Manager.preSend — just send the message
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML
@ -199,9 +184,164 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
"error": err.Error(),
})
tgMsg.ParseMode = ""
_, err = c.bot.SendMessage(ctx, tgMsg)
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}
return nil
}
// StartTyping implements channels.TypingCapable.
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
// The returned stop function is idempotent and cancels the goroutine.
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
cid, err := parseChatID(chatID)
if err != nil {
return func() {}, err
}
// Send the first typing action immediately
_ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
typingCtx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(4 * time.Second)
defer ticker.Stop()
for {
select {
case <-typingCtx.Done():
return
case <-ticker.C:
_ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping))
}
}
}()
return cancel, nil
}
// EditMessage implements channels.MessageEditor.
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
cid, err := parseChatID(chatID)
if err != nil {
return err
}
mid, err := strconv.Atoi(messageID)
if err != nil {
return err
}
htmlContent := markdownToTelegramHTML(content)
editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent)
editMsg.ParseMode = telego.ModeHTML
_, err = c.bot.EditMessageText(ctx, editMsg)
return err
}
// SendPlaceholder implements channels.PlaceholderCapable.
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
// edited to the actual response via EditMessage (channels.MessageEditor).
func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
phCfg := c.config.Channels.Telegram.Placeholder
if !phCfg.Enabled {
return "", nil
}
text := phCfg.Text
if text == "" {
text = "Thinking... 💭"
}
cid, err := parseChatID(chatID)
if err != nil {
return "", err
}
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text))
if err != nil {
return "", err
}
return fmt.Sprintf("%d", pMsg.MessageID), nil
}
// SendMedia implements the channels.MediaSender interface.
func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
chatID, err := parseChatID(msg.ChatID)
if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
store := c.GetMediaStore()
if store == nil {
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
logger.ErrorCF("telegram", "Failed to resolve media ref", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
continue
}
file, err := os.Open(localPath)
if err != nil {
logger.ErrorCF("telegram", "Failed to open media file", map[string]any{
"path": localPath,
"error": err.Error(),
})
continue
}
switch part.Type {
case "image":
params := &telego.SendPhotoParams{
ChatID: tu.ID(chatID),
Photo: telego.InputFile{File: file},
Caption: part.Caption,
}
_, err = c.bot.SendPhoto(ctx, params)
case "audio":
params := &telego.SendAudioParams{
ChatID: tu.ID(chatID),
Audio: telego.InputFile{File: file},
Caption: part.Caption,
}
_, err = c.bot.SendAudio(ctx, params)
case "video":
params := &telego.SendVideoParams{
ChatID: tu.ID(chatID),
Video: telego.InputFile{File: file},
Caption: part.Caption,
}
_, err = c.bot.SendVideo(ctx, params)
default: // "file" or unknown types
params := &telego.SendDocumentParams{
ChatID: tu.ID(chatID),
Document: telego.InputFile{File: file},
Caption: part.Caption,
}
_, err = c.bot.SendDocument(ctx, params)
}
file.Close()
if err != nil {
logger.ErrorCF("telegram", "Failed to send media", map[string]any{
"type": part.Type,
"error": err.Error(),
})
return fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
}
}
return nil
}
@ -216,37 +356,46 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return fmt.Errorf("message sender (user) is nil")
}
senderID := fmt.Sprintf("%d", user.ID)
if user.Username != "" {
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
platformID := fmt.Sprintf("%d", user.ID)
sender := bus.SenderInfo{
Platform: "telegram",
PlatformID: platformID,
CanonicalID: identity.BuildCanonicalID("telegram", platformID),
Username: user.Username,
DisplayName: user.FirstName,
}
// check allowlist to avoid downloading attachments for rejected users
if !c.IsAllowed(senderID) {
if !c.IsAllowedSender(sender) {
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
"user_id": senderID,
"user_id": platformID,
})
return nil
}
chatID := message.Chat.ID
c.chatIDs[senderID] = chatID
c.chatIDs[platformID] = chatID
content := ""
mediaPaths := []string{}
localFiles := []string{} // track local files that need cleanup
// ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
chatIDStr := fmt.Sprintf("%d", chatID)
messageIDStr := fmt.Sprintf("%d", message.MessageID)
scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
// Helper to register a local file with the media store
storeMedia := func(localPath, filename string) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
Source: "telegram",
}, scope)
if err == nil {
return ref
}
}
}()
return localPath // fallback: use raw path
}
if message.Text != "" {
content += message.Text
@ -263,8 +412,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
photo := message.Photo[len(message.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID)
if photoPath != "" {
localFiles = append(localFiles, photoPath)
mediaPaths = append(mediaPaths, photoPath)
mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg"))
if content != "" {
content += "\n"
}
@ -275,43 +423,19 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
if message.Voice != nil {
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
if voicePath != "" {
localFiles = append(localFiles, voicePath)
mediaPaths = append(mediaPaths, voicePath)
var transcribedText string
if c.transcriber != nil && c.transcriber.IsAvailable() {
transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
result, err := c.transcriber.Transcribe(transcriberCtx, voicePath)
if err != nil {
logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{
"error": err.Error(),
"path": voicePath,
})
transcribedText = "[voice (transcription failed)]"
} else {
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{
"text": result.Text,
})
}
} else {
transcribedText = "[voice]"
}
mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
if content != "" {
content += "\n"
}
content += transcribedText
content += "[voice]"
}
}
if message.Audio != nil {
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
if audioPath != "" {
localFiles = append(localFiles, audioPath)
mediaPaths = append(mediaPaths, audioPath)
mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3"))
if content != "" {
content += "\n"
}
@ -322,8 +446,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
if message.Document != nil {
docPath := c.downloadFile(ctx, message.Document.FileID, "")
if docPath != "" {
localFiles = append(localFiles, docPath)
mediaPaths = append(mediaPaths, docPath)
mediaPaths = append(mediaPaths, storeMedia(docPath, "document"))
if content != "" {
content += "\n"
}
@ -335,37 +458,26 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content = "[empty message]"
}
// In group chats, apply unified group trigger filtering
if message.Chat.Type != "private" {
isMentioned := c.isBotMentioned(message)
if isMentioned {
content = c.stripBotMention(content)
}
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
return nil
}
content = cleaned
}
logger.DebugCF("telegram", "Received message", map[string]any{
"sender_id": senderID,
"sender_id": sender.CanonicalID,
"chat_id": fmt.Sprintf("%d", chatID),
"preview": utils.Truncate(content, 50),
})
// Thinking indicator
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
if err != nil {
logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{
"error": err.Error(),
})
}
// Stop any previous thinking animation
chatIDStr := fmt.Sprintf("%d", chatID)
if prevStop, ok := c.stopThinking.Load(chatIDStr); ok {
if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil {
cf.Cancel()
}
}
// Create cancel function for thinking state
_, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel})
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭"))
if err == nil {
pID := pMsg.MessageID
c.placeholders.Store(chatIDStr, pID)
}
// Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable
peerKind := "direct"
peerID := fmt.Sprintf("%d", user.ID)
@ -374,17 +486,26 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
peerID = fmt.Sprintf("%d", chatID)
}
peer := bus.Peer{Kind: peerKind, ID: peerID}
messageID := fmt.Sprintf("%d", message.MessageID)
metadata := map[string]string{
"message_id": fmt.Sprintf("%d", message.MessageID),
"user_id": fmt.Sprintf("%d", user.ID),
"username": user.Username,
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
"peer_kind": peerKind,
"peer_id": peerID,
}
c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
c.HandleMessage(c.ctx,
peer,
messageID,
platformID,
fmt.Sprintf("%d", chatID),
content,
mediaPaths,
metadata,
sender,
)
return nil
}
@ -537,3 +658,52 @@ func escapeHTML(text string) string {
text = strings.ReplaceAll(text, ">", "&gt;")
return text
}
// isBotMentioned checks if the bot is mentioned in the message via entities.
func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool {
botUsername := c.bot.Username()
if botUsername == "" {
return false
}
entities := message.Entities
if entities == nil {
entities = message.CaptionEntities
}
for _, entity := range entities {
if entity.Type == "mention" {
// Extract the mention text from the message
text := message.Text
if text == "" {
text = message.Caption
}
runes := []rune(text)
end := entity.Offset + entity.Length
if end <= len(runes) {
mention := string(runes[entity.Offset:end])
if strings.EqualFold(mention, "@"+botUsername) {
return true
}
}
}
if entity.Type == "text_mention" && entity.User != nil {
if entity.User.Username == botUsername {
return true
}
}
}
return false
}
// stripBotMention removes the @bot mention from the content.
func (c *TelegramChannel) stripBotMention(content string) string {
botUsername := c.bot.Username()
if botUsername == "" {
return content
}
// Case-insensitive replacement
re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername))
content = re.ReplaceAllString(content, "")
return strings.TrimSpace(content)
}

View file

@ -1,4 +1,4 @@
package channels
package telegram
import (
"context"
@ -119,7 +119,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
if provider == "" {
provider = "configured default"
}
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
c.config.Agents.Defaults.GetModelName(), provider)
case "channels":

20
pkg/channels/webhook.go Normal file
View file

@ -0,0 +1,20 @@
package channels
import "net/http"
// WebhookHandler is an optional interface for channels that receive messages
// via HTTP webhooks. Manager discovers channels implementing this interface
// and registers them on the shared HTTP server.
type WebhookHandler interface {
// WebhookPath returns the path to mount this handler on the shared server.
// Examples: "/webhook/line", "/webhook/wecom"
WebhookPath() string
http.Handler // ServeHTTP(w http.ResponseWriter, r *http.Request)
}
// HealthChecker is an optional interface for channels that expose
// a health check endpoint on the shared HTTP server.
type HealthChecker interface {
HealthPath() string
HealthHandler(w http.ResponseWriter, r *http.Request)
}

View file

@ -1,8 +1,4 @@
// PicoClaw - Ultra-lightweight personal AI agent
// WeCom App (企业微信自建应用) channel implementation
// Supports receiving messages via webhook callback and sending messages proactively
package channels
package wecom
import (
"bytes"
@ -11,14 +7,19 @@ import (
"encoding/xml"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
@ -29,9 +30,8 @@ const (
// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)
type WeComAppChannel struct {
*BaseChannel
*channels.BaseChannel
config config.WeComAppConfig
server *http.Server
accessToken string
tokenExpiry time.Time
tokenMu sync.RWMutex
@ -123,7 +123,11 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
}
base := NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom)
base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(2048),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &WeComAppChannel{
BaseChannel: base,
@ -137,7 +141,7 @@ func (c *WeComAppChannel) Name() string {
return "wecom_app"
}
// Start initializes the WeCom App channel with HTTP webhook server
// Start initializes the WeCom App channel
func (c *WeComAppChannel) Start(ctx context.Context) error {
logger.InfoC("wecom_app", "Starting WeCom App channel...")
@ -153,37 +157,8 @@ func (c *WeComAppChannel) Start(ctx context.Context) error {
// Start token refresh goroutine
go c.tokenRefreshLoop()
// Setup HTTP server for webhook
mux := http.NewServeMux()
webhookPath := c.config.WebhookPath
if webhookPath == "" {
webhookPath = "/webhook/wecom-app"
}
mux.HandleFunc(webhookPath, c.handleWebhook)
// Health check endpoint
mux.HandleFunc("/health/wecom-app", c.handleHealth)
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
c.server = &http.Server{
Addr: addr,
Handler: mux,
}
c.setRunning(true)
logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{
"address": addr,
"path": webhookPath,
})
// Start server in goroutine
go func() {
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{
"error": err.Error(),
})
}
}()
c.SetRunning(true)
logger.InfoC("wecom_app", "WeCom App channel started")
return nil
}
@ -196,13 +171,7 @@ func (c *WeComAppChannel) Stop(ctx context.Context) error {
c.cancel()
}
if c.server != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
c.server.Shutdown(shutdownCtx)
}
c.setRunning(false)
c.SetRunning(false)
logger.InfoC("wecom_app", "WeCom App channel stopped")
return nil
}
@ -210,7 +179,7 @@ func (c *WeComAppChannel) Stop(ctx context.Context) error {
// Send sends a message to WeCom user proactively using access token
func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("wecom_app channel not running")
return channels.ErrNotRunning
}
accessToken := c.getAccessToken()
@ -226,6 +195,220 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)
}
// SendMedia implements the channels.MediaSender interface.
func (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
accessToken := c.getAccessToken()
if accessToken == "" {
return fmt.Errorf("no valid access token available: %w", channels.ErrTemporary)
}
store := c.GetMediaStore()
if store == nil {
return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to resolve media ref", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
continue
}
// Map part type to WeCom media type
var mediaType string
switch part.Type {
case "image":
mediaType = "image"
case "audio":
mediaType = "voice"
case "video":
mediaType = "video"
default:
mediaType = "file"
}
// Upload media to get media_id
mediaID, err := c.uploadMedia(ctx, accessToken, mediaType, localPath)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to upload media", map[string]any{
"type": mediaType,
"error": err.Error(),
})
// Fallback: send caption as text
if part.Caption != "" {
_ = c.sendTextMessage(ctx, accessToken, msg.ChatID, part.Caption)
}
continue
}
// Send media message using the media_id
if mediaType == "image" {
err = c.sendImageMessage(ctx, accessToken, msg.ChatID, mediaID)
} else {
// For non-image types, send as text fallback with caption
caption := part.Caption
if caption == "" {
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
}
err = c.sendTextMessage(ctx, accessToken, msg.ChatID, caption)
}
if err != nil {
return err
}
}
return nil
}
// uploadMedia uploads a local file to WeCom temporary media storage.
func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaType, localPath string) (string, error) {
apiURL := fmt.Sprintf("%s/cgi-bin/media/upload?access_token=%s&type=%s",
wecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaType))
file, err := os.Open(localPath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
filename := filepath.Base(localPath)
formFile, err := writer.CreateFormFile("media", filename)
if err != nil {
return "", fmt.Errorf("failed to create form file: %w", err)
}
if _, err = io.Copy(formFile, file); err != nil {
return "", fmt.Errorf("failed to copy file content: %w", err)
}
writer.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody)))
}
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
MediaID string `json:"media_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to parse upload response: %w", err)
}
if result.ErrCode != 0 {
return "", fmt.Errorf("upload API error: %s (code: %d)", result.ErrMsg, result.ErrCode)
}
return result.MediaID, nil
}
// sendImageMessage sends an image message using a media_id.
func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error {
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
msg := WeComImageMessage{
ToUser: userID,
MsgType: "image",
AgentID: c.config.AgentID,
}
msg.Image.MediaID = mediaID
jsonData, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
timeout := c.config.ReplyTimeout
if timeout <= 0 {
timeout = 5
}
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
resp, err := client.Do(req)
if err != nil {
return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody)))
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
var sendResp WeComSendMessageResponse
if err := json.Unmarshal(respBody, &sendResp); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if sendResp.ErrCode != 0 {
return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
}
return nil
}
// WebhookPath returns the path for registering on the shared HTTP server.
func (c *WeComAppChannel) WebhookPath() string {
if c.config.WebhookPath != "" {
return c.config.WebhookPath
}
return "/webhook/wecom-app"
}
// ServeHTTP implements http.Handler for the shared HTTP server.
func (c *WeComAppChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.handleWebhook(w, r)
}
// HealthPath returns the health check endpoint path.
func (c *WeComAppChannel) HealthPath() string {
return "/health/wecom-app"
}
// HealthHandler handles health check requests.
func (c *WeComAppChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
c.handleHealth(w, r)
}
// handleWebhook handles incoming webhook requests from WeCom
func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@ -279,7 +462,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
}
// Verify signature
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{
"token": c.config.Token,
"msg_signature": msgSignature,
@ -298,7 +481,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
"encoding_aes_key": c.config.EncodingAESKey,
"corp_id": c.config.CorpID,
})
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{
"error": err.Error(),
@ -357,7 +540,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
// Verify signature
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
logger.WarnC("wecom_app", "Message signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
@ -365,7 +548,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
// Decrypt message with CorpID verification
// For WeCom App (自建应用), receiveid should be corp_id
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID)
if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{
"error": err.Error(),
@ -428,6 +611,9 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
// Build metadata
// WeCom App only supports direct messages (private chat)
peer := bus.Peer{Kind: "direct", ID: senderID}
messageID := fmt.Sprintf("%d", msg.MsgId)
metadata := map[string]string{
"msg_type": msg.MsgType,
"msg_id": fmt.Sprintf("%d", msg.MsgId),
@ -435,8 +621,6 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
"platform": "wecom_app",
"media_id": msg.MediaId,
"create_time": fmt.Sprintf("%d", msg.CreateTime),
"peer_kind": "direct",
"peer_id": senderID,
}
content := msg.Content
@ -447,8 +631,15 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
"preview": utils.Truncate(content, 50),
})
// Build sender info
appSender := bus.SenderInfo{
Platform: "wecom",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("wecom", senderID),
}
// Handle the message through the base channel
c.HandleMessage(senderID, chatID, content, nil, metadata)
c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender)
}
// tokenRefreshLoop periodically refreshes the access token
@ -550,10 +741,15 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send message: %w", err)
return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)

View file

@ -1,7 +1,4 @@
// PicoClaw - Ultra-lightweight personal AI agent
// WeCom App (企业微信自建应用) channel tests
package channels
package wecom
import (
"bytes"
@ -197,7 +194,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
msgEncrypt := "test_message"
expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt)
if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
t.Error("valid signature should pass verification")
}
})
@ -207,7 +204,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
nonce := "test_nonce"
msgEncrypt := "test_message"
if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification")
}
})
@ -221,7 +218,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
}
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true")
}
})
@ -243,7 +240,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
plainText := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
result, err := decryptMessage(encoded, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -268,7 +265,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
t.Fatalf("failed to encrypt test message: %v", err)
}
result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
result, err := decryptMessage(encrypted, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -286,7 +283,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComAppChannel(cfg, msgBus)
_, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
_, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid base64, got nil")
}
@ -301,7 +298,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComAppChannel(cfg, msgBus)
_, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
_, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid AES key, got nil")
}
@ -319,7 +316,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
// Encrypt a very short message that results in ciphertext less than block size
shortData := make([]byte, 8)
_, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)
_, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for short ciphertext, got nil")
}
@ -361,7 +358,7 @@ func TestWeComAppPKCS7Unpad(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := pkcs7UnpadWeCom(tt.input)
result, err := pkcs7Unpad(tt.input)
if tt.expected == nil {
// This case should return an error
if err == nil {
@ -852,6 +849,28 @@ func TestWeComAppMessageStructures(t *testing.T) {
}
})
t.Run("WeComImageMessage structure", func(t *testing.T) {
msg := WeComImageMessage{
ToUser: "user123",
MsgType: "image",
AgentID: 1000002,
}
msg.Image.MediaID = "media_123456"
if msg.Image.MediaID != "media_123456" {
t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456")
}
if msg.ToUser != "user123" {
t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123")
}
if msg.MsgType != "image" {
t.Errorf("MsgType = %q, want %q", msg.MsgType, "image")
}
if msg.AgentID != 1000002 {
t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002)
}
})
t.Run("WeComAccessTokenResponse structure", func(t *testing.T) {
jsonData := `{
"errcode": 0,

View file

@ -1,29 +1,21 @@
// PicoClaw - Ultra-lightweight personal AI agent
// WeCom Bot (企业微信智能机器人) channel implementation
// Uses webhook callback mode for receiving messages and webhook API for sending replies
package channels
package wecom
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
@ -31,9 +23,8 @@ import (
// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人)
// Uses webhook callback mode - simpler than WeCom App but only supports passive replies
type WeComBotChannel struct {
*BaseChannel
*channels.BaseChannel
config config.WeComConfig
server *http.Server
ctx context.Context
cancel context.CancelFunc
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
@ -96,7 +87,11 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
return nil, fmt.Errorf("wecom token and webhook_url are required")
}
base := NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom)
base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(2048),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &WeComBotChannel{
BaseChannel: base,
@ -110,43 +105,14 @@ func (c *WeComBotChannel) Name() string {
return "wecom"
}
// Start initializes the WeCom Bot channel with HTTP webhook server
// Start initializes the WeCom Bot channel
func (c *WeComBotChannel) Start(ctx context.Context) error {
logger.InfoC("wecom", "Starting WeCom Bot channel...")
c.ctx, c.cancel = context.WithCancel(ctx)
// Setup HTTP server for webhook
mux := http.NewServeMux()
webhookPath := c.config.WebhookPath
if webhookPath == "" {
webhookPath = "/webhook/wecom"
}
mux.HandleFunc(webhookPath, c.handleWebhook)
// Health check endpoint
mux.HandleFunc("/health/wecom", c.handleHealth)
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
c.server = &http.Server{
Addr: addr,
Handler: mux,
}
c.setRunning(true)
logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{
"address": addr,
"path": webhookPath,
})
// Start server in goroutine
go func() {
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("wecom", "HTTP server error", map[string]any{
"error": err.Error(),
})
}
}()
c.SetRunning(true)
logger.InfoC("wecom", "WeCom Bot channel started")
return nil
}
@ -159,13 +125,7 @@ func (c *WeComBotChannel) Stop(ctx context.Context) error {
c.cancel()
}
if c.server != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
c.server.Shutdown(shutdownCtx)
}
c.setRunning(false)
c.SetRunning(false)
logger.InfoC("wecom", "WeCom Bot channel stopped")
return nil
}
@ -175,7 +135,7 @@ func (c *WeComBotChannel) Stop(ctx context.Context) error {
// For delayed responses, we use the webhook URL
func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("wecom channel not running")
return channels.ErrNotRunning
}
logger.DebugCF("wecom", "Sending message via webhook", map[string]any{
@ -186,6 +146,29 @@ func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return c.sendWebhookReply(ctx, msg.ChatID, msg.Content)
}
// WebhookPath returns the path for registering on the shared HTTP server.
func (c *WeComBotChannel) WebhookPath() string {
if c.config.WebhookPath != "" {
return c.config.WebhookPath
}
return "/webhook/wecom"
}
// ServeHTTP implements http.Handler for the shared HTTP server.
func (c *WeComBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.handleWebhook(w, r)
}
// HealthPath returns the health check endpoint path.
func (c *WeComBotChannel) HealthPath() string {
return "/health/wecom"
}
// HealthHandler handles health check requests.
func (c *WeComBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) {
c.handleHealth(w, r)
}
// handleWebhook handles incoming webhook requests from WeCom
func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@ -219,7 +202,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
}
// Verify signature
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
logger.WarnC("wecom", "Signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
@ -228,7 +211,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
// Decrypt echostr
// For AIBOT (智能机器人), receiveid should be empty string ""
// Reference: https://developer.work.weixin.qq.com/document/path/101033
decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{
"error": err.Error(),
@ -281,7 +264,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
}
// Verify signature
if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
logger.WarnC("wecom", "Message signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
@ -290,7 +273,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
// Decrypt message
// For AIBOT (智能机器人), receiveid should be empty string ""
// Reference: https://developer.work.weixin.qq.com/document/path/101033
decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "")
if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{
"error": err.Error(),
@ -387,12 +370,21 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
}
// Build metadata
peer := bus.Peer{Kind: peerKind, ID: peerID}
// In group chats, apply unified group trigger filtering
if isGroupChat {
respond, cleaned := c.ShouldRespondInGroup(false, content)
if !respond {
return
}
content = cleaned
}
metadata := map[string]string{
"msg_type": msg.MsgType,
"msg_id": msg.MsgID,
"platform": "wecom",
"peer_kind": peerKind,
"peer_id": peerID,
"response_url": msg.ResponseURL,
}
if isGroupChat {
@ -408,8 +400,19 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
"preview": utils.Truncate(content, 50),
})
// Build sender info
sender := bus.SenderInfo{
Platform: "wecom",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("wecom", senderID),
}
if !c.IsAllowedSender(sender) {
return
}
// Handle the message through the base channel
c.HandleMessage(senderID, chatID, content, nil, metadata)
c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender)
}
// sendWebhookReply sends a reply using the webhook URL
@ -442,10 +445,15 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send webhook reply: %w", err)
return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
@ -477,129 +485,3 @@ func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
// WeCom common utilities for both WeCom Bot and WeCom App
// The following functions were moved from wecom_common.go
// WeComVerifySignature verifies the message signature for WeCom
// This is a common function used by both WeCom Bot and WeCom App
func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
if token == "" {
return true // Skip verification if token is not set
}
// Sort parameters
params := []string{token, timestamp, nonce, msgEncrypt}
sort.Strings(params)
// Concatenate
str := strings.Join(params, "")
// SHA1 hash
hash := sha1.Sum([]byte(str))
expectedSignature := fmt.Sprintf("%x", hash)
return expectedSignature == msgSignature
}
// WeComDecryptMessage decrypts the encrypted message using AES
// This is a common function used by both WeCom Bot and WeCom App
// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
return WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
}
// WeComDecryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
func WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
if encodingAESKey == "" {
// No encryption, return as is (base64 decode)
decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
if err != nil {
return "", err
}
return string(decoded), nil
}
// Decode AES key (base64)
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
return "", fmt.Errorf("failed to decode AES key: %w", err)
}
// Decode encrypted message
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
if err != nil {
return "", fmt.Errorf("failed to decode message: %w", err)
}
// AES decrypt
block, err := aes.NewCipher(aesKey)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
if len(cipherText) < aes.BlockSize {
return "", fmt.Errorf("ciphertext too short")
}
// IV is the first 16 bytes of AESKey
iv := aesKey[:aes.BlockSize]
mode := cipher.NewCBCDecrypter(block, iv)
plainText := make([]byte, len(cipherText))
mode.CryptBlocks(plainText, cipherText)
// Remove PKCS7 padding
plainText, err = pkcs7UnpadWeCom(plainText)
if err != nil {
return "", fmt.Errorf("failed to unpad: %w", err)
}
// Parse message structure
// Format: random(16) + msg_len(4) + msg + receiveid
if len(plainText) < 20 {
return "", fmt.Errorf("decrypted message too short")
}
msgLen := binary.BigEndian.Uint32(plainText[16:20])
if int(msgLen) > len(plainText)-20 {
return "", fmt.Errorf("invalid message length")
}
msg := plainText[20 : 20+msgLen]
// Verify receiveid if provided
if receiveid != "" && len(plainText) > 20+int(msgLen) {
actualReceiveID := string(plainText[20+msgLen:])
if actualReceiveID != receiveid {
return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
}
}
return string(msg), nil
}
// pkcs7UnpadWeCom removes PKCS7 padding with validation
// WeCom uses block size of 32 (not standard AES block size of 16)
const wecomBlockSize = 32
func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
if len(data) == 0 {
return data, nil
}
padding := int(data[len(data)-1])
// WeCom uses 32-byte block size for PKCS7 padding
if padding == 0 || padding > wecomBlockSize {
return nil, fmt.Errorf("invalid padding size: %d", padding)
}
if padding > len(data) {
return nil, fmt.Errorf("padding size larger than data")
}
// Verify all padding bytes
for i := range padding {
if data[len(data)-1-i] != byte(padding) {
return nil, fmt.Errorf("invalid padding byte at position %d", i)
}
}
return data[:len(data)-padding], nil
}

View file

@ -1,7 +1,4 @@
// PicoClaw - Ultra-lightweight personal AI agent
// WeCom Bot (企业微信智能机器人) channel tests
package channels
package wecom
import (
"bytes"
@ -177,7 +174,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
msgEncrypt := "test_message"
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
t.Error("valid signature should pass verification")
}
})
@ -187,7 +184,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
nonce := "test_nonce"
msgEncrypt := "test_message"
if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification")
}
})
@ -202,7 +199,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
config: cfgEmpty,
}
if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true")
}
})
@ -223,7 +220,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
plainText := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
result, err := decryptMessage(encoded, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -247,7 +244,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
t.Fatalf("failed to encrypt test message: %v", err)
}
result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
result, err := decryptMessage(encrypted, ch.config.EncodingAESKey)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -264,7 +261,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
_, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
_, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid base64, got nil")
}
@ -278,7 +275,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
}
ch, _ := NewWeComBotChannel(cfg, msgBus)
_, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
_, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
if err == nil {
t.Error("expected error for invalid AES key, got nil")
}
@ -320,20 +317,20 @@ func TestWeComBotPKCS7Unpad(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := pkcs7UnpadWeCom(tt.input)
result, err := pkcs7Unpad(tt.input)
if tt.expected == nil {
// This case should return an error
if err == nil {
t.Errorf("pkcs7UnpadWeCom() expected error for invalid padding, got result: %v", result)
t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result)
}
return
}
if err != nil {
t.Errorf("pkcs7UnpadWeCom() unexpected error: %v", err)
t.Errorf("pkcs7Unpad() unexpected error: %v", err)
return
}
if !bytes.Equal(result, tt.expected) {
t.Errorf("pkcs7UnpadWeCom() = %v, want %v", result, tt.expected)
t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected)
}
})
}

View file

@ -0,0 +1,134 @@
package wecom
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"fmt"
"sort"
"strings"
)
// blockSize is the PKCS7 block size used by WeCom (32)
const blockSize = 32
// verifySignature verifies the message signature for WeCom
// This is a common function used by both WeCom Bot and WeCom App
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
if token == "" {
return true // Skip verification if token is not set
}
// Sort parameters
params := []string{token, timestamp, nonce, msgEncrypt}
sort.Strings(params)
// Concatenate
str := strings.Join(params, "")
// SHA1 hash
hash := sha1.Sum([]byte(str))
expectedSignature := fmt.Sprintf("%x", hash)
return expectedSignature == msgSignature
}
// decryptMessage decrypts the encrypted message using AES
// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id
func decryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
return decryptMessageWithVerify(encryptedMsg, encodingAESKey, "")
}
// decryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid
// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification.
func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) {
if encodingAESKey == "" {
// No encryption, return as is (base64 decode)
decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
if err != nil {
return "", err
}
return string(decoded), nil
}
// Decode AES key (base64)
aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
if err != nil {
return "", fmt.Errorf("failed to decode AES key: %w", err)
}
// Decode encrypted message
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
if err != nil {
return "", fmt.Errorf("failed to decode message: %w", err)
}
// AES decrypt
block, err := aes.NewCipher(aesKey)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
if len(cipherText) < aes.BlockSize {
return "", fmt.Errorf("ciphertext too short")
}
// IV is the first 16 bytes of AESKey
iv := aesKey[:aes.BlockSize]
mode := cipher.NewCBCDecrypter(block, iv)
plainText := make([]byte, len(cipherText))
mode.CryptBlocks(plainText, cipherText)
// Remove PKCS7 padding
plainText, err = pkcs7Unpad(plainText)
if err != nil {
return "", fmt.Errorf("failed to unpad: %w", err)
}
// Parse message structure
// Format: random(16) + msg_len(4) + msg + receiveid
if len(plainText) < 20 {
return "", fmt.Errorf("decrypted message too short")
}
msgLen := binary.BigEndian.Uint32(plainText[16:20])
if int(msgLen) > len(plainText)-20 {
return "", fmt.Errorf("invalid message length")
}
msg := plainText[20 : 20+msgLen]
// Verify receiveid if provided
if receiveid != "" && len(plainText) > 20+int(msgLen) {
actualReceiveID := string(plainText[20+msgLen:])
if actualReceiveID != receiveid {
return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID)
}
}
return string(msg), nil
}
// pkcs7Unpad removes PKCS7 padding with validation
func pkcs7Unpad(data []byte) ([]byte, error) {
if len(data) == 0 {
return data, nil
}
padding := int(data[len(data)-1])
// WeCom uses 32-byte block size for PKCS7 padding
if padding == 0 || padding > blockSize {
return nil, fmt.Errorf("invalid padding size: %d", padding)
}
if padding > len(data) {
return nil, fmt.Errorf("padding size larger than data")
}
// Verify all padding bytes
for i := range padding {
if data[len(data)-1-i] != byte(padding) {
return nil, fmt.Errorf("invalid padding byte at position %d", i)
}
}
return data[:len(data)-padding], nil
}

View file

@ -0,0 +1,16 @@
package wecom
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewWeComBotChannel(cfg.Channels.WeCom, b)
})
channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewWeComAppChannel(cfg.Channels.WeComApp, b)
})
}

View file

@ -0,0 +1,13 @@
package whatsapp
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("whatsapp", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewWhatsAppChannel(cfg.Channels.WhatsApp, b)
})
}

View file

@ -1,31 +1,42 @@
package channels
package whatsapp
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
type WhatsAppChannel struct {
*BaseChannel
*channels.BaseChannel
conn *websocket.Conn
config config.WhatsAppConfig
url string
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
connected bool
}
func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom)
base := channels.NewBaseChannel(
"whatsapp",
cfg,
bus,
cfg.AllowFrom,
channels.WithMaxMessageLength(65536),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &WhatsAppChannel{
BaseChannel: base,
@ -36,7 +47,11 @@ func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsA
}
func (c *WhatsAppChannel) Start(ctx context.Context) error {
log.Printf("Starting WhatsApp channel connecting to %s...", c.url)
logger.InfoCF("whatsapp", "Starting WhatsApp channel", map[string]any{
"bridge_url": c.url,
})
c.ctx, c.cancel = context.WithCancel(ctx)
dialer := websocket.DefaultDialer
dialer.HandshakeTimeout = 10 * time.Second
@ -46,6 +61,7 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error {
resp.Body.Close()
}
if err != nil {
c.cancel()
return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err)
}
@ -54,39 +70,57 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error {
c.connected = true
c.mu.Unlock()
c.setRunning(true)
log.Println("WhatsApp channel connected")
c.SetRunning(true)
logger.InfoC("whatsapp", "WhatsApp channel connected")
go c.listen(ctx)
go c.listen()
return nil
}
func (c *WhatsAppChannel) Stop(ctx context.Context) error {
log.Println("Stopping WhatsApp channel...")
logger.InfoC("whatsapp", "Stopping WhatsApp channel...")
// Cancel context first to signal listen goroutine to exit
if c.cancel != nil {
c.cancel()
}
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
if err := c.conn.Close(); err != nil {
log.Printf("Error closing WhatsApp connection: %v", err)
logger.ErrorCF("whatsapp", "Error closing WhatsApp connection", map[string]any{
"error": err.Error(),
})
}
c.conn = nil
}
c.connected = false
c.setRunning(false)
c.SetRunning(false)
return nil
}
func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
// Check ctx before acquiring lock
select {
case <-ctx.Done():
return ctx.Err()
default:
}
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("whatsapp connection not established")
return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
payload := map[string]any{
@ -100,17 +134,20 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("failed to marshal message: %w", err)
}
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
return fmt.Errorf("failed to send message: %w", err)
_ = c.conn.SetWriteDeadline(time.Time{})
return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
}
_ = c.conn.SetWriteDeadline(time.Time{})
return nil
}
func (c *WhatsAppChannel) listen(ctx context.Context) {
func (c *WhatsAppChannel) listen() {
for {
select {
case <-ctx.Done():
case <-c.ctx.Done():
return
default:
c.mu.Lock()
@ -124,14 +161,18 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
_, message, err := conn.ReadMessage()
if err != nil {
log.Printf("WhatsApp read error: %v", err)
logger.ErrorCF("whatsapp", "WhatsApp read error", map[string]any{
"error": err.Error(),
})
time.Sleep(2 * time.Second)
continue
}
var msg map[string]any
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("Failed to unmarshal WhatsApp message: %v", err)
logger.ErrorCF("whatsapp", "Failed to unmarshal WhatsApp message", map[string]any{
"error": err.Error(),
})
continue
}
@ -174,22 +215,38 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
}
metadata := make(map[string]string)
if messageID, ok := msg["id"].(string); ok {
metadata["message_id"] = messageID
var messageID string
if mid, ok := msg["id"].(string); ok {
messageID = mid
}
if userName, ok := msg["from_name"].(string); ok {
metadata["user_name"] = userName
}
var peer bus.Peer
if chatID == senderID {
metadata["peer_kind"] = "direct"
metadata["peer_id"] = senderID
peer = bus.Peer{Kind: "direct", ID: senderID}
} else {
metadata["peer_kind"] = "group"
metadata["peer_id"] = chatID
peer = bus.Peer{Kind: "group", ID: chatID}
}
log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50))
logger.InfoCF("whatsapp", "WhatsApp message received", map[string]any{
"sender": senderID,
"preview": utils.Truncate(content, 50),
})
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
sender := bus.SenderInfo{
Platform: "whatsapp",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("whatsapp", senderID),
}
if display, ok := metadata["user_name"]; ok {
sender.DisplayName = display
}
if !c.IsAllowedSender(sender) {
return
}
c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
}

View file

@ -0,0 +1,20 @@
package whatsapp
import (
"path/filepath"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("whatsapp_native", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
waCfg := cfg.Channels.WhatsApp
storePath := waCfg.SessionStorePath
if storePath == "" {
storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp")
}
return NewWhatsAppNativeChannel(waCfg, b, storePath)
})
}

View file

@ -0,0 +1,341 @@
//go:build whatsapp_native
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package whatsapp
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/mdp/qrterminal/v3"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log"
"google.golang.org/protobuf/proto"
_ "modernc.org/sqlite"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
const (
sqliteDriver = "sqlite"
whatsappDBName = "store.db"
reconnectInitial = 5 * time.Second
reconnectMax = 5 * time.Minute
reconnectMultiplier = 2.0
)
// WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge).
type WhatsAppNativeChannel struct {
*channels.BaseChannel
config config.WhatsAppConfig
storePath string
client *whatsmeow.Client
container *sqlstore.Container
mu sync.Mutex
runCtx context.Context
runCancel context.CancelFunc
reconnectMu sync.Mutex
reconnecting bool
}
// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection.
// storePath is the directory for the SQLite session store (e.g. workspace/whatsapp).
func NewWhatsAppNativeChannel(
cfg config.WhatsAppConfig,
bus *bus.MessageBus,
storePath string,
) (channels.Channel, error) {
base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536))
if storePath == "" {
storePath = "whatsapp"
}
c := &WhatsAppNativeChannel{
BaseChannel: base,
config: cfg,
storePath: storePath,
}
return c, nil
}
func (c *WhatsAppNativeChannel) Start(ctx context.Context) error {
logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath})
if err := os.MkdirAll(c.storePath, 0o700); err != nil {
return fmt.Errorf("create session store dir: %w", err)
}
dbPath := filepath.Join(c.storePath, whatsappDBName)
connStr := "file:" + dbPath + "?_foreign_keys=on"
db, err := sql.Open(sqliteDriver, connStr)
if err != nil {
return fmt.Errorf("open whatsapp store: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
_ = db.Close()
return fmt.Errorf("enable foreign keys: %w", err)
}
waLogger := waLog.Stdout("WhatsApp", "WARN", true)
container := sqlstore.NewWithDB(db, sqliteDriver, waLogger)
if err = container.Upgrade(ctx); err != nil {
_ = db.Close()
return fmt.Errorf("open whatsapp store: %w", err)
}
deviceStore, err := container.GetFirstDevice(ctx)
if err != nil {
_ = container.Close()
return fmt.Errorf("get device store: %w", err)
}
client := whatsmeow.NewClient(deviceStore, waLogger)
client.AddEventHandler(c.eventHandler)
c.mu.Lock()
c.container = container
c.client = client
c.mu.Unlock()
if client.Store.ID == nil {
qrChan, err := client.GetQRChannel(ctx)
if err != nil {
_ = container.Close()
return fmt.Errorf("get QR channel: %w", err)
}
if err := client.Connect(); err != nil {
_ = container.Close()
return fmt.Errorf("connect: %w", err)
}
for evt := range qrChan {
if evt.Event == "code" {
logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil)
qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{
Level: qrterminal.L,
Writer: os.Stdout,
HalfBlocks: true,
})
} else {
logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event})
}
}
} else {
if err := client.Connect(); err != nil {
_ = container.Close()
return fmt.Errorf("connect: %w", err)
}
}
c.runCtx, c.runCancel = context.WithCancel(ctx)
c.SetRunning(true)
logger.InfoC("whatsapp", "WhatsApp native channel connected")
return nil
}
func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error {
logger.InfoC("whatsapp", "Stopping WhatsApp native channel")
if c.runCancel != nil {
c.runCancel()
}
c.mu.Lock()
client := c.client
container := c.container
c.client = nil
c.container = nil
c.mu.Unlock()
if client != nil {
client.Disconnect()
}
if container != nil {
_ = container.Close()
}
c.SetRunning(false)
return nil
}
func (c *WhatsAppNativeChannel) eventHandler(evt any) {
switch evt.(type) {
case *events.Message:
c.handleIncoming(evt.(*events.Message))
case *events.Disconnected:
logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil)
c.reconnectMu.Lock()
if c.reconnecting {
c.reconnectMu.Unlock()
return
}
c.reconnecting = true
c.reconnectMu.Unlock()
go c.reconnectWithBackoff()
}
}
func (c *WhatsAppNativeChannel) reconnectWithBackoff() {
defer func() {
c.reconnectMu.Lock()
c.reconnecting = false
c.reconnectMu.Unlock()
}()
backoff := reconnectInitial
for {
select {
case <-c.runCtx.Done():
return
default:
}
c.mu.Lock()
client := c.client
c.mu.Unlock()
if client == nil {
return
}
logger.InfoCF("whatsapp", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()})
err := client.Connect()
if err == nil {
logger.InfoC("whatsapp", "WhatsApp reconnected")
return
}
logger.WarnCF("whatsapp", "WhatsApp reconnect failed", map[string]any{"error": err.Error()})
select {
case <-c.runCtx.Done():
return
case <-time.After(backoff):
if backoff < reconnectMax {
next := time.Duration(float64(backoff) * reconnectMultiplier)
if next > reconnectMax {
next = reconnectMax
}
backoff = next
}
}
}
}
func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
if evt.Message == nil {
return
}
senderID := evt.Info.Sender.String()
chatID := evt.Info.Chat.String()
content := evt.Message.GetConversation()
if content == "" && evt.Message.ExtendedTextMessage != nil {
content = evt.Message.ExtendedTextMessage.GetText()
}
content = utils.SanitizeMessageContent(content)
if content == "" {
return
}
var mediaPaths []string
metadata := make(map[string]string)
metadata["message_id"] = evt.Info.ID
if evt.Info.PushName != "" {
metadata["user_name"] = evt.Info.PushName
}
if evt.Info.Chat.Server == types.GroupServer {
metadata["peer_kind"] = "group"
metadata["peer_id"] = chatID
} else {
metadata["peer_kind"] = "direct"
metadata["peer_id"] = senderID
}
peerKind := "direct"
if evt.Info.Chat.Server == types.GroupServer {
peerKind = "group"
}
peer := bus.Peer{Kind: peerKind, ID: chatID}
messageID := evt.Info.ID
sender := bus.SenderInfo{
Platform: "whatsapp",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("whatsapp", senderID),
DisplayName: evt.Info.PushName,
}
if !c.IsAllowedSender(sender) {
return
}
logger.DebugCF(
"whatsapp",
"WhatsApp message received",
map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)},
)
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
}
func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
c.mu.Lock()
client := c.client
c.mu.Unlock()
if client == nil || !client.IsConnected() {
return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
to, err := parseJID(msg.ChatID)
if err != nil {
return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
}
waMsg := &waE2E.Message{
Conversation: proto.String(msg.Content),
}
if _, err = client.SendMessage(ctx, to, waMsg); err != nil {
return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
}
return nil
}
// parseJID converts a chat ID (phone number or JID string) to types.JID.
func parseJID(s string) (types.JID, error) {
s = strings.TrimSpace(s)
if s == "" {
return types.JID{}, fmt.Errorf("empty chat id")
}
if strings.Contains(s, "@") {
return types.ParseJID(s)
}
return types.NewJID(s, types.DefaultUserServer), nil
}

View file

@ -0,0 +1,21 @@
//go:build !whatsapp_native
package whatsapp
import (
"fmt"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
// NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native.
// Build with: go build -tags whatsapp_native ./cmd/...
func NewWhatsAppNativeChannel(
cfg config.WhatsAppConfig,
bus *bus.MessageBus,
storePath string,
) (channels.Channel, error) {
return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native")
}

View file

@ -172,7 +172,7 @@ type AgentDefaults struct {
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
@ -203,12 +203,33 @@ type ChannelsConfig struct {
OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"`
Pico PicoConfig `json:"pico"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
type GroupTriggerConfig struct {
MentionOnly bool `json:"mention_only,omitempty"`
Prefixes []string `json:"prefixes,omitempty"`
}
// TypingConfig controls typing indicator behavior (Phase 10).
type TypingConfig struct {
Enabled bool `json:"enabled,omitempty"`
}
// PlaceholderConfig controls placeholder message behavior (Phase 10).
type PlaceholderConfig struct {
Enabled bool `json:"enabled,omitempty"`
Text string `json:"text,omitempty"`
}
type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
}
type TelegramConfig struct {
@ -216,6 +237,10 @@ type TelegramConfig struct {
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
}
type FeishuConfig struct {
@ -225,6 +250,8 @@ type FeishuConfig struct {
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"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
}
type DiscordConfig struct {
@ -232,6 +259,10 @@ type DiscordConfig struct {
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
}
type MaixCamConfig struct {
@ -239,6 +270,7 @@ type MaixCamConfig struct {
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
}
type QQConfig struct {
@ -246,6 +278,8 @@ type QQConfig struct {
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
}
type DingTalkConfig struct {
@ -253,6 +287,8 @@ type DingTalkConfig struct {
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
}
type SlackConfig struct {
@ -260,6 +296,10 @@ type SlackConfig struct {
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
}
type LINEConfig struct {
@ -270,6 +310,10 @@ type LINEConfig struct {
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
}
type OneBotConfig struct {
@ -279,6 +323,10 @@ type OneBotConfig struct {
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
}
type WeComConfig struct {
@ -291,6 +339,8 @@ type WeComConfig struct {
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
}
type WeComAppConfig struct {
@ -305,6 +355,21 @@ type WeComAppConfig struct {
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
}
type PicoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
AllowTokenQuery bool `json:"allow_token_query,omitempty"`
AllowOrigins []string `json:"allow_origins,omitempty"`
PingInterval int `json:"ping_interval,omitempty"`
ReadTimeout int `json:"read_timeout,omitempty"`
WriteTimeout int `json:"write_timeout,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
type HeartbeatConfig struct {
@ -470,11 +535,18 @@ type ExecConfig struct {
CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
}
type MediaCleanupConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"`
MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"`
Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"`
}
type ToolsConfig struct {
Web WebToolsConfig `json:"web"`
Cron CronToolsConfig `json:"cron"`
Exec ExecConfig `json:"exec"`
Skills SkillsToolsConfig `json:"skills"`
MediaCleanup MediaCleanupConfig `json:"media_cleanup"`
}
type SkillsToolsConfig struct {
@ -537,6 +609,9 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
// Auto-migrate: if only legacy providers config exists, convert to model_list
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
cfg.ModelList = ConvertProvidersToModelList(cfg)
@ -550,6 +625,18 @@ func LoadConfig(path string) (*Config, error) {
return cfg, nil
}
func (c *Config) migrateChannelConfigs() {
// Discord: mention_only -> group_trigger.mention_only
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
c.Channels.Discord.GroupTrigger.MentionOnly = true
}
// OneBot: group_trigger_prefix -> group_trigger.prefixes
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
}
}
func SaveConfig(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {

View file

@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@ -210,8 +211,8 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
func TestDefaultConfig_Model(t *testing.T) {
cfg := DefaultConfig()
if cfg.Agents.Defaults.Model == "" {
t.Error("Model should not be empty")
if cfg.Agents.Defaults.Model != "" {
t.Error("Model should be empty")
}
}
@ -324,6 +325,25 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
}
}
func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "config.json")
cfg := DefaultConfig()
if err := SaveConfig(path, cfg); err != nil {
t.Fatalf("SaveConfig failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if !strings.Contains(string(data), `"model": ""`) {
t.Fatalf("saved config should include empty legacy model field, got: %s", string(data))
}
}
// TestConfig_Complete verifies all config fields are set
func TestConfig_Complete(t *testing.T) {
cfg := DefaultConfig()
@ -331,8 +351,8 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should not be empty")
}
if cfg.Agents.Defaults.Model == "" {
t.Error("Model should not be empty")
if cfg.Agents.Defaults.Model != "" {
t.Error("Model should be empty")
}
if cfg.Agents.Defaults.Temperature != nil {
t.Error("Temperature should be nil when not provided")

View file

@ -13,10 +13,10 @@ func DefaultConfig() *Config {
Workspace: "~/.picoclaw/workspace",
RestrictToWorkspace: true,
Provider: "",
Model: "glm-4.7",
MaxTokens: 8192,
Model: "",
MaxTokens: 32768,
Temperature: nil, // nil means use provider default
MaxToolIterations: 20,
MaxToolIterations: 50,
},
},
Bindings: []AgentBinding{},
@ -27,12 +27,19 @@ func DefaultConfig() *Config {
WhatsApp: WhatsAppConfig{
Enabled: false,
BridgeURL: "ws://localhost:3001",
UseNative: false,
SessionStorePath: "",
AllowFrom: FlexibleStringSlice{},
},
Telegram: TelegramConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
Typing: TypingConfig{Enabled: true},
Placeholder: PlaceholderConfig{
Enabled: true,
Text: "Thinking... 💭",
},
},
Feishu: FeishuConfig{
Enabled: false,
@ -80,6 +87,7 @@ func DefaultConfig() *Config {
WebhookPort: 18791,
WebhookPath: "/webhook/line",
AllowFrom: FlexibleStringSlice{},
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
},
OneBot: OneBotConfig{
Enabled: false,
@ -113,6 +121,15 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{},
ReplyTimeout: 5,
},
Pico: PicoConfig{
Enabled: false,
Token: "",
PingInterval: 30,
ReadTimeout: 60,
WriteTimeout: 10,
MaxConnections: 100,
AllowFrom: FlexibleStringSlice{},
},
},
Providers: ProvidersConfig{
OpenAI: OpenAIProviderConfig{WebSearch: true},
@ -276,6 +293,11 @@ func DefaultConfig() *Config {
Port: 18790,
},
Tools: ToolsConfig{
MediaCleanup: MediaCleanupConfig{
Enabled: true,
MaxAge: 30,
Interval: 5,
},
Web: WebToolsConfig{
Proxy: "",
Brave: BraveConfig{

View file

@ -4,6 +4,7 @@ import (
"context"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/constants"
@ -127,7 +128,9 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) {
}
msg := ev.FormatMessage()
msgBus.PublishOutbound(bus.OutboundMessage{
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: platform,
ChatID: userID,
Content: msg,

View file

@ -155,6 +155,13 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
})
}
// RegisterOnMux registers /health and /ready handlers onto the given mux.
// This allows the health endpoints to be served by a shared HTTP server.
func (s *Server) RegisterOnMux(mux *http.ServeMux) {
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler)
}
func statusString(ok bool) string {
if ok {
return "ok"

View file

@ -7,6 +7,7 @@
package heartbeat
import (
"context"
"fmt"
"os"
"path/filepath"
@ -308,7 +309,9 @@ func (hs *HeartbeatService) sendResponse(response string) {
return
}
msgBus.PublishOutbound(bus.OutboundMessage{
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: platform,
ChatID: userID,
Content: response,

107
pkg/identity/identity.go Normal file
View file

@ -0,0 +1,107 @@
// Package identity provides unified user identity utilities for PicoClaw.
// It introduces a canonical "platform:id" format and matching logic
// that is backward-compatible with all legacy allow-list formats.
package identity
import (
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
)
// BuildCanonicalID constructs a canonical "platform:id" identifier.
// Both platform and platformID are lowercased and trimmed.
func BuildCanonicalID(platform, platformID string) string {
p := strings.ToLower(strings.TrimSpace(platform))
id := strings.TrimSpace(platformID)
if p == "" || id == "" {
return ""
}
return p + ":" + id
}
// ParseCanonicalID splits a canonical ID ("platform:id") into its parts.
// Returns ok=false if the input does not contain a colon separator.
func ParseCanonicalID(canonical string) (platform, id string, ok bool) {
canonical = strings.TrimSpace(canonical)
idx := strings.Index(canonical, ":")
if idx <= 0 || idx == len(canonical)-1 {
return "", "", false
}
return canonical[:idx], canonical[idx+1:], true
}
// MatchAllowed checks whether the given sender matches a single allow-list entry.
// It is backward-compatible with all legacy formats:
//
// - "123456" → matches sender.PlatformID
// - "@alice" → matches sender.Username
// - "123456|alice" → matches PlatformID or Username
// - "telegram:123456" → exact match on sender.CanonicalID
func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
allowed = strings.TrimSpace(allowed)
if allowed == "" {
return false
}
// Try canonical match first: "platform:id" format
if platform, id, ok := ParseCanonicalID(allowed); ok {
// Only treat as canonical if the platform portion looks like a known platform name
// (not a pure-numeric string, which could be a compound ID)
if !isNumeric(platform) {
candidate := BuildCanonicalID(platform, id)
if candidate != "" && sender.CanonicalID != "" {
return strings.EqualFold(sender.CanonicalID, candidate)
}
// If sender has no canonical ID, try matching platform + platformID
return strings.EqualFold(platform, sender.Platform) &&
sender.PlatformID == id
}
}
// Strip leading "@" for username matching
trimmed := strings.TrimPrefix(allowed, "@")
// Split compound "id|username" format
allowedID := trimmed
allowedUser := ""
if idx := strings.Index(trimmed, "|"); idx > 0 {
allowedID = trimmed[:idx]
allowedUser = trimmed[idx+1:]
}
// Match against PlatformID
if sender.PlatformID != "" && sender.PlatformID == allowedID {
return true
}
// Match against Username
if sender.Username != "" {
if sender.Username == trimmed || sender.Username == allowedUser {
return true
}
}
// Match compound sender format against allowed parts
if allowedUser != "" && sender.PlatformID != "" && sender.PlatformID == allowedID {
return true
}
if allowedUser != "" && sender.Username != "" && sender.Username == allowedUser {
return true
}
return false
}
// isNumeric returns true if s consists entirely of digits.
func isNumeric(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}

View file

@ -0,0 +1,229 @@
package identity
import (
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
)
func TestBuildCanonicalID(t *testing.T) {
tests := []struct {
platform string
platformID string
want string
}{
{"telegram", "123456", "telegram:123456"},
{"Discord", "98765432", "discord:98765432"},
{"SLACK", "U123ABC", "slack:U123ABC"},
{"", "123", ""},
{"telegram", "", ""},
{" telegram ", " 123 ", "telegram:123"},
}
for _, tt := range tests {
got := BuildCanonicalID(tt.platform, tt.platformID)
if got != tt.want {
t.Errorf("BuildCanonicalID(%q, %q) = %q, want %q",
tt.platform, tt.platformID, got, tt.want)
}
}
}
func TestParseCanonicalID(t *testing.T) {
tests := []struct {
input string
wantPlatform string
wantID string
wantOk bool
}{
{"telegram:123456", "telegram", "123456", true},
{"discord:98765432", "discord", "98765432", true},
{"slack:U123ABC", "slack", "U123ABC", true},
{"nocolon", "", "", false},
{"", "", "", false},
{":missing", "", "", false},
{"missing:", "", "", false},
}
for _, tt := range tests {
platform, id, ok := ParseCanonicalID(tt.input)
if ok != tt.wantOk || platform != tt.wantPlatform || id != tt.wantID {
t.Errorf("ParseCanonicalID(%q) = (%q, %q, %v), want (%q, %q, %v)",
tt.input, platform, id, ok,
tt.wantPlatform, tt.wantID, tt.wantOk)
}
}
}
func TestMatchAllowed(t *testing.T) {
telegramSender := bus.SenderInfo{
Platform: "telegram",
PlatformID: "123456",
CanonicalID: "telegram:123456",
Username: "alice",
DisplayName: "Alice Smith",
}
discordSender := bus.SenderInfo{
Platform: "discord",
PlatformID: "98765432",
CanonicalID: "discord:98765432",
Username: "bob",
DisplayName: "bob#1234",
}
noCanonicalSender := bus.SenderInfo{
Platform: "telegram",
PlatformID: "999",
Username: "carol",
}
tests := []struct {
name string
sender bus.SenderInfo
allowed string
want bool
}{
// Pure numeric ID matching
{
name: "numeric ID matches PlatformID",
sender: telegramSender,
allowed: "123456",
want: true,
},
{
name: "numeric ID does not match",
sender: telegramSender,
allowed: "654321",
want: false,
},
// Username matching
{
name: "@username matches Username",
sender: telegramSender,
allowed: "@alice",
want: true,
},
{
name: "@username does not match",
sender: telegramSender,
allowed: "@bob",
want: false,
},
// Compound format "id|username"
{
name: "compound matches by ID",
sender: telegramSender,
allowed: "123456|alice",
want: true,
},
{
name: "compound matches by username",
sender: telegramSender,
allowed: "999|alice",
want: true,
},
{
name: "compound does not match",
sender: telegramSender,
allowed: "654321|bob",
want: false,
},
// Canonical format "platform:id"
{
name: "canonical matches exactly",
sender: telegramSender,
allowed: "telegram:123456",
want: true,
},
{
name: "canonical case-insensitive platform",
sender: telegramSender,
allowed: "Telegram:123456",
want: true,
},
{
name: "canonical wrong platform",
sender: telegramSender,
allowed: "discord:123456",
want: false,
},
{
name: "canonical wrong ID",
sender: telegramSender,
allowed: "telegram:654321",
want: false,
},
// Cross-platform canonical
{
name: "discord canonical match",
sender: discordSender,
allowed: "discord:98765432",
want: true,
},
{
name: "telegram canonical does not match discord sender",
sender: discordSender,
allowed: "telegram:98765432",
want: false,
},
// Sender without canonical ID
{
name: "canonical match falls back to platform+platformID",
sender: noCanonicalSender,
allowed: "telegram:999",
want: true,
},
{
name: "platform mismatch on fallback",
sender: noCanonicalSender,
allowed: "discord:999",
want: false,
},
// Empty allowed string
{
name: "empty allowed never matches",
sender: telegramSender,
allowed: "",
want: false,
},
// Whitespace handling
{
name: "trimmed allowed matches",
sender: telegramSender,
allowed: " 123456 ",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MatchAllowed(tt.sender, tt.allowed)
if got != tt.want {
t.Errorf("MatchAllowed(%+v, %q) = %v, want %v",
tt.sender, tt.allowed, got, tt.want)
}
})
}
}
func TestIsNumeric(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"123456", true},
{"0", true},
{"", false},
{"abc", false},
{"12a34", false},
{"telegram", false},
}
for _, tt := range tests {
got := isNumeric(tt.input)
if got != tt.want {
t.Errorf("isNumeric(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}

271
pkg/media/store.go Normal file
View file

@ -0,0 +1,271 @@
package media
import (
"fmt"
"os"
"sync"
"time"
"github.com/google/uuid"
"github.com/sipeed/picoclaw/pkg/logger"
)
// MediaMeta holds metadata about a stored media file.
type MediaMeta struct {
Filename string
ContentType string
Source string // "telegram", "discord", "tool:image-gen", etc.
}
// MediaStore manages the lifecycle of media files associated with processing scopes.
type MediaStore interface {
// Store registers an existing local file under the given scope.
// Returns a ref identifier (e.g. "media://<id>").
// Store does not move or copy the file; it only records the mapping.
Store(localPath string, meta MediaMeta, scope string) (ref string, err error)
// Resolve returns the local file path for a given ref.
Resolve(ref string) (localPath string, err error)
// ResolveWithMeta returns the local file path and metadata for a given ref.
ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error)
// ReleaseAll deletes all files registered under the given scope
// and removes the mapping entries. File-not-exist errors are ignored.
ReleaseAll(scope string) error
}
// mediaEntry holds the path and metadata for a stored media file.
type mediaEntry struct {
path string
meta MediaMeta
storedAt time.Time
}
// MediaCleanerConfig configures the background TTL cleanup.
type MediaCleanerConfig struct {
Enabled bool
MaxAge time.Duration
Interval time.Duration
}
// FileMediaStore is a pure in-memory implementation of MediaStore.
// Files are expected to already exist on disk (e.g. in /tmp/picoclaw_media/).
type FileMediaStore struct {
mu sync.RWMutex
refs map[string]mediaEntry
scopeToRefs map[string]map[string]struct{}
refToScope map[string]string
cleanerCfg MediaCleanerConfig
stop chan struct{}
startOnce sync.Once
stopOnce sync.Once
nowFunc func() time.Time // for testing
}
// NewFileMediaStore creates a new FileMediaStore without background cleanup.
func NewFileMediaStore() *FileMediaStore {
return &FileMediaStore{
refs: make(map[string]mediaEntry),
scopeToRefs: make(map[string]map[string]struct{}),
refToScope: make(map[string]string),
nowFunc: time.Now,
}
}
// NewFileMediaStoreWithCleanup creates a FileMediaStore with TTL-based background cleanup.
func NewFileMediaStoreWithCleanup(cfg MediaCleanerConfig) *FileMediaStore {
return &FileMediaStore{
refs: make(map[string]mediaEntry),
scopeToRefs: make(map[string]map[string]struct{}),
refToScope: make(map[string]string),
cleanerCfg: cfg,
stop: make(chan struct{}),
nowFunc: time.Now,
}
}
// Store registers a local file under the given scope. The file must exist.
func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) (string, error) {
if _, err := os.Stat(localPath); err != nil {
return "", fmt.Errorf("media store: %s: %w", localPath, err)
}
ref := "media://" + uuid.New().String()
s.mu.Lock()
defer s.mu.Unlock()
s.refs[ref] = mediaEntry{path: localPath, meta: meta, storedAt: s.nowFunc()}
if s.scopeToRefs[scope] == nil {
s.scopeToRefs[scope] = make(map[string]struct{})
}
s.scopeToRefs[scope][ref] = struct{}{}
s.refToScope[ref] = scope
return ref, nil
}
// Resolve returns the local path for the given ref.
func (s *FileMediaStore) Resolve(ref string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
entry, ok := s.refs[ref]
if !ok {
return "", fmt.Errorf("media store: unknown ref: %s", ref)
}
return entry.path, nil
}
// ResolveWithMeta returns the local path and metadata for the given ref.
func (s *FileMediaStore) ResolveWithMeta(ref string) (string, MediaMeta, error) {
s.mu.RLock()
defer s.mu.RUnlock()
entry, ok := s.refs[ref]
if !ok {
return "", MediaMeta{}, fmt.Errorf("media store: unknown ref: %s", ref)
}
return entry.path, entry.meta, nil
}
// ReleaseAll removes all files under the given scope and cleans up mappings.
// Phase 1 (under lock): remove entries from maps.
// Phase 2 (no lock): delete files from disk.
func (s *FileMediaStore) ReleaseAll(scope string) error {
// Phase 1: collect paths and remove from maps under lock
var paths []string
s.mu.Lock()
refs, ok := s.scopeToRefs[scope]
if !ok {
s.mu.Unlock()
return nil
}
for ref := range refs {
if entry, exists := s.refs[ref]; exists {
paths = append(paths, entry.path)
}
delete(s.refs, ref)
delete(s.refToScope, ref)
}
delete(s.scopeToRefs, scope)
s.mu.Unlock()
// Phase 2: delete files without holding the lock
for _, p := range paths {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
logger.WarnCF("media", "release: failed to remove file", map[string]any{
"path": p,
"error": err.Error(),
})
}
}
return nil
}
// CleanExpired removes all entries older than MaxAge.
// Phase 1 (under lock): identify expired entries and remove from maps.
// Phase 2 (no lock): delete files from disk to minimize lock contention.
func (s *FileMediaStore) CleanExpired() int {
if s.cleanerCfg.MaxAge <= 0 {
return 0
}
// Phase 1: collect expired entries under lock
type expiredEntry struct {
ref string
path string
}
s.mu.Lock()
cutoff := s.nowFunc().Add(-s.cleanerCfg.MaxAge)
var expired []expiredEntry
for ref, entry := range s.refs {
if entry.storedAt.Before(cutoff) {
expired = append(expired, expiredEntry{ref: ref, path: entry.path})
if scope, ok := s.refToScope[ref]; ok {
if scopeRefs, ok := s.scopeToRefs[scope]; ok {
delete(scopeRefs, ref)
if len(scopeRefs) == 0 {
delete(s.scopeToRefs, scope)
}
}
}
delete(s.refs, ref)
delete(s.refToScope, ref)
}
}
s.mu.Unlock()
// Phase 2: delete files without holding the lock
for _, e := range expired {
if err := os.Remove(e.path); err != nil && !os.IsNotExist(err) {
logger.WarnCF("media", "cleanup: failed to remove file", map[string]any{
"path": e.path,
"error": err.Error(),
})
}
}
return len(expired)
}
// Start begins the background cleanup goroutine if cleanup is enabled.
// Safe to call multiple times; only the first call starts the goroutine.
func (s *FileMediaStore) Start() {
if !s.cleanerCfg.Enabled || s.stop == nil {
return
}
if s.cleanerCfg.Interval <= 0 || s.cleanerCfg.MaxAge <= 0 {
logger.WarnCF("media", "cleanup: skipped due to invalid config", map[string]any{
"interval": s.cleanerCfg.Interval.String(),
"max_age": s.cleanerCfg.MaxAge.String(),
})
return
}
s.startOnce.Do(func() {
logger.InfoCF("media", "cleanup enabled", map[string]any{
"interval": s.cleanerCfg.Interval.String(),
"max_age": s.cleanerCfg.MaxAge.String(),
})
go func() {
ticker := time.NewTicker(s.cleanerCfg.Interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if n := s.CleanExpired(); n > 0 {
logger.InfoCF("media", "cleanup: removed expired entries", map[string]any{
"count": n,
})
}
case <-s.stop:
return
}
}
}()
})
}
// Stop terminates the background cleanup goroutine.
// Safe to call multiple times; only the first call closes the channel.
func (s *FileMediaStore) Stop() {
if s.stop == nil {
return
}
s.stopOnce.Do(func() {
close(s.stop)
})
}

530
pkg/media/store_test.go Normal file
View file

@ -0,0 +1,530 @@
package media
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func createTempFile(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte("test content"), 0o644); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
return path
}
func TestStoreAndResolve(t *testing.T) {
dir := t.TempDir()
store := NewFileMediaStore()
path := createTempFile(t, dir, "photo.jpg")
ref, err := store.Store(path, MediaMeta{Filename: "photo.jpg", Source: "telegram"}, "scope1")
if err != nil {
t.Fatalf("Store failed: %v", err)
}
if !strings.HasPrefix(ref, "media://") {
t.Errorf("ref should start with media://, got %q", ref)
}
resolved, err := store.Resolve(ref)
if err != nil {
t.Fatalf("Resolve failed: %v", err)
}
if resolved != path {
t.Errorf("Resolve returned %q, want %q", resolved, path)
}
}
func TestReleaseAll(t *testing.T) {
dir := t.TempDir()
store := NewFileMediaStore()
paths := make([]string, 3)
refs := make([]string, 3)
for i := range 3 {
paths[i] = createTempFile(t, dir, strings.Repeat("a", i+1)+".jpg")
var err error
refs[i], err = store.Store(paths[i], MediaMeta{Source: "test"}, "scope1")
if err != nil {
t.Fatalf("Store failed: %v", err)
}
}
if err := store.ReleaseAll("scope1"); err != nil {
t.Fatalf("ReleaseAll failed: %v", err)
}
// Files should be deleted
for _, p := range paths {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("file %q should have been deleted", p)
}
}
// Refs should be unresolvable
for _, ref := range refs {
if _, err := store.Resolve(ref); err == nil {
t.Errorf("Resolve(%q) should fail after ReleaseAll", ref)
}
}
}
func TestMultiScopeIsolation(t *testing.T) {
dir := t.TempDir()
store := NewFileMediaStore()
pathA := createTempFile(t, dir, "fileA.jpg")
pathB := createTempFile(t, dir, "fileB.jpg")
refA, _ := store.Store(pathA, MediaMeta{Source: "test"}, "scopeA")
refB, _ := store.Store(pathB, MediaMeta{Source: "test"}, "scopeB")
// Release only scopeA
if err := store.ReleaseAll("scopeA"); err != nil {
t.Fatalf("ReleaseAll(scopeA) failed: %v", err)
}
// scopeA file should be gone
if _, err := os.Stat(pathA); !os.IsNotExist(err) {
t.Error("file A should have been deleted")
}
if _, err := store.Resolve(refA); err == nil {
t.Error("refA should be unresolvable after release")
}
// scopeB file should still exist
if _, err := os.Stat(pathB); err != nil {
t.Error("file B should still exist")
}
resolved, err := store.Resolve(refB)
if err != nil {
t.Fatalf("refB should still resolve: %v", err)
}
if resolved != pathB {
t.Errorf("resolved %q, want %q", resolved, pathB)
}
}
func TestReleaseAllIdempotent(t *testing.T) {
store := NewFileMediaStore()
// ReleaseAll on non-existent scope should not error
if err := store.ReleaseAll("nonexistent"); err != nil {
t.Fatalf("ReleaseAll on empty scope should not error: %v", err)
}
// Create and release, then release again
dir := t.TempDir()
path := createTempFile(t, dir, "file.jpg")
_, _ = store.Store(path, MediaMeta{Source: "test"}, "scope1")
if err := store.ReleaseAll("scope1"); err != nil {
t.Fatalf("first ReleaseAll failed: %v", err)
}
if err := store.ReleaseAll("scope1"); err != nil {
t.Fatalf("second ReleaseAll should not error: %v", err)
}
}
func TestReleaseAllCleansMappingsIfRefsMissing(t *testing.T) {
dir := t.TempDir()
store := NewFileMediaStore()
path := createTempFile(t, dir, "file.jpg")
ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1")
if err != nil {
t.Fatalf("Store failed: %v", err)
}
// Simulate internal inconsistency: scopeToRefs/refToScope contains ref but refs map doesn't.
store.mu.Lock()
delete(store.refs, ref)
store.mu.Unlock()
if err := store.ReleaseAll("scope1"); err != nil {
t.Fatalf("ReleaseAll failed: %v", err)
}
// ReleaseAll should still clean mappings (even if it can't delete the file without the path).
store.mu.RLock()
defer store.mu.RUnlock()
if _, ok := store.refToScope[ref]; ok {
t.Error("refToScope should not contain ref after ReleaseAll")
}
if _, ok := store.scopeToRefs["scope1"]; ok {
t.Error("scopeToRefs should not contain scope1 after ReleaseAll")
}
}
func TestStoreNonexistentFile(t *testing.T) {
store := NewFileMediaStore()
_, err := store.Store("/nonexistent/path/file.jpg", MediaMeta{Source: "test"}, "scope1")
if err == nil {
t.Error("Store should fail for nonexistent file")
}
// Error message should include the underlying os error, not just "file does not exist"
if !strings.Contains(err.Error(), "no such file or directory") &&
!strings.Contains(err.Error(), "cannot find") {
t.Errorf("Error should contain OS error detail, got: %v", err)
}
}
func TestResolveWithMeta(t *testing.T) {
dir := t.TempDir()
store := NewFileMediaStore()
path := createTempFile(t, dir, "image.png")
meta := MediaMeta{
Filename: "image.png",
ContentType: "image/png",
Source: "telegram",
}
ref, err := store.Store(path, meta, "scope1")
if err != nil {
t.Fatalf("Store failed: %v", err)
}
resolvedPath, resolvedMeta, err := store.ResolveWithMeta(ref)
if err != nil {
t.Fatalf("ResolveWithMeta failed: %v", err)
}
if resolvedPath != path {
t.Errorf("ResolveWithMeta path = %q, want %q", resolvedPath, path)
}
if resolvedMeta.Filename != meta.Filename {
t.Errorf("ResolveWithMeta Filename = %q, want %q", resolvedMeta.Filename, meta.Filename)
}
if resolvedMeta.ContentType != meta.ContentType {
t.Errorf("ResolveWithMeta ContentType = %q, want %q", resolvedMeta.ContentType, meta.ContentType)
}
if resolvedMeta.Source != meta.Source {
t.Errorf("ResolveWithMeta Source = %q, want %q", resolvedMeta.Source, meta.Source)
}
// Unknown ref should fail
_, _, err = store.ResolveWithMeta("media://nonexistent")
if err == nil {
t.Error("ResolveWithMeta should fail for unknown ref")
}
}
func TestConcurrentSafety(t *testing.T) {
dir := t.TempDir()
store := NewFileMediaStore()
const goroutines = 20
const filesPerGoroutine = 5
var wg sync.WaitGroup
wg.Add(goroutines)
for g := range goroutines {
go func(gIdx int) {
defer wg.Done()
scope := strings.Repeat("s", gIdx+1)
for i := range filesPerGoroutine {
path := createTempFile(t, dir, strings.Repeat("f", gIdx*filesPerGoroutine+i+1)+".tmp")
ref, err := store.Store(path, MediaMeta{Source: "test"}, scope)
if err != nil {
t.Errorf("Store failed: %v", err)
return
}
if _, err := store.Resolve(ref); err != nil {
t.Errorf("Resolve failed: %v", err)
}
}
if err := store.ReleaseAll(scope); err != nil {
t.Errorf("ReleaseAll failed: %v", err)
}
}(g)
}
wg.Wait()
}
// --- TTL cleanup tests ---
func newTestStoreWithCleanup(maxAge time.Duration) *FileMediaStore {
s := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
Enabled: true,
MaxAge: maxAge,
Interval: time.Hour, // won't tick in tests
})
return s
}
func TestCleanExpiredRemovesOldEntries(t *testing.T) {
dir := t.TempDir()
now := time.Now()
store := newTestStoreWithCleanup(10 * time.Minute)
store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }
path := createTempFile(t, dir, "old.jpg")
ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1")
if err != nil {
t.Fatalf("Store failed: %v", err)
}
// Advance clock to present
store.nowFunc = func() time.Time { return now }
removed := store.CleanExpired()
if removed != 1 {
t.Errorf("expected 1 removed, got %d", removed)
}
if _, err := store.Resolve(ref); err == nil {
t.Error("expired ref should be unresolvable")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Error("expired file should be deleted")
}
}
func TestCleanExpiredKeepsNonExpired(t *testing.T) {
dir := t.TempDir()
now := time.Now()
store := newTestStoreWithCleanup(10 * time.Minute)
store.nowFunc = func() time.Time { return now }
path := createTempFile(t, dir, "fresh.jpg")
ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1")
if err != nil {
t.Fatalf("Store failed: %v", err)
}
removed := store.CleanExpired()
if removed != 0 {
t.Errorf("expected 0 removed, got %d", removed)
}
if _, err := store.Resolve(ref); err != nil {
t.Errorf("fresh ref should still resolve: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Error("fresh file should still exist")
}
}
func TestCleanExpiredMixedAges(t *testing.T) {
dir := t.TempDir()
now := time.Now()
store := newTestStoreWithCleanup(10 * time.Minute)
// Store old entry
store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }
oldPath := createTempFile(t, dir, "old.jpg")
oldRef, _ := store.Store(oldPath, MediaMeta{Source: "test"}, "scope1")
// Store fresh entry
store.nowFunc = func() time.Time { return now }
freshPath := createTempFile(t, dir, "fresh.jpg")
freshRef, _ := store.Store(freshPath, MediaMeta{Source: "test"}, "scope1")
removed := store.CleanExpired()
if removed != 1 {
t.Errorf("expected 1 removed, got %d", removed)
}
if _, err := store.Resolve(oldRef); err == nil {
t.Error("old ref should be gone")
}
if _, err := store.Resolve(freshRef); err != nil {
t.Errorf("fresh ref should still resolve: %v", err)
}
}
func TestCleanExpiredCleansEmptyScopes(t *testing.T) {
dir := t.TempDir()
now := time.Now()
store := newTestStoreWithCleanup(10 * time.Minute)
// Store old entry as the only one in scope
store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) }
path := createTempFile(t, dir, "only.jpg")
store.Store(path, MediaMeta{Source: "test"}, "lonely_scope")
store.nowFunc = func() time.Time { return now }
store.CleanExpired()
store.mu.RLock()
defer store.mu.RUnlock()
if _, ok := store.scopeToRefs["lonely_scope"]; ok {
t.Error("empty scope should be cleaned up")
}
}
func TestStartStopLifecycle(t *testing.T) {
store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
Enabled: true,
MaxAge: time.Minute,
Interval: 50 * time.Millisecond,
})
// Start and stop should not panic
store.Start()
// Double start should not spawn a second goroutine
store.Start()
time.Sleep(100 * time.Millisecond)
store.Stop()
// Double stop should not panic
store.Stop()
}
func TestCleanExpiredZeroMaxAge(t *testing.T) {
store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
Enabled: true,
MaxAge: 0,
Interval: time.Hour,
})
dir := t.TempDir()
path := createTempFile(t, dir, "file.jpg")
ref, _ := store.Store(path, MediaMeta{Source: "test"}, "scope1")
// Zero MaxAge should be a no-op
removed := store.CleanExpired()
if removed != 0 {
t.Errorf("expected 0 removed with zero MaxAge, got %d", removed)
}
if _, err := store.Resolve(ref); err != nil {
t.Errorf("ref should still resolve: %v", err)
}
}
func TestStartDisabledIsNoop(t *testing.T) {
store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
Enabled: false,
MaxAge: time.Minute,
Interval: time.Minute,
})
// Should not start any goroutine or panic
store.Start()
store.Stop()
}
func TestStartZeroIntervalNoPanic(t *testing.T) {
store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
Enabled: true,
MaxAge: time.Minute,
Interval: 0,
})
// Zero interval should not panic (time.NewTicker panics on <= 0)
store.Start()
store.Stop()
}
func TestStartZeroMaxAgeNoPanic(t *testing.T) {
store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{
Enabled: true,
MaxAge: 0,
Interval: time.Minute,
})
store.Start()
store.Stop()
}
func TestConcurrentCleanupSafety(t *testing.T) {
dir := t.TempDir()
store := newTestStoreWithCleanup(50 * time.Millisecond)
store.nowFunc = time.Now
const workers = 10
const ops = 20
var wg sync.WaitGroup
wg.Add(workers * 4)
// Store workers
for w := range workers {
go func(wIdx int) {
defer wg.Done()
scope := fmt.Sprintf("scope-%d", wIdx)
for i := range ops {
p := createTempFile(t, dir, fmt.Sprintf("w%d-f%d.tmp", wIdx, i))
store.Store(p, MediaMeta{Source: "test"}, scope)
}
}(w)
}
// Resolve workers
for range workers {
go func() {
defer wg.Done()
for range ops {
store.Resolve("media://nonexistent")
}
}()
}
// ReleaseAll workers
for w := range workers {
go func(wIdx int) {
defer wg.Done()
for range ops {
store.ReleaseAll(fmt.Sprintf("scope-%d", wIdx))
}
}(w)
}
// CleanExpired workers
for range workers {
go func() {
defer wg.Done()
for range ops {
store.CleanExpired()
}
}()
}
wg.Wait()
}
func TestRefToScopeConsistency(t *testing.T) {
dir := t.TempDir()
store := NewFileMediaStore()
// Store entries in two scopes
ref1, _ := store.Store(createTempFile(t, dir, "a.jpg"), MediaMeta{Source: "test"}, "s1")
ref2, _ := store.Store(createTempFile(t, dir, "b.jpg"), MediaMeta{Source: "test"}, "s1")
ref3, _ := store.Store(createTempFile(t, dir, "c.jpg"), MediaMeta{Source: "test"}, "s2")
store.mu.RLock()
checkRef := func(ref, expectedScope string) {
t.Helper()
if scope, ok := store.refToScope[ref]; !ok || scope != expectedScope {
t.Errorf("refToScope[%s] = %q, want %q", ref, scope, expectedScope)
}
}
checkRef(ref1, "s1")
checkRef(ref2, "s1")
checkRef(ref3, "s2")
store.mu.RUnlock()
// Release s1 and verify refToScope is cleaned
store.ReleaseAll("s1")
store.mu.RLock()
defer store.mu.RUnlock()
if _, ok := store.refToScope[ref1]; ok {
t.Error("refToScope should not contain ref1 after ReleaseAll")
}
if _, ok := store.refToScope[ref2]; ok {
t.Error("refToScope should not contain ref2 after ReleaseAll")
}
if _, ok := store.refToScope[ref3]; !ok {
t.Error("refToScope should still contain ref3")
}
}

View file

@ -165,6 +165,12 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
if v, ok := getString(cMap, "bridge_url"); ok {
cfg.Channels.WhatsApp.BridgeURL = v
}
if v, ok := getBool(cMap, "use_native"); ok {
cfg.Channels.WhatsApp.UseNative = v
}
if v, ok := getString(cMap, "session_store_path"); ok {
cfg.Channels.WhatsApp.SessionStorePath = v
}
case "feishu":
cfg.Channels.Feishu.Enabled = enabled
cfg.Channels.Feishu.AllowFrom = allowFrom

View file

@ -296,8 +296,8 @@ func TestConvertConfig(t *testing.T) {
if len(warnings) != 0 {
t.Errorf("expected no warnings, got %v", warnings)
}
if cfg.Agents.Defaults.Model != "glm-4.7" {
t.Errorf("default model should be glm-4.7, got %q", cfg.Agents.Defaults.Model)
if cfg.Agents.Defaults.Model != "" {
t.Errorf("default model should be nil, got %q", cfg.Agents.Defaults.Model)
}
})
}

View file

@ -43,11 +43,26 @@ func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {
// ResolveCandidates parses model config into a deduplicated candidate list.
func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
return ResolveCandidatesWithLookup(cfg, defaultProvider, nil)
}
func ResolveCandidatesWithLookup(
cfg ModelConfig,
defaultProvider string,
lookup func(raw string) (resolved string, ok bool),
) []FallbackCandidate {
seen := make(map[string]bool)
var candidates []FallbackCandidate
addCandidate := func(raw string) {
ref := ParseModelRef(raw, defaultProvider)
candidateRaw := strings.TrimSpace(raw)
if lookup != nil {
if resolved, ok := lookup(candidateRaw); ok {
candidateRaw = resolved
}
}
ref := ParseModelRef(candidateRaw, defaultProvider)
if ref == nil {
return
}

View file

@ -453,6 +453,75 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) {
}
}
func TestResolveCandidatesWithLookup_AliasResolvesToNestedModel(t *testing.T) {
cfg := ModelConfig{
Primary: "step-3.5-flash",
Fallbacks: nil,
}
lookup := func(raw string) (string, bool) {
if raw == "step-3.5-flash" {
return "openrouter/stepfun/step-3.5-flash:free", true
}
return "", false
}
candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
if len(candidates) != 1 {
t.Fatalf("candidates = %d, want 1", len(candidates))
}
if candidates[0].Provider != "openrouter" {
t.Fatalf("provider = %q, want openrouter", candidates[0].Provider)
}
if candidates[0].Model != "stepfun/step-3.5-flash:free" {
t.Fatalf("model = %q, want stepfun/step-3.5-flash:free", candidates[0].Model)
}
}
func TestResolveCandidatesWithLookup_DeduplicateAfterLookup(t *testing.T) {
cfg := ModelConfig{
Primary: "step-3.5-flash",
Fallbacks: []string{"openrouter/stepfun/step-3.5-flash:free"},
}
lookup := func(raw string) (string, bool) {
if raw == "step-3.5-flash" {
return "openrouter/stepfun/step-3.5-flash:free", true
}
return "", false
}
candidates := ResolveCandidatesWithLookup(cfg, "", lookup)
if len(candidates) != 1 {
t.Fatalf("candidates = %d, want 1", len(candidates))
}
}
func TestResolveCandidatesWithLookup_AliasWithoutProtocolUsesDefaultProvider(t *testing.T) {
cfg := ModelConfig{
Primary: "glm-5",
Fallbacks: nil,
}
lookup := func(raw string) (string, bool) {
if raw == "glm-5" {
return "glm-5", true
}
return "", false
}
candidates := ResolveCandidatesWithLookup(cfg, "openai", lookup)
if len(candidates) != 1 {
t.Fatalf("candidates = %d, want 1", len(candidates))
}
if candidates[0].Provider != "openai" {
t.Fatalf("provider = %q, want openai", candidates[0].Provider)
}
if candidates[0].Model != "glm-5" {
t.Fatalf("model = %q, want glm-5", candidates[0].Model)
}
}
func TestFallbackExhaustedError_Message(t *testing.T) {
e := &FallbackExhaustedError{
Attempts: []FallbackAttempt{

View file

@ -25,6 +25,7 @@ type (
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
ExtraContent = protocoltypes.ExtraContent
GoogleExtra = protocoltypes.GoogleExtra
ReasoningDetail = protocoltypes.ReasoningDetail
)
type Provider struct {
@ -200,6 +201,8 @@ func parseResponse(body []byte) (*LLMResponse, error) {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
@ -274,6 +277,8 @@ func parseResponse(body []byte) (*LLMResponse, error) {
return &LLMResponse{
Content: choice.Message.Content,
ReasoningContent: choice.Message.ReasoningContent,
Reasoning: choice.Message.Reasoning,
ReasoningDetails: choice.Message.ReasoningDetails,
ToolCalls: toolCalls,
FinishReason: choice.FinishReason,
Usage: apiResponse.Usage,

View file

@ -30,6 +30,15 @@ type LLMResponse struct {
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
FinishReason string `json:"finish_reason"`
Usage *UsageInfo `json:"usage,omitempty"`
Reasoning string `json:"reasoning"`
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
}
type ReasoningDetail struct {
Format string `json:"format"`
Index int `json:"index"`
Type string `json:"type"`
Text string `json:"text"`
}
type UsageInfo struct {

View file

@ -163,6 +163,15 @@ func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID stri
scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID))
candidates[scopedCandidate] = true
}
// If peerID is already in canonical "platform:id" format, also add the
// bare ID part as a candidate for backward compatibility with identity_links
// that use raw IDs (e.g. "123" instead of "telegram:123").
if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
bareID := rawCandidate[idx+1:]
candidates[bareID] = true
}
if len(candidates) == 0 {
return ""
}

View file

@ -115,6 +115,51 @@ func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) {
}
}
func TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) {
// When peerID is already in canonical "platform:id" format,
// it should match identity_links that use the bare ID.
links := map[string][]string{
"john": {"123"},
}
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
if got != "john" {
t.Errorf("resolveLinkedPeerID with canonical peerID = %q, want %q", got, "john")
}
}
func TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) {
// When identity_links contain canonical IDs and peerID is canonical too
links := map[string][]string{
"john": {"telegram:123", "discord:456"},
}
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
if got != "john" {
t.Errorf("resolveLinkedPeerID canonical in links = %q, want %q", got, "john")
}
}
func TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) {
// When peerID is bare "123" and links have "telegram:123",
// the scoped candidate "telegram:123" should match.
links := map[string][]string{
"john": {"telegram:123"},
}
got := resolveLinkedPeerID(links, "telegram", "123")
if got != "john" {
t.Errorf("resolveLinkedPeerID bare peer matches canonical link = %q, want %q", got, "john")
}
}
func TestResolveLinkedPeerID_NoMatch(t *testing.T) {
links := map[string][]string{
"john": {"telegram:123"},
}
got := resolveLinkedPeerID(links, "discord", "999")
if got != "" {
t.Errorf("resolveLinkedPeerID no match = %q, want empty", got)
}
}
func TestParseAgentSessionKey_Valid(t *testing.T) {
parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123")
if parsed == nil {

View file

@ -296,7 +296,9 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, result.ForLLM)
}
t.msgBus.PublishOutbound(bus.OutboundMessage{
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: output,
@ -306,7 +308,9 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
// If deliver=true, send message directly without agent processing
if job.Payload.Deliver {
t.msgBus.PublishOutbound(bus.OutboundMessage{
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: job.Payload.Message,

View file

@ -30,6 +30,10 @@ type ToolResult struct {
// Err is the underlying error (not JSON serialized).
// Used for internal error handling and logging.
Err error `json:"-"`
// Media contains media store refs produced by this tool.
// When non-empty, the agent will publish these as OutboundMediaMessage.
Media []string `json:"media,omitempty"`
}
// NewToolResult creates a basic ToolResult with content for the LLM.
@ -120,6 +124,19 @@ func UserResult(content string) *ToolResult {
}
}
// MediaResult creates a ToolResult with media refs for the user.
// The agent will publish these refs as OutboundMediaMessage.
//
// Example:
//
// result := MediaResult("Image generated successfully", []string{"media://abc123"})
func MediaResult(forLLM string, mediaRefs []string) *ToolResult {
return &ToolResult{
ForLLM: forLLM,
Media: mediaRefs,
}
}
// MarshalJSON implements custom JSON serialization.
// The Err field is excluded from JSON output via the json:"-" tag.
func (tr *ToolResult) MarshalJSON() ([]byte, error) {

View file

@ -218,7 +218,9 @@ After completing the task, provide a clear summary of what was done.`
// 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)
sm.bus.PublishInbound(bus.InboundMessage{
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

View file

@ -1,167 +0,0 @@
package utils
import (
"strings"
)
// SplitMessage splits long messages into chunks, preserving code block integrity.
// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
// but may extend to maxLen when needed.
// Call SplitMessage with the full text content and the maximum allowed length of a single message;
// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.
func SplitMessage(content string, maxLen int) []string {
var messages []string
// Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
codeBlockBuffer := max(maxLen/10, 50)
if codeBlockBuffer > maxLen/2 {
codeBlockBuffer = maxLen / 2
}
for len(content) > 0 {
if len(content) <= maxLen {
messages = append(messages, content)
break
}
// Effective split point: maxLen minus buffer, to leave room for code blocks
effectiveLimit := max(maxLen-codeBlockBuffer, maxLen/2)
// Find natural split point within the effective limit
msgEnd := findLastNewline(content[:effectiveLimit], 200)
if msgEnd <= 0 {
msgEnd = findLastSpace(content[:effectiveLimit], 100)
}
if msgEnd <= 0 {
msgEnd = effectiveLimit
}
// Check if this would end with an incomplete code block
candidate := content[:msgEnd]
unclosedIdx := findLastUnclosedCodeBlock(candidate)
if unclosedIdx >= 0 {
// Message would end with incomplete code block
// Try to extend up to maxLen to include the closing ```
if len(content) > msgEnd {
closingIdx := findNextClosingCodeBlock(content, msgEnd)
if closingIdx > 0 && closingIdx <= maxLen {
// Extend to include the closing ```
msgEnd = closingIdx
} else {
// Code block is too long to fit in one chunk or missing closing fence.
// Try to split inside by injecting closing and reopening fences.
headerEnd := strings.Index(content[unclosedIdx:], "\n")
if headerEnd == -1 {
headerEnd = unclosedIdx + 3
} else {
headerEnd += unclosedIdx
}
header := strings.TrimSpace(content[unclosedIdx:headerEnd])
// If we have a reasonable amount of content after the header, split inside
if msgEnd > headerEnd+20 {
// Find a better split point closer to maxLen
innerLimit := maxLen - 5 // Leave room for "\n```"
betterEnd := findLastNewline(content[:innerLimit], 200)
if betterEnd > headerEnd {
msgEnd = betterEnd
} else {
msgEnd = innerLimit
}
messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
content = strings.TrimSpace(header + "\n" + content[msgEnd:])
continue
}
// Otherwise, try to split before the code block starts
newEnd := findLastNewline(content[:unclosedIdx], 200)
if newEnd <= 0 {
newEnd = findLastSpace(content[:unclosedIdx], 100)
}
if newEnd > 0 {
msgEnd = newEnd
} else {
// If we can't split before, we MUST split inside (last resort)
if unclosedIdx > 20 {
msgEnd = unclosedIdx
} else {
msgEnd = maxLen - 5
messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
content = strings.TrimSpace(header + "\n" + content[msgEnd:])
continue
}
}
}
}
}
if msgEnd <= 0 {
msgEnd = effectiveLimit
}
messages = append(messages, content[:msgEnd])
content = strings.TrimSpace(content[msgEnd:])
}
return messages
}
// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```
// Returns the position of the opening ``` or -1 if all code blocks are complete
func findLastUnclosedCodeBlock(text string) int {
inCodeBlock := false
lastOpenIdx := -1
for i := 0; i < len(text); i++ {
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
// Toggle code block state on each fence
if !inCodeBlock {
// Entering a code block: record this opening fence
lastOpenIdx = i
}
inCodeBlock = !inCodeBlock
i += 2
}
}
if inCodeBlock {
return lastOpenIdx
}
return -1
}
// findNextClosingCodeBlock finds the next closing ``` starting from a position
// Returns the position after the closing ``` or -1 if not found
func findNextClosingCodeBlock(text string, startIdx int) int {
for i := startIdx; i < len(text); i++ {
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
return i + 3
}
}
return -1
}
// findLastNewline finds the last newline character within the last N characters
// Returns the position of the newline or -1 if not found
func findLastNewline(s string, searchWindow int) int {
searchStart := max(len(s)-searchWindow, 0)
for i := len(s) - 1; i >= searchStart; i-- {
if s[i] == '\n' {
return i
}
}
return -1
}
// findLastSpace finds the last space character within the last N characters
// Returns the position of the space or -1 if not found
func findLastSpace(s string, searchWindow int) int {
searchStart := max(len(s)-searchWindow, 0)
for i := len(s) - 1; i >= searchStart; i-- {
if s[i] == ' ' || s[i] == '\t' {
return i
}
}
return -1
}

View file

@ -1,151 +0,0 @@
package utils
import (
"strings"
"testing"
)
func TestSplitMessage(t *testing.T) {
longText := strings.Repeat("a", 2500)
longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars
tests := []struct {
name string
content string
maxLen int
expectChunks int // Check number of chunks
checkContent func(t *testing.T, chunks []string) // Custom validation
}{
{
name: "Empty message",
content: "",
maxLen: 2000,
expectChunks: 0,
},
{
name: "Short message fits in one chunk",
content: "Hello world",
maxLen: 2000,
expectChunks: 1,
},
{
name: "Simple split regular text",
content: longText,
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
if len(chunks[0]) > 2000 {
t.Errorf("Chunk 0 too large: %d", len(chunks[0]))
}
if len(chunks[0])+len(chunks[1]) != len(longText) {
t.Errorf("Total length mismatch. Got %d, want %d", len(chunks[0])+len(chunks[1]), len(longText))
}
},
},
{
name: "Split at newline",
// 1750 chars then newline, then more chars.
// Dynamic buffer: 2000 / 10 = 200.
// Effective limit: 2000 - 200 = 1800.
// Split should happen at newline because it's at 1750 (< 1800).
// Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051.
content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300),
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
if len(chunks[0]) != 1750 {
t.Errorf("Expected chunk 0 to be 1750 length (split at newline), got %d", len(chunks[0]))
}
if chunks[1] != strings.Repeat("b", 300) {
t.Errorf("Chunk 1 content mismatch. Len: %d", len(chunks[1]))
}
},
},
{
name: "Long code block split",
content: "Prefix\n" + longCode,
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
// Check that first chunk ends with closing fence
if !strings.HasSuffix(chunks[0], "\n```") {
t.Error("First chunk should end with injected closing fence")
}
// Check that second chunk starts with execution header
if !strings.HasPrefix(chunks[1], "```go") {
t.Error("Second chunk should start with injected code block header")
}
},
},
{
name: "Preserve Unicode characters",
content: strings.Repeat("\u4e16", 1000), // 3000 bytes
maxLen: 2000,
expectChunks: 2,
checkContent: func(t *testing.T, chunks []string) {
// Just verify we didn't panic and got valid strings.
// Go strings are UTF-8, if we split mid-rune it would be bad,
// but standard slicing might do that.
// Let's assume standard behavior is acceptable or check if it produces invalid rune?
if !strings.Contains(chunks[0], "\u4e16") {
t.Error("Chunk should contain unicode characters")
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SplitMessage(tc.content, tc.maxLen)
if tc.expectChunks == 0 {
if len(got) != 0 {
t.Errorf("Expected 0 chunks, got %d", len(got))
}
return
}
if len(got) != tc.expectChunks {
t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got))
// Log sizes for debugging
for i, c := range got {
t.Logf("Chunk %d length: %d", i, len(c))
}
return // Stop further checks if count assumes specific split
}
if tc.checkContent != nil {
tc.checkContent(t, got)
}
})
}
}
func TestSplitMessage_CodeBlockIntegrity(t *testing.T) {
// Focused test for the core requirement: splitting inside a code block preserves syntax highlighting
// 60 chars total approximately
content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```"
maxLen := 40
chunks := SplitMessage(content, maxLen)
if len(chunks) != 2 {
t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
}
// First chunk must end with "\n```"
if !strings.HasSuffix(chunks[0], "\n```") {
t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0])
}
// Second chunk must start with the header "```go"
if !strings.HasPrefix(chunks[1], "```go") {
t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1])
}
// First chunk should contain meaningful content
if len(chunks[0]) > 40 {
t.Errorf("First chunk exceeded maxLen: length %d", len(chunks[0]))
}
}

View file

@ -1,5 +1,31 @@
package utils
import (
"strings"
"unicode"
)
// SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides,
// zero-width characters), and other non-graphic characters that could confuse an LLM
// or cause display issues in the agent UI.
func SanitizeMessageContent(input string) string {
var sb strings.Builder
// Pre-allocate memory to avoid multiple allocations
sb.Grow(len(input))
for _, r := range input {
// unicode.IsGraphic returns true if the rune is a Unicode graphic character.
// This includes letters, marks, numbers, punctuation, and symbols.
// It excludes control characters (Cc), format characters (Cf),
// surrogates (Cs), and private use (Co).
if unicode.IsGraphic(r) || r == '\n' || r == '\r' || r == '\t' {
sb.WriteRune(r)
}
}
return sb.String()
}
// Truncate returns a truncated version of s with at most maxLen runes.
// Handles multi-byte Unicode characters properly.
// If the string is truncated, "..." is appended to indicate truncation.

View file

@ -104,3 +104,27 @@ func TestTruncate(t *testing.T) {
})
}
}
func TestSanitizeMessageContent(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"empty", "", ""},
{"plain text unchanged", "Hello world", "Hello world"},
{"strip ZWSP", "Hello\u200bworld", "Helloworld"},
{"strip RTL override", "Hi\u202eevil", "Hievil"},
{"strip BOM", "\uFEFFcontent", "content"},
{"strip multiple", "a\u200c\u202ab\u202cc", "abc"},
{"unicode letters preserved", "café \u65e5\u672c\u8a9e", "café \u65e5\u672c\u8a9e"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SanitizeMessageContent(tt.input)
if got != tt.want {
t.Errorf("SanitizeMessageContent(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}