feat: support attachment send to tool send

This commit is contained in:
Acbox
2026-02-20 22:04:00 +08:00
parent de5c3f47a4
commit 82cc9c357f
4 changed files with 233 additions and 42 deletions
+27 -2
View File
@@ -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
+2 -6
View File
@@ -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
+174 -4
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"strings"
"github.com/memohai/memoh/internal/channel"
@@ -31,17 +32,31 @@ 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
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()
}
@@ -49,6 +64,7 @@ func NewExecutor(log *slog.Logger, sender Sender, reactor Reactor, resolver Chan
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 {
// 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 {
+21 -21
View File
@@ -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": "👍",