Merge branch 'sipeed:main' into main

This commit is contained in:
Harmoon 2026-04-01 00:10:53 +08:00 committed by GitHub
commit 552e1e9b15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 2272 additions and 549 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

After

Width:  |  Height:  |  Size: 365 KiB

View file

@ -48,6 +48,10 @@
"model": "deepseek/deepseek-chat", "model": "deepseek/deepseek-chat",
"api_key": "sk-your-deepseek-key" "api_key": "sk-your-deepseek-key"
}, },
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
},
{ {
"model_name": "longcat", "model_name": "longcat",
"model": "longcat/LongCat-Flash-Thinking", "model": "longcat/LongCat-Flash-Thinking",

View file

@ -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) | | **通义千问 (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) | | **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) | | **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) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
@ -710,6 +711,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
</details> </details>
<details>
<summary><b>LM Studio (local)</b></summary>
```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.<br/>
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.
</details>
<details> <details>
<summary><b>Custom Proxy / LiteLLM</b></summary> <summary><b>Custom Proxy / LiteLLM</b></summary>

View file

@ -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) | | **通义千问 (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) | | **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) | | **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) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **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.<br/>
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** **Custom Proxy/API**
```json ```json

View file

@ -365,6 +365,7 @@ Agent 读取 HEARTBEAT.md
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) | | **通义千问 (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) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key | | **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) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
@ -506,6 +507,21 @@ Agent 读取 HEARTBEAT.md
</details> </details>
<details>
<summary><b>LM Studio本地</b></summary>
```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`
</details>
<details> <details>
<summary><b>自定义代理 / LiteLLM</b></summary> <summary><b>自定义代理 / LiteLLM</b></summary>

View file

@ -53,6 +53,7 @@
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | | **通义千问 (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) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | | **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) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **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** **自定义代理/API**
```json ```json

View file

@ -90,14 +90,29 @@ func findSafeBoundary(history []providers.Message, targetIndex int) int {
// including Content, ReasoningContent, ToolCalls arguments, ToolCallID // including Content, ReasoningContent, ToolCalls arguments, ToolCallID
// metadata, and Media items. Uses a heuristic of 2.5 characters per token. // metadata, and Media items. Uses a heuristic of 2.5 characters per token.
func estimateMessageTokens(msg providers.Message) int { func estimateMessageTokens(msg providers.Message) int {
chars := utf8.RuneCountInString(msg.Content) contentChars := utf8.RuneCountInString(msg.Content)
// ReasoningContent (extended thinking / chain-of-thought) can be // SystemParts are structured system blocks used for cache-aware adapters.
// substantial and is stored in session history via AddFullMessage. // They carry the same content as Content, but in multiple blocks.
if msg.ReasoningContent != "" { // We estimate them as an alternative representation, not additive.
chars += utf8.RuneCountInString(msg.ReasoningContent) systemPartsChars := 0
if len(msg.SystemParts) > 0 {
for _, part := range msg.SystemParts {
systemPartsChars += utf8.RuneCountInString(part.Text)
}
// Per-part overhead for JSON structure (type, text, cache_control).
const perPartOverhead = 20
systemPartsChars += len(msg.SystemParts) * perPartOverhead
} }
// Use the larger of the two representations to stay conservative.
chars := contentChars
if systemPartsChars > chars {
chars = systemPartsChars
}
chars += utf8.RuneCountInString(msg.ReasoningContent)
for _, tc := range msg.ToolCalls { for _, tc := range msg.ToolCalls {
chars += len(tc.ID) + len(tc.Type) chars += len(tc.ID) + len(tc.Type)
if tc.Function != nil { if tc.Function != nil {

View file

@ -529,6 +529,26 @@ func TestEstimateMessageTokens_MediaItems(t *testing.T) {
} }
} }
func TestEstimateMessageTokens_SystemParts(t *testing.T) {
plain := providers.Message{Role: "system", Content: "instructions"}
withParts := providers.Message{
Role: "system",
Content: "instructions",
SystemParts: []providers.ContentBlock{
{Type: "text", Text: "some more system context"},
{Type: "text", Text: "even more cached blocks"},
},
}
plainTokens := estimateMessageTokens(plain)
partsTokens := estimateMessageTokens(withParts)
if partsTokens <= plainTokens {
t.Errorf("system message with SystemParts (%d) should exceed plain message (%d)",
partsTokens, plainTokens)
}
}
// --- estimateToolDefsTokens tests --- // --- estimateToolDefsTokens tests ---
func TestEstimateToolDefsTokens(t *testing.T) { func TestEstimateToolDefsTokens(t *testing.T) {

View file

@ -25,23 +25,25 @@ import (
type fakeChannel struct{ id string } type fakeChannel struct{ id string }
func (f *fakeChannel) Name() string { return "fake" } func (f *fakeChannel) Name() string { return "fake" }
func (f *fakeChannel) Start(ctx context.Context) error { return nil } func (f *fakeChannel) Start(ctx context.Context) error { return nil }
func (f *fakeChannel) Stop(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) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
func (f *fakeChannel) IsRunning() bool { return true } return nil, nil
func (f *fakeChannel) IsAllowed(string) bool { return true } }
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } func (f *fakeChannel) IsRunning() bool { return true }
func (f *fakeChannel) ReasoningChannelID() string { return f.id } 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 { type fakeMediaChannel struct {
fakeChannel fakeChannel
sentMedia []bus.OutboundMediaMessage 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) f.sentMedia = append(f.sentMedia, msg)
return nil return nil, nil
} }
func newStartedTestChannelManager( func newStartedTestChannelManager(

View file

@ -252,28 +252,28 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
**3e. Send method error returns** **3e. Send method error returns**
```go ```go
// Old code: returns plain error // Old code: returned only error
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.running { return fmt.Errorf("not running") } if !c.running { return fmt.Errorf("not running") }
// ... // ...
if err != nil { return err } if err != nil { return err }
} }
// New code: must return sentinel errors for Manager to determine retry strategy // New code: return delivered message IDs plus sentinel errors
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() { if !c.IsRunning() {
return channels.ErrNotRunning // ← Manager will not retry return nil, channels.ErrNotRunning // ← Manager will not retry
} }
// ... // ...
if err != nil { if err != nil {
// Use ClassifySendError to wrap error based on HTTP status code // Use ClassifySendError to wrap error based on HTTP status code
return channels.ClassifySendError(statusCode, err) return nil, channels.ClassifySendError(statusCode, err)
// Or manually wrap: // Or manually wrap:
// return fmt.Errorf("%w: %v", channels.ErrTemporary, err) // return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err)
// return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) // return nil, fmt.Errorf("%w: %v", channels.ErrRateLimit, err)
// return fmt.Errorf("%w: %v", channels.ErrSendFailed, 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 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 // 1. Check running state
if !c.IsRunning() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
// 2. Send message to Matrix // 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 { if err != nil {
// 3. Must use error classification wrapping // 3. Must use error classification wrapping
// If you have an HTTP status code: // If you have an HTTP status code:
// return channels.ClassifySendError(statusCode, err) // return nil, channels.ClassifySendError(statusCode, err)
// If it's a network error: // If it's a network error:
// return channels.ClassifyNetError(err) // return nil, channels.ClassifyNetError(err)
// If manual classification is needed: // 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 ========== // ========== Incoming Message Handling ==========
@ -580,9 +580,9 @@ func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content st
// ========== Internal Methods ========== // ========== 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 // 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 ```go
// If the platform supports sending images/files/audio/video // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
store := c.GetMediaStore() store := c.GetMediaStore()
if store == nil { 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 { for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref) localPath, err := store.Resolve(part.Ref)
if err != nil { if err != nil {
@ -620,8 +621,10 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
default: default:
// Upload file to Matrix // 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 Name() string
Start(ctx context.Context) error Start(ctx context.Context) error
Stop(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 IsRunning() bool
IsAllowed(senderID string) bool IsAllowed(senderID string) bool
IsAllowedSender(sender bus.SenderInfo) bool IsAllowedSender(sender bus.SenderInfo) bool
@ -1279,7 +1282,7 @@ type Channel interface {
// ===== Optional ===== // ===== Optional =====
type MediaSender interface { type MediaSender interface {
SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error)
} }
type TypingCapable interface { type TypingCapable interface {

View file

@ -252,28 +252,28 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
**3e. Send 方法的错误返回** **3e. Send 方法的错误返回**
```go ```go
// 旧代码:返回普通 error // 旧代码:返回 error
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.running { return fmt.Errorf("not running") } if !c.running { return fmt.Errorf("not running") }
// ... // ...
if err != nil { return err } if err != nil { return err }
} }
// 新代码:必须返回哨兵错误,供 Manager 判断重试策略 // 新代码:返回投递后的消息 ID以及供 Manager 判断重试策略的哨兵错误
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() { if !c.IsRunning() {
return channels.ErrNotRunning // ← Manager 不会重试 return nil, channels.ErrNotRunning // ← Manager 不会重试
} }
// ... // ...
if err != nil { if err != nil {
// 使用 ClassifySendError 根据 HTTP 状态码包装错误 // 使用 ClassifySendError 根据 HTTP 状态码包装错误
return channels.ClassifySendError(statusCode, err) return nil, channels.ClassifySendError(statusCode, err)
// 或手动包装: // 或手动包装:
// return fmt.Errorf("%w: %v", channels.ErrTemporary, err) // return nil, fmt.Errorf("%w: %v", channels.ErrTemporary, err)
// return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) // return nil, fmt.Errorf("%w: %v", channels.ErrRateLimit, err)
// return fmt.Errorf("%w: %v", channels.ErrSendFailed, 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 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. 检查运行状态 // 1. 检查运行状态
if !c.IsRunning() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
// 2. 发送消息到 Matrix // 2. 发送消息到 Matrix
err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) eventID, err := c.sendToMatrix(ctx, msg.ChatID, msg.Content)
if err != nil { if err != nil {
// 3. 必须使用错误分类包装 // 3. 必须使用错误分类包装
// 如果你有 HTTP 状态码: // 如果你有 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 调用 // 实际的 Matrix SDK 调用
return nil return "event-id", nil
} }
``` ```
@ -594,16 +594,17 @@ func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string
```go ```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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
store := c.GetMediaStore() store := c.GetMediaStore()
if store == nil { 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 { for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref) localPath, err := store.Resolve(part.Ref)
if err != nil { if err != nil {
@ -620,8 +621,10 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
default: default:
// 上传文件到 Matrix // 上传文件到 Matrix
} }
// 如果 API 能返回平台消息 ID就在这里追加。
// messageIDs = append(messageIDs, uploadedMessageID)
} }
return nil return messageIDs, nil
} }
``` ```
@ -1269,7 +1272,7 @@ type Channel interface {
Name() string Name() string
Start(ctx context.Context) error Start(ctx context.Context) error
Stop(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 IsRunning() bool
IsAllowed(senderID string) bool IsAllowed(senderID string) bool
IsAllowedSender(sender bus.SenderInfo) bool IsAllowedSender(sender bus.SenderInfo) bool
@ -1278,7 +1281,7 @@ type Channel interface {
// ===== 可选实现 ===== // ===== 可选实现 =====
type MediaSender interface { type MediaSender interface {
SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error)
} }
type TypingCapable interface { type TypingCapable interface {

View file

@ -48,7 +48,7 @@ type Channel interface {
Name() string Name() string
Start(ctx context.Context) error Start(ctx context.Context) error
Stop(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 IsRunning() bool
IsAllowed(senderID string) bool IsAllowed(senderID string) bool
IsAllowedSender(sender bus.SenderInfo) bool IsAllowedSender(sender bus.SenderInfo) bool

View file

@ -104,20 +104,20 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error {
} }
// Send sends a message to DingTalk via the chatbot reply API // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
// Get session webhook from storage // Get session webhook from storage
sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID) sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID)
if !ok { 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) sessionWebhook, ok := sessionWebhookRaw.(string)
if !ok { 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{ 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 // 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 // onChatBotMessageReceived implements the IChatBotMessageHandler function signature

View file

@ -128,37 +128,41 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
return nil 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
channelID := msg.ChatID channelID := msg.ChatID
if channelID == "" { if channelID == "" {
return fmt.Errorf("channel ID is empty") return nil, fmt.Errorf("channel ID is empty")
} }
if len([]rune(msg.Content)) == 0 { 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. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
channelID := msg.ChatID channelID := msg.ChatID
if channelID == "" { if channelID == "" {
return fmt.Errorf("channel ID is empty") return nil, fmt.Errorf("channel ID is empty")
} }
store := c.GetMediaStore() store := c.GetMediaStore()
if store == nil { 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 // 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 { if len(files) == 0 {
return nil return nil, nil
} }
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel() defer cancel()
done := make(chan error, 1) type mediaResult struct {
id string
err error
}
done := make(chan mediaResult, 1)
go func() { go func() {
_, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ sentMsg, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Content: caption, Content: caption,
Files: files, Files: files,
}) })
done <- err if err != nil {
done <- mediaResult{err: err}
return
}
done <- mediaResult{id: sentMsg.ID}
}() }()
select { select {
case err := <-done: case r := <-done:
// Close all file readers // Close all file readers
for _, f := range files { for _, f := range files {
if closer, ok := f.Reader.(*os.File); ok { if closer, ok := f.Reader.(*os.File); ok {
closer.Close() closer.Close()
} }
} }
if err != nil { if r.err != nil {
return fmt.Errorf("discord send media: %w", channels.ErrTemporary) return nil, fmt.Errorf("discord send media: %w", channels.ErrTemporary)
} }
return nil return []string{r.id}, nil
case <-sendCtx.Done(): case <-sendCtx.Done():
// Close all file readers // Close all file readers
for _, f := range files { for _, f := range files {
@ -236,7 +248,7 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes
closer.Close() 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 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 // Use the passed ctx for timeout control
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel() defer cancel()
done := make(chan error, 1) type result struct {
id string
err error
}
done := make(chan result, 1)
go func() { go func() {
var err error var (
msg *discordgo.Message
err error
)
// If we have an ID, we send the message as "Reply" // If we have an ID, we send the message as "Reply"
if replyToID != "" { if replyToID != "" {
_, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ msg, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Content: content, Content: content,
Reference: &discordgo.MessageReference{ Reference: &discordgo.MessageReference{
MessageID: replyToID, MessageID: replyToID,
@ -284,20 +303,21 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, repl
}) })
} else { } else {
// Otherwise, we send a normal message // 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 { select {
case err := <-done: case r := <-done:
if err != nil { return r.id, r.err
return fmt.Errorf("discord send: %w", channels.ErrTemporary)
}
return nil
case <-sendCtx.Done(): case <-sendCtx.Done():
return sendCtx.Err() return "", sendCtx.Err()
} }
} }

View file

@ -36,8 +36,8 @@ func (c *FeishuChannel) Stop(ctx context.Context) error {
} }
// Send is a stub method to satisfy the Channel interface // Send is a stub method to satisfy the Channel interface
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
return errUnsupported return nil, errUnsupported
} }
// EditMessage is a stub method to satisfy MessageEditor // 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 // SendMedia is a stub method to satisfy MediaSender
func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
return errUnsupported return nil, errUnsupported
} }

View file

@ -131,26 +131,26 @@ func (c *FeishuChannel) Stop(ctx context.Context) error {
// Send sends a message using Interactive Card format for markdown rendering. // 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). // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
if msg.ChatID == "" { 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 // Build interactive card with markdown content
cardContent, err := buildMarkdownCard(msg.Content) cardContent, err := buildMarkdownCard(msg.Content)
if err != nil { if err != nil {
// If card build fails, fall back to plain text // 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 // First attempt: try sending as interactive card
err = c.sendCard(ctx, msg.ChatID, cardContent) err = c.sendCard(ctx, msg.ChatID, cardContent)
if err == nil { if err == nil {
return nil return nil, nil
} }
// Check if error is due to card table limit (error code 11310) // 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 // Second attempt: fall back to plain text message
textErr := c.sendText(ctx, msg.ChatID, msg.Content) textErr := c.sendText(ctx, msg.ChatID, msg.Content)
if textErr == nil { if textErr == nil {
return nil return nil, nil
} }
// If text also fails, return the text error // If text also fails, return the text error
return textErr return nil, textErr
} }
// For other errors, return the original card error // For other errors, return the original card error
return err return nil, err
} }
// EditMessage implements channels.MessageEditor. // EditMessage implements channels.MessageEditor.
@ -310,27 +310,27 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st
// SendMedia implements channels.MediaSender. // SendMedia implements channels.MediaSender.
// Uploads images/files via Feishu API then sends as messages. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
if msg.ChatID == "" { 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() store := c.GetMediaStore()
if store == nil { 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 { for _, part := range msg.Parts {
if err := c.sendMediaPart(ctx, msg.ChatID, part, store); err != nil { 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. // sendMediaPart resolves and sends a single media part.

View file

@ -130,18 +130,18 @@ func (c *IRCChannel) Stop(ctx context.Context) error {
} }
// Send sends a message to an IRC channel or user. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
target := msg.ChatID target := msg.ChatID
if target == "" { 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) == "" { if strings.TrimSpace(msg.Content) == "" {
return nil return nil, nil
} }
// Send each line separately (IRC is line-oriented) // 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, "target": target,
"lines": len(lines), "lines": len(lines),
}) })
return nil return nil, nil
} }
// StartTyping implements channels.TypingCapable using IRCv3 +typing client tag. // StartTyping implements channels.TypingCapable using IRCv3 +typing client tag.

View file

@ -496,9 +496,9 @@ func (c *LINEChannel) resolveChatID(source lineSource) string {
// Send sends a message to LINE. It first tries the Reply API (free) // 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. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
// Load and consume quote token for this chat // 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, "chat_id": msg.ChatID,
"quoted": quoteToken != "", "quoted": quoteToken != "",
}) })
return nil return nil, nil
} }
logger.DebugC("line", "Reply API failed, falling back to Push API") logger.DebugC("line", "Reply API failed, falling back to Push API")
} }
} }
// Fall 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. // SendMedia implements the channels.MediaSender interface.
// LINE requires media to be accessible via public URL; since we only have local files, // 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. // we fall back to sending a text message with the filename/caption.
// For full support, an external file hosting service would be needed. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
store := c.GetMediaStore() store := c.GetMediaStore()
if store == nil { 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. // 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 { 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. // buildTextMessage creates a text message object, optionally with quoteToken.

View file

@ -240,15 +240,15 @@ func (c *MaixCamChannel) Stop(ctx context.Context) error {
return nil 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
// Check ctx before entering write path // Check ctx before entering write path
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return nil, ctx.Err()
default: default:
} }
@ -257,7 +257,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
if len(c.clients) == 0 { if len(c.clients) == 0 {
logger.WarnC("maixcam", "No MaixCam devices connected") 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{ response := map[string]any{
@ -269,7 +269,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
data, err := json.Marshal(response) data, err := json.Marshal(response)
if err != nil { 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 var sendErr error
@ -285,5 +285,5 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
_ = conn.SetWriteDeadline(time.Time{}) _ = conn.SetWriteDeadline(time.Time{})
} }
return sendErr return nil, sendErr
} }

View file

@ -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. // preSend handles typing stop, reaction undo, and placeholder editing before sending a message.
// Returns true if the message was already delivered (skip Send). // 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) bool { func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) ([]string, bool) {
key := name + ":" + msg.ChatID key := name + ":" + msg.ChatID
// 1. Stop typing // 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 // 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 entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if editor, ok := ch.(MessageEditor); ok { if editor, ok := ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil { 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 // edit failed → fall through to normal Send
} }
} }
} }
return false return nil, false
} }
// preSendMedia handles typing stop, reaction undo, and placeholder cleanup // 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 // - ErrNotRunning / ErrSendFailed: permanent, no retry
// - ErrRateLimit: fixed delay retry // - ErrRateLimit: fixed delay retry
// - ErrTemporary / unknown: exponential backoff 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 // Rate limit: wait for token
if err := w.limiter.Wait(ctx); err != nil { if err := w.limiter.Wait(ctx); err != nil {
// ctx canceled, shutting down // ctx canceled, shutting down
return return nil, false
} }
// Pre-send: stop typing and try to edit placeholder // Pre-send: stop typing and try to edit placeholder
if m.preSend(ctx, name, msg, w.ch) { if msgIDs, handled := m.preSend(ctx, name, msg, w.ch); handled {
return // placeholder was edited successfully, skip Send return msgIDs, true
} }
var lastErr error var lastErr error
var msgIDs []string
for attempt := 0; attempt <= maxRetries; attempt++ { for attempt := 0; attempt <= maxRetries; attempt++ {
lastErr = w.ch.Send(ctx, msg) msgIDs, lastErr = w.ch.Send(ctx, msg)
if lastErr == nil { if lastErr == nil {
return return msgIDs, true
} }
// Permanent failures — don't retry // Permanent failures — don't retry
@ -734,7 +740,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork
case <-time.After(rateLimitDelay): case <-time.After(rateLimitDelay):
continue continue
case <-ctx.Done(): case <-ctx.Done():
return return nil, false
} }
} }
@ -743,7 +749,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork
select { select {
case <-time.After(backoff): case <-time.After(backoff):
case <-ctx.Done(): 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(), "error": lastErr.Error(),
"retries": maxRetries, "retries": maxRetries,
}) })
return nil, false
} }
func dispatchLoop[M any]( func dispatchLoop[M any](
@ -855,7 +863,7 @@ func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWor
if !ok { if !ok {
return return
} }
_ = m.sendMediaWithRetry(ctx, name, w, msg) _, _ = m.sendMediaWithRetry(ctx, name, w, msg)
case <-ctx.Done(): case <-ctx.Done():
return 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 // 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, // retry logic. It returns the message IDs and nil on success, or nil and the last error
// including when the channel does not support MediaSender. // after retries, including when the channel does not support MediaSender.
func (m *Manager) sendMediaWithRetry( func (m *Manager) sendMediaWithRetry(
ctx context.Context, ctx context.Context,
name string, name string,
w *channelWorker, w *channelWorker,
msg bus.OutboundMediaMessage, msg bus.OutboundMediaMessage,
) error { ) ([]string, error) {
ms, ok := w.ch.(MediaSender) ms, ok := w.ch.(MediaSender)
if !ok { if !ok {
err := fmt.Errorf("channel %q does not support media sending", name) err := fmt.Errorf("channel %q does not support media sending", name)
@ -878,22 +886,23 @@ func (m *Manager) sendMediaWithRetry(
"channel": name, "channel": name,
"error": err.Error(), "error": err.Error(),
}) })
return err return nil, err
} }
// Rate limit: wait for token // Rate limit: wait for token
if err := w.limiter.Wait(ctx); err != nil { 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. // Pre-send: stop typing and clean up any placeholder before sending media.
m.preSendMedia(ctx, name, msg, w.ch) m.preSendMedia(ctx, name, msg, w.ch)
var lastErr error var lastErr error
var msgIDs []string
for attempt := 0; attempt <= maxRetries; attempt++ { for attempt := 0; attempt <= maxRetries; attempt++ {
lastErr = ms.SendMedia(ctx, msg) msgIDs, lastErr = ms.SendMedia(ctx, msg)
if lastErr == nil { if lastErr == nil {
return nil return msgIDs, nil
} }
// Permanent failures — don't retry // Permanent failures — don't retry
@ -912,7 +921,7 @@ func (m *Manager) sendMediaWithRetry(
case <-time.After(rateLimitDelay): case <-time.After(rateLimitDelay):
continue continue
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return nil, ctx.Err()
} }
} }
@ -921,7 +930,7 @@ func (m *Manager) sendMediaWithRetry(
select { select {
case <-time.After(backoff): case <-time.After(backoff):
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return nil, ctx.Err()
} }
} }
@ -932,7 +941,7 @@ func (m *Manager) sendMediaWithRetry(
"error": lastErr.Error(), "error": lastErr.Error(),
"retries": maxRetries, "retries": maxRetries,
}) })
return lastErr return nil, lastErr
} }
// runTTLJanitor periodically scans the typingStops and placeholders maps // 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 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 { 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) // Fallback: direct send (should not happen)
channel, _ := m.channels[channelName] channel, _ := m.channels[channelName]
return channel.Send(ctx, msg) _, err := channel.Send(ctx, msg)
return err
} }

View file

@ -25,9 +25,12 @@ type mockChannel struct {
lastPlaceholderID string 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) 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 } 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 { type mockMediaChannel struct {
mockChannel mockChannel
sendMediaFn func(ctx context.Context, msg bus.OutboundMediaMessage) error sendMediaFn func(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error)
sentMediaMessages []bus.OutboundMediaMessage 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) m.sentMediaMessages = append(m.sentMediaMessages, msg)
if m.sendMediaFn != nil { if m.sendMediaFn != nil {
return m.sendMediaFn(ctx, msg) return m.sendMediaFn(ctx, msg)
} }
return nil return nil, nil
} }
type mockDeletingMediaChannel struct { type mockDeletingMediaChannel struct {
@ -247,9 +250,9 @@ func TestSendMedia_Success(t *testing.T) {
m := newTestManager() m := newTestManager()
var callCount int var callCount int
ch := &mockMediaChannel{ ch := &mockMediaChannel{
sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) {
callCount++ callCount++
return nil return nil, nil
}, },
} }
w := &channelWorker{ w := &channelWorker{
@ -275,8 +278,8 @@ func TestSendMedia_Success(t *testing.T) {
func TestSendMedia_PropagatesFailure(t *testing.T) { func TestSendMedia_PropagatesFailure(t *testing.T) {
m := newTestManager() m := newTestManager()
ch := &mockMediaChannel{ ch := &mockMediaChannel{
sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) {
return fmt.Errorf("bad upload: %w", ErrSendFailed) return nil, fmt.Errorf("bad upload: %w", ErrSendFailed)
}, },
} }
w := &channelWorker{ w := &channelWorker{
@ -330,8 +333,8 @@ func TestSendMedia_DeletesPlaceholderBeforeSending(t *testing.T) {
m := newTestManager() m := newTestManager()
ch := &mockDeletingMediaChannel{ ch := &mockDeletingMediaChannel{
mockMediaChannel: mockMediaChannel{ mockMediaChannel: mockMediaChannel{
sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) error { sendMediaFn: func(_ context.Context, _ bus.OutboundMediaMessage) ([]string, error) {
return nil return nil, nil
}, },
}, },
} }
@ -628,7 +631,7 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) {
m.RecordPlaceholder("test", "123", "456") m.RecordPlaceholder("test", "123", "456")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} 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 { if !edited {
t.Fatal("expected preSend to return true (placeholder 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") m.RecordPlaceholder("test", "123", "456")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} 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 { if edited {
t.Fatal("expected preSend to return false when edit fails") 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"} 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 { if edited {
t.Fatal("expected preSend to return false with no registered state") 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") m.RecordPlaceholder("test", "123", "456")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} 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 { if !stopCalled {
t.Fatal("expected typing stop to be called") t.Fatal("expected typing stop to be called")
@ -1025,7 +1028,7 @@ func TestPreSendStillWorksWithWrappedTypes(t *testing.T) {
m.RecordPlaceholder("test", "chat1", "ph_id") m.RecordPlaceholder("test", "chat1", "ph_id")
msg := bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"} 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 { if !stopCalled {
t.Fatal("expected typing stop to be called via wrapped type") t.Fatal("expected typing stop to be called via wrapped type")

View file

@ -380,26 +380,26 @@ func markdownToHTML(md string) string {
return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
roomID := id.RoomID(strings.TrimSpace(msg.ChatID)) roomID := id.RoomID(strings.TrimSpace(msg.ChatID))
if roomID == "" { 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) content := strings.TrimSpace(msg.Content)
if 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 { 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 { func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent {
@ -412,9 +412,9 @@ func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent {
} }
// SendMedia implements channels.MediaSender. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
sendCtx := ctx sendCtx := ctx
if sendCtx == nil { if sendCtx == nil {
@ -423,17 +423,18 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
roomID := id.RoomID(strings.TrimSpace(msg.ChatID)) roomID := id.RoomID(strings.TrimSpace(msg.ChatID))
if roomID == "" { 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() store := c.GetMediaStore()
if store == nil { 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 { for _, part := range msg.Parts {
if err := sendCtx.Err(); err != nil { if err := sendCtx.Err(); err != nil {
return err return nil, err
} }
localPath, meta, err := store.ResolveWithMeta(part.Ref) localPath, meta, err := store.ResolveWithMeta(part.Ref)
@ -498,7 +499,7 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
"type": part.Type, "type": part.Type,
"error": err.Error(), "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) msgType := matrixOutboundMsgType(part.Type, filename, contentType)
@ -511,17 +512,21 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
uploadResp.ContentURI.CUString(), 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{ logger.ErrorCF("matrix", "Failed to send media message", map[string]any{
"room_id": roomID.String(), "room_id": roomID.String(),
"type": msgType, "type": msgType,
"error": err.Error(), "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. // StartTyping implements channels.TypingCapable.

View file

@ -11,5 +11,5 @@ import (
// Manager discovers channels implementing this interface via type // Manager discovers channels implementing this interface via type
// assertion and routes OutboundMediaMessage to them. // assertion and routes OutboundMediaMessage to them.
type MediaSender interface { type MediaSender interface {
SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error)
} }

View file

@ -391,15 +391,15 @@ func (c *OneBotChannel) Stop(ctx context.Context) error {
return nil 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
// Check ctx before entering write path // Check ctx before entering write path
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return nil, ctx.Err()
default: default:
} }
@ -408,12 +408,12 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
c.mu.Unlock() c.mu.Unlock()
if conn == nil { if conn == nil {
return fmt.Errorf("OneBot WebSocket not connected") return nil, fmt.Errorf("OneBot WebSocket not connected")
} }
action, params, err := c.buildSendRequest(msg) action, params, err := c.buildSendRequest(msg)
if err != nil { if err != nil {
return err return nil, err
} }
echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) 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) data, err := json.Marshal(req)
if err != nil { 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() 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{ logger.ErrorCF("onebot", "Failed to send message", map[string]any{
"error": err.Error(), "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. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return nil, ctx.Err()
default: default:
} }
@ -462,12 +462,12 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
c.mu.Unlock() c.mu.Unlock()
if conn == nil { if conn == nil {
return fmt.Errorf("OneBot WebSocket not connected") return nil, fmt.Errorf("OneBot WebSocket not connected")
} }
store := c.GetMediaStore() store := c.GetMediaStore()
if store == nil { 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 // Build media segments
@ -508,7 +508,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
} }
if len(segments) == 0 { if len(segments) == 0 {
return nil return nil, nil
} }
chatID := msg.ChatID chatID := msg.ChatID
@ -524,7 +524,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
id, err := strconv.ParseInt(rawID, 10, 64) id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil { 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)) 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) data, err := json.Marshal(req)
if err != nil { 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() 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{ logger.ErrorCF("onebot", "Failed to send media message", map[string]any{
"error": err.Error(), "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 { func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment {

View file

@ -273,22 +273,22 @@ func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) {
} }
// Send sends a message to the remote server. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
c.mu.Lock() c.mu.Lock()
pc := c.conn pc := c.conn
c.mu.Unlock() c.mu.Unlock()
if pc == nil || pc.closed.Load() { if pc == nil || pc.closed.Load() {
return channels.ErrSendFailed return nil, channels.ErrSendFailed
} }
outMsg := newMessage(TypeMessageSend, map[string]any{ outMsg := newMessage(TypeMessageSend, map[string]any{
"content": msg.Content, "content": msg.Content,
}) })
outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:") outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:")
return pc.writeJSON(outMsg) return nil, pc.writeJSON(outMsg)
} }
// StartTyping implements channels.TypingCapable. // StartTyping implements channels.TypingCapable.

View file

@ -46,7 +46,7 @@ func TestSend_NotRunning(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) { if !errors.Is(err, channels.ErrNotRunning) {
t.Fatalf("expected ErrNotRunning, got %v", err) t.Fatalf("expected ErrNotRunning, got %v", err)
} }
@ -124,7 +124,7 @@ func TestClientChannel_ConnectAndSend(t *testing.T) {
defer ch.Stop(ctx) defer ch.Stop(ctx)
// Send a message // Send a message
err = ch.Send(ctx, bus.OutboundMessage{ _, err = ch.Send(ctx, bus.OutboundMessage{
ChatID: "pico_client:sess-1", ChatID: "pico_client:sess-1",
Content: "hello", Content: "hello",
}) })
@ -179,7 +179,7 @@ func TestClientChannel_ReceivesServerMessage(t *testing.T) {
defer ch.Stop(ctx) defer ch.Stop(ctx)
// Send a message; the echo server replies with message.create // 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", ChatID: "pico_client:sess-echo",
Content: "ping", Content: "ping",
}) })
@ -252,7 +252,7 @@ func TestSend_ClosedConnection(t *testing.T) {
ch.conn.close() ch.conn.close()
ch.mu.Unlock() ch.mu.Unlock()
err = ch.Send(ctx, bus.OutboundMessage{ _, err = ch.Send(ctx, bus.OutboundMessage{
ChatID: "pico_client:sess-close", ChatID: "pico_client:sess-close",
Content: "should fail", Content: "should fail",
}) })

View file

@ -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. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
outMsg := newMessage(TypeMessageCreate, map[string]any{ outMsg := newMessage(TypeMessageCreate, map[string]any{
"content": msg.Content, "content": msg.Content,
}) })
return c.broadcastToSession(msg.ChatID, outMsg) return nil, c.broadcastToSession(msg.ChatID, outMsg)
} }
// EditMessage implements channels.MessageEditor. // EditMessage implements channels.MessageEditor.

View file

@ -200,9 +200,9 @@ func (c *QQChannel) getChatKind(chatID string) string {
return "group" 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
chatKind := c.getChatKind(msg.ChatID) 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. // Route to group or C2C.
var err error var (
sentMsg *dto.Message
err error
)
if chatKind == "group" { if chatKind == "group" {
_, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) sentMsg, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate)
} else { } else {
_, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) sentMsg, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
} }
if err != nil { if err != nil {
@ -249,10 +252,13 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
"chat_kind": chatKind, "chat_kind": chatKind,
"error": err.Error(), "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. // 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: // QQ group/C2C media sending is a two-step flow:
// 1. Upload media to /files using a remote URL or base64-encoded local bytes. // 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. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
chatKind := c.getChatKind(msg.ChatID) chatKind := c.getChatKind(msg.ChatID)
var messageIDs []string
for _, part := range msg.Parts { for _, part := range msg.Parts {
fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part) fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part)
if err != nil { if err != nil {
@ -335,22 +342,26 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage)
"error": err.Error(), "error": err.Error(),
}) })
if errors.Is(err, channels.ErrSendFailed) { 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{ logger.ErrorCF("qq", "Failed to send media", map[string]any{
"type": part.Type, "type": part.Type,
"chat_id": msg.ChatID, "chat_id": msg.ChatID,
"error": err.Error(), "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 { type qqMediaUpload struct {
@ -517,7 +528,7 @@ func (c *QQChannel) sendUploadedMedia(
chatKind, chatID string, chatKind, chatID string,
part bus.MediaPart, part bus.MediaPart,
fileInfo []byte, fileInfo []byte,
) error { ) (*dto.Message, error) {
msg := &dto.MessageToCreate{ msg := &dto.MessageToCreate{
Content: part.Caption, Content: part.Caption,
MsgType: dto.RichMediaMsg, MsgType: dto.RichMediaMsg,
@ -532,11 +543,11 @@ func (c *QQChannel) sendUploadedMedia(
} }
if chatKind == "group" { if chatKind == "group" {
_, err := c.api.PostGroupMessage(ctx, chatID, msg) sentMsg, err := c.api.PostGroupMessage(ctx, chatID, msg)
return err return sentMsg, err
} }
_, err := c.api.PostC2CMessage(ctx, chatID, msg) sentMsg, err := c.api.PostC2CMessage(ctx, chatID, msg)
return err return sentMsg, err
} }
func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) { func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) {

View file

@ -209,7 +209,7 @@ func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) {
ch.lastMsgID.Store("group-1", "msg-1") ch.lastMsgID.Store("group-1", "msg-1")
ch.msgSeqCounters.Store("group-1", new(atomic.Uint64)) 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", ChatID: "group-1",
Parts: []bus.MediaPart{{ Parts: []bus.MediaPart{{
Type: "image", Type: "image",
@ -303,7 +303,7 @@ func assertAudioWAVUploadType(t *testing.T, duration time.Duration, wantFileType
ch.SetMediaStore(store) ch.SetMediaStore(store)
ch.chatType.Store("group-1", "group") ch.chatType.Store("group-1", "group")
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "group-1", ChatID: "group-1",
Parts: []bus.MediaPart{{ Parts: []bus.MediaPart{{
Type: "audio", Type: "audio",
@ -337,7 +337,7 @@ func TestSendMedia_RemoteAudioFallsBackToFileUpload(t *testing.T) {
ch.SetRunning(true) ch.SetRunning(true)
ch.chatType.Store("user-1", "direct") ch.chatType.Store("user-1", "direct")
err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "user-1", ChatID: "user-1",
Parts: []bus.MediaPart{{ Parts: []bus.MediaPart{{
Type: "audio", Type: "audio",
@ -383,7 +383,7 @@ func TestSendMedia_LocalAudioWithUnknownDurationFallsBackToFileUpload(t *testing
ch.SetMediaStore(store) ch.SetMediaStore(store)
ch.chatType.Store("group-1", "group") ch.chatType.Store("group-1", "group")
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "group-1", ChatID: "group-1",
Parts: []bus.MediaPart{{ Parts: []bus.MediaPart{{
Type: "audio", Type: "audio",
@ -417,7 +417,7 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) {
ch.SetRunning(true) ch.SetRunning(true)
ch.chatType.Store("user-1", "direct") ch.chatType.Store("user-1", "direct")
err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "user-1", ChatID: "user-1",
Parts: []bus.MediaPart{{ Parts: []bus.MediaPart{{
Type: "file", Type: "file",
@ -490,7 +490,7 @@ func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) {
ch.SetMediaStore(store) ch.SetMediaStore(store)
ch.chatType.Store("user-1", "direct") ch.chatType.Store("user-1", "direct")
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "user-1", ChatID: "user-1",
Parts: []bus.MediaPart{{ Parts: []bus.MediaPart{{
Type: "file", Type: "file",
@ -528,7 +528,7 @@ func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) {
ch.SetRunning(true) ch.SetRunning(true)
ch.chatType.Store("group-1", "group") ch.chatType.Store("group-1", "group")
err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "group-1", ChatID: "group-1",
Parts: []bus.MediaPart{{ Parts: []bus.MediaPart{{
Type: "image", Type: "image",
@ -578,7 +578,7 @@ func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testin
ch.SetMediaStore(store) ch.SetMediaStore(store)
ch.chatType.Store("group-1", "group") ch.chatType.Store("group-1", "group")
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "group-1", ChatID: "group-1",
Parts: []bus.MediaPart{{ Parts: []bus.MediaPart{{
Type: "file", Type: "file",

View file

@ -108,14 +108,14 @@ func (c *SlackChannel) Stop(ctx context.Context) error {
return nil 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
channelID, threadTS := parseSlackChatID(msg.ChatID) channelID, threadTS := parseSlackChatID(msg.ChatID)
if channelID == "" { 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{ opts := []slack.MsgOption{
@ -130,9 +130,9 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
opts = append(opts, slack.MsgOptionTS(threadTS)) opts = append(opts, slack.MsgOptionTS(threadTS))
} }
_, _, err := c.api.PostMessageContext(ctx, channelID, opts...) _, ts, err := c.api.PostMessageContext(ctx, channelID, opts...)
if err != nil { 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 { 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, "thread_ts": threadTS,
}) })
return nil return []string{ts}, nil
} }
// SendMedia implements the channels.MediaSender interface. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
channelID, _ := parseSlackChatID(msg.ChatID) channelID, _ := parseSlackChatID(msg.ChatID)
if channelID == "" { 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() store := c.GetMediaStore()
if store == nil { 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 { for _, part := range msg.Parts {
@ -198,11 +198,13 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
"filename": filename, "filename": filename,
"error": err.Error(), "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. // ReactToMessage implements channels.ReactionCapable.

View file

@ -16,14 +16,15 @@ func markdownToTelegramHTML(text string) string {
inlineCodes := extractInlineCodes(text) inlineCodes := extractInlineCodes(text)
text = inlineCodes.text text = inlineCodes.text
links := extractLinks(text)
text = links.text
text = reHeading.ReplaceAllString(text, "$1") text = reHeading.ReplaceAllString(text, "$1")
text = reBlockquote.ReplaceAllString(text, "$1") text = reBlockquote.ReplaceAllString(text, "$1")
text = escapeHTML(text) text = escapeHTML(text)
text = reLink.ReplaceAllString(text, `<a href="$2">$1</a>`)
text = reBoldStar.ReplaceAllString(text, "<b>$1</b>") text = reBoldStar.ReplaceAllString(text, "<b>$1</b>")
text = reBoldUnder.ReplaceAllString(text, "<b>$1</b>") text = reBoldUnder.ReplaceAllString(text, "<b>$1</b>")
@ -40,6 +41,12 @@ func markdownToTelegramHTML(text string) string {
text = reListItem.ReplaceAllString(text, "• ") 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(`<a href="%s">%s</a>`, url, label))
}
for i, code := range inlineCodes.codes { for i, code := range inlineCodes.codes {
escaped := escapeHTML(code) escaped := escapeHTML(code)
text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("<code>%s</code>", escaped)) text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("<code>%s</code>", escaped))
@ -57,6 +64,29 @@ func markdownToTelegramHTML(text string) string {
return text 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 { type codeBlockMatch struct {
text string text string
codes []string codes []string

View file

@ -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: "<b>bold text</b>",
},
{
name: "italic",
input: "_italic text_",
expected: "<i>italic text</i>",
},
{
name: "link without underscores in URL",
input: "[click here](https://example.com/path)",
expected: `<a href="https://example.com/path">click here</a>`,
},
{
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
// <i> 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: `<a href="https://www.google.com/travel/flights/search?tfs=CBwQAho_EgoyURL_safe_base64">3 → 10 сентября — от $202</a>`,
},
{
name: "multiple links all survive",
input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)",
expected: `<a href="https://a.com/path_one">first</a> and <a href="https://b.com/path_two_x">second</a>`,
},
{
name: "link label with HTML special chars is escaped",
input: "[a & b](https://example.com)",
expected: `<a href="https://example.com">a &amp; b</a>`,
},
{
name: "HTML special chars in plain text are escaped",
input: "a & b < c > d",
expected: "a &amp; b &lt; c &gt; d",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
actual := markdownToTelegramHTML(tc.input)
require.Equal(t, tc.expected, actual)
})
}
}

View file

@ -168,26 +168,27 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
return nil 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2 useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
chatID, threadID, err := parseTelegramChatID(msg.ChatID) chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil { 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 == "" { if msg.Content == "" {
return nil return nil, nil
} }
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
// so msg.Content is guaranteed to be within that limit. We still need to // 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. // check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
replyToID := msg.ReplyToMessageID replyToID := msg.ReplyToMessageID
var messageIDs []string
queue := []string{msg.Content} queue := []string{msg.Content}
for len(queue) > 0 { for len(queue) > 0 {
chunk := queue[0] chunk := queue[0]
@ -206,16 +207,18 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
} }
if smallerLen <= 0 { if smallerLen <= 0 {
if err := c.sendChunk(ctx, sendChunkParams{ msgID, err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID, chatID: chatID,
threadID: threadID, threadID: threadID,
content: content, content: content,
replyToID: replyToID, replyToID: replyToID,
mdFallback: chunk, mdFallback: chunk,
useMarkdownV2: useMarkdownV2, useMarkdownV2: useMarkdownV2,
}); err != nil { })
return err if err != nil {
return nil, err
} }
messageIDs = append(messageIDs, msgID)
replyToID = "" replyToID = ""
continue continue
} }
@ -244,21 +247,23 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
continue continue
} }
if err := c.sendChunk(ctx, sendChunkParams{ msgID, err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID, chatID: chatID,
threadID: threadID, threadID: threadID,
content: content, content: content,
replyToID: replyToID, replyToID: replyToID,
mdFallback: chunk, mdFallback: chunk,
useMarkdownV2: useMarkdownV2, 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. // Only the first chunk should be a reply; subsequent chunks are normal messages.
replyToID = "" replyToID = ""
} }
return nil return messageIDs, nil
} }
type sendChunkParams struct { type sendChunkParams struct {
@ -275,7 +280,7 @@ type sendChunkParams struct {
func (c *TelegramChannel) sendChunk( func (c *TelegramChannel) sendChunk(
ctx context.Context, ctx context.Context,
params sendChunkParams, params sendChunkParams,
) error { ) (string, error) {
tgMsg := tu.Message(tu.ID(params.chatID), params.content) tgMsg := tu.Message(tu.ID(params.chatID), params.content)
tgMsg.MessageThreadID = params.threadID tgMsg.MessageThreadID = params.threadID
if params.useMarkdownV2 { 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) logParseFailed(err, params.useMarkdownV2)
tgMsg.Text = params.mdFallback tgMsg.Text = params.mdFallback
tgMsg.ParseMode = "" tgMsg.ParseMode = ""
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { pMsg, err = c.bot.SendMessage(ctx, tgMsg)
return fmt.Errorf("telegram send: %w", channels.ErrTemporary) 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. // 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. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
chatID, threadID, err := parseTelegramChatID(msg.ChatID) chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil { 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() store := c.GetMediaStore()
if store == nil { 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 { for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref) localPath, err := store.Resolve(part.Ref)
if err != nil { if err != nil {
@ -454,6 +462,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
continue continue
} }
var tgResult *telego.Message
switch part.Type { switch part.Type {
case "image": case "image":
params := &telego.SendPhotoParams{ params := &telego.SendPhotoParams{
@ -462,11 +471,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Photo: telego.InputFile{File: file}, Photo: telego.InputFile{File: file},
Caption: part.Caption, 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 err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") {
if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil { if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil {
file.Close() 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{ docParams := &telego.SendDocumentParams{
@ -475,7 +484,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Document: telego.InputFile{File: file}, Document: telego.InputFile{File: file},
Caption: part.Caption, Caption: part.Caption,
} }
_, err = c.bot.SendDocument(ctx, docParams) tgResult, err = c.bot.SendDocument(ctx, docParams)
} }
case "audio": case "audio":
// Send OGG files with "voice" in the filename as Telegram voice // 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}, Voice: telego.InputFile{File: file},
Caption: part.Caption, Caption: part.Caption,
} }
_, err = c.bot.SendVoice(ctx, vparams) tgResult, err = c.bot.SendVoice(ctx, vparams)
} else { } else {
params := &telego.SendAudioParams{ params := &telego.SendAudioParams{
ChatID: tu.ID(chatID), ChatID: tu.ID(chatID),
@ -496,7 +505,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Audio: telego.InputFile{File: file}, Audio: telego.InputFile{File: file},
Caption: part.Caption, Caption: part.Caption,
} }
_, err = c.bot.SendAudio(ctx, params) tgResult, err = c.bot.SendAudio(ctx, params)
} }
case "video": case "video":
params := &telego.SendVideoParams{ params := &telego.SendVideoParams{
@ -505,7 +514,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Video: telego.InputFile{File: file}, Video: telego.InputFile{File: file},
Caption: part.Caption, Caption: part.Caption,
} }
_, err = c.bot.SendVideo(ctx, params) tgResult, err = c.bot.SendVideo(ctx, params)
default: // "file" or unknown types default: // "file" or unknown types
params := &telego.SendDocumentParams{ params := &telego.SendDocumentParams{
ChatID: tu.ID(chatID), ChatID: tu.ID(chatID),
@ -513,9 +522,12 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Document: telego.InputFile{File: file}, Document: telego.InputFile{File: file},
Caption: part.Caption, 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() file.Close()
if err != nil { if err != nil {
@ -523,11 +535,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
"type": part.Type, "type": part.Type,
"error": err.Error(), "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 { 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 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 // For forum topics, embed the thread ID as "chatID/threadID" so replies
// route to the correct topic and each topic gets its own session. // route to the correct topic and each topic gets its own session.
// Only forum groups (IsForum) are handled; regular group reply threads // 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, "first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), "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. // Set parent_peer metadata for per-topic agent binding.
if message.Chat.IsForum && threadID != 0 { if message.Chat.IsForum && threadID != 0 {
@ -713,6 +745,122 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return nil 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 { func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil { if err != nil {

View file

@ -7,6 +7,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
@ -104,6 +105,13 @@ func successResponse(t *testing.T) *ta.Response {
return &ta.Response{Ok: true, Result: b} 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. // newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel { func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
return newTestChannelWithConstructor(t, caller, &stubConstructor{}) return newTestChannelWithConstructor(t, caller, &stubConstructor{})
@ -168,7 +176,7 @@ func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {
) )
require.NoError(t, err) require.NoError(t, err)
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345", ChatID: "12345",
Parts: []bus.MediaPart{{ Parts: []bus.MediaPart{{
Type: "image", 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") ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
require.NoError(t, err) require.NoError(t, err)
err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345", ChatID: "12345",
Parts: []bus.MediaPart{{ Parts: []bus.MediaPart{{
Type: "image", Type: "image",
@ -231,7 +239,7 @@ func TestSend_EmptyContent(t *testing.T) {
} }
ch := newTestChannel(t, caller) ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345", ChatID: "12345",
Content: "", Content: "",
}) })
@ -248,7 +256,7 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) {
} }
ch := newTestChannel(t, caller) ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345", ChatID: "12345",
Content: "Hello, world!", Content: "Hello, world!",
}) })
@ -271,7 +279,7 @@ func TestSend_LongMessage_SingleCall(t *testing.T) {
longContent := strings.Repeat("a", 4000) longContent := strings.Repeat("a", 4000)
err := ch.Send(context.Background(), bus.OutboundMessage{ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345", ChatID: "12345",
Content: longContent, Content: longContent,
}) })
@ -294,7 +302,7 @@ func TestSend_HTMLFallback_PerChunk(t *testing.T) {
} }
ch := newTestChannel(t, caller) ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345", ChatID: "12345",
Content: "Hello **world**", Content: "Hello **world**",
}) })
@ -312,7 +320,7 @@ func TestSend_HTMLFallback_BothFail(t *testing.T) {
} }
ch := newTestChannel(t, caller) ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345", ChatID: "12345",
Content: "Hello", Content: "Hello",
}) })
@ -334,7 +342,7 @@ func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
longContent := strings.Repeat("x", 4001) longContent := strings.Repeat("x", 4001)
err := ch.Send(context.Background(), bus.OutboundMessage{ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345", ChatID: "12345",
Content: longContent, 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", "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", ChatID: "12345",
Content: markdownContent, Content: markdownContent,
}) })
@ -399,7 +407,7 @@ func TestSend_HTMLOverflow_WordBoundary(t *testing.T) {
// Ensure the test content matches the intended boundary conditions. // 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") 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", ChatID: "123456",
Content: content, Content: content,
}) })
@ -435,7 +443,7 @@ func TestSend_NotRunning(t *testing.T) {
ch := newTestChannel(t, caller) ch := newTestChannel(t, caller)
ch.SetRunning(false) ch.SetRunning(false)
err := ch.Send(context.Background(), bus.OutboundMessage{ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345", ChatID: "12345",
Content: "Hello", Content: "Hello",
}) })
@ -453,7 +461,7 @@ func TestSend_InvalidChatID(t *testing.T) {
} }
ch := newTestChannel(t, caller) ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "not-a-number", ChatID: "not-a-number",
Content: "Hello", Content: "Hello",
}) })
@ -510,7 +518,7 @@ func TestSend_WithForumThreadID(t *testing.T) {
} }
ch := newTestChannel(t, caller) ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890/42", ChatID: "-1001234567890/42",
Content: "Hello from topic", Content: "Hello from topic",
}) })
@ -642,6 +650,181 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
assert.Empty(t, inbound.Metadata["parent_peer_id"]) 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) { func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
messageBus := bus.NewMessageBus() messageBus := bus.NewMessageBus()
ch := &TelegramChannel{ ch := &TelegramChannel{

View file

@ -184,20 +184,20 @@ func (c *WeComChannel) BeginStream(_ context.Context, chatID string) (channels.S
}, nil }, 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
content := strings.TrimSpace(msg.Content) content := strings.TrimSpace(msg.Content)
if content == "" { if content == "" {
return nil return nil, nil
} }
if turn, ok := c.getTurn(msg.ChatID); ok { if turn, ok := c.getTurn(msg.ChatID); ok {
if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration { if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration {
if err := c.sendStreamReply(turn, content); err == nil { if err := c.sendStreamReply(turn, content); err == nil {
c.consumeTurn(msg.ChatID, turn) c.consumeTurn(msg.ChatID, turn)
return nil return nil, nil
} }
} }
c.consumeTurn(msg.ChatID, turn) 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 route, ok := c.routes.Get(msg.ChatID); ok {
if err := c.sendActivePush(route.ChatID, route.ChatType, content); err != nil { 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 { 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
route, chatType, hasTurn := c.resolveMediaRoute(msg.ChatID) 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 strings.TrimSpace(part.Ref) == "" {
if caption := strings.TrimSpace(part.Caption); caption != "" { if caption := strings.TrimSpace(part.Caption); caption != "" {
if err := c.sendActivePush(chatID, chatType, caption); err != nil { if err := c.sendActivePush(chatID, chatType, caption); err != nil {
return err return nil, err
} }
} }
continue continue
@ -239,7 +239,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part) localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part)
if err != nil { 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() { func() {
@ -283,11 +283,11 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
} }
}() }()
if err != nil { if err != nil {
return err return nil, err
} }
} }
return nil return nil, nil
} }
func (c *WeComChannel) connectLoop() { func (c *WeComChannel) connectLoop() {

View file

@ -190,7 +190,7 @@ func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) {
return wecomTestAck(nil), nil return wecomTestAck(nil), nil
} }
if err := ch.Send(context.Background(), bus.OutboundMessage{ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom", Channel: "wecom",
ChatID: "chat-1", ChatID: "chat-1",
Content: "hello", Content: "hello",
@ -247,7 +247,7 @@ func TestSend_DoesNotSplitStreamReply(t *testing.T) {
} }
content := strings.Repeat("\u4e2d", 30000) content := strings.Repeat("\u4e2d", 30000)
if err := ch.Send(context.Background(), bus.OutboundMessage{ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom", Channel: "wecom",
ChatID: "chat-1", ChatID: "chat-1",
Content: content, Content: content,
@ -283,7 +283,7 @@ func TestSend_DoesNotSplitActivePush(t *testing.T) {
} }
content := strings.Repeat("a", 30000) content := strings.Repeat("a", 30000)
if err := ch.Send(context.Background(), bus.OutboundMessage{ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom", Channel: "wecom",
ChatID: "chat-1", ChatID: "chat-1",
Content: content, 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", Channel: "wecom",
ChatID: "chat-1", ChatID: "chat-1",
Parts: []bus.MediaPart{{ 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", Channel: "wecom",
ChatID: "chat-1", ChatID: "chat-1",
Parts: []bus.MediaPart{{ 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", Channel: "wecom",
ChatID: "chat-2", ChatID: "chat-2",
Parts: []bus.MediaPart{{ Parts: []bus.MediaPart{{

View file

@ -1097,12 +1097,12 @@ func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(),
} }
// SendMedia implements channels.MediaSender. // 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() { if !c.IsRunning() {
return basechannels.ErrNotRunning return nil, basechannels.ErrNotRunning
} }
if err := c.ensureSessionActive(); err != nil { if err := c.ensureSessionActive(); err != nil {
return err return nil, err
} }
contextToken := "" contextToken := ""
@ -1110,7 +1110,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
contextToken, _ = v.(string) contextToken, _ = v.(string)
} }
if contextToken == "" { if contextToken == "" {
return fmt.Errorf( return nil, fmt.Errorf(
"weixin send media: missing context token for chat %s: %w", "weixin send media: missing context token for chat %s: %w",
msg.ChatID, msg.ChatID,
basechannels.ErrSendFailed, basechannels.ErrSendFailed,
@ -1125,7 +1125,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
"ref": part.Ref, "ref": part.Ref,
"error": err.Error(), "error": err.Error(),
}) })
return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed) return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
} }
func() { func() {
if cleanup != nil { if cleanup != nil {
@ -1147,11 +1147,11 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
"error": err.Error(), "error": err.Error(),
}) })
if c.remainingPause() > 0 { 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
} }

View file

@ -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. // 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
if err := c.ensureSessionActive(); err != nil { if err := c.ensureSessionActive(); err != nil {
return err return nil, err
} }
if msg.Content == "" { 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. // 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{ logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{
"to_user_id": toUserID, "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 { 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(), "error": err.Error(),
}) })
if c.remainingPause() > 0 { 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
} }

View file

@ -104,15 +104,15 @@ func (c *WhatsAppChannel) Stop(ctx context.Context) error {
return nil 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
// Check ctx before acquiring lock // Check ctx before acquiring lock
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return nil, ctx.Err()
default: default:
} }
@ -120,7 +120,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
defer c.mu.Unlock() defer c.mu.Unlock()
if c.conn == nil { 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{ payload := map[string]any{
@ -131,17 +131,17 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
data, err := json.Marshal(payload) data, err := json.Marshal(payload)
if err != nil { 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)) _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
_ = c.conn.SetWriteDeadline(time.Time{}) _ = 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{}) _ = c.conn.SetWriteDeadline(time.Time{})
return nil return nil, nil
} }
func (c *WhatsAppChannel) listen() { func (c *WhatsAppChannel) listen() {

View file

@ -396,13 +396,13 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) 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() { if !c.IsRunning() {
return channels.ErrNotRunning return nil, channels.ErrNotRunning
} }
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return nil, ctx.Err()
default: default:
} }
@ -411,18 +411,18 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
c.mu.Unlock() c.mu.Unlock()
if client == nil || !client.IsConnected() { 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) // Detect unpaired state: the client is connected (to WhatsApp servers)
// but has not completed QR-login yet, so sending would fail. // but has not completed QR-login yet, so sending would fail.
if client.Store.ID == nil { 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) to, err := parseJID(msg.ChatID)
if err != nil { 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{ 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 { 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. // parseJID converts a chat ID (phone number or JID string) to types.JID.

View file

@ -636,13 +636,6 @@ func (c *ModelConfig) SetAPIKey(value string) {
} }
} }
type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
}
type ToolDiscoveryConfig struct { type ToolDiscoveryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"` TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`

View file

@ -1418,6 +1418,38 @@ func TestConfigLogLevelEmpty(t *testing.T) {
} }
} }
func TestResolveGatewayLogLevel(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
data := `{"version":1,"gateway":{"log_level":"debug"}}`
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
if got := ResolveGatewayLogLevel(cfgPath); got != "debug" {
t.Fatalf("ResolveGatewayLogLevel() = %q, want %q", got, "debug")
}
}
func TestResolveGatewayLogLevel_UsesEnvOverrideAndNormalizesInvalid(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
data := `{"version":1,"gateway":{"log_level":"debug"}}`
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
t.Setenv("PICOCLAW_LOG_LEVEL", "warning")
if got := ResolveGatewayLogLevel(cfgPath); got != "warn" {
t.Fatalf("ResolveGatewayLogLevel() with env override = %q, want %q", got, "warn")
}
t.Setenv("PICOCLAW_LOG_LEVEL", "garbage")
if got := ResolveGatewayLogLevel(cfgPath); got != DefaultGatewayLogLevel {
t.Fatalf("ResolveGatewayLogLevel() with invalid env override = %q, want %q", got, DefaultGatewayLogLevel)
}
}
func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json") cfgPath := filepath.Join(dir, "config.json")

View file

@ -347,7 +347,7 @@ func DefaultConfig() *Config {
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 18790, Port: 18790,
HotReload: false, HotReload: false,
LogLevel: "warn", LogLevel: DefaultGatewayLogLevel,
}, },
Tools: ToolsConfig{ Tools: ToolsConfig{
FilterSensitiveData: true, FilterSensitiveData: true,

72
pkg/config/gateway.go Normal file
View file

@ -0,0 +1,72 @@
package config
import (
"encoding/json"
"os"
"github.com/sipeed/picoclaw/pkg/logger"
)
const DefaultGatewayLogLevel = "warn"
type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
}
func canonicalGatewayLogLevel(level logger.LogLevel) string {
switch level {
case logger.DEBUG:
return "debug"
case logger.INFO:
return "info"
case logger.WARN:
return "warn"
case logger.ERROR:
return "error"
case logger.FATAL:
return "fatal"
default:
return DefaultGatewayLogLevel
}
}
func normalizeGatewayLogLevel(logLevel string) string {
if level, ok := logger.ParseLevel(logLevel); ok {
return canonicalGatewayLogLevel(level)
}
return DefaultGatewayLogLevel
}
// EffectiveGatewayLogLevel returns the normalized runtime log level from a loaded config.
// Invalid or empty values fall back to the package default.
func EffectiveGatewayLogLevel(cfg *Config) string {
if cfg == nil {
return DefaultGatewayLogLevel
}
return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
}
// ResolveGatewayLogLevel reads the configured gateway log level without triggering
// the full config loader, so startup code can apply logging before config load logs run.
// The PICOCLAW_LOG_LEVEL environment variable overrides the file value.
func ResolveGatewayLogLevel(path string) string {
cfg := struct {
Gateway GatewayConfig `json:"gateway"`
}{
Gateway: GatewayConfig{LogLevel: DefaultGatewayLogLevel},
}
data, err := os.ReadFile(path)
if err == nil {
_ = json.Unmarshal(data, &cfg)
}
if envLevel := os.Getenv("PICOCLAW_LOG_LEVEL"); envLevel != "" {
cfg.Gateway.LogLevel = envLevel
}
return normalizeGatewayLogLevel(cfg.Gateway.LogLevel)
}

View file

@ -98,6 +98,12 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
} }
defer logger.DisableFileLogging() defer logger.DisableFileLogging()
if debug {
logger.SetLevel(logger.DEBUG)
} else {
logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
}
cfg, err := config.LoadConfig(configPath) cfg, err := config.LoadConfig(configPath)
if err != nil { if err != nil {
logger.Fatalf("error loading config: %v", err) logger.Fatalf("error loading config: %v", err)
@ -109,11 +115,11 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
// Debug mode permanently overrides the config log level to DEBUG. // Debug mode permanently overrides the config log level to DEBUG.
if debug { if debug {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled") fmt.Println("🔍 Debug mode enabled")
} else { } else {
logger.SetLevelFromString(cfg.Gateway.LogLevel) effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg)
logger.Infof("Log level set to %q", cfg.Gateway.LogLevel) logger.SetLevelFromString(effectiveLogLevel)
logger.Infof("Log level set to %q", effectiveLogLevel)
} }
// Enforce singleton: write PID file with generated token. // Enforce singleton: write PID file with generated token.
@ -476,8 +482,9 @@ func handleConfigReload(
// Debug mode permanently overrides the config log level to DEBUG. // Debug mode permanently overrides the config log level to DEBUG.
if !debug { if !debug {
// Update log level last so that reload-related info/warn logs above are not suppressed. // Update log level last so that reload-related info/warn logs above are not suppressed.
logger.SetLevelFromString(newCfg.Gateway.LogLevel) effectiveLogLevel := config.EffectiveGatewayLogLevel(newCfg)
logger.Infof("Log level changing from current to %q", newCfg.Gateway.LogLevel) logger.SetLevelFromString(effectiveLogLevel)
logger.Infof("Log level changing from current to %q", effectiveLogLevel)
} }
return nil return nil

View file

@ -208,7 +208,10 @@ func (p *Provider) Chat(
if err != nil { if err != nil {
// Check for SSO token expiration errors and provide actionable guidance // Check for SSO token expiration errors and provide actionable guidance
if isSSOTokenError(err) { 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) return nil, fmt.Errorf("bedrock converse: %w", err)
} }

View file

@ -583,13 +583,17 @@ func TestIsSSOTokenError(t *testing.T) {
expected: true, expected: true,
}, },
{ {
name: "full SSO error message", 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"), err: fmt.Errorf(
"get identity: get credentials: failed to refresh cached credentials, refresh cached SSO token failed, unable to refresh SSO token",
),
expected: true, expected: true,
}, },
{ {
name: "SSO token file missing", 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"), 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, expected: true,
}, },
} }

View file

@ -17,6 +17,48 @@ import (
"github.com/sipeed/picoclaw/pkg/providers/bedrock" "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. // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
func createClaudeAuthProvider() (LLMProvider, error) { func createClaudeAuthProvider() (LLMProvider, error) {
cred, err := getCredential("anthropic") cred, err := getCredential("anthropic")
@ -154,13 +196,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
} }
return provider, modelID, nil return provider, modelID, nil
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "mimo": "coding-plan", "alibaba-coding", "qwen-coding", "mimo":
// All other OpenAI-compatible HTTP providers // 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) return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
} }
apiBase := cfg.APIBase 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. // getDefaultAPIBase returns the default API base URL for a given protocol.
func getDefaultAPIBase(protocol string) string { func getDefaultAPIBase(protocol string) string {
switch protocol { meta, ok := protocolMetaByName[protocol]
case "openai": if !ok {
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:
return "" return ""
} }
return meta.defaultAPIBase
} }

View file

@ -121,6 +121,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"vllm", "vllm"}, {"vllm", "vllm"},
{"deepseek", "deepseek"}, {"deepseek", "deepseek"},
{"ollama", "ollama"}, {"ollama", "ollama"},
{"lmstudio", "lmstudio"},
{"longcat", "longcat"}, {"longcat", "longcat"},
{"modelscope", "modelscope"}, {"modelscope", "modelscope"},
{"mimo", "mimo"}, {"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) { func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
cfg := &config.ModelConfig{ cfg := &config.ModelConfig{
ModelName: "test-litellm", 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) { func TestCreateProviderFromConfig_LongCat(t *testing.T) {
cfg := &config.ModelConfig{ cfg := &config.ModelConfig{
ModelName: "test-longcat", ModelName: "test-longcat",

View file

@ -41,9 +41,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
} }
session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{ session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
Model: model, Model: model,
OnPermissionRequest: copilot.PermissionHandler.ApproveAll, OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
Hooks: &copilot.SessionHooks{}, Hooks: &copilot.SessionHooks{},
}) })
if err != nil { if err != nil {
client.Stop() client.Stop()

View file

@ -42,6 +42,23 @@ type Option func(*Provider)
const defaultRequestTimeout = common.DefaultRequestTimeout 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 { func WithMaxTokensField(maxTokensField string) Option {
return func(p *Provider) { return func(p *Provider) {
p.maxTokensField = maxTokensField p.maxTokensField = maxTokensField
@ -397,13 +414,11 @@ func normalizeModel(model, apiBase string) string {
} }
prefix := strings.ToLower(before) prefix := strings.ToLower(before)
switch prefix { if _, ok := stripModelPrefixProviders[prefix]; ok {
case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
"openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita":
return after return after
default:
return model
} }
return model
} }
func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any { func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any {

View file

@ -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 var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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", input: "ollama/qwen2.5:14b",
wantModel: "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", name: "strips deepseek prefix",
input: "deepseek/deepseek-chat", 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" { if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" {
t.Fatalf("normalizeModel(deepseek) = %q, want %q", 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" { if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
} }

View file

@ -20,6 +20,14 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns) mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
} }
func (h *Handler) applyRuntimeLogLevel() {
if h.debug {
logger.SetLevel(logger.DEBUG)
return
}
logger.SetLevelFromString(config.ResolveGatewayLogLevel(h.configPath))
}
// handleGetConfig returns the complete system configuration. // handleGetConfig returns the complete system configuration.
// //
// GET /api/config // GET /api/config
@ -80,8 +88,6 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
logger.Infof("configuration updated successfully")
if err := config.SaveConfig(h.configPath, &cfg); err != nil { if err := config.SaveConfig(h.configPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return return
@ -89,6 +95,8 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
// Refresh cached pico token in case user changed it. // Refresh cached pico token in case user changed it.
refreshPicoToken(&cfg) refreshPicoToken(&cfg)
h.applyRuntimeLogLevel()
logger.Infof("configuration updated successfully")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
@ -133,7 +141,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return return
} }
existing, err := json.Marshal(cfg) existing, err := json.Marshal(cfg)
if err != nil { if err != nil {
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError) http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
@ -187,6 +194,8 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
// Refresh cached pico token in case user changed it. // Refresh cached pico token in case user changed it.
refreshPicoToken(&newCfg) refreshPicoToken(&newCfg)
h.applyRuntimeLogLevel()
logger.Infof("configuration updated successfully")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) json.NewEncoder(w).Encode(map[string]string{"status": "ok"})

View file

@ -9,8 +9,38 @@ import (
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
) )
func assertGatewayLogLevelApplied(t *testing.T, method, body string, want logger.LogLevel) {
t.Helper()
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
initialLevel := logger.GetLevel()
logger.SetLevel(logger.INFO)
t.Cleanup(func() {
logger.SetLevel(initialLevel)
})
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(method, "/api/config", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s /api/config status = %d, want %d, body=%s", method, rec.Code, http.StatusOK, rec.Body.String())
}
if got := logger.GetLevel(); got != want {
t.Fatalf("logger.GetLevel() = %v, want %v", got, want)
}
}
func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) { func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -251,6 +281,68 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
} }
} }
func TestHandleUpdateConfig_AppliesGatewayLogLevel(t *testing.T) {
assertGatewayLogLevelApplied(t, http.MethodPut, `{
"version": 1,
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "custom-default"
}
},
"gateway": {
"log_level": "error"
},
"model_list": [
{
"model_name": "custom-default",
"model": "openai/gpt-4o",
"api_keys": ["sk-default"]
}
]
}`, logger.ERROR)
}
func TestHandlePatchConfig_AppliesGatewayLogLevel(t *testing.T) {
assertGatewayLogLevelApplied(t, http.MethodPatch, `{
"gateway": {
"log_level": "debug"
}
}`, logger.DEBUG)
}
func TestHandlePatchConfig_PreservesDebugFlagOverride(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
initialLevel := logger.GetLevel()
logger.SetLevel(logger.INFO)
t.Cleanup(func() {
logger.SetLevel(initialLevel)
})
h := NewHandler(configPath)
h.SetDebug(true)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"gateway": {
"log_level": "error"
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := logger.GetLevel(); got != logger.DEBUG {
t.Fatalf("logger.GetLevel() = %v, want %v", got, logger.DEBUG)
}
}
func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) { func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()

View file

@ -59,6 +59,24 @@ func refreshPicoTokensLocked(configPath string) {
gateway.picoToken = cfg.Channels.Pico.Token.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)
}
func (h *Handler) gatewayCommandArgs() []string {
args := []string{"gateway", "-E"}
if h.debug {
args = append(args, "-d")
}
return args
}
const ( const (
protocolKey = "Sec-Websocket-Protocol" protocolKey = "Sec-Websocket-Protocol"
tokenPrefix = "token." tokenPrefix = "token."
@ -521,7 +539,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
execPath := utils.FindPicoclawBinary() execPath := utils.FindPicoclawBinary()
logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath)) logger.InfoC("gateway", fmt.Sprintf("Starting gateway process (%s)", execPath))
cmd = exec.Command(execPath, "gateway", "-E") cmd = exec.Command(execPath, h.gatewayCommandArgs()...)
cmd.Env = os.Environ() cmd.Env = os.Environ()
// Forward the launcher's config path via the environment variable that // Forward the launcher's config path via the environment variable that
// GetConfigPath() already reads, so the gateway sub-process uses the same // GetConfigPath() already reads, so the gateway sub-process uses the same

View file

@ -190,12 +190,20 @@ func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort in
func (h *Handler) picoWebUIAddr(r *http.Request) string { func (h *Handler) picoWebUIAddr(r *http.Request) string {
wsPort := h.serverPort wsPort := h.serverPort
if wsPort == 0 { if wsPort == 0 {
wsPort = 18800 // default web server port wsPort = 18800
} }
if fwdHost := forwardedHostFirst(r); fwdHost != "" { if fwdHost := forwardedHostFirst(r); fwdHost != "" {
return joinClientVisibleHostPort(r, fwdHost, wsPort) return joinClientVisibleHostPort(r, fwdHost, wsPort)
} }
host := requestHostName(r) 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)) return net.JoinHostPort(host, strconv.Itoa(wsPort))
} }

View file

@ -68,6 +68,7 @@ func resetGatewayTestState(t *testing.T) {
originalRestartGracePeriod := gatewayRestartGracePeriod originalRestartGracePeriod := gatewayRestartGracePeriod
originalRestartForceKillWindow := gatewayRestartForceKillWindow originalRestartForceKillWindow := gatewayRestartForceKillWindow
originalRestartPollInterval := gatewayRestartPollInterval originalRestartPollInterval := gatewayRestartPollInterval
t.Setenv("PICOCLAW_HOME", t.TempDir())
t.Cleanup(func() { t.Cleanup(func() {
gatewayHealthGet = originalHealthGet gatewayHealthGet = originalHealthGet
gatewayRestartGracePeriod = originalRestartGracePeriod gatewayRestartGracePeriod = originalRestartGracePeriod
@ -76,6 +77,8 @@ func resetGatewayTestState(t *testing.T) {
gateway.mu.Lock() gateway.mu.Lock()
gateway.cmd = nil gateway.cmd = nil
gateway.pidData = nil
gateway.owned = false
gateway.bootDefaultModel = "" gateway.bootDefaultModel = ""
gateway.bootConfigSignature = "" gateway.bootConfigSignature = ""
setGatewayRuntimeStatusLocked("stopped") setGatewayRuntimeStatusLocked("stopped")
@ -165,6 +168,17 @@ func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
} }
} }
func TestGatewayCommandArgsIncludesDebugFlagWhenEnabled(t *testing.T) {
h := NewHandler(filepath.Join(t.TempDir(), "config.json"))
h.SetDebug(true)
args := h.gatewayCommandArgs()
want := []string{"gateway", "-E", "-d"}
if strings.Join(args, " ") != strings.Join(want, " ") {
t.Fatalf("gatewayCommandArgs() = %v, want %v", args, want)
}
}
func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) { func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()

View file

@ -10,10 +10,22 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
) )
const modelProbeTimeout = 800 * time.Millisecond const modelProbeTimeout = 800 * time.Millisecond
const (
modelStatusAvailable = "available"
modelStatusUnconfigured = "unconfigured"
modelStatusUnreachable = "unreachable"
)
type modelConfigurationSummary struct {
Available bool
Status string
}
var ( var (
probeTCPServiceFunc = probeTCPService probeTCPServiceFunc = probeTCPService
probeOllamaModelFunc = probeOllamaModel probeOllamaModelFunc = probeOllamaModel
@ -42,16 +54,17 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
return apiKey != "" return apiKey != ""
} }
// isModelConfigured reports whether a model is currently available to use. func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary {
// Local models must be reachable; remote/API-key models only need saved config.
func isModelConfigured(m *config.ModelConfig) bool {
if !hasModelConfiguration(m) { if !hasModelConfiguration(m) {
return false return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
} }
if requiresRuntimeProbe(m) { if requiresRuntimeProbe(m) {
return probeLocalModelAvailability(m) if probeLocalModelAvailability(m) {
return modelConfigurationSummary{Available: true, Status: modelStatusAvailable}
}
return modelConfigurationSummary{Available: false, Status: modelStatusUnreachable}
} }
return true return modelConfigurationSummary{Available: true, Status: modelStatusAvailable}
} }
func requiresRuntimeProbe(m *config.ModelConfig) bool { func requiresRuntimeProbe(m *config.ModelConfig) bool {
@ -60,10 +73,14 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
return true return true
} }
switch modelProtocol(m.Model) { protocol := modelProtocol(m.Model)
switch protocol {
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
return true return true
case "ollama", "vllm": }
if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
apiBase := strings.TrimSpace(m.APIBase) apiBase := strings.TrimSpace(m.APIBase)
return apiBase == "" || hasLocalAPIBase(apiBase) return apiBase == "" || hasLocalAPIBase(apiBase)
} }
@ -81,7 +98,7 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool {
switch protocol { switch protocol {
case "ollama": case "ollama":
return probeOllamaModelFunc(apiBase, modelID) return probeOllamaModelFunc(apiBase, modelID)
case "vllm": case "vllm", "lmstudio":
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey()) return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
case "github-copilot", "copilot": case "github-copilot", "copilot":
return probeTCPServiceFunc(apiBase) return probeTCPServiceFunc(apiBase)
@ -100,11 +117,12 @@ func modelProbeAPIBase(m *config.ModelConfig) string {
return normalizeModelProbeAPIBase(apiBase) return normalizeModelProbeAPIBase(apiBase)
} }
switch modelProtocol(m.Model) { protocol := modelProtocol(m.Model)
case "ollama": if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
return "http://localhost:11434/v1" return providers.DefaultAPIBaseForProtocol(protocol)
case "vllm": }
return "http://localhost:8000/v1"
switch protocol {
case "github-copilot", "copilot": case "github-copilot", "copilot":
return "localhost:4321" return "localhost:4321"
default: default:

View file

@ -35,3 +35,53 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T
t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured") 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")
}
}

View file

@ -40,10 +40,11 @@ type modelResponse struct {
ThinkingLevel string `json:"thinking_level,omitempty"` ThinkingLevel string `json:"thinking_level,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"` ExtraBody map[string]any `json:"extra_body,omitempty"`
// Meta // Meta
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Configured bool `json:"configured"` Available bool `json:"available"`
IsDefault bool `json:"is_default"` Status string `json:"status"`
IsVirtual bool `json:"is_virtual"` IsDefault bool `json:"is_default"`
IsVirtual bool `json:"is_virtual"`
} }
// handleListModels returns all model_list entries with masked API keys. // handleListModels returns all model_list entries with masked API keys.
@ -57,14 +58,14 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
} }
defaultModel := cfg.Agents.Defaults.GetModelName() defaultModel := cfg.Agents.Defaults.GetModelName()
configured := make([]bool, len(cfg.ModelList)) modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(len(cfg.ModelList)) wg.Add(len(cfg.ModelList))
for i, m := range cfg.ModelList { for i, m := range cfg.ModelList {
go func(i int, m *config.ModelConfig) { go func(i int, m *config.ModelConfig) {
defer wg.Done() defer wg.Done()
configured[i] = isModelConfigured(m) modelStatuses[i] = modelConfigurationStatus(m)
}(i, m) }(i, m)
} }
wg.Wait() wg.Wait()
@ -87,7 +88,8 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
ThinkingLevel: m.ThinkingLevel, ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody, ExtraBody: m.ExtraBody,
Enabled: m.Enabled, Enabled: m.Enabled,
Configured: configured[i], Available: modelStatuses[i].Available,
Status: modelStatuses[i].Status,
IsDefault: m.ModelName == defaultModel, IsDefault: m.ModelName == defaultModel,
IsVirtual: m.IsVirtual(), IsVirtual: m.IsVirtual(),
}) })

View file

@ -27,7 +27,7 @@ func resetModelProbeHooks(t *testing.T) {
}) })
} }
func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) { func TestHandleListModels_AvailabilityUsesRuntimeProbesForLocalModels(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
resetOAuthHooks(t) resetOAuthHooks(t)
@ -113,25 +113,42 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
t.Fatalf("Unmarshal() error = %v", err) t.Fatalf("Unmarshal() error = %v", err)
} }
got := make(map[string]bool, len(resp.Models)) gotAvailable := make(map[string]bool, len(resp.Models))
gotStatus := make(map[string]string, len(resp.Models))
for _, model := range resp.Models { for _, model := range resp.Models {
got[model.ModelName] = model.Configured gotAvailable[model.ModelName] = model.Available
gotStatus[model.ModelName] = model.Status
} }
if got["openai-oauth"] { if gotAvailable["openai-oauth"] {
t.Fatalf("openai oauth model configured = true, want false without stored credential") t.Fatalf("openai oauth model available = true, want false without stored credential")
} }
if !got["vllm-local"] { if !gotAvailable["vllm-local"] {
t.Fatalf("vllm local model configured = false, want true when local probe succeeds") t.Fatalf("vllm local model available = false, want true when local probe succeeds")
} }
if !got["ollama-default"] { if !gotAvailable["ollama-default"] {
t.Fatalf("ollama default model configured = false, want true when default local probe succeeds") t.Fatalf("ollama default model available = false, want true when default local probe succeeds")
} }
if !got["vllm-remote"] { if !gotAvailable["vllm-remote"] {
t.Fatalf("remote vllm model configured = false, want true with api_key") t.Fatalf("remote vllm model available = false, want true with api_key")
} }
if !got["copilot-gpt-5.4"] { if !gotAvailable["copilot-gpt-5.4"] {
t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds") t.Fatalf("copilot model available = false, want true when local bridge probe succeeds")
}
if gotStatus["openai-oauth"] != modelStatusUnconfigured {
t.Fatalf("openai oauth model status = %q, want %q", gotStatus["openai-oauth"], modelStatusUnconfigured)
}
if gotStatus["vllm-local"] != modelStatusAvailable {
t.Fatalf("vllm local model status = %q, want %q", gotStatus["vllm-local"], modelStatusAvailable)
}
if gotStatus["ollama-default"] != modelStatusAvailable {
t.Fatalf("ollama default model status = %q, want %q", gotStatus["ollama-default"], modelStatusAvailable)
}
if gotStatus["vllm-remote"] != modelStatusAvailable {
t.Fatalf("remote vllm model status = %q, want %q", gotStatus["vllm-remote"], modelStatusAvailable)
}
if gotStatus["copilot-gpt-5.4"] != modelStatusAvailable {
t.Fatalf("copilot model status = %q, want %q", gotStatus["copilot-gpt-5.4"], modelStatusAvailable)
} }
if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" { if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model|" {
t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes) t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes)
@ -144,7 +161,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
} }
} }
func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) { func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
resetOAuthHooks(t) resetOAuthHooks(t)
@ -193,8 +210,8 @@ func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing
if len(resp.Models) != 1 { if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models)) t.Fatalf("len(models) = %d, want 1", len(resp.Models))
} }
if !resp.Models[0].Configured { if !resp.Models[0].Available {
t.Fatalf("oauth model configured = false, want true with stored credential") t.Fatalf("oauth model available = false, want true with stored credential")
} }
} }
@ -306,14 +323,71 @@ func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) {
if len(resp.Models) != 1 { if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models)) t.Fatalf("len(models) = %d, want 1", len(resp.Models))
} }
if !resp.Models[0].Configured { if !resp.Models[0].Available {
t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization") t.Fatal("wildcard-bound local model available = false, want true after probe host normalization")
} }
if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" { if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|") t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
} }
} }
func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
resetModelProbeHooks(t)
probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
return false
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{{
ModelName: "vllm-local-down",
Model: "vllm/custom-model",
APIBase: "http://127.0.0.1:8000/v1",
APIKeys: config.SimpleSecureStrings("test-key"),
}}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Models []modelResponse `json:"models"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
}
if resp.Models[0].Available {
t.Fatal("unreachable local model available = true, want false")
}
if resp.Models[0].Status != modelStatusUnreachable {
t.Fatalf("unreachable local model status = %q, want %q", resp.Models[0].Status, modelStatusUnreachable)
}
if resp.Models[0].APIKey == "" {
t.Fatal("masked API key preview should still be returned when API key is configured")
}
}
func TestHandleAddModel_PersistsAPIKey(t *testing.T) { func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()

View file

@ -56,6 +56,7 @@ func (h *Handler) createWsProxy(origProtocol string, token string) *httputil.Rev
func (h *Handler) handleWebSocketProxy() http.HandlerFunc { func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock() gateway.mu.Lock()
ensurePicoTokenCachedLocked(h.configPath)
gatewayAvailable := gateway.pidData != nil gatewayAvailable := gateway.pidData != nil
gateway.mu.Unlock() gateway.mu.Unlock()

View file

@ -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 { func mustGatewayTestPort(t *testing.T, rawURL string) int {
t.Helper() t.Helper()

View file

@ -14,6 +14,7 @@ type Handler struct {
serverPublic bool serverPublic bool
serverPublicExplicit bool serverPublicExplicit bool
serverCIDRs []string serverCIDRs []string
debug bool
oauthMu sync.Mutex oauthMu sync.Mutex
oauthFlows map[string]*oauthFlow oauthFlows map[string]*oauthFlow
oauthState map[string]string oauthState map[string]string
@ -43,6 +44,10 @@ func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, a
h.serverCIDRs = append([]string(nil), allowedCIDRs...) h.serverCIDRs = append([]string(nil), allowedCIDRs...)
} }
func (h *Handler) SetDebug(debug bool) {
h.debug = debug
}
// RegisterRoutes binds all API endpoint handlers to the ServeMux. // RegisterRoutes binds all API endpoint handlers to the ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) { func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Config CRUD // Config CRUD

View file

@ -90,6 +90,9 @@ func (h *Handler) resolveLaunchCommand() (string, []string, error) {
} }
args := []string{"-no-browser"} args := []string{"-no-browser"}
if h.debug {
args = append(args, "-d")
}
if h.configPath != "" { if h.configPath != "" {
args = append(args, h.configPath) args = append(args, h.configPath)
} }

View file

@ -45,6 +45,29 @@ func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) {
} }
} }
func TestResolveLaunchCommandIncludesDebugFlagWhenEnabled(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
h.SetDebug(true)
_, args, err := h.resolveLaunchCommand()
if err != nil {
t.Fatalf("resolveLaunchCommand() error = %v", err)
}
if len(args) != 3 {
t.Fatalf("args len = %d, want 3 (got %v)", len(args), args)
}
if args[0] != "-no-browser" {
t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser")
}
if args[1] != "-d" {
t.Fatalf("args[1] = %q, want %q", args[1], "-d")
}
if args[2] != configPath {
t.Fatalf("args[2] = %q, want %q", args[2], configPath)
}
}
func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) { func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) {
plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"}) plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"})
if !strings.Contains(plist, "<key>RunAtLoad</key>") { if !strings.Contains(plist, "<key>RunAtLoad</key>") {

View file

@ -55,6 +55,10 @@ var (
noBrowser *bool noBrowser *bool
) )
func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
return !enableConsole || debug
}
func main() { func main() {
port := flag.String("port", "18800", "Port to listen on") port := flag.String("port", "18800", "Port to listen on")
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
@ -62,21 +66,30 @@ func main() {
lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale") lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale")
console := flag.Bool("console", false, "Console mode, no GUI") console := flag.Bool("console", false, "Console mode, no GUI")
var debug bool
flag.BoolVar(&debug, "d", false, "Enable debug logging")
flag.BoolVar(&debug, "debug", false, "Enable debug logging")
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(os.Stderr, "%s Launcher - A web-based configuration editor\n\n", appName) fmt.Fprintf(os.Stderr, "%s Launcher - Web console and gateway manager\n\n", appName)
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Arguments:\n") fmt.Fprintf(os.Stderr, "Arguments:\n")
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n") fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
fmt.Fprintf(os.Stderr, "Options:\n") fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults() flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExamples:\n") fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0]) fmt.Fprintf(os.Stderr, " Use default config path in GUI mode\n")
fmt.Fprintf(os.Stderr, " %s ./config.json\n", os.Args[0])
fmt.Fprintf(os.Stderr, " Specify a config file\n")
fmt.Fprintf( fmt.Fprintf(
os.Stderr, os.Stderr,
" %s -public ./config.json Allow access from other devices on the network\n", " %s -public ./config.json\n",
os.Args[0], os.Args[0],
) )
fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\n")
fmt.Fprintf(os.Stderr, " %s -console -d ./config.json\n", os.Args[0])
fmt.Fprintf(os.Stderr, " Run in the terminal with debug logs enabled\n")
} }
flag.Parse() flag.Parse()
@ -90,12 +103,13 @@ func main() {
} }
defer panicFunc() defer panicFunc()
// By default, detect terminal to decide console log behavior
// If -console-logs flag is explicitly set, it overrides the detection
enableConsole := *console enableConsole := *console
if !enableConsole { fileLoggingEnabled := shouldEnableLauncherFileLogging(enableConsole, debug)
// Disable console logging by setting level to Fatal (no output) if fileLoggingEnabled {
logger.SetConsoleLevel(logger.FATAL) // GUI mode writes launcher logs to file. Debug mode keeps file logging enabled in console mode too.
if !debug {
logger.DisableConsole()
}
f := filepath.Join(picoHome, logPath, logFile) f := filepath.Join(picoHome, logPath, logFile)
if err = logger.EnableFileLogging(f); err != nil { if err = logger.EnableFileLogging(f); err != nil {
@ -103,9 +117,9 @@ func main() {
} }
defer logger.DisableFileLogging() defer logger.DisableFileLogging()
} }
if debug {
logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion)) logger.SetLevel(logger.DEBUG)
logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome)) }
// Set language from command line or auto-detect // Set language from command line or auto-detect
if *lang != "" { if *lang != "" {
@ -126,6 +140,25 @@ func main() {
if err != nil { if err != nil {
logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err) logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err)
} }
if !debug {
logger.SetLevelFromString(config.ResolveGatewayLogLevel(absPath))
}
logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion))
logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome))
if debug {
logger.InfoC("web", "Debug mode enabled")
logger.DebugC(
"web",
fmt.Sprintf(
"Launcher flags: console=%t public=%t no_browser=%t config=%s",
enableConsole,
*public,
*noBrowser,
absPath,
),
)
}
var explicitPort bool var explicitPort bool
var explicitPublic bool var explicitPublic bool
@ -181,7 +214,7 @@ func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
tokenLogFileAbs := "" tokenLogFileAbs := ""
if !enableConsole { if fileLoggingEnabled {
tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile) tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
} }
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
@ -197,6 +230,7 @@ func main() {
// API Routes (e.g. /api/status) // API Routes (e.g. /api/status)
apiHandler = api.NewHandler(absPath) apiHandler = api.NewHandler(absPath)
apiHandler.SetDebug(debug)
if _, err = apiHandler.EnsurePicoChannel(""); err != nil { if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err)) logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
} }
@ -226,7 +260,7 @@ func main() {
) )
// Print startup banner and token (console mode only). // Print startup banner and token (console mode only).
if enableConsole { if enableConsole || debug {
fmt.Print(utils.Banner) fmt.Print(utils.Banner)
fmt.Println() fmt.Println()
fmt.Println(" Open the following URL in your browser:") fmt.Println(" Open the following URL in your browser:")

31
web/backend/main_test.go Normal file
View file

@ -0,0 +1,31 @@
package main
import "testing"
func TestShouldEnableLauncherFileLogging(t *testing.T) {
tests := []struct {
name string
enableConsole bool
debug bool
want bool
}{
{name: "gui mode", enableConsole: false, debug: false, want: true},
{name: "console mode", enableConsole: true, debug: false, want: false},
{name: "debug gui mode", enableConsole: false, debug: true, want: true},
{name: "debug console mode", enableConsole: true, debug: true, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldEnableLauncherFileLogging(tt.enableConsole, tt.debug); got != tt.want {
t.Fatalf(
"shouldEnableLauncherFileLogging(%t, %t) = %t, want %t",
tt.enableConsole,
tt.debug,
got,
tt.want,
)
}
})
}
}

View file

@ -20,7 +20,8 @@ export interface ModelInfo {
thinking_level?: string thinking_level?: string
extra_body?: Record<string, unknown> extra_body?: Record<string, unknown>
// Meta // Meta
configured: boolean available: boolean
status: "available" | "unconfigured" | "unreachable"
is_default: boolean is_default: boolean
is_virtual: boolean is_virtual: boolean
} }

View file

@ -163,6 +163,7 @@ export function AppHeader() {
variant="destructive" variant="destructive"
size="icon-sm" size="icon-sm"
className="size-8" className="size-8"
data-tour="gateway-button"
onClick={handleGatewayToggle} onClick={handleGatewayToggle}
disabled={gwLoading} disabled={gwLoading}
aria-label={t("header.gateway.action.stop")} aria-label={t("header.gateway.action.stop")}
@ -178,6 +179,7 @@ export function AppHeader() {
isStarting || isRestarting || isStopping ? "secondary" : "default" isStarting || isRestarting || isStopping ? "secondary" : "default"
} }
size="sm" size="sm"
data-tour="gateway-button"
className={`h-8 gap-2 px-3 ${ className={`h-8 gap-2 px-3 ${
isStopped ? "bg-green-500 text-white hover:bg-green-600" : "" isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
}`} }`}
@ -209,7 +211,13 @@ export function AppHeader() {
/> />
{/* Docs Link */} {/* Docs Link */}
<Button variant="ghost" size="icon" className="size-8" asChild> <Button
variant="ghost"
size="icon"
className="size-8"
data-tour="docs-button"
asChild
>
<a href="https://docs.picoclaw.io" target="_blank" rel="noreferrer"> <a href="https://docs.picoclaw.io" target="_blank" rel="noreferrer">
<IconBook className="size-4.5" /> <IconBook className="size-4.5" />
</a> </a>

View file

@ -3,6 +3,7 @@ import { Toaster } from "sonner"
import { AppHeader } from "@/components/app-header" import { AppHeader } from "@/components/app-header"
import { AppSidebar } from "@/components/app-sidebar" import { AppSidebar } from "@/components/app-sidebar"
import { TourGuide } from "@/components/tour/tour-guide"
import { SidebarProvider } from "@/components/ui/sidebar" import { SidebarProvider } from "@/components/ui/sidebar"
import { TooltipProvider } from "@/components/ui/tooltip" import { TooltipProvider } from "@/components/ui/tooltip"
@ -21,6 +22,7 @@ export function AppLayout({ children }: { children: ReactNode }) {
</div> </div>
</div> </div>
<Toaster position="bottom-center" /> <Toaster position="bottom-center" />
<TourGuide />
</SidebarProvider> </SidebarProvider>
</TooltipProvider> </TooltipProvider>
) )

View file

@ -199,6 +199,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuButton <SidebarMenuButton
asChild asChild
isActive={isActive} isActive={isActive}
data-tour={item.url === "/models" ? "models-nav" : undefined}
className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`} className={`h-9 px-3 ${isActive ? "bg-accent/80 text-foreground font-medium" : "text-muted-foreground hover:bg-muted/60"}`}
> >
<Link to={item.url}> <Link to={item.url}>

View file

@ -10,19 +10,19 @@ import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
interface ChatEmptyStateProps { interface ChatEmptyStateProps {
hasConfiguredModels: boolean hasAvailableModels: boolean
defaultModelName: string defaultModelName: string
isConnected: boolean isConnected: boolean
} }
export function ChatEmptyState({ export function ChatEmptyState({
hasConfiguredModels, hasAvailableModels,
defaultModelName, defaultModelName,
isConnected, isConnected,
}: ChatEmptyStateProps) { }: ChatEmptyStateProps) {
const { t } = useTranslation() const { t } = useTranslation()
if (!hasConfiguredModels) { if (!hasAvailableModels) {
return ( return (
<div className="flex flex-col items-center justify-center py-20 opacity-70"> <div className="flex flex-col items-center justify-center py-20 opacity-70">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500"> <div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-500/10 text-amber-500">

View file

@ -39,7 +39,7 @@ export function ChatPage() {
const { const {
defaultModelName, defaultModelName,
hasConfiguredModels, hasAvailableModels,
apiKeyModels, apiKeyModels,
oauthModels, oauthModels,
localModels, localModels,
@ -94,7 +94,7 @@ export function ChatPage() {
hasScrolled ? "shadow-sm" : "shadow-none" hasScrolled ? "shadow-sm" : "shadow-none"
}`} }`}
titleExtra={ titleExtra={
hasConfiguredModels && ( hasAvailableModels && (
<ModelSelector <ModelSelector
defaultModelName={defaultModelName} defaultModelName={defaultModelName}
apiKeyModels={apiKeyModels} apiKeyModels={apiKeyModels}
@ -140,7 +140,7 @@ export function ChatPage() {
<div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8"> <div className="mx-auto flex w-full max-w-250 flex-col gap-8 pb-8">
{messages.length === 0 && !isTyping && ( {messages.length === 0 && !isTyping && (
<ChatEmptyState <ChatEmptyState
hasConfiguredModels={hasConfiguredModels} hasAvailableModels={hasAvailableModels}
defaultModelName={defaultModelName} defaultModelName={defaultModelName}
isConnected={isGatewayRunning} isConnected={isGatewayRunning}
/> />

View file

@ -0,0 +1,102 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { type AppConfig, getAppConfig, patchAppConfig } from "@/api/channels"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { refreshGatewayState } from "@/store/gateway"
const LOG_LEVEL_OPTIONS = ["debug", "info", "warn", "error", "fatal"] as const
type GatewayLogLevel = (typeof LOG_LEVEL_OPTIONS)[number]
const LOG_LEVEL_LABELS: Record<GatewayLogLevel, string> = {
debug: "Debug",
info: "Info",
warn: "Warn",
error: "Error",
fatal: "Fatal",
}
function getGatewayLogLevel(config: AppConfig | undefined): GatewayLogLevel {
const gateway = config?.gateway
if (typeof gateway === "object" && gateway !== null) {
const logLevel = (gateway as Record<string, unknown>).log_level
if (
typeof logLevel === "string" &&
LOG_LEVEL_OPTIONS.includes(logLevel as GatewayLogLevel)
) {
return logLevel as GatewayLogLevel
}
}
return "warn"
}
export function LogLevelSelect() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [logLevel, setLogLevel] = useState<GatewayLogLevel>("warn")
const [savingLogLevel, setSavingLogLevel] = useState(false)
const { data: configData } = useQuery({
queryKey: ["config"],
queryFn: getAppConfig,
})
useEffect(() => {
setLogLevel(getGatewayLogLevel(configData))
}, [configData])
const handleLogLevelChange = async (nextValue: string) => {
const nextLevel = nextValue as GatewayLogLevel
const previousLevel = logLevel
setLogLevel(nextLevel)
setSavingLogLevel(true)
try {
await patchAppConfig({
gateway: {
log_level: nextLevel,
},
})
await queryClient.invalidateQueries({ queryKey: ["config"] })
await refreshGatewayState({ force: true })
} catch (error) {
setLogLevel(previousLevel)
toast.error(
error instanceof Error
? error.message
: t("pages.logs.log_level_error"),
)
} finally {
setSavingLogLevel(false)
}
}
return (
<div className="flex items-center gap-2">
<Select
value={logLevel}
onValueChange={handleLogLevelChange}
disabled={savingLogLevel}
>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{LOG_LEVEL_OPTIONS.map((level) => (
<SelectItem key={level} value={level}>
{LOG_LEVEL_LABELS[level]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View file

@ -1,6 +1,7 @@
import { IconTrash } from "@tabler/icons-react" import { IconTrash } from "@tabler/icons-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { LogLevelSelect } from "@/components/logs/log-level-select"
import { LogsPanel } from "@/components/logs/logs-panel" import { LogsPanel } from "@/components/logs/logs-panel"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -17,15 +18,19 @@ export function LogsPage() {
<PageHeader <PageHeader
title={t("navigation.logs")} title={t("navigation.logs")}
children={ children={
<Button <>
variant="outline" <LogLevelSelect />
size="sm"
onClick={clearLogs} <Button
disabled={logs.length === 0 || clearing} variant="outline"
> size="sm"
<IconTrash className="size-4" /> onClick={clearLogs}
{t("pages.logs.clear")} disabled={logs.length === 0 || clearing}
</Button> >
<IconTrash className="size-4" />
{t("pages.logs.clear")}
</Button>
</>
} }
/> />

View file

@ -133,9 +133,10 @@ export function EditModelSheet({
} }
const isOAuth = model?.auth_method === "oauth" const isOAuth = model?.auth_method === "oauth"
const apiKeyPlaceholder = model?.configured const hasSavedAPIKey = Boolean(model?.api_key)
const apiKeyPlaceholder = hasSavedAPIKey
? maskedSecretPlaceholder( ? maskedSecretPlaceholder(
model.api_key, model?.api_key ?? "",
t("models.field.apiKeyPlaceholderSet"), t("models.field.apiKeyPlaceholderSet"),
) )
: t("models.field.apiKeyPlaceholder") : t("models.field.apiKeyPlaceholder")
@ -161,7 +162,7 @@ export function EditModelSheet({
<Field <Field
label={t("models.field.apiKey")} label={t("models.field.apiKey")}
hint={ hint={
model?.configured ? t("models.edit.apiKeyHint") : undefined hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined
} }
> >
<KeyInput <KeyInput

View file

@ -28,14 +28,16 @@ export function ModelCard({
}: ModelCardProps) { }: ModelCardProps) {
const { t } = useTranslation() const { t } = useTranslation()
const isOAuth = model.auth_method === "oauth" const isOAuth = model.auth_method === "oauth"
const status = model.status
const statusLabel = t(`models.status.${status}`)
const canSetDefault = const canSetDefault =
model.configured && !model.is_default && !model.is_virtual model.available && !model.is_default && !model.is_virtual
return ( return (
<div <div
className={[ className={[
"group/card hover:bg-muted/30 relative flex w-full max-w-[36rem] flex-col gap-3 justify-self-start rounded-xl border p-4 transition-colors hover:shadow-xs", "group/card hover:bg-muted/30 relative flex w-full max-w-[36rem] flex-col gap-3 justify-self-start rounded-xl border p-4 transition-colors hover:shadow-xs",
model.configured model.available
? "border-border/60 bg-card" ? "border-border/60 bg-card"
: "border-border/50 bg-card/60", : "border-border/50 bg-card/60",
].join(" ")} ].join(" ")}
@ -47,15 +49,13 @@ export function ModelCard({
"mt-0.5 h-2 w-2 shrink-0 rounded-full", "mt-0.5 h-2 w-2 shrink-0 rounded-full",
model.is_default model.is_default
? "bg-green-400 shadow-[0_0_0_2px_rgba(74,222,128,0.35)]" ? "bg-green-400 shadow-[0_0_0_2px_rgba(74,222,128,0.35)]"
: model.configured : status === "available"
? "bg-green-500" ? "bg-green-500"
: status === "unreachable"
? "bg-amber-500"
: "bg-muted-foreground/25", : "bg-muted-foreground/25",
].join(" ")} ].join(" ")}
title={ title={statusLabel}
model.configured
? t("models.status.configured")
: t("models.status.unconfigured")
}
/> />
<span className="text-foreground truncate text-sm font-semibold"> <span className="text-foreground truncate text-sm font-semibold">
{model.model_name} {model.model_name}
@ -127,14 +127,14 @@ export function ModelCard({
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium"> <span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium">
OAuth OAuth
</span> </span>
) : model.configured && model.api_key ? ( ) : status === "available" && model.api_key ? (
<span className="text-muted-foreground/70 flex items-center gap-1 font-mono text-[11px]"> <span className="text-muted-foreground/70 flex items-center gap-1 font-mono text-[11px]">
<IconKey className="size-3" /> <IconKey className="size-3" />
{model.api_key} {model.api_key}
</span> </span>
) : ( ) : (
<span className="text-muted-foreground/50 text-[11px]"> <span className="text-muted-foreground/50 text-[11px]">
{t("models.status.unconfigured")} {statusLabel}
</span> </span>
)} )}
</div> </div>

View file

@ -40,7 +40,7 @@ interface ProviderGroup {
label: string label: string
models: ModelInfo[] models: ModelInfo[]
hasDefault: boolean hasDefault: boolean
configuredCount: number availableCount: number
} }
export function ModelsPage() { export function ModelsPage() {
@ -62,8 +62,8 @@ export function ModelsPage() {
const sorted = [...data.models].sort((a, b) => { const sorted = [...data.models].sort((a, b) => {
if (a.is_default && !b.is_default) return -1 if (a.is_default && !b.is_default) return -1
if (!a.is_default && b.is_default) return 1 if (!a.is_default && b.is_default) return 1
if (a.configured && !b.configured) return -1 if (a.available && !b.available) return -1
if (!a.configured && b.configured) return 1 if (!a.available && b.available) return 1
return a.model_name.localeCompare(b.model_name) return a.model_name.localeCompare(b.model_name)
}) })
setModels(sorted) setModels(sorted)
@ -107,23 +107,23 @@ export function ModelsPage() {
const providerGroups: ProviderGroup[] = Object.entries(grouped) const providerGroups: ProviderGroup[] = Object.entries(grouped)
.map(([key, group]) => { .map(([key, group]) => {
const configuredCount = group.models.filter( const availableCount = group.models.filter(
(model) => model.configured, (model) => model.available,
).length ).length
return { return {
key, key,
label: group.label, label: group.label,
models: group.models, models: group.models,
hasDefault: group.models.some((model) => model.is_default), hasDefault: group.models.some((model) => model.is_default),
configuredCount, availableCount,
} }
}) })
.sort((a, b) => { .sort((a, b) => {
if (a.hasDefault && !b.hasDefault) return -1 if (a.hasDefault && !b.hasDefault) return -1
if (!a.hasDefault && b.hasDefault) return 1 if (!a.hasDefault && b.hasDefault) return 1
if (a.configuredCount !== b.configuredCount) { if (a.availableCount !== b.availableCount) {
return b.configuredCount - a.configuredCount return b.availableCount - a.availableCount
} }
const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER

View file

@ -0,0 +1,242 @@
import {
IconBook,
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react"
import { useAtom } from "jotai"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import {
tourAtom,
tourCurrentStepAtom,
tourIsActiveAtom,
type TourStep,
useTourActions,
} from "@/store/tour"
import { cn } from "@/lib/utils"
interface TourStepConfig {
title: string
description: string
targetSelector?: string
position: "top" | "bottom" | "left" | "right"
icon?: React.ReactNode
offsetY?: number
}
export function TourGuide() {
const { t } = useTranslation()
const [tourState] = useAtom(tourAtom)
const [, setCurrentStep] = useAtom(tourCurrentStepAtom)
const [, setIsActive] = useAtom(tourIsActiveAtom)
const { goToNextStep, goToPrevStep } = useTourActions()
if (!tourState.isActive || tourState.currentStep === "completed") {
return null
}
const steps: Record<TourStep, TourStepConfig> = {
welcome: {
title: t("tour.welcome.title"),
description: t("tour.welcome.description"),
position: "bottom",
},
models: {
title: t("tour.models.title"),
description: t("tour.models.description"),
targetSelector: "[data-tour='models-nav']",
position: "right",
},
gateway: {
title: t("tour.gateway.title"),
description: t("tour.gateway.description"),
targetSelector: "[data-tour='gateway-button']",
position: "left",
offsetY: 60,
},
docs: {
title: t("tour.docs.title"),
description: t("tour.docs.description"),
targetSelector: "[data-tour='docs-button']",
position: "left",
icon: <IconBook className="size-4" />,
offsetY: 60,
},
completed: {
title: "",
description: "",
position: "bottom",
},
}
const currentConfig = steps[tourState.currentStep]
const stepOrder: TourStep[] = [
"welcome",
"models",
"gateway",
"docs",
"completed",
]
const currentStepIndex = stepOrder.indexOf(tourState.currentStep)
const totalSteps = stepOrder.length - 1
const handleNext = () => {
const nextStep = goToNextStep(tourState.currentStep)
setCurrentStep(nextStep)
if (nextStep === "completed") {
setIsActive(false)
}
}
const handlePrev = () => {
const prevStep = goToPrevStep(tourState.currentStep)
setCurrentStep(prevStep)
}
const handleSkip = () => {
setCurrentStep("completed")
setIsActive(false)
}
const getTargetElement = () => {
if (!currentConfig.targetSelector) return null
return document.querySelector(currentConfig.targetSelector)
}
const targetElement = getTargetElement()
const getPopoverPosition = () => {
if (!targetElement) {
return {
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}
}
const rect = targetElement.getBoundingClientRect()
const offset = 12
const offsetY = currentConfig.offsetY ?? 0
switch (currentConfig.position) {
case "top":
return {
top: rect.top - offset,
left: rect.left + rect.width / 2,
transform: "translate(-50%, -100%)",
}
case "bottom":
return {
top: rect.bottom + offset,
left: rect.left + rect.width / 2,
transform: "translateX(-50%)",
}
case "left":
return {
top: rect.top + rect.height / 2 + offsetY,
left: rect.left - offset,
transform: "translate(-100%, -50%)",
}
case "right":
return {
top: rect.top + rect.height / 2 + offsetY,
left: rect.right + offset,
transform: "translateY(-50%)",
}
default:
return {
top: rect.bottom + offset,
left: rect.left + rect.width / 2,
transform: "translateX(-50%)",
}
}
}
const position = getPopoverPosition()
const isCentered = !targetElement
return (
<>
{targetElement ? (
<div
className="pointer-events-none fixed z-[100] transition-all duration-300"
style={{
top: targetElement.getBoundingClientRect().top - 8,
left: targetElement.getBoundingClientRect().left - 8,
width: targetElement.getBoundingClientRect().width + 16,
height: targetElement.getBoundingClientRect().height + 16,
boxShadow:
"0 0 0 9999px rgba(0, 0, 0, 0.2), 0 0 2px 9999px rgba(0, 0, 0, 0.1)",
borderRadius: "12px",
}}
/>
) : (
<div className="fixed inset-0 z-[100] bg-black/20 backdrop-blur-[2px]" />
)}
{targetElement && (
<div
className="pointer-events-none fixed z-[101] rounded-lg ring-2 ring-primary ring-offset-2 ring-offset-background transition-all duration-300"
style={{
top: targetElement.getBoundingClientRect().top - 4,
left: targetElement.getBoundingClientRect().left - 4,
width: targetElement.getBoundingClientRect().width + 8,
height: targetElement.getBoundingClientRect().height + 8,
}}
/>
)}
<div
className={cn(
"fixed z-[102] w-80 rounded-xl border bg-background p-4 shadow-2xl",
isCentered && "max-w-md",
)}
style={position}
>
<div className="mb-3 flex items-center gap-2">
{currentConfig.icon}
<h3 className="font-semibold">{currentConfig.title}</h3>
</div>
<p className="text-muted-foreground mb-4 text-sm leading-relaxed">
{currentConfig.description}
</p>
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-xs">
{currentStepIndex + 1} / {totalSteps}
</div>
<div className="flex items-center gap-2">
{currentStepIndex > 0 && (
<Button variant="outline" size="sm" onClick={handlePrev}>
<IconChevronLeft className="size-4" />
{t("tour.prev")}
</Button>
)}
<Button size="sm" onClick={handleNext}>
{currentStepIndex === totalSteps - 1
? t("tour.finish")
: t("tour.next")}
{currentStepIndex < totalSteps - 1 && (
<IconChevronRight className="size-4" />
)}
</Button>
</div>
</div>
{currentStepIndex < totalSteps - 1 && (
<Button
variant="link"
size="sm"
className="mt-2 h-auto p-0 text-xs"
onClick={handleSkip}
>
{t("tour.skip")}
</Button>
)}
</div>
</>
)
}

View file

@ -65,32 +65,32 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
[defaultModelName], [defaultModelName],
) )
const hasConfiguredModels = useMemo( const hasAvailableModels = useMemo(
() => modelList.some((m) => m.configured), () => modelList.some((m) => m.available),
[modelList], [modelList],
) )
const oauthModels = useMemo( const oauthModels = useMemo(
() => modelList.filter((m) => m.configured && m.auth_method === "oauth"), () => modelList.filter((m) => m.available && m.auth_method === "oauth"),
[modelList], [modelList],
) )
const localModels = useMemo( const localModels = useMemo(
() => modelList.filter((m) => m.configured && isLocalModel(m)), () => modelList.filter((m) => m.available && isLocalModel(m)),
[modelList], [modelList],
) )
const apiKeyModels = useMemo( const apiKeyModels = useMemo(
() => () =>
modelList.filter( modelList.filter(
(m) => m.configured && m.auth_method !== "oauth" && !isLocalModel(m), (m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m),
), ),
[modelList], [modelList],
) )
return { return {
defaultModelName, defaultModelName,
hasConfiguredModels, hasAvailableModels,
apiKeyModels, apiKeyModels,
oauthModels, oauthModels,
localModels, localModels,

View file

@ -170,8 +170,9 @@
"noDefaultHintPrefix": "No default model set yet. Click", "noDefaultHintPrefix": "No default model set yet. Click",
"noDefaultHintSuffix": "to set one.", "noDefaultHintSuffix": "to set one.",
"status": { "status": {
"configured": "Configured", "available": "Available",
"unconfigured": "Not configured" "unconfigured": "Not configured",
"unreachable": "Service unreachable"
}, },
"badge": { "badge": {
"default": "Default", "default": "Default",
@ -547,8 +548,31 @@
"unsaved_changes": "You have unsaved changes." "unsaved_changes": "You have unsaved changes."
}, },
"logs": { "logs": {
"log_level_error": "Failed to update log level.",
"clear": "Clear logs", "clear": "Clear logs",
"empty": "Waiting for logs..." "empty": "Waiting for logs..."
} }
},
"tour": {
"skip": "Skip tour",
"prev": "Previous",
"next": "Next",
"finish": "Finish",
"welcome": {
"title": "Welcome to PicoClaw",
"description": "PicoClaw is a powerful AI assistant platform. Let's take a few seconds to help you complete the basic setup."
},
"models": {
"title": "Configure Models",
"description": "Click the \"Models\" menu on the left to configure API keys for AI providers. Only configured models can be used for chat."
},
"gateway": {
"title": "Start Gateway",
"description": "After configuring models, click the \"Start Gateway\" button at the top to begin chatting with AI."
},
"docs": {
"title": "View Documentation",
"description": "Need more help? Click the documentation button in the top right corner to view detailed guides and configuration docs."
}
} }
} }

View file

@ -170,8 +170,9 @@
"noDefaultHintPrefix": "尚未设置默认模型,点击", "noDefaultHintPrefix": "尚未设置默认模型,点击",
"noDefaultHintSuffix": "设为默认。", "noDefaultHintSuffix": "设为默认。",
"status": { "status": {
"configured": "已配置", "available": "可用",
"unconfigured": "未配置" "unconfigured": "未配置",
"unreachable": "服务不可达"
}, },
"badge": { "badge": {
"default": "默认", "default": "默认",
@ -547,8 +548,31 @@
"unsaved_changes": "您有未保存的更改。" "unsaved_changes": "您有未保存的更改。"
}, },
"logs": { "logs": {
"log_level_error": "更新日志等级失败。",
"clear": "清空日志", "clear": "清空日志",
"empty": "等待日志中..." "empty": "等待日志中..."
} }
},
"tour": {
"skip": "跳过引导",
"prev": "上一步",
"next": "下一步",
"finish": "完成",
"welcome": {
"title": "欢迎使用 PicoClaw",
"description": "PicoClaw 是一个强大的 AI 助手平台。让我们花几秒钟时间,帮您完成基础配置。"
},
"models": {
"title": "配置模型",
"description": "点击左侧「模型」菜单,为 AI 服务商配置 API Key。只有配置好的模型才能用于对话。"
},
"gateway": {
"title": "启动服务",
"description": "配置好模型后,点击顶部的「启动服务」按钮,即可开始与 AI 对话。"
},
"docs": {
"title": "查看文档",
"description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。"
}
} }
} }

View file

@ -1,2 +1,3 @@
export * from "./gateway" export * from "./gateway"
export * from "./chat" export * from "./chat"
export * from "./tour"

View file

@ -0,0 +1,69 @@
import { atom } from "jotai"
import { atomWithStorage } from "jotai/utils"
export type TourStep = "welcome" | "models" | "gateway" | "docs" | "completed"
export interface TourState {
currentStep: TourStep
isActive: boolean
}
const STORAGE_KEY = "picoclaw-tour-state"
const DEFAULT_TOUR_STATE: TourState = {
currentStep: "welcome",
isActive: true,
}
export const tourAtom = atomWithStorage<TourState>(
STORAGE_KEY,
DEFAULT_TOUR_STATE,
)
export const tourIsActiveAtom = atom(
(get) => get(tourAtom).isActive,
(get, set, isActive: boolean) => {
set(tourAtom, { ...get(tourAtom), isActive })
},
)
export const tourCurrentStepAtom = atom(
(get) => get(tourAtom).currentStep,
(get, set, step: TourStep) => {
set(tourAtom, { ...get(tourAtom), currentStep: step })
},
)
export function useTourActions() {
const goToNextStep = (currentStep: TourStep): TourStep => {
const steps: TourStep[] = [
"welcome",
"models",
"gateway",
"docs",
"completed",
]
const currentIndex = steps.indexOf(currentStep)
if (currentIndex < steps.length - 1) {
return steps[currentIndex + 1]
}
return "completed"
}
const goToPrevStep = (currentStep: TourStep): TourStep => {
const steps: TourStep[] = [
"welcome",
"models",
"gateway",
"docs",
"completed",
]
const currentIndex = steps.indexOf(currentStep)
if (currentIndex > 0) {
return steps[currentIndex - 1]
}
return currentStep
}
return { goToNextStep, goToPrevStep }
}