From 569d509de59fb181be5906677fc1841089fc16c1 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sat, 7 Mar 2026 09:48:44 +0100 Subject: [PATCH 1/8] no-truncate param --- cmd/picoclaw/internal/gateway/command.go | 19 +++++++++++++++++++ pkg/agent/context.go | 6 ++---- pkg/utils/string.go | 13 +++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 66a56f9ce..7ba33d887 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -1,23 +1,42 @@ package gateway import ( + "fmt" + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" ) func NewGatewayCommand() *cobra.Command { var debug bool + var noTruncate bool cmd := &cobra.Command{ Use: "gateway", Aliases: []string{"g"}, Short: "Start picoclaw gateway", Args: cobra.NoArgs, + PreRunE: func(_ *cobra.Command, _ []string) error { + if noTruncate && !debug { + return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)") + } + + if noTruncate { + utils.SetDisableTruncation(true) + logger.Info("String truncation is globally disabled via 'no-truncate' flag") + } + + return nil + }, RunE: func(_ *cobra.Command, _ []string) error { return gatewayCmd(debug) }, } cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") + cmd.Flags().BoolVar(&noTruncate, "no-truncate", false, "Disable string truncation in debug logs") return cmd } diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 719b0cb6d..21dccfeb1 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/utils" ) type ContextBuilder struct { @@ -505,10 +506,7 @@ func (cb *ContextBuilder) BuildMessages( }) // Log preview of system prompt (avoid logging huge content) - preview := fullSystemPrompt - if len(preview) > 500 { - preview = preview[:500] + "... (truncated)" - } + preview := utils.Truncate(fullSystemPrompt, 500) logger.DebugCF("agent", "System prompt preview", map[string]any{ "preview": preview, diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 02f346db4..f7c4d7874 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -2,9 +2,18 @@ package utils import ( "strings" + "sync/atomic" "unicode" ) +// Global variable to disable truncation +var disableTruncation atomic.Bool + +// SetDisableTruncation globally enables or disables string truncation +func SetDisableTruncation(enabled bool) { + disableTruncation.Store(enabled) +} + // SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides, // zero-width characters), and other non-graphic characters that could confuse an LLM // or cause display issues in the agent UI. @@ -30,6 +39,10 @@ func SanitizeMessageContent(input string) string { // Handles multi-byte Unicode characters properly. // If the string is truncated, "..." is appended to indicate truncation. func Truncate(s string, maxLen int) string { + // If the no-truncate flag is active, it returns the integer string + if disableTruncation.Load() { + return s + } if maxLen <= 0 { return "" } From 68e40aeb47fe353ab1674caecc7a326bb3b8735b Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Mon, 9 Mar 2026 23:56:59 +0100 Subject: [PATCH 2/8] fix typo --- pkg/utils/string.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/utils/string.go b/pkg/utils/string.go index f7c4d7874..dbaafdb7f 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -39,7 +39,7 @@ func SanitizeMessageContent(input string) string { // Handles multi-byte Unicode characters properly. // If the string is truncated, "..." is appended to indicate truncation. func Truncate(s string, maxLen int) string { - // If the no-truncate flag is active, it returns the integer string + // If the no-truncate flag is active, it returns the full string if disableTruncation.Load() { return s } From cc955627b7a1de3e8d24a5350116c0ea0d2847c4 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Tue, 10 Mar 2026 00:10:07 +0100 Subject: [PATCH 3/8] debug doc --- docs/debug.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/debug.md diff --git a/docs/debug.md b/docs/debug.md new file mode 100644 index 000000000..7e28a15f2 --- /dev/null +++ b/docs/debug.md @@ -0,0 +1,33 @@ +# Debugging PicoClaw + +PicoClaw performs multiple complex interactions under the hood for every single request it receives—from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates. +## Starting PicoClaw in Debug Mode + +To get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results. + +## Disabling Log Truncation (Full Logs) + +By default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable. + +If you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag. + +**Note:** This flag *only* works when combined with the `--debug` mode. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +When this flag is active, the global truncation function is disabled. This is extremely useful for: + +* Verifying the exact syntax of the messages sent to the provider. +* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`. +* Debugging the session history saved in memory. From 86ce76219fd339fc8d57ca8fee8f3008ea689e7b Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Tue, 10 Mar 2026 00:15:47 +0100 Subject: [PATCH 4/8] no-truncate shorthand flag --- cmd/picoclaw/internal/gateway/command.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 7ba33d887..bfa69f072 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -36,7 +36,7 @@ func NewGatewayCommand() *cobra.Command { } cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") - cmd.Flags().BoolVar(&noTruncate, "no-truncate", false, "Disable string truncation in debug logs") + cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs") return cmd } From 123275fcbe3678de652669b7c0aecdb9e15c4c77 Mon Sep 17 00:00:00 2001 From: statxc <181730535+statxc@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:54:10 +0000 Subject: [PATCH 5/8] feat(telegram): support forum topics with per-topic session isolation --- pkg/channels/telegram/telegram.go | 107 ++++++++++++------ pkg/channels/telegram/telegram_test.go | 143 +++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 31 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 0a36247a6..0df82cd47 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -168,7 +168,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return channels.ErrNotRunning } - chatID, err := parseChatID(msg.ChatID) + chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } @@ -200,7 +200,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err continue } - if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil { + if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk); err != nil { return err } } @@ -210,9 +210,10 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // sendHTMLChunk sends a single HTML message, falling back to the original // markdown as plain text on parse failure so users never see raw HTML tags. -func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error { +func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string) error { tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML + tgMsg.MessageThreadID = threadID if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ @@ -232,13 +233,16 @@ func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlC // (Telegram's typing indicator expires after ~5s) in a background goroutine. // The returned stop function is idempotent and cancels the goroutine. func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { - cid, err := parseChatID(chatID) + cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return func() {}, err } + action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) + action.MessageThreadID = threadID + // Send the first typing action immediately - _ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) + _ = c.bot.SendChatAction(ctx, action) typingCtx, cancel := context.WithCancel(ctx) go func() { @@ -249,7 +253,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( case <-typingCtx.Done(): return case <-ticker.C: - _ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) + a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) + a.MessageThreadID = threadID + _ = c.bot.SendChatAction(typingCtx, a) } } }() @@ -259,7 +265,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( // EditMessage implements channels.MessageEditor. func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - cid, err := parseChatID(chatID) + cid, _, err := parseTelegramChatID(chatID) if err != nil { return err } @@ -288,12 +294,14 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s text = "Thinking... 💭" } - cid, err := parseChatID(chatID) + cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return "", err } - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text)) + phMsg := tu.Message(tu.ID(cid), text) + phMsg.MessageThreadID = threadID + pMsg, err := c.bot.SendMessage(ctx, phMsg) if err != nil { return "", err } @@ -307,7 +315,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe return channels.ErrNotRunning } - chatID, err := parseChatID(msg.ChatID) + chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } @@ -339,30 +347,34 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe switch part.Type { case "image": params := &telego.SendPhotoParams{ - ChatID: tu.ID(chatID), - Photo: telego.InputFile{File: file}, - Caption: part.Caption, + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Photo: telego.InputFile{File: file}, + Caption: part.Caption, } _, err = c.bot.SendPhoto(ctx, params) case "audio": params := &telego.SendAudioParams{ - ChatID: tu.ID(chatID), - Audio: telego.InputFile{File: file}, - Caption: part.Caption, + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Audio: telego.InputFile{File: file}, + Caption: part.Caption, } _, err = c.bot.SendAudio(ctx, params) case "video": params := &telego.SendVideoParams{ - ChatID: tu.ID(chatID), - Video: telego.InputFile{File: file}, - Caption: part.Caption, + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Video: telego.InputFile{File: file}, + Caption: part.Caption, } _, err = c.bot.SendVideo(ctx, params) default: // "file" or unknown types params := &telego.SendDocumentParams{ - ChatID: tu.ID(chatID), - Document: telego.InputFile{File: file}, - Caption: part.Caption, + ChatID: tu.ID(chatID), + MessageThreadID: threadID, + Document: telego.InputFile{File: file}, + Caption: part.Caption, } _, err = c.bot.SendDocument(ctx, params) } @@ -506,19 +518,29 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = cleaned } + // Build composite chatID. For forum topics, embed the thread ID in the + // chatID as "chatID/threadID" (same pattern as Slack's "channelID/threadTS") + // so all outbound methods route replies to the correct topic. + // Note: Telegram's "General" topic uses thread ID 1; it is treated like any + // other topic for session isolation and agent binding. + compositeChatID := fmt.Sprintf("%d", chatID) + threadID := message.MessageThreadID + if threadID != 0 { + compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID) + } + logger.DebugCF("telegram", "Received message", map[string]any{ "sender_id": sender.CanonicalID, - "chat_id": fmt.Sprintf("%d", chatID), + "chat_id": compositeChatID, + "thread_id": threadID, "preview": utils.Truncate(content, 50), }) - // Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable - peerKind := "direct" peerID := fmt.Sprintf("%d", user.ID) if message.Chat.Type != "private" { peerKind = "group" - peerID = fmt.Sprintf("%d", chatID) + peerID = compositeChatID } peer := bus.Peer{Kind: peerKind, ID: peerID} @@ -531,11 +553,18 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } + // For forum topic messages, set parent_peer metadata so the routing system + // can isolate sessions per topic via the existing 7-level priority cascade. + if threadID != 0 { + metadata["parent_peer_kind"] = "topic" + metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID) + } + c.HandleMessage(c.ctx, peer, messageID, platformID, - fmt.Sprintf("%d", chatID), + compositeChatID, content, mediaPaths, metadata, @@ -583,10 +612,26 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) return c.downloadFileWithInfo(file, ext) } -func parseChatID(chatIDStr string) (int64, error) { - var id int64 - _, err := fmt.Sscanf(chatIDStr, "%d", &id) - return id, err +// parseTelegramChatID splits a composite chat ID "chatID/threadID" into its +// components. For non-forum messages the threadID is 0. If the chatID string +// contains a "/" segment, the second part must be a valid integer thread ID; +// otherwise an error is returned. This mirrors the Slack adapter (channelID/threadTS). +// Implemented with strings.Index to avoid allocating a slice in the hot path. +func parseTelegramChatID(chatID string) (int64, int, error) { + idx := strings.Index(chatID, "/") + if idx == -1 { + cid, err := strconv.ParseInt(chatID, 10, 64) + return cid, 0, err + } + cid, err := strconv.ParseInt(chatID[:idx], 10, 64) + if err != nil { + return 0, 0, err + } + tid, err := strconv.Atoi(chatID[idx+1:]) + if err != nil { + return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err) + } + return cid, tid, nil } func markdownToTelegramHTML(text string) string { diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 3a2f1aa66..d8780dc88 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -6,6 +6,7 @@ import ( "errors" "strings" "testing" + "time" "github.com/mymmrac/telego" ta "github.com/mymmrac/telego/telegoapi" @@ -271,3 +272,145 @@ func TestSend_InvalidChatID(t *testing.T) { assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed") assert.Empty(t, caller.calls) } + +func TestParseTelegramChatID_Plain(t *testing.T) { + cid, tid, err := parseTelegramChatID("12345") + assert.NoError(t, err) + assert.Equal(t, int64(12345), cid) + assert.Equal(t, 0, tid) +} + +func TestParseTelegramChatID_NegativeGroup(t *testing.T) { + cid, tid, err := parseTelegramChatID("-1001234567890") + assert.NoError(t, err) + assert.Equal(t, int64(-1001234567890), cid) + assert.Equal(t, 0, tid) +} + +func TestParseTelegramChatID_WithThreadID(t *testing.T) { + cid, tid, err := parseTelegramChatID("-1001234567890/42") + assert.NoError(t, err) + assert.Equal(t, int64(-1001234567890), cid) + assert.Equal(t, 42, tid) +} + +func TestParseTelegramChatID_GeneralTopic(t *testing.T) { + cid, tid, err := parseTelegramChatID("-100123/1") + assert.NoError(t, err) + assert.Equal(t, int64(-100123), cid) + assert.Equal(t, 1, tid) +} + +func TestParseTelegramChatID_Invalid(t *testing.T) { + _, _, err := parseTelegramChatID("not-a-number") + assert.Error(t, err) +} + +func TestParseTelegramChatID_InvalidThreadID(t *testing.T) { + _, _, err := parseTelegramChatID("-100123/not-a-thread") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid thread ID") +} + +func TestSend_WithForumThreadID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890/42", + Content: "Hello from topic", + }) + + assert.NoError(t, err) + assert.Len(t, caller.calls, 1) +} + +func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "hello from topic", + MessageID: 10, + MessageThreadID: 42, + Chat: telego.Chat{ + ID: -1001234567890, + Type: "supergroup", + IsForum: true, + }, + From: &telego.User{ + ID: 7, + FirstName: "Alice", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + require.True(t, ok, "expected inbound message") + + // Composite chatID should include thread ID + assert.Equal(t, "-1001234567890/42", inbound.ChatID) + + // Peer ID should include thread ID for session key isolation + assert.Equal(t, "group", inbound.Peer.Kind) + assert.Equal(t, "-1001234567890/42", inbound.Peer.ID) + + // Parent peer metadata should be set for agent binding + assert.Equal(t, "topic", inbound.Metadata["parent_peer_kind"]) + assert.Equal(t, "42", inbound.Metadata["parent_peer_id"]) +} + +func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "regular group message", + MessageID: 11, + Chat: telego.Chat{ + ID: -100999, + Type: "group", + }, + From: &telego.User{ + ID: 8, + FirstName: "Bob", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + require.True(t, ok) + + // Plain chatID without thread suffix + assert.Equal(t, "-100999", inbound.ChatID) + + // Peer ID should be raw chat ID (no thread suffix) + assert.Equal(t, "group", inbound.Peer.Kind) + assert.Equal(t, "-100999", inbound.Peer.ID) + + // No parent peer metadata + assert.Empty(t, inbound.Metadata["parent_peer_kind"]) + assert.Empty(t, inbound.Metadata["parent_peer_id"]) +} From 3f1e89da7f2e7f60600d9f34c664284941941d73 Mon Sep 17 00:00:00 2001 From: statxc <181730535+statxc@users.noreply.github.com> Date: Tue, 10 Mar 2026 04:22:00 +0000 Subject: [PATCH 6/8] fix: solve Lint errors --- pkg/channels/telegram/telegram.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 0df82cd47..2055d6ddc 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -210,7 +210,9 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // sendHTMLChunk sends a single HTML message, falling back to the original // markdown as plain text on parse failure so users never see raw HTML tags. -func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string) error { +func (c *TelegramChannel) sendHTMLChunk( + ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, +) error { tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML tgMsg.MessageThreadID = threadID From 320fcd1f027ec1aa486e632b7eb40a97c6bba82d Mon Sep 17 00:00:00 2001 From: statxc <181730535+statxc@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:25:14 +0000 Subject: [PATCH 7/8] fix: Add IsForum check so only forum topic threads get session isolation, not regular group reply threads --- pkg/channels/telegram/telegram.go | 23 +++++-------- pkg/channels/telegram/telegram_test.go | 46 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 2055d6ddc..b04beeb6e 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -520,14 +520,13 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = cleaned } - // Build composite chatID. For forum topics, embed the thread ID in the - // chatID as "chatID/threadID" (same pattern as Slack's "channelID/threadTS") - // so all outbound methods route replies to the correct topic. - // Note: Telegram's "General" topic uses thread ID 1; it is treated like any - // other topic for session isolation and agent binding. + // 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 + // must share one session per group. compositeChatID := fmt.Sprintf("%d", chatID) threadID := message.MessageThreadID - if threadID != 0 { + if message.Chat.IsForum && threadID != 0 { compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID) } @@ -555,9 +554,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } - // For forum topic messages, set parent_peer metadata so the routing system - // can isolate sessions per topic via the existing 7-level priority cascade. - if threadID != 0 { + // Set parent_peer metadata for per-topic agent binding. + if message.Chat.IsForum && threadID != 0 { metadata["parent_peer_kind"] = "topic" metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID) } @@ -614,11 +612,8 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) return c.downloadFileWithInfo(file, ext) } -// parseTelegramChatID splits a composite chat ID "chatID/threadID" into its -// components. For non-forum messages the threadID is 0. If the chatID string -// contains a "/" segment, the second part must be a valid integer thread ID; -// otherwise an error is returned. This mirrors the Slack adapter (channelID/threadTS). -// Implemented with strings.Index to avoid allocating a slice in the hot path. +// parseTelegramChatID splits "chatID/threadID" into its components. +// Returns threadID=0 when no "/" is present (non-forum messages). func parseTelegramChatID(chatID string) (int64, int, error) { idx := strings.Index(chatID, "/") if idx == -1 { diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index d8780dc88..c2186d0a3 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -414,3 +414,49 @@ func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) { assert.Empty(t, inbound.Metadata["parent_peer_kind"]) assert.Empty(t, inbound.Metadata["parent_peer_id"]) } + +func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + // In regular groups, reply threads set MessageThreadID to the original + // message ID. This should NOT trigger per-thread session isolation. + msg := &telego.Message{ + Text: "reply in thread", + MessageID: 20, + MessageThreadID: 15, + Chat: telego.Chat{ + ID: -100999, + Type: "supergroup", + IsForum: false, + }, + From: &telego.User{ + ID: 9, + FirstName: "Carol", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + require.True(t, ok) + + // chatID should NOT include thread suffix for non-forum groups + assert.Equal(t, "-100999", inbound.ChatID) + + // Peer ID should be raw chat ID (shared session for whole group) + assert.Equal(t, "group", inbound.Peer.Kind) + assert.Equal(t, "-100999", inbound.Peer.ID) + + // No parent peer metadata + assert.Empty(t, inbound.Metadata["parent_peer_kind"]) + assert.Empty(t, inbound.Metadata["parent_peer_id"]) +} From 2e3e6788abf2e1af554be8538d46a9717c9ecd91 Mon Sep 17 00:00:00 2001 From: statxc <181730535+statxc@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:11:34 +0000 Subject: [PATCH 8/8] fix(session): sanitize '/' and '\' in session keys so forum topic keys don't create invalid paths --- pkg/memory/jsonl.go | 14 +++++++------- pkg/session/manager.go | 20 +++++++++++--------- pkg/session/manager_test.go | 13 ++++++++++++- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index e12e2c5ab..afe374166 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -86,14 +86,14 @@ func (s *JSONLStore) metaPath(key string) string { // sanitizeKey converts a session key to a safe filename component. // Mirrors pkg/session.sanitizeFilename so that migration paths match. -// -// Note: this is a lossy mapping — "telegram:123" and "telegram_123" -// both produce the same filename. This is an intentional tradeoff: -// keys with colons (e.g. from channels) are by far the common case, -// and a bidirectional encoding (like URL-encoding) would complicate -// file listings and debugging. +// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' +// so composite IDs (e.g. Telegram forum "chatID/threadID", Slack "channel/thread_ts") +// do not create subdirectories or break on Windows. func sanitizeKey(key string) string { - return strings.ReplaceAll(key, ":", "_") + s := strings.ReplaceAll(key, ":", "_") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return s } // readMeta loads the metadata file for a session. diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 07f981df1..a31dbd55c 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -146,12 +146,15 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { } // sanitizeFilename converts a session key into a cross-platform safe filename. -// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the -// volume separator on Windows, so filepath.Base would misinterpret the key. -// We replace it with '_'. The original key is preserved inside the JSON file, -// so loadSessions still maps back to the right in-memory key. +// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so +// composite IDs (e.g. Telegram forum "chatID/threadID") do not create +// subdirectories or break on Windows. The original key is preserved inside +// the JSON file, so loadSessions still maps back to the right in-memory key. func sanitizeFilename(key string) string { - return strings.ReplaceAll(key, ":", "_") + s := strings.ReplaceAll(key, ":", "_") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return s } func (sm *SessionManager) Save(key string) error { @@ -162,10 +165,9 @@ func (sm *SessionManager) Save(key string) error { filename := sanitizeFilename(key) // filepath.IsLocal rejects empty names, "..", absolute paths, and - // OS-reserved device names (NUL, COM1 … on Windows). - // The extra checks reject "." and any directory separators so that - // the session file is always written directly inside sm.storage. - if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) { + // OS-reserved device names (NUL, COM1 … on Windows). sanitizeFilename + // already replaced '/' and '\' with '_', so no subdirs are created. + if filename == "." || !filepath.IsLocal(filename) { return os.ErrInvalid } diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go index 5ef5f4349..bc5615966 100644 --- a/pkg/session/manager_test.go +++ b/pkg/session/manager_test.go @@ -17,6 +17,7 @@ func TestSanitizeFilename(t *testing.T) { {"slack:C01234", "slack_C01234"}, {"no-colons-here", "no-colons-here"}, {"multiple:colons:here", "multiple_colons_here"}, + {"agent:main:telegram:group:-1003822706455/12", "agent_main_telegram_group_-1003822706455_12"}, } for _, tt := range tests { @@ -64,11 +65,21 @@ func TestSave_RejectsPathTraversal(t *testing.T) { tmpDir := t.TempDir() sm := NewSessionManager(tmpDir) - badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"} + // Invalid names that must still be rejected. + badKeys := []string{"", ".", ".."} for _, key := range badKeys { sm.GetOrCreate(key) if err := sm.Save(key); err == nil { t.Errorf("Save(%q) should have failed but didn't", key) } } + + // Keys containing path separators are sanitized (no subdirs created). + sm.GetOrCreate("foo/bar") + if err := sm.Save("foo/bar"); err != nil { + t.Fatalf("Save(\"foo/bar\") after sanitize should succeed: %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "foo_bar.json")); os.IsNotExist(err) { + t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)") + } }