From 391b6c686a6fdec27f87f8e4c16a960b24f3f38e Mon Sep 17 00:00:00 2001 From: Duy /zuey/ Date: Fri, 5 Jun 2026 17:29:17 +0700 Subject: [PATCH] fix(telegram): show tool status without placeholder (#139) * fix(channels): add reasoning delivery modes * fix(telegram): show tool status without placeholder --- internal/channels/telegram/send.go | 55 ++++++++- .../telegram/send_placeholder_update_test.go | 104 ++++++++++++++++++ 2 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 internal/channels/telegram/send_placeholder_update_test.go diff --git a/internal/channels/telegram/send.go b/internal/channels/telegram/send.go index c64149ea..74324b21 100644 --- a/internal/channels/telegram/send.go +++ b/internal/channels/telegram/send.go @@ -218,10 +218,7 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { // Placeholder update (e.g. LLM retry notification): edit the placeholder // but keep it alive for the final response. Don't stop typing or cleanup. if msg.Metadata["placeholder_update"] == "true" { - if pID, ok := c.placeholders.Load(localKey); ok { - _ = c.editMessage(ctx, chatID, pID.(int), msg.Content) - } - return nil + return c.updatePlaceholder(ctx, localKey, chatID, msg.Content, replyToMsgID, threadID) } // Stop thinking animation @@ -474,6 +471,56 @@ func (c *Channel) sendHTML(ctx context.Context, chatID int64, htmlContent string return c.sendHTMLWithDepth(ctx, chatID, htmlContent, replyTo, threadID, 0) } +func (c *Channel) updatePlaceholder(ctx context.Context, localKey string, chatID int64, content string, replyTo, threadID int) error { + if strings.TrimSpace(content) == "" { + return nil + } + htmlContent := html.EscapeString(content) + if pID, ok := c.placeholders.Load(localKey); ok { + _ = c.editMessage(ctx, chatID, pID.(int), htmlContent) + return nil + } + + msg, err := c.sendPlaceholder(ctx, chatID, htmlContent, replyTo, threadID) + if err != nil { + if newChatID := extractMigrateChatID(err); newChatID != 0 { + slog.Info("telegram: group migrated to supergroup (placeholder update)", + "old_chat_id", chatID, "new_chat_id", newChatID) + c.migrateGroupChat(ctx, chatID, newChatID) + msg, err = c.sendPlaceholder(ctx, newChatID, htmlContent, replyTo, threadID) + } + if err != nil { + return err + } + } + if msg != nil && msg.MessageID > 0 { + c.placeholders.Store(localKey, msg.MessageID) + } + return nil +} + +func (c *Channel) sendPlaceholder(ctx context.Context, chatID int64, htmlContent string, replyTo, threadID int) (*telego.Message, error) { + tgMsg := tu.Message(tu.ID(chatID), htmlContent) + tgMsg.ParseMode = telego.ModeHTML + if sendThreadID := resolveThreadIDForSend(threadID); sendThreadID > 0 { + tgMsg.MessageThreadID = sendThreadID + } + if replyTo > 0 { + tgMsg.ReplyParameters = &telego.ReplyParameters{ + MessageID: replyTo, + AllowSendingWithoutReply: true, + } + } + + var sent *telego.Message + err := c.retrySend(ctx, "sendMessage", nil, func(ctx context.Context) error { + var sendErr error + sent, sendErr = c.bot.SendMessage(ctx, tgMsg) + return sendErr + }) + return sent, err +} + func (c *Channel) sendHTMLWithDepth(ctx context.Context, chatID int64, htmlContent string, replyTo, threadID, depth int) error { tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML diff --git a/internal/channels/telegram/send_placeholder_update_test.go b/internal/channels/telegram/send_placeholder_update_test.go new file mode 100644 index 00000000..e46e4e1c --- /dev/null +++ b/internal/channels/telegram/send_placeholder_update_test.go @@ -0,0 +1,104 @@ +package telegram + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/mymmrac/telego" + ta "github.com/mymmrac/telego/telegoapi" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels" +) + +func TestSendPlaceholderUpdateCreatesLazyPlaceholderWhenMissing(t *testing.T) { + caller := &recordingTelegramCaller{} + bot, err := telego.NewBot( + "123456:abcdefghijklmnopqrstuvwxyzABCDE1234", + telego.WithAPICaller(caller), + telego.WithDiscardLogger(), + ) + if err != nil { + t.Fatalf("NewBot: %v", err) + } + + ch := &Channel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, nil), + bot: bot, + } + ch.SetRunning(true) + + status := bus.OutboundMessage{ + Channel: "telegram", + ChatID: "123", + Content: "⚡ Running code...", + Metadata: map[string]string{ + "placeholder_update": "true", + "reply_to_message_id": "77", + }, + } + if err := ch.Send(context.Background(), status); err != nil { + t.Fatalf("first placeholder update: %v", err) + } + if got := caller.methodNames(); strings.Join(got, ",") != "sendMessage" { + t.Fatalf("methods after first update = %v, want sendMessage", got) + } + if _, ok := ch.placeholders.Load("123"); !ok { + t.Fatal("placeholder id was not stored after lazy send") + } + + status.Content = "🔍 Searching the web..." + if err := ch.Send(context.Background(), status); err != nil { + t.Fatalf("second placeholder update: %v", err) + } + if got := caller.methodNames(); strings.Join(got, ",") != "sendMessage,editMessageText" { + t.Fatalf("methods after second update = %v, want sendMessage,editMessageText", got) + } + + final := bus.OutboundMessage{ + Channel: "telegram", + ChatID: "123", + Content: "Done", + Metadata: map[string]string{}, + } + if err := ch.Send(context.Background(), final); err != nil { + t.Fatalf("final send: %v", err) + } + if got := caller.methodNames(); strings.Join(got, ",") != "sendMessage,editMessageText,editMessageText" { + t.Fatalf("methods after final = %v, want sendMessage,editMessageText,editMessageText", got) + } + if _, ok := ch.placeholders.Load("123"); ok { + t.Fatal("placeholder survived final answer handoff") + } +} + +type recordingTelegramCaller struct { + calls []recordedTelegramCall +} + +type recordedTelegramCall struct { + method string + body map[string]any +} + +func (c *recordingTelegramCaller) Call(_ context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + method := url[strings.LastIndex(url, "/")+1:] + body := map[string]any{} + if len(data.BodyRaw) > 0 { + _ = json.Unmarshal(data.BodyRaw, &body) + } + c.calls = append(c.calls, recordedTelegramCall{method: method, body: body}) + + result := json.RawMessage(`{"message_id":101,"date":0,"chat":{"id":123,"type":"private"}}`) + return &ta.Response{Ok: true, Result: result}, nil +} + +func (c *recordingTelegramCaller) methodNames() []string { + names := make([]string, 0, len(c.calls)) + for _, call := range c.calls { + names = append(names, call.method) + } + return names +}