Skip to content

Commit d00bef8

Browse files
committed
fix(telegram): suppress duplicate messages on high-latency streaming connections
1 parent e13db25 commit d00bef8

2 files changed

Lines changed: 70 additions & 14 deletions

File tree

internal/channels/telegram/send.go

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ func stripHTML(s string) string {
3939
}
4040

4141
// isRetryableNetworkErr checks if a Telegram API error is a transient network error worth retrying.
42+
// Includes post-connect network stalls and 5xx server errors.
4243
func isRetryableNetworkErr(err error) bool {
4344
if err == nil {
4445
return false
@@ -48,7 +49,24 @@ func isRetryableNetworkErr(err error) bool {
4849
strings.Contains(s, "connection reset") ||
4950
strings.Contains(s, "broken pipe") ||
5051
strings.Contains(s, "EOF") ||
51-
strings.Contains(s, "lookup") // DNS resolution failure
52+
strings.Contains(s, "lookup") || // DNS resolution failure
53+
strings.Contains(s, "502") || // Bad Gateway
54+
strings.Contains(s, "503") || // Service Unavailable
55+
strings.Contains(s, "504") // Gateway Timeout
56+
}
57+
58+
// isPostConnectNetworkErr check if the error likely occurred AFTER reaching the server
59+
// (timeout, connection reset, EOF) vs before (DNS lookup failure).
60+
func isPostConnectNetworkErr(err error) bool {
61+
if err == nil {
62+
return false
63+
}
64+
s := err.Error()
65+
// Exclude "lookup" (DNS) and "connection refused" as they are safe pre-connect errors.
66+
return (strings.Contains(s, "timeout") ||
67+
strings.Contains(s, "connection reset") ||
68+
strings.Contains(s, "broken pipe") ||
69+
strings.Contains(s, "EOF")) && !strings.Contains(s, "lookup")
5270
}
5371

5472
// retrySend wraps a Telegram send call with retry logic for transient network errors.
@@ -185,11 +203,33 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error {
185203
startChunk := 0
186204
if pID, ok := c.placeholders.Load(localKey); ok {
187205
c.placeholders.Delete(localKey)
188-
if err := c.editMessage(ctx, chatID, pID.(int), chunks[0]); err == nil {
189-
startChunk = 1 // first chunk edited into stream message
206+
msgID := pID.(int)
207+
208+
if msgID == -1 {
209+
// SIGNAL from stream: A message transport send likely landed but ID was never retrieved.
210+
// Swallow the first chunk ONLY if there are more chunks to come (minimizes visible duplicate).
211+
// If it is the ONLY chunk, we deliver it anyway to guarantee the user sees the answer.
212+
if len(chunks) > 1 {
213+
slog.Warn("telegram: ghost message detected, skipping first chunk of multi-chunk response", "chat_id", chatID)
214+
startChunk = 1
215+
}
190216
} else {
191-
// Edit failed (message deleted externally, etc.) — delete and send all fresh
192-
_ = c.deleteMessage(ctx, chatID, pID.(int))
217+
err := c.editMessage(ctx, chatID, msgID, chunks[0])
218+
if err == nil {
219+
startChunk = 1 // first chunk edited into stream message
220+
} else if isPostConnectNetworkErr(err) && len(chunks) > 1 {
221+
// Mid-stream timeout/lost connection: the edit likely reached Telegram
222+
// but the response was lost. Swallow and skip chunk 0 ONLY for multi-chunk
223+
// messages where the rest of the answer is still coming.
224+
slog.Warn("telegram: final edit timed out or lost, skipping chunk 0 of multi-chunk response",
225+
"chat_id", chatID, "message_id", msgID, "error", err)
226+
startChunk = 1
227+
} else {
228+
// Edit failed definitely (400 rejection), or a single-chunk edit timed out.
229+
// For single-chunk answers, we delete (best-effort) and send fresh to
230+
// guarantee the user gets the content.
231+
_ = c.deleteMessage(ctx, chatID, msgID)
232+
}
193233
}
194234
}
195235

@@ -543,19 +583,22 @@ func (c *Channel) sendDocument(ctx context.Context, chatID telego.ChatID, filePa
543583
}
544584

545585
// editMessage edits an existing message's text.
586+
// Uses retrySend since edits are idempotent and may fail on transient network issues.
546587
func (c *Channel) editMessage(ctx context.Context, chatID int64, messageID int, htmlText string) error {
547588
editMsg := tu.EditMessageText(tu.ID(chatID), messageID, htmlText)
548589
editMsg.ParseMode = telego.ModeHTML
549590

550-
_, err := c.bot.EditMessageText(ctx, editMsg)
551-
if err != nil {
552-
// Ignore "message is not modified" errors (idempotent edit)
553-
if messageNotModifiedRe.MatchString(err.Error()) {
554-
return nil
591+
return c.retrySend(ctx, "editMessage", nil, func(ctx context.Context) error {
592+
_, err := c.bot.EditMessageText(ctx, editMsg)
593+
if err != nil {
594+
// Ignore "message is not modified" errors (idempotent edit)
595+
if messageNotModifiedRe.MatchString(err.Error()) {
596+
return nil
597+
}
598+
return err
555599
}
556-
return err
557-
}
558-
return nil
600+
return nil
601+
})
559602
}
560603

561604
// deleteMessage deletes a message from the chat.

internal/channels/telegram/stream.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ type DraftStream struct {
8989
draftID int // sendMessageDraft draft_id (0 = message transport)
9090
useDraft bool // true = draft transport, false = message transport
9191
draftFailed bool // true = draft API rejected permanently, using message transport
92+
sendMayHaveLanded bool // true = initial sendMessage was attempted and may have landed (even if timed out)
9293
}
9394

9495
// NewDraftStream creates a new streaming preview manager.
@@ -199,6 +200,7 @@ func (ds *DraftStream) flush(ctx context.Context) error {
199200
if sendThreadID := resolveThreadIDForSend(ds.messageThreadID); sendThreadID > 0 {
200201
params.MessageThreadID = sendThreadID
201202
}
203+
ds.sendMayHaveLanded = true
202204
msg, err := ds.bot.SendMessage(ctx, params)
203205
// TS ref: withTelegramThreadFallback — retry without thread ID when topic is deleted.
204206
if err != nil && params.MessageThreadID != 0 && threadNotFoundRe.MatchString(err.Error()) {
@@ -207,6 +209,10 @@ func (ds *DraftStream) flush(ctx context.Context) error {
207209
msg, err = ds.bot.SendMessage(ctx, params)
208210
}
209211
if err != nil {
212+
if isPostConnectNetworkErr(err) {
213+
slog.Warn("stream: initial sendMessage timed out or lost. Treating as landed to avoid duplicate.", "error", err)
214+
return nil // treat as successful but with unknown messageID
215+
}
210216
slog.Debug("stream: failed to send initial message", "error", err)
211217
return err
212218
}
@@ -312,10 +318,17 @@ func (c *Channel) CreateStream(ctx context.Context, chatID string, firstStream b
312318
// Also stops any thinking animation for the chat.
313319
// Implements channels.StreamingChannel.
314320
func (c *Channel) FinalizeStream(ctx context.Context, chatID string, stream channels.ChannelStream) {
315-
if msgID := stream.MessageID(); msgID != 0 {
321+
msgID := stream.MessageID()
322+
if msgID != 0 {
316323
// Hand off the stream message to Send() for final formatted edit.
317324
c.placeholders.Store(chatID, msgID)
318325
slog.Info("stream: ended, handing off to Send()", "chat_id", chatID, "message_id", msgID)
326+
} else if ds, ok := stream.(*DraftStream); ok && ds.sendMayHaveLanded && !ds.UsedDraftTransport() {
327+
// The message transport was used but no ID was retrieved (timeout).
328+
// We MUST store a -1 placeholder to signal to Send() that a message
329+
// likely landed and it should NOT send a duplicate, even if it cannot edit.
330+
c.placeholders.Store(chatID, -1)
331+
slog.Warn("stream: initial send landed but ID unknown. Suppressing fallback message to avoid duplicate.", "chat_id", chatID)
319332
}
320333

321334
// Stop thinking animation

0 commit comments

Comments
 (0)