From 82cc9c357fe0381fc0bbc9efe18cbcc1005c8af9 Mon Sep 17 00:00:00 2001 From: Acbox Date: Fri, 20 Feb 2026 22:04:00 +0800 Subject: [PATCH] feat: support attachment send to tool `send` --- cmd/agent/main.go | 29 ++- internal/channel/outbound.go | 8 +- internal/mcp/providers/message/provider.go | 196 ++++++++++++++++-- .../mcp/providers/message/provider_test.go | 42 ++-- 4 files changed, 233 insertions(+), 42 deletions(-) diff --git a/cmd/agent/main.go b/cmd/agent/main.go index a6887169..f1ab0d26 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -436,8 +436,12 @@ func provideContainerdHandler(log *slog.Logger, service ctr.Service, manager *mc return handlers.NewContainerdHandler(log, service, manager, cfg.MCP, cfg.Containerd.Namespace, botService, accountService, policyService, queries) } -func provideToolGatewayService(log *slog.Logger, cfg config.Config, channelManager *channel.Manager, registry *channel.Registry, routeService *route.DBService, scheduleService *schedule.Service, memoryService *memory.Service, chatService *conversation.Service, accountService *accounts.Service, settingsService *settings.Service, searchProviderService *searchproviders.Service, manager *mcp.Manager, containerdHandler *handlers.ContainerdHandler, mcpConnService *mcp.ConnectionService) *mcp.ToolGatewayService { - messageExec := mcpmessage.NewExecutor(log, channelManager, channelManager, registry) +func provideToolGatewayService(log *slog.Logger, cfg config.Config, channelManager *channel.Manager, registry *channel.Registry, routeService *route.DBService, scheduleService *schedule.Service, memoryService *memory.Service, chatService *conversation.Service, accountService *accounts.Service, settingsService *settings.Service, searchProviderService *searchproviders.Service, manager *mcp.Manager, containerdHandler *handlers.ContainerdHandler, mcpConnService *mcp.ConnectionService, mediaService *media.Service) *mcp.ToolGatewayService { + var assetResolver mcpmessage.AssetResolver + if mediaService != nil { + assetResolver = &mediaAssetResolverAdapter{media: mediaService} + } + messageExec := mcpmessage.NewExecutor(log, channelManager, channelManager, registry, assetResolver) contactsExec := mcpcontacts.NewExecutor(log, routeService) scheduleExec := mcpschedule.NewExecutor(log, scheduleService) memoryExec := mcpmemory.NewExecutor(log, memoryService, chatService, accountService) @@ -774,6 +778,27 @@ func (a *skillLoaderAdapter) LoadSkills(ctx context.Context, botID string) ([]fl return entries, nil } +// mediaAssetResolverAdapter bridges media.Service to the message tool's AssetResolver interface. +type mediaAssetResolverAdapter struct { + media *media.Service +} + +func (a *mediaAssetResolverAdapter) GetByStorageKey(ctx context.Context, botID, storageKey string) (mcpmessage.AssetMeta, error) { + if a == nil || a.media == nil { + return mcpmessage.AssetMeta{}, fmt.Errorf("media service not configured") + } + asset, err := a.media.GetByStorageKey(ctx, botID, storageKey) + if err != nil { + return mcpmessage.AssetMeta{}, err + } + return mcpmessage.AssetMeta{ + ContentHash: asset.ContentHash, + Mime: asset.Mime, + SizeBytes: asset.SizeBytes, + StorageKey: asset.StorageKey, + }, nil +} + // gatewayAssetLoaderAdapter bridges media service to flow gateway asset loader. type gatewayAssetLoaderAdapter struct { media *media.Service diff --git a/internal/channel/outbound.go b/internal/channel/outbound.go index 9d1ac759..84aa66a2 100644 --- a/internal/channel/outbound.go +++ b/internal/channel/outbound.go @@ -378,18 +378,14 @@ func normalizeAttachmentRefs(attachments []Attachment, defaultPlatform ChannelTy item.URL = strings.TrimSpace(item.URL) item.PlatformKey = strings.TrimSpace(item.PlatformKey) item.ContentHash = strings.TrimSpace(item.ContentHash) + item.Base64 = strings.TrimSpace(item.Base64) item.SourcePlatform = strings.TrimSpace(item.SourcePlatform) if item.SourcePlatform == "" && item.PlatformKey != "" { item.SourcePlatform = defaultPlatform.String() } - if item.URL == "" && item.PlatformKey == "" && item.ContentHash == "" { + if item.URL == "" && item.PlatformKey == "" && item.ContentHash == "" && item.Base64 == "" { return nil, fmt.Errorf("attachment reference is required") } - // content_hash-only attachments require media resolution before dispatch. - // Adapters expect url or platform_key; fail loudly if neither is available. - if item.URL == "" && item.PlatformKey == "" && item.ContentHash != "" { - return nil, fmt.Errorf("attachment %s has content_hash but no sendable url or platform_key; media resolution required before dispatch", item.ContentHash) - } normalized = append(normalized, item) } return normalized, nil diff --git a/internal/mcp/providers/message/provider.go b/internal/mcp/providers/message/provider.go index 65e9a361..324ae391 100644 --- a/internal/mcp/providers/message/provider.go +++ b/internal/mcp/providers/message/provider.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "path/filepath" "strings" "github.com/memohai/memoh/internal/channel" @@ -31,25 +32,40 @@ type ChannelTypeResolver interface { ParseChannelType(raw string) (channel.ChannelType, error) } +// AssetMeta holds resolved metadata for a media asset. +type AssetMeta struct { + ContentHash string + Mime string + SizeBytes int64 + StorageKey string +} + +// AssetResolver looks up persisted media assets by storage key. +type AssetResolver interface { + GetByStorageKey(ctx context.Context, botID, storageKey string) (AssetMeta, error) +} + // Executor exposes send and react as MCP tools. type Executor struct { - sender Sender - reactor Reactor - resolver ChannelTypeResolver - logger *slog.Logger + sender Sender + reactor Reactor + resolver ChannelTypeResolver + assetResolver AssetResolver + logger *slog.Logger } // 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 { +// reactor and assetResolver may be nil. +func NewExecutor(log *slog.Logger, sender Sender, reactor Reactor, resolver ChannelTypeResolver, assetResolver AssetResolver) *Executor { if log == nil { log = slog.Default() } return &Executor{ - sender: sender, - reactor: reactor, - resolver: resolver, - logger: log.With(slog.String("provider", "message_tool")), + sender: sender, + reactor: reactor, + resolver: resolver, + assetResolver: assetResolver, + logger: log.With(slog.String("provider", "message_tool")), } } @@ -58,7 +74,7 @@ func (p *Executor) ListTools(ctx context.Context, session mcpgw.ToolSessionConte 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.", + Description: "Send a message to a channel or session. Supports text, attachments, and replies.", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ @@ -82,6 +98,11 @@ func (p *Executor) ListTools(ctx context.Context, session mcpgw.ToolSessionConte "type": "string", "description": "Message ID to reply to. The reply will reference this message on the platform.", }, + "attachments": map[string]any{ + "type": "array", + "description": "File paths or URLs to attach. Each item is a container path (e.g. /data/media/ab/file.jpg), an HTTP URL, or an object with {path, url, type, name}.", + "items": map[string]any{}, + }, "message": map[string]any{ "type": "object", "description": "Structured message payload with text/parts/attachments", @@ -160,10 +181,25 @@ func (p *Executor) callSend(ctx context.Context, session mcpgw.ToolSessionContex messageText := mcpgw.FirstStringArg(arguments, "text") outboundMessage, parseErr := parseOutboundMessage(arguments, messageText) if parseErr != nil { - return mcpgw.BuildToolErrorResult(parseErr.Error()), nil + // Allow empty message when attachments are provided. + if rawAtt, ok := arguments["attachments"]; !ok || rawAtt == nil { + return mcpgw.BuildToolErrorResult(parseErr.Error()), nil + } + outboundMessage = channel.Message{Text: strings.TrimSpace(messageText)} + } + + // Resolve top-level attachments parameter. + if rawAttachments, ok := arguments["attachments"]; ok && rawAttachments != nil { + if arr, ok := rawAttachments.([]any); ok && len(arr) > 0 { + resolved := p.resolveAttachments(ctx, botID, arr) + outboundMessage.Attachments = append(outboundMessage.Attachments, resolved...) + } + } + + if outboundMessage.IsEmpty() { + return mcpgw.BuildToolErrorResult("message or attachments required"), nil } - // Attach reply reference if reply_to is provided. if replyTo := mcpgw.FirstStringArg(arguments, "reply_to"); replyTo != "" { outboundMessage.Reply = &channel.ReplyRef{MessageID: replyTo} } @@ -281,6 +317,140 @@ func (p *Executor) resolvePlatform(arguments map[string]any, session mcpgw.ToolS return p.resolver.ParseChannelType(platform) } +// --- attachment resolution --- + +// resolveAttachments converts raw attachment arguments (strings or objects) +// into channel.Attachment values, resolving container media paths when possible. +func (p *Executor) resolveAttachments(ctx context.Context, botID string, items []any) []channel.Attachment { + var result []channel.Attachment + for _, item := range items { + switch v := item.(type) { + case string: + if att := p.resolveAttachmentRef(ctx, botID, strings.TrimSpace(v), "", ""); att != nil { + result = append(result, *att) + } + case map[string]any: + path := mcpgw.FirstStringArg(v, "path") + url := mcpgw.FirstStringArg(v, "url") + attType := mcpgw.FirstStringArg(v, "type") + name := mcpgw.FirstStringArg(v, "name") + ref := path + if ref == "" { + ref = url + } + if ref == "" { + continue + } + if att := p.resolveAttachmentRef(ctx, botID, ref, attType, name); att != nil { + result = append(result, *att) + } + } + } + return result +} + +// resolveAttachmentRef resolves a single path or URL to a channel.Attachment. +func (p *Executor) resolveAttachmentRef(ctx context.Context, botID, ref, attType, name string) *channel.Attachment { + ref = strings.TrimSpace(ref) + if ref == "" { + return nil + } + lower := strings.ToLower(ref) + + // HTTP/HTTPS URL — pass through. + if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { + t := channel.AttachmentType(attType) + if t == "" { + t = inferAttachmentTypeFromExt(ref) + } + return &channel.Attachment{ + Type: t, + URL: ref, + Name: name, + } + } + + // Data URL — pass through. + if strings.HasPrefix(lower, "data:") { + t := channel.AttachmentType(attType) + if t == "" { + t = channel.AttachmentImage + } + return &channel.Attachment{ + Type: t, + Base64: ref, + Name: name, + } + } + + // Container media path — resolve via asset storage. + const mediaMarker = "/data/media/" + if idx := strings.Index(ref, mediaMarker); idx >= 0 && p.assetResolver != nil { + storageKey := ref[idx+len(mediaMarker):] + asset, err := p.assetResolver.GetByStorageKey(ctx, botID, storageKey) + if err == nil { + t := channel.AttachmentType(attType) + if t == "" { + t = inferAttachmentTypeFromMime(asset.Mime) + } + att := channel.Attachment{ + Type: t, + ContentHash: asset.ContentHash, + Mime: asset.Mime, + Size: asset.SizeBytes, + Name: name, + Metadata: map[string]any{ + "bot_id": botID, + "storage_key": asset.StorageKey, + }, + } + return &att + } + if p.logger != nil { + p.logger.Warn("resolve media path failed", slog.String("path", ref), slog.Any("error", err)) + } + } + + // Unknown container path — pass through with the path as URL. + t := channel.AttachmentType(attType) + if t == "" { + t = inferAttachmentTypeFromExt(ref) + } + return &channel.Attachment{ + Type: t, + URL: ref, + Name: name, + } +} + +func inferAttachmentTypeFromMime(mime string) channel.AttachmentType { + mime = strings.ToLower(strings.TrimSpace(mime)) + switch { + case strings.HasPrefix(mime, "image/"): + return channel.AttachmentImage + case strings.HasPrefix(mime, "audio/"): + return channel.AttachmentAudio + case strings.HasPrefix(mime, "video/"): + return channel.AttachmentVideo + default: + return channel.AttachmentFile + } +} + +func inferAttachmentTypeFromExt(path string) channel.AttachmentType { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg": + return channel.AttachmentImage + case ".mp3", ".wav", ".ogg", ".flac", ".aac": + return channel.AttachmentAudio + case ".mp4", ".webm", ".avi", ".mov": + return channel.AttachmentVideo + default: + return channel.AttachmentFile + } +} + 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 98a775d7..62998dc1 100644 --- a/internal/mcp/providers/message/provider_test.go +++ b/internal/mcp/providers/message/provider_test.go @@ -44,7 +44,7 @@ func (f *fakeResolver) ParseChannelType(raw string) (channel.ChannelType, error) // --- send tests --- func TestExecutor_ListTools_NilDeps(t *testing.T) { - exec := NewExecutor(nil, nil, nil, nil) + exec := NewExecutor(nil, nil, nil, nil, nil) tools, err := exec.ListTools(context.Background(), mcpgw.ToolSessionContext{}) if err != nil { t.Fatal(err) @@ -58,7 +58,7 @@ func TestExecutor_ListTools(t *testing.T) { sender := &fakeSender{} reactor := &fakeReactor{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, reactor, resolver) + exec := NewExecutor(nil, sender, reactor, resolver, nil) tools, err := exec.ListTools(context.Background(), mcpgw.ToolSessionContext{}) if err != nil { t.Fatal(err) @@ -77,7 +77,7 @@ func TestExecutor_ListTools(t *testing.T) { func TestExecutor_ListTools_OnlySender(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, nil, resolver) + exec := NewExecutor(nil, sender, nil, resolver, nil) tools, err := exec.ListTools(context.Background(), mcpgw.ToolSessionContext{}) if err != nil { t.Fatal(err) @@ -93,7 +93,7 @@ func TestExecutor_ListTools_OnlySender(t *testing.T) { func TestExecutor_CallTool_NotFound(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{ct: channel.ChannelType("feishu")} - exec := NewExecutor(nil, sender, nil, resolver) + exec := NewExecutor(nil, sender, nil, resolver, nil) _, err := exec.CallTool(context.Background(), mcpgw.ToolSessionContext{BotID: "bot1"}, "other_tool", nil) if err != mcpgw.ErrToolNotFound { t.Errorf("expected ErrToolNotFound, got %v", err) @@ -101,7 +101,7 @@ func TestExecutor_CallTool_NotFound(t *testing.T) { } func TestExecutor_CallTool_NilDeps(t *testing.T) { - exec := NewExecutor(nil, nil, nil, nil) + exec := NewExecutor(nil, nil, nil, nil, nil) result, err := exec.CallTool(context.Background(), mcpgw.ToolSessionContext{BotID: "bot1"}, toolSend, map[string]any{ "platform": "feishu", "target": "t1", "text": "hi", }) @@ -116,7 +116,7 @@ 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, nil, resolver) + exec := NewExecutor(nil, sender, nil, resolver, nil) result, err := exec.CallTool(context.Background(), mcpgw.ToolSessionContext{}, toolSend, map[string]any{ "platform": "feishu", "target": "t1", "text": "hi", }) @@ -131,7 +131,7 @@ 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, nil, resolver) + exec := NewExecutor(nil, sender, nil, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1"} result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "bot_id": "other", "platform": "feishu", "target": "t1", "text": "hi", @@ -147,7 +147,7 @@ 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, nil, resolver) + exec := NewExecutor(nil, sender, nil, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1"} result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "target": "t1", "text": "hi", @@ -163,7 +163,7 @@ 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, nil, resolver) + exec := NewExecutor(nil, sender, nil, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "feishu"} result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "platform": "bad", "target": "t1", "text": "hi", @@ -179,7 +179,7 @@ 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, nil, resolver) + exec := NewExecutor(nil, sender, nil, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1"} result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "platform": "feishu", "target": "t1", @@ -195,7 +195,7 @@ 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, nil, resolver) + exec := NewExecutor(nil, sender, nil, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1"} result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "platform": "feishu", "text": "hi", @@ -211,7 +211,7 @@ 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, nil, resolver) + exec := NewExecutor(nil, sender, nil, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1", ReplyTarget: "t1"} result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "platform": "feishu", "text": "hi", @@ -227,7 +227,7 @@ 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, nil, resolver) + exec := NewExecutor(nil, sender, nil, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "feishu", ReplyTarget: "chat1"} result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "text": "hello", @@ -253,7 +253,7 @@ 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) + exec := NewExecutor(nil, sender, nil, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "text": "reply text", @@ -276,7 +276,7 @@ func TestExecutor_CallTool_ReplyTo(t *testing.T) { func TestExecutor_CallTool_NoReplyTo(t *testing.T) { sender := &fakeSender{} resolver := &fakeResolver{ct: channel.ChannelType("telegram")} - exec := NewExecutor(nil, sender, nil, resolver) + exec := NewExecutor(nil, sender, nil, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} result, err := exec.CallTool(context.Background(), session, toolSend, map[string]any{ "text": "no reply", @@ -295,7 +295,7 @@ func TestExecutor_CallTool_NoReplyTo(t *testing.T) { // --- react tests --- func TestExecutor_React_NilReactor(t *testing.T) { - exec := NewExecutor(nil, nil, nil, nil) + exec := NewExecutor(nil, 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": "👍", }) @@ -310,7 +310,7 @@ func TestExecutor_React_NilReactor(t *testing.T) { func TestExecutor_React_NoMessageID(t *testing.T) { reactor := &fakeReactor{} resolver := &fakeResolver{ct: channel.ChannelType("telegram")} - exec := NewExecutor(nil, nil, reactor, resolver) + exec := NewExecutor(nil, nil, reactor, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} result, err := exec.CallTool(context.Background(), session, toolReact, map[string]any{ "emoji": "👍", @@ -326,7 +326,7 @@ func TestExecutor_React_NoMessageID(t *testing.T) { func TestExecutor_React_NoTarget(t *testing.T) { reactor := &fakeReactor{} resolver := &fakeResolver{ct: channel.ChannelType("telegram")} - exec := NewExecutor(nil, nil, reactor, resolver) + exec := NewExecutor(nil, nil, reactor, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram"} result, err := exec.CallTool(context.Background(), session, toolReact, map[string]any{ "message_id": "456", "emoji": "👍", @@ -342,7 +342,7 @@ func TestExecutor_React_NoTarget(t *testing.T) { func TestExecutor_React_Success(t *testing.T) { reactor := &fakeReactor{} resolver := &fakeResolver{ct: channel.ChannelType("telegram")} - exec := NewExecutor(nil, nil, reactor, resolver) + exec := NewExecutor(nil, nil, reactor, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} result, err := exec.CallTool(context.Background(), session, toolReact, map[string]any{ "message_id": "456", "emoji": "👍", @@ -371,7 +371,7 @@ func TestExecutor_React_Success(t *testing.T) { func TestExecutor_React_Remove(t *testing.T) { reactor := &fakeReactor{} resolver := &fakeResolver{ct: channel.ChannelType("telegram")} - exec := NewExecutor(nil, nil, reactor, resolver) + exec := NewExecutor(nil, nil, reactor, resolver, nil) 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, @@ -394,7 +394,7 @@ func TestExecutor_React_Remove(t *testing.T) { 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) + exec := NewExecutor(nil, nil, reactor, resolver, nil) session := mcpgw.ToolSessionContext{BotID: "bot1", CurrentPlatform: "telegram", ReplyTarget: "123"} result, err := exec.CallTool(context.Background(), session, toolReact, map[string]any{ "message_id": "456", "emoji": "👍",