From 6d550118354290de191e6203ba2fa14bcf4ac88d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:06:40 +0000 Subject: [PATCH 1/4] Initial plan From fb2179d1b5a6ba487e371e13a8c4d04c0caebe30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:11:44 +0000 Subject: [PATCH 2/4] Add Telegram Group Topics (Forum) support - Add threadIDs sync.Map to TelegramChannel struct to store thread ID mappings - Implement parseTelegramChatID() to parse chatID:threadID format (similar to Slack) - Update handleMessage() to extract and store MessageThreadID from incoming messages - Encode thread ID into chatID string when messages come from topics - Update Send() method to parse and set MessageThreadID on outgoing messages - Set MessageThreadID on "Thinking..." placeholder messages - Set MessageThreadID on SendChatAction (typing indicator) - Add metadata fields: message_thread_id and is_topic_message - Add comprehensive unit tests for parseTelegramChatID() Co-authored-by: zhaopengme <1415418+zhaopengme@users.noreply.github.com> --- pkg/channels/telegram.go | 67 ++++++++++++++++-- pkg/channels/telegram_test.go | 128 ++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 pkg/channels/telegram_test.go diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index b14b1632e..70422a714 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -29,6 +29,7 @@ type TelegramChannel struct { transcriber *voice.GroqTranscriber placeholders sync.Map // chatID -> messageID stopThinking sync.Map // chatID -> thinkingCancel + threadIDs sync.Map // chatIDStr -> MessageThreadID } type thinkingCancel struct { @@ -124,7 +125,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("telegram bot not running") } - chatID, err := parseChatID(msg.ChatID) + chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID: %w", err) } @@ -144,6 +145,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err c.placeholders.Delete(msg.ChatID) editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) editMsg.ParseMode = telego.ModeHTML + // Note: EditMessageText doesn't require MessageThreadID as it edits existing message if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { return nil @@ -153,6 +155,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML + + // Set thread ID if present + if threadID != 0 { + 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]interface{}{ @@ -195,6 +202,16 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat chatID := message.Chat.ID c.chatIDs[senderID] = chatID + // Check for message thread (forum topic) + messageThreadID := message.MessageThreadID + chatIDStr := fmt.Sprintf("%d", chatID) + if messageThreadID != 0 { + // Store thread ID for later use + c.threadIDs.Store(chatIDStr, messageThreadID) + // Encode thread ID into chatID string (similar to Slack pattern) + chatIDStr = fmt.Sprintf("%d:%d", chatID, messageThreadID) + } + content := "" mediaPaths := []string{} localFiles := []string{} // 跟踪需要清理的本地文件 @@ -301,11 +318,16 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat logger.DebugCF("telegram", "Received message", map[string]interface{}{ "sender_id": senderID, "chat_id": fmt.Sprintf("%d", chatID), + "thread_id": messageThreadID, "preview": utils.Truncate(content, 50), }) - // Thinking indicator - err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) + // Thinking indicator - include thread ID if present + chatAction := tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping) + if messageThreadID != 0 { + chatAction.MessageThreadID = messageThreadID + } + err := c.bot.SendChatAction(ctx, chatAction) if err != nil { logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{ "error": err.Error(), @@ -313,7 +335,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat } // Stop any previous thinking animation - chatIDStr := fmt.Sprintf("%d", chatID) if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { cf.Cancel() @@ -324,7 +345,12 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) + // Send "Thinking..." message - include thread ID if present + thinkingMsg := tu.Message(tu.ID(chatID), "Thinking... 💭") + if messageThreadID != 0 { + thinkingMsg.MessageThreadID = messageThreadID + } + pMsg, err := c.bot.SendMessage(ctx, thinkingMsg) if err == nil { pID := pMsg.MessageID c.placeholders.Store(chatIDStr, pID) @@ -337,8 +363,13 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } + + if messageThreadID != 0 { + metadata["message_thread_id"] = fmt.Sprintf("%d", messageThreadID) + metadata["is_topic_message"] = "true" + } - c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) + c.HandleMessage(senderID, chatIDStr, content, mediaPaths, metadata) } func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { @@ -386,6 +417,30 @@ func parseChatID(chatIDStr string) (int64, error) { return id, err } +// parseTelegramChatID extracts chatID and threadID from a combined chatID string +// Format: "chatID" or "chatID:threadID" +func parseTelegramChatID(chatIDStr string) (chatID int64, threadID int, err error) { + parts := strings.SplitN(chatIDStr, ":", 2) + + var id int64 + _, err = fmt.Sscanf(parts[0], "%d", &id) + if err != nil { + return 0, 0, err + } + chatID = id + + if len(parts) > 1 { + var tid int + _, err = fmt.Sscanf(parts[1], "%d", &tid) + if err != nil { + return chatID, 0, fmt.Errorf("invalid thread ID: %w", err) + } + threadID = tid + } + + return chatID, threadID, nil +} + func markdownToTelegramHTML(text string) string { if text == "" { return "" diff --git a/pkg/channels/telegram_test.go b/pkg/channels/telegram_test.go new file mode 100644 index 000000000..969f03222 --- /dev/null +++ b/pkg/channels/telegram_test.go @@ -0,0 +1,128 @@ +package channels + +import ( + "testing" +) + +func TestParseTelegramChatID(t *testing.T) { + tests := []struct { + name string + chatIDStr string + wantChatID int64 + wantThreadID int + wantErr bool + }{ + { + name: "chat only", + chatIDStr: "123456789", + wantChatID: 123456789, + wantThreadID: 0, + wantErr: false, + }, + { + name: "negative chat ID (private chat)", + chatIDStr: "-987654321", + wantChatID: -987654321, + wantThreadID: 0, + wantErr: false, + }, + { + name: "chat with thread", + chatIDStr: "123456789:42", + wantChatID: 123456789, + wantThreadID: 42, + wantErr: false, + }, + { + name: "negative chat with thread", + chatIDStr: "-987654321:100", + wantChatID: -987654321, + wantThreadID: 100, + wantErr: false, + }, + { + name: "invalid chat ID", + chatIDStr: "invalid", + wantChatID: 0, + wantThreadID: 0, + wantErr: true, + }, + { + name: "invalid thread ID", + chatIDStr: "123456789:invalid", + wantChatID: 123456789, + wantThreadID: 0, + wantErr: true, + }, + { + name: "empty string", + chatIDStr: "", + wantChatID: 0, + wantThreadID: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chatID, threadID, err := parseTelegramChatID(tt.chatIDStr) + + if (err != nil) != tt.wantErr { + t.Errorf("parseTelegramChatID(%q) error = %v, wantErr %v", tt.chatIDStr, err, tt.wantErr) + return + } + + if !tt.wantErr { + if chatID != tt.wantChatID { + t.Errorf("parseTelegramChatID(%q) chatID = %d, want %d", tt.chatIDStr, chatID, tt.wantChatID) + } + if threadID != tt.wantThreadID { + t.Errorf("parseTelegramChatID(%q) threadID = %d, want %d", tt.chatIDStr, threadID, tt.wantThreadID) + } + } + }) + } +} + +func TestParseChatID(t *testing.T) { + tests := []struct { + name string + chatIDStr string + want int64 + wantErr bool + }{ + { + name: "positive chat ID", + chatIDStr: "123456789", + want: 123456789, + wantErr: false, + }, + { + name: "negative chat ID", + chatIDStr: "-987654321", + want: -987654321, + wantErr: false, + }, + { + name: "invalid chat ID", + chatIDStr: "invalid", + want: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseChatID(tt.chatIDStr) + + if (err != nil) != tt.wantErr { + t.Errorf("parseChatID(%q) error = %v, wantErr %v", tt.chatIDStr, err, tt.wantErr) + return + } + + if !tt.wantErr && got != tt.want { + t.Errorf("parseChatID(%q) = %d, want %d", tt.chatIDStr, got, tt.want) + } + }) + } +} From e088cbafa61845294f4c408cad70c42ffe20145f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:13:06 +0000 Subject: [PATCH 3/4] Improve error message for invalid thread ID Include the actual invalid value in error message for better debugging Co-authored-by: zhaopengme <1415418+zhaopengme@users.noreply.github.com> --- pkg/channels/telegram.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 70422a714..1670ec48d 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -433,7 +433,7 @@ func parseTelegramChatID(chatIDStr string) (chatID int64, threadID int, err erro var tid int _, err = fmt.Sscanf(parts[1], "%d", &tid) if err != nil { - return chatID, 0, fmt.Errorf("invalid thread ID: %w", err) + return chatID, 0, fmt.Errorf("invalid thread ID '%s': %w", parts[1], err) } threadID = tid } From b453f7e9fb113a08533a8dca0d41d2ee90530159 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 09:15:01 +0000 Subject: [PATCH 4/4] Complete Telegram Group Topics implementation Co-authored-by: zhaopengme <1415418+zhaopengme@users.noreply.github.com> --- go.mod | 5 +---- go.sum | 2 ++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 98aecd6ab..c321ec7b9 100644 --- a/go.mod +++ b/go.mod @@ -19,8 +19,6 @@ require ( golang.org/x/oauth2 v0.35.0 ) - - require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect @@ -28,9 +26,9 @@ require ( github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/github/copilot-sdk/go v0.1.23 - github.com/google/jsonschema-go v0.4.2 // indirect github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -47,5 +45,4 @@ require ( golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - ) diff --git a/go.sum b/go.sum index 6a565b93e..18da86f62 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=