diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index cd6a2560f..57070c6d7 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -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. func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { _, err := c.session.ChannelMessageEdit(chatID, messageID, content) diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index 74caeeac5..4421e7662 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -22,6 +22,12 @@ type ReactionCapable interface { 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 // (e.g. "Thinking... 💭") that will later be edited to the actual response. // The channel MUST also implement MessageEditor for the placeholder to be useful. diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 2999404e5..f6ba2c2cc 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -35,6 +35,8 @@ const ( janitorInterval = 10 * time.Second typingStopTTL = 5 * 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. @@ -55,6 +57,12 @@ type placeholderEntry struct { 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. var channelRateConfig = map[string]float64{ "telegram": 20, @@ -80,9 +88,11 @@ type Manager struct { mediaStore media.MediaStore dispatchTask *asyncTask mu sync.RWMutex - placeholders sync.Map // "channel:chatID" → placeholderID (string) - typingStops sync.Map // "channel:chatID" → func() + placeholders sync.Map // "channel:chatID" → placeholderEntry + typingStops sync.Map // "channel:chatID" → typingEntry 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 { @@ -110,8 +120,8 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()}) } -// preSend handles typing stop, reaction undo, and placeholder editing before sending a message. -// Returns true if the message was edited into a placeholder (skip Send). +// preSend handles typing stop, reaction undo, and placeholder/status editing before sending a message. +// 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 { 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 entry, ok := v.(placeholderEntry); ok && entry.id != "" { if editor, ok := ch.(MessageEditor); ok { @@ -419,6 +440,17 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) if !ok { 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 if mlp, ok := w.ch.(MessageLengthProvider); ok { 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 // retry logic. It classifies errors to determine the retry strategy: // - ErrNotRunning / ErrSendFailed: permanent, no retry @@ -700,6 +819,22 @@ func (m *Manager) runTTLJanitor(ctx context.Context) { } 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 + }) } } } diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 6b9f151c3..7ccec64eb 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -862,3 +862,383 @@ func TestBuildMediaScope_WithMessageID(t *testing.T) { 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") + } +} diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 2ae82d8da..70d79bac0 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -150,6 +150,26 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { 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. func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { outMsg := newMessage(TypeMessageUpdate, map[string]any{ diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index a11cf53b8..c302ddb68 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -192,6 +192,35 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err 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. // It sends ChatAction(typing) immediately and then repeats every 4 seconds // (Telegram's typing indicator expires after ~5s) in a background goroutine.