Skip to content

Commit 2b7bfec

Browse files
skensell201skenselclaude
authored
feat(telegram): agent-callable message reaction (message action=react) (#1407)
Adds a "react" action to the message tool so an agent can set an emoji reaction on an existing message (e.g. mark its own status post 👍 once fully paid). Mirrors the ChannelEditor wiring: ReactionSetter capability -> Manager.ReactToMessage -> telegram Channel.ReactToMessage (validates against Telegram's supported reaction set; ✅/❌ are rejected). Co-authored-by: skensel <skensel@MacBook-Pro-skensel.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fb28b72 commit 2b7bfec

5 files changed

Lines changed: 119 additions & 12 deletions

File tree

cmd/gateway.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,9 @@ func runGateway() {
721721
if ce, ok := t.(tools.ChannelEditorAware); ok {
722722
ce.SetChannelEditor(channelMgr.EditChannelMessage)
723723
}
724+
if rs, ok := t.(tools.ReactionSetterAware); ok {
725+
rs.SetReactionSetter(channelMgr.ReactToMessage)
726+
}
724727
if tr, ok := t.(tools.TopicResolverAware); ok && pgStores != nil && pgStores.Contacts != nil {
725728
contacts := pgStores.Contacts
726729
tr.SetTopicResolver(func(ctx context.Context, channel, chatID, topicName string) (string, bool) {

internal/channels/dispatch.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,29 @@ func (m *Manager) EditChannelMessage(ctx context.Context, channelName, chatID st
218218
return editor.EditMessage(ctx, chatID, messageID, content)
219219
}
220220

221+
// MessageReactor is optionally implemented by channels that can set an emoji
222+
// reaction on an existing message.
223+
type MessageReactor interface {
224+
ReactToMessage(ctx context.Context, chatID string, messageID int, emoji string) error
225+
}
226+
227+
// ReactToMessage sets an emoji reaction on a message in a channel by name.
228+
// Returns an error if the channel is unknown or does not support reactions.
229+
func (m *Manager) ReactToMessage(ctx context.Context, channelName, chatID string, messageID int, emoji string) error {
230+
m.mu.RLock()
231+
channel, exists := m.channels[channelName]
232+
m.mu.RUnlock()
233+
234+
if !exists {
235+
return fmt.Errorf("channel %s not found", channelName)
236+
}
237+
reactor, ok := channel.(MessageReactor)
238+
if !ok {
239+
return fmt.Errorf("channel %s (%s) does not support reactions", channelName, channel.Type())
240+
}
241+
return reactor.ReactToMessage(ctx, chatID, messageID, emoji)
242+
}
243+
221244
// TopicMessagePoster is optionally implemented by channels that can post a
222245
// message into a forum topic and return the sent message's id.
223246
type TopicMessagePoster interface {

internal/channels/telegram/send.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,27 @@ func (c *Channel) EditMessage(ctx context.Context, chatID string, messageID int,
813813
return err
814814
}
815815

816+
// ReactToMessage sets a single emoji reaction on a message (implements
817+
// channels.MessageReactor). Only Telegram-supported reaction emojis are allowed
818+
// — ✅/❌ are not among them, so callers must pass e.g. 👍. Reacting to the bot's
819+
// own status post is allowed.
820+
func (c *Channel) ReactToMessage(ctx context.Context, chatID string, messageID int, emoji string) error {
821+
if !telegramSupportedEmojis[emoji] {
822+
return fmt.Errorf("emoji %q is not a supported Telegram reaction", emoji)
823+
}
824+
id, err := parseRawChatID(chatID)
825+
if err != nil {
826+
return fmt.Errorf("invalid telegram chat id %q: %w", chatID, err)
827+
}
828+
return c.bot.SetMessageReaction(ctx, &telego.SetMessageReactionParams{
829+
ChatID: tu.ID(id),
830+
MessageID: messageID,
831+
Reaction: []telego.ReactionType{
832+
&telego.ReactionTypeEmoji{Type: telego.ReactionEmoji, Emoji: emoji},
833+
},
834+
})
835+
}
836+
816837
// editMessageCaption edits the caption of a media message (photo/document).
817838
func (c *Channel) editMessageCaption(ctx context.Context, chatID int64, messageID int, htmlCaption string) error {
818839
editCap := &telego.EditMessageCaptionParams{

internal/tools/message.go

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@ var embeddedMediaPattern = regexp.MustCompile(`MEDIA:\S+`)
2323

2424
// MessageTool allows the agent to proactively send messages to channels.
2525
type MessageTool struct {
26-
workspace string
27-
restrict bool
28-
sender ChannelSender
29-
editor ChannelEditor
30-
topicResolver TopicResolver
31-
topicPoster TopicPoster
32-
msgBus *bus.MessageBus
33-
tenantChecker ChannelTenantChecker
26+
workspace string
27+
restrict bool
28+
sender ChannelSender
29+
editor ChannelEditor
30+
topicResolver TopicResolver
31+
topicPoster TopicPoster
32+
reactionSetter ReactionSetter
33+
msgBus *bus.MessageBus
34+
tenantChecker ChannelTenantChecker
3435
}
3536

3637
func NewMessageTool(workspace string, restrict bool) *MessageTool {
@@ -41,6 +42,7 @@ func (t *MessageTool) SetChannelSender(s ChannelSender) { t.sender
4142
func (t *MessageTool) SetChannelEditor(e ChannelEditor) { t.editor = e }
4243
func (t *MessageTool) SetTopicResolver(r TopicResolver) { t.topicResolver = r }
4344
func (t *MessageTool) SetTopicPoster(p TopicPoster) { t.topicPoster = p }
45+
func (t *MessageTool) SetReactionSetter(r ReactionSetter) { t.reactionSetter = r }
4446
func (t *MessageTool) SetMessageBus(b *bus.MessageBus) { t.msgBus = b }
4547
func (t *MessageTool) SetChannelTenantChecker(c ChannelTenantChecker) { t.tenantChecker = c }
4648

@@ -55,12 +57,16 @@ func (t *MessageTool) Parameters() map[string]any {
5557
"properties": map[string]any{
5658
"action": map[string]any{
5759
"type": "string",
58-
"description": "Action: 'send' a new message, or 'edit' an existing one (change its text in place). To edit, the user usually replies to the target message — use its id from reply_to_message_id in your context.",
59-
"enum": []string{"send", "edit"},
60+
"description": "Action: 'send' a new message, 'edit' an existing one (change its text in place), or 'react' (set an emoji reaction on an existing message — e.g. mark your own status post as done). To edit, the user usually replies to the target message — use its id from reply_to_message_id in your context.",
61+
"enum": []string{"send", "edit", "react"},
6062
},
6163
"message_id": map[string]any{
6264
"type": "integer",
63-
"description": "For action='edit': the id of the message to change. Take it from reply_to_message_id when the user replied to the message they want edited.",
65+
"description": "For action='edit' or 'react': the id of the target message. For 'react' this is usually your OWN earlier post (the message_id returned when you sent it). For 'edit' take it from reply_to_message_id when the user replied to the message they want edited.",
66+
},
67+
"emoji": map[string]any{
68+
"type": "string",
69+
"description": "For action='react': the reaction emoji to set on message_id. Telegram allows only a fixed set (e.g. 👍 👎 🔥 🎉 💯 🤝) — ✅ and ❌ are NOT valid reactions. Use 👍 for done/paid.",
6470
},
6571
"topic": map[string]any{
6672
"type": "string",
@@ -96,8 +102,11 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *Result
96102
if action == "edit" {
97103
return t.executeEdit(ctx, args)
98104
}
105+
if action == "react" {
106+
return t.executeReact(ctx, args)
107+
}
99108
if action != "send" {
100-
return ErrorResult(fmt.Sprintf("unsupported action: %s (only 'send' and 'edit' are supported)", action))
109+
return ErrorResult(fmt.Sprintf("unsupported action: %s (only 'send', 'edit' and 'react' are supported)", action))
101110
}
102111

103112
// Posting into a named forum topic of the current group.
@@ -392,6 +401,46 @@ func (t *MessageTool) executeEdit(ctx context.Context, args map[string]any) *Res
392401
return SilentResult(fmt.Sprintf(`{"status":"edited","channel":"%s","target":"%s","message_id":%d}`, channel, target, messageID))
393402
}
394403

404+
// executeReact sets an emoji reaction on an existing message (e.g. 👍 on the
405+
// bot's own status post once it's fully paid). Targets message_id in the current
406+
// channel/chat. Only platform-supported reaction emojis are allowed.
407+
func (t *MessageTool) executeReact(ctx context.Context, args map[string]any) *Result {
408+
if t.reactionSetter == nil {
409+
return ErrorResult("reactions are not supported in this context")
410+
}
411+
messageID := argInt(args, "message_id")
412+
if messageID == 0 {
413+
return ErrorResult("message_id is required for react (the id of the message to react to — usually your own earlier post)")
414+
}
415+
emoji := argString(args, "emoji")
416+
if emoji == "" {
417+
return ErrorResult("emoji is required for react (e.g. 👍 — note ✅/❌ are not valid Telegram reactions)")
418+
}
419+
420+
channel := ToolChannelFromCtx(ctx)
421+
if channel == "" {
422+
channel = argString(args, "channel")
423+
}
424+
if channel == "" {
425+
return ErrorResult("channel is required (no current channel in context)")
426+
}
427+
target := ToolChatIDFromCtx(ctx)
428+
if target == "" {
429+
target = argString(args, "target")
430+
}
431+
if target == "" {
432+
return ErrorResult("target chat ID is required (no current chat in context)")
433+
}
434+
435+
if err := t.validateChannelTenant(ctx, channel, target); err != nil {
436+
return err
437+
}
438+
if err := t.reactionSetter(ctx, channel, target, messageID, emoji); err != nil {
439+
return ErrorResult(fmt.Sprintf("failed to set reaction: %v", err))
440+
}
441+
return SilentResult(fmt.Sprintf(`{"status":"reacted","channel":"%s","target":"%s","message_id":%d,"emoji":"%s"}`, channel, target, messageID, emoji))
442+
}
443+
395444
// validateChannelTenant checks the target channel belongs to the current tenant.
396445
// Returns an error Result if the send should be blocked, nil if allowed.
397446
func (t *MessageTool) validateChannelTenant(ctx context.Context, channel, target string) *Result {

internal/tools/types.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ type ChannelEditorAware interface {
111111
SetChannelEditor(ChannelEditor)
112112
}
113113

114+
// ReactionSetter abstracts setting a single emoji reaction on an existing
115+
// message. Implemented by channels.Manager.ReactToMessage. The emoji must be a
116+
// platform-supported reaction (e.g. Telegram allows a fixed set like 👍/👎/🔥);
117+
// unsupported channels or emojis return an error.
118+
type ReactionSetter func(ctx context.Context, channel, chatID string, messageID int, emoji string) error
119+
120+
// ReactionSetterAware tools can receive a reaction setter function.
121+
type ReactionSetterAware interface {
122+
SetReactionSetter(ReactionSetter)
123+
}
124+
114125
// TopicResolver resolves a forum topic name to its message_thread_id within a
115126
// specific chat, so the agent can post into a named topic (e.g. "Announcements").
116127
// Returns ("", false) when the topic is unknown.

0 commit comments

Comments
 (0)