diff --git a/cmd/gateway.go b/cmd/gateway.go index 8528ee16..235ada1a 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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) { diff --git a/internal/channels/dispatch.go b/internal/channels/dispatch.go index 42a9f006..c338ba72 100644 --- a/internal/channels/dispatch.go +++ b/internal/channels/dispatch.go @@ -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 { diff --git a/internal/channels/telegram/send.go b/internal/channels/telegram/send.go index cde94be7..0ab68345 100644 --- a/internal/channels/telegram/send.go +++ b/internal/channels/telegram/send.go @@ -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{ diff --git a/internal/tools/message.go b/internal/tools/message.go index 46c57116..f4709dc3 100644 --- a/internal/tools/message.go +++ b/internal/tools/message.go @@ -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 { diff --git a/internal/tools/types.go b/internal/tools/types.go index 7a8d2276..84b55975 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -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.