diff --git a/assets/wechat.png b/assets/wechat.png index 9a6130ce0..07a05dd91 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/config/config.example.json b/config/config.example.json index ff2969dcb..814c82503 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -48,6 +48,10 @@ "model": "deepseek/deepseek-chat", "api_key": "sk-your-deepseek-key" }, + { + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" + }, { "model_name": "longcat", "model": "longcat/LongCat-Flash-Thinking", diff --git a/docs/configuration.md b/docs/configuration.md index 61eb24b14..58930cbfa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -563,6 +563,7 @@ For complete documentation, see [`security_configuration.md`](security_configura | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | @@ -710,6 +711,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' +
+LM Studio (local) + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.
+PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server. + +
+
Custom Proxy / LiteLLM diff --git a/docs/providers.md b/docs/providers.md index 43cba55b5..f45aa5f3b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -56,6 +56,7 @@ This design also enables **multi-agent support** with flexible provider selectio | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | @@ -226,6 +227,18 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' } ``` +**LM Studio (local)** + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.
+PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server. + **Custom Proxy/API** ```json diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index d4366b6a8..a405df09c 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -365,6 +365,7 @@ Agent 读取 HEARTBEAT.md | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | @@ -506,6 +507,21 @@ Agent 读取 HEARTBEAT.md
+
+LM Studio(本地) + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。 +PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。 + +
+
自定义代理 / LiteLLM diff --git a/docs/zh/providers.md b/docs/zh/providers.md index d19dcdd89..04b2f7a88 100644 --- a/docs/zh/providers.md +++ b/docs/zh/providers.md @@ -53,6 +53,7 @@ | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | @@ -211,6 +212,18 @@ } ``` +**LM Studio(本地)** + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。 +PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。 + **自定义代理/API** ```json diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 58149f92c..9513d8aca 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -25,23 +25,25 @@ import ( 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 (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) ([]string, error) { + return nil, 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 } type fakeMediaChannel struct { fakeChannel sentMedia []bus.OutboundMediaMessage } -func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { f.sentMedia = append(f.sentMedia, msg) - return nil + return nil, nil } func newStartedTestChannelManager( diff --git a/pkg/channels/README.md b/pkg/channels/README.md index 7f238ece5..c4d12ef59 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -252,28 +252,28 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { **3e. Send method error returns** ```go -// Old code: returns plain error +// Old code: returned only error func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.running { return fmt.Errorf("not running") } // ... if err != nil { return err } } -// New code: must return sentinel errors for Manager to determine retry strategy -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +// New code: return delivered message IDs plus sentinel errors +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning // ← Manager will not retry + return nil, channels.ErrNotRunning // ← Manager will not retry } // ... if err != nil { // Use ClassifySendError to wrap error based on HTTP status code - return channels.ClassifySendError(statusCode, err) + return nil, channels.ClassifySendError(statusCode, err) // Or manually wrap: - // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) - // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) - // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrRateLimit, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrSendFailed, err) } - return nil + return []string{deliveredID}, nil // or return nil, nil if IDs are unavailable } ``` @@ -502,25 +502,25 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { // 1. Check running state if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // 2. Send message to Matrix - err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) + eventID, err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) if err != nil { // 3. Must use error classification wrapping // If you have an HTTP status code: - // return channels.ClassifySendError(statusCode, err) + // return nil, channels.ClassifySendError(statusCode, err) // If it's a network error: - // return channels.ClassifyNetError(err) + // return nil, channels.ClassifyNetError(err) // If manual classification is needed: - return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) } - return nil + return []string{eventID}, nil } // ========== Incoming Message Handling ========== @@ -580,9 +580,9 @@ func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content st // ========== Internal Methods ========== -func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error { +func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) (string, error) { // Actual Matrix SDK call - return nil + return "event-id", nil } ``` @@ -594,16 +594,17 @@ Depending on platform capabilities, your channel can optionally implement the fo ```go // If the platform supports sending images/files/audio/video -func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store: %w", channels.ErrSendFailed) } + var messageIDs []string for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { @@ -620,8 +621,10 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess default: // Upload file to Matrix } + // Append platform IDs here when the API returns them. + // messageIDs = append(messageIDs, uploadedMessageID) } - return nil + return messageIDs, nil } ``` @@ -1270,7 +1273,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool @@ -1279,7 +1282,7 @@ type Channel interface { // ===== Optional ===== type MediaSender interface { - SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) } type TypingCapable interface { diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index 8bc8c8dbc..3edc5cb6b 100644 --- a/pkg/channels/README.zh.md +++ b/pkg/channels/README.zh.md @@ -252,28 +252,28 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { **3e. Send 方法的错误返回** ```go -// 旧代码:返回普通 error +// 旧代码:只返回 error func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.running { return fmt.Errorf("not running") } // ... if err != nil { return err } } -// 新代码:必须返回哨兵错误,供 Manager 判断重试策略 -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +// 新代码:返回投递后的消息 ID,以及供 Manager 判断重试策略的哨兵错误 +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning // ← Manager 不会重试 + return nil, channels.ErrNotRunning // ← Manager 不会重试 } // ... if err != nil { // 使用 ClassifySendError 根据 HTTP 状态码包装错误 - return channels.ClassifySendError(statusCode, err) + return nil, channels.ClassifySendError(statusCode, err) // 或手动包装: - // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) - // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) - // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrRateLimit, err) + // return nil, fmt.Errorf("%w: %v", channels.ErrSendFailed, err) } - return nil + return []string{deliveredID}, nil // 如果拿不到 ID,也可以返回 nil, nil } ``` @@ -502,25 +502,25 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { // 1. 检查运行状态 if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // 2. 发送消息到 Matrix - err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) + eventID, err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) if err != nil { // 3. 必须使用错误分类包装 // 如果你有 HTTP 状态码: - // return channels.ClassifySendError(statusCode, err) + // return nil, channels.ClassifySendError(statusCode, err) // 如果是网络错误: - // return channels.ClassifyNetError(err) + // return nil, channels.ClassifyNetError(err) // 如果需要手动分类: - return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err) } - return nil + return []string{eventID}, nil } // ========== 消息接收处理 ========== @@ -580,9 +580,9 @@ func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content st // ========== 内部方法 ========== -func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error { +func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) (string, error) { // 实际的 Matrix SDK 调用 - return nil + return "event-id", nil } ``` @@ -594,16 +594,17 @@ func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string ```go // 如果平台支持发送图片/文件/音频/视频 -func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store: %w", channels.ErrSendFailed) } + var messageIDs []string for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { @@ -620,8 +621,10 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess default: // 上传文件到 Matrix } + // 如果 API 能返回平台消息 ID,就在这里追加。 + // messageIDs = append(messageIDs, uploadedMessageID) } - return nil + return messageIDs, nil } ``` @@ -1269,7 +1272,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool @@ -1278,7 +1281,7 @@ type Channel interface { // ===== 可选实现 ===== type MediaSender interface { - SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) } type TypingCapable interface { diff --git a/pkg/channels/base.go b/pkg/channels/base.go index f6ea691e7..bd4ced849 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -48,7 +48,7 @@ type Channel interface { Name() string Start(ctx context.Context) error Stop(ctx context.Context) error - Send(ctx context.Context, msg bus.OutboundMessage) error + Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 450dcce54..04ccec8a2 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -104,20 +104,20 @@ 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 { +func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Get session webhook from storage sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID) if !ok { - return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) + return nil, fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) } sessionWebhook, ok := sessionWebhookRaw.(string) if !ok { - return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) + return nil, fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) } logger.DebugCF("dingtalk", "Sending message", map[string]any{ @@ -126,7 +126,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err }) // Use the session webhook to send the reply - return c.SendDirectReply(ctx, sessionWebhook, msg.Content) + return nil, c.SendDirectReply(ctx, sessionWebhook, msg.Content) } // onChatBotMessageReceived implements the IChatBotMessageHandler function signature diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index cc0ef4ffe..b3070a822 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -128,37 +128,41 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { return nil } -func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID := msg.ChatID if channelID == "" { - return fmt.Errorf("channel ID is empty") + return nil, fmt.Errorf("channel ID is empty") } if len([]rune(msg.Content)) == 0 { - return nil + return nil, nil } - return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) + msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) + if err != nil { + return nil, err + } + return []string{msgID}, nil } // SendMedia implements the channels.MediaSender interface. -func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID := msg.ChatID if channelID == "" { - return fmt.Errorf("channel ID is empty") + return nil, fmt.Errorf("channel ID is empty") } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // Collect all files into a single ChannelMessageSendComplex call @@ -202,33 +206,41 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes } if len(files) == 0 { - return nil + return nil, nil } sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() - done := make(chan error, 1) + type mediaResult struct { + id string + err error + } + done := make(chan mediaResult, 1) go func() { - _, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + sentMsg, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ Content: caption, Files: files, }) - done <- err + if err != nil { + done <- mediaResult{err: err} + return + } + done <- mediaResult{id: sentMsg.ID} }() select { - case err := <-done: + case r := <-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) + if r.err != nil { + return nil, fmt.Errorf("discord send media: %w", channels.ErrTemporary) } - return nil + return []string{r.id}, nil case <-sendCtx.Done(): // Close all file readers for _, f := range files { @@ -236,7 +248,7 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes closer.Close() } } - return sendCtx.Err() + return nil, sendCtx.Err() } } @@ -264,18 +276,25 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } -func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error { +func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() - done := make(chan error, 1) + type result struct { + id string + err error + } + done := make(chan result, 1) go func() { - var err error + var ( + msg *discordgo.Message + err error + ) // If we have an ID, we send the message as "Reply" if replyToID != "" { - _, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + msg, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ Content: content, Reference: &discordgo.MessageReference{ MessageID: replyToID, @@ -284,20 +303,21 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, repl }) } else { // Otherwise, we send a normal message - _, err = c.session.ChannelMessageSend(channelID, content) + msg, err = c.session.ChannelMessageSend(channelID, content) } - done <- err + if err != nil { + done <- result{err: fmt.Errorf("discord send: %w", channels.ErrTemporary)} + return + } + done <- result{id: msg.ID} }() select { - case err := <-done: - if err != nil { - return fmt.Errorf("discord send: %w", channels.ErrTemporary) - } - return nil + case r := <-done: + return r.id, r.err case <-sendCtx.Done(): - return sendCtx.Err() + return "", sendCtx.Err() } } diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index f5e3aa224..f3fe2a6cb 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -36,8 +36,8 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } // Send is a stub method to satisfy the Channel interface -func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - return errUnsupported +func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + return nil, errUnsupported } // EditMessage is a stub method to satisfy MessageEditor @@ -56,6 +56,6 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st } // SendMedia is a stub method to satisfy MediaSender -func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { - return errUnsupported +func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + return nil, errUnsupported } diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index d0c351119..b0b231d09 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -131,26 +131,26 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { // Send sends a message using Interactive Card format for markdown rendering. // Falls back to plain text message if card sending fails (e.g., table limit exceeded). -func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } if msg.ChatID == "" { - return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } // Build interactive card with markdown content cardContent, err := buildMarkdownCard(msg.Content) if err != nil { // If card build fails, fall back to plain text - return c.sendText(ctx, msg.ChatID, msg.Content) + return nil, c.sendText(ctx, msg.ChatID, msg.Content) } // First attempt: try sending as interactive card err = c.sendCard(ctx, msg.ChatID, cardContent) if err == nil { - return nil + return nil, nil } // Check if error is due to card table limit (error code 11310) @@ -167,14 +167,14 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error // Second attempt: fall back to plain text message textErr := c.sendText(ctx, msg.ChatID, msg.Content) if textErr == nil { - return nil + return nil, nil } // If text also fails, return the text error - return textErr + return nil, textErr } // For other errors, return the original card error - return err + return nil, err } // EditMessage implements channels.MessageEditor. @@ -310,27 +310,27 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st // SendMedia implements channels.MediaSender. // Uploads images/files via Feishu API then sends as messages. -func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } if msg.ChatID == "" { - return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil { - return err + return nil, err } } - return nil + return nil, nil } // sendMediaPart resolves and sends a single media part. diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go index 3a4f213ca..e8a70923f 100644 --- a/pkg/channels/irc/irc.go +++ b/pkg/channels/irc/irc.go @@ -130,18 +130,18 @@ func (c *IRCChannel) Stop(ctx context.Context) error { } // Send sends a message to an IRC channel or user. -func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } target := msg.ChatID if target == "" { - return fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } if strings.TrimSpace(msg.Content) == "" { - return nil + return nil, nil } // Send each line separately (IRC is line-oriented) @@ -158,7 +158,7 @@ func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "target": target, "lines": len(lines), }) - return nil + return nil, nil } // StartTyping implements channels.TypingCapable using IRCv3 +typing client tag. diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 867ab24ee..e29896389 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -496,9 +496,9 @@ func (c *LINEChannel) resolveChatID(source lineSource) string { // Send sends a message to LINE. It first tries the Reply API (free) // using a cached reply token, then falls back to the Push API. -func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Load and consume quote token for this chat @@ -516,28 +516,28 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "chat_id": msg.ChatID, "quoted": quoteToken != "", }) - return nil + return nil, nil } logger.DebugC("line", "Reply API failed, falling back to Push API") } } // Fall back to Push API - return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) + return nil, 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 { +func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // LINE Messaging API requires publicly accessible URLs for media messages. @@ -549,11 +549,11 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag } if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil { - return err + return nil, err } } - return nil + return nil, nil } // buildTextMessage creates a text message object, optionally with quoteToken. diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index ff9a3ed1a..bbbf2da56 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -240,15 +240,15 @@ func (c *MaixCamChannel) Stop(ctx context.Context) error { return nil } -func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before entering write path select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -257,7 +257,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro if len(c.clients) == 0 { logger.WarnC("maixcam", "No MaixCam devices connected") - return fmt.Errorf("no connected MaixCam devices") + return nil, fmt.Errorf("no connected MaixCam devices") } response := map[string]any{ @@ -269,7 +269,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro data, err := json.Marshal(response) if err != nil { - return fmt.Errorf("failed to marshal response: %w", err) + return nil, fmt.Errorf("failed to marshal response: %w", err) } var sendErr error @@ -285,5 +285,5 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro _ = conn.SetWriteDeadline(time.Time{}) } - return sendErr + return nil, sendErr } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 4e8074189..5fbf35ebf 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -158,8 +158,8 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { } // preSend handles typing stop, reaction undo, and placeholder editing before sending a message. -// Returns true if the message was already delivered (skip Send). -func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { +// Returns the delivered message IDs and true when delivery completed before a normal Send. +func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) ([]string, bool) { key := name + ":" + msg.ChatID // 1. Stop typing @@ -188,7 +188,7 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } } - return true + return nil, true } // 4. Try editing placeholder @@ -196,14 +196,14 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess 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 + return []string{entry.id}, true } // edit failed → fall through to normal Send } } } - return false + return nil, false } // preSendMedia handles typing stop, reaction undo, and placeholder cleanup @@ -699,23 +699,29 @@ func splitByLength(content string, maxLen int) []string { // - 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) { +func (m *Manager) sendWithRetry( + ctx context.Context, + name string, + w *channelWorker, + msg bus.OutboundMessage, +) ([]string, bool) { // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { // ctx canceled, shutting down - return + return nil, false } // Pre-send: stop typing and try to edit placeholder - if m.preSend(ctx, name, msg, w.ch) { - return // placeholder was edited successfully, skip Send + if msgIDs, handled := m.preSend(ctx, name, msg, w.ch); handled { + return msgIDs, true } var lastErr error + var msgIDs []string for attempt := 0; attempt <= maxRetries; attempt++ { - lastErr = w.ch.Send(ctx, msg) + msgIDs, lastErr = w.ch.Send(ctx, msg) if lastErr == nil { - return + return msgIDs, true } // Permanent failures — don't retry @@ -734,7 +740,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork case <-time.After(rateLimitDelay): continue case <-ctx.Done(): - return + return nil, false } } @@ -743,7 +749,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork select { case <-time.After(backoff): case <-ctx.Done(): - return + return nil, false } } @@ -754,6 +760,8 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork "error": lastErr.Error(), "retries": maxRetries, }) + + return nil, false } func dispatchLoop[M any]( @@ -855,7 +863,7 @@ func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWor if !ok { return } - _ = m.sendMediaWithRetry(ctx, name, w, msg) + _, _ = m.sendMediaWithRetry(ctx, name, w, msg) case <-ctx.Done(): return } @@ -863,14 +871,14 @@ func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWor } // sendMediaWithRetry sends a media message through the channel with rate limiting and -// retry logic. It returns nil on success, or the last error after retries, -// including when the channel does not support MediaSender. +// retry logic. It returns the message IDs and nil on success, or nil and the last error +// after retries, including when the channel does not support MediaSender. func (m *Manager) sendMediaWithRetry( ctx context.Context, name string, w *channelWorker, msg bus.OutboundMediaMessage, -) error { +) ([]string, error) { ms, ok := w.ch.(MediaSender) if !ok { err := fmt.Errorf("channel %q does not support media sending", name) @@ -878,22 +886,23 @@ func (m *Manager) sendMediaWithRetry( "channel": name, "error": err.Error(), }) - return err + return nil, err } // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { - return err + return nil, err } // Pre-send: stop typing and clean up any placeholder before sending media. m.preSendMedia(ctx, name, msg, w.ch) var lastErr error + var msgIDs []string for attempt := 0; attempt <= maxRetries; attempt++ { - lastErr = ms.SendMedia(ctx, msg) + msgIDs, lastErr = ms.SendMedia(ctx, msg) if lastErr == nil { - return nil + return msgIDs, nil } // Permanent failures — don't retry @@ -912,7 +921,7 @@ func (m *Manager) sendMediaWithRetry( case <-time.After(rateLimitDelay): continue case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() } } @@ -921,7 +930,7 @@ func (m *Manager) sendMediaWithRetry( select { case <-time.After(backoff): case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() } } @@ -932,7 +941,7 @@ func (m *Manager) sendMediaWithRetry( "error": lastErr.Error(), "retries": maxRetries, }) - return lastErr + return nil, lastErr } // runTTLJanitor periodically scans the typingStops and placeholders maps @@ -1166,7 +1175,8 @@ func (m *Manager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) e return fmt.Errorf("channel %s has no active worker", msg.Channel) } - return m.sendMediaWithRetry(ctx, msg.Channel, w, msg) + _, err := m.sendMediaWithRetry(ctx, msg.Channel, w, msg) + return err } func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { @@ -1196,5 +1206,6 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten // Fallback: direct send (should not happen) channel, _ := m.channels[channelName] - return channel.Send(ctx, msg) + _, err := channel.Send(ctx, msg) + return err } diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index b4fd2ba3d..e76212905 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -25,9 +25,12 @@ type mockChannel struct { lastPlaceholderID string } -func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { m.sentMessages = append(m.sentMessages, msg) - return m.sendFn(ctx, msg) + if m.sendFn == nil { + return nil, nil + } + return nil, m.sendFn(ctx, msg) } func (m *mockChannel) Start(ctx context.Context) error { return nil } @@ -46,16 +49,16 @@ func (m *mockChannel) EditMessage(ctx context.Context, chatID, messageID, conten type mockMediaChannel struct { mockChannel - sendMediaFn func(ctx context.Context, msg bus.OutboundMediaMessage) error + sendMediaFn func(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) sentMediaMessages []bus.OutboundMediaMessage } -func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { m.sentMediaMessages = append(m.sentMediaMessages, msg) if m.sendMediaFn != nil { return m.sendMediaFn(ctx, msg) } - return nil + return nil, nil } type mockDeletingMediaChannel struct { @@ -247,9 +250,9 @@ func TestSendMedia_Success(t *testing.T) { m := newTestManager() var callCount int ch := &mockMediaChannel{ - sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { callCount++ - return nil + return nil, nil }, } w := &channelWorker{ @@ -275,8 +278,8 @@ func TestSendMedia_Success(t *testing.T) { func TestSendMedia_PropagatesFailure(t *testing.T) { m := newTestManager() ch := &mockMediaChannel{ - sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { - return fmt.Errorf("bad upload: %w", ErrSendFailed) + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { + return nil, fmt.Errorf("bad upload: %w", ErrSendFailed) }, } w := &channelWorker{ @@ -330,8 +333,8 @@ func TestSendMedia_DeletesPlaceholderBeforeSending(t *testing.T) { m := newTestManager() ch := &mockDeletingMediaChannel{ mockMediaChannel: mockMediaChannel{ - sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { - return nil + sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) { + return nil, nil }, }, } @@ -628,7 +631,7 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) { m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if !edited { t.Fatal("expected preSend to return true (placeholder edited)") @@ -658,7 +661,7 @@ func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if edited { t.Fatal("expected preSend to return false when edit fails") @@ -734,7 +737,7 @@ func TestPreSend_NoRegisteredState(t *testing.T) { } msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if edited { t.Fatal("expected preSend to return false with no registered state") @@ -764,7 +767,7 @@ func TestPreSend_TypingAndPlaceholder(t *testing.T) { m.RecordPlaceholder("test", "123", "456") msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop to be called") @@ -1025,7 +1028,7 @@ func TestPreSendStillWorksWithWrappedTypes(t *testing.T) { m.RecordPlaceholder("test", "chat1", "ph_id") msg := bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"} - edited := m.preSend(context.Background(), "test", msg, ch) + _, edited := m.preSend(context.Background(), "test", msg, ch) if !stopCalled { t.Fatal("expected typing stop to be called via wrapped type") diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index b0009e482..96db964cf 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -380,26 +380,26 @@ func markdownToHTML(md string) string { return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) } -func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } roomID := id.RoomID(strings.TrimSpace(msg.ChatID)) if roomID == "" { - return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) } content := strings.TrimSpace(msg.Content) if content == "" { - return nil + return nil, nil } - _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) + resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { - return fmt.Errorf("matrix send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("matrix send: %w", channels.ErrTemporary) } - return nil + return []string{resp.EventID.String()}, nil } func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { @@ -412,9 +412,9 @@ func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { } // SendMedia implements channels.MediaSender. -func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } sendCtx := ctx if sendCtx == nil { @@ -423,17 +423,18 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess roomID := id.RoomID(strings.TrimSpace(msg.ChatID)) if roomID == "" { - return fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("matrix room ID is empty: %w", channels.ErrSendFailed) } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } + var eventIDs []string for _, part := range msg.Parts { if err := sendCtx.Err(); err != nil { - return err + return nil, err } localPath, meta, err := store.ResolveWithMeta(part.Ref) @@ -498,7 +499,7 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess "type": part.Type, "error": err.Error(), }) - return fmt.Errorf("matrix upload media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("matrix upload media: %w", channels.ErrTemporary) } msgType := matrixOutboundMsgType(part.Type, filename, contentType) @@ -511,17 +512,21 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess uploadResp.ContentURI.CUString(), ) - if _, err := c.client.SendMessageEvent(sendCtx, roomID, event.EventMessage, content); err != nil { + sendResp, err := c.client.SendMessageEvent(sendCtx, roomID, event.EventMessage, content) + if err != nil { logger.ErrorCF("matrix", "Failed to send media message", map[string]any{ "room_id": roomID.String(), "type": msgType, "error": err.Error(), }) - return fmt.Errorf("matrix send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("matrix send media: %w", channels.ErrTemporary) + } + if sendResp != nil { + eventIDs = append(eventIDs, sendResp.EventID.String()) } } - return nil + return eventIDs, nil } // StartTyping implements channels.TypingCapable. diff --git a/pkg/channels/media.go b/pkg/channels/media.go index c645a6180..95905ae00 100644 --- a/pkg/channels/media.go +++ b/pkg/channels/media.go @@ -11,5 +11,5 @@ import ( // 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 + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index e5f8b8fd1..a9b95c20f 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -391,15 +391,15 @@ func (c *OneBotChannel) Stop(ctx context.Context) error { return nil } -func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before entering write path select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -408,12 +408,12 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error c.mu.Unlock() if conn == nil { - return fmt.Errorf("OneBot WebSocket not connected") + return nil, fmt.Errorf("OneBot WebSocket not connected") } action, params, err := c.buildSendRequest(msg) if err != nil { - return err + return nil, err } echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) @@ -426,7 +426,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error data, err := json.Marshal(req) if err != nil { - return fmt.Errorf("failed to marshal OneBot request: %w", err) + return nil, fmt.Errorf("failed to marshal OneBot request: %w", err) } c.writeMu.Lock() @@ -439,21 +439,21 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error logger.ErrorCF("onebot", "Failed to send message", map[string]any{ "error": err.Error(), }) - return fmt.Errorf("onebot send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("onebot send: %w", channels.ErrTemporary) } - return nil + return nil, nil } // SendMedia implements the channels.MediaSender interface. -func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -462,12 +462,12 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess c.mu.Unlock() if conn == nil { - return fmt.Errorf("OneBot WebSocket not connected") + return nil, fmt.Errorf("OneBot WebSocket not connected") } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // Build media segments @@ -508,7 +508,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } if len(segments) == 0 { - return nil + return nil, nil } chatID := msg.ChatID @@ -524,7 +524,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess id, err := strconv.ParseInt(rawID, 10, 64) if err != nil { - return fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed) + return nil, fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed) } echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) @@ -537,7 +537,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess data, err := json.Marshal(req) if err != nil { - return fmt.Errorf("failed to marshal OneBot request: %w", err) + return nil, fmt.Errorf("failed to marshal OneBot request: %w", err) } c.writeMu.Lock() @@ -550,10 +550,10 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess 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, fmt.Errorf("onebot send media: %w", channels.ErrTemporary) } - return nil + return nil, nil } func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment { diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go index 4fdcbbf39..b4bfd09e5 100644 --- a/pkg/channels/pico/client.go +++ b/pkg/channels/pico/client.go @@ -273,22 +273,22 @@ func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { } // Send sends a message to the remote server. -func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } c.mu.Lock() pc := c.conn c.mu.Unlock() if pc == nil || pc.closed.Load() { - return channels.ErrSendFailed + return nil, channels.ErrSendFailed } outMsg := newMessage(TypeMessageSend, map[string]any{ "content": msg.Content, }) outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:") - return pc.writeJSON(outMsg) + return nil, pc.writeJSON(outMsg) } // StartTyping implements channels.TypingCapable. diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go index 7f2719e7d..7c5a62801 100644 --- a/pkg/channels/pico/client_test.go +++ b/pkg/channels/pico/client_test.go @@ -46,7 +46,7 @@ func TestSend_NotRunning(t *testing.T) { if err != nil { t.Fatal(err) } - err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"}) + _, err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"}) if !errors.Is(err, channels.ErrNotRunning) { t.Fatalf("expected ErrNotRunning, got %v", err) } @@ -124,7 +124,7 @@ func TestClientChannel_ConnectAndSend(t *testing.T) { defer ch.Stop(ctx) // Send a message - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-1", Content: "hello", }) @@ -179,7 +179,7 @@ func TestClientChannel_ReceivesServerMessage(t *testing.T) { defer ch.Stop(ctx) // Send a message; the echo server replies with message.create - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-echo", Content: "ping", }) @@ -252,7 +252,7 @@ func TestSend_ClosedConnection(t *testing.T) { ch.conn.close() ch.mu.Unlock() - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-close", Content: "should fail", }) diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 0e2bea67c..0a7bf15a4 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -234,16 +234,16 @@ func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Send implements Channel — sends a message to the appropriate WebSocket connection. -func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } outMsg := newMessage(TypeMessageCreate, map[string]any{ "content": msg.Content, }) - return c.broadcastToSession(msg.ChatID, outMsg) + return nil, c.broadcastToSession(msg.ChatID, outMsg) } // EditMessage implements channels.MessageEditor. diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index dfea85ba3..3a8cf9652 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -200,9 +200,9 @@ func (c *QQChannel) getChatKind(chatID string) string { return "group" } -func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) @@ -236,11 +236,14 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { } // Route to group or C2C. - var err error + var ( + sentMsg *dto.Message + err error + ) if chatKind == "group" { - _, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) + sentMsg, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) } else { - _, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) + sentMsg, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) } if err != nil { @@ -249,10 +252,13 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "chat_kind": chatKind, "error": err.Error(), }) - return fmt.Errorf("qq send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send: %w", channels.ErrTemporary) } - return nil + if sentMsg == nil { + return nil, nil + } + return []string{sentMsg.ID}, nil } // StartTyping implements channels.TypingCapable. @@ -319,13 +325,14 @@ func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), err // QQ group/C2C media sending is a two-step flow: // 1. Upload media to /files using a remote URL or base64-encoded local bytes. // 2. Send a msg_type=7 message using the returned file_info. -func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) + var messageIDs []string for _, part := range msg.Parts { fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part) if err != nil { @@ -335,22 +342,26 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) "error": err.Error(), }) if errors.Is(err, channels.ErrSendFailed) { - return err + return nil, err } - return fmt.Errorf("qq send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary) } - if err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo); err != nil { + sentMsg, err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo) + if err != nil { logger.ErrorCF("qq", "Failed to send media", map[string]any{ "type": part.Type, "chat_id": msg.ChatID, "error": err.Error(), }) - return fmt.Errorf("qq send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary) + } + if sentMsg != nil && sentMsg.ID != "" { + messageIDs = append(messageIDs, sentMsg.ID) } } - return nil + return messageIDs, nil } type qqMediaUpload struct { @@ -517,7 +528,7 @@ func (c *QQChannel) sendUploadedMedia( chatKind, chatID string, part bus.MediaPart, fileInfo []byte, -) error { +) (*dto.Message, error) { msg := &dto.MessageToCreate{ Content: part.Caption, MsgType: dto.RichMediaMsg, @@ -532,11 +543,11 @@ func (c *QQChannel) sendUploadedMedia( } if chatKind == "group" { - _, err := c.api.PostGroupMessage(ctx, chatID, msg) - return err + sentMsg, err := c.api.PostGroupMessage(ctx, chatID, msg) + return sentMsg, err } - _, err := c.api.PostC2CMessage(ctx, chatID, msg) - return err + sentMsg, err := c.api.PostC2CMessage(ctx, chatID, msg) + return sentMsg, err } func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) { diff --git a/pkg/channels/qq/qq_test.go b/pkg/channels/qq/qq_test.go index 7ed736827..83a912cd7 100644 --- a/pkg/channels/qq/qq_test.go +++ b/pkg/channels/qq/qq_test.go @@ -209,7 +209,7 @@ func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) { ch.lastMsgID.Store("group-1", "msg-1") ch.msgSeqCounters.Store("group-1", new(atomic.Uint64)) - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "image", @@ -303,7 +303,7 @@ func assertAudioWAVUploadType(t *testing.T, duration time.Duration, wantFileType ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -337,7 +337,7 @@ func TestSendMedia_RemoteAudioFallsBackToFileUpload(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("user-1", "direct") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -383,7 +383,7 @@ func TestSendMedia_LocalAudioWithUnknownDurationFallsBackToFileUpload(t *testing ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -417,7 +417,7 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("user-1", "direct") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "file", @@ -490,7 +490,7 @@ func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) { ch.SetMediaStore(store) ch.chatType.Store("user-1", "direct") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "file", @@ -528,7 +528,7 @@ func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("group-1", "group") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "image", @@ -578,7 +578,7 @@ func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testin ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "file", diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index acd857a06..1e4a4fef5 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -108,14 +108,14 @@ func (c *SlackChannel) Stop(ctx context.Context) error { return nil } -func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID, threadTS := parseSlackChatID(msg.ChatID) if channelID == "" { - return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + return nil, fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } opts := []slack.MsgOption{ @@ -130,9 +130,9 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error opts = append(opts, slack.MsgOptionTS(threadTS)) } - _, _, err := c.api.PostMessageContext(ctx, channelID, opts...) + _, ts, err := c.api.PostMessageContext(ctx, channelID, opts...) if err != nil { - return fmt.Errorf("slack send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("slack send: %w", channels.ErrTemporary) } if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok { @@ -148,23 +148,23 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error "thread_ts": threadTS, }) - return nil + return []string{ts}, nil } // SendMedia implements the channels.MediaSender interface. -func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID, _ := parseSlackChatID(msg.ChatID) if channelID == "" { - return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + return nil, 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) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { @@ -198,11 +198,13 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa "filename": filename, "error": err.Error(), }) - return fmt.Errorf("slack send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("slack send media: %w", channels.ErrTemporary) } } - return nil + // UploadFileV2 does not expose the posted message timestamp in its + // response; returning nil avoids conflating file IDs with message IDs. + return nil, nil } // ReactToMessage implements channels.ReactionCapable. diff --git a/pkg/channels/telegram/parser_markdown_to_html.go b/pkg/channels/telegram/parser_markdown_to_html.go index bdaa51807..95dc3e9d6 100644 --- a/pkg/channels/telegram/parser_markdown_to_html.go +++ b/pkg/channels/telegram/parser_markdown_to_html.go @@ -16,14 +16,15 @@ func markdownToTelegramHTML(text string) string { inlineCodes := extractInlineCodes(text) text = inlineCodes.text + links := extractLinks(text) + text = links.text + text = reHeading.ReplaceAllString(text, "$1") text = reBlockquote.ReplaceAllString(text, "$1") text = escapeHTML(text) - text = reLink.ReplaceAllString(text, `$1`) - text = reBoldStar.ReplaceAllString(text, "$1") text = reBoldUnder.ReplaceAllString(text, "$1") @@ -40,6 +41,12 @@ func markdownToTelegramHTML(text string) string { text = reListItem.ReplaceAllString(text, "• ") + for i, lnk := range links.links { + label := escapeHTML(lnk[0]) + url := lnk[1] + text = strings.ReplaceAll(text, fmt.Sprintf("\x00LK%d\x00", i), fmt.Sprintf(`%s`, url, label)) + } + for i, code := range inlineCodes.codes { escaped := escapeHTML(code) text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) @@ -57,6 +64,29 @@ func markdownToTelegramHTML(text string) string { return text } +type linkMatch struct { + text string + links [][2]string // [label, url] +} + +func extractLinks(text string) linkMatch { + matches := reLink.FindAllStringSubmatch(text, -1) + + extracted := make([][2]string, 0, len(matches)) + for _, match := range matches { + extracted = append(extracted, [2]string{match[1], match[2]}) + } + + i := 0 + text = reLink.ReplaceAllStringFunc(text, func(m string) string { + placeholder := fmt.Sprintf("\x00LK%d\x00", i) + i++ + return placeholder + }) + + return linkMatch{text: text, links: extracted} +} + type codeBlockMatch struct { text string codes []string diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go new file mode 100644 index 000000000..7754ee076 --- /dev/null +++ b/pkg/channels/telegram/parser_markdown_to_html_test.go @@ -0,0 +1,66 @@ +package telegram + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_markdownToTelegramHTML(t *testing.T) { + cases := []struct { + name string + input string + expected string + }{ + { + name: "plain text", + input: "hello world", + expected: "hello world", + }, + { + name: "bold", + input: "**bold text**", + expected: "bold text", + }, + { + name: "italic", + input: "_italic text_", + expected: "italic text", + }, + { + name: "link without underscores in URL", + input: "[click here](https://example.com/path)", + expected: `click here`, + }, + { + name: "link with underscores in URL is not corrupted by italic regex", + // Google Flights URLs use URL-safe base64 with underscores in the tfs param. + // Previously reItalic ran after reLink, matching _text_ inside href and injecting + // tags into the URL, which broke the link in Telegram. + input: "[3 → 10 сентября — от $202](https://www.google.com/travel/flights/search?tfs=CBwQAho_EgoyURL_safe_base64)", + expected: `3 → 10 сентября — от $202`, + }, + { + name: "multiple links all survive", + input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)", + expected: `first and second`, + }, + { + name: "link label with HTML special chars is escaped", + input: "[a & b](https://example.com)", + expected: `a & b`, + }, + { + name: "HTML special chars in plain text are escaped", + input: "a & b < c > d", + expected: "a & b < c > d", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + actual := markdownToTelegramHTML(tc.input) + require.Equal(t, tc.expected, actual) + }) + } +} diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f64a8f79b..831eb43cc 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -168,26 +168,27 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { return nil } -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2 chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { - return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) + return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } if msg.Content == "" { - return nil + return nil, nil } // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), // so msg.Content is guaranteed to be within that limit. We still need to // check if HTML expansion pushes it beyond Telegram's 4096-char API limit. replyToID := msg.ReplyToMessageID + var messageIDs []string queue := []string{msg.Content} for len(queue) > 0 { chunk := queue[0] @@ -206,16 +207,18 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err } if smallerLen <= 0 { - if err := c.sendChunk(ctx, sendChunkParams{ + msgID, err := c.sendChunk(ctx, sendChunkParams{ chatID: chatID, threadID: threadID, content: content, replyToID: replyToID, mdFallback: chunk, useMarkdownV2: useMarkdownV2, - }); err != nil { - return err + }) + if err != nil { + return nil, err } + messageIDs = append(messageIDs, msgID) replyToID = "" continue } @@ -244,21 +247,23 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err continue } - if err := c.sendChunk(ctx, sendChunkParams{ + msgID, err := c.sendChunk(ctx, sendChunkParams{ chatID: chatID, threadID: threadID, content: content, replyToID: replyToID, mdFallback: chunk, useMarkdownV2: useMarkdownV2, - }); err != nil { - return err + }) + if err != nil { + return nil, err } + messageIDs = append(messageIDs, msgID) // Only the first chunk should be a reply; subsequent chunks are normal messages. replyToID = "" } - return nil + return messageIDs, nil } type sendChunkParams struct { @@ -275,7 +280,7 @@ type sendChunkParams struct { func (c *TelegramChannel) sendChunk( ctx context.Context, params sendChunkParams, -) error { +) (string, error) { tgMsg := tu.Message(tu.ID(params.chatID), params.content) tgMsg.MessageThreadID = params.threadID if params.useMarkdownV2 { @@ -292,17 +297,19 @@ func (c *TelegramChannel) sendChunk( } } - if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { + pMsg, err := c.bot.SendMessage(ctx, tgMsg) + if err != nil { logParseFailed(err, params.useMarkdownV2) tgMsg.Text = params.mdFallback tgMsg.ParseMode = "" - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - return fmt.Errorf("telegram send: %w", channels.ErrTemporary) + pMsg, err = c.bot.SendMessage(ctx, tgMsg) + if err != nil { + return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary) } } - return nil + return strconv.Itoa(pMsg.MessageID), nil } // maxTypingDuration limits how long the typing indicator can run. @@ -420,21 +427,22 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s } // SendMedia implements the channels.MediaSender interface. -func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { - return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) + return nil, 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) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } + var messageIDs []string for _, part := range msg.Parts { localPath, err := store.Resolve(part.Ref) if err != nil { @@ -454,6 +462,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe continue } + var tgResult *telego.Message switch part.Type { case "image": params := &telego.SendPhotoParams{ @@ -462,11 +471,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Photo: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendPhoto(ctx, params) + tgResult, err = c.bot.SendPhoto(ctx, params) if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") { if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil { file.Close() - return fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary) + return nil, fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary) } docParams := &telego.SendDocumentParams{ @@ -475,7 +484,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Document: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendDocument(ctx, docParams) + tgResult, err = c.bot.SendDocument(ctx, docParams) } case "audio": // Send OGG files with "voice" in the filename as Telegram voice @@ -488,7 +497,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Voice: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendVoice(ctx, vparams) + tgResult, err = c.bot.SendVoice(ctx, vparams) } else { params := &telego.SendAudioParams{ ChatID: tu.ID(chatID), @@ -496,7 +505,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Audio: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendAudio(ctx, params) + tgResult, err = c.bot.SendAudio(ctx, params) } case "video": params := &telego.SendVideoParams{ @@ -505,7 +514,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Video: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendVideo(ctx, params) + tgResult, err = c.bot.SendVideo(ctx, params) default: // "file" or unknown types params := &telego.SendDocumentParams{ ChatID: tu.ID(chatID), @@ -513,9 +522,12 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Document: telego.InputFile{File: file}, Caption: part.Caption, } - _, err = c.bot.SendDocument(ctx, params) + tgResult, err = c.bot.SendDocument(ctx, params) } + if tgResult != nil { + messageIDs = append(messageIDs, strconv.Itoa(tgResult.MessageID)) + } file.Close() if err != nil { @@ -523,11 +535,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe "type": part.Type, "error": err.Error(), }) - return fmt.Errorf("telegram send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("telegram send media: %w", channels.ErrTemporary) } } - return nil + return messageIDs, nil } func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { @@ -660,6 +672,23 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = cleaned } + if message.ReplyToMessage != nil { + quotedMedia := quotedTelegramMediaRefs( + message.ReplyToMessage, + func(fileID, ext, filename string) string { + localPath := c.downloadFile(ctx, fileID, ext) + if localPath == "" { + return "" + } + return storeMedia(localPath, filename) + }, + ) + if len(quotedMedia) > 0 { + mediaPaths = append(quotedMedia, mediaPaths...) + } + content = c.prependTelegramQuotedReply(content, message.ReplyToMessage) + } + // For forum topics, embed the thread ID as "chatID/threadID" so replies // route to the correct topic and each topic gets its own session. // Only forum groups (IsForum) are handled; regular group reply threads @@ -693,6 +722,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } + if message.ReplyToMessage != nil { + metadata["reply_to_message_id"] = fmt.Sprintf("%d", message.ReplyToMessage.MessageID) + } // Set parent_peer metadata for per-topic agent binding. if message.Chat.IsForum && threadID != 0 { @@ -713,6 +745,122 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes return nil } +func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string { + quoted := strings.TrimSpace(telegramQuotedContent(reply)) + if quoted == "" { + return content + } + + author := telegramQuotedAuthor(reply) + role := c.telegramQuotedRole(reply) + if strings.TrimSpace(content) == "" { + return fmt.Sprintf("[quoted %s message from %s]: %s", role, author, quoted) + } + return fmt.Sprintf("[quoted %s message from %s]: %s\n\n%s", role, author, quoted, content) +} + +func (c *TelegramChannel) telegramQuotedRole(message *telego.Message) string { + if message == nil { + return "unknown" + } + + if message.From != nil { + if !message.From.IsBot { + return "user" + } + if c.isOwnBotUser(message.From) { + return "assistant" + } + return "bot" + } + + if message.SenderChat != nil { + return "chat" + } + + return "unknown" +} + +func (c *TelegramChannel) isOwnBotUser(user *telego.User) bool { + if c == nil || c.bot == nil || user == nil || !user.IsBot { + return false + } + + if botID := c.bot.ID(); botID != 0 && user.ID == botID { + return true + } + + botUsername := strings.TrimPrefix(strings.TrimSpace(c.bot.Username()), "@") + if botUsername == "" { + return false + } + return strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(user.Username), "@"), botUsername) +} + +func telegramQuotedAuthor(message *telego.Message) string { + if message == nil || message.From == nil { + return "unknown" + } + if username := strings.TrimSpace(message.From.Username); username != "" { + return username + } + if firstName := strings.TrimSpace(message.From.FirstName); firstName != "" { + return firstName + } + return "unknown" +} + +func telegramQuotedContent(message *telego.Message) string { + if message == nil { + return "" + } + + var parts []string + if text := strings.TrimSpace(message.Text); text != "" { + parts = append(parts, text) + } + if caption := strings.TrimSpace(message.Caption); caption != "" { + parts = append(parts, caption) + } + switch { + case len(message.Photo) > 0: + parts = append(parts, "[image: photo]") + } + switch { + case message.Voice != nil: + parts = append(parts, "[voice]") + case message.Audio != nil: + parts = append(parts, "[audio]") + } + if message.Document != nil { + parts = append(parts, "[file]") + } + + return strings.Join(parts, "\n") +} + +func quotedTelegramMediaRefs( + message *telego.Message, + resolve func(fileID, ext, filename string) string, +) []string { + if message == nil || resolve == nil { + return nil + } + + var refs []string + if message.Voice != nil { + if ref := resolve(message.Voice.FileID, ".ogg", "voice.ogg"); ref != "" { + refs = append(refs, ref) + } + } + if message.Audio != nil { + if ref := resolve(message.Audio.FileID, ".mp3", "audio.mp3"); ref != "" { + refs = append(refs, ref) + } + } + return refs +} + func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index fd189d9a7..4f7a2600b 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "testing" @@ -104,6 +105,13 @@ func successResponse(t *testing.T) *ta.Response { return &ta.Response{Ok: true, Result: b} } +func successUserResponse(t *testing.T, user *telego.User) *ta.Response { + t.Helper() + b, err := json.Marshal(user) + require.NoError(t, err) + return &ta.Response{Ok: true, Result: b} +} + // newTestChannel creates a TelegramChannel with a mocked bot for unit testing. func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel { return newTestChannelWithConstructor(t, caller, &stubConstructor{}) @@ -168,7 +176,7 @@ func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) { ) require.NoError(t, err) - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "12345", Parts: []bus.MediaPart{{ Type: "image", @@ -206,7 +214,7 @@ func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) { ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1") require.NoError(t, err) - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "12345", Parts: []bus.MediaPart{{ Type: "image", @@ -231,7 +239,7 @@ func TestSend_EmptyContent(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "", }) @@ -248,7 +256,7 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello, world!", }) @@ -271,7 +279,7 @@ func TestSend_LongMessage_SingleCall(t *testing.T) { longContent := strings.Repeat("a", 4000) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: longContent, }) @@ -294,7 +302,7 @@ func TestSend_HTMLFallback_PerChunk(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello **world**", }) @@ -312,7 +320,7 @@ func TestSend_HTMLFallback_BothFail(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello", }) @@ -334,7 +342,7 @@ func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) { longContent := strings.Repeat("x", 4001) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: longContent, }) @@ -364,7 +372,7 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) { "HTML expansion must exceed Telegram limit for this test to be meaningful", ) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: markdownContent, }) @@ -399,7 +407,7 @@ func TestSend_HTMLOverflow_WordBoundary(t *testing.T) { // Ensure the test content matches the intended boundary conditions. assert.LessOrEqual(t, len([]rune(content)), 4000, "markdown content must not exceed chunk size for this test") - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "123456", Content: content, }) @@ -435,7 +443,7 @@ func TestSend_NotRunning(t *testing.T) { ch := newTestChannel(t, caller) ch.SetRunning(false) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "12345", Content: "Hello", }) @@ -453,7 +461,7 @@ func TestSend_InvalidChatID(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "not-a-number", Content: "Hello", }) @@ -510,7 +518,7 @@ func TestSend_WithForumThreadID(t *testing.T) { } ch := newTestChannel(t, caller) - err := ch.Send(context.Background(), bus.OutboundMessage{ + _, err := ch.Send(context.Background(), bus.OutboundMessage{ ChatID: "-1001234567890/42", Content: "Hello from topic", }) @@ -642,6 +650,181 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) { assert.Empty(t, inbound.Metadata["parent_peer_id"]) } +func assertHandleMessageQuotedUserReply( + t *testing.T, + chatID int64, + messageID int, + userID int64, + userName string, + userText string, + replyMessageID int, + replyText string, + replyCaption string, + replyAuthorID int64, + replyAuthorName string, + expectedContent string, +) { + t.Helper() + + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: userText, + MessageID: messageID, + Chat: telego.Chat{ + ID: chatID, + Type: "private", + }, + From: &telego.User{ + ID: userID, + FirstName: userName, + }, + ReplyToMessage: &telego.Message{ + MessageID: replyMessageID, + Text: replyText, + Caption: replyCaption, + From: &telego.User{ + ID: replyAuthorID, + FirstName: replyAuthorName, + }, + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + assert.Equal(t, strconv.Itoa(replyMessageID), inbound.Metadata["reply_to_message_id"]) + assert.Equal(t, expectedContent, inbound.Content) +} + +func TestHandleMessage_ReplyToMessage_PrependsQuotedTextAndMetadata(t *testing.T) { + assertHandleMessageQuotedUserReply( + t, + 456, + 21, + 11, + "Alice", + "follow up", + 99, + "old context", + "", + 12, + "Bob", + "[quoted user message from Bob]: old context\n\nfollow up", + ) +} + +func TestHandleMessage_ReplyToMessage_UsesCaptionWhenQuotedTextMissing(t *testing.T) { + assertHandleMessageQuotedUserReply( + t, + 789, + 22, + 13, + "Carol", + "answer this", + 100, + "", + "caption context", + 14, + "Dave", + "[quoted user message from Dave]: caption context\n\nanswer this", + ) +} + +func TestHandleMessage_ReplyToOwnBotMessage_UsesAssistantRole(t *testing.T) { + messageBus := bus.NewMessageBus() + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + if strings.Contains(url, "getMe") { + return successUserResponse(t, &telego.User{ + ID: 42, + IsBot: true, + FirstName: "Pico", + Username: "afjcjsbx_picoclaw_bot", + }), nil + } + t.Fatalf("unexpected API call: %s", url) + return nil, nil + }, + } + ch := newTestChannel(t, caller) + ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil) + ch.ctx = context.Background() + + msg := &telego.Message{ + Text: "ti ricordi questo file?", + MessageID: 23, + Chat: telego.Chat{ + ID: 999, + Type: "private", + }, + From: &telego.User{ + ID: 15, + FirstName: "Eve", + }, + ReplyToMessage: &telego.Message{ + MessageID: 101, + Text: "Fatto! Ho creato il file notizie_2026_03_28.md", + From: &telego.User{ + ID: 42, + IsBot: true, + FirstName: "Pico", + Username: "afjcjsbx_picoclaw_bot", + }, + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + inbound, ok := <-messageBus.InboundChan() + require.True(t, ok) + assert.Equal(t, "101", inbound.Metadata["reply_to_message_id"]) + assert.Equal( + t, + "[quoted assistant message from afjcjsbx_picoclaw_bot]: Fatto! Ho creato il file notizie_2026_03_28.md\n\nti ricordi questo file?", + inbound.Content, + ) +} + +func TestTelegramQuotedContent_IncludesVoiceMarkerAlongsideCaption(t *testing.T) { + msg := &telego.Message{ + Caption: "listen to this", + Voice: &telego.Voice{ + FileID: "voice-file", + }, + } + + assert.Equal(t, "listen to this\n[voice]", telegramQuotedContent(msg)) +} + +func TestQuotedTelegramMediaRefs_ResolvesQuotedAudioInOrder(t *testing.T) { + msg := &telego.Message{ + Voice: &telego.Voice{FileID: "voice-file"}, + Audio: &telego.Audio{FileID: "audio-file"}, + } + + var calls []string + refs := quotedTelegramMediaRefs(msg, func(fileID, ext, filename string) string { + calls = append(calls, fileID+"|"+ext+"|"+filename) + return "ref://" + filename + }) + + assert.Equal( + t, + []string{"voice-file|.ogg|voice.ogg", "audio-file|.mp3|audio.mp3"}, + calls, + ) + assert.Equal(t, []string{"ref://voice.ogg", "ref://audio.mp3"}, refs) +} + func TestHandleMessage_EmptyContent_Ignored(t *testing.T) { messageBus := bus.NewMessageBus() ch := &TelegramChannel{ diff --git a/pkg/channels/wecom/wecom.go b/pkg/channels/wecom/wecom.go index 6096b7db3..9689d5171 100644 --- a/pkg/channels/wecom/wecom.go +++ b/pkg/channels/wecom/wecom.go @@ -184,20 +184,20 @@ func (c *WeComChannel) BeginStream(_ context.Context, chatID string) (channels.S }, nil } -func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } content := strings.TrimSpace(msg.Content) if content == "" { - return nil + return nil, nil } if turn, ok := c.getTurn(msg.ChatID); ok { if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration { if err := c.sendStreamReply(turn, content); err == nil { c.consumeTurn(msg.ChatID, turn) - return nil + return nil, nil } } c.consumeTurn(msg.ChatID, turn) @@ -205,20 +205,20 @@ func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error if route, ok := c.routes.Get(msg.ChatID); ok { if err := c.sendActivePush(route.ChatID, route.ChatType, content); err != nil { - return err + return nil, err } - return nil + return nil, nil } if err := c.sendActivePush(msg.ChatID, 0, content); err != nil { - return err + return nil, err } - return nil + return nil, nil } -func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } route, chatType, hasTurn := c.resolveMediaRoute(msg.ChatID) @@ -231,7 +231,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa if strings.TrimSpace(part.Ref) == "" { if caption := strings.TrimSpace(part.Caption); caption != "" { if err := c.sendActivePush(chatID, chatType, caption); err != nil { - return err + return nil, err } } continue @@ -239,7 +239,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part) if err != nil { - return fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed) + return nil, fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed) } func() { @@ -283,11 +283,11 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa } }() if err != nil { - return err + return nil, err } } - return nil + return nil, nil } func (c *WeComChannel) connectLoop() { diff --git a/pkg/channels/wecom/wecom_test.go b/pkg/channels/wecom/wecom_test.go index c7a4adfc0..b3a87e246 100644 --- a/pkg/channels/wecom/wecom_test.go +++ b/pkg/channels/wecom/wecom_test.go @@ -190,7 +190,7 @@ func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) { return wecomTestAck(nil), nil } - if err := ch.Send(context.Background(), bus.OutboundMessage{ + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ Channel: "wecom", ChatID: "chat-1", Content: "hello", @@ -247,7 +247,7 @@ func TestSend_DoesNotSplitStreamReply(t *testing.T) { } content := strings.Repeat("\u4e2d", 30000) - if err := ch.Send(context.Background(), bus.OutboundMessage{ + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ Channel: "wecom", ChatID: "chat-1", Content: content, @@ -283,7 +283,7 @@ func TestSend_DoesNotSplitActivePush(t *testing.T) { } content := strings.Repeat("a", 30000) - if err := ch.Send(context.Background(), bus.OutboundMessage{ + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ Channel: "wecom", ChatID: "chat-1", Content: content, @@ -346,7 +346,7 @@ func TestSendMedia_SendsActiveImage(t *testing.T) { } } - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ Channel: "wecom", ChatID: "chat-1", Parts: []bus.MediaPart{{ @@ -457,7 +457,7 @@ func TestSendMedia_UsesTurnImageAndFinishesStream(t *testing.T) { } } - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ Channel: "wecom", ChatID: "chat-1", Parts: []bus.MediaPart{{ @@ -553,7 +553,7 @@ func TestSendMedia_SendsActiveFile(t *testing.T) { } } - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ Channel: "wecom", ChatID: "chat-2", Parts: []bus.MediaPart{{ diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go index 4da7f0db9..cf1b45612 100644 --- a/pkg/channels/weixin/media.go +++ b/pkg/channels/weixin/media.go @@ -1097,12 +1097,12 @@ func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(), } // SendMedia implements channels.MediaSender. -func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return basechannels.ErrNotRunning + return nil, basechannels.ErrNotRunning } if err := c.ensureSessionActive(); err != nil { - return err + return nil, err } contextToken := "" @@ -1110,7 +1110,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess contextToken, _ = v.(string) } if contextToken == "" { - return fmt.Errorf( + return nil, fmt.Errorf( "weixin send media: missing context token for chat %s: %w", msg.ChatID, basechannels.ErrSendFailed, @@ -1125,7 +1125,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess "ref": part.Ref, "error": err.Error(), }) - return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) } func() { if cleanup != nil { @@ -1147,11 +1147,11 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess "error": err.Error(), }) if c.remainingPause() > 0 { - return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) } - return fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary) + return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary) } } - return nil + return nil, nil } diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go index 65fabe399..0e9010131 100644 --- a/pkg/channels/weixin/weixin.go +++ b/pkg/channels/weixin/weixin.go @@ -358,16 +358,16 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess } // Send implements channels.Channel by sending a text message to the WeChat user. -func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } if err := c.ensureSessionActive(); err != nil { - return err + return nil, err } if msg.Content == "" { - return nil + return nil, nil } // We need a context_token to send a reply. It should be stored in the conversation metadata. @@ -386,7 +386,7 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{ "to_user_id": toUserID, }) - return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID) + return nil, fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID) } if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil { @@ -395,10 +395,10 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error "error": err.Error(), }) if c.remainingPause() > 0 { - return fmt.Errorf("weixin send: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("weixin send: %w", channels.ErrSendFailed) } - return fmt.Errorf("weixin send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("weixin send: %w", channels.ErrTemporary) } - return nil + return nil, nil } diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index 70b3e02bf..98622fe37 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -104,15 +104,15 @@ func (c *WhatsAppChannel) Stop(ctx context.Context) error { return nil } -func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before acquiring lock select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -120,7 +120,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err defer c.mu.Unlock() if c.conn == nil { - return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } payload := map[string]any{ @@ -131,17 +131,17 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err data, err := json.Marshal(payload) if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) + return nil, 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 { _ = c.conn.SetWriteDeadline(time.Time{}) - return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } _ = c.conn.SetWriteDeadline(time.Time{}) - return nil + return nil, nil } func (c *WhatsAppChannel) listen() { diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 188a7c8fa..d0a74a405 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -396,13 +396,13 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } -func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -411,18 +411,18 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag c.mu.Unlock() if client == nil || !client.IsConnected() { - return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } // Detect unpaired state: the client is connected (to WhatsApp servers) // but has not completed QR-login yet, so sending would fail. if client.Store.ID == nil { - return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) } to, err := parseJID(msg.ChatID) if err != nil { - return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) + return nil, fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) } waMsg := &waE2E.Message{ @@ -430,9 +430,9 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag } if _, err = client.SendMessage(ctx, to, waMsg); err != nil { - return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } - return nil + return nil, nil } // parseJID converts a chat ID (phone number or JID string) to types.JID. diff --git a/pkg/providers/bedrock/provider_bedrock.go b/pkg/providers/bedrock/provider_bedrock.go index 9ca29455f..3798c5fd8 100644 --- a/pkg/providers/bedrock/provider_bedrock.go +++ b/pkg/providers/bedrock/provider_bedrock.go @@ -208,7 +208,10 @@ func (p *Provider) Chat( if err != nil { // Check for SSO token expiration errors and provide actionable guidance if isSSOTokenError(err) { - return nil, fmt.Errorf("bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", err) + return nil, fmt.Errorf( + "bedrock converse: AWS credentials may have expired. If using AWS SSO, run 'aws sso login' to refresh: %w", + err, + ) } return nil, fmt.Errorf("bedrock converse: %w", err) } diff --git a/pkg/providers/bedrock/provider_bedrock_test.go b/pkg/providers/bedrock/provider_bedrock_test.go index 882c2971c..38a5e26da 100644 --- a/pkg/providers/bedrock/provider_bedrock_test.go +++ b/pkg/providers/bedrock/provider_bedrock_test.go @@ -583,13 +583,17 @@ func TestIsSSOTokenError(t *testing.T) { expected: true, }, { - name: "full SSO error message", - err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token"), + name: "full SSO error message", + err: fmt.Errorf( + "get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token", + ), expected: true, }, { - name: "SSO token file missing", - err: fmt.Errorf("get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory"), + name: "SSO token file missing", + err: fmt.Errorf( + "get identity: get credentials: failed to refresh cached credentials, failed to read cached SSO token file, open ~/.aws/sso/cache/abc123.json: no such file or directory", + ), expected: true, }, } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 962e6ae19..e956db209 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -17,6 +17,48 @@ import ( "github.com/sipeed/picoclaw/pkg/providers/bedrock" ) +type protocolMeta struct { + defaultAPIBase string + emptyAPIKeyAllowed bool +} + +var protocolMetaByName = map[string]protocolMeta{ + "openai": {defaultAPIBase: "https://api.openai.com/v1"}, + "openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"}, + "litellm": {defaultAPIBase: "http://localhost:4000/v1"}, + "lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true}, + "novita": {defaultAPIBase: "https://api.novita.ai/openai"}, + "groq": {defaultAPIBase: "https://api.groq.com/openai/v1"}, + "zhipu": {defaultAPIBase: "https://open.bigmodel.cn/api/paas/v4"}, + "gemini": {defaultAPIBase: "https://generativelanguage.googleapis.com/v1beta"}, + "nvidia": {defaultAPIBase: "https://integrate.api.nvidia.com/v1"}, + "ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true}, + "moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"}, + "shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"}, + "deepseek": {defaultAPIBase: "https://api.deepseek.com/v1"}, + "cerebras": {defaultAPIBase: "https://api.cerebras.ai/v1"}, + "vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"}, + "volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"}, + "qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"}, + "qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, + "qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, + "dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, + "qwen-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"}, + "dashscope-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"}, + "coding-plan": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, + "alibaba-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, + "qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"}, + "coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, + "alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"}, + "vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true}, + "mistral": {defaultAPIBase: "https://api.mistral.ai/v1"}, + "avian": {defaultAPIBase: "https://api.avian.io/v1"}, + "minimax": {defaultAPIBase: "https://api.minimaxi.com/v1"}, + "longcat": {defaultAPIBase: "https://api.longcat.chat/openai"}, + "modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"}, + "mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"}, +} + // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. func createClaudeAuthProvider() (LLMProvider, error) { cred, err := getCredential("anthropic") @@ -154,13 +196,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } return provider, modelID, nil - case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", + case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "coding-plan", "alibaba-coding", "qwen-coding", "mimo": // All other OpenAI-compatible HTTP providers - if cfg.APIKey() == "" && cfg.APIBase == "" { + if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase @@ -294,64 +336,30 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } } +func isEmptyAPIKeyAllowed(protocol string) bool { + meta, ok := protocolMetaByName[protocol] + return ok && meta.emptyAPIKeyAllowed +} + +// IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests +// without api_key when using its default local endpoint. +func IsEmptyAPIKeyAllowedForProtocol(protocol string) bool { + protocol = strings.ToLower(strings.TrimSpace(protocol)) + return isEmptyAPIKeyAllowed(protocol) +} + +// DefaultAPIBaseForProtocol returns the configured default API base for a protocol. +// It returns empty string if the protocol has no default base. +func DefaultAPIBaseForProtocol(protocol string) string { + protocol = strings.ToLower(strings.TrimSpace(protocol)) + return getDefaultAPIBase(protocol) +} + // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { - switch protocol { - case "openai": - return "https://api.openai.com/v1" - case "openrouter": - return "https://openrouter.ai/api/v1" - case "litellm": - return "http://localhost:4000/v1" - case "novita": - return "https://api.novita.ai/openai" - case "groq": - return "https://api.groq.com/openai/v1" - case "zhipu": - return "https://open.bigmodel.cn/api/paas/v4" - case "gemini": - return "https://generativelanguage.googleapis.com/v1beta" - case "nvidia": - return "https://integrate.api.nvidia.com/v1" - case "ollama": - return "http://localhost:11434/v1" - case "moonshot": - return "https://api.moonshot.cn/v1" - case "shengsuanyun": - return "https://router.shengsuanyun.com/api/v1" - case "deepseek": - return "https://api.deepseek.com/v1" - case "cerebras": - return "https://api.cerebras.ai/v1" - case "vivgrid": - return "https://api.vivgrid.com/v1" - case "volcengine": - return "https://ark.cn-beijing.volces.com/api/v3" - case "qwen": - return "https://dashscope.aliyuncs.com/compatible-mode/v1" - case "qwen-intl", "qwen-international", "dashscope-intl": - return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" - case "qwen-us", "dashscope-us": - return "https://dashscope-us.aliyuncs.com/compatible-mode/v1" - case "coding-plan", "alibaba-coding", "qwen-coding": - return "https://coding-intl.dashscope.aliyuncs.com/v1" - case "coding-plan-anthropic", "alibaba-coding-anthropic": - return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" - case "vllm": - return "http://localhost:8000/v1" - case "mistral": - return "https://api.mistral.ai/v1" - case "avian": - return "https://api.avian.io/v1" - case "minimax": - return "https://api.minimaxi.com/v1" - case "longcat": - return "https://api.longcat.chat/openai" - case "modelscope": - return "https://api-inference.modelscope.cn/v1" - case "mimo": - return "https://api.xiaomimimo.com/v1" - default: + meta, ok := protocolMetaByName[protocol] + if !ok { return "" } + return meta.defaultAPIBase } diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index f1fe02cc2..588b81650 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -121,6 +121,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { {"vllm", "vllm"}, {"deepseek", "deepseek"}, {"ollama", "ollama"}, + {"lmstudio", "lmstudio"}, {"longcat", "longcat"}, {"modelscope", "modelscope"}, {"mimo", "mimo"}, @@ -153,6 +154,12 @@ func TestGetDefaultAPIBase_LiteLLM(t *testing.T) { } } +func TestGetDefaultAPIBase_LMStudio(t *testing.T) { + if got := getDefaultAPIBase("lmstudio"); got != "http://localhost:1234/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "lmstudio", got, "http://localhost:1234/v1") + } +} + func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-litellm", @@ -173,6 +180,85 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { } } +func TestCreateProviderFromConfig_LocalProviders(t *testing.T) { + tests := []struct { + name string + modelName string + model string + apiKey string + wantModelID string + }{ + { + name: "LMStudio with API key", + modelName: "test-lmstudio", + model: "lmstudio/openai/gpt-oss-20b", + apiKey: "test-key", + wantModelID: "openai/gpt-oss-20b", + }, + { + name: "LMStudio without API key", + modelName: "test-lmstudio", + model: "lmstudio/openai/gpt-oss-20b", + apiKey: "", + wantModelID: "openai/gpt-oss-20b", + }, + { + name: "Ollama with API key", + modelName: "test-ollama", + model: "ollama/llama3.1:8b", + apiKey: "test-key", + wantModelID: "llama3.1:8b", + }, + { + name: "Ollama without API key", + modelName: "test-ollama", + model: "ollama/llama3.1:8b", + apiKey: "", + wantModelID: "llama3.1:8b", + }, + { + name: "VLLM with API key", + modelName: "test-vllm", + model: "vllm/Qwen/Qwen3-8B", + apiKey: "test-key", + wantModelID: "Qwen/Qwen3-8B", + }, + { + name: "VLLM without API key", + modelName: "test-vllm", + model: "vllm/Qwen/Qwen3-8B", + apiKey: "", + wantModelID: "Qwen/Qwen3-8B", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: tt.modelName, + Model: tt.model, + } + if tt.apiKey != "" { + cfg.SetAPIKey(tt.apiKey) + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != tt.wantModelID { + t.Errorf("modelID = %q, want %q", modelID, tt.wantModelID) + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } + }) + } +} + func TestCreateProviderFromConfig_LongCat(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-longcat", diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index e2d1d7d98..472c14257 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -41,9 +41,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi } session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ - Model: model, + Model: model, OnPermissionRequest: copilot.PermissionHandler.ApproveAll, - Hooks: &copilot.SessionHooks{}, + Hooks: &copilot.SessionHooks{}, }) if err != nil { client.Stop() diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 90bc683b8..aa9473731 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -42,6 +42,23 @@ type Option func(*Provider) const defaultRequestTimeout = common.DefaultRequestTimeout +var stripModelPrefixProviders = map[string]struct{}{ + "litellm": {}, + "moonshot": {}, + "nvidia": {}, + "groq": {}, + "ollama": {}, + "deepseek": {}, + "google": {}, + "openrouter": {}, + "zhipu": {}, + "mistral": {}, + "vivgrid": {}, + "minimax": {}, + "novita": {}, + "lmstudio": {}, +} + func WithMaxTokensField(maxTokensField string) Option { return func(p *Provider) { p.maxTokensField = maxTokensField @@ -397,13 +414,11 @@ func normalizeModel(model, apiBase string) string { } prefix := strings.ToLower(before) - switch prefix { - case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", - "openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita": + if _, ok := stripModelPrefixProviders[prefix]; ok { return after - default: - return model } + + return model } func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index ab632ccf3..823b0ff28 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -432,7 +432,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin } } -func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T) { +func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -474,6 +474,11 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T input: "ollama/qwen2.5:14b", wantModel: "qwen2.5:14b", }, + { + name: "strips lmstudio prefix and keeps nested model", + input: "lmstudio/openai/gpt-oss-20b", + wantModel: "openai/gpt-oss-20b", + }, { name: "strips deepseek prefix", input: "deepseek/deepseek-chat", @@ -579,6 +584,9 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) { if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" { t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat") } + if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" { + t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b") + } if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" { t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") } diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index ce3a9ca1e..1e2520920 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -59,6 +59,16 @@ func refreshPicoTokensLocked(configPath string) { gateway.picoToken = cfg.Channels.Pico.Token.String() } +// ensurePicoTokenCachedLocked lazily fills the in-memory pico token cache when +// the launcher has already discovered a running gateway via pidData, but has +// not yet refreshed the token into memory. +func ensurePicoTokenCachedLocked(configPath string) { + if gateway.picoToken != "" { + return + } + refreshPicoTokensLocked(configPath) +} + const ( protocolKey = "Sec-Websocket-Protocol" tokenPrefix = "token." diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index 6190f0c7c..f8e8eadba 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -190,12 +190,20 @@ func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort in func (h *Handler) picoWebUIAddr(r *http.Request) string { wsPort := h.serverPort if wsPort == 0 { - wsPort = 18800 // default web server port + wsPort = 18800 } if fwdHost := forwardedHostFirst(r); fwdHost != "" { return joinClientVisibleHostPort(r, fwdHost, wsPort) } host := requestHostName(r) + // Use clientVisiblePort only when an explicit port is present in headers + // or Host header — do not infer from TLS/scheme, as serverPort takes priority. + if p := forwardedPortFirst(r); p != "" { + return net.JoinHostPort(host, p) + } + if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { + return net.JoinHostPort(host, port) + } return net.JoinHostPort(host, strconv.Itoa(wsPort)) } diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index ca6639f67..0ef027490 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -68,6 +68,7 @@ func resetGatewayTestState(t *testing.T) { originalRestartGracePeriod := gatewayRestartGracePeriod originalRestartForceKillWindow := gatewayRestartForceKillWindow originalRestartPollInterval := gatewayRestartPollInterval + t.Setenv("PICOCLAW_HOME", t.TempDir()) t.Cleanup(func() { gatewayHealthGet = originalHealthGet gatewayRestartGracePeriod = originalRestartGracePeriod diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go index aeef85119..2269147df 100644 --- a/web/backend/api/model_status.go +++ b/web/backend/api/model_status.go @@ -10,6 +10,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" ) const modelProbeTimeout = 800 * time.Millisecond @@ -60,10 +61,14 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool { return true } - switch modelProtocol(m.Model) { + protocol := modelProtocol(m.Model) + + switch protocol { case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": return true - case "ollama", "vllm": + } + + if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { apiBase := strings.TrimSpace(m.APIBase) return apiBase == "" || hasLocalAPIBase(apiBase) } @@ -81,7 +86,7 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool { switch protocol { case "ollama": return probeOllamaModelFunc(apiBase, modelID) - case "vllm": + case "vllm", "lmstudio": return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) case "github-copilot", "copilot": return probeTCPServiceFunc(apiBase) @@ -100,11 +105,12 @@ func modelProbeAPIBase(m *config.ModelConfig) string { return normalizeModelProbeAPIBase(apiBase) } - switch modelProtocol(m.Model) { - case "ollama": - return "http://localhost:11434/v1" - case "vllm": - return "http://localhost:8000/v1" + protocol := modelProtocol(m.Model) + if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) { + return providers.DefaultAPIBaseForProtocol(protocol) + } + + switch protocol { case "github-copilot", "copilot": return "localhost:4321" default: diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go index df942a9e9..bfeadf1fe 100644 --- a/web/backend/api/model_status_test.go +++ b/web/backend/api/model_status_test.go @@ -35,3 +35,53 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured") } } + +func TestRequiresRuntimeProbe_LMStudio(t *testing.T) { + if !requiresRuntimeProbe(&config.ModelConfig{ + Model: "lmstudio/openai/gpt-oss-20b", + }) { + t.Fatal("requiresRuntimeProbe(lmstudio with default base) = false, want true") + } + + if requiresRuntimeProbe(&config.ModelConfig{ + Model: "lmstudio/openai/gpt-oss-20b", + APIBase: "https://api.example.com/v1", + }) { + t.Fatal("requiresRuntimeProbe(lmstudio with remote base) = true, want false") + } +} + +func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) { + got := modelProbeAPIBase(&config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"}) + if got != "http://localhost:1234/v1" { + t.Fatalf("modelProbeAPIBase(lmstudio) = %q, want %q", got, "http://localhost:1234/v1") + } +} + +func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testing.T) { + originalProbe := probeOpenAICompatibleModelFunc + defer func() { probeOpenAICompatibleModelFunc = originalProbe }() + + called := false + probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool { + called = true + if apiBase != "http://localhost:1234/v1" { + t.Fatalf("apiBase = %q, want %q", apiBase, "http://localhost:1234/v1") + } + if modelID != "openai/gpt-oss-20b" { + t.Fatalf("modelID = %q, want %q", modelID, "openai/gpt-oss-20b") + } + if apiKey != "" { + t.Fatalf("apiKey = %q, want empty", apiKey) + } + return true + } + + model := &config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"} + if !probeLocalModelAvailability(model) { + t.Fatal("probeLocalModelAvailability(lmstudio) = false, want true") + } + if !called { + t.Fatal("probeOpenAICompatibleModelFunc was not called for lmstudio") + } +} diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 0e8cd07fc..c8ef47308 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -56,6 +56,7 @@ func (h *Handler) createWsProxy(origProtocol string, token string) *httputil.Rev func (h *Handler) handleWebSocketProxy() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gateway.mu.Lock() + ensurePicoTokenCachedLocked(h.configPath) gatewayAvailable := gateway.pidData != nil gateway.mu.Unlock() diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index beff4d77f..ee5586746 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -377,6 +377,55 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { } } +func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handleWebSocketProxy() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "proxied") + })) + defer server.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server.URL) + cfg.Channels.Pico.Enabled = true + cfg.Channels.Pico.SetToken("cached-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + t.Cleanup(func() { + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + }) + + gateway.pidData = &ppid.PidFileData{} + gateway.picoToken = "" + + req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil) + req.Header.Set(protocolKey, tokenPrefix+"cached-token") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "proxied" { + t.Fatalf("body = %q, want %q", body, "proxied") + } + if gateway.picoToken != "cached-token" { + t.Fatalf("gateway.picoToken = %q, want %q", gateway.picoToken, "cached-token") + } +} + func mustGatewayTestPort(t *testing.T, rawURL string) int { t.Helper()