diff --git a/Makefile b/Makefile
index 9581fa633..992182775 100644
--- a/Makefile
+++ b/Makefile
@@ -130,14 +130,17 @@ build: generate
build-launcher:
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
- @if [ ! -f web/backend/dist/index.html ]; then \
- echo "Building frontend..."; \
- cd web/frontend && pnpm install && pnpm build:backend; \
- fi
- @$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
+ @$(MAKE) -C web build \
+ OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \
+ WEB_GO='$(WEB_GO)' \
+ GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
+ LDFLAGS='$(LDFLAGS)'
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
+build-launcher-frontend:
+ @$(MAKE) -C web build-frontend
+
## build-launcher-tui: Build the picoclaw-launcher TUI binary
build-launcher-tui:
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
diff --git a/README.fr.md b/README.fr.md
index a4fa628c9..8a035f9b3 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -18,7 +18,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+|
+
+ |
+
+
+ |
+
+
+
+
+
Mod Jurutera Full-Stack |
+Pengelogan & Perancangan |
+Carian Web & Pembelajaran |
+
|---|---|---|
|
+
|
+
|
+
| Bangun · Deploy · Skala | +Jadual · Automatik · Ingat | +Temui · Wawasan · Trend | +
+
+
+
+
+
+
+
+
+
+**Pilihan 2: APK (akan datang)**
+
+APK Android bebas dengan WebUI terbina dalam sedang dalam pembangunan. Nantikan!
+
+
diff --git a/README.pt-br.md b/README.pt-br.md
index d4b303e24..dfe7cb0f2 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -18,7 +18,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
x"},
- {"plain text", "just text", "just text"},
+ {
+ name: "paragraph",
+ md: "just **some** text with _custom_ formatting and `inline` code",
+ rendered: "just some text with custom formatting and inline code
foo()\n",
+ },
+ {
+ name: "loose list",
+ md: "- Item one\n\n- Item two\n",
+ rendered: `Item one
Item two
Steps overview:
+ +Term\n: Definition of the term.
", + }, + { + name: "comprehensive document with headings, paragraphs, list, and code block", + md: "# Overview\n\nThis is a sample document designed to demonstrate various Markdown elements in a single block of text.\n\nThe first paragraph introduces the concept of structured data.\n\n## Details\n\nThe following is a list:\n\n* First\n* Second\n* Third\n\nThe second paragraph focuses on details. Below is a generic code snippet:\n\n```python\ndef calculate_area(radius):\n import math\n return math.pi * (radius ** 2)\n```\n\nThis concludes the generic sample text.\n", + rendered: `This is a sample document designed to demonstrate various Markdown elements in a single block of text.
+ +The first paragraph introduces the concept of structured data.
+ +The following is a list:
+ +The second paragraph focuses on details. Below is a generic code snippet:
+ +def calculate_area(radius):
+ import math
+ return math.pi * (radius ** 2)
+
+
+This concludes the generic sample text.
`, + }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := markdownToHTML(tt.input) - if !strings.Contains(got, tt.contains) { - t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := markdownToHTML(tc.md); got != tc.rendered { + t.Fatalf("markdownToHTML(%q)\n got: %q\nwant: %q", tc.md, got, tc.rendered) } }) } diff --git a/pkg/channels/media.go b/pkg/channels/media.go index c645a6180..95905ae00 100644 --- a/pkg/channels/media.go +++ b/pkg/channels/media.go @@ -11,5 +11,5 @@ import ( // Manager discovers channels implementing this interface via type // assertion and routes OutboundMediaMessage to them. type MediaSender interface { - SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 2cbc5bc72..0c59965c1 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -391,15 +391,15 @@ func (c *OneBotChannel) Stop(ctx context.Context) error { return nil } -func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } // Check ctx before entering write path select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -408,12 +408,12 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error c.mu.Unlock() if conn == nil { - return fmt.Errorf("OneBot WebSocket not connected") + return nil, fmt.Errorf("OneBot WebSocket not connected") } action, params, err := c.buildSendRequest(msg) if err != nil { - return err + return nil, err } echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) @@ -426,7 +426,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error data, err := json.Marshal(req) if err != nil { - return fmt.Errorf("failed to marshal OneBot request: %w", err) + return nil, fmt.Errorf("failed to marshal OneBot request: %w", err) } c.writeMu.Lock() @@ -439,21 +439,21 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error logger.ErrorCF("onebot", "Failed to send message", map[string]any{ "error": err.Error(), }) - return fmt.Errorf("onebot send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("onebot send: %w", channels.ErrTemporary) } - return nil + return nil, nil } // SendMedia implements the channels.MediaSender interface. -func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } select { case <-ctx.Done(): - return ctx.Err() + return nil, ctx.Err() default: } @@ -462,12 +462,12 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess c.mu.Unlock() if conn == nil { - return fmt.Errorf("OneBot WebSocket not connected") + return nil, fmt.Errorf("OneBot WebSocket not connected") } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } // Build media segments @@ -508,7 +508,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } if len(segments) == 0 { - return nil + return nil, nil } chatID := msg.ChatID @@ -524,7 +524,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess id, err := strconv.ParseInt(rawID, 10, 64) if err != nil { - return fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed) + return nil, fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed) } echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) @@ -537,7 +537,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess data, err := json.Marshal(req) if err != nil { - return fmt.Errorf("failed to marshal OneBot request: %w", err) + return nil, fmt.Errorf("failed to marshal OneBot request: %w", err) } c.writeMu.Lock() @@ -550,10 +550,10 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess logger.ErrorCF("onebot", "Failed to send media message", map[string]any{ "error": err.Error(), }) - return fmt.Errorf("onebot send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("onebot send media: %w", channels.ErrTemporary) } - return nil + return nil, nil } func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment { diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go index 4fdcbbf39..b4bfd09e5 100644 --- a/pkg/channels/pico/client.go +++ b/pkg/channels/pico/client.go @@ -273,22 +273,22 @@ func (c *PicoClientChannel) handleServerMessage(pc *picoConn, msg PicoMessage) { } // Send sends a message to the remote server. -func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *PicoClientChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } c.mu.Lock() pc := c.conn c.mu.Unlock() if pc == nil || pc.closed.Load() { - return channels.ErrSendFailed + return nil, channels.ErrSendFailed } outMsg := newMessage(TypeMessageSend, map[string]any{ "content": msg.Content, }) outMsg.SessionID = strings.TrimPrefix(msg.ChatID, "pico_client:") - return pc.writeJSON(outMsg) + return nil, pc.writeJSON(outMsg) } // StartTyping implements channels.TypingCapable. diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go index 7f2719e7d..7c5a62801 100644 --- a/pkg/channels/pico/client_test.go +++ b/pkg/channels/pico/client_test.go @@ -46,7 +46,7 @@ func TestSend_NotRunning(t *testing.T) { if err != nil { t.Fatal(err) } - err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"}) + _, err = ch.Send(context.Background(), bus.OutboundMessage{Content: "hi"}) if !errors.Is(err, channels.ErrNotRunning) { t.Fatalf("expected ErrNotRunning, got %v", err) } @@ -124,7 +124,7 @@ func TestClientChannel_ConnectAndSend(t *testing.T) { defer ch.Stop(ctx) // Send a message - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-1", Content: "hello", }) @@ -179,7 +179,7 @@ func TestClientChannel_ReceivesServerMessage(t *testing.T) { defer ch.Stop(ctx) // Send a message; the echo server replies with message.create - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-echo", Content: "ping", }) @@ -252,7 +252,7 @@ func TestSend_ClosedConnection(t *testing.T) { ch.conn.close() ch.mu.Unlock() - err = ch.Send(ctx, bus.OutboundMessage{ + _, err = ch.Send(ctx, bus.OutboundMessage{ ChatID: "pico_client:sess-close", Content: "should fail", }) diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 0e2bea67c..0a7bf15a4 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -234,16 +234,16 @@ func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Send implements Channel — sends a message to the appropriate WebSocket connection. -func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } outMsg := newMessage(TypeMessageCreate, map[string]any{ "content": msg.Content, }) - return c.broadcastToSession(msg.ChatID, outMsg) + return nil, c.broadcastToSession(msg.ChatID, outMsg) } // EditMessage implements channels.MessageEditor. diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index 0a630e193..192c96164 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -17,6 +17,8 @@ const ( TypeTypingStop = "typing.stop" TypeError = "error" TypePong = "pong" + + PicoTokenPrefix = "pico-" ) // PicoMessage is the wire format for all Pico Protocol messages. diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 0f60c2a6f..f2b70aec9 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -200,9 +200,9 @@ func (c *QQChannel) getChatKind(chatID string) string { return "group" } -func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) @@ -236,11 +236,14 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { } // Route to group or C2C. - var err error + var ( + sentMsg *dto.Message + err error + ) if chatKind == "group" { - _, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) + sentMsg, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) } else { - _, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) + sentMsg, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) } if err != nil { @@ -249,10 +252,13 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { "chat_kind": chatKind, "error": err.Error(), }) - return fmt.Errorf("qq send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send: %w", channels.ErrTemporary) } - return nil + if sentMsg == nil { + return nil, nil + } + return []string{sentMsg.ID}, nil } // StartTyping implements channels.TypingCapable. @@ -319,13 +325,14 @@ func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), err // QQ group/C2C media sending is a two-step flow: // 1. Upload media to /files using a remote URL or base64-encoded local bytes. // 2. Send a msg_type=7 message using the returned file_info. -func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) + var messageIDs []string for _, part := range msg.Parts { fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part) if err != nil { @@ -335,22 +342,26 @@ func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) "error": err.Error(), }) if errors.Is(err, channels.ErrSendFailed) { - return err + return nil, err } - return fmt.Errorf("qq send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary) } - if err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo); err != nil { + sentMsg, err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo) + if err != nil { logger.ErrorCF("qq", "Failed to send media", map[string]any{ "type": part.Type, "chat_id": msg.ChatID, "error": err.Error(), }) - return fmt.Errorf("qq send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary) + } + if sentMsg != nil && sentMsg.ID != "" { + messageIDs = append(messageIDs, sentMsg.ID) } } - return nil + return messageIDs, nil } type qqMediaUpload struct { @@ -517,7 +528,7 @@ func (c *QQChannel) sendUploadedMedia( chatKind, chatID string, part bus.MediaPart, fileInfo []byte, -) error { +) (*dto.Message, error) { msg := &dto.MessageToCreate{ Content: part.Caption, MsgType: dto.RichMediaMsg, @@ -532,11 +543,11 @@ func (c *QQChannel) sendUploadedMedia( } if chatKind == "group" { - _, err := c.api.PostGroupMessage(ctx, chatID, msg) - return err + sentMsg, err := c.api.PostGroupMessage(ctx, chatID, msg) + return sentMsg, err } - _, err := c.api.PostC2CMessage(ctx, chatID, msg) - return err + sentMsg, err := c.api.PostC2CMessage(ctx, chatID, msg) + return sentMsg, err } func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) { diff --git a/pkg/channels/qq/qq_test.go b/pkg/channels/qq/qq_test.go index 7ed736827..83a912cd7 100644 --- a/pkg/channels/qq/qq_test.go +++ b/pkg/channels/qq/qq_test.go @@ -209,7 +209,7 @@ func TestSendMedia_UploadsLocalFileAsBase64(t *testing.T) { ch.lastMsgID.Store("group-1", "msg-1") ch.msgSeqCounters.Store("group-1", new(atomic.Uint64)) - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "image", @@ -303,7 +303,7 @@ func assertAudioWAVUploadType(t *testing.T, duration time.Duration, wantFileType ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -337,7 +337,7 @@ func TestSendMedia_RemoteAudioFallsBackToFileUpload(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("user-1", "direct") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -383,7 +383,7 @@ func TestSendMedia_LocalAudioWithUnknownDurationFallsBackToFileUpload(t *testing ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "audio", @@ -417,7 +417,7 @@ func TestSendMedia_UsesRemoteURLUploadForC2C(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("user-1", "direct") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "file", @@ -490,7 +490,7 @@ func TestSendMedia_LocalFileUploadIncludesStoredFilename(t *testing.T) { ch.SetMediaStore(store) ch.chatType.Store("user-1", "direct") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "user-1", Parts: []bus.MediaPart{{ Type: "file", @@ -528,7 +528,7 @@ func TestSendMedia_ReturnsSendFailedWithoutMediaStore(t *testing.T) { ch.SetRunning(true) ch.chatType.Store("group-1", "group") - err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err := ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "image", @@ -578,7 +578,7 @@ func TestSendMedia_ReturnsSendFailedWhenLocalFileExceedsBase64MiBLimit(t *testin ch.SetMediaStore(store) ch.chatType.Store("group-1", "group") - err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ ChatID: "group-1", Parts: []bus.MediaPart{{ Type: "file", diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index acd857a06..1e4a4fef5 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -108,14 +108,14 @@ func (c *SlackChannel) Stop(ctx context.Context) error { return nil } -func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { +func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID, threadTS := parseSlackChatID(msg.ChatID) if channelID == "" { - return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + return nil, fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } opts := []slack.MsgOption{ @@ -130,9 +130,9 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error opts = append(opts, slack.MsgOptionTS(threadTS)) } - _, _, err := c.api.PostMessageContext(ctx, channelID, opts...) + _, ts, err := c.api.PostMessageContext(ctx, channelID, opts...) if err != nil { - return fmt.Errorf("slack send: %w", channels.ErrTemporary) + return nil, fmt.Errorf("slack send: %w", channels.ErrTemporary) } if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok { @@ -148,23 +148,23 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error "thread_ts": threadTS, }) - return nil + return []string{ts}, nil } // SendMedia implements the channels.MediaSender interface. -func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { +func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { - return channels.ErrNotRunning + return nil, channels.ErrNotRunning } channelID, _ := parseSlackChatID(msg.ChatID) if channelID == "" { - return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + return nil, fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } store := c.GetMediaStore() if store == nil { - return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) } for _, part := range msg.Parts { @@ -198,11 +198,13 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa "filename": filename, "error": err.Error(), }) - return fmt.Errorf("slack send media: %w", channels.ErrTemporary) + return nil, fmt.Errorf("slack send media: %w", channels.ErrTemporary) } } - return nil + // UploadFileV2 does not expose the posted message timestamp in its + // response; returning nil avoids conflating file IDs with message IDs. + return nil, nil } // ReactToMessage implements channels.ReactionCapable. diff --git a/pkg/channels/telegram/parser_markdown_to_html.go b/pkg/channels/telegram/parser_markdown_to_html.go index bdaa51807..95dc3e9d6 100644 --- a/pkg/channels/telegram/parser_markdown_to_html.go +++ b/pkg/channels/telegram/parser_markdown_to_html.go @@ -16,14 +16,15 @@ func markdownToTelegramHTML(text string) string { inlineCodes := extractInlineCodes(text) text = inlineCodes.text + links := extractLinks(text) + text = links.text + text = reHeading.ReplaceAllString(text, "$1") text = reBlockquote.ReplaceAllString(text, "$1") text = escapeHTML(text) - text = reLink.ReplaceAllString(text, `$1`) - text = reBoldStar.ReplaceAllString(text, "$1") text = reBoldUnder.ReplaceAllString(text, "$1") @@ -40,6 +41,12 @@ func markdownToTelegramHTML(text string) string { text = reListItem.ReplaceAllString(text, "• ") + for i, lnk := range links.links { + label := escapeHTML(lnk[0]) + url := lnk[1] + text = strings.ReplaceAll(text, fmt.Sprintf("\x00LK%d\x00", i), fmt.Sprintf(`%s`, url, label)) + } + for i, code := range inlineCodes.codes { escaped := escapeHTML(code) text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped))
@@ -57,6 +64,29 @@ func markdownToTelegramHTML(text string) string {
return text
}
+type linkMatch struct {
+ text string
+ links [][2]string // [label, url]
+}
+
+func extractLinks(text string) linkMatch {
+ matches := reLink.FindAllStringSubmatch(text, -1)
+
+ extracted := make([][2]string, 0, len(matches))
+ for _, match := range matches {
+ extracted = append(extracted, [2]string{match[1], match[2]})
+ }
+
+ i := 0
+ text = reLink.ReplaceAllStringFunc(text, func(m string) string {
+ placeholder := fmt.Sprintf("\x00LK%d\x00", i)
+ i++
+ return placeholder
+ })
+
+ return linkMatch{text: text, links: extracted}
+}
+
type codeBlockMatch struct {
text string
codes []string
diff --git a/pkg/channels/telegram/parser_markdown_to_html_test.go b/pkg/channels/telegram/parser_markdown_to_html_test.go
new file mode 100644
index 000000000..7754ee076
--- /dev/null
+++ b/pkg/channels/telegram/parser_markdown_to_html_test.go
@@ -0,0 +1,66 @@
+package telegram
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func Test_markdownToTelegramHTML(t *testing.T) {
+ cases := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "plain text",
+ input: "hello world",
+ expected: "hello world",
+ },
+ {
+ name: "bold",
+ input: "**bold text**",
+ expected: "bold text",
+ },
+ {
+ name: "italic",
+ input: "_italic text_",
+ expected: "italic text",
+ },
+ {
+ name: "link without underscores in URL",
+ input: "[click here](https://example.com/path)",
+ expected: `click here`,
+ },
+ {
+ name: "link with underscores in URL is not corrupted by italic regex",
+ // Google Flights URLs use URL-safe base64 with underscores in the tfs param.
+ // Previously reItalic ran after reLink, matching _text_ inside href and injecting
+ // tags into the URL, which broke the link in Telegram.
+ input: "[3 → 10 сентября — от $202](https://www.google.com/travel/flights/search?tfs=CBwQAho_EgoyURL_safe_base64)",
+ expected: `3 → 10 сентября — от $202`,
+ },
+ {
+ name: "multiple links all survive",
+ input: "[first](https://a.com/path_one) and [second](https://b.com/path_two_x)",
+ expected: `first and second`,
+ },
+ {
+ name: "link label with HTML special chars is escaped",
+ input: "[a & b](https://example.com)",
+ expected: `a & b`,
+ },
+ {
+ name: "HTML special chars in plain text are escaped",
+ input: "a & b < c > d",
+ expected: "a & b < c > d",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ actual := markdownToTelegramHTML(tc.input)
+ require.Equal(t, tc.expected, actual)
+ })
+ }
+}
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 3e68c10d1..ccb394a57 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -168,26 +168,27 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
return nil
}
-func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
+ return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
if msg.Content == "" {
- return nil
+ return nil, nil
}
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
// so msg.Content is guaranteed to be within that limit. We still need to
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
replyToID := msg.ReplyToMessageID
+ var messageIDs []string
queue := []string{msg.Content}
for len(queue) > 0 {
chunk := queue[0]
@@ -206,16 +207,18 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
}
if smallerLen <= 0 {
- if err := c.sendChunk(ctx, sendChunkParams{
+ msgID, err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID,
threadID: threadID,
content: content,
replyToID: replyToID,
mdFallback: chunk,
useMarkdownV2: useMarkdownV2,
- }); err != nil {
- return err
+ })
+ if err != nil {
+ return nil, err
}
+ messageIDs = append(messageIDs, msgID)
replyToID = ""
continue
}
@@ -244,21 +247,23 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
continue
}
- if err := c.sendChunk(ctx, sendChunkParams{
+ msgID, err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID,
threadID: threadID,
content: content,
replyToID: replyToID,
mdFallback: chunk,
useMarkdownV2: useMarkdownV2,
- }); err != nil {
- return err
+ })
+ if err != nil {
+ return nil, err
}
+ messageIDs = append(messageIDs, msgID)
// Only the first chunk should be a reply; subsequent chunks are normal messages.
replyToID = ""
}
- return nil
+ return messageIDs, nil
}
type sendChunkParams struct {
@@ -275,7 +280,7 @@ type sendChunkParams struct {
func (c *TelegramChannel) sendChunk(
ctx context.Context,
params sendChunkParams,
-) error {
+) (string, error) {
tgMsg := tu.Message(tu.ID(params.chatID), params.content)
tgMsg.MessageThreadID = params.threadID
if params.useMarkdownV2 {
@@ -292,17 +297,19 @@ func (c *TelegramChannel) sendChunk(
}
}
- if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
+ pMsg, err := c.bot.SendMessage(ctx, tgMsg)
+ if err != nil {
logParseFailed(err, params.useMarkdownV2)
tgMsg.Text = params.mdFallback
tgMsg.ParseMode = ""
- if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
- return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
+ pMsg, err = c.bot.SendMessage(ctx, tgMsg)
+ if err != nil {
+ return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}
- return nil
+ return strconv.Itoa(pMsg.MessageID), nil
}
// maxTypingDuration limits how long the typing indicator can run.
@@ -420,21 +427,22 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
}
// SendMedia implements the channels.MediaSender interface.
-func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
+ return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
store := c.GetMediaStore()
if store == nil {
- return fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
+ return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
+ var messageIDs []string
for _, part := range msg.Parts {
localPath, err := store.Resolve(part.Ref)
if err != nil {
@@ -454,6 +462,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
continue
}
+ var tgResult *telego.Message
switch part.Type {
case "image":
params := &telego.SendPhotoParams{
@@ -462,11 +471,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Photo: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendPhoto(ctx, params)
+ tgResult, err = c.bot.SendPhoto(ctx, params)
if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") {
if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil {
file.Close()
- return fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("telegram rewind media after photo failure: %w", channels.ErrTemporary)
}
docParams := &telego.SendDocumentParams{
@@ -475,7 +484,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Document: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendDocument(ctx, docParams)
+ tgResult, err = c.bot.SendDocument(ctx, docParams)
}
case "audio":
// Send OGG files with "voice" in the filename as Telegram voice
@@ -488,7 +497,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Voice: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendVoice(ctx, vparams)
+ tgResult, err = c.bot.SendVoice(ctx, vparams)
} else {
params := &telego.SendAudioParams{
ChatID: tu.ID(chatID),
@@ -496,7 +505,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Audio: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendAudio(ctx, params)
+ tgResult, err = c.bot.SendAudio(ctx, params)
}
case "video":
params := &telego.SendVideoParams{
@@ -505,7 +514,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Video: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendVideo(ctx, params)
+ tgResult, err = c.bot.SendVideo(ctx, params)
default: // "file" or unknown types
params := &telego.SendDocumentParams{
ChatID: tu.ID(chatID),
@@ -513,9 +522,12 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
Document: telego.InputFile{File: file},
Caption: part.Caption,
}
- _, err = c.bot.SendDocument(ctx, params)
+ tgResult, err = c.bot.SendDocument(ctx, params)
}
+ if tgResult != nil {
+ messageIDs = append(messageIDs, strconv.Itoa(tgResult.MessageID))
+ }
file.Close()
if err != nil {
@@ -523,11 +535,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
"type": part.Type,
"error": err.Error(),
})
- return fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("telegram send media: %w", channels.ErrTemporary)
}
}
- return nil
+ return messageIDs, nil
}
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
@@ -660,6 +672,23 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content = cleaned
}
+ if message.ReplyToMessage != nil {
+ quotedMedia := quotedTelegramMediaRefs(
+ message.ReplyToMessage,
+ func(fileID, ext, filename string) string {
+ localPath := c.downloadFile(ctx, fileID, ext)
+ if localPath == "" {
+ return ""
+ }
+ return storeMedia(localPath, filename)
+ },
+ )
+ if len(quotedMedia) > 0 {
+ mediaPaths = append(quotedMedia, mediaPaths...)
+ }
+ content = c.prependTelegramQuotedReply(content, message.ReplyToMessage)
+ }
+
// For forum topics, embed the thread ID as "chatID/threadID" so replies
// route to the correct topic and each topic gets its own session.
// Only forum groups (IsForum) are handled; regular group reply threads
@@ -693,6 +722,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
}
+ if message.ReplyToMessage != nil {
+ metadata["reply_to_message_id"] = fmt.Sprintf("%d", message.ReplyToMessage.MessageID)
+ }
// Set parent_peer metadata for per-topic agent binding.
if message.Chat.IsForum && threadID != 0 {
@@ -713,6 +745,122 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return nil
}
+func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string {
+ quoted := strings.TrimSpace(telegramQuotedContent(reply))
+ if quoted == "" {
+ return content
+ }
+
+ author := telegramQuotedAuthor(reply)
+ role := c.telegramQuotedRole(reply)
+ if strings.TrimSpace(content) == "" {
+ return fmt.Sprintf("[quoted %s message from %s]: %s", role, author, quoted)
+ }
+ return fmt.Sprintf("[quoted %s message from %s]: %s\n\n%s", role, author, quoted, content)
+}
+
+func (c *TelegramChannel) telegramQuotedRole(message *telego.Message) string {
+ if message == nil {
+ return "unknown"
+ }
+
+ if message.From != nil {
+ if !message.From.IsBot {
+ return "user"
+ }
+ if c.isOwnBotUser(message.From) {
+ return "assistant"
+ }
+ return "bot"
+ }
+
+ if message.SenderChat != nil {
+ return "chat"
+ }
+
+ return "unknown"
+}
+
+func (c *TelegramChannel) isOwnBotUser(user *telego.User) bool {
+ if c == nil || c.bot == nil || user == nil || !user.IsBot {
+ return false
+ }
+
+ if botID := c.bot.ID(); botID != 0 && user.ID == botID {
+ return true
+ }
+
+ botUsername := strings.TrimPrefix(strings.TrimSpace(c.bot.Username()), "@")
+ if botUsername == "" {
+ return false
+ }
+ return strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(user.Username), "@"), botUsername)
+}
+
+func telegramQuotedAuthor(message *telego.Message) string {
+ if message == nil || message.From == nil {
+ return "unknown"
+ }
+ if username := strings.TrimSpace(message.From.Username); username != "" {
+ return username
+ }
+ if firstName := strings.TrimSpace(message.From.FirstName); firstName != "" {
+ return firstName
+ }
+ return "unknown"
+}
+
+func telegramQuotedContent(message *telego.Message) string {
+ if message == nil {
+ return ""
+ }
+
+ var parts []string
+ if text := strings.TrimSpace(message.Text); text != "" {
+ parts = append(parts, text)
+ }
+ if caption := strings.TrimSpace(message.Caption); caption != "" {
+ parts = append(parts, caption)
+ }
+ switch {
+ case len(message.Photo) > 0:
+ parts = append(parts, "[image: photo]")
+ }
+ switch {
+ case message.Voice != nil:
+ parts = append(parts, "[voice]")
+ case message.Audio != nil:
+ parts = append(parts, "[audio]")
+ }
+ if message.Document != nil {
+ parts = append(parts, "[file]")
+ }
+
+ return strings.Join(parts, "\n")
+}
+
+func quotedTelegramMediaRefs(
+ message *telego.Message,
+ resolve func(fileID, ext, filename string) string,
+) []string {
+ if message == nil || resolve == nil {
+ return nil
+ }
+
+ var refs []string
+ if message.Voice != nil {
+ if ref := resolve(message.Voice.FileID, ".ogg", "voice.ogg"); ref != "" {
+ refs = append(refs, ref)
+ }
+ }
+ if message.Audio != nil {
+ if ref := resolve(message.Audio.FileID, ".mp3", "audio.mp3"); ref != "" {
+ refs = append(refs, ref)
+ }
+ }
+ return refs
+}
+
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {
diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go
index fd189d9a7..4f7a2600b 100644
--- a/pkg/channels/telegram/telegram_test.go
+++ b/pkg/channels/telegram/telegram_test.go
@@ -7,6 +7,7 @@ import (
"io"
"os"
"path/filepath"
+ "strconv"
"strings"
"testing"
@@ -104,6 +105,13 @@ func successResponse(t *testing.T) *ta.Response {
return &ta.Response{Ok: true, Result: b}
}
+func successUserResponse(t *testing.T, user *telego.User) *ta.Response {
+ t.Helper()
+ b, err := json.Marshal(user)
+ require.NoError(t, err)
+ return &ta.Response{Ok: true, Result: b}
+}
+
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
return newTestChannelWithConstructor(t, caller, &stubConstructor{})
@@ -168,7 +176,7 @@ func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {
)
require.NoError(t, err)
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: []bus.MediaPart{{
Type: "image",
@@ -206,7 +214,7 @@ func TestSendMedia_ImageNonDimensionErrorDoesNotFallback(t *testing.T) {
ref, err := store.Store(localPath, media.MediaMeta{Filename: "image.png", ContentType: "image/png"}, "scope-1")
require.NoError(t, err)
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "12345",
Parts: []bus.MediaPart{{
Type: "image",
@@ -231,7 +239,7 @@ func TestSend_EmptyContent(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "",
})
@@ -248,7 +256,7 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello, world!",
})
@@ -271,7 +279,7 @@ func TestSend_LongMessage_SingleCall(t *testing.T) {
longContent := strings.Repeat("a", 4000)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
@@ -294,7 +302,7 @@ func TestSend_HTMLFallback_PerChunk(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello **world**",
})
@@ -312,7 +320,7 @@ func TestSend_HTMLFallback_BothFail(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
@@ -334,7 +342,7 @@ func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
longContent := strings.Repeat("x", 4001)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
@@ -364,7 +372,7 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
"HTML expansion must exceed Telegram limit for this test to be meaningful",
)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: markdownContent,
})
@@ -399,7 +407,7 @@ func TestSend_HTMLOverflow_WordBoundary(t *testing.T) {
// Ensure the test content matches the intended boundary conditions.
assert.LessOrEqual(t, len([]rune(content)), 4000, "markdown content must not exceed chunk size for this test")
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "123456",
Content: content,
})
@@ -435,7 +443,7 @@ func TestSend_NotRunning(t *testing.T) {
ch := newTestChannel(t, caller)
ch.SetRunning(false)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
@@ -453,7 +461,7 @@ func TestSend_InvalidChatID(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "not-a-number",
Content: "Hello",
})
@@ -510,7 +518,7 @@ func TestSend_WithForumThreadID(t *testing.T) {
}
ch := newTestChannel(t, caller)
- err := ch.Send(context.Background(), bus.OutboundMessage{
+ _, err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890/42",
Content: "Hello from topic",
})
@@ -642,6 +650,181 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
assert.Empty(t, inbound.Metadata["parent_peer_id"])
}
+func assertHandleMessageQuotedUserReply(
+ t *testing.T,
+ chatID int64,
+ messageID int,
+ userID int64,
+ userName string,
+ userText string,
+ replyMessageID int,
+ replyText string,
+ replyCaption string,
+ replyAuthorID int64,
+ replyAuthorName string,
+ expectedContent string,
+) {
+ t.Helper()
+
+ messageBus := bus.NewMessageBus()
+ ch := &TelegramChannel{
+ BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
+ chatIDs: make(map[string]int64),
+ ctx: context.Background(),
+ }
+
+ msg := &telego.Message{
+ Text: userText,
+ MessageID: messageID,
+ Chat: telego.Chat{
+ ID: chatID,
+ Type: "private",
+ },
+ From: &telego.User{
+ ID: userID,
+ FirstName: userName,
+ },
+ ReplyToMessage: &telego.Message{
+ MessageID: replyMessageID,
+ Text: replyText,
+ Caption: replyCaption,
+ From: &telego.User{
+ ID: replyAuthorID,
+ FirstName: replyAuthorName,
+ },
+ },
+ }
+
+ err := ch.handleMessage(context.Background(), msg)
+ require.NoError(t, err)
+
+ inbound, ok := <-messageBus.InboundChan()
+ require.True(t, ok)
+ assert.Equal(t, strconv.Itoa(replyMessageID), inbound.Metadata["reply_to_message_id"])
+ assert.Equal(t, expectedContent, inbound.Content)
+}
+
+func TestHandleMessage_ReplyToMessage_PrependsQuotedTextAndMetadata(t *testing.T) {
+ assertHandleMessageQuotedUserReply(
+ t,
+ 456,
+ 21,
+ 11,
+ "Alice",
+ "follow up",
+ 99,
+ "old context",
+ "",
+ 12,
+ "Bob",
+ "[quoted user message from Bob]: old context\n\nfollow up",
+ )
+}
+
+func TestHandleMessage_ReplyToMessage_UsesCaptionWhenQuotedTextMissing(t *testing.T) {
+ assertHandleMessageQuotedUserReply(
+ t,
+ 789,
+ 22,
+ 13,
+ "Carol",
+ "answer this",
+ 100,
+ "",
+ "caption context",
+ 14,
+ "Dave",
+ "[quoted user message from Dave]: caption context\n\nanswer this",
+ )
+}
+
+func TestHandleMessage_ReplyToOwnBotMessage_UsesAssistantRole(t *testing.T) {
+ messageBus := bus.NewMessageBus()
+ caller := &stubCaller{
+ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
+ if strings.Contains(url, "getMe") {
+ return successUserResponse(t, &telego.User{
+ ID: 42,
+ IsBot: true,
+ FirstName: "Pico",
+ Username: "afjcjsbx_picoclaw_bot",
+ }), nil
+ }
+ t.Fatalf("unexpected API call: %s", url)
+ return nil, nil
+ },
+ }
+ ch := newTestChannel(t, caller)
+ ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil)
+ ch.ctx = context.Background()
+
+ msg := &telego.Message{
+ Text: "ti ricordi questo file?",
+ MessageID: 23,
+ Chat: telego.Chat{
+ ID: 999,
+ Type: "private",
+ },
+ From: &telego.User{
+ ID: 15,
+ FirstName: "Eve",
+ },
+ ReplyToMessage: &telego.Message{
+ MessageID: 101,
+ Text: "Fatto! Ho creato il file notizie_2026_03_28.md",
+ From: &telego.User{
+ ID: 42,
+ IsBot: true,
+ FirstName: "Pico",
+ Username: "afjcjsbx_picoclaw_bot",
+ },
+ },
+ }
+
+ err := ch.handleMessage(context.Background(), msg)
+ require.NoError(t, err)
+
+ inbound, ok := <-messageBus.InboundChan()
+ require.True(t, ok)
+ assert.Equal(t, "101", inbound.Metadata["reply_to_message_id"])
+ assert.Equal(
+ t,
+ "[quoted assistant message from afjcjsbx_picoclaw_bot]: Fatto! Ho creato il file notizie_2026_03_28.md\n\nti ricordi questo file?",
+ inbound.Content,
+ )
+}
+
+func TestTelegramQuotedContent_IncludesVoiceMarkerAlongsideCaption(t *testing.T) {
+ msg := &telego.Message{
+ Caption: "listen to this",
+ Voice: &telego.Voice{
+ FileID: "voice-file",
+ },
+ }
+
+ assert.Equal(t, "listen to this\n[voice]", telegramQuotedContent(msg))
+}
+
+func TestQuotedTelegramMediaRefs_ResolvesQuotedAudioInOrder(t *testing.T) {
+ msg := &telego.Message{
+ Voice: &telego.Voice{FileID: "voice-file"},
+ Audio: &telego.Audio{FileID: "audio-file"},
+ }
+
+ var calls []string
+ refs := quotedTelegramMediaRefs(msg, func(fileID, ext, filename string) string {
+ calls = append(calls, fileID+"|"+ext+"|"+filename)
+ return "ref://" + filename
+ })
+
+ assert.Equal(
+ t,
+ []string{"voice-file|.ogg|voice.ogg", "audio-file|.mp3|audio.mp3"},
+ calls,
+ )
+ assert.Equal(t, []string{"ref://voice.ogg", "ref://audio.mp3"}, refs)
+}
+
func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
diff --git a/pkg/channels/wecom/wecom.go b/pkg/channels/wecom/wecom.go
index 6096b7db3..9689d5171 100644
--- a/pkg/channels/wecom/wecom.go
+++ b/pkg/channels/wecom/wecom.go
@@ -184,20 +184,20 @@ func (c *WeComChannel) BeginStream(_ context.Context, chatID string) (channels.S
}, nil
}
-func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
content := strings.TrimSpace(msg.Content)
if content == "" {
- return nil
+ return nil, nil
}
if turn, ok := c.getTurn(msg.ChatID); ok {
if time.Since(turn.CreatedAt) <= wecomStreamMaxDuration {
if err := c.sendStreamReply(turn, content); err == nil {
c.consumeTurn(msg.ChatID, turn)
- return nil
+ return nil, nil
}
}
c.consumeTurn(msg.ChatID, turn)
@@ -205,20 +205,20 @@ func (c *WeComChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
if route, ok := c.routes.Get(msg.ChatID); ok {
if err := c.sendActivePush(route.ChatID, route.ChatType, content); err != nil {
- return err
+ return nil, err
}
- return nil
+ return nil, nil
}
if err := c.sendActivePush(msg.ChatID, 0, content); err != nil {
- return err
+ return nil, err
}
- return nil
+ return nil, nil
}
-func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
route, chatType, hasTurn := c.resolveMediaRoute(msg.ChatID)
@@ -231,7 +231,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
if strings.TrimSpace(part.Ref) == "" {
if caption := strings.TrimSpace(part.Caption); caption != "" {
if err := c.sendActivePush(chatID, chatType, caption); err != nil {
- return err
+ return nil, err
}
}
continue
@@ -239,7 +239,7 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part)
if err != nil {
- return fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
+ return nil, fmt.Errorf("wecom resolve media %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
}
func() {
@@ -283,11 +283,11 @@ func (c *WeComChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
}
}()
if err != nil {
- return err
+ return nil, err
}
}
- return nil
+ return nil, nil
}
func (c *WeComChannel) connectLoop() {
diff --git a/pkg/channels/wecom/wecom_test.go b/pkg/channels/wecom/wecom_test.go
index c7a4adfc0..b3a87e246 100644
--- a/pkg/channels/wecom/wecom_test.go
+++ b/pkg/channels/wecom/wecom_test.go
@@ -190,7 +190,7 @@ func TestSend_StreamFailureFallsBackToActualChatID(t *testing.T) {
return wecomTestAck(nil), nil
}
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: "hello",
@@ -247,7 +247,7 @@ func TestSend_DoesNotSplitStreamReply(t *testing.T) {
}
content := strings.Repeat("\u4e2d", 30000)
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: content,
@@ -283,7 +283,7 @@ func TestSend_DoesNotSplitActivePush(t *testing.T) {
}
content := strings.Repeat("a", 30000)
- if err := ch.Send(context.Background(), bus.OutboundMessage{
+ if _, err := ch.Send(context.Background(), bus.OutboundMessage{
Channel: "wecom",
ChatID: "chat-1",
Content: content,
@@ -346,7 +346,7 @@ func TestSendMedia_SendsActiveImage(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-1",
Parts: []bus.MediaPart{{
@@ -457,7 +457,7 @@ func TestSendMedia_UsesTurnImageAndFinishesStream(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-1",
Parts: []bus.MediaPart{{
@@ -553,7 +553,7 @@ func TestSendMedia_SendsActiveFile(t *testing.T) {
}
}
- err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
+ _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
Channel: "wecom",
ChatID: "chat-2",
Parts: []bus.MediaPart{{
diff --git a/pkg/channels/weixin/api.go b/pkg/channels/weixin/api.go
index 7f9b3b5c6..6dc52790e 100644
--- a/pkg/channels/weixin/api.go
+++ b/pkg/channels/weixin/api.go
@@ -12,6 +12,14 @@ import (
"net/http"
"net/url"
"path"
+ "strconv"
+)
+
+const (
+ weixinChannelVersion = "2.1.1"
+ weixinIlinkAppID = "bot"
+ // 2.1.1 encoded as 0x00MMNNPP => 0x00020101 => 131329
+ weixinClientVersion = 131329
)
type ApiClient struct {
@@ -80,13 +88,9 @@ func (c *ApiClient) post(ctx context.Context, endpoint string, body any, respons
}
req.Header.Set("Content-Type", "application/json")
- if endpoint == "ilink/bot/get_bot_qrcode" || endpoint == "ilink/bot/get_qrcode_status" {
- // QR routes have different headers sometimes, but let's stick to base ones
- if endpoint == "ilink/bot/get_qrcode_status" {
- // Use direct map assignment to send exact header name the Tencent API expects
- req.Header["iLink-App-ClientVersion"] = []string{"1"}
- }
- } else {
+ req.Header["iLink-App-Id"] = []string{weixinIlinkAppID}
+ req.Header["iLink-App-ClientVersion"] = []string{strconv.Itoa(weixinClientVersion)}
+ if endpoint != "ilink/bot/get_bot_qrcode" && endpoint != "ilink/bot/get_qrcode_status" {
req.Header["AuthorizationType"] = []string{"ilink_bot_token"}
req.Header["X-WECHAT-UIN"] = []string{randomWechatUIN()}
if c.Token != "" {
@@ -119,7 +123,7 @@ func (c *ApiClient) post(ctx context.Context, endpoint string, body any, respons
}
func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpdatesResp, error) {
- req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
+ req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion}
var resp GetUpdatesResp
err := c.post(ctx, "ilink/bot/getupdates", req, &resp)
if err != nil {
@@ -129,7 +133,7 @@ func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpda
}
func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) (*SendMessageResp, error) {
- req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
+ req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion}
var resp SendMessageResp
if err := c.post(ctx, "ilink/bot/sendmessage", req, &resp); err != nil {
return nil, err
@@ -138,7 +142,7 @@ func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) (*SendM
}
func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*GetUploadUrlResp, error) {
- req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
+ req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion}
var resp GetUploadUrlResp
err := c.post(ctx, "ilink/bot/getuploadurl", req, &resp)
if err != nil {
@@ -148,7 +152,7 @@ func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*Get
}
func (c *ApiClient) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfigResp, error) {
- req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
+ req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion}
var resp GetConfigResp
if err := c.post(ctx, "ilink/bot/getconfig", req, &resp); err != nil {
return nil, err
@@ -157,7 +161,7 @@ func (c *ApiClient) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfig
}
func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTypingResp, error) {
- req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"}
+ req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion}
var resp SendTypingResp
if err := c.post(ctx, "ilink/bot/sendtyping", req, &resp); err != nil {
return nil, err
@@ -165,38 +169,51 @@ func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTyp
return &resp, nil
}
-func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) {
- // get_bot_qrcode is GET, not POST
+func (c *ApiClient) getQR(ctx context.Context, endpoint string, query map[string]string, respObj any) error {
u, err := url.Parse(c.BaseURL)
if err != nil {
- return nil, err
+ return err
}
- u.Path = path.Join(u.Path, "ilink/bot/get_bot_qrcode")
+ u.Path = path.Join(u.Path, endpoint)
q := u.Query()
- q.Set("bot_type", botType)
+ for key, value := range query {
+ q.Set(key, value)
+ }
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
- return nil, err
+ return err
}
+ req.Header["iLink-App-Id"] = []string{weixinIlinkAppID}
+ req.Header["iLink-App-ClientVersion"] = []string{strconv.Itoa(weixinClientVersion)}
resp, err := c.HttpClient.Do(req)
if err != nil {
- return nil, err
+ return err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
- return nil, err
+ return err
}
if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("get_bot_qrcode failed: %d %s", resp.StatusCode, string(respBody))
+ return fmt.Errorf("%s failed: %d %s", endpoint, resp.StatusCode, string(respBody))
+ }
+ if err := json.Unmarshal(respBody, respObj); err != nil {
+ return err
}
+ return nil
+}
+
+func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) {
+ // get_bot_qrcode is GET, not POST
var qrcodeResp QRCodeResponse
- if err := json.Unmarshal(respBody, &qrcodeResp); err != nil {
+ if err := c.getQR(ctx, "ilink/bot/get_bot_qrcode", map[string]string{
+ "bot_type": botType,
+ }, &qrcodeResp); err != nil {
return nil, err
}
return &qrcodeResp, nil
@@ -204,37 +221,10 @@ func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeRespo
func (c *ApiClient) GetQRCodeStatus(ctx context.Context, qrcode string) (*StatusResponse, error) {
// get_qrcode_status is GET
- u, err := url.Parse(c.BaseURL)
- if err != nil {
- return nil, err
- }
- u.Path = path.Join(u.Path, "ilink/bot/get_qrcode_status")
- q := u.Query()
- q.Set("qrcode", qrcode)
- u.RawQuery = q.Encode()
-
- req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
- if err != nil {
- return nil, err
- }
- req.Header["iLink-App-ClientVersion"] = []string{"1"}
-
- resp, err := c.HttpClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- respBody, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, err
- }
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("get_qrcode_status failed: %d %s", resp.StatusCode, string(respBody))
- }
-
var statusResp StatusResponse
- if err := json.Unmarshal(respBody, &statusResp); err != nil {
+ if err := c.getQR(ctx, "ilink/bot/get_qrcode_status", map[string]string{
+ "qrcode": qrcode,
+ }, &statusResp); err != nil {
return nil, err
}
return &statusResp, nil
diff --git a/pkg/channels/weixin/auth.go b/pkg/channels/weixin/auth.go
index 52ec2a6df..0a0e597c1 100644
--- a/pkg/channels/weixin/auth.go
+++ b/pkg/channels/weixin/auth.go
@@ -40,6 +40,7 @@ func PerformLoginInteractive(
if err != nil {
return "", "", "", "", fmt.Errorf("failed to create api client: %w", err)
}
+ pollAPI := api
logger.InfoC("weixin", "Requesting Weixin QR code...")
qrResp, err := api.GetQRCode(ctx, opts.BotType)
@@ -76,7 +77,7 @@ func PerformLoginInteractive(
case <-timeoutCtx.Done():
return "", "", "", "", fmt.Errorf("login timeout")
case <-pollTicker.C:
- statusResp, err := api.GetQRCodeStatus(timeoutCtx, qrResp.Qrcode)
+ statusResp, err := pollAPI.GetQRCodeStatus(timeoutCtx, qrResp.Qrcode)
if err != nil {
// Long poll timeout or temporary error
continue
@@ -99,6 +100,27 @@ func PerformLoginInteractive(
})
return statusResp.BotToken, statusResp.IlinkUserID, statusResp.IlinkBotID, statusResp.Baseurl, nil
+ case "scaned_but_redirect":
+ if statusResp.RedirectHost == "" {
+ logger.WarnC(
+ "weixin",
+ "scaned_but_redirect received without redirect_host; continuing on current host",
+ )
+ continue
+ }
+ nextBaseURL := "https://" + statusResp.RedirectHost + "/"
+ nextAPI, nextErr := NewApiClient(nextBaseURL, "", opts.Proxy)
+ if nextErr != nil {
+ logger.WarnCF("weixin", "Failed to switch QR polling host", map[string]any{
+ "redirect_host": statusResp.RedirectHost,
+ "error": nextErr.Error(),
+ })
+ continue
+ }
+ pollAPI = nextAPI
+ logger.InfoCF("weixin", "Switched QR polling host", map[string]any{
+ "redirect_host": statusResp.RedirectHost,
+ })
case "expired":
return "", "", "", "", fmt.Errorf("qrcode expired, please try again")
default:
diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go
index 72af27438..cf1b45612 100644
--- a/pkg/channels/weixin/media.go
+++ b/pkg/channels/weixin/media.go
@@ -34,6 +34,8 @@ const (
weixinMediaMaxBytes = 100 << 20
weixinTypingKeepAlive = 5 * time.Second
weixinUploadRetryMax = 3
+ weixinDownloadRetryMax = 2
+ weixinDownloadRetryDelay = 300 * time.Millisecond
weixinVoiceTranscodeTimeout = 15 * time.Second
)
@@ -163,49 +165,108 @@ func buildCDNDownloadURL(base, encryptedQueryParam string) string {
"/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam)
}
+func shouldRetryCDNDownload(statusCode int) bool {
+ // statusCode=0 represents transport/build errors from the HTTP client.
+ return statusCode == 0 || statusCode >= 500 || statusCode == http.StatusTooManyRequests
+}
+
func buildCDNUploadURL(base, uploadParam, filekey string) string {
return strings.TrimRight(base, "/") +
"/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) +
"&filekey=" + url.QueryEscape(filekey)
}
-func (c *WeixinChannel) downloadCDNBuffer(ctx context.Context, encryptedQueryParam string) ([]byte, error) {
- req, err := http.NewRequestWithContext(
- ctx,
- http.MethodGet,
- buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam),
- nil,
- )
+func uniqCDNURLs(urls []string) []string {
+ seen := make(map[string]struct{}, len(urls))
+ out := make([]string, 0, len(urls))
+ for _, raw := range urls {
+ u := strings.TrimSpace(raw)
+ if u == "" {
+ continue
+ }
+ if _, ok := seen[u]; ok {
+ continue
+ }
+ seen[u] = struct{}{}
+ out = append(out, u)
+ }
+ return out
+}
+
+func (c *WeixinChannel) downloadCDNBufferOnce(ctx context.Context, downloadURL string) ([]byte, int, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
- return nil, err
+ return nil, 0, err
}
resp, err := c.api.HttpClient.Do(req)
if err != nil {
- return nil, err
+ return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
- return nil, fmt.Errorf("cdn download HTTP %d: %s", resp.StatusCode, string(body))
+ return nil, resp.StatusCode, fmt.Errorf("cdn download HTTP %d: %s", resp.StatusCode, string(body))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1))
if err != nil {
- return nil, err
+ return nil, resp.StatusCode, err
}
if len(data) > weixinMediaMaxBytes {
- return nil, fmt.Errorf("cdn media too large: %d bytes", len(data))
+ return nil, resp.StatusCode, fmt.Errorf("cdn media too large: %d bytes", len(data))
}
- return data, nil
+ return data, resp.StatusCode, nil
+}
+
+func (c *WeixinChannel) downloadCDNBuffer(
+ ctx context.Context,
+ encryptedQueryParam,
+ fullURL string,
+) ([]byte, error) {
+ candidates := uniqCDNURLs([]string{
+ strings.TrimSpace(fullURL),
+ func() string {
+ if strings.TrimSpace(encryptedQueryParam) == "" {
+ return ""
+ }
+ return buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam)
+ }(),
+ })
+ if len(candidates) == 0 {
+ return nil, fmt.Errorf("missing CDN download URL")
+ }
+
+ var lastErr error
+ for _, downloadURL := range candidates {
+ for attempt := 1; attempt <= weixinDownloadRetryMax; attempt++ {
+ data, statusCode, err := c.downloadCDNBufferOnce(ctx, downloadURL)
+ if err == nil {
+ return data, nil
+ }
+ lastErr = fmt.Errorf("%w (attempt=%d url=%s)", err, attempt, downloadURL)
+ if !shouldRetryCDNDownload(statusCode) {
+ break
+ }
+ if attempt < weixinDownloadRetryMax {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(weixinDownloadRetryDelay):
+ }
+ }
+ }
+ }
+ return nil, lastErr
}
func (c *WeixinChannel) downloadAndDecryptCDNBuffer(
ctx context.Context,
encryptedQueryParam string,
+ fullURL string,
key []byte,
) ([]byte, error) {
- data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam)
+ data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam, fullURL)
if err != nil {
return nil, err
}
@@ -215,6 +276,33 @@ func (c *WeixinChannel) downloadAndDecryptCDNBuffer(
return decryptAESECB(data, key)
}
+func (c *WeixinChannel) downloadImageBuffer(
+ ctx context.Context,
+ img *ImageItem,
+ key []byte,
+) ([]byte, error) {
+ if img == nil {
+ return nil, fmt.Errorf("image item is nil")
+ }
+ if img.Media != nil {
+ data, err := c.downloadAndDecryptCDNBuffer(ctx, img.Media.EncryptQueryParam, img.Media.FullURL, key)
+ if err == nil {
+ return data, nil
+ }
+ if img.ThumbMedia == nil {
+ return nil, fmt.Errorf("image download failed: %w", err)
+ }
+ }
+ if img.ThumbMedia != nil {
+ data, err := c.downloadAndDecryptCDNBuffer(ctx, img.ThumbMedia.EncryptQueryParam, img.ThumbMedia.FullURL, key)
+ if err == nil {
+ return data, nil
+ }
+ return nil, fmt.Errorf("image download failed: %w", err)
+ }
+ return nil, fmt.Errorf("image media is nil")
+}
+
func detectMediaMetadata(data []byte, fallbackName, fallbackContentType string) (string, string) {
contentType := strings.TrimSpace(fallbackContentType)
ext := filepath.Ext(fallbackName)
@@ -310,15 +398,18 @@ func isDownloadableMediaItem(item *MessageItem) bool {
switch item.Type {
case MessageItemTypeImage:
- return item.ImageItem != nil && item.ImageItem.Media != nil && item.ImageItem.Media.EncryptQueryParam != ""
+ return item.ImageItem != nil && item.ImageItem.Media != nil &&
+ (item.ImageItem.Media.EncryptQueryParam != "" || item.ImageItem.Media.FullURL != "")
case MessageItemTypeVideo:
- return item.VideoItem != nil && item.VideoItem.Media != nil && item.VideoItem.Media.EncryptQueryParam != ""
+ return item.VideoItem != nil && item.VideoItem.Media != nil &&
+ (item.VideoItem.Media.EncryptQueryParam != "" || item.VideoItem.Media.FullURL != "")
case MessageItemTypeFile:
- return item.FileItem != nil && item.FileItem.Media != nil && item.FileItem.Media.EncryptQueryParam != ""
+ return item.FileItem != nil && item.FileItem.Media != nil &&
+ (item.FileItem.Media.EncryptQueryParam != "" || item.FileItem.Media.FullURL != "")
case MessageItemTypeVoice:
return item.VoiceItem != nil &&
item.VoiceItem.Media != nil &&
- item.VoiceItem.Media.EncryptQueryParam != "" &&
+ (item.VoiceItem.Media.EncryptQueryParam != "" || item.VoiceItem.Media.FullURL != "") &&
strings.TrimSpace(item.VoiceItem.Text) == ""
default:
return false
@@ -434,16 +525,20 @@ func (c *WeixinChannel) downloadMediaFromItem(
switch item.Type {
case MessageItemTypeImage:
+ if item.ImageItem == nil {
+ return "", fmt.Errorf("image media is nil")
+ }
key, ok, err := imageAESKey(item.ImageItem)
if err != nil {
return "", err
}
- data, err := c.downloadAndDecryptCDNBuffer(ctx, item.ImageItem.Media.EncryptQueryParam, func() []byte {
+ decryptKey := func() []byte {
if ok {
return key
}
return nil
- }())
+ }()
+ data, err := c.downloadImageBuffer(ctx, item.ImageItem, decryptKey)
if err != nil {
return "", err
}
@@ -454,7 +549,12 @@ func (c *WeixinChannel) downloadMediaFromItem(
if err != nil {
return "", err
}
- silk, err := c.downloadAndDecryptCDNBuffer(ctx, item.VoiceItem.Media.EncryptQueryParam, key)
+ silk, err := c.downloadAndDecryptCDNBuffer(
+ ctx,
+ item.VoiceItem.Media.EncryptQueryParam,
+ item.VoiceItem.Media.FullURL,
+ key,
+ )
if err != nil {
return "", err
}
@@ -468,7 +568,12 @@ func (c *WeixinChannel) downloadMediaFromItem(
if err != nil {
return "", err
}
- data, err := c.downloadAndDecryptCDNBuffer(ctx, item.FileItem.Media.EncryptQueryParam, key)
+ data, err := c.downloadAndDecryptCDNBuffer(
+ ctx,
+ item.FileItem.Media.EncryptQueryParam,
+ item.FileItem.Media.FullURL,
+ key,
+ )
if err != nil {
return "", err
}
@@ -484,7 +589,12 @@ func (c *WeixinChannel) downloadMediaFromItem(
if err != nil {
return "", err
}
- data, err := c.downloadAndDecryptCDNBuffer(ctx, item.VideoItem.Media.EncryptQueryParam, key)
+ data, err := c.downloadAndDecryptCDNBuffer(
+ ctx,
+ item.VideoItem.Media.EncryptQueryParam,
+ item.VideoItem.Media.FullURL,
+ key,
+ )
if err != nil {
return "", err
}
@@ -701,11 +811,13 @@ func (c *WeixinChannel) uploadLocalFile(
}
return nil, fmt.Errorf("getuploadurl failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg)
}
- if strings.TrimSpace(resp.UploadParam) == "" {
- return nil, fmt.Errorf("getuploadurl returned empty upload_param")
+ uploadParam := strings.TrimSpace(resp.UploadParam)
+ uploadFullURL := strings.TrimSpace(resp.UploadFullURL)
+ if uploadParam == "" && uploadFullURL == "" {
+ return nil, fmt.Errorf("getuploadurl returned no upload URL")
}
- downloadParam, err := c.uploadBufferToCDN(ctx, data, resp.UploadParam, filekey, aesKey)
+ downloadParam, err := c.uploadBufferToCDN(ctx, data, uploadParam, uploadFullURL, filekey, aesKey)
if err != nil {
return nil, err
}
@@ -723,6 +835,7 @@ func (c *WeixinChannel) uploadBufferToCDN(
ctx context.Context,
plaintext []byte,
uploadParam,
+ uploadFullURL,
filekey string,
aesKey []byte,
) (string, error) {
@@ -731,7 +844,13 @@ func (c *WeixinChannel) uploadBufferToCDN(
return "", err
}
- uploadURL := buildCDNUploadURL(c.cdnBaseURL(), uploadParam, filekey)
+ uploadURL := strings.TrimSpace(uploadFullURL)
+ if uploadURL == "" {
+ if strings.TrimSpace(uploadParam) == "" {
+ return "", fmt.Errorf("missing CDN upload URL")
+ }
+ uploadURL = buildCDNUploadURL(c.cdnBaseURL(), uploadParam, filekey)
+ }
var lastErr error
for attempt := 1; attempt <= weixinUploadRetryMax; attempt++ {
@@ -978,12 +1097,12 @@ func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(),
}
// SendMedia implements channels.MediaSender.
-func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
+func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
- return basechannels.ErrNotRunning
+ return nil, basechannels.ErrNotRunning
}
if err := c.ensureSessionActive(); err != nil {
- return err
+ return nil, err
}
contextToken := ""
@@ -991,7 +1110,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
contextToken, _ = v.(string)
}
if contextToken == "" {
- return fmt.Errorf(
+ return nil, fmt.Errorf(
"weixin send media: missing context token for chat %s: %w",
msg.ChatID,
basechannels.ErrSendFailed,
@@ -1006,7 +1125,7 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
"ref": part.Ref,
"error": err.Error(),
})
- return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
}
func() {
if cleanup != nil {
@@ -1028,11 +1147,11 @@ func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess
"error": err.Error(),
})
if c.remainingPause() > 0 {
- return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
}
- return fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
+ return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
}
}
- return nil
+ return nil, nil
}
diff --git a/pkg/channels/weixin/state.go b/pkg/channels/weixin/state.go
index 2d1b9f4a6..8fbdd00dd 100644
--- a/pkg/channels/weixin/state.go
+++ b/pkg/channels/weixin/state.go
@@ -36,22 +36,29 @@ type syncCursorFile struct {
GetUpdatesBuf string `json:"get_updates_buf"`
}
+type contextTokensFile struct {
+ Tokens map[string]string `json:"tokens"`
+}
+
func picoclawHomeDir() string {
- if home := os.Getenv(config.EnvHome); home != "" {
- return home
+ return config.GetHome()
+}
+
+func genWeixinAccountKey(cfg config.WeixinConfig) string {
+ token := strings.TrimSpace(cfg.Token.String())
+ if token == "" {
+ return "default"
}
- userHome, _ := os.UserHomeDir()
- return filepath.Join(userHome, ".picoclaw")
+ sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token))
+ return hex.EncodeToString(sum[:8])
}
func buildWeixinSyncBufPath(cfg config.WeixinConfig) string {
- key := "default"
- token := strings.TrimSpace(cfg.Token.String())
- if token != "" {
- sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token))
- key = hex.EncodeToString(sum[:8])
- }
- return filepath.Join(picoclawHomeDir(), "channels", "weixin", "sync", key+".json")
+ return filepath.Join(picoclawHomeDir(), "channels", "weixin", "sync", genWeixinAccountKey(cfg)+".json")
+}
+
+func buildWeixinContextTokensPath(cfg config.WeixinConfig) string {
+ return filepath.Join(picoclawHomeDir(), "channels", "weixin", "context-tokens", genWeixinAccountKey(cfg)+".json")
}
func loadGetUpdatesBuf(path string) (string, error) {
@@ -79,6 +86,29 @@ func saveGetUpdatesBuf(path, cursor string) error {
return fileutil.WriteFileAtomic(path, data, 0o600)
}
+func loadContextTokens(path string) (map[string]string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var decoded contextTokensFile
+ if err := json.Unmarshal(data, &decoded); err != nil {
+ return nil, err
+ }
+ return decoded.Tokens, nil
+}
+
+func saveContextTokens(path string, tokens map[string]string) error {
+ data, err := json.Marshal(contextTokensFile{Tokens: tokens})
+ if err != nil {
+ return err
+ }
+ return fileutil.WriteFileAtomic(path, data, 0o600)
+}
+
func (c *WeixinChannel) cdnBaseURL() string {
if base := strings.TrimSpace(c.config.CDNBaseURL); base != "" {
return strings.TrimRight(base, "/")
diff --git a/pkg/channels/weixin/types.go b/pkg/channels/weixin/types.go
index 74c6e63c3..f2c03894f 100644
--- a/pkg/channels/weixin/types.go
+++ b/pkg/channels/weixin/types.go
@@ -38,6 +38,7 @@ type GetUploadUrlResp struct {
APIStatus
UploadParam string `json:"upload_param,omitempty"`
ThumbUploadParam string `json:"thumb_upload_param,omitempty"`
+ UploadFullURL string `json:"upload_full_url,omitempty"`
}
const (
@@ -69,6 +70,7 @@ type CDNMedia struct {
EncryptQueryParam string `json:"encrypt_query_param,omitempty"`
AesKey string `json:"aes_key,omitempty"` // base64 encoded
EncryptType int `json:"encrypt_type,omitempty"`
+ FullURL string `json:"full_url,omitempty"`
}
type ImageItem struct {
@@ -202,9 +204,10 @@ type QRCodeResponse struct {
}
type StatusResponse struct {
- Status string `json:"status"` // "wait", "scaned", "confirmed", "expired"
- BotToken string `json:"bot_token,omitempty"`
- IlinkBotID string `json:"ilink_bot_id,omitempty"`
- Baseurl string `json:"baseurl,omitempty"`
- IlinkUserID string `json:"ilink_user_id,omitempty"`
+ Status string `json:"status"` // "wait", "scaned", "confirmed", "expired", "scaned_but_redirect"
+ BotToken string `json:"bot_token,omitempty"`
+ IlinkBotID string `json:"ilink_bot_id,omitempty"`
+ Baseurl string `json:"baseurl,omitempty"`
+ IlinkUserID string `json:"ilink_user_id,omitempty"`
+ RedirectHost string `json:"redirect_host,omitempty"`
}
diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go
index 937471db9..a0d0c96b5 100644
--- a/pkg/channels/weixin/weixin.go
+++ b/pkg/channels/weixin/weixin.go
@@ -26,12 +26,13 @@ type WeixinChannel struct {
bus *bus.MessageBus
// contextTokens stores the last context_token per user (from_user_id → context_token).
// This is required by the iLink API to associate replies with the right chat session.
- contextTokens sync.Map
- typingMu sync.Mutex
- typingCache map[string]typingTicketCacheEntry
- pauseMu sync.Mutex
- pauseUntil time.Time
- syncBufPath string
+ contextTokens sync.Map
+ typingMu sync.Mutex
+ typingCache map[string]typingTicketCacheEntry
+ pauseMu sync.Mutex
+ pauseUntil time.Time
+ syncBufPath string
+ contextTokensPath string
}
func init() {
@@ -57,12 +58,13 @@ func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*Wei
)
return &WeixinChannel{
- BaseChannel: base,
- api: api,
- config: cfg,
- bus: messageBus,
- typingCache: make(map[string]typingTicketCacheEntry),
- syncBufPath: buildWeixinSyncBufPath(cfg),
+ BaseChannel: base,
+ api: api,
+ config: cfg,
+ bus: messageBus,
+ typingCache: make(map[string]typingTicketCacheEntry),
+ syncBufPath: buildWeixinSyncBufPath(cfg),
+ contextTokensPath: buildWeixinContextTokensPath(cfg),
}, nil
}
@@ -70,11 +72,53 @@ func (c *WeixinChannel) Start(ctx context.Context) error {
logger.InfoC("weixin", "Starting Weixin channel")
c.ctx, c.cancel = context.WithCancel(ctx)
c.SetRunning(true)
+ c.restoreContextTokens()
go c.pollLoop(c.ctx)
logger.InfoC("weixin", "Weixin channel started")
return nil
}
+// restoreContextTokens loads persisted context tokens from disk into memory.
+func (c *WeixinChannel) restoreContextTokens() {
+ tokens, err := loadContextTokens(c.contextTokensPath)
+ if err != nil {
+ logger.WarnCF("weixin", "Failed to load persisted context tokens", map[string]any{
+ "path": c.contextTokensPath,
+ "error": err.Error(),
+ })
+ return
+ }
+ if len(tokens) == 0 {
+ return
+ }
+ for userID, token := range tokens {
+ c.contextTokens.Store(userID, token)
+ }
+ logger.InfoCF("weixin", "Restored context tokens from disk", map[string]any{
+ "path": c.contextTokensPath,
+ "count": len(tokens),
+ })
+}
+
+// persistContextTokens saves all in-memory context tokens to disk.
+func (c *WeixinChannel) persistContextTokens() {
+ tokens := make(map[string]string)
+ c.contextTokens.Range(func(k, v any) bool {
+ if userID, ok := k.(string); ok {
+ if token, ok := v.(string); ok {
+ tokens[userID] = token
+ }
+ }
+ return true
+ })
+ if err := saveContextTokens(c.contextTokensPath, tokens); err != nil {
+ logger.WarnCF("weixin", "Failed to persist context tokens", map[string]any{
+ "path": c.contextTokensPath,
+ "error": err.Error(),
+ })
+ }
+}
+
func (c *WeixinChannel) Stop(ctx context.Context) error {
logger.InfoC("weixin", "Stopping Weixin channel")
c.SetRunning(false)
@@ -307,22 +351,23 @@ func (c *WeixinChannel) handleInboundMessage(ctx context.Context, msg WeixinMess
// Store context_token for outbound reply association
if msg.ContextToken != "" {
c.contextTokens.Store(fromUserID, msg.ContextToken)
+ c.persistContextTokens()
}
c.HandleMessage(ctx, peer, messageID, fromUserID, fromUserID, content, mediaRefs, metadata, sender)
}
// Send implements channels.Channel by sending a text message to the WeChat user.
-func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
if err := c.ensureSessionActive(); err != nil {
- return err
+ return nil, err
}
if msg.Content == "" {
- return nil
+ return nil, nil
}
// We need a context_token to send a reply. It should be stored in the conversation metadata.
@@ -341,7 +386,7 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
logger.ErrorCF("weixin", "Missing context token, cannot send message", map[string]any{
"to_user_id": toUserID,
})
- return fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
+ return nil, fmt.Errorf("weixin send: %w: missing context token for chat %s", channels.ErrSendFailed, toUserID)
}
if err := c.sendTextMessage(ctx, toUserID, contextToken, msg.Content); err != nil {
@@ -350,12 +395,12 @@ func (c *WeixinChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
"error": err.Error(),
})
if c.remainingPause() > 0 {
- return fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
+ return nil, fmt.Errorf("weixin send: %w", channels.ErrSendFailed)
}
- return fmt.Errorf("weixin send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("weixin send: %w", channels.ErrTemporary)
}
- return nil
+ return nil, nil
}
// VoiceCapabilities returns the voice capabilities of the channel.
diff --git a/pkg/channels/weixin/weixin_test.go b/pkg/channels/weixin/weixin_test.go
index 62984c965..b41b930db 100644
--- a/pkg/channels/weixin/weixin_test.go
+++ b/pkg/channels/weixin/weixin_test.go
@@ -72,7 +72,7 @@ func TestDownloadAndDecryptCDNBuffer(t *testing.T) {
typingCache: make(map[string]typingTicketCacheEntry),
}
- got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", key)
+ got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", "", key)
if err != nil {
t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err)
}
@@ -81,6 +81,116 @@ func TestDownloadAndDecryptCDNBuffer(t *testing.T) {
}
}
+func TestDownloadAndDecryptCDNBufferUsesFullURLWhenProvided(t *testing.T) {
+ key := []byte("1234567890abcdef")
+ plaintext := []byte("hello weixin")
+ ciphertext, err := encryptAESECB(plaintext, key)
+ if err != nil {
+ t.Fatalf("encryptAESECB() error = %v", err)
+ }
+
+ fullURLAttempts := 0
+ ch := &WeixinChannel{
+ api: &ApiClient{
+ HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ if r.URL.String() == "https://full.example.com/download" {
+ fullURLAttempts++
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(bytes.NewReader(ciphertext)),
+ Header: make(http.Header),
+ }, nil
+ }
+ t.Fatalf("unexpected fallback request: %s", r.URL.String())
+ return nil, nil
+ })},
+ },
+ config: config.WeixinConfig{
+ CDNBaseURL: "https://cdn.example.com",
+ },
+ typingCache: make(map[string]typingTicketCacheEntry),
+ }
+
+ got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", "https://full.example.com/download", key)
+ if err != nil {
+ t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err)
+ }
+ if !bytes.Equal(got, plaintext) {
+ t.Fatalf("downloadAndDecryptCDNBuffer() = %q, want %q", got, plaintext)
+ }
+ if fullURLAttempts == 0 {
+ t.Fatalf("fullURLAttempts = %d, want > 0", fullURLAttempts)
+ }
+}
+
+func TestDownloadAndDecryptCDNBufferFallsBackToConstructedURLWhenFullURLFails(t *testing.T) {
+ key := []byte("1234567890abcdef")
+ plaintext := []byte("hello weixin")
+ ciphertext, err := encryptAESECB(plaintext, key)
+ if err != nil {
+ t.Fatalf("encryptAESECB() error = %v", err)
+ }
+
+ fullURLAttempts := 0
+ constructedAttempts := 0
+ ch := &WeixinChannel{
+ api: &ApiClient{
+ HttpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ if r.URL.String() == "https://full.example.com/download?encrypted_query_param=token&taskid=123" {
+ fullURLAttempts++
+ return &http.Response{
+ StatusCode: http.StatusInternalServerError,
+ Body: io.NopCloser(bytes.NewReader(nil)),
+ Header: make(http.Header),
+ }, nil
+ }
+ if r.URL.String() != "https://cdn.example.com/download?encrypted_query_param=token" {
+ t.Fatalf("unexpected fallback request: %s", r.URL.String())
+ }
+ constructedAttempts++
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(bytes.NewReader(ciphertext)),
+ Header: make(http.Header),
+ }, nil
+ })},
+ },
+ config: config.WeixinConfig{
+ CDNBaseURL: "https://cdn.example.com",
+ },
+ typingCache: make(map[string]typingTicketCacheEntry),
+ }
+
+ got, err := ch.downloadAndDecryptCDNBuffer(
+ context.Background(),
+ "token",
+ "https://full.example.com/download?encrypted_query_param=token&taskid=123",
+ key,
+ )
+ if err != nil {
+ t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err)
+ }
+ if !bytes.Equal(got, plaintext) {
+ t.Fatalf("downloadAndDecryptCDNBuffer() = %q, want %q", got, plaintext)
+ }
+ if fullURLAttempts == 0 {
+ t.Fatalf("fullURLAttempts = %d, want > 0", fullURLAttempts)
+ }
+ if constructedAttempts == 0 {
+ t.Fatalf("constructedAttempts = %d, want > 0", constructedAttempts)
+ }
+}
+
+func TestBuildCDNDownloadURLEscapesOpaqueToken(t *testing.T) {
+ token := "MFcCAQAESzBJAgEAAgSieMV9AgM9CcwCBEoKPqICBGnHZB0EJDk4OWY5YWU0LTc4OGItNGQ5Ni1iMjZhLWU4YjhlMmEwOWVkZgIEIR0IAgIBAAQFAExUPQA%3D"
+
+ got := buildCDNDownloadURL("https://cdn.example.com", token)
+
+ if got != "https://cdn.example.com/download?encrypted_query_param=MFcCAQAESzBJAgEAAgSieMV9AgM9CcwCBEoKPqICBGnHZB0EJDk4OWY5YWU0LTc4OGItNGQ5Ni1iMjZhLWU4YjhlMmEwOWVkZgIEIR0IAgIBAAQFAExUPQA%253D" {
+ t.Fatalf("buildCDNDownloadURL() = %q", got)
+ }
+}
+
func TestUploadBufferToCDN(t *testing.T) {
key := []byte("1234567890abcdef")
plaintext := []byte("upload me")
@@ -120,7 +230,7 @@ func TestUploadBufferToCDN(t *testing.T) {
typingCache: make(map[string]typingTicketCacheEntry),
}
- got, err := ch.uploadBufferToCDN(context.Background(), plaintext, "upload-param", "file-key", key)
+ got, err := ch.uploadBufferToCDN(context.Background(), plaintext, "upload-param", "", "file-key", key)
if err != nil {
t.Fatalf("uploadBufferToCDN() error = %v", err)
}
diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go
index 70b3e02bf..98622fe37 100644
--- a/pkg/channels/whatsapp/whatsapp.go
+++ b/pkg/channels/whatsapp/whatsapp.go
@@ -104,15 +104,15 @@ func (c *WhatsAppChannel) Stop(ctx context.Context) error {
return nil
}
-func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
// Check ctx before acquiring lock
select {
case <-ctx.Done():
- return ctx.Err()
+ return nil, ctx.Err()
default:
}
@@ -120,7 +120,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
defer c.mu.Unlock()
if c.conn == nil {
- return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
payload := map[string]any{
@@ -131,17 +131,17 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
data, err := json.Marshal(payload)
if err != nil {
- return fmt.Errorf("failed to marshal message: %w", err)
+ return nil, fmt.Errorf("failed to marshal message: %w", err)
}
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
_ = c.conn.SetWriteDeadline(time.Time{})
- return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
}
_ = c.conn.SetWriteDeadline(time.Time{})
- return nil
+ return nil, nil
}
func (c *WhatsAppChannel) listen() {
diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go
index 188a7c8fa..d0a74a405 100644
--- a/pkg/channels/whatsapp_native/whatsapp_native.go
+++ b/pkg/channels/whatsapp_native/whatsapp_native.go
@@ -396,13 +396,13 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender)
}
-func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
- return channels.ErrNotRunning
+ return nil, channels.ErrNotRunning
}
select {
case <-ctx.Done():
- return ctx.Err()
+ return nil, ctx.Err()
default:
}
@@ -411,18 +411,18 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
c.mu.Unlock()
if client == nil || !client.IsConnected() {
- return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
}
// Detect unpaired state: the client is connected (to WhatsApp servers)
// but has not completed QR-login yet, so sending would fail.
if client.Store.ID == nil {
- return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary)
}
to, err := parseJID(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
+ return nil, fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err)
}
waMsg := &waE2E.Message{
@@ -430,9 +430,9 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag
}
if _, err = client.SendMessage(ctx, to, waMsg); err != nil {
- return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
+ return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
}
- return nil
+ return nil, nil
}
// parseJID converts a chat ID (phone number or JID string) to types.JID.
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 10c86bfea..7a11d1ab7 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -7,8 +7,8 @@ import (
"math/rand"
"os"
"path/filepath"
- "strings"
"sync/atomic"
+ "time"
"github.com/caarlos0/env/v11"
@@ -20,89 +20,8 @@ import (
// rrCounter is a global counter for round-robin load balancing across models.
var rrCounter atomic.Uint64
-// FlexibleStringSlice is a []string that also accepts JSON numbers,
-// so allow_from can contain both "123" and 123.
-// It also supports parsing comma-separated strings from environment variables,
-// including both English (,) and Chinese (,) commas.
-type FlexibleStringSlice []string
-
-func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
- // Accept a single JSON string for convenience, e.g.:
- // "text": "Thinking..."
- var singleString string
- if err := json.Unmarshal(data, &singleString); err == nil {
- *f = FlexibleStringSlice{singleString}
- return nil
- }
-
- // Accept a single JSON number too, to keep symmetry with mixed allow_from
- // payloads that may contain numeric identifiers.
- var singleNumber float64
- if err := json.Unmarshal(data, &singleNumber); err == nil {
- *f = FlexibleStringSlice{fmt.Sprintf("%.0f", singleNumber)}
- return nil
- }
-
- // Try []string first
- var ss []string
- if err := json.Unmarshal(data, &ss); err == nil {
- *f = ss
- return nil
- }
-
- // Try []interface{} to handle mixed types
- var raw []any
- if err := json.Unmarshal(data, &raw); err != nil {
- var s string
- // fail over to compatible to old format string
- if err = json.Unmarshal(data, &s); err != nil {
- return err
- }
- *f = []string{s}
- return nil
- }
-
- result := make([]string, 0, len(raw))
- for _, v := range raw {
- switch val := v.(type) {
- case string:
- result = append(result, val)
- case float64:
- result = append(result, fmt.Sprintf("%.0f", val))
- default:
- result = append(result, fmt.Sprintf("%v", val))
- }
- }
- *f = result
- return nil
-}
-
-// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
-// It handles comma-separated values with both English (,) and Chinese (,) commas.
-func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
- if len(text) == 0 {
- *f = nil
- return nil
- }
-
- s := string(text)
- // Replace Chinese comma with English comma, then split
- s = strings.ReplaceAll(s, ",", ",")
- parts := strings.Split(s, ",")
-
- result := make([]string, 0, len(parts))
- for _, part := range parts {
- part = strings.TrimSpace(part)
- if part != "" {
- result = append(result, part)
- }
- }
- *f = result
- return nil
-}
-
// CurrentVersion is the latest config schema version
-const CurrentVersion = 1
+const CurrentVersion = 2
// Config is the current config structure with version support
type Config struct {
@@ -675,6 +594,11 @@ type ModelConfig struct {
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
+ // Enabled indicates whether this model entry is active. When omitted in
+ // existing configs, the field is inferred during load: models with API keys
+ // or the reserved "local-model" name are auto-enabled.
+ Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
+
// isVirtual marks this model as a virtual model generated from multi-key expansion.
// Virtual models should not be persisted to config files.
isVirtual bool
@@ -712,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 {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
@@ -1048,6 +965,35 @@ func LoadConfig(path string) (*Config, error) {
defer func(cfg *Config) {
_ = SaveConfig(path, cfg)
}(cfg)
+ case 1:
+ // V1→V2 migration: infer Enabled and migrate channel config fields
+ logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
+ cfg, err = loadConfig(data)
+ if err != nil {
+ return nil, err
+ }
+ secPath := securityPath(path)
+ err = loadSecurityConfig(cfg, secPath)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, fmt.Errorf("failed to load security config: %w", err)
+ }
+
+ oldCfg := &configV1{Config: *cfg}
+ cfg, err = oldCfg.Migrate()
+ if err != nil {
+ logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
+ return nil, err
+ }
+
+ err = makeBackup(path)
+ if err != nil {
+ return nil, err
+ }
+
+ defer func(cfg *Config) {
+ _ = SaveConfig(path, cfg)
+ }(cfg)
+ logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
case CurrentVersion:
// Current version
cfg, err = loadConfig(data)
@@ -1065,29 +1011,21 @@ func LoadConfig(path string) (*Config, error) {
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
}
- if err := env.Parse(cfg); err != nil {
+ if err = env.Parse(cfg); err != nil {
return nil, err
}
// Expand multi-key configs into separate entries for key-level failover
cfg.ModelList = expandMultiKeyModels(cfg.ModelList)
- // Migrate legacy channel config fields to new unified structures
- cfg.migrateChannelConfigs()
-
// Validate model_list for uniqueness and required fields
- if err := cfg.ValidateModelList(); err != nil {
+ if err = cfg.ValidateModelList(); err != nil {
return nil, err
}
// Ensure Workspace has a default if not set
if cfg.Agents.Defaults.Workspace == "" {
- homePath, _ := os.UserHomeDir()
- if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" {
- homePath = picoclawHome
- } else if homePath != "" {
- homePath = filepath.Join(homePath, pkg.DefaultPicoClawHome)
- }
+ homePath := GetHome()
cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName)
}
@@ -1098,12 +1036,22 @@ func makeBackup(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil
}
- // Create backup of the config file before migration
- bakPath := path + ".bak"
+ dateSuffix := time.Now().Format(".20060102.bak")
+ // Backup config file
+ bakPath := path + dateSuffix
if err := fileutil.CopyFile(path, bakPath, 0o600); err != nil {
logger.ErrorF("failed to create config backup", map[string]any{"error": err})
return fmt.Errorf("failed to create config backup: %w", err)
}
+ // Backup security config file
+ secPath := securityPath(path)
+ if _, err := os.Stat(secPath); err == nil {
+ secBakPath := secPath + dateSuffix
+ if secErr := fileutil.CopyFile(secPath, secBakPath, 0o600); secErr != nil {
+ logger.ErrorF("failed to create security backup", map[string]any{"error": secErr})
+ return fmt.Errorf("failed to create security backup: %w", secErr)
+ }
+ }
return nil
}
@@ -1119,19 +1067,6 @@ func toNameIndex(list []*ModelConfig) []string {
return nameList
}
-func (c *Config) migrateChannelConfigs() {
- // Discord: mention_only -> group_trigger.mention_only
- if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
- c.Channels.Discord.GroupTrigger.MentionOnly = true
- }
-
- // OneBot: group_trigger_prefix -> group_trigger.prefixes
- if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
- len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
- c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
- }
-}
-
func SaveConfig(path string, cfg *Config) error {
if cfg.Version < CurrentVersion {
cfg.Version = CurrentVersion
@@ -1145,6 +1080,10 @@ func SaveConfig(path string, cfg *Config) error {
}
// Temporarily replace ModelList with filtered version for serialization
originalModelList := cfg.ModelList
+ defer func() {
+ // Restore original ModelList after serialization
+ cfg.ModelList = originalModelList
+ }()
cfg.ModelList = nonVirtualModels
if err := saveSecurityConfig(securityPath(path), cfg); err != nil {
@@ -1153,8 +1092,6 @@ func SaveConfig(path string, cfg *Config) error {
}
data, err := json.MarshalIndent(cfg, "", " ")
- // Restore original ModelList after serialization
- cfg.ModelList = originalModelList
if err != nil {
return err
}
@@ -1224,29 +1161,6 @@ func (c *Config) SecurityCopyFrom(path string) error {
return loadSecurityConfig(c, securityPath(path))
}
-func MergeAPIKeys(apiKey string, apiKeys []string) []string {
- seen := make(map[string]struct{})
- var all []string
-
- if k := strings.TrimSpace(apiKey); k != "" {
- if _, exists := seen[k]; !exists {
- seen[k] = struct{}{}
- all = append(all, k)
- }
- }
-
- for _, k := range apiKeys {
- if trimmed := strings.TrimSpace(k); trimmed != "" {
- if _, exists := seen[trimmed]; !exists {
- seen[trimmed] = struct{}{}
- all = append(all, trimmed)
- }
- }
- }
-
- return all
-}
-
// expandMultiKeyModels expands ModelConfig entries with multiple API keys into
// separate entries for key-level failover. Each key gets its own ModelConfig entry,
// and the original entry's fallbacks are set up to chain through the expanded entries.
diff --git a/pkg/config/config_old.go b/pkg/config/config_old.go
index fd54c9e08..150275aac 100644
--- a/pkg/config/config_old.go
+++ b/pkg/config/config_old.go
@@ -734,7 +734,8 @@ func (c *configV0) Migrate() (*Config, error) {
// Convert []modelConfigV0 to []ModelConfig
cfg.ModelList = make([]*ModelConfig, len(c.ModelList))
for i, m := range c.ModelList {
- cfg.ModelList[i] = &ModelConfig{
+ mergedKeys := toSecureStrings(mergeAPIKeys(m.APIKey, m.APIKeys))
+ mc := &ModelConfig{
ModelName: m.ModelName,
Model: m.Model,
APIBase: m.APIBase,
@@ -747,8 +748,13 @@ func (c *configV0) Migrate() (*Config, error) {
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
- APIKeys: toSecureStrings(MergeAPIKeys(m.APIKey, m.APIKeys)),
+ APIKeys: mergedKeys,
}
+ // Infer Enabled during V0→V1 migration
+ if len(mergedKeys) > 0 || m.ModelName == "local-model" {
+ mc.Enabled = true
+ }
+ cfg.ModelList[i] = mc
}
}
@@ -756,6 +762,52 @@ func (c *configV0) Migrate() (*Config, error) {
return cfg, nil
}
+type configV1 struct {
+ Config
+}
+
+// Migrate applies V1→Current Version migrations to an already-loaded Config.
+//
+// It must be called AFTER loadSecurityConfig so that API keys (which live in
+// the security file) are available for the Enabled inference.
+func (c *configV1) Migrate() (*Config, error) {
+ c.migrateModelEnabled()
+ c.migrateChannelConfigs()
+ return &c.Config, nil
+}
+
+// migrateModelEnabled infers the Enabled field for models loaded from V1 configs
+// that predate the field (JSON where "enabled" is absent).
+//
+// Rules (only applied when Enabled has not been explicitly set by the user):
+// - Models with API keys are considered enabled.
+// - The reserved "local-model" entry is considered enabled.
+func (cfg *configV1) migrateModelEnabled() {
+ for _, m := range cfg.ModelList {
+ if m.Enabled {
+ continue
+ }
+ if len(m.APIKeys) > 0 || m.ModelName == "local-model" {
+ m.Enabled = true
+ }
+ }
+}
+
+// migrateChannelConfigs migrates legacy channel config fields in a V1 Config
+// to the new unified structures.
+func (cfg *configV1) migrateChannelConfigs() {
+ // Discord: mention_only -> group_trigger.mention_only
+ if cfg.Channels.Discord.MentionOnly && !cfg.Channels.Discord.GroupTrigger.MentionOnly {
+ cfg.Channels.Discord.GroupTrigger.MentionOnly = true
+ }
+
+ // OneBot: group_trigger_prefix -> group_trigger.prefixes
+ if len(cfg.Channels.OneBot.GroupTriggerPrefix) > 0 &&
+ len(cfg.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
+ cfg.Channels.OneBot.GroupTrigger.Prefixes = cfg.Channels.OneBot.GroupTriggerPrefix
+ }
+}
+
type webToolsConfigV0 struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
Brave braveConfigV0 ` json:"brave"`
@@ -791,7 +843,7 @@ func (v *braveConfigV0) ToBraveConfig() BraveConfig {
return BraveConfig{
Enabled: v.Enabled,
MaxResults: v.MaxResults,
- APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
+ APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)),
}
}
@@ -808,7 +860,7 @@ func (v *tavilyConfigV0) ToTavilyConfig() TavilyConfig {
Enabled: v.Enabled,
BaseURL: v.BaseURL,
MaxResults: v.MaxResults,
- APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
+ APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)),
}
}
@@ -823,7 +875,7 @@ func (v *perplexityConfigV0) ToPerplexityConfig() PerplexityConfig {
return PerplexityConfig{
Enabled: v.Enabled,
MaxResults: v.MaxResults,
- APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
+ APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)),
}
}
diff --git a/pkg/config/config_struct.go b/pkg/config/config_struct.go
new file mode 100644
index 000000000..0b8dd85c8
--- /dev/null
+++ b/pkg/config/config_struct.go
@@ -0,0 +1,327 @@
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+
+ "gopkg.in/yaml.v3"
+
+ "github.com/sipeed/picoclaw/pkg/credential"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+// FlexibleStringSlice is a []string that also accepts JSON numbers,
+// so allow_from can contain both "123" and 123.
+// It also supports parsing comma-separated strings from environment variables,
+// including both English (,) and Chinese (,) commas.
+type FlexibleStringSlice []string
+
+func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
+ // Accept a single JSON string for convenience, e.g.:
+ // "text": "Thinking..."
+ var singleString string
+ if err := json.Unmarshal(data, &singleString); err == nil {
+ *f = FlexibleStringSlice{singleString}
+ return nil
+ }
+
+ // Accept a single JSON number too, to keep symmetry with mixed allow_from
+ // payloads that may contain numeric identifiers.
+ var singleNumber float64
+ if err := json.Unmarshal(data, &singleNumber); err == nil {
+ *f = FlexibleStringSlice{fmt.Sprintf("%.0f", singleNumber)}
+ return nil
+ }
+
+ // Try []string first
+ var ss []string
+ if err := json.Unmarshal(data, &ss); err == nil {
+ *f = ss
+ return nil
+ }
+
+ // Try []interface{} to handle mixed types
+ var raw []any
+ if err := json.Unmarshal(data, &raw); err != nil {
+ var s string
+ // fail over to compatible to old format string
+ if err = json.Unmarshal(data, &s); err != nil {
+ return err
+ }
+ *f = []string{s}
+ return nil
+ }
+
+ result := make([]string, 0, len(raw))
+ for _, v := range raw {
+ switch val := v.(type) {
+ case string:
+ result = append(result, val)
+ case float64:
+ result = append(result, fmt.Sprintf("%.0f", val))
+ default:
+ result = append(result, fmt.Sprintf("%v", val))
+ }
+ }
+ *f = result
+ return nil
+}
+
+// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
+// It handles comma-separated values with both English (,) and Chinese (,) commas.
+func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
+ if len(text) == 0 {
+ *f = nil
+ return nil
+ }
+
+ s := string(text)
+ // Replace Chinese comma with English comma, then split
+ s = strings.ReplaceAll(s, ",", ",")
+ parts := strings.Split(s, ",")
+
+ result := make([]string, 0, len(parts))
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if part != "" {
+ result = append(result, part)
+ }
+ }
+ *f = result
+ return nil
+}
+
+const (
+ notHere = `"[NOT_HERE]"`
+)
+
+// SecureStrings is a slice of SecureString
+type SecureStrings []*SecureString
+
+// Values returns the decrypted/resolved values
+func (s *SecureStrings) Values() []string {
+ if s == nil {
+ return nil
+ }
+ keys := make([]string, len(*s))
+ for i, k := range *s {
+ keys[i] = k.String()
+ }
+ return unique(keys)
+}
+
+func SimpleSecureStrings(val ...string) SecureStrings {
+ val = unique(val)
+ vv := make(SecureStrings, len(val))
+ for i, s := range val {
+ vv[i] = NewSecureString(s)
+ }
+ return vv
+}
+
+// unique returns a new slice with duplicate elements removed.
+func unique[T comparable](input []T) []T {
+ m := make(map[T]struct{})
+ var result []T
+ for _, v := range input {
+ if _, ok := m[v]; !ok {
+ m[v] = struct{}{}
+ result = append(result, v)
+ }
+ }
+ return result
+}
+
+func (s SecureStrings) MarshalJSON() ([]byte, error) {
+ return []byte(notHere), nil
+}
+
+func (s *SecureStrings) UnmarshalJSON(value []byte) error {
+ if string(value) == notHere {
+ return nil
+ }
+ var v []*SecureString
+ err := json.Unmarshal(value, &v)
+ if err != nil {
+ return err
+ }
+ *s = v
+ return nil
+}
+
+// SecureString the string value that can be decrypted or resolved
+//
+//nolint:recvcheck
+type SecureString struct {
+ resolved string // Decrypted/resolved value returned by String()
+ raw string // Persisted raw value (enc://, file://, or plaintext)
+}
+
+func callerFromYaml() bool {
+ _, file, _, ok := runtime.Caller(2)
+ if ok {
+ d := filepath.Dir(file)
+ // check the caller is from yaml.v
+ if !strings.Contains(d, "yaml.v") {
+ return true
+ }
+ }
+ return false
+}
+
+// IsZero returns true if the SecureString is empty
+// if caller not yaml, just return true for prevent marshal this field
+func (s SecureString) IsZero() bool {
+ if callerFromYaml() {
+ return true
+ }
+ return s.resolved == ""
+}
+
+func NewSecureString(value string) *SecureString {
+ s := &SecureString{}
+ if err := s.fromRaw(value); err != nil {
+ logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err))
+ }
+ return s
+}
+
+func (s *SecureString) String() string {
+ if s == nil {
+ return ""
+ }
+ return s.resolved
+}
+
+func (s *SecureString) Set(value string) *SecureString {
+ s.resolved = value
+ s.raw = ""
+ return s
+}
+
+func (s SecureString) MarshalJSON() ([]byte, error) {
+ return []byte(notHere), nil
+}
+
+func (s *SecureString) UnmarshalJSON(value []byte) error {
+ if string(value) == notHere {
+ return nil
+ }
+ var v string
+ if err := json.Unmarshal(value, &v); err != nil {
+ return err
+ }
+ return s.fromRaw(v)
+}
+
+func (s SecureString) MarshalYAML() (any, error) {
+ // Preserve raw value if it is already a reference (enc:// or file://)
+ if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) {
+ return s.raw, nil
+ }
+ // If resolved is a reference format (e.g. set via Set), copy back to raw
+ if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) {
+ s.raw = s.resolved
+ return s.raw, nil
+ }
+ // Try to encrypt the resolved value
+ if passphrase := credential.PassphraseProvider(); passphrase != "" {
+ encrypted, err := credential.Encrypt(passphrase, "", s.resolved)
+ if err != nil {
+ logger.Errorf("Encrypt error: %v", err)
+ return nil, err
+ }
+ s.raw = encrypted
+ } else {
+ s.raw = s.resolved
+ }
+ return s.raw, nil
+}
+
+func (s *SecureString) UnmarshalYAML(value *yaml.Node) error {
+ return s.fromRaw(value.Value)
+}
+
+func (s *SecureString) fromRaw(v string) error {
+ s.raw = v
+ vv, err := resolveKey(v)
+ if err != nil {
+ return err
+ }
+ s.resolved = vv
+ return nil
+}
+
+var (
+ secResolverMu sync.RWMutex
+ secResolver *credential.Resolver
+)
+
+func updateResolver(path string) {
+ secResolverMu.Lock()
+ defer secResolverMu.Unlock()
+ secResolver = credential.NewResolver(path)
+}
+
+func resolveKey(v string) (string, error) {
+ secResolverMu.RLock()
+ resolver := secResolver
+ secResolverMu.RUnlock()
+ if resolver == nil {
+ resolver = credential.NewResolver("")
+ }
+ if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") {
+ decrypted, err := resolver.Resolve(v)
+ if err != nil {
+ logger.Errorf("Resolve error: %v", err)
+ return "", err
+ }
+ return decrypted, nil
+ }
+ return v, nil
+}
+
+func (s *SecureString) UnmarshalText(text []byte) error {
+ v := string(text)
+ return s.fromRaw(v)
+}
+
+type SecureModelList []*ModelConfig
+
+func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error {
+ mm := make(map[string]*ModelConfig)
+ if err := value.Decode(&mm); err != nil {
+ logger.Errorf("Decode error: %v", err)
+ return err
+ }
+ nameList := toNameIndex(*v)
+ for i, m := range *v {
+ sec := mm[nameList[i]]
+ if sec == nil {
+ sec = mm[m.ModelName]
+ }
+ if sec != nil {
+ m.APIKeys = sec.APIKeys
+ }
+ }
+ return nil
+}
+
+func (v SecureModelList) MarshalYAML() (any, error) {
+ type onlySecureData struct {
+ APIKeys SecureStrings `yaml:"api_keys,omitempty"`
+ }
+ mm := make(map[string]onlySecureData)
+ nameList := toNameIndex(v)
+ for i, m := range v {
+ mm[nameList[i]] = onlySecureData{
+ APIKeys: m.APIKeys,
+ }
+ }
+
+ return mm, nil
+}
diff --git a/pkg/config/config_struct_test.go b/pkg/config/config_struct_test.go
new file mode 100644
index 000000000..674b6a064
--- /dev/null
+++ b/pkg/config/config_struct_test.go
@@ -0,0 +1,145 @@
+package config
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/caarlos0/env/v11"
+ "github.com/stretchr/testify/assert"
+ "gopkg.in/yaml.v3"
+
+ "github.com/sipeed/picoclaw/pkg/credential"
+)
+
+func TestLoadSecurityValue(t *testing.T) {
+ type valueStruct struct {
+ Url string `json:"url,omitempty" yaml:"-"`
+ Token *SecureString `json:"token,omitempty" yaml:"token,omitempty" env:"PICO_TOKEN"`
+ ApiKeys SecureStrings `json:"api_keys,omitempty" yaml:"api_keys,omitempty" env:"PICO_API_KEYS"`
+ }
+
+ type testStruct struct {
+ Pico *valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
+ }
+
+ v1 := &testStruct{
+ Pico: &valueStruct{
+ Url: "https://example.com",
+ Token: NewSecureString("token1"),
+ ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
+ },
+ }
+ bytes, err := yaml.Marshal(v1)
+ assert.NoError(t, err)
+ jsonBytes, err := json.Marshal(v1)
+ assert.NoError(t, err)
+ const want = `pico:
+ token: token1
+ api_keys:
+ - api-key1
+ - api-key2
+`
+ const jsonPost = `{"pico":{"url":"https://example.com","token":"token0"}}`
+ v0 := &testStruct{}
+ err = json.Unmarshal([]byte(jsonPost), v0)
+ assert.NoError(t, err)
+ assert.Equal(t, "https://example.com", v0.Pico.Url)
+ assert.Equal(t, "token0", v0.Pico.Token.String())
+
+ const jsonWant = `{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}`
+ assert.Equal(t, want, string(bytes))
+ assert.Equal(t, jsonWant, string(jsonBytes))
+
+ v2 := &testStruct{}
+ err = json.Unmarshal(jsonBytes, v2)
+ assert.NoError(t, err)
+ err = yaml.Unmarshal(bytes, v2)
+ assert.NoError(t, err)
+ assert.Equal(t, "https://example.com", v2.Pico.Url)
+ if v2.Pico.Token != nil {
+ assert.Equal(t, "token1", v2.Pico.Token.String())
+ assert.Equal(t, "token1", v2.Pico.Token.raw)
+ }
+
+ v2.Pico.Token = NewSecureString("token1")
+ v2.Pico.Token.raw = "abc"
+ err = yaml.Unmarshal(bytes, v2)
+ assert.NoError(t, err)
+ assert.Equal(t, "token1", v2.Pico.Token.raw)
+
+ os.Setenv("PICO_TOKEN", "token_env")
+ err = env.Parse(v2)
+ assert.NoError(t, err)
+ assert.NotNil(t, v2.Pico.Token)
+ assert.Equal(t, "token1", v2.Pico.Token.String())
+
+ v3 := &testStruct{Pico: &valueStruct{}}
+ err = env.Parse(v3)
+ assert.NoError(t, err)
+ if v3.Pico.Token != nil {
+ assert.Equal(t, "token_env", v3.Pico.Token.String())
+ }
+
+ type toolsStruct struct {
+ Pico valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
+ }
+
+ type testStruct2 struct {
+ Tools toolsStruct `json:"tools,omitempty" yaml:",inline"`
+ }
+
+ v4 := &testStruct2{
+ Tools: toolsStruct{
+ Pico: valueStruct{
+ Url: "https://example.com",
+ Token: NewSecureString("token1"),
+ ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
+ },
+ },
+ }
+ bytes, err = yaml.Marshal(v4)
+ assert.NoError(t, err)
+ assert.Equal(t, want, string(bytes))
+ jsonBytes, err = json.Marshal(v4)
+ assert.NoError(t, err)
+ assert.Equal(
+ t,
+ `{"tools":{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}}`,
+ string(jsonBytes),
+ )
+
+ v5 := &testStruct2{}
+ err = json.Unmarshal(jsonBytes, v5)
+ assert.NoError(t, err)
+ assert.Equal(t, "https://example.com", v5.Tools.Pico.Url)
+ err = yaml.Unmarshal(bytes, v5)
+ assert.NoError(t, err)
+ assert.NotNil(t, v5.Tools.Pico.Token)
+ assert.Equal(t, "token1", v5.Tools.Pico.Token.raw)
+
+ dir := t.TempDir()
+ sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
+ if err = os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil {
+ t.Fatalf("setup: %v", err)
+ }
+
+ const passphrase = "test-passphrase-32bytes-long-ok!"
+
+ t.Setenv(credential.SSHKeyPathEnvVar, sshKeyPath)
+
+ t.Setenv(credential.PassphraseEnvVar, passphrase)
+
+ v5.Tools.Pico.Token.Set("newtoken1")
+ v5.Tools.Pico.ApiKeys[0].Set("newapi-key1")
+ bytes, err = yaml.Marshal(v5)
+ assert.NoError(t, err)
+ t.Logf("yaml: %s", string(bytes))
+
+ v6 := &testStruct2{}
+ err = yaml.Unmarshal(bytes, v6)
+ assert.NoError(t, err)
+ assert.NotNil(t, v6.Tools.Pico.Token)
+ assert.Equal(t, "newtoken1", v6.Tools.Pico.Token.String())
+}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 75eb458b8..278dfa43a 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -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) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json")
@@ -1673,3 +1705,163 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
})
}
}
+
+// ---------------------------------------------------------------------------
+// makeBackup tests
+// ---------------------------------------------------------------------------
+
+// TestMakeBackup_WithDateSuffix verifies backup files include a date suffix.
+func TestMakeBackup_WithDateSuffix(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ if err := os.WriteFile(configPath, []byte(`{"version":2}`), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ if err := makeBackup(configPath); err != nil {
+ t.Fatalf("makeBackup: %v", err)
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatalf("ReadDir: %v", err)
+ }
+
+ var hasDatedBackup bool
+ for _, e := range entries {
+ if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
+ hasDatedBackup = true
+ // Verify backup content matches original
+ bakPath := filepath.Join(dir, e.Name())
+ data, err := os.ReadFile(bakPath)
+ if err != nil {
+ t.Fatalf("ReadFile backup: %v", err)
+ }
+ if string(data) != `{"version":2}` {
+ t.Errorf("backup content = %q, want original content", string(data))
+ }
+ break
+ }
+ }
+ if !hasDatedBackup {
+ t.Error("expected backup file with date suffix pattern config.json.20*.bak")
+ }
+}
+
+// TestMakeBackup_AlsoBacksSecurityFile verifies that the security config file
+// is also backed up with the same date suffix.
+func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ secPath := securityPath(configPath)
+
+ os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
+ os.WriteFile(secPath, []byte(`model_list:\n test:0:\n api_keys:\n - "sk-test"\n`), 0o600)
+
+ if err := makeBackup(configPath); err != nil {
+ t.Fatalf("makeBackup: %v", err)
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatalf("ReadDir: %v", err)
+ }
+
+ configBackups := 0
+ secBackups := 0
+ for _, e := range entries {
+ if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
+ configBackups++
+ }
+ if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched {
+ secBackups++
+ }
+ }
+ if configBackups != 1 {
+ t.Errorf("expected 1 config backup, got %d", configBackups)
+ }
+ if secBackups != 1 {
+ t.Errorf("expected 1 security backup, got %d", secBackups)
+ }
+}
+
+// TestMakeBackup_NonexistentFileSkipsBackup verifies that makeBackup returns nil
+// when the config file does not exist (no error, no panic).
+func TestMakeBackup_NonexistentFileSkipsBackup(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "nonexistent.json")
+
+ if err := makeBackup(configPath); err != nil {
+ t.Fatalf("makeBackup on nonexistent file should return nil, got: %v", err)
+ }
+}
+
+// TestMakeBackup_OnlyConfigNoSecurity verifies backup succeeds when only
+// the config file exists and no security file.
+func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
+
+ if err := makeBackup(configPath); err != nil {
+ t.Fatalf("makeBackup: %v", err)
+ }
+
+ entries, _ := os.ReadDir(dir)
+ configBackups := 0
+ secBackups := 0
+ for _, e := range entries {
+ if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
+ configBackups++
+ }
+ if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched {
+ secBackups++
+ }
+ }
+ if configBackups != 1 {
+ t.Errorf("expected 1 config backup, got %d", configBackups)
+ }
+ if secBackups != 0 {
+ t.Errorf("expected 0 security backups when no security file exists, got %d", secBackups)
+ }
+}
+
+// TestMakeBackup_SameDateSuffix verifies that config and security backups
+// share the same date suffix (they are created in the same makeBackup call).
+func TestMakeBackup_SameDateSuffix(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ secPath := securityPath(configPath)
+
+ os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
+ os.WriteFile(secPath, []byte(`key: value`), 0o600)
+
+ if err := makeBackup(configPath); err != nil {
+ t.Fatalf("makeBackup: %v", err)
+ }
+
+ entries, _ := os.ReadDir(dir)
+ var configDate, secDate string
+ for _, e := range entries {
+ name := e.Name()
+ // Extract date part: after the last . before .bak
+ // e.g. config.json.20260330.bak → 20260330
+ if strings.HasPrefix(name, "config.json.") && strings.HasSuffix(name, ".bak") {
+ configDate = strings.TrimPrefix(name, "config.json.")
+ configDate = strings.TrimSuffix(configDate, ".bak")
+ }
+ if strings.HasPrefix(name, ".security.yml.") && strings.HasSuffix(name, ".bak") {
+ secDate = strings.TrimPrefix(name, ".security.yml.")
+ secDate = strings.TrimSuffix(secDate, ".bak")
+ }
+ }
+ if configDate == "" {
+ t.Fatal("config backup file not found")
+ }
+ if secDate == "" {
+ t.Fatal("security backup file not found")
+ }
+ if configDate != secDate {
+ t.Errorf("config backup date = %q, security backup date = %q, should match", configDate, secDate)
+ }
+}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 272e825b1..6eac5d8b9 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -6,7 +6,6 @@
package config
import (
- "os"
"path/filepath"
"github.com/sipeed/picoclaw/pkg"
@@ -14,16 +13,7 @@ import (
// DefaultConfig returns the default configuration for PicoClaw.
func DefaultConfig() *Config {
- // Determine the base path for the workspace.
- // Priority: $PICOCLAW_HOME > ~/.picoclaw
- var homePath string
- if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" {
- homePath = picoclawHome
- } else {
- userHome, _ := os.UserHomeDir()
- homePath = filepath.Join(userHome, pkg.DefaultPicoClawHome)
- }
- workspacePath := filepath.Join(homePath, pkg.WorkspaceName)
+ workspacePath := filepath.Join(GetHome(), pkg.WorkspaceName)
return &Config{
Version: CurrentVersion,
@@ -357,7 +347,7 @@ func DefaultConfig() *Config {
Host: "127.0.0.1",
Port: 18790,
HotReload: false,
- LogLevel: "warn",
+ LogLevel: DefaultGatewayLogLevel,
},
Tools: ToolsConfig{
FilterSensitiveData: true,
diff --git a/pkg/config/envkeys.go b/pkg/config/envkeys.go
index b04ff19f5..615769d3c 100644
--- a/pkg/config/envkeys.go
+++ b/pkg/config/envkeys.go
@@ -5,6 +5,13 @@
package config
+import (
+ "os"
+ "path/filepath"
+
+ "github.com/sipeed/picoclaw/pkg"
+)
+
// Runtime environment variable keys for the picoclaw process.
// These control the location of files and binaries at runtime and are read
// directly via os.Getenv / os.LookupEnv. All picoclaw-specific keys use the
@@ -35,3 +42,16 @@ const (
// Default: "127.0.0.1"
EnvGatewayHost = "PICOCLAW_GATEWAY_HOST"
)
+
+func GetHome() string {
+ homePath, _ := os.UserHomeDir()
+ if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" {
+ homePath = picoclawHome
+ } else if homePath != "" {
+ homePath = filepath.Join(homePath, pkg.DefaultPicoClawHome)
+ }
+ if homePath == "" {
+ homePath = "."
+ }
+ return homePath
+}
diff --git a/pkg/config/gateway.go b/pkg/config/gateway.go
new file mode 100644
index 000000000..e9f4085d3
--- /dev/null
+++ b/pkg/config/gateway.go
@@ -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)
+}
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index fee800a76..7430050b3 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -534,3 +534,26 @@ func loadConfig(data []byte) (*Config, error) {
}
return cfg, nil
}
+
+func mergeAPIKeys(apiKey string, apiKeys []string) []string {
+ seen := make(map[string]struct{})
+ var all []string
+
+ if k := strings.TrimSpace(apiKey); k != "" {
+ if _, exists := seen[k]; !exists {
+ seen[k] = struct{}{}
+ all = append(all, k)
+ }
+ }
+
+ for _, k := range apiKeys {
+ if trimmed := strings.TrimSpace(k); trimmed != "" {
+ if _, exists := seen[trimmed]; !exists {
+ seen[trimmed] = struct{}{}
+ all = append(all, trimmed)
+ }
+ }
+ }
+
+ return all
+}
diff --git a/pkg/config/migration_integration_test.go b/pkg/config/migration_integration_test.go
index bc8160967..b180dda90 100644
--- a/pkg/config/migration_integration_test.go
+++ b/pkg/config/migration_integration_test.go
@@ -681,3 +681,473 @@ web:
t.Error("Discord token not preserved in .security.yml file")
}
}
+
+// ---------------------------------------------------------------------------
+// V1 → V2 migration tests
+// ---------------------------------------------------------------------------
+
+// TestMigrateModelEnabled_APIKeysInferredEnabled verifies that models with API keys
+// are marked as enabled during V1→V2 migration.
+func TestMigrateModelEnabled_APIKeysInferredEnabled(t *testing.T) {
+ v1 := &configV1{Config: Config{
+ ModelList: []*ModelConfig{
+ {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
+ {ModelName: "claude", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("sk-ant")},
+ },
+ }}
+ v1.migrateModelEnabled()
+ for _, m := range v1.ModelList {
+ if !m.Enabled {
+ t.Errorf("model %q with API key should be enabled", m.ModelName)
+ }
+ }
+}
+
+// TestMigrateModelEnabled_LocalModelInferredEnabled verifies that the reserved
+// "local-model" entry is enabled even without API keys.
+func TestMigrateModelEnabled_LocalModelInferredEnabled(t *testing.T) {
+ v1 := &configV1{Config: Config{
+ ModelList: []*ModelConfig{
+ {ModelName: "local-model", Model: "vllm/custom-model", APIBase: "http://localhost:8000/v1"},
+ },
+ }}
+ v1.migrateModelEnabled()
+ if !v1.ModelList[0].Enabled {
+ t.Error("local-model should be enabled")
+ }
+}
+
+// TestMigrateModelEnabled_NoKeyStaysDisabled verifies that models without API keys
+// and not named "local-model" remain disabled.
+func TestMigrateModelEnabled_NoKeyStaysDisabled(t *testing.T) {
+ v1 := &configV1{Config: Config{
+ ModelList: []*ModelConfig{
+ {ModelName: "gpt-4", Model: "openai/gpt-4"},
+ {ModelName: "claude", Model: "anthropic/claude"},
+ },
+ }}
+ v1.migrateModelEnabled()
+ for _, m := range v1.ModelList {
+ if m.Enabled {
+ t.Errorf("model %q without API key should stay disabled", m.ModelName)
+ }
+ }
+}
+
+// TestMigrateModelEnabled_ExplicitEnabledPreserved verifies that a model with
+// explicitly enabled=true is NOT overridden by the migration.
+func TestMigrateModelEnabled_ExplicitEnabledPreserved(t *testing.T) {
+ v1 := &configV1{Config: Config{
+ ModelList: []*ModelConfig{
+ {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: true},
+ },
+ }}
+ v1.migrateModelEnabled()
+ if !v1.ModelList[0].Enabled {
+ t.Error("explicitly enabled model should remain enabled")
+ }
+}
+
+// TestMigrateModelEnabled_ExplicitDisabledNotOverridden verifies that a model with
+// explicitly enabled=false and API keys gets enabled during migration.
+// Note: since Go's zero value for bool is false and JSON omitempty omits false,
+// migration cannot distinguish "explicitly false" from "field absent". Both cases
+// get the same inference treatment.
+func TestMigrateModelEnabled_ExplicitDisabledNotOverridden(t *testing.T) {
+ v1 := &configV1{Config: Config{
+ ModelList: []*ModelConfig{
+ {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: false},
+ },
+ }}
+ v1.migrateModelEnabled()
+ // Even though Enabled was set to false, migration infers it as true because
+ // the migration cannot distinguish from a missing field (both are zero value).
+ if !v1.ModelList[0].Enabled {
+ t.Error("model with API key should be enabled by migration inference")
+ }
+}
+
+// TestMigrateModelEnabled_Mixed verifies a mix of models.
+func TestMigrateModelEnabled_Mixed(t *testing.T) {
+ v1 := &configV1{Config: Config{
+ ModelList: []*ModelConfig{
+ {ModelName: "with-key", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
+ {ModelName: "no-key", Model: "openai/gpt-4"},
+ {ModelName: "local-model", Model: "vllm/custom"},
+ {
+ ModelName: "disabled-explicit",
+ Model: "openai/gpt-4",
+ APIKeys: SimpleSecureStrings("sk-test"),
+ Enabled: false,
+ },
+ },
+ }}
+ v1.migrateModelEnabled()
+
+ assertEnabled := func(name string, want bool) {
+ for _, m := range v1.ModelList {
+ if m.ModelName == name {
+ if m.Enabled != want {
+ t.Errorf("model %q: Enabled=%v, want %v", name, m.Enabled, want)
+ }
+ return
+ }
+ }
+ t.Errorf("model %q not found", name)
+ }
+
+ assertEnabled("with-key", true)
+ assertEnabled("no-key", false)
+ assertEnabled("local-model", true)
+ assertEnabled("disabled-explicit", true) // false is zero value, migration infers from API key
+}
+
+// TestMigrateChannelConfigs_DiscordMentionOnly verifies Discord mention_only migration.
+func TestMigrateChannelConfigs_DiscordMentionOnly(t *testing.T) {
+ v1 := &configV1{Config: Config{
+ Channels: ChannelsConfig{
+ Discord: DiscordConfig{
+ MentionOnly: true,
+ },
+ },
+ }}
+ v1.migrateChannelConfigs()
+ if !v1.Channels.Discord.GroupTrigger.MentionOnly {
+ t.Error("Discord GroupTrigger.MentionOnly should be set to true")
+ }
+}
+
+// TestMigrateChannelConfigs_DiscordAlreadyMigrated is a no-op test.
+func TestMigrateChannelConfigs_DiscordAlreadyMigrated(t *testing.T) {
+ v1 := &configV1{Config: Config{
+ Channels: ChannelsConfig{
+ Discord: DiscordConfig{
+ GroupTrigger: GroupTriggerConfig{MentionOnly: true},
+ },
+ },
+ }}
+ v1.migrateChannelConfigs()
+}
+
+// TestMigrateChannelConfigs_OneBotPrefix verifies OneBot prefix migration.
+func TestMigrateChannelConfigs_OneBotPrefix(t *testing.T) {
+ v1 := &configV1{Config: Config{
+ Channels: ChannelsConfig{
+ OneBot: OneBotConfig{
+ GroupTriggerPrefix: []string{"/"},
+ },
+ },
+ }}
+ v1.migrateChannelConfigs()
+ if len(v1.Channels.OneBot.GroupTrigger.Prefixes) != 1 || v1.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" {
+ t.Errorf("OneBot GroupTrigger.Prefixes = %v, want [\"/\"]", v1.Channels.OneBot.GroupTrigger.Prefixes)
+ }
+}
+
+// TestMigrateConfigV1_Combined verifies that configV1.Migrate applies both migrations.
+func TestMigrateConfigV1_Combined(t *testing.T) {
+ v1 := &configV1{Config: Config{
+ ModelList: []*ModelConfig{
+ {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
+ },
+ Channels: ChannelsConfig{
+ Discord: DiscordConfig{MentionOnly: true},
+ },
+ }}
+ result, err := v1.Migrate()
+ if err != nil {
+ t.Fatalf("Migrate: %v", err)
+ }
+
+ if !result.ModelList[0].Enabled {
+ t.Error("model with API key should be enabled after V1→V2 migration")
+ }
+ if !result.Channels.Discord.GroupTrigger.MentionOnly {
+ t.Error("Discord mention_only should be migrated after V1→V2 migration")
+ }
+}
+
+// TestLoadConfig_V1ToV2Migration verifies end-to-end V1→V2 config migration
+// through LoadConfig, including Enabled field inference and version bump.
+func TestLoadConfig_V1ToV2Migration(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ // Write a V1 config with model_list but no "enabled" field
+ v1Config := `{
+ "version": 1,
+ "model_list": [
+ {
+ "model_name": "gpt-4",
+ "model": "openai/gpt-4"
+ },
+ {
+ "model_name": "local-model",
+ "model": "vllm/custom-model",
+ "api_base": "http://localhost:8000/v1"
+ }
+ ],
+ "channels": {
+ "discord": {
+ "mention_only": true
+ }
+ },
+ "gateway": {"host": "127.0.0.1", "port": 18790}
+ }`
+
+ if err := os.WriteFile(configPath, []byte(v1Config), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig: %v", err)
+ }
+
+ // Version should be bumped to 2
+ if cfg.Version != CurrentVersion {
+ t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
+ }
+
+ // gpt-4 has no API key → disabled
+ gpt4, err := cfg.GetModelConfig("gpt-4")
+ if err != nil {
+ t.Fatalf("GetModelConfig(gpt-4): %v", err)
+ }
+ if gpt4.Enabled {
+ t.Error("gpt-4 without API key should be disabled after migration")
+ }
+
+ // local-model → enabled
+ local, err := cfg.GetModelConfig("local-model")
+ if err != nil {
+ t.Fatalf("GetModelConfig(local-model): %v", err)
+ }
+ if !local.Enabled {
+ t.Error("local-model should be enabled after migration")
+ }
+
+ // Discord channel config should be migrated
+ if !cfg.Channels.Discord.GroupTrigger.MentionOnly {
+ t.Error("Discord mention_only should be migrated to group_trigger.mention_only")
+ }
+
+ // Verify backup was created with date suffix
+ entries, err := os.ReadDir(tmpDir)
+ if err != nil {
+ t.Fatalf("ReadDir: %v", err)
+ }
+ var hasBackup bool
+ for _, e := range entries {
+ if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
+ hasBackup = true
+ break
+ }
+ }
+ if !hasBackup {
+ t.Error("expected backup file with date suffix to be created")
+ }
+
+ // Verify the saved config on disk now has version 2
+ saved, err := os.ReadFile(configPath)
+ if err != nil {
+ t.Fatalf("ReadFile saved config: %v", err)
+ }
+ var versionCheck struct {
+ Version int `json:"version"`
+ }
+ if err := json.Unmarshal(saved, &versionCheck); err != nil {
+ t.Fatalf("Unmarshal saved config: %v", err)
+ }
+ if versionCheck.Version != 2 {
+ t.Errorf("saved config version = %d, want 2", versionCheck.Version)
+ }
+}
+
+// TestLoadConfig_V1WithAPIKeysInferredEnabled verifies that V1 configs with
+// API keys in the security file get Enabled=true after migration.
+func TestLoadConfig_V1WithAPIKeysInferredEnabled(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+ secPath := securityPath(configPath)
+
+ v1Config := `{
+ "version": 1,
+ "model_list": [
+ {"model_name": "gpt-4", "model": "openai/gpt-4"},
+ {"model_name": "claude", "model": "anthropic/claude"}
+ ],
+ "gateway": {"host": "127.0.0.1", "port": 18790}
+ }`
+
+ securityConfig := `model_list:
+ gpt-4:0:
+ api_keys:
+ - "sk-gpt-key"
+ claude:0:
+ api_keys:
+ - "sk-claude-key"
+`
+
+ if err := os.WriteFile(configPath, []byte(v1Config), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+ if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil {
+ t.Fatalf("WriteFile security: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig: %v", err)
+ }
+
+ for _, m := range cfg.ModelList {
+ if !m.Enabled {
+ t.Errorf("model %q with API key in security file should be enabled", m.ModelName)
+ }
+ }
+}
+
+// TestLoadConfig_V2DirectLoad verifies that V2 configs load directly without
+// running any migration.
+func TestLoadConfig_V2DirectLoad(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ v2Config := `{
+ "version": 2,
+ "model_list": [
+ {
+ "model_name": "gpt-4",
+ "model": "openai/gpt-4",
+ "enabled": true
+ },
+ {
+ "model_name": "claude",
+ "model": "anthropic/claude"
+ }
+ ],
+ "gateway": {"host": "127.0.0.1", "port": 18790}
+ }`
+
+ if err := os.WriteFile(configPath, []byte(v2Config), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig: %v", err)
+ }
+
+ if cfg.Version != 2 {
+ t.Errorf("Version = %d, want 2", cfg.Version)
+ }
+
+ gpt4, _ := cfg.GetModelConfig("gpt-4")
+ if !gpt4.Enabled {
+ t.Error("gpt-4 with explicit enabled=true should remain enabled")
+ }
+
+ claude, _ := cfg.GetModelConfig("claude")
+ if claude.Enabled {
+ t.Error("claude without enabled field should be false (no migration for V2)")
+ }
+
+ // No backup should be created for V2 load
+ entries, _ := os.ReadDir(tmpDir)
+ for _, e := range entries {
+ if matched, _ := filepath.Match("config.json.*.bak", e.Name()); matched {
+ t.Errorf("V2 load should not create backup, but found %q", e.Name())
+ }
+ }
+}
+
+// TestLoadConfig_V0MigrateProducesV2 verifies that V0→V2 migration produces
+// correct Enabled fields and version.
+func TestLoadConfig_V0MigrateProducesV2(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ v0Config := `{
+ "model_list": [
+ {
+ "model_name": "gpt-4",
+ "model": "openai/gpt-4",
+ "api_key": "sk-test"
+ },
+ {
+ "model_name": "claude",
+ "model": "anthropic/claude"
+ },
+ {
+ "model_name": "local-model",
+ "model": "vllm/custom-model"
+ }
+ ],
+ "gateway": {"host": "127.0.0.1", "port": 18790}
+ }`
+
+ if err := os.WriteFile(configPath, []byte(v0Config), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig: %v", err)
+ }
+
+ if cfg.Version != CurrentVersion {
+ t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
+ }
+
+ // Check enabled status
+ modelEnabled := func(name string) bool {
+ m, err := cfg.GetModelConfig(name)
+ if err != nil {
+ return false
+ }
+ return m.Enabled
+ }
+
+ if !modelEnabled("gpt-4") {
+ t.Error("gpt-4 with API key from V0 should be enabled")
+ }
+ if modelEnabled("claude") {
+ t.Error("claude without API key from V0 should be disabled")
+ }
+ if !modelEnabled("local-model") {
+ t.Error("local-model from V0 should be enabled")
+ }
+}
+
+// TestLoadConfig_UnsupportedVersion verifies that unsupported versions return an error.
+func TestLoadConfig_UnsupportedVersion(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.json")
+
+ badConfig := `{"version": 99, "gateway": {"host": "127.0.0.1", "port": 18790}}`
+ if err := os.WriteFile(configPath, []byte(badConfig), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+
+ _, err := LoadConfig(configPath)
+ if err == nil {
+ t.Fatal("LoadConfig should return error for unsupported version")
+ }
+ if !containsString(err.Error(), "unsupported config version") {
+ t.Errorf("error = %q, want 'unsupported config version'", err.Error())
+ }
+}
+
+func containsString(s, substr string) bool {
+ return len(s) >= len(substr) && searchString(s, substr)
+}
+
+func searchString(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go
index e58c6dc9e..947e942da 100644
--- a/pkg/config/multikey_test.go
+++ b/pkg/config/multikey_test.go
@@ -345,7 +345,7 @@ func TestMergeAPIKeys(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := MergeAPIKeys(tt.apiKey, tt.apiKeys)
+ result := mergeAPIKeys(tt.apiKey, tt.apiKeys)
if len(result) != len(tt.expected) {
t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result))
}
diff --git a/pkg/config/security.go b/pkg/config/security.go
index 79dd26e14..2414cd7fa 100644
--- a/pkg/config/security.go
+++ b/pkg/config/security.go
@@ -7,20 +7,16 @@ package config
import (
"bytes"
- "encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
- "runtime"
"strings"
"sync"
"gopkg.in/yaml.v3"
- "github.com/sipeed/picoclaw/pkg/credential"
"github.com/sipeed/picoclaw/pkg/fileutil"
- "github.com/sipeed/picoclaw/pkg/logger"
)
const (
@@ -66,7 +62,6 @@ func saveSecurityConfig(securityPath string, sec *Config) error {
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
}
-// SensitiveDataCache caches the compiled regex for filtering sensitive data.
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
// Computed once on first access via sync.Once.
type SensitiveDataCache struct {
@@ -178,234 +173,3 @@ func collectSensitive(v reflect.Value, values *[]string) {
}
}
}
-
-const (
- notHere = `"[NOT_HERE]"`
-)
-
-// SecureStrings is a slice of SecureString
-type SecureStrings []*SecureString
-
-// Values returns the decrypted/resolved values
-func (s *SecureStrings) Values() []string {
- if s == nil {
- return nil
- }
- keys := make([]string, len(*s))
- for i, k := range *s {
- keys[i] = k.String()
- }
- return unique(keys)
-}
-
-func SimpleSecureStrings(val ...string) SecureStrings {
- val = unique(val)
- vv := make(SecureStrings, len(val))
- for i, s := range val {
- vv[i] = NewSecureString(s)
- }
- return vv
-}
-
-// unique returns a new slice with duplicate elements removed.
-func unique[T comparable](input []T) []T {
- m := make(map[T]struct{})
- var result []T
- for _, v := range input {
- if _, ok := m[v]; !ok {
- m[v] = struct{}{}
- result = append(result, v)
- }
- }
- return result
-}
-
-func (s SecureStrings) MarshalJSON() ([]byte, error) {
- return []byte(notHere), nil
-}
-
-func (s *SecureStrings) UnmarshalJSON(value []byte) error {
- if string(value) == notHere {
- return nil
- }
- var v []*SecureString
- err := json.Unmarshal(value, &v)
- if err != nil {
- return err
- }
- *s = v
- return nil
-}
-
-// SecureString the string value that can be decrypted or resolved
-//
-//nolint:recvcheck
-type SecureString struct {
- resolved string // Decrypted/resolved value returned by String()
- raw string // Persisted raw value (enc://, file://, or plaintext)
-}
-
-func callerFromYaml() bool {
- _, file, _, ok := runtime.Caller(2)
- if ok {
- d := filepath.Dir(file)
- // check the caller is from yaml.v
- if !strings.Contains(d, "yaml.v") {
- return true
- }
- }
- return false
-}
-
-// IsZero returns true if the SecureString is empty
-// if caller not yaml, just return true for prevent marshal this field
-func (s SecureString) IsZero() bool {
- if callerFromYaml() {
- return true
- }
- return s.resolved == ""
-}
-
-func NewSecureString(value string) *SecureString {
- s := &SecureString{}
- if err := s.fromRaw(value); err != nil {
- logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err))
- }
- return s
-}
-
-func (s *SecureString) String() string {
- if s == nil {
- return ""
- }
- return s.resolved
-}
-
-func (s *SecureString) Set(value string) *SecureString {
- s.resolved = value
- s.raw = ""
- return s
-}
-
-func (s SecureString) MarshalJSON() ([]byte, error) {
- return []byte(notHere), nil
-}
-
-func (s *SecureString) UnmarshalJSON(value []byte) error {
- if string(value) == notHere {
- return nil
- }
- var v string
- if err := json.Unmarshal(value, &v); err != nil {
- return err
- }
- return s.fromRaw(v)
-}
-
-func (s SecureString) MarshalYAML() (any, error) {
- // Preserve raw value if it is already a reference (enc:// or file://)
- if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) {
- return s.raw, nil
- }
- // If resolved is a reference format (e.g. set via Set), copy back to raw
- if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) {
- s.raw = s.resolved
- return s.raw, nil
- }
- // Try to encrypt the resolved value
- if passphrase := credential.PassphraseProvider(); passphrase != "" {
- encrypted, err := credential.Encrypt(passphrase, "", s.resolved)
- if err != nil {
- logger.Errorf("Encrypt error: %v", err)
- return nil, err
- }
- s.raw = encrypted
- } else {
- s.raw = s.resolved
- }
- return s.raw, nil
-}
-
-func (s *SecureString) UnmarshalYAML(value *yaml.Node) error {
- return s.fromRaw(value.Value)
-}
-
-func (s *SecureString) fromRaw(v string) error {
- s.raw = v
- vv, err := resolveKey(v)
- if err != nil {
- return err
- }
- s.resolved = vv
- return nil
-}
-
-var (
- secResolverMu sync.RWMutex
- secResolver *credential.Resolver
-)
-
-func updateResolver(path string) {
- secResolverMu.Lock()
- defer secResolverMu.Unlock()
- secResolver = credential.NewResolver(path)
-}
-
-func resolveKey(v string) (string, error) {
- secResolverMu.RLock()
- resolver := secResolver
- secResolverMu.RUnlock()
- if resolver == nil {
- resolver = credential.NewResolver("")
- }
- if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") {
- decrypted, err := resolver.Resolve(v)
- if err != nil {
- logger.Errorf("Resolve error: %v", err)
- return "", err
- }
- return decrypted, nil
- }
- return v, nil
-}
-
-func (s *SecureString) UnmarshalText(text []byte) error {
- v := string(text)
- return s.fromRaw(v)
-}
-
-type SecureModelList []*ModelConfig
-
-func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error {
- mm := make(map[string]*ModelConfig)
- if err := value.Decode(&mm); err != nil {
- logger.Errorf("Decode error: %v", err)
- return err
- }
- nameList := toNameIndex(*v)
- for i, m := range *v {
- sec := mm[nameList[i]]
- if sec == nil {
- sec = mm[m.ModelName]
- }
- if sec != nil {
- m.APIKeys = sec.APIKeys
- }
- }
- return nil
-}
-
-func (v SecureModelList) MarshalYAML() (any, error) {
- type onlySecureData struct {
- APIKeys SecureStrings `yaml:"api_keys,omitempty"`
- }
- mm := make(map[string]onlySecureData)
- nameList := toNameIndex(v)
- for i, m := range v {
- mm[nameList[i]] = onlySecureData{
- APIKeys: m.APIKeys,
- }
- }
-
- return mm, nil
-}
diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go
index 24170f84b..6ca8637f4 100644
--- a/pkg/config/security_integration_test.go
+++ b/pkg/config/security_integration_test.go
@@ -15,7 +15,7 @@ import (
"github.com/stretchr/testify/require"
)
-// Test JSON unmarshal of private fields
+// Test JSON unmarshal of private fields (unexported fields are never filled, with or without json tag).
func TestJSONUnmarshalPrivateFields(t *testing.T) {
type testStruct struct {
PublicField string `json:"public"`
diff --git a/pkg/config/security_test.go b/pkg/config/security_test.go
index 834ba3606..548a6dc87 100644
--- a/pkg/config/security_test.go
+++ b/pkg/config/security_test.go
@@ -15,8 +15,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
-
- "github.com/sipeed/picoclaw/pkg/credential"
)
func TestSecurityConfig(t *testing.T) {
@@ -227,134 +225,3 @@ skills:
assert.Equal(t, "abc", cfg2.Tools.Web.Brave.APIKeys[1].raw)
})
}
-
-func TestLoadSecurityValue(t *testing.T) {
- type valueStruct struct {
- Url string `json:"url,omitempty" yaml:"-"`
- Token *SecureString `json:"token,omitempty" yaml:"token,omitempty" env:"PICO_TOKEN"`
- ApiKeys SecureStrings `json:"api_keys,omitempty" yaml:"api_keys,omitempty" env:"PICO_API_KEYS"`
- }
-
- type testStruct struct {
- Pico *valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
- }
-
- v1 := &testStruct{
- Pico: &valueStruct{
- Url: "https://example.com",
- Token: NewSecureString("token1"),
- ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
- },
- }
- bytes, err := yaml.Marshal(v1)
- assert.NoError(t, err)
- jsonBytes, err := json.Marshal(v1)
- assert.NoError(t, err)
- const want = `pico:
- token: token1
- api_keys:
- - api-key1
- - api-key2
-`
- const jsonPost = `{"pico":{"url":"https://example.com","token":"token0"}}`
- v0 := &testStruct{}
- err = json.Unmarshal([]byte(jsonPost), v0)
- assert.NoError(t, err)
- assert.Equal(t, "https://example.com", v0.Pico.Url)
- assert.Equal(t, "token0", v0.Pico.Token.String())
-
- const jsonWant = `{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}`
- assert.Equal(t, want, string(bytes))
- assert.Equal(t, jsonWant, string(jsonBytes))
-
- v2 := &testStruct{}
- err = json.Unmarshal(jsonBytes, v2)
- assert.NoError(t, err)
- err = yaml.Unmarshal(bytes, v2)
- assert.NoError(t, err)
- assert.Equal(t, "https://example.com", v2.Pico.Url)
- if v2.Pico.Token != nil {
- assert.Equal(t, "token1", v2.Pico.Token.String())
- assert.Equal(t, "token1", v2.Pico.Token.raw)
- }
-
- v2.Pico.Token = NewSecureString("token1")
- v2.Pico.Token.raw = "abc"
- err = yaml.Unmarshal(bytes, v2)
- assert.NoError(t, err)
- assert.Equal(t, "token1", v2.Pico.Token.raw)
-
- os.Setenv("PICO_TOKEN", "token_env")
- err = env.Parse(v2)
- assert.NoError(t, err)
- assert.NotNil(t, v2.Pico.Token)
- assert.Equal(t, "token1", v2.Pico.Token.String())
-
- v3 := &testStruct{Pico: &valueStruct{}}
- err = env.Parse(v3)
- assert.NoError(t, err)
- if v3.Pico.Token != nil {
- assert.Equal(t, "token_env", v3.Pico.Token.String())
- }
-
- type toolsStruct struct {
- Pico valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
- }
-
- type testStruct2 struct {
- Tools toolsStruct `json:"tools,omitempty" yaml:",inline"`
- }
-
- v4 := &testStruct2{
- Tools: toolsStruct{
- Pico: valueStruct{
- Url: "https://example.com",
- Token: NewSecureString("token1"),
- ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
- },
- },
- }
- bytes, err = yaml.Marshal(v4)
- assert.NoError(t, err)
- assert.Equal(t, want, string(bytes))
- jsonBytes, err = json.Marshal(v4)
- assert.NoError(t, err)
- assert.Equal(
- t,
- `{"tools":{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}}`,
- string(jsonBytes),
- )
-
- v5 := &testStruct2{}
- err = json.Unmarshal(jsonBytes, v5)
- assert.NoError(t, err)
- assert.Equal(t, "https://example.com", v5.Tools.Pico.Url)
- err = yaml.Unmarshal(bytes, v5)
- assert.NoError(t, err)
- assert.NotNil(t, v5.Tools.Pico.Token)
- assert.Equal(t, "token1", v5.Tools.Pico.Token.raw)
-
- dir := t.TempDir()
- sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
- if err = os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil {
- t.Fatalf("setup: %v", err)
- }
-
- const passphrase = "test-passphrase-32bytes-long-ok!"
-
- t.Setenv(credential.SSHKeyPathEnvVar, sshKeyPath)
-
- t.Setenv(credential.PassphraseEnvVar, passphrase)
-
- v5.Tools.Pico.Token.Set("newtoken1")
- v5.Tools.Pico.ApiKeys[0].Set("newapi-key1")
- bytes, err = yaml.Marshal(v5)
- assert.NoError(t, err)
- t.Logf("yaml: %s", string(bytes))
-
- v6 := &testStruct2{}
- err = yaml.Unmarshal(bytes, v6)
- assert.NoError(t, err)
- assert.NotNil(t, v6.Tools.Pico.Token)
- assert.Equal(t, "newtoken1", v6.Tools.Pico.Token.String())
-}
diff --git a/pkg/cron/service.go b/pkg/cron/service.go
index 77a413133..6a8728943 100644
--- a/pkg/cron/service.go
+++ b/pkg/cron/service.go
@@ -27,7 +27,6 @@ type CronPayload struct {
Kind string `json:"kind"`
Message string `json:"message"`
Command string `json:"command,omitempty"`
- Deliver bool `json:"deliver"`
Channel string `json:"channel,omitempty"`
To string `json:"to,omitempty"`
}
@@ -409,7 +408,6 @@ func (cs *CronService) AddJob(
name string,
schedule CronSchedule,
message string,
- deliver bool,
channel, to string,
) (*CronJob, error) {
cs.mu.Lock()
@@ -428,7 +426,6 @@ func (cs *CronService) AddJob(
Payload: CronPayload{
Kind: "agent_turn",
Message: message,
- Deliver: deliver,
Channel: channel,
To: to,
},
diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go
index c55e62174..6dff3b387 100644
--- a/pkg/cron/service_test.go
+++ b/pkg/cron/service_test.go
@@ -20,7 +20,7 @@ func TestSaveStore_FilePermissions(t *testing.T) {
cs := NewCronService(storePath, nil)
- _, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", false, "cli", "direct")
+ _, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", "cli", "direct")
if err != nil {
t.Fatalf("AddJob failed: %v", err)
}
@@ -52,7 +52,7 @@ func TestCronService_CRUD(t *testing.T) {
// Test AddJob
at := time.Now().Add(time.Hour).UnixMilli()
- job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", true, "ch", "to")
+ job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", "ch", "to")
if err != nil || job.ID == "" {
t.Fatalf("AddJob failed: %v", err)
}
@@ -134,7 +134,7 @@ func TestCronService_ExecutionFlow(t *testing.T) {
// Add a job then runs 100ms from now
target := time.Now().Add(100 * time.Millisecond).UnixMilli()
- job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", false, "", "")
+ job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", "", "")
// Check for job execution with a timeout
success := false
@@ -167,7 +167,7 @@ func TestCronService_PersistenceIntegrity(t *testing.T) {
// write a job and persist
cs1 := NewCronService(tmpFile, nil)
at := int64(2000000000000)
- cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", true, "ch1", "")
+ cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", "ch1", "")
// check file exists
if _, err := os.Stat(tmpFile); os.IsNotExist(err) {
@@ -213,7 +213,7 @@ func TestCronService_ConcurrentAccess(t *testing.T) {
defer wg.Done()
for j := range iterations {
at := time.Now().Add(time.Hour).UnixMilli()
- cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", false, "", "")
+ cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", "", "")
time.Sleep(100 * time.Microsecond)
}
}(i)
diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go
index c06ac5ac6..b5e8c1f36 100644
--- a/pkg/gateway/gateway.go
+++ b/pkg/gateway/gateway.go
@@ -7,6 +7,7 @@ import (
"os/signal"
"path/filepath"
"sort"
+ "strings"
"sync"
"sync/atomic"
"syscall"
@@ -24,7 +25,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
- _ "github.com/sipeed/picoclaw/pkg/channels/pico"
+ "github.com/sipeed/picoclaw/pkg/channels/pico"
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
@@ -39,6 +40,7 @@ import (
"github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
+ "github.com/sipeed/picoclaw/pkg/pid"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
@@ -64,6 +66,7 @@ type services struct {
VoiceAgentCancel context.CancelFunc
manualReloadChan chan struct{}
reloading atomic.Bool
+ authToken string
}
type startupBlockedProvider struct {
@@ -115,22 +118,41 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
defer panicFunc()
if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil {
- panic(fmt.Sprintf("error enabling file logging: %v", err))
+ logger.Fatal(fmt.Sprintf("error enabling file logging: %v", err))
}
defer logger.DisableFileLogging()
- cfg, err := config.LoadConfig(configPath)
- if err != nil {
- return fmt.Errorf("error loading config: %w", err)
- }
-
- logger.SetLevelFromString(cfg.Gateway.LogLevel)
-
if debug {
logger.SetLevel(logger.DEBUG)
- fmt.Println("🔍 Debug mode enabled")
+ } else {
+ logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
}
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ logger.Fatalf("error loading config: %v", err)
+ }
+
+ if err = preCheckConfig(cfg); err != nil {
+ logger.Fatalf("config pre-check failed: %v", err)
+ }
+
+ // Debug mode permanently overrides the config log level to DEBUG.
+ if debug {
+ fmt.Println("🔍 Debug mode enabled")
+ } else {
+ effectiveLogLevel := config.EffectiveGatewayLogLevel(cfg)
+ logger.SetLevelFromString(effectiveLogLevel)
+ logger.Infof("Log level set to %q", effectiveLogLevel)
+ }
+
+ // Enforce singleton: write PID file with generated token.
+ pidData, err := pid.WritePidFile(homePath, cfg.Gateway.Host, cfg.Gateway.Port)
+ if err != nil {
+ return fmt.Errorf("singleton check failed: %w", err)
+ }
+ defer pid.RemovePidFile(homePath)
+
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
if err != nil {
return fmt.Errorf("error creating provider: %w", err)
@@ -157,7 +179,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
"skills_available": skillsInfo["available"],
})
- runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus)
+ runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token)
if err != nil {
return err
}
@@ -211,7 +233,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
logger.Warn("Config reload skipped: another reload is in progress")
continue
}
- err := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
+ err := executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup, debug)
if err != nil {
logger.Errorf("Config reload failed: %v", err)
}
@@ -228,7 +250,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
runningServices.reloading.Store(false)
continue
}
- err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
+ err = executeReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup, debug)
if err != nil {
logger.Errorf("Manual reload failed: %v", err)
} else {
@@ -238,6 +260,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
}
}
+func preCheckConfig(cfg *config.Config) error {
+ if cfg.Gateway.Port <= 0 || cfg.Gateway.Port > 65535 {
+ return fmt.Errorf("invalid gateway port: %d, port must be between 1 and 65535", cfg.Gateway.Port)
+ }
+ return nil
+}
+
func executeReload(
ctx context.Context,
agentLoop *agent.AgentLoop,
@@ -246,9 +275,13 @@ func executeReload(
runningServices *services,
msgBus *bus.MessageBus,
allowEmptyStartup bool,
+ debug bool,
) error {
defer runningServices.reloading.Store(false)
- return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup)
+
+ overridePicoToken(newCfg, runningServices.authToken)
+
+ return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup, debug)
}
func createStartupProvider(
@@ -272,6 +305,7 @@ func setupAndStartServices(
cfg *config.Config,
agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus,
+ authToken string,
) (*services, error) {
runningServices := &services{}
@@ -314,6 +348,8 @@ func setupAndStartServices(
fms.Start()
}
+ overridePicoToken(cfg, authToken)
+
runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)
if err != nil {
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
@@ -341,7 +377,8 @@ func setupAndStartServices(
}
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
- runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
+ runningServices.authToken = authToken
+ runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, authToken)
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
@@ -432,6 +469,7 @@ func handleConfigReload(
runningServices *services,
msgBus *bus.MessageBus,
allowEmptyStartup bool,
+ debug bool,
) error {
logger.Info("🔄 Config file changed, reloading...")
@@ -480,6 +518,15 @@ func handleConfigReload(
}
logger.Info(" ✓ Provider, configuration, and services reloaded successfully (thread-safe)")
+
+ // Debug mode permanently overrides the config log level to DEBUG.
+ if !debug {
+ // Update log level last so that reload-related info/warn logs above are not suppressed.
+ effectiveLogLevel := config.EffectiveGatewayLogLevel(newCfg)
+ logger.SetLevelFromString(effectiveLogLevel)
+ logger.Infof("Log level changing from current to %q", effectiveLogLevel)
+ }
+
return nil
}
@@ -530,12 +577,13 @@ func restartServices(
}
al.SetMediaStore(runningServices.MediaStore)
- runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)
- if err != nil {
- return fmt.Errorf("error recreating channel manager: %w", err)
- }
al.SetChannelManager(runningServices.ChannelManager)
+ if err = runningServices.ChannelManager.Reload(context.Background(), cfg); err != nil {
+ return fmt.Errorf("error reload channels: %w", err)
+ }
+ fmt.Println(" ✓ Channels restarted.")
+
enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels)
@@ -543,18 +591,6 @@ func restartServices(
fmt.Println(" ⚠ Warning: No channels enabled")
}
- addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
- // Reuse existing HealthServer to preserve reloadFunc
- if runningServices.HealthServer == nil {
- runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
- }
- runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
-
- if err = runningServices.ChannelManager.Reload(context.Background(), cfg); err != nil {
- return fmt.Errorf("error reload channels: %w", err)
- }
- fmt.Println(" ✓ Channels restarted.")
-
stateManager := state.NewManager(cfg.WorkspacePath())
runningServices.DeviceService = devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
@@ -583,6 +619,8 @@ func restartServices(
ttsAvailable := tts.DetectTTS(cfg) != nil
logChannelVoiceCapabilities(runningServices.ChannelManager, transcriber != nil, ttsAvailable)
+ // NOTE: PID file is written once at startup and not updated on reload.
+ // Changing the gateway listen address requires a full restart.
return nil
}
@@ -702,6 +740,20 @@ func setupCronTool(
return cronService, nil
}
+// overridePicoToken replaces the pico channel token with the one from the PID file.
+// The PID file is the single source of truth for the pico auth token;
+// it is generated once at gateway startup and remains unchanged across reloads.
+func overridePicoToken(cfg *config.Config, token string) {
+ if !cfg.Channels.Pico.Enabled {
+ return
+ }
+ picoToken := cfg.Channels.Pico.Token.String()
+ if picoToken == "" || strings.HasPrefix(picoToken, pico.PicoTokenPrefix) {
+ return
+ }
+ cfg.Channels.Pico.SetToken(pico.PicoTokenPrefix + token + picoToken)
+}
+
func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult {
return func(prompt, channel, chatID string) *tools.ToolResult {
if channel == "" || chatID == "" {
diff --git a/pkg/health/server.go b/pkg/health/server.go
index fe20e4b94..2602cb965 100644
--- a/pkg/health/server.go
+++ b/pkg/health/server.go
@@ -2,11 +2,11 @@ package health
import (
"context"
+ "crypto/subtle"
"encoding/json"
"fmt"
"maps"
"net/http"
- "os"
"sync"
"time"
)
@@ -18,6 +18,7 @@ type Server struct {
checks map[string]Check
startTime time.Time
reloadFunc func() error
+ authToken string // optional bearer token for protected endpoints
}
type Check struct {
@@ -31,15 +32,15 @@ type StatusResponse struct {
Status string `json:"status"`
Uptime string `json:"uptime"`
Checks map[string]Check `json:"checks,omitempty"`
- Pid int `json:"pid"`
}
-func NewServer(host string, port int) *Server {
+func NewServer(host string, port int, token string) *Server {
mux := http.NewServeMux()
s := &Server{
ready: false,
checks: make(map[string]Check),
startTime: time.Now(),
+ authToken: token,
}
mux.HandleFunc("/health", s.healthHandler)
@@ -123,6 +124,21 @@ func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ // Token check
+ s.mu.RLock()
+ requiredToken := s.authToken
+ s.mu.RUnlock()
+
+ if requiredToken != "" {
+ given := extractBearerToken(r.Header.Get("Authorization"))
+ if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(requiredToken)) != 1 {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
+ return
+ }
+ }
+
s.mu.Lock()
reloadFunc := s.reloadFunc
s.mu.Unlock()
@@ -154,7 +170,6 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
resp := StatusResponse{
Status: "ok",
Uptime: uptime.String(),
- Pid: os.Getpid(),
}
json.NewEncoder(w).Encode(resp)
@@ -198,9 +213,17 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
})
}
+// HandlerMux is the interface for registering HTTP handlers, used by
+// RegisterOnMux so that callers can pass any mux implementation
+// (e.g. *http.ServeMux or a custom dynamic mux).
+type HandlerMux interface {
+ Handle(pattern string, handler http.Handler)
+ HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
+}
+
// RegisterOnMux registers /health, /ready and /reload handlers onto the given mux.
// This allows the health endpoints to be served by a shared HTTP server.
-func (s *Server) RegisterOnMux(mux *http.ServeMux) {
+func (s *Server) RegisterOnMux(mux HandlerMux) {
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler)
mux.HandleFunc("/reload", s.reloadHandler)
@@ -212,3 +235,16 @@ func statusString(ok bool) string {
}
return "fail"
}
+
+// extractBearerToken returns the token from an "Authorization: Bearer