From 81d606766afdebdb6dc11e4e9c8060042aacae8c Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 9 Mar 2026 19:38:35 +0200 Subject: [PATCH] Add reaction tool with typing/placeholder cleanup Introduces a new `reaction` tool that lets the LLM add an emoji reaction to a Telegram message instead of sending a text reply. When the reaction tool (or message tool) handles a turn, CleanupState is now called on the channel manager so typing indicators and placeholder messages are properly removed even though no outbound message is published via the bus. --- config/config.example.json | 20 +++ pkg/agent/loop.go | 111 ++++++++++----- pkg/agent/loop_test.go | 112 +++++++++++++++ pkg/channels/interfaces.go | 9 +- pkg/channels/manager.go | 54 ++++++++ pkg/channels/telegram/telegram.go | 28 ++++ pkg/channels/telegram/telegram_test.go | 28 ++++ pkg/config/config.go | 48 +++++-- pkg/config/config_test.go | 14 ++ pkg/config/defaults.go | 12 +- pkg/tools/base.go | 7 + pkg/tools/reaction.go | 184 +++++++++++++++++++++++++ pkg/tools/reaction_test.go | 121 ++++++++++++++++ pkg/tools/registry.go | 34 ++++- pkg/tools/registry_test.go | 40 ++++++ pkg/tools/toolloop.go | 2 +- 16 files changed, 771 insertions(+), 53 deletions(-) create mode 100644 pkg/tools/reaction.go create mode 100644 pkg/tools/reaction_test.go diff --git a/config/config.example.json b/config/config.example.json index 3e2edac26..5d753c5e5 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -57,6 +57,23 @@ "allow_from": [ "YOUR_USER_ID" ], + "allowed_reaction_emoji": [ + "👍", + "👎", + "❤️", + "🔥", + "🥰", + "👏", + "😁", + "🤔", + "🤯", + "😱", + "🤬", + "😢", + "🎉", + "🤩", + "🤮" + ], "reasoning_channel_id": "" }, "discord": { @@ -432,6 +449,9 @@ "message": { "enabled": true }, + "reaction": { + "enabled": true + }, "read_file": { "enabled": true }, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f29c764c0..b753c63f2 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -67,8 +67,9 @@ type processOptions struct { } type agentResponse struct { - Content string - ReplyToMessageID string + Content string + ReplyToMessageID string + HandledExternally bool } func (r agentResponse) outboundMessage(channel, chatID string) bus.OutboundMessage { @@ -200,6 +201,9 @@ func registerSharedTools( }) agent.Tools.Register(messageTool) } + if cfg.Tools.IsToolEnabled("reaction") { + agent.Tools.Register(tools.NewReactionTool([]string(cfg.Channels.Telegram.AllowedReactionEmoji))) + } // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) if cfg.Tools.IsToolEnabled("send_file") { @@ -359,30 +363,13 @@ func (al *AgentLoop) Run(ctx context.Context) error { response = agentResponse{Content: fmt.Sprintf("Error processing message: %v", err)} } - if response.Content != "" { - // Check if the message tool already sent a response during this round. - // If so, skip publishing to avoid duplicate messages to the user. - // Use default agent's tools to check (message tool is shared). - alreadySent := false - defaultAgent := al.registry.GetDefaultAgent() - if defaultAgent != nil { - if tool, ok := defaultAgent.Tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { - alreadySent = mt.HasSentInRound() - } - } + if response.HandledExternally { + // A direct tool action (message or reaction) already sent user-facing output. + // Stop typing and delete placeholder since no outbound message will trigger preSend. + if al.channelManager != nil { + al.channelManager.CleanupState(ctx, msg.Channel, msg.ChatID) } - - if !alreadySent { - al.bus.PublishOutbound(ctx, response.outboundMessage(msg.Channel, msg.ChatID)) - logger.InfoCF("agent", "Published outbound response", - map[string]any{ - "channel": msg.Channel, - "chat_id": msg.ChatID, - "content_len": len(response.Content), - "reply_to_message_id": response.ReplyToMessageID, - }) - } else { + if response.Content != "" { logger.DebugCF( "agent", "Skipped outbound (message tool already sent)", @@ -394,6 +381,15 @@ func (al *AgentLoop) Run(ctx context.Context) error { }, ) } + } else if response.Content != "" { + al.bus.PublishOutbound(ctx, response.outboundMessage(msg.Channel, msg.ChatID)) + logger.InfoCF("agent", "Published outbound response", + map[string]any{ + "channel": msg.Channel, + "chat_id": msg.ChatID, + "content_len": len(response.Content), + "reply_to_message_id": response.ReplyToMessageID, + }) } }() } @@ -417,6 +413,7 @@ func (al *AgentLoop) RegisterTool(tool tools.Tool) { func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { al.channelManager = cm al.bindAdvancedMessageManagers(cm) + al.bindReactionTools(cm) } // bindAdvancedMessageManagers wires up channel callbacks to any tools that @@ -442,6 +439,16 @@ func (al *AgentLoop) bindAdvancedMessageManagers(cm *channels.Manager) { }) } +func (al *AgentLoop) bindReactionTools(cm *channels.Manager) { + al.registry.ForEachTool("reaction", func(t tools.Tool) { + if rt, ok := t.(*tools.ReactionTool); ok { + rt.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID, emoji string) error { + return cm.SetMessageReaction(ctx, channel, chatID, messageID, emoji) + }) + } + }) +} + // SetMediaStore injects a MediaStore for media lifecycle management. func (al *AgentLoop) SetMediaStore(s media.MediaStore) { al.mediaStore = s @@ -459,6 +466,39 @@ func (al *AgentLoop) SetTranscriber(t voice.Transcriber) { al.transcriber = t } +func (al *AgentLoop) agentTurnHandledByDirectToolAction(agent *AgentInstance) bool { + if agent == nil { + return false + } + if tool, ok := agent.Tools.Get("message"); ok { + if mt, ok := tool.(*tools.MessageTool); ok && mt.HasSentInRound() { + return true + } + } + if tool, ok := agent.Tools.Get("reaction"); ok { + if rt, ok := tool.(*tools.ReactionTool); ok && rt.HasHandledInRound() { + return true + } + } + return false +} + +func (al *AgentLoop) resetRoundActionTools(agent *AgentInstance) { + if agent == nil { + return + } + if tool, ok := agent.Tools.Get("message"); ok { + if resetter, ok := tool.(interface{ ResetSentInRound() }); ok { + resetter.ResetSentInRound() + } + } + if tool, ok := agent.Tools.Get("reaction"); ok { + if resetter, ok := tool.(interface{ ResetHandledInRound() }); ok { + resetter.ResetHandledInRound() + } + } +} + var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) // transcribeAudioInMessage resolves audio media refs, transcribes them, and @@ -644,12 +684,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return agentResponse{}, routeErr } - // Reset message-tool state for this round so we don't skip publishing due to a previous round. - if tool, ok := agent.Tools.Get("message"); ok { - if resetter, ok := tool.(interface{ ResetSentInRound() }); ok { - resetter.ResetSentInRound() - } - } + al.resetRoundActionTools(agent) // Resolve session key from route, while preserving explicit agent-scoped keys. scopeKey := resolveScopeKey(route, msg.SessionKey) @@ -847,16 +882,19 @@ func (al *AgentLoop) runAgentLoop( // This is controlled by the tool's Silent flag and ForUser content // 4. Handle empty response - if finalContent == "" { + directActionHandled := al.agentTurnHandledByDirectToolAction(agent) + if finalContent == "" && !directActionHandled { finalContent = opts.DefaultResponse } response := resolveFinalResponse(opts.Channel, opts.ReplyContext, finalContent) - if response.Content == "" { + if response.Content == "" && !directActionHandled { response.Content = opts.DefaultResponse } // 5. Save final assistant message to session - agent.Sessions.AddMessage(opts.SessionKey, "assistant", response.Content) + if response.Content != "" { + agent.Sessions.AddMessage(opts.SessionKey, "assistant", response.Content) + } agent.Sessions.Save(opts.SessionKey) // 6. Optional: summarization @@ -865,7 +903,7 @@ func (al *AgentLoop) runAgentLoop( } // 7. Optional: send response via bus - if opts.SendResponse { + if opts.SendResponse && response.Content != "" { al.bus.PublishOutbound(ctx, response.outboundMessage(opts.Channel, opts.ChatID)) } @@ -879,6 +917,7 @@ func (al *AgentLoop) runAgentLoop( "final_length": len(response.Content), }) + response.HandledExternally = directActionHandled return response, nil } @@ -1061,7 +1100,7 @@ func (al *AgentLoop) runLLMIteration( }) // Build tool definitions - providerToolDefs := agent.Tools.ToProviderDefs() + providerToolDefs := agent.Tools.ToProviderDefsWithContext(ctx, opts.Channel, opts.ChatID) // Log LLM request details logger.DebugCF("agent", "LLM request", diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index eaafbaa81..7ae67b3c8 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -438,6 +438,42 @@ func (m *taskToolRaceMockProvider) GetDefaultModel() string { return "tasktool-race-mock-model" } +type reactionToolMockProvider struct { + calls int +} + +func (m *reactionToolMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{ + { + ID: "call_reaction", + Name: "reaction", + Arguments: map[string]any{ + "emoji": "❤️", + }, + }, + }, + }, nil + } + + return &providers.LLMResponse{ + Content: "", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *reactionToolMockProvider) GetDefaultModel() string { + return "reaction-tool-mock-model" +} + type blockingSequentialTaskTool struct { inner *tools.TaskTool createOnce sync.Once @@ -946,6 +982,82 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) { } } +func TestReactionTool_SuppressesDefaultFinalResponse(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = tmpDir + cfg.Agents.Defaults.Model = "test-model" + cfg.Agents.Defaults.MaxTokens = 4096 + cfg.Agents.Defaults.MaxToolIterations = 4 + + msgBus := bus.NewMessageBus() + provider := &reactionToolMockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + defaultAgent := al.registry.GetDefaultAgent() + tool, ok := defaultAgent.Tools.Get("reaction") + if !ok { + t.Fatal("expected reaction tool to be registered") + } + rt, ok := tool.(*tools.ReactionTool) + if !ok { + t.Fatalf("reaction tool type = %T", tool) + } + + var calls int + rt.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID, emoji string) error { + calls++ + if channel != "telegram" || chatID != "chat1" || messageID != "910" || emoji != "❤️" { + t.Fatalf("unexpected callback args channel=%q chatID=%q messageID=%q emoji=%q", channel, chatID, messageID, emoji) + } + return nil + }) + + response := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "thanks", + MessageID: "910", + }) + + if calls != 1 { + t.Fatalf("reaction callback calls = %d, want 1", calls) + } + if response != "" { + t.Fatalf("expected empty final response after reaction tool, got %q", response) + } +} + +func TestReactionTool_BecomesAvailableForTelegramAfterChannelManagerBinding(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Channels.Telegram.Enabled = true + + al := NewAgentLoop(cfg, bus.NewMessageBus(), nil) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + before := defaultAgent.Tools.ToProviderDefsWithContext(context.Background(), "telegram", "chat1") + if slices.ContainsFunc(before, func(def providers.ToolDefinition) bool { return def.Function.Name == "reaction" }) { + t.Fatal("reaction tool should not be available before channel manager binding") + } + + al.SetChannelManager(&channels.Manager{}) + + after := defaultAgent.Tools.ToProviderDefsWithContext(context.Background(), "telegram", "chat1") + if !slices.ContainsFunc(after, func(def providers.ToolDefinition) bool { return def.Function.Name == "reaction" }) { + t.Fatal("reaction tool should be available for telegram after channel manager binding") + } +} + func TestTaskTool_DirectModeWithoutChannelManagerReturnsPlan(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index 3986cd8b8..0cb211da8 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -26,13 +26,20 @@ type MessageDeleter interface { DeleteMessage(ctx context.Context, chatID string, messageID string) error } -// ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message. +// ReactionCapable — channels that can add a temporary reaction (e.g. 👀) to an +// inbound message as a processing indicator. // ReactToMessage adds a reaction and returns an undo function to remove it. // The undo function MUST be idempotent and safe to call multiple times. type ReactionCapable interface { ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) } +// MessageReactor — channels that can set an explicit emoji reaction on a +// specific message as a final user-visible action. +type MessageReactor interface { + SetMessageReaction(ctx context.Context, chatID, messageID, emoji string) error +} + // PlaceholderCapable — channels that can send a placeholder message // (e.g. "Thinking... 💭") that will later be edited to the actual response. // The channel MUST also implement MessageEditor for the placeholder to be useful. diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 82037330a..40fd9c586 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -115,6 +115,46 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()}) } +// CleanupState stops typing, undoes any reaction indicator, and deletes the placeholder +// for the given channel+chatID without sending a response message. +// Called when the agent completes a turn via a direct tool action (e.g. reaction tool) +// that produces no outbound message, so preSend never runs. +func (m *Manager) CleanupState(ctx context.Context, channelName, chatID string) { + key := channelName + ":" + chatID + + if v, loaded := m.typingStops.LoadAndDelete(key); loaded { + if entry, ok := v.(typingEntry); ok { + entry.stop() + } + } + + if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded { + if entry, ok := v.(reactionEntry); ok { + entry.undo() + } + } + + if v, loaded := m.placeholders.LoadAndDelete(key); loaded { + if entry, ok := v.(placeholderEntry); ok && entry.id != "" { + ch, ok := m.GetChannel(channelName) + if !ok { + return + } + if deleter, ok := ch.(MessageDeleter); ok { + if err := deleter.DeleteMessage(ctx, chatID, entry.id); err != nil { + logger.WarnCF("manager", "Failed to delete placeholder after tool action", + map[string]any{ + "channel": channelName, + "chat_id": chatID, + "placeholder_id": entry.id, + "error": err.Error(), + }) + } + } + } + } +} + // preSend handles typing stop, reaction undo, and placeholder editing before sending a message. // Returns true if the message was edited into a placeholder (skip Send). func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { @@ -900,3 +940,17 @@ func (m *Manager) EditMessage(ctx context.Context, channelName, chatID, messageI } return editor.EditMessage(ctx, chatID, messageID, content) } + +// SetMessageReaction synchronously adds an explicit emoji reaction to a +// specific message if the channel supports MessageReactor. +func (m *Manager) SetMessageReaction(ctx context.Context, channelName, chatID, messageID, emoji string) error { + ch, ok := m.GetChannel(channelName) + if !ok { + return fmt.Errorf("channel %s not found", channelName) + } + reactor, ok := ch.(MessageReactor) + if !ok { + return fmt.Errorf("channel %s does not support message reactions", channelName) + } + return reactor.SetMessageReaction(ctx, chatID, messageID, emoji) +} diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 98d4dc341..7cdc4075c 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -339,6 +339,34 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess return nil } +// SetMessageReaction implements channels.MessageReactor. +func (c *TelegramChannel) SetMessageReaction(ctx context.Context, chatID, messageID, emoji string) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + target, err := parseTelegramTarget(chatID) + if err != nil { + return fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed) + } + messageIDs, err := parseTelegramMessageIDs(messageID) + if err != nil { + return fmt.Errorf("invalid message ID %s: %w", messageID, channels.ErrSendFailed) + } + if len(messageIDs) != 1 { + return fmt.Errorf("telegram react: expected a single message ID: %w", channels.ErrSendFailed) + } + + if err := c.bot.SetMessageReaction(ctx, (&telego.SetMessageReactionParams{}). + WithChatID(tu.ID(target.ChatID)). + WithMessageID(messageIDs[0]). + WithReaction(tu.ReactionEmoji(emoji))); err != nil { + return fmt.Errorf("telegram react: %w", channels.ErrTemporary) + } + + return nil +} + // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message (e.g. "Thinking... 💭") that will later be // edited to the actual response via EditMessage (channels.MessageEditor). diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 187cc90d4..1ab6d0f37 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -79,6 +79,11 @@ func successResponseWithID(t *testing.T, id int) *ta.Response { return &ta.Response{Ok: true, Result: b} } +func successBoolResponse(t *testing.T) *ta.Response { + t.Helper() + return &ta.Response{Ok: true, Result: []byte("true")} +} + // newTestChannel creates a TelegramChannel with a mocked bot for unit testing. func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel { t.Helper() @@ -343,6 +348,29 @@ func TestEditMessage_MultipleChunkIDs(t *testing.T) { assert.Len(t, caller.calls, 2, "multi-part edit should update every tracked message") } +func TestSetMessageReaction_SendsConfiguredEmoji(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successBoolResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + err := ch.SetMessageReaction(context.Background(), "12345", "99", "❤️") + + require.NoError(t, err) + require.Len(t, caller.calls, 1) + body := decodeCallBody(t, caller.calls[0]) + assert.Equal(t, float64(99), body["message_id"]) + reaction, ok := body["reaction"].([]any) + require.True(t, ok) + require.Len(t, reaction, 1) + first, ok := reaction[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "emoji", first["type"]) + assert.Equal(t, "❤️", first["emoji"]) +} + func TestStartTyping_ForumTopic_UsesThreadID(t *testing.T) { caller := &stubCaller{ callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 68e50ebc1..44238bc5b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -262,16 +262,17 @@ type WhatsAppConfig struct { } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` - Groups map[string]TelegramGroupConfig `json:"groups,omitempty"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + AllowedReactionEmoji FlexibleStringSlice `json:"allowed_reaction_emoji" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOWED_REACTION_EMOJI"` + Groups map[string]TelegramGroupConfig `json:"groups,omitempty"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` } type TelegramTopicConfig struct { @@ -295,6 +296,30 @@ type FeishuConfig struct { RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"` } +var defaultTelegramReactionEmoji = FlexibleStringSlice{ + "👍", + "👎", + "❤️", + "🔥", + "🥰", + "👏", + "😁", + "🤔", + "🤯", + "😱", + "🤬", + "😢", + "🎉", + "🤩", + "🤮", +} + +func DefaultTelegramReactionEmoji() FlexibleStringSlice { + emojis := make(FlexibleStringSlice, len(defaultTelegramReactionEmoji)) + copy(emojis, defaultTelegramReactionEmoji) + return emojis +} + type DiscordConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` @@ -692,6 +717,7 @@ type ToolsConfig struct { InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` + Reaction ToolConfig `json:"reaction" envPrefix:"PICOCLAW_TOOLS_REACTION_"` ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` @@ -970,6 +996,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.ListDir.Enabled case "message": return t.Message.Enabled + case "reaction": + return t.Reaction.Enabled case "read_file": return t.ReadFile.Enabled case "spawn": diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d1258d57d..416cb0279 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -493,6 +493,20 @@ func TestDefaultConfig_DMScope(t *testing.T) { } } +func TestDefaultConfig_TelegramReactionEmojiDefaults(t *testing.T) { + cfg := DefaultConfig() + + if len(cfg.Channels.Telegram.AllowedReactionEmoji) != 15 { + t.Fatalf("AllowedReactionEmoji len = %d, want 15", len(cfg.Channels.Telegram.AllowedReactionEmoji)) + } + if cfg.Channels.Telegram.AllowedReactionEmoji[0] != "👍" { + t.Fatalf("first AllowedReactionEmoji = %q, want %q", cfg.Channels.Telegram.AllowedReactionEmoji[0], "👍") + } + if !cfg.Tools.Reaction.Enabled { + t.Fatal("DefaultConfig().Tools.Reaction.Enabled should be true") + } +} + func TestDefaultConfig_WorkspacePath_Default(t *testing.T) { // Unset to ensure we test the default t.Setenv("PICOCLAW_HOME", "") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 509f21127..5ed2991e0 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -50,10 +50,11 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, }, Telegram: TelegramConfig{ - Enabled: false, - Token: "", - AllowFrom: FlexibleStringSlice{}, - Typing: TypingConfig{Enabled: true}, + Enabled: false, + Token: "", + AllowFrom: FlexibleStringSlice{}, + AllowedReactionEmoji: DefaultTelegramReactionEmoji(), + Typing: TypingConfig{Enabled: true}, Placeholder: PlaceholderConfig{ Enabled: true, Text: "Thinking... 💭", @@ -466,6 +467,9 @@ func DefaultConfig() *Config { Message: ToolConfig{ Enabled: true, }, + Reaction: ToolConfig{ + Enabled: true, + }, ReadFile: ReadFileToolConfig{ Enabled: true, MaxReadFileSize: 64 * 1024, // 64KB diff --git a/pkg/tools/base.go b/pkg/tools/base.go index 6f0c6c880..4b8dcc34a 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -127,6 +127,13 @@ type SequentialTool interface { ExecuteSequentially() bool } +// AvailabilityAwareTool marks tools whose visibility depends on the current +// request context, such as channel-specific tools. +type AvailabilityAwareTool interface { + Tool + Available(ctx context.Context) bool +} + func ToolToSchema(tool Tool) map[string]any { return map[string]any{ "type": "function", diff --git a/pkg/tools/reaction.go b/pkg/tools/reaction.go new file mode 100644 index 000000000..3f2edd916 --- /dev/null +++ b/pkg/tools/reaction.go @@ -0,0 +1,184 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type ReactionCallback func(ctx context.Context, channel, chatID, messageID, emoji string) error + +const ( + reactionTargetCurrent = "current" + reactionTargetParent = "parent" + reactionTargetMessage = "message_id" +) + +type ReactionTool struct { + allowedEmoji []string + reactCallback ReactionCallback + handledInRound atomic.Bool +} + +func NewReactionTool(allowedEmoji []string) *ReactionTool { + emoji := normalizeAllowedEmoji(allowedEmoji) + if len(emoji) == 0 { + emoji = normalizeAllowedEmoji([]string(config.DefaultTelegramReactionEmoji())) + } + return &ReactionTool{allowedEmoji: emoji} +} + +func normalizeAllowedEmoji(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func (t *ReactionTool) Name() string { + return "reaction" +} + +func (t *ReactionTool) Description() string { + if len(t.allowedEmoji) == 0 { + return "Add an emoji reaction to the current Telegram message instead of sending a text reply. Use this for short acknowledgements like thanks, ok, or got it." + } + return fmt.Sprintf( + "Add an emoji reaction to the current Telegram message instead of sending a text reply. Use this for short acknowledgements like thanks, ok, or got it. You MUST choose one of these configured emojis: %s.", + strings.Join(t.allowedEmoji, " "), + ) +} + +func (t *ReactionTool) Available(ctx context.Context) bool { + return t.reactCallback != nil && ToolChannel(ctx) == "telegram" +} + +func (t *ReactionTool) Parameters() map[string]any { + emojiSchema := map[string]any{ + "type": "string", + "description": "Emoji reaction to add to the target Telegram message", + } + if len(t.allowedEmoji) > 0 { + emojiSchema["enum"] = append([]string(nil), t.allowedEmoji...) + } + + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "emoji": emojiSchema, + "target": map[string]any{ + "type": "string", + "description": "Which Telegram message to react to. Defaults to current.", + "enum": []string{reactionTargetCurrent, reactionTargetParent, reactionTargetMessage}, + }, + "message_id": map[string]any{ + "type": "string", + "description": "Explicit Telegram message ID when target=message_id", + }, + }, + "required": []string{"emoji"}, + } +} + +func (t *ReactionTool) ExecuteSequentially() bool { + return true +} + +func (t *ReactionTool) SetReactionCallback(callback ReactionCallback) { + t.reactCallback = callback +} + +func (t *ReactionTool) ResetHandledInRound() { + t.handledInRound.Store(false) +} + +func (t *ReactionTool) HasHandledInRound() bool { + return t.handledInRound.Load() +} + +func (t *ReactionTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + emoji, _ := args["emoji"].(string) + emoji = strings.TrimSpace(emoji) + if emoji == "" { + return ErrorResult("emoji is required") + } + if len(t.allowedEmoji) > 0 { + allowed := false + for _, candidate := range t.allowedEmoji { + if candidate == emoji { + allowed = true + break + } + } + if !allowed { + return ErrorResult(fmt.Sprintf("emoji %q is not allowed; use one of: %s", emoji, strings.Join(t.allowedEmoji, " "))) + } + } + + channel := ToolChannel(ctx) + chatID := ToolChatID(ctx) + if channel == "" || chatID == "" { + return ErrorResult("reaction tool requires a current channel/chat context") + } + if channel != "telegram" { + return ErrorResult("reaction tool currently supports Telegram only") + } + + messageID, err := resolveReactionTarget(ctx, args) + if err != nil { + return ErrorResult(err.Error()).WithError(err) + } + if t.reactCallback == nil { + return ErrorResult("reaction sending not configured") + } + + if err := t.reactCallback(ctx, channel, chatID, messageID, emoji); err != nil { + return ErrorResult(fmt.Sprintf("adding reaction: %v", err)).WithError(err) + } + + t.handledInRound.Store(true) + return SilentResult(fmt.Sprintf("Reaction %s added to telegram:%s message %s", emoji, chatID, messageID)) +} + +func resolveReactionTarget(ctx context.Context, args map[string]any) (string, error) { + target, _ := args["target"].(string) + target = strings.ToLower(strings.TrimSpace(target)) + if target == "" { + target = reactionTargetCurrent + } + + switch target { + case reactionTargetCurrent: + if id := strings.TrimSpace(ToolCurrentMessageID(ctx)); id != "" { + return id, nil + } + return "", fmt.Errorf("target=current requested but current message id is unavailable") + case reactionTargetParent: + if id := strings.TrimSpace(ToolParentMessageID(ctx)); id != "" { + return id, nil + } + return "", fmt.Errorf("target=parent requested but parent message id is unavailable") + case reactionTargetMessage: + messageID, _ := args["message_id"].(string) + messageID = strings.TrimSpace(messageID) + if messageID == "" { + return "", fmt.Errorf("target=message_id requires message_id") + } + return messageID, nil + default: + return "", fmt.Errorf("unsupported reaction target %q", target) + } +} diff --git a/pkg/tools/reaction_test.go b/pkg/tools/reaction_test.go new file mode 100644 index 000000000..bc960c72e --- /dev/null +++ b/pkg/tools/reaction_test.go @@ -0,0 +1,121 @@ +package tools + +import ( + "context" + "testing" +) + +func TestReactionTool_Parameters_ExposeAllowedEmojiEnum(t *testing.T) { + tool := NewReactionTool([]string{"❤️", "🔥"}) + + params := tool.Parameters() + properties, ok := params["properties"].(map[string]any) + if !ok { + t.Fatalf("properties missing or invalid: %#v", params["properties"]) + } + emojiProp, ok := properties["emoji"].(map[string]any) + if !ok { + t.Fatalf("emoji property missing or invalid: %#v", properties["emoji"]) + } + enumValues, ok := emojiProp["enum"].([]string) + if !ok { + t.Fatalf("emoji enum missing or invalid: %#v", emojiProp["enum"]) + } + if len(enumValues) != 2 { + t.Fatalf("emoji enum len = %d, want 2", len(enumValues)) + } + if enumValues[0] != "❤️" || enumValues[1] != "🔥" { + t.Fatalf("emoji enum = %#v", enumValues) + } +} + +func TestReactionTool_Execute_CurrentMessage(t *testing.T) { + tool := NewReactionTool([]string{"❤️", "🔥"}) + + var called bool + tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID, emoji string) error { + called = true + if channel != "telegram" { + t.Fatalf("channel=%q", channel) + } + if chatID != "chat-1" { + t.Fatalf("chatID=%q", chatID) + } + if messageID != "910" { + t.Fatalf("messageID=%q", messageID) + } + if emoji != "❤️" { + t.Fatalf("emoji=%q", emoji) + } + return nil + }) + + ctx := WithToolReplyContext( + WithToolContext(context.Background(), "telegram", "chat-1"), + "910", + "905", + ) + result := tool.Execute(ctx, map[string]any{ + "emoji": "❤️", + }) + + if !called { + t.Fatal("expected reaction callback to be called") + } + if result.IsError { + t.Fatalf("unexpected error result: %q", result.ForLLM) + } + if !result.Silent { + t.Fatal("expected silent result") + } + if !tool.HasHandledInRound() { + t.Fatal("expected handledInRound to be true") + } +} + +func TestReactionTool_Execute_ParentMessage(t *testing.T) { + tool := NewReactionTool([]string{"❤️"}) + + var gotMessageID string + tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID, emoji string) error { + gotMessageID = messageID + return nil + }) + + ctx := WithToolReplyContext( + WithToolContext(context.Background(), "telegram", "chat-1"), + "910", + "905", + ) + result := tool.Execute(ctx, map[string]any{ + "emoji": "❤️", + "target": "parent", + }) + + if result.IsError { + t.Fatalf("unexpected error result: %q", result.ForLLM) + } + if gotMessageID != "905" { + t.Fatalf("gotMessageID=%q, want %q", gotMessageID, "905") + } +} + +func TestReactionTool_Execute_RejectsEmojiOutsideAllowlist(t *testing.T) { + tool := NewReactionTool([]string{"❤️"}) + + ctx := WithToolReplyContext( + WithToolContext(context.Background(), "telegram", "chat-1"), + "910", + "905", + ) + result := tool.Execute(ctx, map[string]any{ + "emoji": "🔥", + }) + + if !result.IsError { + t.Fatal("expected error result") + } + if tool.HasHandledInRound() { + t.Fatal("handledInRound should remain false on error") + } +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 1b77d03ef..049320e80 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -141,13 +141,22 @@ func (r *ToolRegistry) sortedToolNames() []string { } func (r *ToolRegistry) GetDefinitions() []map[string]any { + return r.GetDefinitionsWithContext(context.Background(), "", "") +} + +func (r *ToolRegistry) GetDefinitionsWithContext(ctx context.Context, channel, chatID string) []map[string]any { r.mu.RLock() defer r.mu.RUnlock() + ctx = availabilityContext(ctx, channel, chatID) sorted := r.sortedToolNames() definitions := make([]map[string]any, 0, len(sorted)) for _, name := range sorted { - definitions = append(definitions, ToolToSchema(r.tools[name])) + tool := r.tools[name] + if !toolAvailableInContext(tool, ctx) { + continue + } + definitions = append(definitions, ToolToSchema(tool)) } return definitions } @@ -155,13 +164,21 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any { // ToProviderDefs converts tool definitions to provider-compatible format. // This is the format expected by LLM provider APIs. func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { + return r.ToProviderDefsWithContext(context.Background(), "", "") +} + +func (r *ToolRegistry) ToProviderDefsWithContext(ctx context.Context, channel, chatID string) []providers.ToolDefinition { r.mu.RLock() defer r.mu.RUnlock() + ctx = availabilityContext(ctx, channel, chatID) sorted := r.sortedToolNames() definitions := make([]providers.ToolDefinition, 0, len(sorted)) for _, name := range sorted { tool := r.tools[name] + if !toolAvailableInContext(tool, ctx) { + continue + } schema := ToolToSchema(tool) // Safely extract nested values with type checks @@ -186,6 +203,21 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { return definitions } +func availabilityContext(ctx context.Context, channel, chatID string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return WithToolContext(ctx, channel, chatID) +} + +func toolAvailableInContext(tool Tool, ctx context.Context) bool { + conditional, ok := tool.(AvailabilityAwareTool) + if !ok { + return true + } + return conditional.Available(ctx) +} + // List returns a list of all registered tool names. func (r *ToolRegistry) List() []string { r.mu.RLock() diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 0f5fe93d8..e86ff2930 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -54,6 +54,17 @@ func (m *mockSequentialRegistryTool) ExecuteSequentially() bool { return m.sequential } +type mockAvailabilityTool struct { + mockRegistryTool + allowedChannel string + lastCtx context.Context +} + +func (m *mockAvailabilityTool) Available(ctx context.Context) bool { + m.lastCtx = ctx + return ToolChannel(ctx) == m.allowedChannel +} + // --- helpers --- func newMockTool(name, desc string) *mockRegistryTool { @@ -295,6 +306,35 @@ func TestToolRegistry_ToProviderDefs(t *testing.T) { } } +func TestToolRegistry_ToProviderDefsWithContext_FiltersUnavailableTools(t *testing.T) { + r := NewToolRegistry() + r.Register(newMockTool("always", "always visible")) + rt := &mockAvailabilityTool{ + mockRegistryTool: *newMockTool("reaction", "telegram only"), + allowedChannel: "telegram", + } + r.Register(rt) + + defs := r.ToProviderDefsWithContext(context.Background(), "cli", "direct") + if len(defs) != 1 { + t.Fatalf("defs len = %d, want 1", len(defs)) + } + if defs[0].Function.Name != "always" { + t.Fatalf("visible tool = %q, want %q", defs[0].Function.Name, "always") + } + if rt.lastCtx == nil { + t.Fatal("expected availability check to receive context") + } + if got := ToolChannel(rt.lastCtx); got != "cli" { + t.Fatalf("availability context channel = %q, want %q", got, "cli") + } + + defs = r.ToProviderDefsWithContext(context.Background(), "telegram", "chat-1") + if len(defs) != 2 { + t.Fatalf("telegram defs len = %d, want 2", len(defs)) + } +} + func TestToolRegistry_List(t *testing.T) { r := NewToolRegistry() r.Register(newMockTool("x", "")) diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 12a4bc7fd..e28354958 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -55,7 +55,7 @@ func RunToolLoop( // 1. Build tool definitions var providerToolDefs []providers.ToolDefinition if config.Tools != nil { - providerToolDefs = config.Tools.ToProviderDefs() + providerToolDefs = config.Tools.ToProviderDefsWithContext(ctx, channel, chatID) } // 2. Set default LLM options