diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index f2217a829..d09ef3eae 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -633,26 +633,43 @@ func (m *Manager) handleTaskStatusSend(ctx context.Context, name string, w *chan taskKey := taskStatusKey(name, msg.ChatID, msg.TaskID) - // Final message: send as permanent (non-draft) message so it persists. - // Drafts are ephemeral and disappear after a short time; the completion - // message must survive. Clear the draft tracking and send via SendWithID - // or regular Send, which creates a permanent Telegram message. + // Final message: reuse the existing bubble when possible to avoid + // duplicate messages. If a permanent message (messageID) is tracked, + // edit it in-place. If a draft (draftID) is tracked, update it with + // the completion content (the draft persists in Telegram and serves + // as the visible message; sending a separate permanent message would + // create a duplicate). if msg.Final { - if v, loaded := m.taskMsgIDs.LoadAndDelete(taskKey); loaded { - if entry, ok := v.(statusMsgEntry); ok && entry.draftID != 0 { - if drafter, ok := w.ch.(DraftSender); ok { - if err := drafter.SendDraft(ctx, msg.ChatID, entry.draftID, ""); err != nil { - logger.WarnCF("channels", "Failed to dismiss task draft before final message", map[string]any{ - "task_id": taskKey, - "chat_id": msg.ChatID, - "draft_id": entry.draftID, - "error": err.Error(), - }) + v, loaded := m.taskMsgIDs.LoadAndDelete(taskKey) + m.statusEditTimes.Delete(taskKey) + + if loaded { + if entry, ok := v.(statusMsgEntry); ok { + // Path A: a permanent message exists — edit it in-place. + if entry.messageID != "" { + if editor, ok := w.ch.(MessageEditor); ok { + if err := editor.EditMessage(ctx, msg.ChatID, entry.messageID, msg.Content); err == nil { + return + } } + // Edit failed — fall through to send a new message. + } + + // Path B: a draft exists — update it with the final + // content. Drafts persist visibly in Telegram, so do NOT + // send a separate permanent message (that causes duplicates). + if entry.draftID != 0 { + if drafter, ok := w.ch.(DraftSender); ok { + if err := drafter.SendDraft(ctx, msg.ChatID, entry.draftID, msg.Content); err == nil { + return + } + } + // Draft update failed — fall through to send permanent. } } } - m.statusEditTimes.Delete(taskKey) + + // No existing bubble to reuse — send a new permanent message. if sender, ok := w.ch.(MessageSenderWithID); ok { if msgID, err := sender.SendWithID(ctx, msg.ChatID, msg.Content); err == nil && msgID != "" { return diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 928823ac9..c7efaaea2 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -1439,39 +1439,32 @@ func TestHandleTaskStatusSend_UsesDraftSender(t *testing.T) { } } -func TestHandleTaskStatusSend_Final_DismissesDraftBeforePermanentMessage(t *testing.T) { +func TestHandleTaskStatusSend_Final_UpdatesDraftInPlace(t *testing.T) { m := newTestManager() - var dismissCalled bool - var dismissDraftID int - var dismissContent string - var finalSendWithIDCalled bool + var draftUpdateCalled bool + var draftUpdateDraftID int + var draftUpdateContent string ch := &mockDraftSender{ mockChannel: mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { - t.Fatal("Send should not be called when SendWithID succeeds") + t.Fatal("Send should not be called when draft update succeeds") return nil }, }, draftFn: func(_ context.Context, chatID string, draftID int, content string) error { - dismissCalled = true - dismissDraftID = draftID - dismissContent = content + draftUpdateCalled = true + draftUpdateDraftID = draftID + draftUpdateContent = content if chatID != "123" { - t.Fatalf("expected dismiss chatID 123, got %s", chatID) + t.Fatalf("expected chatID 123, got %s", chatID) } return nil }, editFn: func(_ context.Context, _, _, _ string) error { return nil }, - sendWithID: func(_ context.Context, chatID, content string) (string, error) { - finalSendWithIDCalled = true - if chatID != "123" { - t.Fatalf("expected final chatID 123, got %s", chatID) - } - if content != "task completed" { - t.Fatalf("expected final content 'task completed', got %s", content) - } - return "task-final-1", nil + sendWithID: func(_ context.Context, _, _ string) (string, error) { + t.Fatal("SendWithID should not be called when draft update succeeds") + return "", nil }, } @@ -1490,17 +1483,14 @@ func TestHandleTaskStatusSend_Final_DismissesDraftBeforePermanentMessage(t *test } m.handleTaskStatusSend(context.Background(), "test", w, msg) - if !dismissCalled { - t.Fatal("expected SendDraft dismiss call for final task status") + if !draftUpdateCalled { + t.Fatal("expected SendDraft to update draft with final content") } - if dismissDraftID != 42 { - t.Fatalf("expected dismiss draftID 42, got %d", dismissDraftID) + if draftUpdateDraftID != 42 { + t.Fatalf("expected draftID 42, got %d", draftUpdateDraftID) } - if dismissContent != "" { - t.Fatalf("expected empty dismiss content, got %q", dismissContent) - } - if !finalSendWithIDCalled { - t.Fatal("expected final SendWithID to be called") + if draftUpdateContent != "task completed" { + t.Fatalf("expected draft content 'task completed', got %q", draftUpdateContent) } if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "123", "task-final")); loaded { t.Fatal("expected taskMsgIDs entry to be deleted for final task status")