mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
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:
5 files changed
+119
-12
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user