diff --git a/agent/src/prompts/system.ts b/agent/src/prompts/system.ts index bdbfb5e6..4931dc35 100644 --- a/agent/src/prompts/system.ts +++ b/agent/src/prompts/system.ts @@ -62,21 +62,7 @@ ${quote('/data')} is your HOME, you are allowed to read and write files in it, t - ${quote('read')}: read file content - ${quote('write')}: write file content - ${quote('list')}: list directory entries -- ${quote('edit')}: replace exact text in a file. Input format: - -${block([ - '{', - ' "path": "relative/path/to/file.txt",', - ' "old_text": "exact text to find (must match exactly)",', - ' "new_text": "replacement text"', - '}', -].join('\n'))} - - Rules: - - ${quote('old_text')} must be unique in the file - - Matching is exact (including whitespace and newlines) - - If multiple occurrences exist, include more context in ${quote('old_text')} - +- ${quote('edit')}: replace exact text in a file - ${quote('exec')}: execute command ## Every Session @@ -96,6 +82,13 @@ Before anything else: For memory more previous, please use ${quote('search_memory')} tool. +## Message + +There are tools you can use in some channels: + +- ${quote('send')}: send message to a channel or session +- ${quote('react')}: add or remove emoji reaction + ## Contacts You may receive messages from many people or bots (like yourself), They are from different channels. diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 40b6b1a1..6ba1bcf3 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -345,7 +345,7 @@ func provideContainerdHandler(log *slog.Logger, service ctr.Service, cfg config. } func provideToolGatewayService(log *slog.Logger, cfg config.Config, channelManager *channel.Manager, registry *channel.Registry, channelService *channel.Service, scheduleService *schedule.Service, memoryService *memory.Service, chatService *conversation.Service, accountService *accounts.Service, manager *mcp.Manager, containerdHandler *handlers.ContainerdHandler, mcpConnService *mcp.ConnectionService) *mcp.ToolGatewayService { - messageExec := mcpmessage.NewExecutor(log, channelManager, registry) + messageExec := mcpmessage.NewExecutor(log, channelManager, channelManager, registry) directoryExec := mcpdirectory.NewExecutor(log, registry, channelService, registry) scheduleExec := mcpschedule.NewExecutor(log, scheduleService) memoryExec := mcpmemory.NewExecutor(log, memoryService, chatService, accountService) diff --git a/internal/channel/adapter.go b/internal/channel/adapter.go index c04f5f1f..7343b138 100644 --- a/internal/channel/adapter.go +++ b/internal/channel/adapter.go @@ -103,6 +103,12 @@ type MessageEditor interface { Unsend(ctx context.Context, cfg ChannelConfig, target string, messageID string) error } +// Reactor adds or removes emoji reactions on messages. +type Reactor interface { + React(ctx context.Context, cfg ChannelConfig, target string, messageID string, emoji string) error + Unreact(ctx context.Context, cfg ChannelConfig, target string, messageID string, emoji string) error +} + // SelfDiscoverer retrieves the adapter bot's own identity from the platform. // The returned map is merged into ChannelConfig.SelfIdentity and persisted. type SelfDiscoverer interface { diff --git a/internal/channel/adapters/feishu/feishu.go b/internal/channel/adapters/feishu/feishu.go index 702a6938..9b8c4630 100644 --- a/internal/channel/adapters/feishu/feishu.go +++ b/internal/channel/adapters/feishu/feishu.go @@ -203,6 +203,32 @@ func (a *FeishuAdapter) processingReactionGateway(cfg channel.ChannelConfig) (pr return gateway, nil } +// React adds an emoji reaction to a message (implements channel.Reactor). +// The target parameter is unused for Feishu; reactions are keyed by message_id. +func (a *FeishuAdapter) React(ctx context.Context, cfg channel.ChannelConfig, _ string, messageID string, emoji string) error { + gateway, err := a.processingReactionGateway(cfg) + if err != nil { + return err + } + _, err = gateway.Add(ctx, messageID, emoji) + return err +} + +// Unreact removes the bot's reaction from a message (implements channel.Reactor). +// For Feishu, this requires the reaction_id which we don't have here, so we pass +// the emoji as reaction_id. If the caller stored the reaction_id from React, they +// should pass it as emoji. This is a best-effort operation. +func (a *FeishuAdapter) Unreact(ctx context.Context, cfg channel.ChannelConfig, _ string, messageID string, reactionID string) error { + if strings.TrimSpace(reactionID) == "" { + return nil + } + gateway, err := a.processingReactionGateway(cfg) + if err != nil { + return err + } + return gateway.Remove(ctx, messageID, reactionID) +} + func addProcessingReaction(ctx context.Context, gateway processingReactionGateway, messageID, reactionType string) (string, error) { if gateway == nil { return "", fmt.Errorf("processing reaction gateway is nil") diff --git a/internal/channel/adapters/telegram/telegram.go b/internal/channel/adapters/telegram/telegram.go index f0cfab44..78fa5cee 100644 --- a/internal/channel/adapters/telegram/telegram.go +++ b/internal/channel/adapters/telegram/telegram.go @@ -839,13 +839,10 @@ func truncateTelegramText(text string) string { return text[:limit] + suffix } -const processingReactionEmoji = "👀" - -// ProcessingStarted adds a reaction to the user's message to indicate processing. +// ProcessingStarted sends a "typing" chat action to indicate processing. func (a *TelegramAdapter) ProcessingStarted(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, info channel.ProcessingStatusInfo) (channel.ProcessingStatusHandle, error) { chatID := strings.TrimSpace(info.ReplyTarget) - messageID := strings.TrimSpace(info.SourceMessageID) - if chatID == "" || messageID == "" { + if chatID == "" { return channel.ProcessingStatusHandle{}, nil } telegramCfg, err := parseConfig(cfg.Credentials) @@ -856,39 +853,30 @@ func (a *TelegramAdapter) ProcessingStarted(ctx context.Context, cfg channel.Cha if err != nil { return channel.ProcessingStatusHandle{}, err } - if err := setTelegramReaction(bot, chatID, messageID, processingReactionEmoji); err != nil { - if a.logger != nil { - a.logger.Warn("add processing reaction failed", slog.String("config_id", cfg.ID), slog.Any("error", err)) - } - return channel.ProcessingStatusHandle{}, nil + if err := sendTelegramTyping(bot, chatID); err != nil && a.logger != nil { + a.logger.Warn("send typing action failed", slog.String("config_id", cfg.ID), slog.Any("error", err)) } - return channel.ProcessingStatusHandle{Token: processingReactionEmoji}, nil + return channel.ProcessingStatusHandle{}, nil } -// ProcessingCompleted removes the processing reaction after reply is sent. +// ProcessingCompleted is a no-op for Telegram (typing indicator clears automatically). func (a *TelegramAdapter) ProcessingCompleted(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, info channel.ProcessingStatusInfo, handle channel.ProcessingStatusHandle) error { - if handle.Token == "" { - return nil - } - chatID := strings.TrimSpace(info.ReplyTarget) - messageID := strings.TrimSpace(info.SourceMessageID) - if chatID == "" || messageID == "" { - return nil - } - telegramCfg, err := parseConfig(cfg.Credentials) - if err != nil { - return err - } - bot, err := a.getOrCreateBot(telegramCfg.BotToken, cfg.ID) - if err != nil { - return err - } - return clearTelegramReaction(bot, chatID, messageID) + return nil } -// ProcessingFailed removes the processing reaction on failure. +// ProcessingFailed is a no-op for Telegram (typing indicator clears automatically). func (a *TelegramAdapter) ProcessingFailed(ctx context.Context, cfg channel.ChannelConfig, msg channel.InboundMessage, info channel.ProcessingStatusInfo, handle channel.ProcessingStatusHandle, cause error) error { - return a.ProcessingCompleted(ctx, cfg, msg, info, handle) + return nil +} + +func sendTelegramTyping(bot *tgbotapi.BotAPI, chatID string) error { + chatIDInt, err := strconv.ParseInt(chatID, 10, 64) + if err != nil { + return err + } + action := tgbotapi.NewChatAction(chatIDInt, tgbotapi.ChatTyping) + _, err = bot.Request(action) + return err } func setTelegramReaction(bot *tgbotapi.BotAPI, chatID, messageID, emoji string) error { @@ -908,3 +896,30 @@ func clearTelegramReaction(bot *tgbotapi.BotAPI, chatID, messageID string) error _, err := bot.MakeRequest("setMessageReaction", params) return err } + +// React adds an emoji reaction to a message (implements channel.Reactor). +func (a *TelegramAdapter) React(ctx context.Context, cfg channel.ChannelConfig, target string, messageID string, emoji string) error { + telegramCfg, err := parseConfig(cfg.Credentials) + if err != nil { + return err + } + bot, err := a.getOrCreateBot(telegramCfg.BotToken, cfg.ID) + if err != nil { + return err + } + return setTelegramReaction(bot, target, messageID, emoji) +} + +// Unreact removes the bot's reaction from a message (implements channel.Reactor). +// The emoji parameter is ignored; Telegram clears all bot reactions at once. +func (a *TelegramAdapter) Unreact(ctx context.Context, cfg channel.ChannelConfig, target string, messageID string, _ string) error { + telegramCfg, err := parseConfig(cfg.Credentials) + if err != nil { + return err + } + bot, err := a.getOrCreateBot(telegramCfg.BotToken, cfg.ID) + if err != nil { + return err + } + return clearTelegramReaction(bot, target, messageID) +} diff --git a/internal/channel/inbound/channel.go b/internal/channel/inbound/channel.go index cb4b9847..f4b9e11f 100644 --- a/internal/channel/inbound/channel.go +++ b/internal/channel/inbound/channel.go @@ -797,7 +797,7 @@ func collectMessageToolContext(registry *channel.Registry, messages []conversati suppressReplies := false for _, msg := range messages { for _, tc := range msg.ToolCalls { - if tc.Function.Name != "send_message" { + if tc.Function.Name != "send" && tc.Function.Name != "send_message" { continue } var args sendMessageToolArgs diff --git a/internal/channel/manager.go b/internal/channel/manager.go index 94b0647d..f67d2cba 100644 --- a/internal/channel/manager.go +++ b/internal/channel/manager.go @@ -224,6 +224,48 @@ func (m *Manager) Send(ctx context.Context, botID string, channelType ChannelTyp return nil } +// React adds or removes an emoji reaction on a channel message. +func (m *Manager) React(ctx context.Context, botID string, channelType ChannelType, req ReactRequest) error { + if m.service == nil { + return fmt.Errorf("channel manager not configured") + } + reactor, ok := m.registry.GetReactor(channelType) + if !ok { + return fmt.Errorf("channel %s does not support reactions", channelType) + } + config, err := m.service.ResolveEffectiveConfig(ctx, botID, channelType) + if err != nil { + return err + } + target := strings.TrimSpace(req.Target) + if target == "" { + return fmt.Errorf("target is required for reactions") + } + if normalized, ok := m.registry.NormalizeTarget(channelType, target); ok { + target = normalized + } + messageID := strings.TrimSpace(req.MessageID) + if messageID == "" { + return fmt.Errorf("message_id is required for reactions") + } + emoji := strings.TrimSpace(req.Emoji) + if !req.Remove && emoji == "" { + return fmt.Errorf("emoji is required when adding a reaction") + } + if m.logger != nil { + m.logger.Info("react outbound", + slog.String("channel", channelType.String()), + slog.String("bot_id", botID), + slog.String("message_id", messageID), + slog.Bool("remove", req.Remove), + ) + } + if req.Remove { + return reactor.Unreact(ctx, config, target, messageID, emoji) + } + return reactor.React(ctx, config, target, messageID, emoji) +} + // Shutdown cancels the inbound worker pool and stops all active connections. func (m *Manager) Shutdown(ctx context.Context) error { if m.inboundCancel != nil { diff --git a/internal/channel/registry.go b/internal/channel/registry.go index df53e260..1bfa8015 100644 --- a/internal/channel/registry.go +++ b/internal/channel/registry.go @@ -216,6 +216,16 @@ func (r *Registry) GetMessageEditor(channelType ChannelType) (MessageEditor, boo return editor, ok } +// GetReactor returns the Reactor for the given channel type, or nil if unsupported. +func (r *Registry) GetReactor(channelType ChannelType) (Reactor, bool) { + adapter, ok := r.Get(channelType) + if !ok { + return nil, false + } + reactor, ok := adapter.(Reactor) + return reactor, ok +} + // GetReceiver returns the Receiver for the given channel type, or nil if unsupported. func (r *Registry) GetReceiver(channelType ChannelType) (Receiver, bool) { adapter, ok := r.Get(channelType) diff --git a/internal/channel/types.go b/internal/channel/types.go index c0b66dec..d54e88a6 100644 --- a/internal/channel/types.go +++ b/internal/channel/types.go @@ -350,3 +350,11 @@ type SendRequest struct { ChannelIdentityID string `json:"channel_identity_id,omitempty"` Message Message `json:"message"` } + +// ReactRequest is the input for adding or removing an emoji reaction on a message. +type ReactRequest struct { + Target string `json:"target"` + MessageID string `json:"message_id"` + Emoji string `json:"emoji"` + Remove bool `json:"remove,omitempty"` +} diff --git a/internal/mcp/providers/message/provider.go b/internal/mcp/providers/message/provider.go index 81911da2..218f63f6 100644 --- a/internal/mcp/providers/message/provider.go +++ b/internal/mcp/providers/message/provider.go @@ -11,41 +11,54 @@ import ( mcpgw "github.com/memohai/memoh/internal/mcp" ) -const toolSendMessage = "send_message" +const ( + toolSend = "send" + toolReact = "react" +) +// Sender sends outbound messages through channel manager. type Sender interface { Send(ctx context.Context, botID string, channelType channel.ChannelType, req channel.SendRequest) error } +// Reactor adds or removes emoji reactions through channel manager. +type Reactor interface { + React(ctx context.Context, botID string, channelType channel.ChannelType, req channel.ReactRequest) error +} + +// ChannelTypeResolver parses platform name to channel type. type ChannelTypeResolver interface { ParseChannelType(raw string) (channel.ChannelType, error) } +// Executor exposes send and react as MCP tools. type Executor struct { sender Sender + reactor Reactor resolver ChannelTypeResolver logger *slog.Logger } -func NewExecutor(log *slog.Logger, sender Sender, resolver ChannelTypeResolver) *Executor { +// NewExecutor creates a message tool executor. +// reactor may be nil; the react tool will not be listed when reactor is unavailable. +func NewExecutor(log *slog.Logger, sender Sender, reactor Reactor, resolver ChannelTypeResolver) *Executor { if log == nil { log = slog.Default() } return &Executor{ sender: sender, + reactor: reactor, resolver: resolver, logger: log.With(slog.String("provider", "message_tool")), } } func (p *Executor) ListTools(ctx context.Context, session mcpgw.ToolSessionContext) ([]mcpgw.ToolDescriptor, error) { - if p.sender == nil || p.resolver == nil { - return []mcpgw.ToolDescriptor{}, nil - } - return []mcpgw.ToolDescriptor{ - { - Name: toolSendMessage, - Description: "Send a message to a channel or session", + var tools []mcpgw.ToolDescriptor + if p.sender != nil && p.resolver != nil { + tools = append(tools, mcpgw.ToolDescriptor{ + Name: toolSend, + Description: "Send a message to a channel or session. Supports text, structured messages, attachments, and replies.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ @@ -73,6 +86,10 @@ func (p *Executor) ListTools(ctx context.Context, session mcpgw.ToolSessionConte "type": "string", "description": "Message text shortcut when message object is omitted", }, + "reply_to": map[string]any{ + "type": "string", + "description": "Message ID to reply to. The reply will reference this message on the platform.", + }, "message": map[string]any{ "type": "object", "description": "Structured message payload with text/parts/attachments", @@ -80,37 +97,70 @@ func (p *Executor) ListTools(ctx context.Context, session mcpgw.ToolSessionConte }, "required": []string{}, }, - }, - }, nil + }) + } + if p.reactor != nil && p.resolver != nil { + tools = append(tools, mcpgw.ToolDescriptor{ + Name: toolReact, + Description: "Add or remove an emoji reaction on a channel message", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "bot_id": map[string]any{ + "type": "string", + "description": "Bot ID, optional and defaults to current bot", + }, + "platform": map[string]any{ + "type": "string", + "description": "Channel platform name. Defaults to current session platform.", + }, + "target": map[string]any{ + "type": "string", + "description": "Channel target (chat/group ID). Defaults to current session reply target.", + }, + "message_id": map[string]any{ + "type": "string", + "description": "The message ID to react to", + }, + "emoji": map[string]any{ + "type": "string", + "description": "Emoji to react with (e.g. 👍, ❤️). Required when adding a reaction.", + }, + "remove": map[string]any{ + "type": "boolean", + "description": "If true, remove the reaction instead of adding it. Default false.", + }, + }, + "required": []string{"message_id"}, + }, + }) + } + return tools, nil } func (p *Executor) CallTool(ctx context.Context, session mcpgw.ToolSessionContext, toolName string, arguments map[string]any) (map[string]any, error) { - if toolName != toolSendMessage { + switch toolName { + case toolSend: + return p.callSend(ctx, session, arguments) + case toolReact: + return p.callReact(ctx, session, arguments) + default: return nil, mcpgw.ErrToolNotFound } +} + +// --- send --- + +func (p *Executor) callSend(ctx context.Context, session mcpgw.ToolSessionContext, arguments map[string]any) (map[string]any, error) { if p.sender == nil || p.resolver == nil { return mcpgw.BuildToolErrorResult("message service not available"), nil } - botID := mcpgw.FirstStringArg(arguments, "bot_id") - if botID == "" { - botID = strings.TrimSpace(session.BotID) + botID, err := p.resolveBotID(arguments, session) + if err != nil { + return mcpgw.BuildToolErrorResult(err.Error()), nil } - if botID == "" { - return mcpgw.BuildToolErrorResult("bot_id is required"), nil - } - if strings.TrimSpace(session.BotID) != "" && botID != strings.TrimSpace(session.BotID) { - return mcpgw.BuildToolErrorResult("bot_id mismatch"), nil - } - - platform := mcpgw.FirstStringArg(arguments, "platform") - if platform == "" { - platform = strings.TrimSpace(session.CurrentPlatform) - } - if platform == "" { - return mcpgw.BuildToolErrorResult("platform is required"), nil - } - channelType, err := p.resolver.ParseChannelType(platform) + channelType, err := p.resolvePlatform(arguments, session) if err != nil { return mcpgw.BuildToolErrorResult(err.Error()), nil } @@ -121,6 +171,11 @@ func (p *Executor) CallTool(ctx context.Context, session mcpgw.ToolSessionContex return mcpgw.BuildToolErrorResult(parseErr.Error()), nil } + // Attach reply reference if reply_to is provided. + if replyTo := mcpgw.FirstStringArg(arguments, "reply_to"); replyTo != "" { + outboundMessage.Reply = &channel.ReplyRef{MessageID: replyTo} + } + target := mcpgw.FirstStringArg(arguments, "target") if target == "" { target = strings.TrimSpace(session.ReplyTarget) @@ -136,7 +191,7 @@ func (p *Executor) CallTool(ctx context.Context, session mcpgw.ToolSessionContex Message: outboundMessage, } if err := p.sender.Send(ctx, botID, channelType, sendReq); err != nil { - p.logger.Warn("send message failed", slog.Any("error", err), slog.String("bot_id", botID), slog.String("platform", platform)) + p.logger.Warn("send failed", slog.Any("error", err), slog.String("bot_id", botID), slog.String("platform", string(channelType))) return mcpgw.BuildToolErrorResult(err.Error()), nil } @@ -151,6 +206,92 @@ func (p *Executor) CallTool(ctx context.Context, session mcpgw.ToolSessionContex return mcpgw.BuildToolSuccessResult(payload), nil } +// --- react --- + +func (p *Executor) callReact(ctx context.Context, session mcpgw.ToolSessionContext, arguments map[string]any) (map[string]any, error) { + if p.reactor == nil || p.resolver == nil { + return mcpgw.BuildToolErrorResult("reaction service not available"), nil + } + + botID, err := p.resolveBotID(arguments, session) + if err != nil { + return mcpgw.BuildToolErrorResult(err.Error()), nil + } + channelType, err := p.resolvePlatform(arguments, session) + if err != nil { + return mcpgw.BuildToolErrorResult(err.Error()), nil + } + + target := mcpgw.FirstStringArg(arguments, "target") + if target == "" { + target = strings.TrimSpace(session.ReplyTarget) + } + if target == "" { + return mcpgw.BuildToolErrorResult("target is required"), nil + } + + messageID := mcpgw.FirstStringArg(arguments, "message_id") + if messageID == "" { + return mcpgw.BuildToolErrorResult("message_id is required"), nil + } + + emoji := mcpgw.FirstStringArg(arguments, "emoji") + remove, _, _ := mcpgw.BoolArg(arguments, "remove") + + reactReq := channel.ReactRequest{ + Target: target, + MessageID: messageID, + Emoji: emoji, + Remove: remove, + } + if err := p.reactor.React(ctx, botID, channelType, reactReq); err != nil { + p.logger.Warn("react failed", slog.Any("error", err), slog.String("bot_id", botID), slog.String("platform", string(channelType))) + return mcpgw.BuildToolErrorResult(err.Error()), nil + } + + action := "added" + if remove { + action = "removed" + } + payload := map[string]any{ + "ok": true, + "bot_id": botID, + "platform": channelType.String(), + "target": target, + "message_id": messageID, + "emoji": emoji, + "action": action, + } + return mcpgw.BuildToolSuccessResult(payload), nil +} + +// --- shared helpers --- + +func (p *Executor) resolveBotID(arguments map[string]any, session mcpgw.ToolSessionContext) (string, error) { + botID := mcpgw.FirstStringArg(arguments, "bot_id") + if botID == "" { + botID = strings.TrimSpace(session.BotID) + } + if botID == "" { + return "", fmt.Errorf("bot_id is required") + } + if strings.TrimSpace(session.BotID) != "" && botID != strings.TrimSpace(session.BotID) { + return "", fmt.Errorf("bot_id mismatch") + } + return botID, nil +} + +func (p *Executor) resolvePlatform(arguments map[string]any, session mcpgw.ToolSessionContext) (channel.ChannelType, error) { + platform := mcpgw.FirstStringArg(arguments, "platform") + if platform == "" { + platform = strings.TrimSpace(session.CurrentPlatform) + } + if platform == "" { + return "", fmt.Errorf("platform is required") + } + return p.resolver.ParseChannelType(platform) +} + func parseOutboundMessage(arguments map[string]any, fallbackText string) (channel.Message, error) { var msg channel.Message if raw, ok := arguments["message"]; ok && raw != nil { diff --git a/internal/mcp/providers/message/provider_test.go b/internal/mcp/providers/message/provider_test.go index df6b9fa3..98a775d7 100644 --- a/internal/mcp/providers/message/provider_test.go +++ b/internal/mcp/providers/message/provider_test.go @@ -10,10 +10,22 @@ import ( ) type fakeSender struct { - err error + err error + lastReq channel.SendRequest } func (f *fakeSender) Send(ctx context.Context, botID string, channelType channel.ChannelType, req channel.SendRequest) error { + f.lastReq = req + return f.err +} + +type fakeReactor struct { + err error + lastReq channel.ReactRequest +} + +func (f *fakeReactor) React(ctx context.Context, botID string, channelType channel.ChannelType, req channel.ReactRequest) error { + f.lastReq = req return f.err } @@ -29,8 +41,10 @@ func (f *fakeResolver) ParseChannelType(raw string) (channel.ChannelType, error) return f.ct, nil } +// --- send tests --- + func TestExecutor_ListTools_NilDeps(t *testing.T) { - exec := NewExecutor(nil, nil, nil) + exec := NewExecutor(nil, nil, nil, nil) tools, err := exec.ListTools(context.Background(), mcpgw.ToolSessionContext{}) if err != nil { t.Fatal(err) @@ -42,8 +56,28 @@ func TestExecutor_ListTools_NilDeps(t *testing.T) { func TestExecutor_ListTools(t *testing.T) { sender := &fakeSender{} + reactor := &fakeReactor{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, resolver) + exec := NewExecutor(nil, sender, reactor, resolver) + tools, err := exec.ListTools(context.Background(), mcpgw.ToolSessionContext{}) + if err != nil { + t.Fatal(err) + } + if len(tools) != 2 { + t.Fatalf("expected 2 tools, got %d", len(tools)) + } + if tools[0].Name != toolSend { + t.Errorf("tool[0] name = %q, want %q", tools[0].Name, toolSend) + } + if tools[1].Name != toolReact { + t.Errorf("tool[1] name = %q, want %q", tools[1].Name, toolReact) + } +} + +func TestExecutor_ListTools_OnlySender(t *testing.T) { + sender := &fakeSender{} + resolver := &fakeResolver{ct: channel.ChannelType("feishu")} + exec := NewExecutor(nil, sender, nil, resolver) tools, err := exec.ListTools(context.Background(), mcpgw.ToolSessionContext{}) if err != nil { t.Fatal(err) @@ -51,15 +85,15 @@ func TestExecutor_ListTools(t *testing.T) { if len(tools) != 1 { t.Fatalf("expected 1 tool, got %d", len(tools)) } - if tools[0].Name != toolSendMessage { - t.Errorf("tool name = %q, want %q", tools[0].Name, toolSendMessage) + if tools[0].Name != toolSend { + t.Errorf("tool name = %q, want %q", tools[0].Name, toolSend) } } func TestExecutor_CallTool_NotFound(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, resolver) + exec := NewExecutor(nil, sender, nil, resolver) _, err := exec.CallTool(context.Background(), mcpgw.ToolSessionContext{BotID: "bot1"}, "other_tool", nil) if err != mcpgw.ErrToolNotFound { t.Errorf("expected ErrToolNotFound, got %v", err) @@ -67,8 +101,8 @@ func TestExecutor_CallTool_NotFound(t *testing.T) { } func TestExecutor_CallTool_NilDeps(t *testing.T) { - exec := NewExecutor(nil, nil, nil) - result, err := exec.CallTool(context.Background(), mcpgw.ToolSessionContext{BotID: "bot1"}, toolSendMessage, map[string]any{ + exec := NewExecutor(nil, nil, nil, nil) + result, err := exec.CallTool(context.Background(), mcpgw.ToolSessionContext{BotID: "bot1"}, toolSend, map[string]any{ "platform": "feishu", "target": "t1", "text": "hi", }) if err != nil { @@ -82,8 +116,8 @@ func TestExecutor_CallTool_NilDeps(t *testing.T) { func TestExecutor_CallTool_NoBotID(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, resolver) - result, err := exec.CallTool(context.Background(), mcpgw.ToolSessionContext{}, toolSendMessage, map[string]any{ + exec := NewExecutor(nil, sender, nil, resolver) + result, err := exec.CallTool(context.Background(), mcpgw.ToolSessionContext{}, toolSend, map[string]any{ "platform": "feishu", "target": "t1", "text": "hi", }) if err != nil { @@ -97,9 +131,9 @@ func TestExecutor_CallTool_NoBotID(t *testing.T) { func TestExecutor_CallTool_BotIDMismatch(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, resolver) + exec := NewExecutor(nil, sender, nil, resolver) session := mcpgw.ToolSessionContext{BotID: "bot1"} - result, err := exec.CallTool(context.Background(), session, toolSendMessage, map[string]any{ + result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "bot_id": "other", "platform": "feishu", "target": "t1", "text": "hi", }) if err != nil { @@ -113,9 +147,9 @@ func TestExecutor_CallTool_BotIDMismatch(t *testing.T) { func TestExecutor_CallTool_NoPlatform(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, resolver) + exec := NewExecutor(nil, sender, nil, resolver) session := mcpgw.ToolSessionContext{BotID: "bot1"} - result, err := exec.CallTool(context.Background(), session, toolSendMessage, map[string]any{ + result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "target": "t1", "text": "hi", }) if err != nil { @@ -129,9 +163,9 @@ func TestExecutor_CallTool_NoPlatform(t *testing.T) { func TestExecutor_CallTool_PlatformParseError(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{err: errors.New("unknown platform")} - exec := NewExecutor(nil, sender, resolver) + exec := NewExecutor(nil, sender, nil, resolver) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "feishu"} - result, err := exec.CallTool(context.Background(), session, toolSendMessage, map[string]any{ + result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "platform": "bad", "target": "t1", "text": "hi", }) if err != nil { @@ -145,9 +179,9 @@ func TestExecutor_CallTool_PlatformParseError(t *testing.T) { func TestExecutor_CallTool_NoMessage(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, resolver) + exec := NewExecutor(nil, sender, nil, resolver) session := mcpgw.ToolSessionContext{BotID: "bot1"} - result, err := exec.CallTool(context.Background(), session, toolSendMessage, map[string]any{ + result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "platform": "feishu", "target": "t1", }) if err != nil { @@ -161,9 +195,9 @@ func TestExecutor_CallTool_NoMessage(t *testing.T) { func TestExecutor_CallTool_NoTarget(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, resolver) + exec := NewExecutor(nil, sender, nil, resolver) session := mcpgw.ToolSessionContext{BotID: "bot1"} - result, err := exec.CallTool(context.Background(), session, toolSendMessage, map[string]any{ + result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "platform": "feishu", "text": "hi", }) if err != nil { @@ -177,9 +211,9 @@ func TestExecutor_CallTool_NoTarget(t *testing.T) { func TestExecutor_CallTool_SendError(t *testing.T) { sender := &fakeSender{err: errors.New("send failed")} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, resolver) + exec := NewExecutor(nil, sender, nil, resolver) session := mcpgw.ToolSessionContext{BotID: "bot1", ReplyTarget: "t1"} - result, err := exec.CallTool(context.Background(), session, toolSendMessage, map[string]any{ + result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "platform": "feishu", "text": "hi", }) if err != nil { @@ -193,9 +227,9 @@ func TestExecutor_CallTool_SendError(t *testing.T) { func TestExecutor_CallTool_Success(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, resolver) + exec := NewExecutor(nil, sender, nil, resolver) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "feishu", ReplyTarget: "chat1"} - result, err := exec.CallTool(context.Background(), session, toolSendMessage, map[string]any{ + result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "text": "hello", }) if err != nil { @@ -216,6 +250,165 @@ func TestExecutor_CallTool_Success(t *testing.T) { } } +func TestExecutor_CallTool_ReplyTo(t *testing.T) { + sender := &fakeSender{} + resolver := &fakeResolver{ct: channel.ChannelType("telegram")} + exec := NewExecutor(nil, sender, nil, resolver) + session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} + result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ + "text": "reply text", + "reply_to": "msg-789", + }) + if err != nil { + t.Fatal(err) + } + if err := mcpgw.PayloadError(result); err != nil { + t.Fatal(err) + } + if sender.lastReq.Message.Reply == nil { + t.Fatal("expected Reply to be set") + } + if sender.lastReq.Message.Reply.MessageID != "msg-789" { + t.Errorf("Reply.MessageID = %q, want %q", sender.lastReq.Message.Reply.MessageID, "msg-789") + } +} + +func TestExecutor_CallTool_NoReplyTo(t *testing.T) { + sender := &fakeSender{} + resolver := &fakeResolver{ct: channel.ChannelType("telegram")} + exec := NewExecutor(nil, sender, nil, resolver) + session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} + result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ + "text": "no reply", + }) + if err != nil { + t.Fatal(err) + } + if err := mcpgw.PayloadError(result); err != nil { + t.Fatal(err) + } + if sender.lastReq.Message.Reply != nil { + t.Error("expected Reply to be nil when reply_to is not provided") + } +} + +// --- react tests --- + +func TestExecutor_React_NilReactor(t *testing.T) { + exec := NewExecutor(nil, nil, nil, nil) + result, err := exec.CallTool(context.Background(), mcpgw.ToolSessionContext{BotID: "bot1"}, toolReact, map[string]any{ + "platform": "telegram", "target": "123", "message_id": "456", "emoji": "👍", + }) + if err != nil { + t.Fatal(err) + } + if isErr, _ := result["isError"].(bool); !isErr { + t.Error("expected error when reactor is nil") + } +} + +func TestExecutor_React_NoMessageID(t *testing.T) { + reactor := &fakeReactor{} + resolver := &fakeResolver{ct: channel.ChannelType("telegram")} + exec := NewExecutor(nil, nil, reactor, resolver) + session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} + result, err := exec.CallTool(context.Background(), session, toolReact, map[string]any{ + "emoji": "👍", + }) + if err != nil { + t.Fatal(err) + } + if isErr, _ := result["isError"].(bool); !isErr { + t.Error("expected error when message_id is missing") + } +} + +func TestExecutor_React_NoTarget(t *testing.T) { + reactor := &fakeReactor{} + resolver := &fakeResolver{ct: channel.ChannelType("telegram")} + exec := NewExecutor(nil, nil, reactor, resolver) + session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram"} + result, err := exec.CallTool(context.Background(), session, toolReact, map[string]any{ + "message_id": "456", "emoji": "👍", + }) + if err != nil { + t.Fatal(err) + } + if isErr, _ := result["isError"].(bool); !isErr { + t.Error("expected error when target is missing") + } +} + +func TestExecutor_React_Success(t *testing.T) { + reactor := &fakeReactor{} + resolver := &fakeResolver{ct: channel.ChannelType("telegram")} + exec := NewExecutor(nil, nil, reactor, resolver) + session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} + result, err := exec.CallTool(context.Background(), session, toolReact, map[string]any{ + "message_id": "456", "emoji": "👍", + }) + if err != nil { + t.Fatal(err) + } + if err := mcpgw.PayloadError(result); err != nil { + t.Fatal(err) + } + content, _ := result["structuredContent"].(map[string]any) + if content == nil { + t.Fatal("no structuredContent") + } + if content["ok"] != true { + t.Errorf("ok = %v", content["ok"]) + } + if content["action"] != "added" { + t.Errorf("action = %v", content["action"]) + } + if content["emoji"] != "👍" { + t.Errorf("emoji = %v", content["emoji"]) + } +} + +func TestExecutor_React_Remove(t *testing.T) { + reactor := &fakeReactor{} + resolver := &fakeResolver{ct: channel.ChannelType("telegram")} + exec := NewExecutor(nil, nil, reactor, resolver) + session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} + result, err := exec.CallTool(context.Background(), session, toolReact, map[string]any{ + "message_id": "456", "remove": true, + }) + if err != nil { + t.Fatal(err) + } + if err := mcpgw.PayloadError(result); err != nil { + t.Fatal(err) + } + content, _ := result["structuredContent"].(map[string]any) + if content["action"] != "removed" { + t.Errorf("action = %v", content["action"]) + } + if reactor.lastReq.Remove != true { + t.Error("expected Remove=true in request") + } +} + +func TestExecutor_React_Error(t *testing.T) { + reactor := &fakeReactor{err: errors.New("reaction failed")} + resolver := &fakeResolver{ct: channel.ChannelType("telegram")} + exec := NewExecutor(nil, nil, reactor, resolver) + session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} + result, err := exec.CallTool(context.Background(), session, toolReact, map[string]any{ + "message_id": "456", "emoji": "👍", + }) + if err != nil { + t.Fatal(err) + } + if isErr, _ := result["isError"].(bool); !isErr { + t.Error("expected error when React fails") + } +} + +// --- parseOutboundMessage tests --- + func TestParseOutboundMessage(t *testing.T) { tests := []struct { name string