Merge pull request #1 from zhaopengme/copilot/support-telegram-group-topics
This commit is contained in:
commit
20f40139d1
4 changed files with 192 additions and 10 deletions
5
go.mod
5
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
|
||||
|
||||
)
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
|
|
@ -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 '%s': %w", parts[1], err)
|
||||
}
|
||||
threadID = tid
|
||||
}
|
||||
|
||||
return chatID, threadID, nil
|
||||
}
|
||||
|
||||
func markdownToTelegramHTML(text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
|
|
|
|||
128
pkg/channels/telegram_test.go
Normal file
128
pkg/channels/telegram_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue