mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
refactor: channel tools
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user