mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
269 lines
9.0 KiB
Go
269 lines
9.0 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"mime"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/sessions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
)
|
|
|
|
// resolveAgentRoute determines which agent should handle a message
|
|
// based on config bindings. Priority: peer → channel → default.
|
|
// Matching TS resolve-route.ts binding resolution.
|
|
func resolveAgentRoute(cfg *config.Config, channel, chatID, peerKind string) string {
|
|
for _, binding := range cfg.Bindings {
|
|
if bindingMatchesInbound(binding, channel, chatID, peerKind) {
|
|
return config.NormalizeAgentID(binding.AgentID)
|
|
}
|
|
}
|
|
|
|
return cfg.ResolveDefaultAgentID()
|
|
}
|
|
|
|
type defaultAgentGetter interface {
|
|
GetDefault(ctx context.Context) (*store.AgentData, error)
|
|
}
|
|
|
|
func resolveAgentRouteForInbound(ctx context.Context, cfg *config.Config, agentStore defaultAgentGetter, channel, chatID, peerKind string) string {
|
|
if cfg == nil {
|
|
return config.DefaultAgentID
|
|
}
|
|
for _, binding := range cfg.Bindings {
|
|
if bindingMatchesInbound(binding, channel, chatID, peerKind) {
|
|
return config.NormalizeAgentID(binding.AgentID)
|
|
}
|
|
}
|
|
if agentStore != nil {
|
|
if ag, err := agentStore.GetDefault(ctx); err == nil && ag != nil && ag.AgentKey != "" {
|
|
return ag.AgentKey
|
|
}
|
|
}
|
|
return cfg.ResolveDefaultAgentID()
|
|
}
|
|
|
|
func bindingMatchesInbound(binding config.AgentBinding, channel, chatID, peerKind string) bool {
|
|
match := binding.Match
|
|
if match.Channel != channel {
|
|
return false
|
|
}
|
|
|
|
// Peer-level match (most specific)
|
|
if match.Peer != nil {
|
|
return match.Peer.Kind == peerKind && match.Peer.ID == chatID
|
|
}
|
|
|
|
// Channel-level match (least specific, no peer constraint)
|
|
return true
|
|
}
|
|
|
|
func inboundMessageTenantContext(ctx context.Context, msg bus.InboundMessage) context.Context {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if msg.TenantID != uuid.Nil {
|
|
return store.WithTenantID(ctx, msg.TenantID)
|
|
}
|
|
return store.WithTenantID(ctx, store.MasterTenantID)
|
|
}
|
|
|
|
// overrideSessionKeyFromLocalKey extracts topic/thread ID from the composite
|
|
// local_key and returns the correct session key for forum topics or DM threads.
|
|
// If localKey is empty or has no suffix, the original sessionKey is returned unchanged.
|
|
func overrideSessionKeyFromLocalKey(sessionKey, localKey, agentID, channel, chatID, peerKind string) string {
|
|
if localKey == "" {
|
|
return sessionKey
|
|
}
|
|
if idx := strings.Index(localKey, ":topic:"); idx > 0 && peerKind == string(sessions.PeerGroup) {
|
|
var topicID int
|
|
fmt.Sscanf(localKey[idx+7:], "%d", &topicID)
|
|
if topicID > 0 {
|
|
return sessions.BuildGroupTopicSessionKey(agentID, channel, chatID, topicID)
|
|
}
|
|
} else if idx := strings.Index(localKey, ":thread:"); idx > 0 && peerKind == string(sessions.PeerDirect) {
|
|
var threadID int
|
|
fmt.Sscanf(localKey[idx+8:], "%d", &threadID)
|
|
if threadID > 0 {
|
|
return sessions.BuildDMThreadSessionKey(agentID, channel, chatID, threadID)
|
|
}
|
|
}
|
|
return sessionKey
|
|
}
|
|
|
|
// extractSessionMetadata builds a metadata map from channel InboundMessage metadata.
|
|
// Used to persist friendly names (display_name, username, chat_title) into sessions
|
|
// and user profiles so the web UI can show human-readable labels.
|
|
func extractSessionMetadata(msg bus.InboundMessage, peerKind string) map[string]string {
|
|
meta := make(map[string]string)
|
|
|
|
// Display name: prefer first_name (Telegram), fall back to display_name (Discord)
|
|
if v := msg.Metadata["first_name"]; v != "" {
|
|
meta["display_name"] = v
|
|
} else if v := msg.Metadata["display_name"]; v != "" {
|
|
meta["display_name"] = v
|
|
}
|
|
|
|
if v := msg.Metadata[tools.MetaUsername]; v != "" {
|
|
meta[tools.MetaUsername] = v
|
|
}
|
|
if peerKind != "" {
|
|
meta["peer_kind"] = peerKind
|
|
}
|
|
if v := msg.Metadata[tools.MetaChatTitle]; v != "" {
|
|
meta[tools.MetaChatTitle] = v
|
|
}
|
|
|
|
if len(meta) == 0 {
|
|
return nil
|
|
}
|
|
return meta
|
|
}
|
|
|
|
// resolveGroupDisplayTitle fills presentation context for internally
|
|
// re-ingressed group messages that no longer carry inbound chat metadata.
|
|
// It leaves routing identifiers untouched and silently falls back when a
|
|
// channel cannot resolve a platform-specific display title.
|
|
func resolveGroupDisplayTitle(ctx context.Context, mgr *channels.Manager, channel, chatID, peerKind, title string) string {
|
|
if title != "" || peerKind != string(sessions.PeerGroup) || mgr == nil || channel == "" || chatID == "" {
|
|
return title
|
|
}
|
|
resolved, err := mgr.ResolveGroupDisplayTitle(ctx, channel, chatID)
|
|
if err != nil {
|
|
return title
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func resolveInboundChatTitle(ctx context.Context, mgr *channels.Manager, msg bus.InboundMessage, peerKind string) string {
|
|
title := msg.Metadata[tools.MetaChatTitle]
|
|
if !bus.IsInternalSender(msg.SenderID) {
|
|
return title
|
|
}
|
|
return resolveGroupDisplayTitle(ctx, mgr, msg.Channel, msg.ChatID, peerKind, title)
|
|
}
|
|
|
|
// buildPancakeSessionLabel returns "Pancake:{senderName}:{pageName}" with non-empty parts only.
|
|
func buildPancakeSessionLabel(senderName, pageName string) string {
|
|
label := "Pancake"
|
|
if senderName != "" {
|
|
label += ":" + senderName
|
|
}
|
|
if pageName != "" {
|
|
label += ":" + pageName
|
|
}
|
|
return label
|
|
}
|
|
|
|
// buildAnnounceOutMeta builds outbound metadata for announce messages so that
|
|
// Send() can route replies to the correct forum topic or DM thread.
|
|
func buildAnnounceOutMeta(localKey string) map[string]string {
|
|
if localKey == "" {
|
|
return nil
|
|
}
|
|
meta := map[string]string{"local_key": localKey}
|
|
if idx := strings.Index(localKey, ":topic:"); idx > 0 {
|
|
meta[tools.MetaMessageThreadID] = localKey[idx+7:]
|
|
} else if idx := strings.Index(localKey, ":thread:"); idx > 0 {
|
|
meta[tools.MetaMessageThreadID] = localKey[idx+8:]
|
|
}
|
|
return meta
|
|
}
|
|
|
|
// mediaToMarkdown converts media results to markdown image/link syntax using the
|
|
// /v1/files/ HTTP endpoint. Used for WS channel where outbound media attachments
|
|
// are not supported (no channel handler). Returns empty string if no media.
|
|
// Uses absolute file paths with the /v1/files endpoint (auth-token protected).
|
|
// Generates relative URLs (/v1/files/...) so they work regardless of the server's
|
|
// external hostname — the browser resolves them from the current origin.
|
|
func mediaToMarkdown(media []agent.MediaResult, cfg *config.Config) string {
|
|
if len(media) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var parts []string
|
|
for _, mr := range media {
|
|
cleanPath := filepath.Clean(mr.Path)
|
|
// Strip leading "/" so URL path is /v1/files/app/.goclaw/...
|
|
urlPath := strings.TrimPrefix(cleanPath, "/")
|
|
if urlPath == "" {
|
|
continue
|
|
}
|
|
// Store clean path only — no auth tokens in persisted session messages.
|
|
// Frontend adds auth (Bearer header or ?ft= signed token) at render time.
|
|
// Guard: if path is already a /v1/ URL (e.g. from mutated media), don't double-prefix.
|
|
var fileURL string
|
|
if strings.HasPrefix(urlPath, "v1/files/") || strings.HasPrefix(urlPath, "v1/media/") {
|
|
fileURL = "/" + strings.SplitN(urlPath, "?", 2)[0] // strip any existing query params
|
|
} else {
|
|
fileURL = "/v1/files/" + urlPath
|
|
}
|
|
if strings.HasPrefix(mr.ContentType, "image/") {
|
|
parts = append(parts, fmt.Sprintf("", fileURL))
|
|
} else {
|
|
parts = append(parts, fmt.Sprintf("[%s](%s)", filepath.Base(mr.Path), fileURL))
|
|
}
|
|
}
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return "\n\n" + strings.Join(parts, "\n")
|
|
}
|
|
|
|
// mediaToMarkdownFromPaths is like mediaToMarkdown but accepts raw file paths
|
|
// ([]string from bus.InboundMessage.Media) instead of []agent.MediaResult.
|
|
func mediaToMarkdownFromPaths(files []bus.MediaFile, cfg *config.Config) string {
|
|
if len(files) == 0 {
|
|
return ""
|
|
}
|
|
media := make([]agent.MediaResult, 0, len(files))
|
|
for _, f := range files {
|
|
ct := f.MimeType
|
|
if ct == "" {
|
|
ct = mime.TypeByExtension(filepath.Ext(f.Path))
|
|
}
|
|
if ct == "" {
|
|
ct = "application/octet-stream"
|
|
}
|
|
media = append(media, agent.MediaResult{
|
|
Path: f.Path,
|
|
ContentType: ct,
|
|
})
|
|
}
|
|
return mediaToMarkdown(media, cfg)
|
|
}
|
|
|
|
// resolveChannelType returns the platform type for a channel instance name.
|
|
// Returns empty string if channelMgr is nil or channel name is empty.
|
|
func resolveChannelType(channelMgr *channels.Manager, name string) string {
|
|
if channelMgr == nil || name == "" {
|
|
return ""
|
|
}
|
|
return channelMgr.ChannelTypeForName(name)
|
|
}
|
|
|
|
// resolveSenderName extracts the sender display name from channel metadata.
|
|
// Checks "sender_name" (Feishu), "first_name" (Telegram), "user_name" (WhatsApp).
|
|
// Sanitizes to prevent prompt injection via newlines/control chars.
|
|
func resolveSenderName(msg bus.InboundMessage) string {
|
|
for _, key := range []string{"sender_name", "first_name", "user_name", "push_name", "display_name"} {
|
|
if name := msg.Metadata[key]; name != "" {
|
|
clean := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(strings.TrimSpace(name))
|
|
if len([]rune(clean)) > 100 {
|
|
clean = string([]rune(clean)[:100])
|
|
}
|
|
return clean
|
|
}
|
|
}
|
|
return ""
|
|
}
|