Files
Memoh/internal/agent/tools/message.go
T

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,
}
}