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>
This commit is contained in:
authored and GitHub committed 2026-07-09 16:55:54 +07:00
1 parent fb28b72b27
commit 2b7bfec504
5 files changed
+119 -12

No files matched your search

+3
View File
@@ -721,6 +721,9 @@ func runGateway() {
if ce, ok := t.(tools.ChannelEditorAware); ok {
ce.SetChannelEditor(channelMgr.EditChannelMessage)
}
if rs, ok := t.(tools.ReactionSetterAware); ok {
rs.SetReactionSetter(channelMgr.ReactToMessage)
}
if tr, ok := t.(tools.TopicResolverAware); ok && pgStores != nil && pgStores.Contacts != nil {
contacts := pgStores.Contacts
tr.SetTopicResolver(func(ctx context.Context, channel, chatID, topicName string) (string, bool) {
+23
View File
@@ -218,6 +218,29 @@ func (m *Manager) EditChannelMessage(ctx context.Context, channelName, chatID st
return editor.EditMessage(ctx, chatID, messageID, content)
}
// MessageReactor is optionally implemented by channels that can set an emoji
// reaction on an existing message.
type MessageReactor interface {
ReactToMessage(ctx context.Context, chatID string, messageID int, emoji string) error
}
// ReactToMessage sets an emoji reaction on a message in a channel by name.
// Returns an error if the channel is unknown or does not support reactions.
func (m *Manager) ReactToMessage(ctx context.Context, channelName, chatID string, messageID int, emoji string) error {
m.mu.RLock()
channel, exists := m.channels[channelName]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("channel %s not found", channelName)
}
reactor, ok := channel.(MessageReactor)
if !ok {
return fmt.Errorf("channel %s (%s) does not support reactions", channelName, channel.Type())
}
return reactor.ReactToMessage(ctx, chatID, messageID, emoji)
}
// TopicMessagePoster is optionally implemented by channels that can post a
// message into a forum topic and return the sent message's id.
type TopicMessagePoster interface {
+21
View File
@@ -813,6 +813,27 @@ func (c *Channel) EditMessage(ctx context.Context, chatID string, messageID int,
return err
}
// ReactToMessage sets a single emoji reaction on a message (implements
// channels.MessageReactor). Only Telegram-supported reaction emojis are allowed
// — ✅/❌ are not among them, so callers must pass e.g. 👍. Reacting to the bot's
// own status post is allowed.
func (c *Channel) ReactToMessage(ctx context.Context, chatID string, messageID int, emoji string) error {
if !telegramSupportedEmojis[emoji] {
return fmt.Errorf("emoji %q is not a supported Telegram reaction", emoji)
}
id, err := parseRawChatID(chatID)
if err != nil {
return fmt.Errorf("invalid telegram chat id %q: %w", chatID, err)
}
return c.bot.SetMessageReaction(ctx, &telego.SetMessageReactionParams{
ChatID: tu.ID(id),
MessageID: messageID,
Reaction: []telego.ReactionType{
&telego.ReactionTypeEmoji{Type: telego.ReactionEmoji, Emoji: emoji},
},
})
}
// editMessageCaption edits the caption of a media message (photo/document).
func (c *Channel) editMessageCaption(ctx context.Context, chatID int64, messageID int, htmlCaption string) error {
editCap := &telego.EditMessageCaptionParams{
+61 -12
View File
@@ -23,14 +23,15 @@ var embeddedMediaPattern = regexp.MustCompile(`MEDIA:\S+`)
// MessageTool allows the agent to proactively send messages to channels.
type MessageTool struct {
workspace string
restrict bool
sender ChannelSender
editor ChannelEditor
topicResolver TopicResolver
topicPoster TopicPoster
msgBus *bus.MessageBus
tenantChecker ChannelTenantChecker
workspace string
restrict bool
sender ChannelSender
editor ChannelEditor
topicResolver TopicResolver
topicPoster TopicPoster
reactionSetter ReactionSetter
msgBus *bus.MessageBus
tenantChecker ChannelTenantChecker
}
func NewMessageTool(workspace string, restrict bool) *MessageTool {
@@ -41,6 +42,7 @@ func (t *MessageTool) SetChannelSender(s ChannelSender) { t.sender
func (t *MessageTool) SetChannelEditor(e ChannelEditor) { t.editor = e }
func (t *MessageTool) SetTopicResolver(r TopicResolver) { t.topicResolver = r }
func (t *MessageTool) SetTopicPoster(p TopicPoster) { t.topicPoster = p }
func (t *MessageTool) SetReactionSetter(r ReactionSetter) { t.reactionSetter = r }
func (t *MessageTool) SetMessageBus(b *bus.MessageBus) { t.msgBus = b }
func (t *MessageTool) SetChannelTenantChecker(c ChannelTenantChecker) { t.tenantChecker = c }
@@ -55,12 +57,16 @@ func (t *MessageTool) Parameters() map[string]any {
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"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.",
"enum": []string{"send", "edit"},
"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.",
"enum": []string{"send", "edit", "react"},
},
"message_id": map[string]any{
"type": "integer",
"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.",
"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.",
},
"emoji": map[string]any{
"type": "string",
"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.",
},
"topic": map[string]any{
"type": "string",
@@ -96,8 +102,11 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *Result
if action == "edit" {
return t.executeEdit(ctx, args)
}
if action == "react" {
return t.executeReact(ctx, args)
}
if action != "send" {
return ErrorResult(fmt.Sprintf("unsupported action: %s (only 'send' and 'edit' are supported)", action))
return ErrorResult(fmt.Sprintf("unsupported action: %s (only 'send', 'edit' and 'react' are supported)", action))
}
// 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
return SilentResult(fmt.Sprintf(`{"status":"edited","channel":"%s","target":"%s","message_id":%d}`, channel, target, messageID))
}
// executeReact sets an emoji reaction on an existing message (e.g. 👍 on the
// bot's own status post once it's fully paid). Targets message_id in the current
// channel/chat. Only platform-supported reaction emojis are allowed.
func (t *MessageTool) executeReact(ctx context.Context, args map[string]any) *Result {
if t.reactionSetter == nil {
return ErrorResult("reactions are not supported in this context")
}
messageID := argInt(args, "message_id")
if messageID == 0 {
return ErrorResult("message_id is required for react (the id of the message to react to — usually your own earlier post)")
}
emoji := argString(args, "emoji")
if emoji == "" {
return ErrorResult("emoji is required for react (e.g. 👍 — note ✅/❌ are not valid Telegram reactions)")
}
channel := ToolChannelFromCtx(ctx)
if channel == "" {
channel = argString(args, "channel")
}
if channel == "" {
return ErrorResult("channel is required (no current channel in context)")
}
target := ToolChatIDFromCtx(ctx)
if target == "" {
target = argString(args, "target")
}
if target == "" {
return ErrorResult("target chat ID is required (no current chat in context)")
}
if err := t.validateChannelTenant(ctx, channel, target); err != nil {
return err
}
if err := t.reactionSetter(ctx, channel, target, messageID, emoji); err != nil {
return ErrorResult(fmt.Sprintf("failed to set reaction: %v", err))
}
return SilentResult(fmt.Sprintf(`{"status":"reacted","channel":"%s","target":"%s","message_id":%d,"emoji":"%s"}`, channel, target, messageID, emoji))
}
// validateChannelTenant checks the target channel belongs to the current tenant.
// Returns an error Result if the send should be blocked, nil if allowed.
func (t *MessageTool) validateChannelTenant(ctx context.Context, channel, target string) *Result {
+11
View File
@@ -111,6 +111,17 @@ type ChannelEditorAware interface {
SetChannelEditor(ChannelEditor)
}
// ReactionSetter abstracts setting a single emoji reaction on an existing
// message. Implemented by channels.Manager.ReactToMessage. The emoji must be a
// platform-supported reaction (e.g. Telegram allows a fixed set like 👍/👎/🔥);
// unsupported channels or emojis return an error.
type ReactionSetter func(ctx context.Context, channel, chatID string, messageID int, emoji string) error
// ReactionSetterAware tools can receive a reaction setter function.
type ReactionSetterAware interface {
SetReactionSetter(ReactionSetter)
}
// TopicResolver resolves a forum topic name to its message_thread_id within a
// specific chat, so the agent can post into a named topic (e.g. "Announcements").
// Returns ("", false) when the topic is unknown.