refactor: channel tools

This commit is contained in:
Acbox
2026-02-15 17:48:20 +08:00
parent 37360ad8e4
commit 38753ef054
11 changed files with 537 additions and 103 deletions
+8 -15
View File
@@ -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.
+1 -1
View File
@@ -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)
+6
View File
@@ -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 {
@@ -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")
+46 -31
View File
@@ -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)
}
+1 -1
View File
@@ -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
+42
View File
@@ -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 {
+10
View File
@@ -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)
+8
View File
@@ -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"`
}
+172 -31
View File
@@ -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 {
+217 -24
View File
@@ -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