Skip to content

Commit 5b47866

Browse files
jaantaponenclaude
andauthored
feat(telereactions): extract Telegram message_reaction dispatch (#27)
telebot v4 exposes MessageReaction update fields but does not route them to a Handle() endpoint, so telegram_api.go inlined a MiddlewarePoller filter that diffed OldReaction/NewReaction. Move that logic into internal/telereactions so the platform glue only wires callbacks to DB updates, and add unit tests for the diff against synthetic tele.Update fixtures. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 131d194 commit 5b47866

3 files changed

Lines changed: 209 additions & 45 deletions

File tree

internal/platforms/telegram_api.go

Lines changed: 17 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/napuu/gpsp-bot/internal/chain"
1010
"github.com/napuu/gpsp-bot/internal/config"
1111
"github.com/napuu/gpsp-bot/internal/handlers"
12+
"github.com/napuu/gpsp-bot/internal/telereactions"
1213
"github.com/napuu/gpsp-bot/pkg/utils"
1314

1415
tele "gopkg.in/telebot.v4"
@@ -56,44 +57,25 @@ func RunTelegramBot() {
5657

5758
func getTelegramBot(dbPath string) *tele.Bot {
5859
inner := &tele.LongPoller{
59-
Timeout: 10 * time.Second,
60-
AllowedUpdates: []string{
61-
"message",
62-
"message_reaction",
63-
},
60+
Timeout: 10 * time.Second,
61+
AllowedUpdates: []string{"message"},
6462
}
6563

66-
poller := tele.NewMiddlewarePoller(inner, func(u *tele.Update) bool {
67-
if u.MessageReaction != nil {
68-
mr := u.MessageReaction
69-
groupId := "telegram:" + fmt.Sprint(mr.Chat.ID)
70-
botMsgId := fmt.Sprint(mr.MessageID)
71-
72-
db, err := utils.OpenStatsDB(dbPath)
73-
if err != nil {
74-
slog.Warn("Failed to open stats DB for reaction", "error", err)
75-
return true
76-
}
77-
defer db.Close()
78-
79-
// Find added reactions (present in New but not Old)
80-
for _, r := range mr.NewReaction {
81-
if !containsEmoji(mr.OldReaction, r.Emoji) {
82-
if err := utils.UpdateReactionCount(db, "telegram", groupId, botMsgId, r.Emoji, +1); err != nil {
83-
slog.Warn("Failed to update Telegram reaction count", "error", err)
84-
}
85-
}
86-
}
87-
// Find removed reactions (present in Old but not New)
88-
for _, r := range mr.OldReaction {
89-
if !containsEmoji(mr.NewReaction, r.Emoji) {
90-
if err := utils.UpdateReactionCount(db, "telegram", groupId, botMsgId, r.Emoji, -1); err != nil {
91-
slog.Warn("Failed to update Telegram reaction count", "error", err)
92-
}
93-
}
94-
}
64+
updateCount := func(e telereactions.Event, delta int) {
65+
db, err := utils.OpenStatsDB(dbPath)
66+
if err != nil {
67+
slog.Warn("Failed to open stats DB for reaction", "error", err)
68+
return
9569
}
96-
return true
70+
defer db.Close()
71+
groupId := "telegram:" + fmt.Sprint(e.Chat.ID)
72+
if err := utils.UpdateReactionCount(db, "telegram", groupId, fmt.Sprint(e.MessageID), e.Emoji, delta); err != nil {
73+
slog.Warn("Failed to update Telegram reaction count", "error", err)
74+
}
75+
}
76+
poller := telereactions.Wrap(inner, telereactions.Handlers{
77+
OnAdd: func(e telereactions.Event) { updateCount(e, +1) },
78+
OnRemove: func(e telereactions.Event) { updateCount(e, -1) },
9779
})
9880

9981
pref := tele.Settings{
@@ -109,13 +91,3 @@ func getTelegramBot(dbPath string) *tele.Bot {
10991

11092
return b
11193
}
112-
113-
// containsEmoji reports whether emoji e is present in the reaction slice.
114-
func containsEmoji(reactions []tele.Reaction, emoji string) bool {
115-
for _, r := range reactions {
116-
if r.Emoji == emoji {
117-
return true
118-
}
119-
}
120-
return false
121-
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Package telereactions bridges a gap in gopkg.in/telebot.v4: the library
2+
// exposes message_reaction update fields but does not dispatch them to a
3+
// Handle() endpoint. Wrap attaches a filter to a LongPoller that diffs each
4+
// update's OldReaction/NewReaction and invokes per-emoji add/remove callbacks.
5+
package telereactions
6+
7+
import (
8+
tele "gopkg.in/telebot.v4"
9+
)
10+
11+
// AllowedUpdate is the Telegram update type this package consumes. Wrap
12+
// ensures it is present in the inner poller's AllowedUpdates.
13+
const AllowedUpdate = "message_reaction"
14+
15+
// Event describes a single emoji change on a message. One MessageReaction
16+
// update with N changed emoji becomes N events.
17+
type Event struct {
18+
Chat *tele.Chat
19+
MessageID int
20+
User *tele.User
21+
ActorChat *tele.Chat
22+
Emoji string
23+
}
24+
25+
// Handlers holds the callbacks Wrap invokes. Either field may be nil.
26+
type Handlers struct {
27+
OnAdd func(Event)
28+
OnRemove func(Event)
29+
}
30+
31+
// Wrap returns a MiddlewarePoller that diffs message_reaction updates and
32+
// fires Handlers for each changed emoji. It mutates inner.AllowedUpdates to
33+
// include "message_reaction" if missing.
34+
func Wrap(inner *tele.LongPoller, h Handlers) *tele.MiddlewarePoller {
35+
ensureAllowed(inner)
36+
return tele.NewMiddlewarePoller(inner, func(u *tele.Update) bool {
37+
dispatch(u, h)
38+
return true
39+
})
40+
}
41+
42+
func ensureAllowed(p *tele.LongPoller) {
43+
for _, u := range p.AllowedUpdates {
44+
if u == AllowedUpdate {
45+
return
46+
}
47+
}
48+
p.AllowedUpdates = append(p.AllowedUpdates, AllowedUpdate)
49+
}
50+
51+
func dispatch(u *tele.Update, h Handlers) {
52+
if u == nil || u.MessageReaction == nil {
53+
return
54+
}
55+
mr := u.MessageReaction
56+
base := Event{
57+
Chat: mr.Chat,
58+
MessageID: mr.MessageID,
59+
User: mr.User,
60+
ActorChat: mr.ActorChat,
61+
}
62+
if h.OnAdd != nil {
63+
for _, r := range mr.NewReaction {
64+
if !containsEmoji(mr.OldReaction, r.Emoji) {
65+
e := base
66+
e.Emoji = r.Emoji
67+
h.OnAdd(e)
68+
}
69+
}
70+
}
71+
if h.OnRemove != nil {
72+
for _, r := range mr.OldReaction {
73+
if !containsEmoji(mr.NewReaction, r.Emoji) {
74+
e := base
75+
e.Emoji = r.Emoji
76+
h.OnRemove(e)
77+
}
78+
}
79+
}
80+
}
81+
82+
func containsEmoji(rs []tele.Reaction, emoji string) bool {
83+
for _, r := range rs {
84+
if r.Emoji == emoji {
85+
return true
86+
}
87+
}
88+
return false
89+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package telereactions
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
tele "gopkg.in/telebot.v4"
8+
)
9+
10+
func emoji(s string) tele.Reaction {
11+
return tele.Reaction{Type: tele.ReactionTypeEmoji, Emoji: s}
12+
}
13+
14+
func TestDispatch_AddRemoveDiff(t *testing.T) {
15+
var added, removed []string
16+
h := Handlers{
17+
OnAdd: func(e Event) { added = append(added, e.Emoji) },
18+
OnRemove: func(e Event) { removed = append(removed, e.Emoji) },
19+
}
20+
21+
u := &tele.Update{
22+
MessageReaction: &tele.MessageReaction{
23+
Chat: &tele.Chat{ID: -1001234567890},
24+
MessageID: 42,
25+
OldReaction: []tele.Reaction{emoji("👍"), emoji("❤")},
26+
NewReaction: []tele.Reaction{emoji("👍"), emoji("🔥")},
27+
},
28+
}
29+
dispatch(u, h)
30+
31+
if !reflect.DeepEqual(added, []string{"🔥"}) {
32+
t.Errorf("added = %v, want [🔥]", added)
33+
}
34+
if !reflect.DeepEqual(removed, []string{"❤"}) {
35+
t.Errorf("removed = %v, want [❤]", removed)
36+
}
37+
}
38+
39+
func TestDispatch_NilHandlersDoNotPanic(t *testing.T) {
40+
u := &tele.Update{
41+
MessageReaction: &tele.MessageReaction{
42+
Chat: &tele.Chat{ID: 1},
43+
NewReaction: []tele.Reaction{emoji("👍")},
44+
},
45+
}
46+
dispatch(u, Handlers{}) // OnAdd + OnRemove both nil
47+
}
48+
49+
func TestDispatch_IgnoresNonReactionUpdates(t *testing.T) {
50+
called := false
51+
h := Handlers{OnAdd: func(Event) { called = true }}
52+
dispatch(&tele.Update{}, h)
53+
dispatch(nil, h)
54+
if called {
55+
t.Error("OnAdd fired for update without MessageReaction")
56+
}
57+
}
58+
59+
func TestDispatch_CarriesChatAndMessageID(t *testing.T) {
60+
var got Event
61+
h := Handlers{OnAdd: func(e Event) { got = e }}
62+
u := &tele.Update{
63+
MessageReaction: &tele.MessageReaction{
64+
Chat: &tele.Chat{ID: -100999},
65+
MessageID: 777,
66+
User: &tele.User{ID: 55},
67+
NewReaction: []tele.Reaction{emoji("👎")},
68+
},
69+
}
70+
dispatch(u, h)
71+
72+
if got.Chat.ID != -100999 || got.MessageID != 777 || got.User.ID != 55 || got.Emoji != "👎" {
73+
t.Errorf("event = %+v", got)
74+
}
75+
}
76+
77+
func TestEnsureAllowed_AppendsWhenMissing(t *testing.T) {
78+
p := &tele.LongPoller{AllowedUpdates: []string{"message"}}
79+
ensureAllowed(p)
80+
want := []string{"message", "message_reaction"}
81+
if !reflect.DeepEqual(p.AllowedUpdates, want) {
82+
t.Errorf("AllowedUpdates = %v, want %v", p.AllowedUpdates, want)
83+
}
84+
}
85+
86+
func TestEnsureAllowed_NoDuplicate(t *testing.T) {
87+
p := &tele.LongPoller{AllowedUpdates: []string{"message", "message_reaction"}}
88+
ensureAllowed(p)
89+
if len(p.AllowedUpdates) != 2 {
90+
t.Errorf("AllowedUpdates = %v, expected no duplicate", p.AllowedUpdates)
91+
}
92+
}
93+
94+
func TestWrap_ReturnsMiddlewarePoller(t *testing.T) {
95+
inner := &tele.LongPoller{}
96+
mp := Wrap(inner, Handlers{})
97+
if mp == nil || mp.Poller != inner {
98+
t.Error("Wrap did not return a MiddlewarePoller wrapping the inner poller")
99+
}
100+
if len(inner.AllowedUpdates) == 0 || inner.AllowedUpdates[0] != "message_reaction" {
101+
t.Errorf("Wrap did not inject message_reaction; got %v", inner.AllowedUpdates)
102+
}
103+
}

0 commit comments

Comments
 (0)