fix: deduplicate status/task messages by editing instead of creating new bubbles

IsStatus and IsTaskStatus flags on OutboundMessage were set by the agent
loop but completely ignored by the channel Manager. Every status update
created a new message bubble, flooding chats with redundant messages.

Add MessageSenderWithID interface and implement it for Telegram, Discord,
and Pico channels. Manager now routes IsStatus/IsTaskStatus messages to
dedicated handlers that track message IDs and edit in-place. Final
responses also reuse the tracked status message via preSend. TTL janitor
extended to clean up stale tracking entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-01 05:38:09 +09:00
parent 0033e621ff
commit c5a3990dd3
6 changed files with 611 additions and 5 deletions

View file

@ -219,6 +219,42 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes
} }
} }
// SendWithID implements channels.MessageSenderWithID.
// It sends a message and returns the platform message ID.
func (c *DiscordChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
if !c.IsRunning() {
return "", channels.ErrNotRunning
}
if chatID == "" {
return "", fmt.Errorf("channel ID is empty")
}
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
type result struct {
id string
err error
}
done := make(chan result, 1)
go func() {
msg, err := c.session.ChannelMessageSend(chatID, content)
if err != nil {
done <- result{"", fmt.Errorf("discord send: %w", channels.ErrTemporary)}
} else {
done <- result{msg.ID, nil}
}
}()
select {
case r := <-done:
return r.id, r.err
case <-sendCtx.Done():
return "", sendCtx.Err()
}
}
// EditMessage implements channels.MessageEditor. // EditMessage implements channels.MessageEditor.
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
_, err := c.session.ChannelMessageEdit(chatID, messageID, content) _, err := c.session.ChannelMessageEdit(chatID, messageID, content)

View file

@ -22,6 +22,12 @@ type ReactionCapable interface {
ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)
} }
// MessageSenderWithID — channels that can send a message and return its platform-specific ID.
// Used by Manager to track status/task messages for later editing.
type MessageSenderWithID interface {
SendWithID(ctx context.Context, chatID string, content string) (messageID string, err error)
}
// PlaceholderCapable — channels that can send a placeholder message // PlaceholderCapable — channels that can send a placeholder message
// (e.g. "Thinking... 💭") that will later be edited to the actual response. // (e.g. "Thinking... 💭") that will later be edited to the actual response.
// The channel MUST also implement MessageEditor for the placeholder to be useful. // The channel MUST also implement MessageEditor for the placeholder to be useful.

View file

@ -35,6 +35,8 @@ const (
janitorInterval = 10 * time.Second janitorInterval = 10 * time.Second
typingStopTTL = 5 * time.Minute typingStopTTL = 5 * time.Minute
placeholderTTL = 10 * time.Minute placeholderTTL = 10 * time.Minute
statusMsgTTL = 5 * time.Minute
taskMsgTTL = 30 * time.Minute
) )
// typingEntry wraps a typing stop function with a creation timestamp for TTL eviction. // typingEntry wraps a typing stop function with a creation timestamp for TTL eviction.
@ -55,6 +57,12 @@ type placeholderEntry struct {
createdAt time.Time createdAt time.Time
} }
// statusMsgEntry tracks a status or task message ID for later editing.
type statusMsgEntry struct {
messageID string
createdAt time.Time
}
// channelRateConfig maps channel name to per-second rate limit. // channelRateConfig maps channel name to per-second rate limit.
var channelRateConfig = map[string]float64{ var channelRateConfig = map[string]float64{
"telegram": 20, "telegram": 20,
@ -80,9 +88,11 @@ type Manager struct {
mediaStore media.MediaStore mediaStore media.MediaStore
dispatchTask *asyncTask dispatchTask *asyncTask
mu sync.RWMutex mu sync.RWMutex
placeholders sync.Map // "channel:chatID" → placeholderID (string) placeholders sync.Map // "channel:chatID" → placeholderEntry
typingStops sync.Map // "channel:chatID" → func() typingStops sync.Map // "channel:chatID" → typingEntry
reactionUndos sync.Map // "channel:chatID" → reactionEntry reactionUndos sync.Map // "channel:chatID" → reactionEntry
statusMsgIDs sync.Map // "channel:chatID" → statusMsgEntry (streaming preview)
taskMsgIDs sync.Map // taskID → statusMsgEntry (background task status)
} }
type asyncTask struct { type asyncTask struct {
@ -110,8 +120,8 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()}) m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()})
} }
// preSend handles typing stop, reaction undo, and placeholder editing before sending a message. // preSend handles typing stop, reaction undo, and placeholder/status editing before sending a message.
// Returns true if the message was edited into a placeholder (skip Send). // Returns true if the message was edited into an existing message (skip Send).
func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool {
key := name + ":" + msg.ChatID key := name + ":" + msg.ChatID
@ -129,7 +139,18 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess
} }
} }
// 3. Try editing placeholder // 3. Try editing a tracked status message (from streaming preview)
if v, loaded := m.statusMsgIDs.LoadAndDelete(key); loaded {
if entry, ok := v.(statusMsgEntry); ok && entry.messageID != "" {
if editor, ok := ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.messageID, msg.Content); err == nil {
return true // edited successfully, skip Send
}
}
}
}
// 4. Try editing placeholder
if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if editor, ok := ch.(MessageEditor); ok { if editor, ok := ch.(MessageEditor); ok {
@ -419,6 +440,17 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
if !ok { if !ok {
return return
} }
// Route status/task messages to dedicated handlers
if msg.IsStatus {
m.handleStatusSend(ctx, name, w, msg)
continue
}
if msg.IsTaskStatus {
m.handleTaskStatusSend(ctx, name, w, msg)
continue
}
maxLen := 0 maxLen := 0
if mlp, ok := w.ch.(MessageLengthProvider); ok { if mlp, ok := w.ch.(MessageLengthProvider); ok {
maxLen = mlp.MaxMessageLength() maxLen = mlp.MaxMessageLength()
@ -439,6 +471,93 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker)
} }
} }
// handleStatusSend processes IsStatus messages (streaming previews).
// It reuses an existing placeholder or tracked status message, or sends a new
// one via SendWithID so subsequent status updates edit the same bubble.
// If the channel doesn't support editing, the message is silently dropped.
func (m *Manager) handleStatusSend(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) {
if err := w.limiter.Wait(ctx); err != nil {
return
}
key := name + ":" + msg.ChatID
// 1. Try editing an existing placeholder
if v, loaded := m.placeholders.Load(key); loaded {
if entry, ok := v.(placeholderEntry); ok && entry.id != "" {
if editor, ok := w.ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil {
return
}
}
}
}
// 2. Try editing a previously tracked status message
if v, loaded := m.statusMsgIDs.Load(key); loaded {
if entry, ok := v.(statusMsgEntry); ok && entry.messageID != "" {
if editor, ok := w.ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.messageID, msg.Content); err == nil {
return
}
}
}
}
// 3. Send new message via SendWithID and track it
if sender, ok := w.ch.(MessageSenderWithID); ok {
if msgID, err := sender.SendWithID(ctx, msg.ChatID, msg.Content); err == nil && msgID != "" {
m.statusMsgIDs.Store(key, statusMsgEntry{
messageID: msgID,
createdAt: time.Now(),
})
return
}
}
// 4. Channel doesn't support SendWithID or editing — drop silently
}
// handleTaskStatusSend processes IsTaskStatus messages (background task status).
// It reuses a previously tracked task message, or sends a new one via SendWithID.
// If the channel doesn't support editing, falls back to regular Send.
func (m *Manager) handleTaskStatusSend(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) {
if err := w.limiter.Wait(ctx); err != nil {
return
}
taskKey := msg.TaskID
// 1. Try editing an existing task message
if taskKey != "" {
if v, loaded := m.taskMsgIDs.Load(taskKey); loaded {
if entry, ok := v.(statusMsgEntry); ok && entry.messageID != "" {
if editor, ok := w.ch.(MessageEditor); ok {
if err := editor.EditMessage(ctx, msg.ChatID, entry.messageID, msg.Content); err == nil {
return
}
}
}
}
}
// 2. Send new message via SendWithID and track it
if sender, ok := w.ch.(MessageSenderWithID); ok {
if msgID, err := sender.SendWithID(ctx, msg.ChatID, msg.Content); err == nil && msgID != "" {
if taskKey != "" {
m.taskMsgIDs.Store(taskKey, statusMsgEntry{
messageID: msgID,
createdAt: time.Now(),
})
}
return
}
}
// 3. Fallback: regular Send (for channels without SendWithID)
_ = w.ch.Send(ctx, msg)
}
// sendWithRetry sends a message through the channel with rate limiting and // sendWithRetry sends a message through the channel with rate limiting and
// retry logic. It classifies errors to determine the retry strategy: // retry logic. It classifies errors to determine the retry strategy:
// - ErrNotRunning / ErrSendFailed: permanent, no retry // - ErrNotRunning / ErrSendFailed: permanent, no retry
@ -700,6 +819,22 @@ func (m *Manager) runTTLJanitor(ctx context.Context) {
} }
return true return true
}) })
m.statusMsgIDs.Range(func(key, value any) bool {
if entry, ok := value.(statusMsgEntry); ok {
if now.Sub(entry.createdAt) > statusMsgTTL {
m.statusMsgIDs.Delete(key)
}
}
return true
})
m.taskMsgIDs.Range(func(key, value any) bool {
if entry, ok := value.(statusMsgEntry); ok {
if now.Sub(entry.createdAt) > taskMsgTTL {
m.taskMsgIDs.Delete(key)
}
}
return true
})
} }
} }
} }

View file

@ -862,3 +862,383 @@ func TestBuildMediaScope_WithMessageID(t *testing.T) {
t.Fatalf("expected %s, got %s", expected, scope) t.Fatalf("expected %s, got %s", expected, scope)
} }
} }
// --- Status / TaskStatus message handling tests ---
// mockEditorWithSendID implements MessageEditor and MessageSenderWithID.
type mockEditorWithSendID struct {
mockChannel
editFn func(ctx context.Context, chatID, messageID, content string) error
sendWithID func(ctx context.Context, chatID, content string) (string, error)
}
func (m *mockEditorWithSendID) EditMessage(ctx context.Context, chatID, messageID, content string) error {
return m.editFn(ctx, chatID, messageID, content)
}
func (m *mockEditorWithSendID) SendWithID(ctx context.Context, chatID, content string) (string, error) {
return m.sendWithID(ctx, chatID, content)
}
func TestHandleStatusSend_EditsPlaceholder(t *testing.T) {
m := newTestManager()
var editCalled bool
var editedContent string
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, content string) error {
editCalled = true
editedContent = content
if messageID != "ph-42" {
t.Fatalf("expected messageID ph-42, got %s", messageID)
}
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when placeholder exists")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
// Register a placeholder
m.RecordPlaceholder("test", "123", "ph-42")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "status update 1", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if !editCalled {
t.Fatal("expected EditMessage to be called on placeholder")
}
if editedContent != "status update 1" {
t.Fatalf("expected content 'status update 1', got %s", editedContent)
}
}
func TestHandleStatusSend_EditsTrackedStatus(t *testing.T) {
m := newTestManager()
var editCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, _ string) error {
editCalled = true
if messageID != "status-99" {
t.Fatalf("expected messageID status-99, got %s", messageID)
}
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when statusMsgID exists")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
// Pre-store a tracked status message
m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-99", createdAt: time.Now()})
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "update 2", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if !editCalled {
t.Fatal("expected EditMessage to be called on tracked status message")
}
}
func TestHandleStatusSend_SendsNewAndTracks(t *testing.T) {
m := newTestManager()
var sendWithIDCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, _, _ string) error {
return nil
},
sendWithID: func(_ context.Context, chatID, content string) (string, error) {
sendWithIDCalled = true
if chatID != "123" {
t.Fatalf("expected chatID 123, got %s", chatID)
}
return "new-msg-1", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
// No placeholder, no tracked status → should use SendWithID
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "first status", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if !sendWithIDCalled {
t.Fatal("expected SendWithID to be called")
}
// Verify tracked
v, ok := m.statusMsgIDs.Load("test:123")
if !ok {
t.Fatal("expected statusMsgIDs to contain tracked entry")
}
entry := v.(statusMsgEntry)
if entry.messageID != "new-msg-1" {
t.Fatalf("expected messageID new-msg-1, got %s", entry.messageID)
}
}
func TestHandleTaskStatusSend_EditsExisting(t *testing.T) {
m := newTestManager()
var editCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, content string) error {
editCalled = true
if messageID != "task-msg-1" {
t.Fatalf("expected messageID task-msg-1, got %s", messageID)
}
if content != "task progress 50%" {
t.Fatalf("expected content 'task progress 50%%', got %s", content)
}
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when task message exists")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
// Pre-store task message
m.taskMsgIDs.Store("task-abc", statusMsgEntry{messageID: "task-msg-1", createdAt: time.Now()})
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task progress 50%",
IsTaskStatus: true,
TaskID: "task-abc",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !editCalled {
t.Fatal("expected EditMessage to be called")
}
}
func TestHandleTaskStatusSend_SendsNewAndTracks(t *testing.T) {
m := newTestManager()
var sendWithIDCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, _, _ string) error { return nil },
sendWithID: func(_ context.Context, _, _ string) (string, error) {
sendWithIDCalled = true
return "new-task-msg", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task started",
IsTaskStatus: true,
TaskID: "task-xyz",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !sendWithIDCalled {
t.Fatal("expected SendWithID to be called")
}
v, ok := m.taskMsgIDs.Load("task-xyz")
if !ok {
t.Fatal("expected taskMsgIDs to contain tracked entry")
}
entry := v.(statusMsgEntry)
if entry.messageID != "new-task-msg" {
t.Fatalf("expected messageID new-task-msg, got %s", entry.messageID)
}
}
func TestHandleTaskStatusSend_FallbackToSend(t *testing.T) {
m := newTestManager()
var sendCalled bool
// Channel without SendWithID — only has Send
ch := &mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
sendCalled = true
if msg.Content != "task status" {
t.Fatalf("expected content 'task status', got %s", msg.Content)
}
return nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task status",
IsTaskStatus: true,
TaskID: "task-fallback",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !sendCalled {
t.Fatal("expected fallback Send to be called")
}
}
func TestPreSend_EditsStatusMessage(t *testing.T) {
m := newTestManager()
var editCalled bool
ch := &mockMessageEditor{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, _ string) error {
editCalled = true
if messageID != "status-msg-77" {
t.Fatalf("expected messageID status-msg-77, got %s", messageID)
}
return nil
},
}
// Store a tracked status message
m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-msg-77", createdAt: time.Now()})
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"}
edited := m.preSend(context.Background(), "test", msg, ch)
if !edited {
t.Fatal("expected preSend to return true (status message edited)")
}
if !editCalled {
t.Fatal("expected EditMessage to be called")
}
// Verify status message was consumed (LoadAndDelete)
if _, loaded := m.statusMsgIDs.Load("test:123"); loaded {
t.Fatal("expected statusMsgIDs entry to be deleted after preSend")
}
}
func TestRunWorker_RoutesStatusMessages(t *testing.T) {
m := newTestManager()
var regularSendCount atomic.Int32
var sendWithIDCount atomic.Int32
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
regularSendCount.Add(1)
return nil
},
},
editFn: func(_ context.Context, _, _, _ string) error {
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
sendWithIDCount.Add(1)
return "tracked-1", nil
},
}
w := &channelWorker{
ch: ch,
queue: make(chan bus.OutboundMessage, 10),
done: make(chan struct{}),
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go m.runWorker(ctx, "test", w)
// Send a status message for chatID "1" (routed to handleStatusSend → SendWithID)
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "status", IsStatus: true}
// Send a task status message for chatID "2" (routed to handleTaskStatusSend → SendWithID)
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "2", Content: "task", IsTaskStatus: true, TaskID: "t1"}
// Send a regular message for chatID "3" (no tracked status → regular Send)
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "3", Content: "hello"}
time.Sleep(200 * time.Millisecond)
if regularSendCount.Load() != 1 {
t.Fatalf("expected 1 regular Send call, got %d", regularSendCount.Load())
}
if sendWithIDCount.Load() != 2 {
t.Fatalf("expected 2 SendWithID calls (status + task), got %d", sendWithIDCount.Load())
}
}
func TestStatusMsgTTLJanitor(t *testing.T) {
m := newTestManager()
// Store entries with timestamps in the past
m.statusMsgIDs.Store("test:old", statusMsgEntry{
messageID: "old-status",
createdAt: time.Now().Add(-10 * time.Minute),
})
m.taskMsgIDs.Store("task-old", statusMsgEntry{
messageID: "old-task",
createdAt: time.Now().Add(-60 * time.Minute),
})
// Store a fresh entry that should survive
m.statusMsgIDs.Store("test:fresh", statusMsgEntry{
messageID: "fresh-status",
createdAt: time.Now(),
})
// Simulate janitor logic
now := time.Now()
m.statusMsgIDs.Range(func(key, value any) bool {
if entry, ok := value.(statusMsgEntry); ok {
if now.Sub(entry.createdAt) > statusMsgTTL {
m.statusMsgIDs.Delete(key)
}
}
return true
})
m.taskMsgIDs.Range(func(key, value any) bool {
if entry, ok := value.(statusMsgEntry); ok {
if now.Sub(entry.createdAt) > taskMsgTTL {
m.taskMsgIDs.Delete(key)
}
}
return true
})
if _, loaded := m.statusMsgIDs.Load("test:old"); loaded {
t.Fatal("expected old status entry to be evicted")
}
if _, loaded := m.taskMsgIDs.Load("task-old"); loaded {
t.Fatal("expected old task entry to be evicted")
}
if _, loaded := m.statusMsgIDs.Load("test:fresh"); !loaded {
t.Fatal("expected fresh status entry to survive")
}
}

View file

@ -150,6 +150,26 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return c.broadcastToSession(msg.ChatID, outMsg) return c.broadcastToSession(msg.ChatID, outMsg)
} }
// SendWithID implements channels.MessageSenderWithID.
// It sends a message and returns a generated message ID.
func (c *PicoChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
if !c.IsRunning() {
return "", channels.ErrNotRunning
}
msgID := uuid.New().String()
outMsg := newMessage(TypeMessageCreate, map[string]any{
"content": content,
"message_id": msgID,
})
if err := c.broadcastToSession(chatID, outMsg); err != nil {
return "", err
}
return msgID, nil
}
// EditMessage implements channels.MessageEditor. // EditMessage implements channels.MessageEditor.
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
outMsg := newMessage(TypeMessageUpdate, map[string]any{ outMsg := newMessage(TypeMessageUpdate, map[string]any{

View file

@ -192,6 +192,35 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return nil return nil
} }
// SendWithID implements channels.MessageSenderWithID.
// It sends a message and returns the platform message ID.
func (c *TelegramChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
if !c.IsRunning() {
return "", channels.ErrNotRunning
}
cid, err := parseChatID(chatID)
if err != nil {
return "", fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
}
htmlContent := markdownToTelegramHTML(content)
tgMsg := tu.Message(tu.ID(cid), htmlContent)
tgMsg.ParseMode = telego.ModeHTML
sent, err := c.bot.SendMessage(ctx, tgMsg)
if err != nil {
// Fallback to plain text
tgMsg.ParseMode = ""
sent, err = c.bot.SendMessage(ctx, tgMsg)
if err != nil {
return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}
return fmt.Sprintf("%d", sent.MessageID), nil
}
// StartTyping implements channels.TypingCapable. // StartTyping implements channels.TypingCapable.
// It sends ChatAction(typing) immediately and then repeats every 4 seconds // It sends ChatAction(typing) immediately and then repeats every 4 seconds
// (Telegram's typing indicator expires after ~5s) in a background goroutine. // (Telegram's typing indicator expires after ~5s) in a background goroutine.