mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 06:10:46 +00:00
967f7ae46f
Extract helper functions and move existing functions to dedicated files: - cmd/gateway.go → gateway_channels_setup.go (channel registration, RPC wiring, event subscribers) - cmd/gateway_consumer.go → gateway_consumer_helpers.go, gateway_consumer_process.go - cmd/gateway_managed.go → gateway_http_handlers.go (wireHTTP) - cmd/onboard.go → onboard_resolve.go (API key resolution helpers) - internal/agent/loop.go → loop_run.go (Run entry point) No logic changes — only code movement between files within the same package.
176 lines
5.6 KiB
Go
176 lines
5.6 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"mime"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"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"
|
|
)
|
|
|
|
// 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 {
|
|
match := binding.Match
|
|
if match.Channel != channel {
|
|
continue
|
|
}
|
|
|
|
// Peer-level match (most specific)
|
|
if match.Peer != nil {
|
|
if match.Peer.Kind == peerKind && match.Peer.ID == chatID {
|
|
return config.NormalizeAgentID(binding.AgentID)
|
|
}
|
|
continue // has peer constraint but doesn't match — skip
|
|
}
|
|
|
|
// Channel-level match (least specific, no peer constraint)
|
|
return config.NormalizeAgentID(binding.AgentID)
|
|
}
|
|
|
|
return cfg.ResolveDefaultAgentID()
|
|
}
|
|
|
|
// 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["username"]; v != "" {
|
|
meta["username"] = v
|
|
}
|
|
if peerKind != "" {
|
|
meta["peer_kind"] = peerKind
|
|
}
|
|
if v := msg.Metadata["chat_title"]; v != "" {
|
|
meta["chat_title"] = v
|
|
}
|
|
|
|
if len(meta) == 0 {
|
|
return nil
|
|
}
|
|
return meta
|
|
}
|
|
|
|
// 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["message_thread_id"] = localKey[idx+7:]
|
|
} else if idx := strings.Index(localKey, ":thread:"); idx > 0 {
|
|
meta["message_thread_id"] = 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 ""
|
|
}
|
|
|
|
tokenQuery := ""
|
|
if cfg.Gateway.Token != "" {
|
|
tokenQuery = "?token=" + cfg.Gateway.Token
|
|
}
|
|
|
|
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
|
|
}
|
|
fileURL := "/v1/files/" + urlPath + tokenQuery
|
|
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)
|
|
}
|