@@ -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.
4243func 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.
546587func (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.
0 commit comments