mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
204 lines
7.2 KiB
Go
204 lines
7.2 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
sdk "github.com/memohai/twilight-ai/sdk"
|
|
|
|
"github.com/memohai/memoh/internal/channel"
|
|
"github.com/memohai/memoh/internal/messaging"
|
|
)
|
|
|
|
type MessageProvider struct {
|
|
exec *messaging.Executor
|
|
}
|
|
|
|
func NewMessageProvider(log *slog.Logger, sender messaging.Sender, reactor messaging.Reactor, resolver messaging.ChannelTypeResolver, assetResolver messaging.AssetResolver) *MessageProvider {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return &MessageProvider{
|
|
exec: &messaging.Executor{
|
|
Sender: sender,
|
|
Reactor: reactor,
|
|
Resolver: resolver,
|
|
AssetResolver: assetResolver,
|
|
Logger: log.With(slog.String("tool", "message")),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (p *MessageProvider) Tools(_ context.Context, session SessionContext) ([]sdk.Tool, error) {
|
|
if session.IsSubagent {
|
|
return nil, nil
|
|
}
|
|
var tools []sdk.Tool
|
|
sess := session
|
|
if p.exec.CanSend() {
|
|
tools = append(tools, sdk.Tool{
|
|
Name: "send",
|
|
Description: "Send a message, file, or attachment. When target is omitted, delivers to the current conversation as an inline attachment/message. When target is specified, sends to that channel/person.",
|
|
Parameters: 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/thread ID). Optional — omit to send in the current conversation. Use get_contacts to find targets for other conversations."},
|
|
"text": map[string]any{"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."},
|
|
"attachments": map[string]any{"type": "array", "description": "File paths or URLs to attach.", "items": map[string]any{"type": "string"}},
|
|
"message": map[string]any{"type": "object", "description": "Structured message payload with text/parts/attachments"},
|
|
},
|
|
"required": []string{},
|
|
},
|
|
Execute: func(ctx *sdk.ToolExecContext, input any) (any, error) {
|
|
return p.execSend(ctx.Context, sess, inputAsMap(input))
|
|
},
|
|
})
|
|
}
|
|
if p.exec.CanReact() {
|
|
tools = append(tools, sdk.Tool{
|
|
Name: "react",
|
|
Description: "Add or remove an emoji reaction on a message. When target/platform are omitted, reacts in the current conversation.",
|
|
Parameters: 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"},
|
|
},
|
|
Execute: func(ctx *sdk.ToolExecContext, input any) (any, error) {
|
|
return p.execReact(ctx.Context, sess, inputAsMap(input))
|
|
},
|
|
})
|
|
}
|
|
return tools, nil
|
|
}
|
|
|
|
func (p *MessageProvider) execSend(ctx context.Context, session SessionContext, args map[string]any) (any, error) {
|
|
result, err := p.exec.Send(ctx, toMessagingSession(session), args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Discuss mode: same-conversation sends must go through the channel adapter
|
|
// directly — there is no active stream to emit into.
|
|
if result.Local && session.SessionType == "discuss" {
|
|
sendResult, err := p.exec.SendDirect(ctx, toMessagingSession(session), result.Target, args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp := map[string]any{
|
|
"ok": true, "bot_id": sendResult.BotID, "platform": sendResult.Platform, "target": sendResult.Target,
|
|
"delivered": "current_conversation",
|
|
}
|
|
if sendResult.MessageID != "" {
|
|
resp["message_id"] = sendResult.MessageID
|
|
}
|
|
return resp, nil
|
|
}
|
|
if result.Local && session.Emitter != nil {
|
|
atts := channelAttachmentsToToolAttachments(result.LocalAttachments)
|
|
if len(atts) > 0 {
|
|
session.Emitter(ToolStreamEvent{
|
|
Type: StreamEventAttachment,
|
|
Attachments: atts,
|
|
})
|
|
}
|
|
resp := map[string]any{
|
|
"ok": true,
|
|
"delivered": "current_conversation",
|
|
"attachments": len(atts),
|
|
}
|
|
if result.MessageID != "" {
|
|
resp["message_id"] = result.MessageID
|
|
}
|
|
return resp, nil
|
|
}
|
|
resp := map[string]any{
|
|
"ok": true, "bot_id": result.BotID, "platform": result.Platform, "target": result.Target,
|
|
}
|
|
if result.MessageID != "" {
|
|
resp["message_id"] = result.MessageID
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func channelAttachmentsToToolAttachments(atts []channel.Attachment) []Attachment {
|
|
if len(atts) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]Attachment, 0, len(atts))
|
|
for _, a := range atts {
|
|
result = append(result, Attachment{
|
|
Type: string(a.Type),
|
|
URL: a.URL,
|
|
Mime: a.Mime,
|
|
Name: a.Name,
|
|
ContentHash: a.ContentHash,
|
|
Size: a.Size,
|
|
Metadata: a.Metadata,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (p *MessageProvider) execReact(ctx context.Context, session SessionContext, args map[string]any) (any, error) {
|
|
// Check same-conversation before delegating to executor.
|
|
platform := FirstStringArg(args, "platform")
|
|
if platform == "" {
|
|
platform = strings.TrimSpace(session.CurrentPlatform)
|
|
}
|
|
target := FirstStringArg(args, "target")
|
|
if target == "" {
|
|
target = strings.TrimSpace(session.ReplyTarget)
|
|
}
|
|
if session.IsSameConversation(platform, target) && session.Emitter != nil {
|
|
messageID := FirstStringArg(args, "message_id")
|
|
emoji := FirstStringArg(args, "emoji")
|
|
remove, _, _ := BoolArg(args, "remove")
|
|
if messageID == "" {
|
|
return nil, nil
|
|
}
|
|
session.Emitter(ToolStreamEvent{
|
|
Type: StreamEventReaction,
|
|
Reactions: []Reaction{{
|
|
Emoji: emoji,
|
|
MessageID: messageID,
|
|
Remove: remove,
|
|
}},
|
|
})
|
|
action := "added"
|
|
if remove {
|
|
action = "removed"
|
|
}
|
|
return map[string]any{
|
|
"ok": true, "emoji": emoji, "action": action,
|
|
"delivered": "current_conversation",
|
|
}, nil
|
|
}
|
|
result, err := p.exec.React(ctx, toMessagingSession(session), args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"ok": true, "bot_id": result.BotID, "platform": result.Platform,
|
|
"target": result.Target, "message_id": result.MessageID, "emoji": result.Emoji, "action": result.Action,
|
|
}, nil
|
|
}
|
|
|
|
func toMessagingSession(s SessionContext) messaging.SessionContext {
|
|
return messaging.SessionContext{
|
|
BotID: s.BotID,
|
|
ChatID: s.ChatID,
|
|
CurrentPlatform: s.CurrentPlatform,
|
|
ReplyTarget: s.ReplyTarget,
|
|
}
|
|
}
|