Skip to content

Commit 2eef403

Browse files
ernadoclaude
andcommitted
fix: dedup redelivered business messages
A business bot replied to the same message repeatedly: Telegram may redeliver updates (notably to bots, which lack user-style getDifference gap recovery), so the same updateBotNewBusinessMessage arrives more than once. The official telegram-bot-api server is once-only because TDLib dedups; match that with a bounded FIFO set keyed by connectionID:msgID:editDate (Bot.businessSeen), checked in dispatchBusinessMessage before routing. Edit date lets genuine edits through while suppressing redeliveries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6f2c6ed commit 2eef403

5 files changed

Lines changed: 116 additions & 0 deletions

File tree

bot.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ type Bot struct {
5050
runCtx context.Context
5151

5252
self *tg.User
53+
54+
// businessSeen suppresses re-processing of redelivered business messages, so
55+
// the bot does not reply to the same message twice.
56+
businessSeen *businessDedup
5357
}
5458

5559
// New constructs an unconnected Bot from a BotFather token. It performs no
@@ -118,6 +122,7 @@ func New(token string, opt Options) (*Bot, error) {
118122
disp: disp,
119123
onStart: opt.OnStart,
120124
registerCommands: !opt.DisableCommandRegistration,
125+
businessSeen: newBusinessDedup(businessDedupSize),
121126
}
122127
b.installHandlers()
123128

business_dedup.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package botapi
2+
3+
import (
4+
"strconv"
5+
"sync"
6+
7+
"github.com/gotd/td/tg"
8+
)
9+
10+
// businessDedupSize is the number of recent business messages remembered for
11+
// deduplication. Telegram may redeliver updates (notably to bots after a
12+
// reconnect), and the qts sequence does not always suppress this; the window
13+
// only needs to cover the burst a redelivery replays.
14+
const businessDedupSize = 4096
15+
16+
// businessDedup remembers recently delivered business messages so a redelivered
17+
// update does not fire handlers — and reply — twice. It is a fixed-size set with
18+
// FIFO eviction: safe for concurrent use, bounded in memory.
19+
type businessDedup struct {
20+
mu sync.Mutex
21+
seen map[string]struct{}
22+
ring []string
23+
pos int
24+
}
25+
26+
func newBusinessDedup(size int) *businessDedup {
27+
return &businessDedup{
28+
seen: make(map[string]struct{}, size),
29+
ring: make([]string, size),
30+
}
31+
}
32+
33+
// fresh reports whether key has not been seen recently, recording it. A repeated
34+
// key (a redelivered update) returns false.
35+
func (d *businessDedup) fresh(key string) bool {
36+
d.mu.Lock()
37+
defer d.mu.Unlock()
38+
39+
if _, ok := d.seen[key]; ok {
40+
return false
41+
}
42+
43+
if old := d.ring[d.pos]; old != "" {
44+
delete(d.seen, old)
45+
}
46+
47+
d.ring[d.pos] = key
48+
d.pos = (d.pos + 1) % len(d.ring)
49+
d.seen[key] = struct{}{}
50+
51+
return true
52+
}
53+
54+
// businessMessageKey identifies a business message for deduplication. The edit
55+
// date distinguishes a genuine edit (which should be handled) from a redelivery
56+
// of the same message (which should not).
57+
func businessMessageKey(connectionID string, m *tg.Message) string {
58+
edit, _ := m.GetEditDate()
59+
60+
return connectionID + ":" + strconv.Itoa(m.ID) + ":" + strconv.Itoa(edit)
61+
}

business_updates.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ func (b *Bot) dispatchBusinessMessage(ctx context.Context, e tg.Entities, connec
6161

6262
m.BusinessConnectionID = connectionID
6363
if m.raw != nil {
64+
// Drop redelivered messages so the bot does not reply to the same one
65+
// twice (Telegram may resend updates, especially to bots on reconnect).
66+
if b.businessSeen != nil && !b.businessSeen.fresh(businessMessageKey(connectionID, m.raw)) {
67+
return
68+
}
69+
6470
m.businessPeer = inputPeerFromEntities(m.raw.PeerID, e)
6571
}
6672

business_updates_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,48 @@ func TestDispatchBusinessMessageEdited(t *testing.T) {
7474
}
7575
}
7676

77+
func TestDispatchBusinessMessageDedup(t *testing.T) {
78+
b := newMockBot(newMockInvoker())
79+
80+
var fires int
81+
82+
b.OnBusinessMessage(func(c *Context) error { fires++; return nil })
83+
84+
e := tg.Entities{Users: map[int64]*tg.User{10: {ID: 10, AccessHash: 1}}}
85+
msg := &tg.Message{ID: 50, PeerID: &tg.PeerUser{UserID: 10}}
86+
87+
// A redelivered (identical) business message must be handled only once.
88+
b.dispatchBusinessMessage(context.Background(), e, "bc1", msg, false)
89+
b.dispatchBusinessMessage(context.Background(), e, "bc1", msg, false)
90+
91+
if fires != 1 {
92+
t.Fatalf("handler fired %d times, want 1", fires)
93+
}
94+
95+
// The same id on a different connection is a different message.
96+
b.dispatchBusinessMessage(context.Background(), e, "bc2", msg, false)
97+
98+
if fires != 2 {
99+
t.Fatalf("handler fired %d times, want 2", fires)
100+
}
101+
}
102+
103+
func TestBusinessDedupFresh(t *testing.T) {
104+
d := newBusinessDedup(2)
105+
106+
if !d.fresh("a") || d.fresh("a") {
107+
t.Fatal("first 'a' fresh, second not")
108+
}
109+
110+
// Fill past capacity to evict "a", which then reads as fresh again.
111+
d.fresh("b")
112+
d.fresh("c")
113+
114+
if !d.fresh("a") {
115+
t.Fatal("evicted key should read fresh again")
116+
}
117+
}
118+
77119
func TestContextBusinessFromMessage(t *testing.T) {
78120
b := newMockBot(newMockInvoker())
79121
c := &Context{Context: context.Background(), Bot: b, Update: &Update{

harness_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ func newMockBot(inv *mockInvoker) *Bot {
193193
peers: peers.Options{}.Build(raw),
194194
disp: tg.NewUpdateDispatcher(),
195195
self: &tg.User{ID: 1, Bot: true, AccessHash: 1, Username: "test_bot"},
196+
197+
businessSeen: newBusinessDedup(businessDedupSize),
196198
}
197199
}
198200

0 commit comments

Comments
 (0)