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.
This commit is contained in:
parent
c1718da019
commit
81d606766a
16 changed files with 771 additions and 53 deletions
|
|
@ -57,6 +57,23 @@
|
||||||
"allow_from": [
|
"allow_from": [
|
||||||
"YOUR_USER_ID"
|
"YOUR_USER_ID"
|
||||||
],
|
],
|
||||||
|
"allowed_reaction_emoji": [
|
||||||
|
"👍",
|
||||||
|
"👎",
|
||||||
|
"❤️",
|
||||||
|
"🔥",
|
||||||
|
"🥰",
|
||||||
|
"👏",
|
||||||
|
"😁",
|
||||||
|
"🤔",
|
||||||
|
"🤯",
|
||||||
|
"😱",
|
||||||
|
"🤬",
|
||||||
|
"😢",
|
||||||
|
"🎉",
|
||||||
|
"🤩",
|
||||||
|
"🤮"
|
||||||
|
],
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
|
|
@ -432,6 +449,9 @@
|
||||||
"message": {
|
"message": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"reaction": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
"read_file": {
|
"read_file": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,9 @@ type processOptions struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type agentResponse struct {
|
type agentResponse struct {
|
||||||
Content string
|
Content string
|
||||||
ReplyToMessageID string
|
ReplyToMessageID string
|
||||||
|
HandledExternally bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r agentResponse) outboundMessage(channel, chatID string) bus.OutboundMessage {
|
func (r agentResponse) outboundMessage(channel, chatID string) bus.OutboundMessage {
|
||||||
|
|
@ -200,6 +201,9 @@ func registerSharedTools(
|
||||||
})
|
})
|
||||||
agent.Tools.Register(messageTool)
|
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)
|
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
|
||||||
if cfg.Tools.IsToolEnabled("send_file") {
|
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)}
|
response = agentResponse{Content: fmt.Sprintf("Error processing message: %v", err)}
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.Content != "" {
|
if response.HandledExternally {
|
||||||
// Check if the message tool already sent a response during this round.
|
// A direct tool action (message or reaction) already sent user-facing output.
|
||||||
// If so, skip publishing to avoid duplicate messages to the user.
|
// Stop typing and delete placeholder since no outbound message will trigger preSend.
|
||||||
// Use default agent's tools to check (message tool is shared).
|
if al.channelManager != nil {
|
||||||
alreadySent := false
|
al.channelManager.CleanupState(ctx, msg.Channel, msg.ChatID)
|
||||||
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.Content != "" {
|
||||||
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 {
|
|
||||||
logger.DebugCF(
|
logger.DebugCF(
|
||||||
"agent",
|
"agent",
|
||||||
"Skipped outbound (message tool already sent)",
|
"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) {
|
func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
||||||
al.channelManager = cm
|
al.channelManager = cm
|
||||||
al.bindAdvancedMessageManagers(cm)
|
al.bindAdvancedMessageManagers(cm)
|
||||||
|
al.bindReactionTools(cm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bindAdvancedMessageManagers wires up channel callbacks to any tools that
|
// 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.
|
// SetMediaStore injects a MediaStore for media lifecycle management.
|
||||||
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||||
al.mediaStore = s
|
al.mediaStore = s
|
||||||
|
|
@ -459,6 +466,39 @@ func (al *AgentLoop) SetTranscriber(t voice.Transcriber) {
|
||||||
al.transcriber = t
|
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)(?::[^\]]*)?\]`)
|
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
|
||||||
|
|
||||||
// transcribeAudioInMessage resolves audio media refs, transcribes them, and
|
// 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
|
return agentResponse{}, routeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
al.resetRoundActionTools(agent)
|
||||||
if tool, ok := agent.Tools.Get("message"); ok {
|
|
||||||
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
|
||||||
resetter.ResetSentInRound()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve session key from route, while preserving explicit agent-scoped keys.
|
// Resolve session key from route, while preserving explicit agent-scoped keys.
|
||||||
scopeKey := resolveScopeKey(route, msg.SessionKey)
|
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
|
// This is controlled by the tool's Silent flag and ForUser content
|
||||||
|
|
||||||
// 4. Handle empty response
|
// 4. Handle empty response
|
||||||
if finalContent == "" {
|
directActionHandled := al.agentTurnHandledByDirectToolAction(agent)
|
||||||
|
if finalContent == "" && !directActionHandled {
|
||||||
finalContent = opts.DefaultResponse
|
finalContent = opts.DefaultResponse
|
||||||
}
|
}
|
||||||
response := resolveFinalResponse(opts.Channel, opts.ReplyContext, finalContent)
|
response := resolveFinalResponse(opts.Channel, opts.ReplyContext, finalContent)
|
||||||
if response.Content == "" {
|
if response.Content == "" && !directActionHandled {
|
||||||
response.Content = opts.DefaultResponse
|
response.Content = opts.DefaultResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Save final assistant message to session
|
// 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)
|
agent.Sessions.Save(opts.SessionKey)
|
||||||
|
|
||||||
// 6. Optional: summarization
|
// 6. Optional: summarization
|
||||||
|
|
@ -865,7 +903,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Optional: send response via bus
|
// 7. Optional: send response via bus
|
||||||
if opts.SendResponse {
|
if opts.SendResponse && response.Content != "" {
|
||||||
al.bus.PublishOutbound(ctx, response.outboundMessage(opts.Channel, opts.ChatID))
|
al.bus.PublishOutbound(ctx, response.outboundMessage(opts.Channel, opts.ChatID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -879,6 +917,7 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
"final_length": len(response.Content),
|
"final_length": len(response.Content),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
response.HandledExternally = directActionHandled
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1061,7 +1100,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build tool definitions
|
// Build tool definitions
|
||||||
providerToolDefs := agent.Tools.ToProviderDefs()
|
providerToolDefs := agent.Tools.ToProviderDefsWithContext(ctx, opts.Channel, opts.ChatID)
|
||||||
|
|
||||||
// Log LLM request details
|
// Log LLM request details
|
||||||
logger.DebugCF("agent", "LLM request",
|
logger.DebugCF("agent", "LLM request",
|
||||||
|
|
|
||||||
|
|
@ -438,6 +438,42 @@ func (m *taskToolRaceMockProvider) GetDefaultModel() string {
|
||||||
return "tasktool-race-mock-model"
|
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 {
|
type blockingSequentialTaskTool struct {
|
||||||
inner *tools.TaskTool
|
inner *tools.TaskTool
|
||||||
createOnce sync.Once
|
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) {
|
func TestTaskTool_DirectModeWithoutChannelManagerReturnsPlan(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,20 @@ type MessageDeleter interface {
|
||||||
DeleteMessage(ctx context.Context, chatID string, messageID string) error
|
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.
|
// ReactToMessage adds a reaction and returns an undo function to remove it.
|
||||||
// The undo function MUST be idempotent and safe to call multiple times.
|
// The undo function MUST be idempotent and safe to call multiple times.
|
||||||
type ReactionCapable interface {
|
type ReactionCapable interface {
|
||||||
ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)
|
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
|
// PlaceholderCapable — channels that can send a placeholder message
|
||||||
// (e.g. "Thinking... 💭") that will later be edited to the actual response.
|
// (e.g. "Thinking... 💭") that will later be edited to the actual response.
|
||||||
// The channel MUST also implement MessageEditor for the placeholder to be useful.
|
// The channel MUST also implement MessageEditor for the placeholder to be useful.
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,46 @@ func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) {
|
||||||
m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()})
|
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.
|
// 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).
|
// 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 {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,34 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess
|
||||||
return nil
|
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.
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
||||||
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
|
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
|
||||||
// edited to the actual response via EditMessage (channels.MessageEditor).
|
// edited to the actual response via EditMessage (channels.MessageEditor).
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,11 @@ func successResponseWithID(t *testing.T, id int) *ta.Response {
|
||||||
return &ta.Response{Ok: true, Result: b}
|
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.
|
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
|
||||||
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
|
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
|
||||||
t.Helper()
|
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")
|
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) {
|
func TestStartTyping_ForumTopic_UsesThreadID(t *testing.T) {
|
||||||
caller := &stubCaller{
|
caller := &stubCaller{
|
||||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||||
|
|
|
||||||
|
|
@ -262,16 +262,17 @@ type WhatsAppConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||||
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
||||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||||
Groups map[string]TelegramGroupConfig `json:"groups,omitempty"`
|
AllowedReactionEmoji FlexibleStringSlice `json:"allowed_reaction_emoji" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOWED_REACTION_EMOJI"`
|
||||||
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
Groups map[string]TelegramGroupConfig `json:"groups,omitempty"`
|
||||||
Typing TypingConfig `json:"typing,omitempty"`
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
Typing TypingConfig `json:"typing,omitempty"`
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelegramTopicConfig struct {
|
type TelegramTopicConfig struct {
|
||||||
|
|
@ -295,6 +296,30 @@ type FeishuConfig struct {
|
||||||
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
|
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 {
|
type DiscordConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
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_"`
|
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
|
||||||
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
|
||||||
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
|
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_"`
|
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||||
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||||
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||||
|
|
@ -970,6 +996,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
return t.ListDir.Enabled
|
return t.ListDir.Enabled
|
||||||
case "message":
|
case "message":
|
||||||
return t.Message.Enabled
|
return t.Message.Enabled
|
||||||
|
case "reaction":
|
||||||
|
return t.Reaction.Enabled
|
||||||
case "read_file":
|
case "read_file":
|
||||||
return t.ReadFile.Enabled
|
return t.ReadFile.Enabled
|
||||||
case "spawn":
|
case "spawn":
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestDefaultConfig_WorkspacePath_Default(t *testing.T) {
|
||||||
// Unset to ensure we test the default
|
// Unset to ensure we test the default
|
||||||
t.Setenv("PICOCLAW_HOME", "")
|
t.Setenv("PICOCLAW_HOME", "")
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,11 @@ func DefaultConfig() *Config {
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
Telegram: TelegramConfig{
|
Telegram: TelegramConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
Token: "",
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
Typing: TypingConfig{Enabled: true},
|
AllowedReactionEmoji: DefaultTelegramReactionEmoji(),
|
||||||
|
Typing: TypingConfig{Enabled: true},
|
||||||
Placeholder: PlaceholderConfig{
|
Placeholder: PlaceholderConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Text: "Thinking... 💭",
|
Text: "Thinking... 💭",
|
||||||
|
|
@ -466,6 +467,9 @@ func DefaultConfig() *Config {
|
||||||
Message: ToolConfig{
|
Message: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
|
Reaction: ToolConfig{
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
ReadFile: ReadFileToolConfig{
|
ReadFile: ReadFileToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
MaxReadFileSize: 64 * 1024, // 64KB
|
MaxReadFileSize: 64 * 1024, // 64KB
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,13 @@ type SequentialTool interface {
|
||||||
ExecuteSequentially() bool
|
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 {
|
func ToolToSchema(tool Tool) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
|
|
||||||
184
pkg/tools/reaction.go
Normal file
184
pkg/tools/reaction.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
pkg/tools/reaction_test.go
Normal file
121
pkg/tools/reaction_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -141,13 +141,22 @@ func (r *ToolRegistry) sortedToolNames() []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
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()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
ctx = availabilityContext(ctx, channel, chatID)
|
||||||
sorted := r.sortedToolNames()
|
sorted := r.sortedToolNames()
|
||||||
definitions := make([]map[string]any, 0, len(sorted))
|
definitions := make([]map[string]any, 0, len(sorted))
|
||||||
for _, name := range 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
|
return definitions
|
||||||
}
|
}
|
||||||
|
|
@ -155,13 +164,21 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any {
|
||||||
// ToProviderDefs converts tool definitions to provider-compatible format.
|
// ToProviderDefs converts tool definitions to provider-compatible format.
|
||||||
// This is the format expected by LLM provider APIs.
|
// This is the format expected by LLM provider APIs.
|
||||||
func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
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()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
ctx = availabilityContext(ctx, channel, chatID)
|
||||||
sorted := r.sortedToolNames()
|
sorted := r.sortedToolNames()
|
||||||
definitions := make([]providers.ToolDefinition, 0, len(sorted))
|
definitions := make([]providers.ToolDefinition, 0, len(sorted))
|
||||||
for _, name := range sorted {
|
for _, name := range sorted {
|
||||||
tool := r.tools[name]
|
tool := r.tools[name]
|
||||||
|
if !toolAvailableInContext(tool, ctx) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
schema := ToolToSchema(tool)
|
schema := ToolToSchema(tool)
|
||||||
|
|
||||||
// Safely extract nested values with type checks
|
// Safely extract nested values with type checks
|
||||||
|
|
@ -186,6 +203,21 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
|
||||||
return definitions
|
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.
|
// List returns a list of all registered tool names.
|
||||||
func (r *ToolRegistry) List() []string {
|
func (r *ToolRegistry) List() []string {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,17 @@ func (m *mockSequentialRegistryTool) ExecuteSequentially() bool {
|
||||||
return m.sequential
|
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 ---
|
// --- helpers ---
|
||||||
|
|
||||||
func newMockTool(name, desc string) *mockRegistryTool {
|
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) {
|
func TestToolRegistry_List(t *testing.T) {
|
||||||
r := NewToolRegistry()
|
r := NewToolRegistry()
|
||||||
r.Register(newMockTool("x", ""))
|
r.Register(newMockTool("x", ""))
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ func RunToolLoop(
|
||||||
// 1. Build tool definitions
|
// 1. Build tool definitions
|
||||||
var providerToolDefs []providers.ToolDefinition
|
var providerToolDefs []providers.ToolDefinition
|
||||||
if config.Tools != nil {
|
if config.Tools != nil {
|
||||||
providerToolDefs = config.Tools.ToProviderDefs()
|
providerToolDefs = config.Tools.ToProviderDefsWithContext(ctx, channel, chatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Set default LLM options
|
// 2. Set default LLM options
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue