From e85624ce96b8e513d757323928d651f1962ca8e8 Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 9 Mar 2026 10:41:54 +0700 Subject: [PATCH] refactor: split large Go files into smaller modules for maintainability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split 11 Go files exceeding 500 lines into smaller, focused files: - channels/manager.go → manager.go + dispatch.go + runs.go + events.go - channels/slack/channel.go → channel.go + send.go + utils.go - channels/slack/handlers.go → handlers.go + handlers_mention.go + handlers_files.go - channels/telegram/handlers.go → handlers.go + handlers_utils.go - channels/zalo/personal/channel.go → channel.go + send.go + listen.go + handlers.go - gateway/methods/agents.go → agents.go + agents_create.go + agents_update.go + agents_delete.go - http/summoner.go → summoner.go + summoner_regenerate.go + summoner_prompts.go + summoner_utils.go - http/skills.go → skills.go + skills_upload.go + skills_versions.go + skills_grants.go - http/mcp.go → mcp.go + mcp_tools.go + mcp_grants.go + mcp_requests.go - store/pg/cron.go → cron.go + cron_crud.go + cron_update.go + cron_exec.go No logic changes — pure file reorganization to keep files under 200 lines. --- internal/channels/dispatch.go | 178 +++++++ internal/channels/events.go | 182 +++++++ internal/channels/manager.go | 410 +-------------- internal/channels/runs.go | 54 ++ internal/channels/slack/channel.go | 207 +------- internal/channels/slack/handlers.go | 416 --------------- internal/channels/slack/handlers_files.go | 229 +++++++++ internal/channels/slack/handlers_mention.go | 210 ++++++++ internal/channels/slack/send.go | 120 +++++ internal/channels/slack/utils.go | 102 ++++ internal/channels/telegram/handlers.go | 74 --- internal/channels/telegram/handlers_utils.go | 82 +++ internal/channels/zalo/personal/channel.go | 420 ---------------- internal/channels/zalo/personal/handlers.go | 218 ++++++++ internal/channels/zalo/personal/listen.go | 129 +++++ internal/channels/zalo/personal/send.go | 107 ++++ internal/gateway/methods/agents.go | 382 -------------- internal/gateway/methods/agents_create.go | 164 ++++++ internal/gateway/methods/agents_delete.go | 97 ++++ internal/gateway/methods/agents_update.go | 159 ++++++ internal/http/mcp.go | 330 ------------ internal/http/mcp_grants.go | 178 +++++++ internal/http/mcp_requests.go | 85 ++++ internal/http/mcp_tools.go | 92 ++++ internal/http/skills.go | 393 --------------- internal/http/skills_grants.go | 19 + internal/http/skills_upload.go | 183 +++++++ internal/http/skills_versions.go | 212 ++++++++ internal/http/summoner.go | 504 ------------------- internal/http/summoner_prompts.go | 257 ++++++++++ internal/http/summoner_regenerate.go | 218 ++++++++ internal/http/summoner_utils.go | 57 +++ internal/store/pg/cron.go | 429 ---------------- internal/store/pg/cron_crud.go | 161 ++++++ internal/store/pg/cron_exec.go | 111 ++++ internal/store/pg/cron_update.go | 185 +++++++ 36 files changed, 3800 insertions(+), 3554 deletions(-) create mode 100644 internal/channels/dispatch.go create mode 100644 internal/channels/events.go create mode 100644 internal/channels/runs.go create mode 100644 internal/channels/slack/handlers_files.go create mode 100644 internal/channels/slack/handlers_mention.go create mode 100644 internal/channels/slack/send.go create mode 100644 internal/channels/slack/utils.go create mode 100644 internal/channels/telegram/handlers_utils.go create mode 100644 internal/channels/zalo/personal/handlers.go create mode 100644 internal/channels/zalo/personal/listen.go create mode 100644 internal/channels/zalo/personal/send.go create mode 100644 internal/gateway/methods/agents_create.go create mode 100644 internal/gateway/methods/agents_delete.go create mode 100644 internal/gateway/methods/agents_update.go create mode 100644 internal/http/mcp_grants.go create mode 100644 internal/http/mcp_requests.go create mode 100644 internal/http/mcp_tools.go create mode 100644 internal/http/skills_upload.go create mode 100644 internal/http/skills_versions.go create mode 100644 internal/http/summoner_prompts.go create mode 100644 internal/http/summoner_regenerate.go create mode 100644 internal/http/summoner_utils.go create mode 100644 internal/store/pg/cron_crud.go create mode 100644 internal/store/pg/cron_exec.go create mode 100644 internal/store/pg/cron_update.go diff --git a/internal/channels/dispatch.go b/internal/channels/dispatch.go new file mode 100644 index 00000000..8cbc3be8 --- /dev/null +++ b/internal/channels/dispatch.go @@ -0,0 +1,178 @@ +package channels + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "regexp" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// WebhookRoute holds a path and handler pair for mounting on the main gateway mux. +type WebhookRoute struct { + Path string + Handler http.Handler +} + +// dispatchOutbound consumes outbound messages from the bus and routes them +// to the appropriate channel. Internal channels are silently skipped. +func (m *Manager) dispatchOutbound(ctx context.Context) { + slog.Info("outbound dispatcher started") + + for { + select { + case <-ctx.Done(): + slog.Info("outbound dispatcher stopped") + return + default: + msg, ok := m.bus.SubscribeOutbound(ctx) + if !ok { + continue + } + + // Skip internal channels + if IsInternalChannel(msg.Channel) { + continue + } + + m.mu.RLock() + channel, exists := m.channels[msg.Channel] + m.mu.RUnlock() + + if !exists { + slog.Warn("unknown channel for outbound message", "channel", msg.Channel) + continue + } + + if err := channel.Send(ctx, msg); err != nil { + slog.Error("error sending message to channel", + "channel", msg.Channel, + "error", err, + ) + // Try to send a text-only error notification back to the chat. + // Only for media failures — text-only failures likely mean the chat + // is inaccessible (kicked, blocked, etc.) so retrying won't help. + if len(msg.Media) > 0 { + notifyMsg := bus.OutboundMessage{ + Channel: msg.Channel, + ChatID: msg.ChatID, + Content: formatChannelSendError(err), + Metadata: sendErrorMeta(msg.Metadata), + } + if err2 := channel.Send(ctx, notifyMsg); err2 != nil { + slog.Warn("failed to send error notification", + "channel", msg.Channel, "error", err2) + } + } + } + + // Clean up temporary media files after successful (or failed) send. + // Files are created by tools (create_image, tts) and only needed for the send. + for _, media := range msg.Media { + if media.URL != "" { + if err := os.Remove(media.URL); err != nil { + slog.Debug("failed to clean up media file", "path", media.URL, "error", err) + } + } + } + } + } +} + +// WebhookHandlers returns all webhook handlers from channels that implement WebhookChannel. +// Used to mount webhook routes on the main gateway mux. +func (m *Manager) WebhookHandlers() []WebhookRoute { + m.mu.RLock() + defer m.mu.RUnlock() + + var routes []WebhookRoute + for _, ch := range m.channels { + if wh, ok := ch.(WebhookChannel); ok { + if path, handler := wh.WebhookHandler(); path != "" && handler != nil { + routes = append(routes, WebhookRoute{Path: path, Handler: handler}) + } + } + } + return routes +} + +// SendToChannel delivers a message to a specific channel by name. +func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { + m.mu.RLock() + channel, exists := m.channels[channelName] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("channel %s not found", channelName) + } + + msg := bus.OutboundMessage{ + Channel: channelName, + ChatID: chatID, + Content: content, + } + + return channel.Send(ctx, msg) +} + +// --- Send error notification helpers --- + +// telegramAPIDescRe extracts the human-readable description from Telegram Bot API errors. +// Example: `telego: sendPhoto: api: 400 "Bad Request: not enough rights to send photos to the chat"` +// +// → "not enough rights to send photos to the chat" +var telegramAPIDescRe = regexp.MustCompile(`"Bad Request:\s*(.+?)"`) + +// formatChannelSendError converts a channel.Send error into a user-friendly message. +// Never exposes raw library/HTTP details. +func formatChannelSendError(err error) string { + raw := err.Error() + lower := strings.ToLower(raw) + + // Telegram "Bad Request: " — extract description + if m := telegramAPIDescRe.FindStringSubmatch(raw); len(m) == 2 { + return fmt.Sprintf("⚠️ Send failed: %s", m[1]) + } + + // Common Telegram API errors (non-Bad Request) + switch { + case strings.Contains(lower, "not enough rights"): + return "⚠️ Send failed: bot doesn't have permission to send this type of message." + case strings.Contains(lower, "chat not found"): + return "⚠️ Send failed: chat not found." + case strings.Contains(lower, "bot was blocked"): + return "⚠️ Send failed: bot was blocked by the user." + case strings.Contains(lower, "user is deactivated"): + return "⚠️ Send failed: user account is deactivated." + case strings.Contains(lower, "too many requests") || strings.Contains(lower, "flood"): + return "⚠️ Send failed: rate limited by Telegram. Please try again later." + case strings.Contains(lower, "file is too big") || strings.Contains(lower, "wrong file"): + return "⚠️ Send failed: file is too large or invalid for Telegram." + } + + // Generic fallback — don't expose internals + return "⚠️ Failed to deliver message. Check bot logs for details." +} + +// sendErrorMeta copies only the routing fields from outbound metadata. +// Strips reply_to_message_id, placeholder_key, audio_as_voice, etc. +// that could cause unintended side effects on the error notification. +func sendErrorMeta(orig map[string]string) map[string]string { + if orig == nil { + return nil + } + meta := make(map[string]string) + for _, k := range []string{"local_key", "message_thread_id"} { + if v := orig[k]; v != "" { + meta[k] = v + } + } + if len(meta) == 0 { + return nil + } + return meta +} diff --git a/internal/channels/events.go b/internal/channels/events.go new file mode 100644 index 00000000..7f227df4 --- /dev/null +++ b/internal/channels/events.go @@ -0,0 +1,182 @@ +package channels + +import ( + "context" + "fmt" + "log/slog" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// HandleAgentEvent routes agent lifecycle events to streaming/reaction channels. +// Called from the bus event subscriber — must be non-blocking. +// eventType: "run.started", "chunk", "tool.call", "tool.result", "run.completed", "run.failed" +func (m *Manager) HandleAgentEvent(eventType, runID string, payload interface{}) { + val, ok := m.runs.Load(runID) + if !ok { + return + } + rc := val.(*RunContext) + + m.mu.RLock() + ch, exists := m.channels[rc.ChannelName] + m.mu.RUnlock() + if !exists { + return + } + + ctx := context.Background() + + // Forward to StreamingChannel + if sc, ok := ch.(StreamingChannel); ok { + switch eventType { + case protocol.AgentEventRunStarted: + if err := sc.OnStreamStart(ctx, rc.ChatID); err != nil { + slog.Debug("stream start failed", "channel", rc.ChannelName, "error", err) + } + case protocol.AgentEventToolCall: + // Agent is executing a tool — mark tool phase so the next chunk + // (new LLM iteration) resets the stream buffer. + // Also clear the current DraftStream so the next iteration starts + // a fresh streaming message (matching TS onAssistantMessageStart pattern). + rc.mu.Lock() + rc.inToolPhase = true + rc.mu.Unlock() + if err := sc.OnStreamEnd(ctx, rc.ChatID, ""); err != nil { + slog.Debug("stream tool-phase end failed", "channel", rc.ChannelName, "error", err) + } + case protocol.ChatEventChunk: + // Accumulate chunk deltas into full text. + // When entering a new LLM iteration (first chunk after tool.call), + // reset the buffer so we don't concatenate text from previous iterations. + content := extractPayloadString(payload, "content") + if content != "" { + rc.mu.Lock() + if rc.inToolPhase { + // New LLM iteration — reset buffer and start fresh stream + rc.streamBuffer = "" + rc.inToolPhase = false + rc.mu.Unlock() + // Create new DraftStream for this iteration + if err := sc.OnStreamStart(ctx, rc.ChatID); err != nil { + slog.Debug("stream restart failed", "channel", rc.ChannelName, "error", err) + } + rc.mu.Lock() + } + rc.streamBuffer += content + fullText := rc.streamBuffer + rc.mu.Unlock() + if err := sc.OnChunkEvent(ctx, rc.ChatID, fullText); err != nil { + slog.Debug("stream chunk failed", "channel", rc.ChannelName, "error", err) + } + } + case protocol.AgentEventRunCompleted: + rc.mu.Lock() + finalText := rc.streamBuffer + rc.mu.Unlock() + if err := sc.OnStreamEnd(ctx, rc.ChatID, finalText); err != nil { + slog.Debug("stream end failed", "channel", rc.ChannelName, "error", err) + } + case protocol.AgentEventRunFailed: + // Clean up streaming state + _ = sc.OnStreamEnd(ctx, rc.ChatID, "") + } + } + + // Handle block.reply: deliver intermediate assistant text to non-streaming channels. + // Gated by BlockReplyEnabled (resolved from gateway + per-channel config at RegisterRun time). + // Streaming channels already deliver via chunks, so skip to avoid double-delivery. + if eventType == protocol.AgentEventBlockReply { + if !rc.BlockReplyEnabled { + return + } + content := extractPayloadString(payload, "content") + if content == "" { + return + } + rc.mu.Lock() + streaming := rc.Streaming + rc.mu.Unlock() + + if streaming { + return // streaming already delivered via chunks + } + + // Build outbound metadata: copy routing fields but strip reply_to_message_id + // (block replies are standalone) and placeholder_key (reserve for final message). + var outMeta map[string]string + if rc.Metadata != nil { + outMeta = make(map[string]string) + for _, k := range []string{"message_thread_id", "local_key", "group_id"} { + if v := rc.Metadata[k]; v != "" { + outMeta[k] = v + } + } + if len(outMeta) == 0 { + outMeta = nil + } + } + + m.bus.PublishOutbound(bus.OutboundMessage{ + Channel: rc.ChannelName, + ChatID: rc.ChatID, + Content: content, + Metadata: outMeta, + }) + return + } + + // Handle LLM retry: update placeholder to notify user + if eventType == protocol.AgentEventRunRetrying { + attempt := extractPayloadString(payload, "attempt") + maxAttempts := extractPayloadString(payload, "maxAttempts") + retryMsg := fmt.Sprintf("Provider busy, retrying... (%s/%s)", attempt, maxAttempts) + m.bus.PublishOutbound(bus.OutboundMessage{ + Channel: rc.ChannelName, + ChatID: rc.ChatID, + Content: retryMsg, + Metadata: map[string]string{ + "placeholder_update": "true", + }, + }) + } + + // Forward to ReactionChannel + if reactionCh, ok := ch.(ReactionChannel); ok { + status := "" + switch eventType { + case protocol.AgentEventRunStarted: + status = "thinking" + case protocol.AgentEventToolCall: + status = "tool" + case protocol.AgentEventRunCompleted: + status = "done" + case protocol.AgentEventRunFailed: + status = "error" + } + if status != "" { + if err := reactionCh.OnReactionEvent(ctx, rc.ChatID, rc.MessageID, status); err != nil { + slog.Debug("reaction event failed", "channel", rc.ChannelName, "status", status, "error", err) + } + } + } + + // Clean up on terminal events + if eventType == protocol.AgentEventRunCompleted || eventType == protocol.AgentEventRunFailed { + m.runs.Delete(runID) + } +} + +// extractPayloadString extracts a string field from a payload (map[string]string or map[string]interface{}). +func extractPayloadString(payload interface{}, key string) string { + switch p := payload.(type) { + case map[string]string: + return p[key] + case map[string]interface{}: + if v, ok := p[key].(string); ok { + return v + } + } + return "" +} diff --git a/internal/channels/manager.go b/internal/channels/manager.go index 91187eaa..f7ad0da4 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -2,29 +2,23 @@ package channels import ( "context" - "fmt" "log/slog" - "net/http" - "os" - "regexp" - "strings" "sync" "github.com/nextlevelbuilder/goclaw/internal/bus" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // RunContext tracks an active agent run for streaming/reaction event forwarding. type RunContext struct { - ChannelName string - ChatID string - MessageID string // platform message ID (string to support Feishu "om_xxx", Telegram "12345", etc.) - Metadata map[string]string // outbound routing metadata (thread_id, local_key, group_id) + ChannelName string + ChatID string + MessageID string // platform message ID (string to support Feishu "om_xxx", Telegram "12345", etc.) + Metadata map[string]string // outbound routing metadata (thread_id, local_key, group_id) Streaming bool // whether run uses streaming (to avoid double-delivery of block replies) BlockReplyEnabled bool // whether block.reply delivery is enabled for this run (resolved at RegisterRun time) - mu sync.Mutex - streamBuffer string // accumulated streaming text (chunks are deltas) - inToolPhase bool // true after tool.call, reset on next chunk (new LLM iteration) + mu sync.Mutex + streamBuffer string // accumulated streaming text (chunks are deltas) + inToolPhase bool // true after tool.call, reset on next chunk (new LLM iteration) } // Manager manages all registered channels, handling their lifecycle @@ -103,71 +97,6 @@ func (m *Manager) StopAll(ctx context.Context) error { return nil } -// dispatchOutbound consumes outbound messages from the bus and routes them -// to the appropriate channel. Internal channels are silently skipped. -func (m *Manager) dispatchOutbound(ctx context.Context) { - slog.Info("outbound dispatcher started") - - for { - select { - case <-ctx.Done(): - slog.Info("outbound dispatcher stopped") - return - default: - msg, ok := m.bus.SubscribeOutbound(ctx) - if !ok { - continue - } - - // Skip internal channels - if IsInternalChannel(msg.Channel) { - continue - } - - m.mu.RLock() - channel, exists := m.channels[msg.Channel] - m.mu.RUnlock() - - if !exists { - slog.Warn("unknown channel for outbound message", "channel", msg.Channel) - continue - } - - if err := channel.Send(ctx, msg); err != nil { - slog.Error("error sending message to channel", - "channel", msg.Channel, - "error", err, - ) - // Try to send a text-only error notification back to the chat. - // Only for media failures — text-only failures likely mean the chat - // is inaccessible (kicked, blocked, etc.) so retrying won't help. - if len(msg.Media) > 0 { - notifyMsg := bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: formatChannelSendError(err), - Metadata: sendErrorMeta(msg.Metadata), - } - if err2 := channel.Send(ctx, notifyMsg); err2 != nil { - slog.Warn("failed to send error notification", - "channel", msg.Channel, "error", err2) - } - } - } - - // Clean up temporary media files after successful (or failed) send. - // Files are created by tools (create_image, tts) and only needed for the send. - for _, media := range msg.Media { - if media.URL != "" { - if err := os.Remove(media.URL); err != nil { - slog.Debug("failed to clean up media file", "path", media.URL, "error", err) - } - } - } - } - } -} - // GetChannel returns a channel by name. func (m *Manager) GetChannel(name string) (Channel, bool) { m.mu.RLock() @@ -227,328 +156,3 @@ func (m *Manager) UnregisterChannel(name string) { defer m.mu.Unlock() delete(m.channels, name) } - -// WebhookRoute holds a path and handler pair for mounting on the main gateway mux. -type WebhookRoute struct { - Path string - Handler http.Handler -} - -// WebhookHandlers returns all webhook handlers from channels that implement WebhookChannel. -// Used to mount webhook routes on the main gateway mux. -func (m *Manager) WebhookHandlers() []WebhookRoute { - m.mu.RLock() - defer m.mu.RUnlock() - - var routes []WebhookRoute - for _, ch := range m.channels { - if wh, ok := ch.(WebhookChannel); ok { - if path, handler := wh.WebhookHandler(); path != "" && handler != nil { - routes = append(routes, WebhookRoute{Path: path, Handler: handler}) - } - } - } - return routes -} - -// SendToChannel delivers a message to a specific channel by name. -func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { - m.mu.RLock() - channel, exists := m.channels[channelName] - m.mu.RUnlock() - - if !exists { - return fmt.Errorf("channel %s not found", channelName) - } - - msg := bus.OutboundMessage{ - Channel: channelName, - ChatID: chatID, - Content: content, - } - - return channel.Send(ctx, msg) -} - -// --- Run tracking for streaming/reaction event forwarding --- - -// RegisterRun associates a run ID with a channel context so agent events -// (chunks, tool calls, completion) can be forwarded to the originating channel. -func (m *Manager) RegisterRun(runID, channelName, chatID, messageID string, metadata map[string]string, streaming, blockReply bool) { - m.runs.Store(runID, &RunContext{ - ChannelName: channelName, - ChatID: chatID, - MessageID: messageID, - Metadata: metadata, - Streaming: streaming, - BlockReplyEnabled: blockReply, - }) -} - -// UnregisterRun removes a run tracking entry. -func (m *Manager) UnregisterRun(runID string) { - m.runs.Delete(runID) -} - -// IsStreamingChannel checks if a named channel implements StreamingChannel -// AND has streaming currently enabled for the given chat type. -// isGroup: true for group chats, false for DMs. -func (m *Manager) IsStreamingChannel(channelName string, isGroup bool) bool { - m.mu.RLock() - ch, exists := m.channels[channelName] - m.mu.RUnlock() - if !exists { - return false - } - sc, ok := ch.(StreamingChannel) - if !ok { - return false - } - return sc.StreamEnabled(isGroup) -} - -// ResolveBlockReply checks per-channel override, falls back to gateway default. -// Returns true only if block.reply delivery should be enabled for this channel. -func (m *Manager) ResolveBlockReply(channelName string, globalDefault *bool) bool { - m.mu.RLock() - ch, exists := m.channels[channelName] - m.mu.RUnlock() - if exists { - if bc, ok := ch.(BlockReplyChannel); ok { - if v := bc.BlockReplyEnabled(); v != nil { - return *v - } - } - } - return globalDefault != nil && *globalDefault -} - -// HandleAgentEvent routes agent lifecycle events to streaming/reaction channels. -// Called from the bus event subscriber — must be non-blocking. -// eventType: "run.started", "chunk", "tool.call", "tool.result", "run.completed", "run.failed" -func (m *Manager) HandleAgentEvent(eventType, runID string, payload interface{}) { - val, ok := m.runs.Load(runID) - if !ok { - return - } - rc := val.(*RunContext) - - m.mu.RLock() - ch, exists := m.channels[rc.ChannelName] - m.mu.RUnlock() - if !exists { - return - } - - ctx := context.Background() - - // Forward to StreamingChannel - if sc, ok := ch.(StreamingChannel); ok { - switch eventType { - case protocol.AgentEventRunStarted: - if err := sc.OnStreamStart(ctx, rc.ChatID); err != nil { - slog.Debug("stream start failed", "channel", rc.ChannelName, "error", err) - } - case protocol.AgentEventToolCall: - // Agent is executing a tool — mark tool phase so the next chunk - // (new LLM iteration) resets the stream buffer. - // Also clear the current DraftStream so the next iteration starts - // a fresh streaming message (matching TS onAssistantMessageStart pattern). - rc.mu.Lock() - rc.inToolPhase = true - rc.mu.Unlock() - if err := sc.OnStreamEnd(ctx, rc.ChatID, ""); err != nil { - slog.Debug("stream tool-phase end failed", "channel", rc.ChannelName, "error", err) - } - case protocol.ChatEventChunk: - // Accumulate chunk deltas into full text. - // When entering a new LLM iteration (first chunk after tool.call), - // reset the buffer so we don't concatenate text from previous iterations. - content := extractPayloadString(payload, "content") - if content != "" { - rc.mu.Lock() - if rc.inToolPhase { - // New LLM iteration — reset buffer and start fresh stream - rc.streamBuffer = "" - rc.inToolPhase = false - rc.mu.Unlock() - // Create new DraftStream for this iteration - if err := sc.OnStreamStart(ctx, rc.ChatID); err != nil { - slog.Debug("stream restart failed", "channel", rc.ChannelName, "error", err) - } - rc.mu.Lock() - } - rc.streamBuffer += content - fullText := rc.streamBuffer - rc.mu.Unlock() - if err := sc.OnChunkEvent(ctx, rc.ChatID, fullText); err != nil { - slog.Debug("stream chunk failed", "channel", rc.ChannelName, "error", err) - } - } - case protocol.AgentEventRunCompleted: - rc.mu.Lock() - finalText := rc.streamBuffer - rc.mu.Unlock() - if err := sc.OnStreamEnd(ctx, rc.ChatID, finalText); err != nil { - slog.Debug("stream end failed", "channel", rc.ChannelName, "error", err) - } - case protocol.AgentEventRunFailed: - // Clean up streaming state - _ = sc.OnStreamEnd(ctx, rc.ChatID, "") - } - } - - // Handle block.reply: deliver intermediate assistant text to non-streaming channels. - // Gated by BlockReplyEnabled (resolved from gateway + per-channel config at RegisterRun time). - // Streaming channels already deliver via chunks, so skip to avoid double-delivery. - if eventType == protocol.AgentEventBlockReply { - if !rc.BlockReplyEnabled { - return - } - content := extractPayloadString(payload, "content") - if content == "" { - return - } - rc.mu.Lock() - streaming := rc.Streaming - rc.mu.Unlock() - - if streaming { - return // streaming already delivered via chunks - } - - // Build outbound metadata: copy routing fields but strip reply_to_message_id - // (block replies are standalone) and placeholder_key (reserve for final message). - var outMeta map[string]string - if rc.Metadata != nil { - outMeta = make(map[string]string) - for _, k := range []string{"message_thread_id", "local_key", "group_id"} { - if v := rc.Metadata[k]; v != "" { - outMeta[k] = v - } - } - if len(outMeta) == 0 { - outMeta = nil - } - } - - m.bus.PublishOutbound(bus.OutboundMessage{ - Channel: rc.ChannelName, - ChatID: rc.ChatID, - Content: content, - Metadata: outMeta, - }) - return - } - - // Handle LLM retry: update placeholder to notify user - if eventType == protocol.AgentEventRunRetrying { - attempt := extractPayloadString(payload, "attempt") - maxAttempts := extractPayloadString(payload, "maxAttempts") - retryMsg := fmt.Sprintf("Provider busy, retrying... (%s/%s)", attempt, maxAttempts) - m.bus.PublishOutbound(bus.OutboundMessage{ - Channel: rc.ChannelName, - ChatID: rc.ChatID, - Content: retryMsg, - Metadata: map[string]string{ - "placeholder_update": "true", - }, - }) - } - - // Forward to ReactionChannel - if reactionCh, ok := ch.(ReactionChannel); ok { - status := "" - switch eventType { - case protocol.AgentEventRunStarted: - status = "thinking" - case protocol.AgentEventToolCall: - status = "tool" - case protocol.AgentEventRunCompleted: - status = "done" - case protocol.AgentEventRunFailed: - status = "error" - } - if status != "" { - if err := reactionCh.OnReactionEvent(ctx, rc.ChatID, rc.MessageID, status); err != nil { - slog.Debug("reaction event failed", "channel", rc.ChannelName, "status", status, "error", err) - } - } - } - - // Clean up on terminal events - if eventType == protocol.AgentEventRunCompleted || eventType == protocol.AgentEventRunFailed { - m.runs.Delete(runID) - } -} - -// extractPayloadString extracts a string field from a payload (map[string]string or map[string]interface{}). -func extractPayloadString(payload interface{}, key string) string { - switch p := payload.(type) { - case map[string]string: - return p[key] - case map[string]interface{}: - if v, ok := p[key].(string); ok { - return v - } - } - return "" -} - -// --- Send error notification helpers --- - -// telegramAPIDescRe extracts the human-readable description from Telegram Bot API errors. -// Example: `telego: sendPhoto: api: 400 "Bad Request: not enough rights to send photos to the chat"` -// -// → "not enough rights to send photos to the chat" -var telegramAPIDescRe = regexp.MustCompile(`"Bad Request:\s*(.+?)"`) - -// formatChannelSendError converts a channel.Send error into a user-friendly message. -// Never exposes raw library/HTTP details. -func formatChannelSendError(err error) string { - raw := err.Error() - lower := strings.ToLower(raw) - - // Telegram "Bad Request: " — extract description - if m := telegramAPIDescRe.FindStringSubmatch(raw); len(m) == 2 { - return fmt.Sprintf("⚠️ Send failed: %s", m[1]) - } - - // Common Telegram API errors (non-Bad Request) - switch { - case strings.Contains(lower, "not enough rights"): - return "⚠️ Send failed: bot doesn't have permission to send this type of message." - case strings.Contains(lower, "chat not found"): - return "⚠️ Send failed: chat not found." - case strings.Contains(lower, "bot was blocked"): - return "⚠️ Send failed: bot was blocked by the user." - case strings.Contains(lower, "user is deactivated"): - return "⚠️ Send failed: user account is deactivated." - case strings.Contains(lower, "too many requests") || strings.Contains(lower, "flood"): - return "⚠️ Send failed: rate limited by Telegram. Please try again later." - case strings.Contains(lower, "file is too big") || strings.Contains(lower, "wrong file"): - return "⚠️ Send failed: file is too large or invalid for Telegram." - } - - // Generic fallback — don't expose internals - return "⚠️ Failed to deliver message. Check bot logs for details." -} - -// sendErrorMeta copies only the routing fields from outbound metadata. -// Strips reply_to_message_id, placeholder_key, audio_as_voice, etc. -// that could cause unintended side effects on the error notification. -func sendErrorMeta(orig map[string]string) map[string]string { - if orig == nil { - return nil - } - meta := make(map[string]string) - for _, k := range []string{"local_key", "message_thread_id"} { - if v := orig[k]; v != "" { - meta[k] = v - } - } - if len(meta) == 0 { - return nil - } - return meta -} diff --git a/internal/channels/runs.go b/internal/channels/runs.go new file mode 100644 index 00000000..95425c71 --- /dev/null +++ b/internal/channels/runs.go @@ -0,0 +1,54 @@ +package channels + +// --- Run tracking for streaming/reaction event forwarding --- + +// RegisterRun associates a run ID with a channel context so agent events +// (chunks, tool calls, completion) can be forwarded to the originating channel. +func (m *Manager) RegisterRun(runID, channelName, chatID, messageID string, metadata map[string]string, streaming, blockReply bool) { + m.runs.Store(runID, &RunContext{ + ChannelName: channelName, + ChatID: chatID, + MessageID: messageID, + Metadata: metadata, + Streaming: streaming, + BlockReplyEnabled: blockReply, + }) +} + +// UnregisterRun removes a run tracking entry. +func (m *Manager) UnregisterRun(runID string) { + m.runs.Delete(runID) +} + +// IsStreamingChannel checks if a named channel implements StreamingChannel +// AND has streaming currently enabled for the given chat type. +// isGroup: true for group chats, false for DMs. +func (m *Manager) IsStreamingChannel(channelName string, isGroup bool) bool { + m.mu.RLock() + ch, exists := m.channels[channelName] + m.mu.RUnlock() + if !exists { + return false + } + sc, ok := ch.(StreamingChannel) + if !ok { + return false + } + return sc.StreamEnabled(isGroup) +} + +// ResolveBlockReply checks per-channel override, falls back to gateway default. +// Returns true only if block.reply delivery should be enabled for this channel. +func (m *Manager) ResolveBlockReply(channelName string, globalDefault *bool) bool { + m.mu.RLock() + ch, exists := m.channels[channelName] + m.mu.RUnlock() + if exists { + if bc, ok := ch.(BlockReplyChannel); ok { + if v := bc.BlockReplyEnabled(); v != nil { + return *v + } + } + } + return globalDefault != nil && *globalDefault +} diff --git a/internal/channels/slack/channel.go b/internal/channels/slack/channel.go index 4ab94bc2..09df18b0 100644 --- a/internal/channels/slack/channel.go +++ b/internal/channels/slack/channel.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log/slog" - "regexp" "strings" "sync" "time" @@ -28,9 +27,9 @@ const ( // Channel connects to Slack via Socket Mode for event-driven messaging. type Channel struct { *channels.BaseChannel - api *slackapi.Client // Bot Token API client (xoxb-) - userAPI *slackapi.Client // User Token API client (xoxp-, optional) - sm *socketmode.Client // Socket Mode client (xapp-) + api *slackapi.Client // Bot Token API client (xoxb-) + userAPI *slackapi.Client // User Token API client (xoxp-, optional) + sm *socketmode.Client // Socket Mode client (xapp-) config config.SlackConfig botUserID string // populated on Start() via auth.test teamID string // populated on Start() via auth.test @@ -56,7 +55,7 @@ type Channel struct { groupHistory *channels.PendingHistory historyLimit int debounceDelay time.Duration - threadTTL time.Duration // thread participation expiry (0 = disabled) + threadTTL time.Duration // thread participation expiry (0 = disabled) wg sync.WaitGroup // tracks goroutines for clean shutdown cancelFn context.CancelFunc } @@ -321,201 +320,3 @@ func (c *Channel) Stop(_ context.Context) error { return nil } - -// HandleMessage overrides BaseChannel to allow messages when the chatID (Slack channel) -// is in the allowlist, enabling group-level allowlisting without requiring individual user IDs. -// This is Slack-specific: other channels only check senderID in BaseChannel.HandleMessage. -func (c *Channel) HandleMessage(senderID, chatID, content string, mediaPaths []string, metadata map[string]string, peerKind string) { - // Allow if either the sender or the Slack channel ID is in the allowlist. - if !c.IsAllowed(senderID) && !c.IsAllowed(chatID) { - return - } - - userID := senderID - if idx := strings.IndexByte(senderID, '|'); idx > 0 { - userID = senderID[:idx] - } - - var mediaFiles []bus.MediaFile - for _, p := range mediaPaths { - mediaFiles = append(mediaFiles, bus.MediaFile{Path: p}) - } - - c.Bus().PublishInbound(bus.InboundMessage{ - Channel: c.Name(), - SenderID: senderID, - ChatID: chatID, - Content: content, - Media: mediaFiles, - PeerKind: peerKind, - UserID: userID, - Metadata: metadata, - AgentID: c.AgentID(), - }) -} - -// BlockReplyEnabled returns the per-channel block_reply override. -func (c *Channel) BlockReplyEnabled() *bool { return c.config.BlockReply } - -// Send delivers an outbound message to Slack. -func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("slack bot not running") - } - - channelID := msg.ChatID - if channelID == "" { - return fmt.Errorf("empty chat ID for slack send") - } - - placeholderKey := channelID - if pk := msg.Metadata["placeholder_key"]; pk != "" { - placeholderKey = pk - } - threadTS := msg.Metadata["message_thread_id"] - - // Placeholder update (LLM retry notification) - if msg.Metadata["placeholder_update"] == "true" { - if pTS, ok := c.placeholders.Load(placeholderKey); ok { - ts := pTS.(string) - _, _, _, _ = c.api.UpdateMessage(channelID, ts, - slackapi.MsgOptionText(msg.Content, false)) - } - return nil - } - - content := msg.Content - - // NO_REPLY: delete placeholder, return - if content == "" { - if pTS, ok := c.placeholders.Load(placeholderKey); ok { - c.placeholders.Delete(placeholderKey) - ts := pTS.(string) - _, _, _ = c.api.DeleteMessage(channelID, ts) - } - return nil - } - - content = markdownToSlackMrkdwn(content) - - // Edit placeholder with first chunk, send rest as follow-ups - if pTS, ok := c.placeholders.Load(placeholderKey); ok { - c.placeholders.Delete(placeholderKey) - ts := pTS.(string) - - editContent, remaining := splitAtLimit(content, maxMessageLen) - - opts := []slackapi.MsgOption{slackapi.MsgOptionText(editContent, false)} - if threadTS != "" { - opts = append(opts, slackapi.MsgOptionTS(threadTS)) - } - - if _, _, _, editErr := c.api.UpdateMessage(channelID, ts, opts...); editErr == nil { - if remaining != "" { - return c.sendChunked(channelID, remaining, threadTS) - } - return nil - } else { - slog.Warn("slack placeholder edit failed, sending new message", - "channel_id", channelID, "error", editErr) - } - } - - // Handle media attachments - for _, media := range msg.Media { - if err := c.uploadFile(channelID, threadTS, media); err != nil { - slog.Warn("slack: file upload failed", - "file", media.URL, "error", err) - c.sendChunked(channelID, fmt.Sprintf("[File upload failed: %s]", media.URL), threadTS) - } - } - - return c.sendChunked(channelID, content, threadTS) -} - -func (c *Channel) sendChunked(channelID, content, threadTS string) error { - for len(content) > 0 { - chunk, rest := splitAtLimit(content, maxMessageLen) - content = rest - - opts := []slackapi.MsgOption{slackapi.MsgOptionText(chunk, false)} - if threadTS != "" { - opts = append(opts, slackapi.MsgOptionTS(threadTS)) - } - - if _, _, err := c.api.PostMessage(channelID, opts...); err != nil { - return fmt.Errorf("send slack message: %w", err) - } - } - return nil -} - -// splitAtLimit splits content at maxLen runes, preferring newline boundaries. -func splitAtLimit(content string, maxLen int) (chunk, remaining string) { - runes := []rune(content) - if len(runes) <= maxLen { - return content, "" - } - cutAt := maxLen - // Try to break at a newline in the second half - candidate := string(runes[:maxLen]) - if idx := strings.LastIndex(candidate, "\n"); idx > len(candidate)/2 { - return content[:idx+1], content[idx+1:] - } - return string(runes[:cutAt]), string(runes[cutAt:]) -} - -// resolveDisplayName fetches and caches the Slack display name for a user ID. -func (c *Channel) resolveDisplayName(userID string) string { - c.userCacheMu.RLock() - cu, found := c.userCache[userID] - c.userCacheMu.RUnlock() - - if found && time.Since(cu.fetchedAt) < userCacheTTL { - return cu.displayName - } - - user, err := c.api.GetUserInfo(userID) - if err != nil { - slog.Debug("slack: failed to resolve user", "user_id", userID, "error", err) - return userID - } - - name := user.Profile.DisplayName - if name == "" { - name = user.RealName - } - if name == "" { - name = user.Name - } - - c.userCacheMu.Lock() - c.userCache[userID] = cachedUser{displayName: name, fetchedAt: time.Now()} - c.userCacheMu.Unlock() - - return name -} - -// nonRetryableAuthErrors matches Slack errors that indicate permanent auth failure. -var nonRetryableAuthErrors = regexp.MustCompile( - `(?i)(invalid_auth|token_revoked|account_inactive|not_authed|team_not_found|missing_scope)`, -) - -func isNonRetryableAuthError(errMsg string) bool { - return nonRetryableAuthErrors.MatchString(errMsg) -} - -// HealthProbe performs an auth.test call to verify the Slack connection is alive. -func (c *Channel) HealthProbe(ctx context.Context) (ok bool, elapsed time.Duration, err error) { - if c.api == nil { - return false, 0, fmt.Errorf("slack client not initialized (Start() not called)") - } - - start := time.Now() - probeCtx, cancel := context.WithTimeout(ctx, healthProbeTimeout) - defer cancel() - - _, err = c.api.AuthTestContext(probeCtx) - elapsed = time.Since(start) - return err == nil, elapsed, err -} diff --git a/internal/channels/slack/handlers.go b/internal/channels/slack/handlers.go index 180d912b..0a3610e7 100644 --- a/internal/channels/slack/handlers.go +++ b/internal/channels/slack/handlers.go @@ -1,23 +1,15 @@ package slack import ( - "bytes" "fmt" - "io" "log/slog" - "net/http" - "net/url" - "os" - "path/filepath" "strings" - "sync" "time" slackapi "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" - "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" ) @@ -257,411 +249,3 @@ func (c *Channel) handleMessage(ev *slackevents.MessageEvent) { c.groupHistory.Clear(localKey) } } - -func (c *Channel) handleAppMention(ev *slackevents.AppMentionEvent) { - if ev.User == c.botUserID || ev.User == "" { - return - } - - // Dedup: app_mention may arrive alongside a message event - dedupKey := ev.Channel + ":" + ev.TimeStamp - if _, loaded := c.dedup.LoadOrStore(dedupKey, time.Now()); loaded { - return - } - - // If requireMention is false, message handler already processes all channel messages - if !c.requireMention { - return - } - - senderID := ev.User - channelID := ev.Channel - content := ev.Text - - displayName := strings.ReplaceAll(c.resolveDisplayName(senderID), "|", "_") - compoundSenderID := fmt.Sprintf("%s|%s", senderID, displayName) - - if !c.checkGroupPolicy(senderID, channelID) { - return - } - - content = c.stripBotMention(content) - content = strings.TrimSpace(content) - - if content == "" { - return - } - - localKey := channelID - threadTS := ev.ThreadTimeStamp - if threadTS != "" { - localKey = fmt.Sprintf("%s:thread:%s", channelID, threadTS) - } - - slog.Debug("slack app_mention received", - "sender_id", senderID, "channel_id", channelID, - "preview", channels.Truncate(content, 50)) - - replyThreadTS := threadTS - if replyThreadTS == "" { - replyThreadTS = ev.TimeStamp - } - - placeholderOpts := []slackapi.MsgOption{ - slackapi.MsgOptionText("Thinking...", false), - } - if replyThreadTS != "" { - placeholderOpts = append(placeholderOpts, slackapi.MsgOptionTS(replyThreadTS)) - } - - _, placeholderTS, err := c.api.PostMessage(channelID, placeholderOpts...) - if err == nil { - c.placeholders.Store(localKey, placeholderTS) - } - - annotated := fmt.Sprintf("[From: %s]\n%s", displayName, content) - finalContent := annotated - if c.historyLimit > 0 { - finalContent = c.groupHistory.BuildContext(localKey, annotated, c.historyLimit) - } - - metadata := map[string]string{ - "message_id": ev.TimeStamp, - "user_id": senderID, - "username": displayName, - "channel_id": channelID, - "is_dm": "false", - "local_key": localKey, - "placeholder_key": localKey, - } - if replyThreadTS != "" { - metadata["message_thread_id"] = replyThreadTS - } - - c.HandleMessage(compoundSenderID, channelID, finalContent, nil, metadata, "group") - - // Record thread participation - if replyThreadTS != "" { - participKey := channelID + ":particip:" + replyThreadTS - c.threadParticip.Store(participKey, time.Now()) - } - - c.groupHistory.Clear(localKey) -} - -// isBotMentioned checks if the message text contains <@botUserID>. -func (c *Channel) isBotMentioned(text string) bool { - return strings.Contains(text, "<@"+c.botUserID+">") -} - -// stripBotMention removes <@botUserID> from message text. -func (c *Channel) stripBotMention(text string) string { - return strings.ReplaceAll(text, "<@"+c.botUserID+">", "") -} - -// --- Message debounce/batching --- - -type debounceEntry struct { - timer *time.Timer - messages []string - mu sync.Mutex - senderID string - channelID string - media []string - metadata map[string]string - peerKind string -} - -// debounceMessage batches rapid messages. Returns true if message was debounced. -func (c *Channel) debounceMessage(localKey, senderID, channelID, content string, media []string, metadata map[string]string, peerKind string) bool { - c.debounceMu.Lock() - entry, loaded := c.debounceTimers[localKey] - if !loaded { - entry = &debounceEntry{ - senderID: senderID, - channelID: channelID, - media: media, - metadata: metadata, - peerKind: peerKind, - } - c.debounceTimers[localKey] = entry - } - c.debounceMu.Unlock() - - entry.mu.Lock() - defer entry.mu.Unlock() - - entry.messages = append(entry.messages, content) - if loaded { - // Only append media for subsequent messages; first message's media is set in constructor. - entry.media = append(entry.media, media...) - } - entry.metadata = metadata // use latest message's metadata - - if !loaded { - entry.timer = time.AfterFunc(c.debounceDelay, func() { - c.flushDebounce(localKey) - }) - return true - } - - if entry.timer != nil { - entry.timer.Reset(c.debounceDelay) - } - return true -} - -func (c *Channel) flushDebounce(localKey string) { - c.debounceMu.Lock() - entry, ok := c.debounceTimers[localKey] - if ok { - delete(c.debounceTimers, localKey) - } - c.debounceMu.Unlock() - - if !ok { - return - } - - entry.mu.Lock() - combined := strings.Join(entry.messages, "\n") - entry.mu.Unlock() - - c.HandleMessage(entry.senderID, entry.channelID, combined, entry.media, entry.metadata, entry.peerKind) - - if entry.peerKind == "group" { - c.groupHistory.Clear(localKey) - } -} - -// --- Policy checks --- - -func (c *Channel) checkDMPolicy(senderID, channelID string) bool { - dmPolicy := c.config.DMPolicy - if dmPolicy == "" { - dmPolicy = "pairing" - } - - switch dmPolicy { - case "disabled": - return false - case "open": - return true - case "allowlist": - return c.HasAllowList() && c.IsAllowed(senderID) - default: // "pairing" - if c.pairingService != nil && c.pairingService.IsPaired(senderID, c.Name()) { - return true - } - if c.HasAllowList() && c.IsAllowed(senderID) { - return true - } - c.sendPairingReply(senderID, channelID) - return false - } -} - -func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { - groupPolicy := c.config.GroupPolicy - if groupPolicy == "" { - groupPolicy = "open" - } - - switch groupPolicy { - case "disabled": - return false - case "allowlist": - if !c.HasAllowList() { - return false - } - // Allow if user ID or channel ID is in the allowlist - return c.IsAllowed(senderID) || c.IsAllowed(channelID) - case "pairing": - if c.HasAllowList() && c.IsAllowed(senderID) { - return true - } - if _, cached := c.approvedGroups.Load(channelID); cached { - return true - } - groupSenderID := fmt.Sprintf("group:%s", channelID) - if c.pairingService != nil && c.pairingService.IsPaired(groupSenderID, c.Name()) { - c.approvedGroups.Store(channelID, true) - return true - } - c.sendPairingReply(groupSenderID, channelID) - return false - default: // "open" - return true - } -} - -func (c *Channel) sendPairingReply(senderID, channelID string) { - if c.pairingService == nil { - return - } - - if lastSent, ok := c.pairingDebounce.Load(senderID); ok { - if time.Since(lastSent.(time.Time)) < pairingDebounceTime { - return - } - } - - code, err := c.pairingService.RequestPairing(senderID, c.Name(), channelID, "default", nil) - if err != nil { - slog.Warn("slack: failed to request pairing code", "error", err) - return - } - - // Security: do not expose pairing code in group channels (visible to all members). - // Instead, direct admin to CLI or web UI where pending codes are listed. - var msg string - if strings.HasPrefix(senderID, "group:") { - msg = fmt.Sprintf("This channel is not authorized to use this bot.\n\n"+ - "An admin can approve via CLI:\n goclaw pairing approve %s\n\n"+ - "Or approve via the GoClaw web UI (Pairing section).", code) - } else { - msg = fmt.Sprintf("GoClaw: access not configured.\n\nYour Slack user ID: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s", - senderID, code, code) - } - if _, _, err := c.api.PostMessage(channelID, slackapi.MsgOptionText(msg, false)); err != nil { - slog.Warn("slack: failed to send pairing reply", - "channel_id", channelID, "error", err) - } - c.pairingDebounce.Store(senderID, time.Now()) -} - -// --- File download (SSRF-protected) --- - -var slackDownloadAllowlist = []string{ - ".slack.com", - ".slack-edge.com", - ".slack-files.com", -} - -func isAllowedDownloadHost(rawURL string) bool { - u, err := url.Parse(rawURL) - if err != nil || u.Scheme != "https" { - return false - } - host := strings.ToLower(u.Hostname()) - for _, suffix := range slackDownloadAllowlist { - if strings.HasSuffix(host, suffix) { - return true - } - } - return false -} - -func (c *Channel) downloadFile(name, urlPrivate, urlPrivateDownload string, maxBytes int64) (string, error) { - downloadURL := urlPrivateDownload - if downloadURL == "" { - downloadURL = urlPrivate - } - if downloadURL == "" { - return "", fmt.Errorf("no download URL for file %s", name) - } - - if !isAllowedDownloadHost(downloadURL) { - return "", fmt.Errorf("security: download URL hostname not in Slack allowlist: %s", downloadURL) - } - - ext := filepath.Ext(name) - if ext == "" { - ext = ".dat" - } - tmpFile, err := os.CreateTemp("", "slack-file-*"+ext) - if err != nil { - return "", fmt.Errorf("create temp file: %w", err) - } - defer tmpFile.Close() - - client := &http.Client{ - Timeout: 60 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - req.Header.Del("Authorization") // strip auth on redirect (CDN presigned URL) - if req.URL.Scheme != "https" { - return fmt.Errorf("security: redirect to non-HTTPS URL blocked: %s", req.URL) - } - // Only allow redirects to known Slack CDN domains to prevent SSRF. - host := req.URL.Hostname() - if !isAllowedSlackHost(host) { - return fmt.Errorf("security: redirect to untrusted host blocked: %s", host) - } - if len(via) >= 3 { - return fmt.Errorf("too many redirects") - } - return nil - }, - } - - req, err := http.NewRequest("GET", downloadURL, nil) - if err != nil { - os.Remove(tmpFile.Name()) - return "", err - } - req.Header.Set("Authorization", "Bearer "+c.config.BotToken) - - resp, err := client.Do(req) - if err != nil { - os.Remove(tmpFile.Name()) - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - os.Remove(tmpFile.Name()) - return "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode) - } - - if _, err := io.Copy(tmpFile, io.LimitReader(resp.Body, maxBytes)); err != nil { - os.Remove(tmpFile.Name()) - return "", err - } - - return tmpFile.Name(), nil -} - -// allowedSlackHosts contains trusted Slack CDN domains for redirect validation. -var allowedSlackHosts = []string{ - ".slack-edge.com", - ".slack.com", - "files.slack.com", -} - -// isAllowedSlackHost checks if a hostname belongs to a known Slack CDN domain. -func isAllowedSlackHost(host string) bool { - for _, suffix := range allowedSlackHosts { - if host == strings.TrimPrefix(suffix, ".") || strings.HasSuffix(host, suffix) { - return true - } - } - return false -} - -// --- File upload (v2 3-step API) --- - -func (c *Channel) uploadFile(channelID, threadTS string, media bus.MediaAttachment) error { - filePath := media.URL - fileName := filepath.Base(filePath) - - data, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("read file %s: %w", filePath, err) - } - - params := slackapi.UploadFileParameters{ - Filename: fileName, - FileSize: len(data), - Reader: bytes.NewReader(data), - Title: fileName, - Channel: channelID, - ThreadTimestamp: threadTS, - } - - _, err = c.api.UploadFile(params) - if err != nil { - return fmt.Errorf("upload file: %w", err) - } - - return nil -} diff --git a/internal/channels/slack/handlers_files.go b/internal/channels/slack/handlers_files.go new file mode 100644 index 00000000..f96a1788 --- /dev/null +++ b/internal/channels/slack/handlers_files.go @@ -0,0 +1,229 @@ +package slack + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + slackapi "github.com/slack-go/slack" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// --- Message debounce/batching --- + +type debounceEntry struct { + timer *time.Timer + messages []string + mu sync.Mutex + senderID string + channelID string + media []string + metadata map[string]string + peerKind string +} + +// debounceMessage batches rapid messages. Returns true if message was debounced. +func (c *Channel) debounceMessage(localKey, senderID, channelID, content string, media []string, metadata map[string]string, peerKind string) bool { + c.debounceMu.Lock() + entry, loaded := c.debounceTimers[localKey] + if !loaded { + entry = &debounceEntry{ + senderID: senderID, + channelID: channelID, + media: media, + metadata: metadata, + peerKind: peerKind, + } + c.debounceTimers[localKey] = entry + } + c.debounceMu.Unlock() + + entry.mu.Lock() + defer entry.mu.Unlock() + + entry.messages = append(entry.messages, content) + if loaded { + // Only append media for subsequent messages; first message's media is set in constructor. + entry.media = append(entry.media, media...) + } + entry.metadata = metadata // use latest message's metadata + + if !loaded { + entry.timer = time.AfterFunc(c.debounceDelay, func() { + c.flushDebounce(localKey) + }) + return true + } + + if entry.timer != nil { + entry.timer.Reset(c.debounceDelay) + } + return true +} + +func (c *Channel) flushDebounce(localKey string) { + c.debounceMu.Lock() + entry, ok := c.debounceTimers[localKey] + if ok { + delete(c.debounceTimers, localKey) + } + c.debounceMu.Unlock() + + if !ok { + return + } + + entry.mu.Lock() + combined := strings.Join(entry.messages, "\n") + entry.mu.Unlock() + + c.HandleMessage(entry.senderID, entry.channelID, combined, entry.media, entry.metadata, entry.peerKind) + + if entry.peerKind == "group" { + c.groupHistory.Clear(localKey) + } +} + +// --- File download (SSRF-protected) --- + +var slackDownloadAllowlist = []string{ + ".slack.com", + ".slack-edge.com", + ".slack-files.com", +} + +func isAllowedDownloadHost(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil || u.Scheme != "https" { + return false + } + host := strings.ToLower(u.Hostname()) + for _, suffix := range slackDownloadAllowlist { + if strings.HasSuffix(host, suffix) { + return true + } + } + return false +} + +func (c *Channel) downloadFile(name, urlPrivate, urlPrivateDownload string, maxBytes int64) (string, error) { + downloadURL := urlPrivateDownload + if downloadURL == "" { + downloadURL = urlPrivate + } + if downloadURL == "" { + return "", fmt.Errorf("no download URL for file %s", name) + } + + if !isAllowedDownloadHost(downloadURL) { + return "", fmt.Errorf("security: download URL hostname not in Slack allowlist: %s", downloadURL) + } + + ext := filepath.Ext(name) + if ext == "" { + ext = ".dat" + } + tmpFile, err := os.CreateTemp("", "slack-file-*"+ext) + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + defer tmpFile.Close() + + client := &http.Client{ + Timeout: 60 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + req.Header.Del("Authorization") // strip auth on redirect (CDN presigned URL) + if req.URL.Scheme != "https" { + return fmt.Errorf("security: redirect to non-HTTPS URL blocked: %s", req.URL) + } + // Only allow redirects to known Slack CDN domains to prevent SSRF. + host := req.URL.Hostname() + if !isAllowedSlackHost(host) { + return fmt.Errorf("security: redirect to untrusted host blocked: %s", host) + } + if len(via) >= 3 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + + req, err := http.NewRequest("GET", downloadURL, nil) + if err != nil { + os.Remove(tmpFile.Name()) + return "", err + } + req.Header.Set("Authorization", "Bearer "+c.config.BotToken) + + resp, err := client.Do(req) + if err != nil { + os.Remove(tmpFile.Name()) + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode) + } + + if _, err := io.Copy(tmpFile, io.LimitReader(resp.Body, maxBytes)); err != nil { + os.Remove(tmpFile.Name()) + return "", err + } + + return tmpFile.Name(), nil +} + +// allowedSlackHosts contains trusted Slack CDN domains for redirect validation. +var allowedSlackHosts = []string{ + ".slack-edge.com", + ".slack.com", + "files.slack.com", +} + +// isAllowedSlackHost checks if a hostname belongs to a known Slack CDN domain. +func isAllowedSlackHost(host string) bool { + for _, suffix := range allowedSlackHosts { + if host == strings.TrimPrefix(suffix, ".") || strings.HasSuffix(host, suffix) { + return true + } + } + return false +} + +// --- File upload (v2 3-step API) --- + +func (c *Channel) uploadFile(channelID, threadTS string, media bus.MediaAttachment) error { + filePath := media.URL + fileName := filepath.Base(filePath) + + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("read file %s: %w", filePath, err) + } + + params := slackapi.UploadFileParameters{ + Filename: fileName, + FileSize: len(data), + Reader: bytes.NewReader(data), + Title: fileName, + Channel: channelID, + ThreadTimestamp: threadTS, + } + + _, err = c.api.UploadFile(params) + if err != nil { + return fmt.Errorf("upload file: %w", err) + } + + return nil +} diff --git a/internal/channels/slack/handlers_mention.go b/internal/channels/slack/handlers_mention.go new file mode 100644 index 00000000..3fa594c4 --- /dev/null +++ b/internal/channels/slack/handlers_mention.go @@ -0,0 +1,210 @@ +package slack + +import ( + "fmt" + "log/slog" + "strings" + "time" + + slackapi "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + + "github.com/nextlevelbuilder/goclaw/internal/channels" +) + +func (c *Channel) handleAppMention(ev *slackevents.AppMentionEvent) { + if ev.User == c.botUserID || ev.User == "" { + return + } + + // Dedup: app_mention may arrive alongside a message event + dedupKey := ev.Channel + ":" + ev.TimeStamp + if _, loaded := c.dedup.LoadOrStore(dedupKey, time.Now()); loaded { + return + } + + // If requireMention is false, message handler already processes all channel messages + if !c.requireMention { + return + } + + senderID := ev.User + channelID := ev.Channel + content := ev.Text + + displayName := strings.ReplaceAll(c.resolveDisplayName(senderID), "|", "_") + compoundSenderID := fmt.Sprintf("%s|%s", senderID, displayName) + + if !c.checkGroupPolicy(senderID, channelID) { + return + } + + content = c.stripBotMention(content) + content = strings.TrimSpace(content) + + if content == "" { + return + } + + localKey := channelID + threadTS := ev.ThreadTimeStamp + if threadTS != "" { + localKey = fmt.Sprintf("%s:thread:%s", channelID, threadTS) + } + + slog.Debug("slack app_mention received", + "sender_id", senderID, "channel_id", channelID, + "preview", channels.Truncate(content, 50)) + + replyThreadTS := threadTS + if replyThreadTS == "" { + replyThreadTS = ev.TimeStamp + } + + placeholderOpts := []slackapi.MsgOption{ + slackapi.MsgOptionText("Thinking...", false), + } + if replyThreadTS != "" { + placeholderOpts = append(placeholderOpts, slackapi.MsgOptionTS(replyThreadTS)) + } + + _, placeholderTS, err := c.api.PostMessage(channelID, placeholderOpts...) + if err == nil { + c.placeholders.Store(localKey, placeholderTS) + } + + annotated := fmt.Sprintf("[From: %s]\n%s", displayName, content) + finalContent := annotated + if c.historyLimit > 0 { + finalContent = c.groupHistory.BuildContext(localKey, annotated, c.historyLimit) + } + + metadata := map[string]string{ + "message_id": ev.TimeStamp, + "user_id": senderID, + "username": displayName, + "channel_id": channelID, + "is_dm": "false", + "local_key": localKey, + "placeholder_key": localKey, + } + if replyThreadTS != "" { + metadata["message_thread_id"] = replyThreadTS + } + + c.HandleMessage(compoundSenderID, channelID, finalContent, nil, metadata, "group") + + // Record thread participation + if replyThreadTS != "" { + participKey := channelID + ":particip:" + replyThreadTS + c.threadParticip.Store(participKey, time.Now()) + } + + c.groupHistory.Clear(localKey) +} + +// isBotMentioned checks if the message text contains <@botUserID>. +func (c *Channel) isBotMentioned(text string) bool { + return strings.Contains(text, "<@"+c.botUserID+">") +} + +// stripBotMention removes <@botUserID> from message text. +func (c *Channel) stripBotMention(text string) string { + return strings.ReplaceAll(text, "<@"+c.botUserID+">", "") +} + +// --- Policy checks --- + +func (c *Channel) checkDMPolicy(senderID, channelID string) bool { + dmPolicy := c.config.DMPolicy + if dmPolicy == "" { + dmPolicy = "pairing" + } + + switch dmPolicy { + case "disabled": + return false + case "open": + return true + case "allowlist": + return c.HasAllowList() && c.IsAllowed(senderID) + default: // "pairing" + if c.pairingService != nil && c.pairingService.IsPaired(senderID, c.Name()) { + return true + } + if c.HasAllowList() && c.IsAllowed(senderID) { + return true + } + c.sendPairingReply(senderID, channelID) + return false + } +} + +func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { + groupPolicy := c.config.GroupPolicy + if groupPolicy == "" { + groupPolicy = "open" + } + + switch groupPolicy { + case "disabled": + return false + case "allowlist": + if !c.HasAllowList() { + return false + } + // Allow if user ID or channel ID is in the allowlist + return c.IsAllowed(senderID) || c.IsAllowed(channelID) + case "pairing": + if c.HasAllowList() && c.IsAllowed(senderID) { + return true + } + if _, cached := c.approvedGroups.Load(channelID); cached { + return true + } + groupSenderID := fmt.Sprintf("group:%s", channelID) + if c.pairingService != nil && c.pairingService.IsPaired(groupSenderID, c.Name()) { + c.approvedGroups.Store(channelID, true) + return true + } + c.sendPairingReply(groupSenderID, channelID) + return false + default: // "open" + return true + } +} + +func (c *Channel) sendPairingReply(senderID, channelID string) { + if c.pairingService == nil { + return + } + + if lastSent, ok := c.pairingDebounce.Load(senderID); ok { + if time.Since(lastSent.(time.Time)) < pairingDebounceTime { + return + } + } + + code, err := c.pairingService.RequestPairing(senderID, c.Name(), channelID, "default", nil) + if err != nil { + slog.Warn("slack: failed to request pairing code", "error", err) + return + } + + // Security: do not expose pairing code in group channels (visible to all members). + // Instead, direct admin to CLI or web UI where pending codes are listed. + var msg string + if strings.HasPrefix(senderID, "group:") { + msg = fmt.Sprintf("This channel is not authorized to use this bot.\n\n"+ + "An admin can approve via CLI:\n goclaw pairing approve %s\n\n"+ + "Or approve via the GoClaw web UI (Pairing section).", code) + } else { + msg = fmt.Sprintf("GoClaw: access not configured.\n\nYour Slack user ID: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s", + senderID, code, code) + } + if _, _, err := c.api.PostMessage(channelID, slackapi.MsgOptionText(msg, false)); err != nil { + slog.Warn("slack: failed to send pairing reply", + "channel_id", channelID, "error", err) + } + c.pairingDebounce.Store(senderID, time.Now()) +} diff --git a/internal/channels/slack/send.go b/internal/channels/slack/send.go new file mode 100644 index 00000000..10f15c46 --- /dev/null +++ b/internal/channels/slack/send.go @@ -0,0 +1,120 @@ +package slack + +import ( + "context" + "fmt" + "log/slog" + "strings" + + slackapi "github.com/slack-go/slack" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// Send delivers an outbound message to Slack. +func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("slack bot not running") + } + + channelID := msg.ChatID + if channelID == "" { + return fmt.Errorf("empty chat ID for slack send") + } + + placeholderKey := channelID + if pk := msg.Metadata["placeholder_key"]; pk != "" { + placeholderKey = pk + } + threadTS := msg.Metadata["message_thread_id"] + + // Placeholder update (LLM retry notification) + if msg.Metadata["placeholder_update"] == "true" { + if pTS, ok := c.placeholders.Load(placeholderKey); ok { + ts := pTS.(string) + _, _, _, _ = c.api.UpdateMessage(channelID, ts, + slackapi.MsgOptionText(msg.Content, false)) + } + return nil + } + + content := msg.Content + + // NO_REPLY: delete placeholder, return + if content == "" { + if pTS, ok := c.placeholders.Load(placeholderKey); ok { + c.placeholders.Delete(placeholderKey) + ts := pTS.(string) + _, _, _ = c.api.DeleteMessage(channelID, ts) + } + return nil + } + + content = markdownToSlackMrkdwn(content) + + // Edit placeholder with first chunk, send rest as follow-ups + if pTS, ok := c.placeholders.Load(placeholderKey); ok { + c.placeholders.Delete(placeholderKey) + ts := pTS.(string) + + editContent, remaining := splitAtLimit(content, maxMessageLen) + + opts := []slackapi.MsgOption{slackapi.MsgOptionText(editContent, false)} + if threadTS != "" { + opts = append(opts, slackapi.MsgOptionTS(threadTS)) + } + + if _, _, _, editErr := c.api.UpdateMessage(channelID, ts, opts...); editErr == nil { + if remaining != "" { + return c.sendChunked(channelID, remaining, threadTS) + } + return nil + } else { + slog.Warn("slack placeholder edit failed, sending new message", + "channel_id", channelID, "error", editErr) + } + } + + // Handle media attachments + for _, media := range msg.Media { + if err := c.uploadFile(channelID, threadTS, media); err != nil { + slog.Warn("slack: file upload failed", + "file", media.URL, "error", err) + c.sendChunked(channelID, fmt.Sprintf("[File upload failed: %s]", media.URL), threadTS) + } + } + + return c.sendChunked(channelID, content, threadTS) +} + +func (c *Channel) sendChunked(channelID, content, threadTS string) error { + for len(content) > 0 { + chunk, rest := splitAtLimit(content, maxMessageLen) + content = rest + + opts := []slackapi.MsgOption{slackapi.MsgOptionText(chunk, false)} + if threadTS != "" { + opts = append(opts, slackapi.MsgOptionTS(threadTS)) + } + + if _, _, err := c.api.PostMessage(channelID, opts...); err != nil { + return fmt.Errorf("send slack message: %w", err) + } + } + return nil +} + +// splitAtLimit splits content at maxLen runes, preferring newline boundaries. +func splitAtLimit(content string, maxLen int) (chunk, remaining string) { + runes := []rune(content) + if len(runes) <= maxLen { + return content, "" + } + cutAt := maxLen + // Try to break at a newline in the second half + candidate := string(runes[:maxLen]) + if idx := strings.LastIndex(candidate, "\n"); idx > len(candidate)/2 { + return content[:idx+1], content[idx+1:] + } + return string(runes[:cutAt]), string(runes[cutAt:]) +} diff --git a/internal/channels/slack/utils.go b/internal/channels/slack/utils.go new file mode 100644 index 00000000..a4c66883 --- /dev/null +++ b/internal/channels/slack/utils.go @@ -0,0 +1,102 @@ +package slack + +import ( + "context" + "fmt" + "log/slog" + "regexp" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// HandleMessage overrides BaseChannel to allow messages when the chatID (Slack channel) +// is in the allowlist, enabling group-level allowlisting without requiring individual user IDs. +// This is Slack-specific: other channels only check senderID in BaseChannel.HandleMessage. +func (c *Channel) HandleMessage(senderID, chatID, content string, mediaPaths []string, metadata map[string]string, peerKind string) { + // Allow if either the sender or the Slack channel ID is in the allowlist. + if !c.IsAllowed(senderID) && !c.IsAllowed(chatID) { + return + } + + userID := senderID + if idx := strings.IndexByte(senderID, '|'); idx > 0 { + userID = senderID[:idx] + } + + var mediaFiles []bus.MediaFile + for _, p := range mediaPaths { + mediaFiles = append(mediaFiles, bus.MediaFile{Path: p}) + } + + c.Bus().PublishInbound(bus.InboundMessage{ + Channel: c.Name(), + SenderID: senderID, + ChatID: chatID, + Content: content, + Media: mediaFiles, + PeerKind: peerKind, + UserID: userID, + Metadata: metadata, + AgentID: c.AgentID(), + }) +} + +// BlockReplyEnabled returns the per-channel block_reply override. +func (c *Channel) BlockReplyEnabled() *bool { return c.config.BlockReply } + +// resolveDisplayName fetches and caches the Slack display name for a user ID. +func (c *Channel) resolveDisplayName(userID string) string { + c.userCacheMu.RLock() + cu, found := c.userCache[userID] + c.userCacheMu.RUnlock() + + if found && time.Since(cu.fetchedAt) < userCacheTTL { + return cu.displayName + } + + user, err := c.api.GetUserInfo(userID) + if err != nil { + slog.Debug("slack: failed to resolve user", "user_id", userID, "error", err) + return userID + } + + name := user.Profile.DisplayName + if name == "" { + name = user.RealName + } + if name == "" { + name = user.Name + } + + c.userCacheMu.Lock() + c.userCache[userID] = cachedUser{displayName: name, fetchedAt: time.Now()} + c.userCacheMu.Unlock() + + return name +} + +// nonRetryableAuthErrors matches Slack errors that indicate permanent auth failure. +var nonRetryableAuthErrors = regexp.MustCompile( + `(?i)(invalid_auth|token_revoked|account_inactive|not_authed|team_not_found|missing_scope)`, +) + +func isNonRetryableAuthError(errMsg string) bool { + return nonRetryableAuthErrors.MatchString(errMsg) +} + +// HealthProbe performs an auth.test call to verify the Slack connection is alive. +func (c *Channel) HealthProbe(ctx context.Context) (ok bool, elapsed time.Duration, err error) { + if c.api == nil { + return false, 0, fmt.Errorf("slack client not initialized (Start() not called)") + } + + start := time.Now() + probeCtx, cancel := context.WithTimeout(ctx, healthProbeTimeout) + defer cancel() + + _, err = c.api.AuthTestContext(probeCtx) + elapsed = time.Since(start) + return err == nil, elapsed, err +} diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index 9f2c0b9c..5713102a 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -470,77 +470,3 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { } } -// detectMention checks if a Telegram message mentions the bot. -// Checks both msg.Text/Entities (text messages) and msg.Caption/CaptionEntities (photo/media messages). -func (c *Channel) detectMention(msg *telego.Message, botUsername string) bool { - if botUsername == "" { - return false - } - lowerBot := strings.ToLower(botUsername) - - // Check both text entities and caption entities (photos use Caption, not Text). - for _, pair := range []struct { - entities []telego.MessageEntity - text string - }{ - {msg.Entities, msg.Text}, - {msg.CaptionEntities, msg.Caption}, - } { - if pair.text == "" { - continue - } - for _, entity := range pair.entities { - if entity.Type == "mention" { - mentioned := pair.text[entity.Offset : entity.Offset+entity.Length] - if strings.EqualFold(mentioned, "@"+botUsername) { - return true - } - } - if entity.Type == "bot_command" { - cmdText := pair.text[entity.Offset : entity.Offset+entity.Length] - if strings.Contains(strings.ToLower(cmdText), "@"+lowerBot) { - return true - } - } - } - } - - // Fallback: substring check in both text and caption - if msg.Text != "" && strings.Contains(strings.ToLower(msg.Text), "@"+lowerBot) { - return true - } - if msg.Caption != "" && strings.Contains(strings.ToLower(msg.Caption), "@"+lowerBot) { - return true - } - - // Reply to bot's message = implicit mention - if msg.ReplyToMessage != nil && msg.ReplyToMessage.From != nil { - if msg.ReplyToMessage.From.Username == botUsername { - return true - } - } - - return false -} - -// isServiceMessage returns true if the Telegram message is a service/system message -// (member added/removed, title changed, pinned, etc.) rather than a user-sent message. -// Service messages have no text, caption, or media content. -func isServiceMessage(msg *telego.Message) bool { - // Has text or caption → user message - if msg.Text != "" || msg.Caption != "" { - return false - } - - // Has media → user message (photo, audio, video, document, sticker, etc.) - if msg.Photo != nil || msg.Audio != nil || msg.Video != nil || - msg.Document != nil || msg.Voice != nil || msg.VideoNote != nil || - msg.Sticker != nil || msg.Animation != nil || msg.Contact != nil || - msg.Location != nil || msg.Venue != nil || msg.Poll != nil { - return false - } - - // No user content — likely a service message (new_chat_members, left_chat_member, - // new_chat_title, new_chat_photo, pinned_message, etc.) - return true -} diff --git a/internal/channels/telegram/handlers_utils.go b/internal/channels/telegram/handlers_utils.go new file mode 100644 index 00000000..d169b4b0 --- /dev/null +++ b/internal/channels/telegram/handlers_utils.go @@ -0,0 +1,82 @@ +package telegram + +import ( + "strings" + + "github.com/mymmrac/telego" +) + +// detectMention checks if a Telegram message mentions the bot. +// Checks both msg.Text/Entities (text messages) and msg.Caption/CaptionEntities (photo/media messages). +func (c *Channel) detectMention(msg *telego.Message, botUsername string) bool { + if botUsername == "" { + return false + } + lowerBot := strings.ToLower(botUsername) + + // Check both text entities and caption entities (photos use Caption, not Text). + for _, pair := range []struct { + entities []telego.MessageEntity + text string + }{ + {msg.Entities, msg.Text}, + {msg.CaptionEntities, msg.Caption}, + } { + if pair.text == "" { + continue + } + for _, entity := range pair.entities { + if entity.Type == "mention" { + mentioned := pair.text[entity.Offset : entity.Offset+entity.Length] + if strings.EqualFold(mentioned, "@"+botUsername) { + return true + } + } + if entity.Type == "bot_command" { + cmdText := pair.text[entity.Offset : entity.Offset+entity.Length] + if strings.Contains(strings.ToLower(cmdText), "@"+lowerBot) { + return true + } + } + } + } + + // Fallback: substring check in both text and caption + if msg.Text != "" && strings.Contains(strings.ToLower(msg.Text), "@"+lowerBot) { + return true + } + if msg.Caption != "" && strings.Contains(strings.ToLower(msg.Caption), "@"+lowerBot) { + return true + } + + // Reply to bot's message = implicit mention + if msg.ReplyToMessage != nil && msg.ReplyToMessage.From != nil { + if msg.ReplyToMessage.From.Username == botUsername { + return true + } + } + + return false +} + +// isServiceMessage returns true if the Telegram message is a service/system message +// (member added/removed, title changed, pinned, etc.) rather than a user-sent message. +// Service messages have no text, caption, or media content. +func isServiceMessage(msg *telego.Message) bool { + // Has text or caption → user message + if msg.Text != "" || msg.Caption != "" { + return false + } + + // Has media → user message (photo, audio, video, document, sticker, etc.) + if msg.Photo != nil || msg.Audio != nil || msg.Video != nil || + msg.Document != nil || msg.Voice != nil || msg.VideoNote != nil || + msg.Sticker != nil || msg.Animation != nil || msg.Contact != nil || + msg.Location != nil || msg.Venue != nil || msg.Poll != nil { + return false + } + + // No user content — likely a service message (new_chat_members, left_chat_member, + // new_chat_title, new_chat_photo, pinned_message, etc.) + return true +} diff --git a/internal/channels/zalo/personal/channel.go b/internal/channels/zalo/personal/channel.go index 73bdfdcf..e38210e9 100644 --- a/internal/channels/zalo/personal/channel.go +++ b/internal/channels/zalo/personal/channel.go @@ -3,30 +3,15 @@ package personal import ( "context" "fmt" - "io" "log/slog" - "net/http" - "os" - "path/filepath" - "strings" "sync" - "time" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/channels/typing" - "github.com/nextlevelbuilder/goclaw/internal/channels/zalo" "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/internal/tools" -) - -const ( - maxTextLength = 2000 - maxChannelRestarts = 10 - maxChannelBackoff = 60 * time.Second - code3000InitialDelay = 60 * time.Second ) // Channel connects to Zalo Personal Chat via the internal protocol port (from zcago, MIT). @@ -152,408 +137,3 @@ func (c *Channel) Stop(_ context.Context) error { c.SetRunning(false) return nil } - -// Send delivers an outbound message to a Zalo chat. -func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { - sess := c.session() - if !c.IsRunning() || sess == nil { - return fmt.Errorf("zalo_personal channel not running") - } - - // Strip markdown — Zalo does not support any markup rendering. - msg.Content = zalo.StripMarkdown(msg.Content) - - // Stop typing indicator before sending response - if ctrl, ok := c.typingCtrls.LoadAndDelete(msg.ChatID); ok { - ctrl.(*typing.Controller).Stop() - } - - threadType := protocol.ThreadTypeUser - if _, isGroup := c.approvedGroups.Load(msg.ChatID); isGroup { - threadType = protocol.ThreadTypeGroup - } else if msg.Metadata != nil { - if _, ok := msg.Metadata["group_id"]; ok { - threadType = protocol.ThreadTypeGroup - c.approvedGroups.Store(msg.ChatID, true) - } - } - - // Send media attachments. - for _, media := range msg.Media { - if protocol.IsImageFile(media.URL) { - if err := c.sendImage(ctx, sess, msg.ChatID, threadType, media.URL, media.Caption); err != nil { - slog.Warn("zalo_personal: failed to send image", "path", media.URL, "error", err) - } - } else { - if err := c.sendFile(ctx, sess, msg.ChatID, threadType, media.URL); err != nil { - slog.Warn("zalo_personal: failed to send file", "path", media.URL, "error", err) - } - } - } - - // Send text content (if any remains after media). - if msg.Content != "" { - return c.sendChunkedText(ctx, sess, msg.ChatID, threadType, msg.Content) - } - return nil -} - -// sendImage uploads and sends an image file to a Zalo thread. -func (c *Channel) sendImage(ctx context.Context, sess *protocol.Session, chatID string, threadType protocol.ThreadType, filePath, caption string) error { - upload, err := protocol.UploadImage(ctx, sess, chatID, threadType, filePath) - if err != nil { - return fmt.Errorf("upload: %w", err) - } - - _, err = protocol.SendImage(ctx, sess, chatID, threadType, upload, caption) - return err -} - -// sendFile uploads and sends a file to a Zalo thread. -func (c *Channel) sendFile(ctx context.Context, sess *protocol.Session, chatID string, threadType protocol.ThreadType, filePath string) error { - ln := c.getListener() - if ln == nil { - return fmt.Errorf("listener not available for file upload") - } - upload, err := protocol.UploadFile(ctx, sess, ln, chatID, threadType, filePath) - if err != nil { - return fmt.Errorf("upload: %w", err) - } - - _, err = protocol.SendFile(ctx, sess, chatID, threadType, upload) - return err -} - -func (c *Channel) listenLoop(ctx context.Context) { - defer c.SetRunning(false) - for { - if !c.runListenerLoop(ctx) { - return - } - } -} - -// runListenerLoop reads from the current listener until it closes. -// Returns true if the channel restarted and the outer loop should continue, -// false if the channel should stop permanently. -func (c *Channel) runListenerLoop(ctx context.Context) bool { - ln := c.getListener() - if ln == nil { - return false - } - for { - select { - case <-ctx.Done(): - slog.Info("zalo_personal listener loop stopped (context)") - return false - case <-c.stopCh: - slog.Info("zalo_personal listener loop stopped") - return false - - case msg, ok := <-ln.Messages(): - if !ok { - return false - } - c.handleMessage(msg) - - case ci := <-ln.Disconnected(): - slog.Warn("zalo_personal disconnected", "code", ci.Code, "reason", ci.Reason) - - case ci := <-ln.Closed(): - slog.Warn("zalo_personal connection closed", "code", ci.Code, "reason", ci.Reason) - - // Code 3000: wait 60s before retry (duplicate session may be transient) - if ci.Code == protocol.CloseCodeDuplicate { - slog.Warn("zalo_personal duplicate session (code 3000), waiting before retry", "channel", c.Name()) - select { - case <-ctx.Done(): - return false - case <-c.stopCh: - return false - case <-time.After(code3000InitialDelay): - } - } - - return c.restartWithBackoff(ctx) - - case err := <-ln.Errors(): - slog.Warn("zalo_personal listener error", "error", err) - } - } -} - -// restartWithBackoff attempts to restart the channel with exponential backoff. -// This is the channel-level retry layer for auth/session failures. The listener -// has its own internal retry for transient WS disconnects (endpoint rotation). -// When the listener exhausts its retries, it emits to closedCh which triggers this. -// Returns true if restart succeeded and the listen loop should continue. -func (c *Channel) restartWithBackoff(ctx context.Context) bool { - for attempt := range maxChannelRestarts { - delay := min(time.Duration(1< 0 { - finalContent = c.groupHistory.BuildContext(threadID, annotated, c.historyLimit) - } - - c.startTyping(threadID, protocol.ThreadTypeGroup) - - metadata := map[string]string{ - "message_id": msg.Data.MsgID, - "platform": "zalo_personal", - "group_id": threadID, - } - c.HandleMessage(senderID, threadID, finalContent, media, metadata, "group") - - // Clear pending history after sending to agent (matches Telegram/Discord/Slack/Feishu pattern). - c.groupHistory.Clear(threadID) -} - -// startTyping starts a typing indicator with keepalive for the given thread. -// Zalo typing expires after ~5s, so keepalive fires every 3s. -func (c *Channel) startTyping(threadID string, threadType protocol.ThreadType) { - sess := c.session() - ctrl := typing.New(typing.Options{ - MaxDuration: 60 * time.Second, - KeepaliveInterval: 4 * time.Second, - StartFn: func() error { - return protocol.SendTypingEvent(context.Background(), sess, threadID, threadType) - }, - }) - if prev, ok := c.typingCtrls.Load(threadID); ok { - prev.(*typing.Controller).Stop() - } - c.typingCtrls.Store(threadID, ctrl) - ctrl.Start() -} - -// extractContentAndMedia returns text content and optional local media paths from a message. -// For text messages, media is nil. For image attachments, the image is downloaded to a temp file. -func extractContentAndMedia(content protocol.Content) (string, []string) { - if text := content.Text(); text != "" { - return text, nil - } - att := content.ParseAttachment() - if att == nil { - return "", nil - } - text := content.AttachmentText() - if text == "" { - return "", nil - } - var media []string - if att.IsImage() { - if path, err := downloadFile(context.Background(), att.Href); err != nil { - slog.Warn("zalo_personal: failed to download image", "url", att.Href, "error", err) - } else { - media = []string{path} - } - } - return text, media -} - -const maxImageBytes = 10 * 1024 * 1024 // 10MB - -// downloadFile downloads an image URL to a temp file and returns the local path. -// Validates against SSRF and enforces HTTPS-only, timeout, and size limits. -func downloadFile(ctx context.Context, imageURL string) (string, error) { - if err := tools.CheckSSRF(imageURL); err != nil { - return "", fmt.Errorf("ssrf check: %w", err) - } - - client := &http.Client{Timeout: 30 * time.Second} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) - if err != nil { - return "", fmt.Errorf("download: %w", err) - } - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("download: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("download status %d", resp.StatusCode) - } - - // Strip query params before extracting extension - path := imageURL - if i := strings.IndexByte(path, '?'); i >= 0 { - path = path[:i] - } - ext := filepath.Ext(path) - if ext == "" || len(ext) > 5 { - ext = ".jpg" - } - - tmpFile, err := os.CreateTemp("", "goclaw_zca_*"+ext) - if err != nil { - return "", fmt.Errorf("create temp: %w", err) - } - defer tmpFile.Close() - - written, err := io.Copy(tmpFile, io.LimitReader(resp.Body, maxImageBytes+1)) - if err != nil { - os.Remove(tmpFile.Name()) - return "", fmt.Errorf("save: %w", err) - } - if written > maxImageBytes { - os.Remove(tmpFile.Name()) - return "", fmt.Errorf("image too large: %d bytes", written) - } - - return tmpFile.Name(), nil -} - -func (c *Channel) sendChunkedText(ctx context.Context, sess *protocol.Session, chatID string, threadType protocol.ThreadType, text string) error { - for len(text) > 0 { - chunk := text - if len(chunk) > maxTextLength { - cutAt := maxTextLength - if idx := strings.LastIndex(text[:maxTextLength], "\n"); idx > maxTextLength/2 { - cutAt = idx + 1 - } - chunk = text[:cutAt] - text = text[cutAt:] - } else { - text = "" - } - - if _, err := protocol.SendMessage(ctx, sess, chatID, threadType, chunk); err != nil { - return err - } - } - return nil -} diff --git a/internal/channels/zalo/personal/handlers.go b/internal/channels/zalo/personal/handlers.go new file mode 100644 index 00000000..5406c064 --- /dev/null +++ b/internal/channels/zalo/personal/handlers.go @@ -0,0 +1,218 @@ +package personal + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/channels/typing" + "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol" + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +func (c *Channel) handleMessage(msg protocol.Message) { + if msg.IsSelf() { + return + } + + switch m := msg.(type) { + case protocol.UserMessage: + c.handleDM(m) + case protocol.GroupMessage: + c.handleGroupMessage(m) + } +} + +func (c *Channel) handleDM(msg protocol.UserMessage) { + senderID := msg.Data.UIDFrom + threadID := msg.ThreadID() + + content, media := extractContentAndMedia(msg.Data.Content) + if content == "" { + return + } + + if !c.checkDMPolicy(senderID, threadID) { + return + } + + slog.Debug("zalo_personal DM received", + "sender", senderID, + "thread", threadID, + "preview", channels.Truncate(content, 50), + ) + + c.startTyping(threadID, protocol.ThreadTypeUser) + + metadata := map[string]string{ + "message_id": msg.Data.MsgID, + "platform": "zalo_personal", + } + c.HandleMessage(senderID, threadID, content, media, metadata, "direct") +} + +func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { + senderID := msg.Data.UIDFrom + threadID := msg.ThreadID() + + content, media := extractContentAndMedia(msg.Data.Content) + if content == "" { + return + } + + // Step 1: enforce access policy (allowlist/pairing). Hard reject — don't record history. + if !c.checkGroupPolicy(senderID, threadID) { + return + } + + senderName := msg.Data.DName + if senderName == "" { + senderName = senderID + } + + // Step 2: @mention gating — record non-mentioned messages in history and return. + if c.requireMention { + wasMentioned := c.checkBotMentioned(msg.Data.Mentions) + if !wasMentioned { + c.groupHistory.Record(threadID, channels.HistoryEntry{ + Sender: senderName, + Body: content, + Timestamp: time.Now(), + MessageID: msg.Data.MsgID, + }, c.historyLimit) + slog.Debug("zalo_personal group message recorded (no mention)", + "group_id", threadID, + "sender", senderName, + ) + return + } + } + + slog.Debug("zalo_personal group message received", + "sender", senderID, + "group", threadID, + "preview", channels.Truncate(content, 50), + ) + + // Step 3: flush pending history + annotate current message with sender name. + annotated := fmt.Sprintf("[From: %s]\n%s", senderName, content) + finalContent := annotated + if c.historyLimit > 0 { + finalContent = c.groupHistory.BuildContext(threadID, annotated, c.historyLimit) + } + + c.startTyping(threadID, protocol.ThreadTypeGroup) + + metadata := map[string]string{ + "message_id": msg.Data.MsgID, + "platform": "zalo_personal", + "group_id": threadID, + } + c.HandleMessage(senderID, threadID, finalContent, media, metadata, "group") + + // Clear pending history after sending to agent (matches Telegram/Discord/Slack/Feishu pattern). + c.groupHistory.Clear(threadID) +} + +// startTyping starts a typing indicator with keepalive for the given thread. +// Zalo typing expires after ~5s, so keepalive fires every 3s. +func (c *Channel) startTyping(threadID string, threadType protocol.ThreadType) { + sess := c.session() + ctrl := typing.New(typing.Options{ + MaxDuration: 60 * time.Second, + KeepaliveInterval: 4 * time.Second, + StartFn: func() error { + return protocol.SendTypingEvent(context.Background(), sess, threadID, threadType) + }, + }) + if prev, ok := c.typingCtrls.Load(threadID); ok { + prev.(*typing.Controller).Stop() + } + c.typingCtrls.Store(threadID, ctrl) + ctrl.Start() +} + +// extractContentAndMedia returns text content and optional local media paths from a message. +// For text messages, media is nil. For image attachments, the image is downloaded to a temp file. +func extractContentAndMedia(content protocol.Content) (string, []string) { + if text := content.Text(); text != "" { + return text, nil + } + att := content.ParseAttachment() + if att == nil { + return "", nil + } + text := content.AttachmentText() + if text == "" { + return "", nil + } + var media []string + if att.IsImage() { + if path, err := downloadFile(context.Background(), att.Href); err != nil { + slog.Warn("zalo_personal: failed to download image", "url", att.Href, "error", err) + } else { + media = []string{path} + } + } + return text, media +} + +const maxImageBytes = 10 * 1024 * 1024 // 10MB + +// downloadFile downloads an image URL to a temp file and returns the local path. +// Validates against SSRF and enforces HTTPS-only, timeout, and size limits. +func downloadFile(ctx context.Context, imageURL string) (string, error) { + if err := tools.CheckSSRF(imageURL); err != nil { + return "", fmt.Errorf("ssrf check: %w", err) + } + + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + return "", fmt.Errorf("download: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download status %d", resp.StatusCode) + } + + // Strip query params before extracting extension + path := imageURL + if i := strings.IndexByte(path, '?'); i >= 0 { + path = path[:i] + } + ext := filepath.Ext(path) + if ext == "" || len(ext) > 5 { + ext = ".jpg" + } + + tmpFile, err := os.CreateTemp("", "goclaw_zca_*"+ext) + if err != nil { + return "", fmt.Errorf("create temp: %w", err) + } + defer tmpFile.Close() + + written, err := io.Copy(tmpFile, io.LimitReader(resp.Body, maxImageBytes+1)) + if err != nil { + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("save: %w", err) + } + if written > maxImageBytes { + os.Remove(tmpFile.Name()) + return "", fmt.Errorf("image too large: %d bytes", written) + } + + return tmpFile.Name(), nil +} diff --git a/internal/channels/zalo/personal/listen.go b/internal/channels/zalo/personal/listen.go new file mode 100644 index 00000000..99133287 --- /dev/null +++ b/internal/channels/zalo/personal/listen.go @@ -0,0 +1,129 @@ +package personal + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol" +) + +const ( + maxChannelRestarts = 10 + maxChannelBackoff = 60 * time.Second + code3000InitialDelay = 60 * time.Second +) + +func (c *Channel) listenLoop(ctx context.Context) { + defer c.SetRunning(false) + for { + if !c.runListenerLoop(ctx) { + return + } + } +} + +// runListenerLoop reads from the current listener until it closes. +// Returns true if the channel restarted and the outer loop should continue, +// false if the channel should stop permanently. +func (c *Channel) runListenerLoop(ctx context.Context) bool { + ln := c.getListener() + if ln == nil { + return false + } + for { + select { + case <-ctx.Done(): + slog.Info("zalo_personal listener loop stopped (context)") + return false + case <-c.stopCh: + slog.Info("zalo_personal listener loop stopped") + return false + + case msg, ok := <-ln.Messages(): + if !ok { + return false + } + c.handleMessage(msg) + + case ci := <-ln.Disconnected(): + slog.Warn("zalo_personal disconnected", "code", ci.Code, "reason", ci.Reason) + + case ci := <-ln.Closed(): + slog.Warn("zalo_personal connection closed", "code", ci.Code, "reason", ci.Reason) + + // Code 3000: wait 60s before retry (duplicate session may be transient) + if ci.Code == protocol.CloseCodeDuplicate { + slog.Warn("zalo_personal duplicate session (code 3000), waiting before retry", "channel", c.Name()) + select { + case <-ctx.Done(): + return false + case <-c.stopCh: + return false + case <-time.After(code3000InitialDelay): + } + } + + return c.restartWithBackoff(ctx) + + case err := <-ln.Errors(): + slog.Warn("zalo_personal listener error", "error", err) + } + } +} + +// restartWithBackoff attempts to restart the channel with exponential backoff. +// This is the channel-level retry layer for auth/session failures. The listener +// has its own internal retry for transient WS disconnects (endpoint rotation). +// When the listener exhausts its retries, it emits to closedCh which triggers this. +// Returns true if restart succeeded and the listen loop should continue. +func (c *Channel) restartWithBackoff(ctx context.Context) bool { + for attempt := range maxChannelRestarts { + delay := min(time.Duration(1< 0 { + chunk := text + if len(chunk) > maxTextLength { + cutAt := maxTextLength + if idx := strings.LastIndex(text[:maxTextLength], "\n"); idx > maxTextLength/2 { + cutAt = idx + 1 + } + chunk = text[:cutAt] + text = text[cutAt:] + } else { + text = "" + } + + if _, err := protocol.SendMessage(ctx, sess, chatID, threadType, chunk); err != nil { + return err + } + } + return nil +} diff --git a/internal/gateway/methods/agents.go b/internal/gateway/methods/agents.go index f2d6c217..5992f053 100644 --- a/internal/gateway/methods/agents.go +++ b/internal/gateway/methods/agents.go @@ -3,13 +3,9 @@ package methods import ( "context" "encoding/json" - "fmt" "log/slog" - "os" - "path/filepath" "github.com/nextlevelbuilder/goclaw/internal/agent" - "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -153,381 +149,3 @@ func (m *AgentsMethods) handleList(ctx context.Context, client *gateway.Client, "agents": infos, })) } - -// --- agents.create --- -// Matching TS src/gateway/server-methods/agents.ts:216-287 - -func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - var params struct { - Name string `json:"name"` - Workspace string `json:"workspace"` - Emoji string `json:"emoji"` - Avatar string `json:"avatar"` - AgentType string `json:"agent_type"` // "open" (default) or "predefined" - OwnerIDs []string `json:"owner_ids,omitempty"` // first entry used as DB owner_id; falls back to "system" - // Per-agent config overrides - ToolsConfig json.RawMessage `json:"tools_config,omitempty"` - SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` - SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"` - MemoryConfig json.RawMessage `json:"memory_config,omitempty"` - CompactionConfig json.RawMessage `json:"compaction_config,omitempty"` - ContextPruning json.RawMessage `json:"context_pruning,omitempty"` - OtherConfig json.RawMessage `json:"other_config,omitempty"` - } - if req.Params != nil { - json.Unmarshal(req.Params, ¶ms) - } - - if params.Name == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required")) - return - } - - agentType := params.AgentType - if agentType == "" { - agentType = store.AgentTypeOpen - } - - agentID := config.NormalizeAgentID(params.Name) - if agentID == "default" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot create agent with reserved id 'default'")) - return - } - - // Resolve workspace - ws := params.Workspace - if ws == "" { - ws = filepath.Join(m.workspace, "agents", agentID) - } else { - ws = config.ExpandHome(ws) - } - - if m.agentStore != nil { - // --- Managed mode: create agent in DB --- - ctx := context.Background() - - // Check if agent already exists in DB - if existing, _ := m.agentStore.GetByKey(ctx, agentID); existing != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent already exists: "+agentID)) - return - } - - // Resolve owner: use first provided ID so external provisioning tools (e.g. goclaw-wizards) - // can set a real user as owner at creation time. Falls back to "system" for backward compat. - ownerID := "system" - if len(params.OwnerIDs) > 0 && params.OwnerIDs[0] != "" { - ownerID = params.OwnerIDs[0] - } - - agentData := &store.AgentData{ - AgentKey: agentID, - DisplayName: params.Name, - OwnerID: ownerID, - AgentType: agentType, - Provider: m.cfg.Agents.Defaults.Provider, - Model: m.cfg.Agents.Defaults.Model, - Workspace: ws, - Status: store.AgentStatusActive, - ToolsConfig: params.ToolsConfig, - SubagentsConfig: params.SubagentsConfig, - SandboxConfig: params.SandboxConfig, - MemoryConfig: params.MemoryConfig, - CompactionConfig: params.CompactionConfig, - ContextPruning: params.ContextPruning, - OtherConfig: params.OtherConfig, - } - if err := m.agentStore.Create(ctx, agentData); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to create agent: %v", err))) - return - } - - // Seed context files to DB (skipped for open agents) - if _, err := bootstrap.SeedToStore(ctx, m.agentStore, agentData.ID, agentData.AgentType); err != nil { - slog.Warn("failed to seed bootstrap for agent", "agent", agentID, "error", err) - } - - // Set identity in DB bootstrap - if params.Name != "" || params.Emoji != "" || params.Avatar != "" { - content := buildIdentityContent(params.Name, params.Emoji, params.Avatar) - if err := m.agentStore.SetAgentContextFile(ctx, agentData.ID, "IDENTITY.md", content); err != nil { - slog.Warn("failed to set IDENTITY.md", "agent", agentID, "error", err) - } - } - - // Invalidate router cache so resolver re-loads from DB - m.agents.InvalidateAgent(agentID) - } else { - // --- Fallback: config.json + filesystem --- - if _, ok := m.cfg.Agents.List[agentID]; ok { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent already exists: "+agentID)) - return - } - - spec := config.AgentSpec{ - DisplayName: params.Name, - Workspace: ws, - } - if params.Emoji != "" || params.Avatar != "" { - spec.Identity = &config.IdentityConfig{ - Emoji: params.Emoji, - } - } - - if m.cfg.Agents.List == nil { - m.cfg.Agents.List = make(map[string]config.AgentSpec) - } - m.cfg.Agents.List[agentID] = spec - - if err := config.Save(m.cfgPath, m.cfg); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error())) - return - } - - // Append identity metadata to IDENTITY.md - if params.Name != "" || params.Emoji != "" || params.Avatar != "" { - identityPath := filepath.Join(ws, "IDENTITY.md") - appendIdentityFields(identityPath, params.Name, params.Emoji, params.Avatar) - } - } - - // Both modes: create workspace dir + seed filesystem backup - os.MkdirAll(ws, 0755) - bootstrap.EnsureWorkspaceFiles(ws) - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ - "ok": true, - "agentId": agentID, - "name": params.Name, - "workspace": ws, - })) -} - -// --- agents.update --- -// Matching TS src/gateway/server-methods/agents.ts:288-346 - -func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - var params struct { - AgentID string `json:"agentId"` - Name string `json:"name"` - Workspace string `json:"workspace"` - Model string `json:"model"` - Avatar string `json:"avatar"` - // Per-agent config overrides - ToolsConfig json.RawMessage `json:"tools_config,omitempty"` - SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` - SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"` - MemoryConfig json.RawMessage `json:"memory_config,omitempty"` - CompactionConfig json.RawMessage `json:"compaction_config,omitempty"` - ContextPruning json.RawMessage `json:"context_pruning,omitempty"` - OtherConfig json.RawMessage `json:"other_config,omitempty"` - } - if req.Params != nil { - json.Unmarshal(req.Params, ¶ms) - } - - if params.AgentID == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agentId is required")) - return - } - - if m.agentStore != nil { - // --- Managed mode: update agent in DB --- - ctx := context.Background() - ag, err := m.agentStore.GetByKey(ctx, params.AgentID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID)) - return - } - - updates := map[string]any{} - if params.Name != "" { - updates["display_name"] = params.Name - } - if params.Workspace != "" { - ws := config.ExpandHome(params.Workspace) - updates["workspace"] = ws - os.MkdirAll(ws, 0755) - } - if params.Model != "" { - updates["model"] = params.Model - } - // Per-agent JSONB config overrides - if len(params.ToolsConfig) > 0 { - updates["tools_config"] = []byte(params.ToolsConfig) - } - if len(params.SubagentsConfig) > 0 { - updates["subagents_config"] = []byte(params.SubagentsConfig) - } - if len(params.SandboxConfig) > 0 { - updates["sandbox_config"] = []byte(params.SandboxConfig) - } - if len(params.MemoryConfig) > 0 { - updates["memory_config"] = []byte(params.MemoryConfig) - } - if len(params.CompactionConfig) > 0 { - updates["compaction_config"] = []byte(params.CompactionConfig) - } - if len(params.ContextPruning) > 0 { - updates["context_pruning"] = []byte(params.ContextPruning) - } - if len(params.OtherConfig) > 0 { - updates["other_config"] = []byte(params.OtherConfig) - } - - if len(updates) > 0 { - if err := m.agentStore.Update(ctx, ag.ID, updates); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to update agent: %v", err))) - return - } - } - - // Update identity in DB bootstrap - if params.Avatar != "" || params.Name != "" { - content := buildIdentityContent(params.Name, "", params.Avatar) - if err := m.agentStore.SetAgentContextFile(ctx, ag.ID, "IDENTITY.md", content); err != nil { - slog.Warn("failed to update IDENTITY.md", "agent", params.AgentID, "error", err) - } - // Invalidate interceptor cache so updated IDENTITY.md is served immediately - if m.interceptor != nil { - m.interceptor.InvalidateAgent(ag.ID) - } - } - - m.agents.InvalidateAgent(params.AgentID) - } else { - // --- Fallback: config.json --- - spec, ok := m.cfg.Agents.List[params.AgentID] - if !ok { - if params.AgentID != "default" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID)) - return - } - } - - if params.Name != "" { - spec.DisplayName = params.Name - } - if params.Workspace != "" { - spec.Workspace = config.ExpandHome(params.Workspace) - os.MkdirAll(spec.Workspace, 0755) - } - if params.Model != "" { - spec.Model = params.Model - } - - if params.AgentID == "default" { - if params.Model != "" { - m.cfg.Agents.Defaults.Model = params.Model - } - if params.Workspace != "" { - m.cfg.Agents.Defaults.Workspace = params.Workspace - } - } else { - m.cfg.Agents.List[params.AgentID] = spec - } - - if params.Avatar != "" { - ws := spec.Workspace - if ws == "" { - ws = config.ExpandHome(m.cfg.Agents.Defaults.Workspace) - } - identityPath := filepath.Join(ws, "IDENTITY.md") - appendIdentityFields(identityPath, "", "", params.Avatar) - } - - if err := config.Save(m.cfgPath, m.cfg); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error())) - return - } - } - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ - "ok": true, - "agentId": params.AgentID, - })) -} - -// --- agents.delete --- -// Matching TS src/gateway/server-methods/agents.ts:347-398 - -func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - var params struct { - AgentID string `json:"agentId"` - DeleteFiles bool `json:"deleteFiles"` - } - params.DeleteFiles = true // default - if req.Params != nil { - json.Unmarshal(req.Params, ¶ms) - } - - if params.AgentID == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agentId is required")) - return - } - if params.AgentID == "default" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot delete the default agent")) - return - } - - var removedBindings int - - if m.agentStore != nil { - // --- Managed mode: delete from DB --- - ctx := context.Background() - ag, err := m.agentStore.GetByKey(ctx, params.AgentID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID)) - return - } - - if err := m.agentStore.Delete(ctx, ag.ID); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to delete agent: %v", err))) - return - } - - m.agents.InvalidateAgent(params.AgentID) - m.agents.Remove(params.AgentID) - - // Best-effort delete workspace - if params.DeleteFiles && ag.Workspace != "" { - os.RemoveAll(ag.Workspace) - } - } else { - // --- Fallback: config.json --- - spec, ok := m.cfg.Agents.List[params.AgentID] - if !ok { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID)) - return - } - - delete(m.cfg.Agents.List, params.AgentID) - - var kept []config.AgentBinding - for _, b := range m.cfg.Bindings { - if b.AgentID == params.AgentID { - removedBindings++ - } else { - kept = append(kept, b) - } - } - m.cfg.Bindings = kept - - m.agents.Remove(params.AgentID) - - if err := config.Save(m.cfgPath, m.cfg); err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error())) - return - } - - if params.DeleteFiles && spec.Workspace != "" { - ws := config.ExpandHome(spec.Workspace) - os.RemoveAll(ws) - } - } - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ - "ok": true, - "agentId": params.AgentID, - "removedBindings": removedBindings, - })) -} diff --git a/internal/gateway/methods/agents_create.go b/internal/gateway/methods/agents_create.go new file mode 100644 index 00000000..c54cc416 --- /dev/null +++ b/internal/gateway/methods/agents_create.go @@ -0,0 +1,164 @@ +package methods + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// --- agents.create --- +// Matching TS src/gateway/server-methods/agents.ts:216-287 + +func (m *AgentsMethods) handleCreate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params struct { + Name string `json:"name"` + Workspace string `json:"workspace"` + Emoji string `json:"emoji"` + Avatar string `json:"avatar"` + AgentType string `json:"agent_type"` // "open" (default) or "predefined" + OwnerIDs []string `json:"owner_ids,omitempty"` // first entry used as DB owner_id; falls back to "system" + // Per-agent config overrides + ToolsConfig json.RawMessage `json:"tools_config,omitempty"` + SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` + SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"` + MemoryConfig json.RawMessage `json:"memory_config,omitempty"` + CompactionConfig json.RawMessage `json:"compaction_config,omitempty"` + ContextPruning json.RawMessage `json:"context_pruning,omitempty"` + OtherConfig json.RawMessage `json:"other_config,omitempty"` + } + if req.Params != nil { + json.Unmarshal(req.Params, ¶ms) + } + + if params.Name == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "name is required")) + return + } + + agentType := params.AgentType + if agentType == "" { + agentType = store.AgentTypeOpen + } + + agentID := config.NormalizeAgentID(params.Name) + if agentID == "default" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot create agent with reserved id 'default'")) + return + } + + // Resolve workspace + ws := params.Workspace + if ws == "" { + ws = filepath.Join(m.workspace, "agents", agentID) + } else { + ws = config.ExpandHome(ws) + } + + if m.agentStore != nil { + // --- Managed mode: create agent in DB --- + ctx := context.Background() + + // Check if agent already exists in DB + if existing, _ := m.agentStore.GetByKey(ctx, agentID); existing != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent already exists: "+agentID)) + return + } + + // Resolve owner: use first provided ID so external provisioning tools (e.g. goclaw-wizards) + // can set a real user as owner at creation time. Falls back to "system" for backward compat. + ownerID := "system" + if len(params.OwnerIDs) > 0 && params.OwnerIDs[0] != "" { + ownerID = params.OwnerIDs[0] + } + + agentData := &store.AgentData{ + AgentKey: agentID, + DisplayName: params.Name, + OwnerID: ownerID, + AgentType: agentType, + Provider: m.cfg.Agents.Defaults.Provider, + Model: m.cfg.Agents.Defaults.Model, + Workspace: ws, + Status: store.AgentStatusActive, + ToolsConfig: params.ToolsConfig, + SubagentsConfig: params.SubagentsConfig, + SandboxConfig: params.SandboxConfig, + MemoryConfig: params.MemoryConfig, + CompactionConfig: params.CompactionConfig, + ContextPruning: params.ContextPruning, + OtherConfig: params.OtherConfig, + } + if err := m.agentStore.Create(ctx, agentData); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to create agent: %v", err))) + return + } + + // Seed context files to DB (skipped for open agents) + if _, err := bootstrap.SeedToStore(ctx, m.agentStore, agentData.ID, agentData.AgentType); err != nil { + slog.Warn("failed to seed bootstrap for agent", "agent", agentID, "error", err) + } + + // Set identity in DB bootstrap + if params.Name != "" || params.Emoji != "" || params.Avatar != "" { + content := buildIdentityContent(params.Name, params.Emoji, params.Avatar) + if err := m.agentStore.SetAgentContextFile(ctx, agentData.ID, "IDENTITY.md", content); err != nil { + slog.Warn("failed to set IDENTITY.md", "agent", agentID, "error", err) + } + } + + // Invalidate router cache so resolver re-loads from DB + m.agents.InvalidateAgent(agentID) + } else { + // --- Fallback: config.json + filesystem --- + if _, ok := m.cfg.Agents.List[agentID]; ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent already exists: "+agentID)) + return + } + + spec := config.AgentSpec{ + DisplayName: params.Name, + Workspace: ws, + } + if params.Emoji != "" || params.Avatar != "" { + spec.Identity = &config.IdentityConfig{ + Emoji: params.Emoji, + } + } + + if m.cfg.Agents.List == nil { + m.cfg.Agents.List = make(map[string]config.AgentSpec) + } + m.cfg.Agents.List[agentID] = spec + + if err := config.Save(m.cfgPath, m.cfg); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error())) + return + } + + // Append identity metadata to IDENTITY.md + if params.Name != "" || params.Emoji != "" || params.Avatar != "" { + identityPath := filepath.Join(ws, "IDENTITY.md") + appendIdentityFields(identityPath, params.Name, params.Emoji, params.Avatar) + } + } + + // Both modes: create workspace dir + seed filesystem backup + os.MkdirAll(ws, 0755) + bootstrap.EnsureWorkspaceFiles(ws) + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "ok": true, + "agentId": agentID, + "name": params.Name, + "workspace": ws, + })) +} diff --git a/internal/gateway/methods/agents_delete.go b/internal/gateway/methods/agents_delete.go new file mode 100644 index 00000000..6c63444c --- /dev/null +++ b/internal/gateway/methods/agents_delete.go @@ -0,0 +1,97 @@ +package methods + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// --- agents.delete --- +// Matching TS src/gateway/server-methods/agents.ts:347-398 + +func (m *AgentsMethods) handleDelete(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params struct { + AgentID string `json:"agentId"` + DeleteFiles bool `json:"deleteFiles"` + } + params.DeleteFiles = true // default + if req.Params != nil { + json.Unmarshal(req.Params, ¶ms) + } + + if params.AgentID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agentId is required")) + return + } + if params.AgentID == "default" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "cannot delete the default agent")) + return + } + + var removedBindings int + + if m.agentStore != nil { + // --- Managed mode: delete from DB --- + ctx := context.Background() + ag, err := m.agentStore.GetByKey(ctx, params.AgentID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID)) + return + } + + if err := m.agentStore.Delete(ctx, ag.ID); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to delete agent: %v", err))) + return + } + + m.agents.InvalidateAgent(params.AgentID) + m.agents.Remove(params.AgentID) + + // Best-effort delete workspace + if params.DeleteFiles && ag.Workspace != "" { + os.RemoveAll(ag.Workspace) + } + } else { + // --- Fallback: config.json --- + spec, ok := m.cfg.Agents.List[params.AgentID] + if !ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID)) + return + } + + delete(m.cfg.Agents.List, params.AgentID) + + var kept []config.AgentBinding + for _, b := range m.cfg.Bindings { + if b.AgentID == params.AgentID { + removedBindings++ + } else { + kept = append(kept, b) + } + } + m.cfg.Bindings = kept + + m.agents.Remove(params.AgentID) + + if err := config.Save(m.cfgPath, m.cfg); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error())) + return + } + + if params.DeleteFiles && spec.Workspace != "" { + ws := config.ExpandHome(spec.Workspace) + os.RemoveAll(ws) + } + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "ok": true, + "agentId": params.AgentID, + "removedBindings": removedBindings, + })) +} diff --git a/internal/gateway/methods/agents_update.go b/internal/gateway/methods/agents_update.go new file mode 100644 index 00000000..4fd59743 --- /dev/null +++ b/internal/gateway/methods/agents_update.go @@ -0,0 +1,159 @@ +package methods + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// --- agents.update --- +// Matching TS src/gateway/server-methods/agents.ts:288-346 + +func (m *AgentsMethods) handleUpdate(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { + var params struct { + AgentID string `json:"agentId"` + Name string `json:"name"` + Workspace string `json:"workspace"` + Model string `json:"model"` + Avatar string `json:"avatar"` + // Per-agent config overrides + ToolsConfig json.RawMessage `json:"tools_config,omitempty"` + SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` + SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"` + MemoryConfig json.RawMessage `json:"memory_config,omitempty"` + CompactionConfig json.RawMessage `json:"compaction_config,omitempty"` + ContextPruning json.RawMessage `json:"context_pruning,omitempty"` + OtherConfig json.RawMessage `json:"other_config,omitempty"` + } + if req.Params != nil { + json.Unmarshal(req.Params, ¶ms) + } + + if params.AgentID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agentId is required")) + return + } + + if m.agentStore != nil { + // --- Managed mode: update agent in DB --- + ctx := context.Background() + ag, err := m.agentStore.GetByKey(ctx, params.AgentID) + if err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID)) + return + } + + updates := map[string]any{} + if params.Name != "" { + updates["display_name"] = params.Name + } + if params.Workspace != "" { + ws := config.ExpandHome(params.Workspace) + updates["workspace"] = ws + os.MkdirAll(ws, 0755) + } + if params.Model != "" { + updates["model"] = params.Model + } + // Per-agent JSONB config overrides + if len(params.ToolsConfig) > 0 { + updates["tools_config"] = []byte(params.ToolsConfig) + } + if len(params.SubagentsConfig) > 0 { + updates["subagents_config"] = []byte(params.SubagentsConfig) + } + if len(params.SandboxConfig) > 0 { + updates["sandbox_config"] = []byte(params.SandboxConfig) + } + if len(params.MemoryConfig) > 0 { + updates["memory_config"] = []byte(params.MemoryConfig) + } + if len(params.CompactionConfig) > 0 { + updates["compaction_config"] = []byte(params.CompactionConfig) + } + if len(params.ContextPruning) > 0 { + updates["context_pruning"] = []byte(params.ContextPruning) + } + if len(params.OtherConfig) > 0 { + updates["other_config"] = []byte(params.OtherConfig) + } + + if len(updates) > 0 { + if err := m.agentStore.Update(ctx, ag.ID, updates); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, fmt.Sprintf("failed to update agent: %v", err))) + return + } + } + + // Update identity in DB bootstrap + if params.Avatar != "" || params.Name != "" { + content := buildIdentityContent(params.Name, "", params.Avatar) + if err := m.agentStore.SetAgentContextFile(ctx, ag.ID, "IDENTITY.md", content); err != nil { + slog.Warn("failed to update IDENTITY.md", "agent", params.AgentID, "error", err) + } + // Invalidate interceptor cache so updated IDENTITY.md is served immediately + if m.interceptor != nil { + m.interceptor.InvalidateAgent(ag.ID) + } + } + + m.agents.InvalidateAgent(params.AgentID) + } else { + // --- Fallback: config.json --- + spec, ok := m.cfg.Agents.List[params.AgentID] + if !ok { + if params.AgentID != "default" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, "agent not found: "+params.AgentID)) + return + } + } + + if params.Name != "" { + spec.DisplayName = params.Name + } + if params.Workspace != "" { + spec.Workspace = config.ExpandHome(params.Workspace) + os.MkdirAll(spec.Workspace, 0755) + } + if params.Model != "" { + spec.Model = params.Model + } + + if params.AgentID == "default" { + if params.Model != "" { + m.cfg.Agents.Defaults.Model = params.Model + } + if params.Workspace != "" { + m.cfg.Agents.Defaults.Workspace = params.Workspace + } + } else { + m.cfg.Agents.List[params.AgentID] = spec + } + + if params.Avatar != "" { + ws := spec.Workspace + if ws == "" { + ws = config.ExpandHome(m.cfg.Agents.Defaults.Workspace) + } + identityPath := filepath.Join(ws, "IDENTITY.md") + appendIdentityFields(identityPath, "", "", params.Avatar) + } + + if err := config.Save(m.cfgPath, m.cfg); err != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "failed to save config: "+err.Error())) + return + } + } + + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{ + "ok": true, + "agentId": params.AgentID, + })) +} diff --git a/internal/http/mcp.go b/internal/http/mcp.go index 25fdd2fb..82c3aac2 100644 --- a/internal/http/mcp.go +++ b/internal/http/mcp.go @@ -8,7 +8,6 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" - mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -195,332 +194,3 @@ func (h *MCPHandler) handleDeleteServer(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } -// --- Test connection --- - -func (h *MCPHandler) handleTestConnection(w http.ResponseWriter, r *http.Request) { - var req struct { - Transport string `json:"transport"` - Command string `json:"command"` - Args []string `json:"args"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - Env map[string]string `json:"env"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) - return - } - if req.Transport == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "transport is required"}) - return - } - - tools, err := mcpbridge.DiscoverTools(r.Context(), req.Transport, req.Command, req.Args, req.Env, req.URL, req.Headers) - if err != nil { - writeJSON(w, http.StatusOK, map[string]interface{}{ - "success": false, - "error": err.Error(), - }) - return - } - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "tool_count": len(tools), - }) -} - -// --- Server tools --- - -func (h *MCPHandler) handleListServerTools(w http.ResponseWriter, r *http.Request) { - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) - return - } - - srv, err := h.store.GetServer(r.Context(), id) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"}) - return - } - - // Try runtime Manager first — returns names only (no descriptions available). - var tools []mcpbridge.ToolInfo - if h.mgr != nil { - if names := h.mgr.ServerToolNames(srv.Name); len(names) > 0 { - tools = make([]mcpbridge.ToolInfo, len(names)) - for i, n := range names { - tools[i] = mcpbridge.ToolInfo{Name: n} - } - } - } - - // Fallback: on-demand discovery (returns names + descriptions). - if len(tools) == 0 && srv.Transport != "" { - var args []string - var env, headers map[string]string - _ = json.Unmarshal(srv.Args, &args) - _ = json.Unmarshal(srv.Env, &env) - _ = json.Unmarshal(srv.Headers, &headers) - - discovered, err := mcpbridge.DiscoverTools(r.Context(), srv.Transport, srv.Command, args, env, srv.URL, headers) - if err != nil { - slog.Warn("mcp.discover_tools", "server", srv.Name, "error", err) - } else { - tools = discovered - } - } - - if tools == nil { - tools = []mcpbridge.ToolInfo{} - } - writeJSON(w, http.StatusOK, map[string]interface{}{"tools": tools}) -} - -// --- Agent grants --- - -func (h *MCPHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) { - serverID, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) - return - } - - var req struct { - AgentID string `json:"agent_id"` - ToolAllow json.RawMessage `json:"tool_allow,omitempty"` - ToolDeny json.RawMessage `json:"tool_deny,omitempty"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) - return - } - - agentID, err := uuid.Parse(req.AgentID) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"}) - return - } - - grant := store.MCPAgentGrant{ - ServerID: serverID, - AgentID: agentID, - Enabled: true, - ToolAllow: req.ToolAllow, - ToolDeny: req.ToolDeny, - GrantedBy: store.UserIDFromContext(r.Context()), - } - - if err := h.store.GrantToAgent(r.Context(), &grant); err != nil { - slog.Error("mcp.grant_agent", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - h.emitCacheInvalidate() - writeJSON(w, http.StatusCreated, map[string]string{"status": "granted"}) -} - -func (h *MCPHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request) { - serverID, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) - return - } - - agentID, err := uuid.Parse(r.PathValue("agentID")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"}) - return - } - - if err := h.store.RevokeFromAgent(r.Context(), serverID, agentID); err != nil { - slog.Error("mcp.revoke_agent", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - h.emitCacheInvalidate() - writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) -} - -func (h *MCPHandler) handleListAgentGrants(w http.ResponseWriter, r *http.Request) { - agentID, err := uuid.Parse(r.PathValue("agentID")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"}) - return - } - - grants, err := h.store.ListAgentGrants(r.Context(), agentID) - if err != nil { - slog.Error("mcp.list_agent_grants", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - writeJSON(w, http.StatusOK, map[string]interface{}{"grants": grants}) -} - -func (h *MCPHandler) handleListServerGrants(w http.ResponseWriter, r *http.Request) { - serverID, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) - return - } - - grants, err := h.store.ListServerGrants(r.Context(), serverID) - if err != nil { - slog.Error("mcp.list_server_grants", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list grants"}) - return - } - - writeJSON(w, http.StatusOK, map[string]interface{}{"grants": grants}) -} - -// --- User grants --- - -func (h *MCPHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) { - serverID, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) - return - } - - var req struct { - UserID string `json:"user_id"` - ToolAllow json.RawMessage `json:"tool_allow,omitempty"` - ToolDeny json.RawMessage `json:"tool_deny,omitempty"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) - return - } - - if req.UserID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id is required"}) - return - } - if err := store.ValidateUserID(req.UserID); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - - grant := store.MCPUserGrant{ - ServerID: serverID, - UserID: req.UserID, - Enabled: true, - ToolAllow: req.ToolAllow, - ToolDeny: req.ToolDeny, - GrantedBy: store.UserIDFromContext(r.Context()), - } - - if err := h.store.GrantToUser(r.Context(), &grant); err != nil { - slog.Error("mcp.grant_user", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - h.emitCacheInvalidate() - writeJSON(w, http.StatusCreated, map[string]string{"status": "granted"}) -} - -func (h *MCPHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request) { - serverID, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) - return - } - - targetUserID := r.PathValue("userID") - if err := store.ValidateUserID(targetUserID); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - - if err := h.store.RevokeFromUser(r.Context(), serverID, targetUserID); err != nil { - slog.Error("mcp.revoke_user", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - h.emitCacheInvalidate() - writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) -} - -// --- Access requests --- - -func (h *MCPHandler) handleCreateRequest(w http.ResponseWriter, r *http.Request) { - var req store.MCPAccessRequest - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) - return - } - - if req.ServerID == uuid.Nil || req.Scope == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "server_id and scope are required"}) - return - } - if req.Scope != "agent" && req.Scope != "user" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "scope must be 'agent' or 'user'"}) - return - } - - req.RequestedBy = store.UserIDFromContext(r.Context()) - req.Status = "pending" - - if err := h.store.CreateRequest(r.Context(), &req); err != nil { - slog.Error("mcp.create_request", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - writeJSON(w, http.StatusCreated, req) -} - -func (h *MCPHandler) handleListPendingRequests(w http.ResponseWriter, r *http.Request) { - requests, err := h.store.ListPendingRequests(r.Context()) - if err != nil { - slog.Error("mcp.list_pending_requests", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - writeJSON(w, http.StatusOK, map[string]interface{}{"requests": requests}) -} - -func (h *MCPHandler) handleReviewRequest(w http.ResponseWriter, r *http.Request) { - requestID, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request ID"}) - return - } - - var req struct { - Approved bool `json:"approved"` - Note string `json:"note,omitempty"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) - return - } - - reviewedBy := store.UserIDFromContext(r.Context()) - - if err := h.store.ReviewRequest(r.Context(), requestID, req.Approved, reviewedBy, req.Note); err != nil { - slog.Error("mcp.review_request", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - if req.Approved { - h.emitCacheInvalidate() - } - - status := "rejected" - if req.Approved { - status = "approved" - } - writeJSON(w, http.StatusOK, map[string]string{"status": status}) -} diff --git a/internal/http/mcp_grants.go b/internal/http/mcp_grants.go new file mode 100644 index 00000000..d8409d80 --- /dev/null +++ b/internal/http/mcp_grants.go @@ -0,0 +1,178 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (h *MCPHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) { + serverID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + var req struct { + AgentID string `json:"agent_id"` + ToolAllow json.RawMessage `json:"tool_allow,omitempty"` + ToolDeny json.RawMessage `json:"tool_deny,omitempty"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + agentID, err := uuid.Parse(req.AgentID) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id"}) + return + } + + grant := store.MCPAgentGrant{ + ServerID: serverID, + AgentID: agentID, + Enabled: true, + ToolAllow: req.ToolAllow, + ToolDeny: req.ToolDeny, + GrantedBy: store.UserIDFromContext(r.Context()), + } + + if err := h.store.GrantToAgent(r.Context(), &grant); err != nil { + slog.Error("mcp.grant_agent", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + h.emitCacheInvalidate() + writeJSON(w, http.StatusCreated, map[string]string{"status": "granted"}) +} + +func (h *MCPHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request) { + serverID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + agentID, err := uuid.Parse(r.PathValue("agentID")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"}) + return + } + + if err := h.store.RevokeFromAgent(r.Context(), serverID, agentID); err != nil { + slog.Error("mcp.revoke_agent", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + h.emitCacheInvalidate() + writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) +} + +func (h *MCPHandler) handleListAgentGrants(w http.ResponseWriter, r *http.Request) { + agentID, err := uuid.Parse(r.PathValue("agentID")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"}) + return + } + + grants, err := h.store.ListAgentGrants(r.Context(), agentID) + if err != nil { + slog.Error("mcp.list_agent_grants", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"grants": grants}) +} + +func (h *MCPHandler) handleListServerGrants(w http.ResponseWriter, r *http.Request) { + serverID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + grants, err := h.store.ListServerGrants(r.Context(), serverID) + if err != nil { + slog.Error("mcp.list_server_grants", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list grants"}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"grants": grants}) +} + +func (h *MCPHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) { + serverID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + var req struct { + UserID string `json:"user_id"` + ToolAllow json.RawMessage `json:"tool_allow,omitempty"` + ToolDeny json.RawMessage `json:"tool_deny,omitempty"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + if req.UserID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id is required"}) + return + } + if err := store.ValidateUserID(req.UserID); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + grant := store.MCPUserGrant{ + ServerID: serverID, + UserID: req.UserID, + Enabled: true, + ToolAllow: req.ToolAllow, + ToolDeny: req.ToolDeny, + GrantedBy: store.UserIDFromContext(r.Context()), + } + + if err := h.store.GrantToUser(r.Context(), &grant); err != nil { + slog.Error("mcp.grant_user", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + h.emitCacheInvalidate() + writeJSON(w, http.StatusCreated, map[string]string{"status": "granted"}) +} + +func (h *MCPHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request) { + serverID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + targetUserID := r.PathValue("userID") + if err := store.ValidateUserID(targetUserID); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + if err := h.store.RevokeFromUser(r.Context(), serverID, targetUserID); err != nil { + slog.Error("mcp.revoke_user", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + h.emitCacheInvalidate() + writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) +} diff --git a/internal/http/mcp_requests.go b/internal/http/mcp_requests.go new file mode 100644 index 00000000..4a193258 --- /dev/null +++ b/internal/http/mcp_requests.go @@ -0,0 +1,85 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (h *MCPHandler) handleCreateRequest(w http.ResponseWriter, r *http.Request) { + var req store.MCPAccessRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + if req.ServerID == uuid.Nil || req.Scope == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "server_id and scope are required"}) + return + } + if req.Scope != "agent" && req.Scope != "user" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "scope must be 'agent' or 'user'"}) + return + } + + req.RequestedBy = store.UserIDFromContext(r.Context()) + req.Status = "pending" + + if err := h.store.CreateRequest(r.Context(), &req); err != nil { + slog.Error("mcp.create_request", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusCreated, req) +} + +func (h *MCPHandler) handleListPendingRequests(w http.ResponseWriter, r *http.Request) { + requests, err := h.store.ListPendingRequests(r.Context()) + if err != nil { + slog.Error("mcp.list_pending_requests", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"requests": requests}) +} + +func (h *MCPHandler) handleReviewRequest(w http.ResponseWriter, r *http.Request) { + requestID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request ID"}) + return + } + + var req struct { + Approved bool `json:"approved"` + Note string `json:"note,omitempty"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + + reviewedBy := store.UserIDFromContext(r.Context()) + + if err := h.store.ReviewRequest(r.Context(), requestID, req.Approved, reviewedBy, req.Note); err != nil { + slog.Error("mcp.review_request", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + if req.Approved { + h.emitCacheInvalidate() + } + + status := "rejected" + if req.Approved { + status = "approved" + } + writeJSON(w, http.StatusOK, map[string]string{"status": status}) +} diff --git a/internal/http/mcp_tools.go b/internal/http/mcp_tools.go new file mode 100644 index 00000000..a0202f27 --- /dev/null +++ b/internal/http/mcp_tools.go @@ -0,0 +1,92 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/google/uuid" + + mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" +) + +// handleTestConnection tests an MCP server connection without saving it. +func (h *MCPHandler) handleTestConnection(w http.ResponseWriter, r *http.Request) { + var req struct { + Transport string `json:"transport"` + Command string `json:"command"` + Args []string `json:"args"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Env map[string]string `json:"env"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.Transport == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "transport is required"}) + return + } + + tools, err := mcpbridge.DiscoverTools(r.Context(), req.Transport, req.Command, req.Args, req.Env, req.URL, req.Headers) + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "tool_count": len(tools), + }) +} + +// handleListServerTools lists tools for a specific MCP server. +func (h *MCPHandler) handleListServerTools(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + srv, err := h.store.GetServer(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "server not found"}) + return + } + + // Try runtime Manager first — returns names only (no descriptions available). + var tools []mcpbridge.ToolInfo + if h.mgr != nil { + if names := h.mgr.ServerToolNames(srv.Name); len(names) > 0 { + tools = make([]mcpbridge.ToolInfo, len(names)) + for i, n := range names { + tools[i] = mcpbridge.ToolInfo{Name: n} + } + } + } + + // Fallback: on-demand discovery (returns names + descriptions). + if len(tools) == 0 && srv.Transport != "" { + var args []string + var env, headers map[string]string + _ = json.Unmarshal(srv.Args, &args) + _ = json.Unmarshal(srv.Env, &env) + _ = json.Unmarshal(srv.Headers, &headers) + + discovered, err := mcpbridge.DiscoverTools(r.Context(), srv.Transport, srv.Command, args, env, srv.URL, headers) + if err != nil { + slog.Warn("mcp.discover_tools", "server", srv.Name, "error", err) + } else { + tools = discovered + } + } + + if tools == nil { + tools = []mcpbridge.ToolInfo{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"tools": tools}) +} diff --git a/internal/http/skills.go b/internal/http/skills.go index 5364dba4..aef302ce 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -1,19 +1,9 @@ package http import ( - "archive/zip" - "crypto/sha256" "encoding/json" - "fmt" - "io" - "log/slog" "net/http" - "os" - "path/filepath" "regexp" - "sort" - "strconv" - "strings" "github.com/google/uuid" @@ -144,386 +134,3 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) } -// handleUpload processes a ZIP file upload containing a skill (must have SKILL.md at root). -func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { - userID := store.UserIDFromContext(r.Context()) - if userID == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "X-GoClaw-User-Id header required"}) - return - } - - r.Body = http.MaxBytesReader(w, r.Body, maxSkillUploadSize) - - file, header, err := r.FormFile("file") - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file is required: " + err.Error()}) - return - } - defer file.Close() - - // Save to temp file for zip processing - tmp, err := os.CreateTemp("", "skill-upload-*.zip") - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create temp file"}) - return - } - defer os.Remove(tmp.Name()) - defer tmp.Close() - - hasher := sha256.New() - size, err := io.Copy(io.MultiWriter(tmp, hasher), file) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save upload"}) - return - } - fileHash := fmt.Sprintf("%x", hasher.Sum(nil)) - - // Open as zip - zr, err := zip.OpenReader(tmp.Name()) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid ZIP file"}) - return - } - defer zr.Close() - - // Validate: must have SKILL.md at root or inside a single top-level directory. - // Many ZIP tools wrap contents in a folder (e.g. "my-skill/SKILL.md"). - var skillMD *zip.File - var stripPrefix string - for _, f := range zr.File { - name := strings.TrimPrefix(f.Name, "./") - if name == "SKILL.md" { - skillMD = f - stripPrefix = "" - break - } - // Allow one level of directory nesting: "dirname/SKILL.md" - parts := strings.SplitN(name, "/", 3) - if len(parts) == 2 && parts[1] == "SKILL.md" && !f.FileInfo().IsDir() { - skillMD = f - stripPrefix = parts[0] + "/" - break - } - } - if skillMD == nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ZIP must contain SKILL.md at root (or inside a single top-level directory)"}) - return - } - - // Read and parse SKILL.md frontmatter - skillContent, err := readZipFile(skillMD) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read SKILL.md"}) - return - } - - name, description, slug, frontmatter := parseSkillFrontmatter(skillContent) - if name == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "SKILL.md must have a name in frontmatter"}) - return - } - if slug == "" { - slug = slugify(name) - } - if !slugRegexp.MatchString(slug) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "slug must be a valid slug (lowercase letters, numbers, hyphens only)"}) - return - } - - // Determine version (always increment — includes archived skills so re-upload gets v2+) - version := h.skills.GetNextVersion(slug) - - // Extract to filesystem: baseDir/slug/version/ - destDir := filepath.Join(h.baseDir, slug, fmt.Sprintf("%d", version)) - if err := os.MkdirAll(destDir, 0755); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create skill directory"}) - return - } - - for _, f := range zr.File { - if f.FileInfo().IsDir() { - continue - } - // Skip symlinks in ZIP — prevent directory escape attacks - if f.Mode()&os.ModeSymlink != 0 { - continue - } - // Strip wrapper directory prefix if ZIP had one - entryName := strings.TrimPrefix(f.Name, "./") - if stripPrefix != "" { - entryName = strings.TrimPrefix(entryName, stripPrefix) - if entryName == "" { - continue - } - } - // Skip macOS/system artifacts - if isSystemArtifact(entryName) { - continue - } - // Security: prevent path traversal - name := filepath.Clean(entryName) - if strings.Contains(name, "..") { - continue - } - destPath := filepath.Join(destDir, name) - if !strings.HasPrefix(destPath, destDir+string(filepath.Separator)) { - continue - } - if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { - continue - } - data, err := readZipFile(f) - if err != nil { - continue - } - os.WriteFile(destPath, []byte(data), 0644) - } - - // Save metadata to DB - desc := description - skill := pg.SkillCreateParams{ - Name: name, - Slug: slug, - Description: &desc, - OwnerID: userID, - Visibility: "internal", - Version: version, - FilePath: destDir, - FileSize: size, - FileHash: &fileHash, - Frontmatter: frontmatter, - } - - id, err := h.skills.CreateSkillManaged(r.Context(), skill) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save skill: " + err.Error()}) - return - } - - h.skills.BumpVersion() - slog.Info("skill uploaded", "id", id, "slug", slug, "version", version, "size", header.Size) - - writeJSON(w, http.StatusCreated, map[string]interface{}{ - "id": id, - "slug": slug, - "version": version, - "name": name, - }) -} - -func (h *SkillsHandler) handleListAgentSkills(w http.ResponseWriter, r *http.Request) { - agentIDStr := r.PathValue("agentID") - agentID, err := uuid.Parse(agentIDStr) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"}) - return - } - - skills, err := h.skills.ListWithGrantStatus(r.Context(), agentID) - if err != nil { - slog.Error("failed to list skills with grant status", "agent_id", agentID, "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list skills"}) - return - } - - writeJSON(w, http.StatusOK, map[string]interface{}{"skills": skills}) -} - -// handleListVersions returns all available version numbers for a skill. -func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Request) { - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"}) - return - } - - _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) - if !ok { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) - return - } - - slugDir := filepath.Join(h.baseDir, slug) - entries, err := os.ReadDir(slugDir) - if err != nil { - writeJSON(w, http.StatusOK, map[string]interface{}{ - "versions": []int{currentVersion}, - "current": currentVersion, - }) - return - } - - var versions []int - for _, e := range entries { - if !e.IsDir() { - continue - } - v, err := strconv.Atoi(e.Name()) - if err != nil || v < 1 { - continue - } - versions = append(versions, v) - } - sort.Ints(versions) - if len(versions) == 0 { - versions = []int{currentVersion} - } - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "versions": versions, - "current": currentVersion, - }) -} - -// handleListFiles returns all files in a skill version directory. -func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) { - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"}) - return - } - - _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) - if !ok { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) - return - } - - version := currentVersion - if v := r.URL.Query().Get("version"); v != "" { - parsed, err := strconv.Atoi(v) - if err != nil || parsed < 1 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"}) - return - } - version = parsed - } - - versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version)) - if _, err := os.Stat(versionDir); err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "version not found"}) - return - } - - type fileEntry struct { - Path string `json:"path"` - Name string `json:"name"` - IsDir bool `json:"isDir"` - Size int64 `json:"size"` - } - - var files []fileEntry - filepath.WalkDir(versionDir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - rel, _ := filepath.Rel(versionDir, path) - if rel == "." { - return nil - } - // Skip system artifacts (__MACOSX, .DS_Store, etc.) - if isSystemArtifact(rel) { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - // Skip symlinks — prevent escape from skill directory - if d.Type()&os.ModeSymlink != 0 { - return nil - } - entry := fileEntry{ - Path: rel, - Name: d.Name(), - IsDir: d.IsDir(), - } - if !d.IsDir() { - if info, err := d.Info(); err == nil { - entry.Size = info.Size() - } - } - files = append(files, entry) - return nil - }) - - if files == nil { - files = []fileEntry{} - } - writeJSON(w, http.StatusOK, map[string]interface{}{"files": files}) -} - -// handleReadFile reads a single file from a skill version directory. -func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) { - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"}) - return - } - - relPath := r.PathValue("path") - if relPath == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"}) - return - } - if strings.Contains(relPath, "..") { - slog.Warn("security.skill_files_traversal", "path", relPath) - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) - return - } - - _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) - if !ok { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) - return - } - - version := currentVersion - if v := r.URL.Query().Get("version"); v != "" { - parsed, err := strconv.Atoi(v) - if err != nil || parsed < 1 { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"}) - return - } - version = parsed - } - - versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version)) - absPath := filepath.Join(versionDir, filepath.Clean(relPath)) - - // Verify resolved path is within the version directory - if !strings.HasPrefix(absPath, versionDir+string(filepath.Separator)) { - slog.Warn("security.skill_files_escape", "resolved", absPath, "root", versionDir) - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) - return - } - - // Use Lstat to detect symlinks — reject them to prevent directory escape - info, err := os.Lstat(absPath) - if err != nil || info.IsDir() { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"}) - return - } - if info.Mode()&os.ModeSymlink != 0 { - slog.Warn("security.skill_files_symlink", "path", absPath) - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) - return - } - - // Skip system artifacts - if isSystemArtifact(relPath) { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"}) - return - } - - data, err := os.ReadFile(absPath) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"}) - return - } - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "content": string(data), - "path": relPath, - "size": info.Size(), - }) -} diff --git a/internal/http/skills_grants.go b/internal/http/skills_grants.go index 09ee3517..76bc4082 100644 --- a/internal/http/skills_grants.go +++ b/internal/http/skills_grants.go @@ -4,6 +4,7 @@ import ( "archive/zip" "encoding/json" "io" + "log/slog" "net/http" "path/filepath" "strings" @@ -14,6 +15,24 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +func (h *SkillsHandler) handleListAgentSkills(w http.ResponseWriter, r *http.Request) { + agentIDStr := r.PathValue("agentID") + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent ID"}) + return + } + + skills, err := h.skills.ListWithGrantStatus(r.Context(), agentID) + if err != nil { + slog.Error("failed to list skills with grant status", "agent_id", agentID, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list skills"}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{"skills": skills}) +} + func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) { userID := store.UserIDFromContext(r.Context()) idStr := r.PathValue("id") diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go new file mode 100644 index 00000000..e87a2fe9 --- /dev/null +++ b/internal/http/skills_upload.go @@ -0,0 +1,183 @@ +package http + +import ( + "archive/zip" + "crypto/sha256" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/store/pg" +) + +// handleUpload processes a ZIP file upload containing a skill (must have SKILL.md at root). +func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { + userID := store.UserIDFromContext(r.Context()) + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "X-GoClaw-User-Id header required"}) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxSkillUploadSize) + + file, header, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file is required: " + err.Error()}) + return + } + defer file.Close() + + // Save to temp file for zip processing + tmp, err := os.CreateTemp("", "skill-upload-*.zip") + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create temp file"}) + return + } + defer os.Remove(tmp.Name()) + defer tmp.Close() + + hasher := sha256.New() + size, err := io.Copy(io.MultiWriter(tmp, hasher), file) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save upload"}) + return + } + fileHash := fmt.Sprintf("%x", hasher.Sum(nil)) + + // Open as zip + zr, err := zip.OpenReader(tmp.Name()) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid ZIP file"}) + return + } + defer zr.Close() + + // Validate: must have SKILL.md at root or inside a single top-level directory. + // Many ZIP tools wrap contents in a folder (e.g. "my-skill/SKILL.md"). + var skillMD *zip.File + var stripPrefix string + for _, f := range zr.File { + name := strings.TrimPrefix(f.Name, "./") + if name == "SKILL.md" { + skillMD = f + stripPrefix = "" + break + } + // Allow one level of directory nesting: "dirname/SKILL.md" + parts := strings.SplitN(name, "/", 3) + if len(parts) == 2 && parts[1] == "SKILL.md" && !f.FileInfo().IsDir() { + skillMD = f + stripPrefix = parts[0] + "/" + break + } + } + if skillMD == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ZIP must contain SKILL.md at root (or inside a single top-level directory)"}) + return + } + + // Read and parse SKILL.md frontmatter + skillContent, err := readZipFile(skillMD) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read SKILL.md"}) + return + } + + name, description, slug, frontmatter := parseSkillFrontmatter(skillContent) + if name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "SKILL.md must have a name in frontmatter"}) + return + } + if slug == "" { + slug = slugify(name) + } + if !slugRegexp.MatchString(slug) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "slug must be a valid slug (lowercase letters, numbers, hyphens only)"}) + return + } + + // Determine version (always increment — includes archived skills so re-upload gets v2+) + version := h.skills.GetNextVersion(slug) + + // Extract to filesystem: baseDir/slug/version/ + destDir := filepath.Join(h.baseDir, slug, fmt.Sprintf("%d", version)) + if err := os.MkdirAll(destDir, 0755); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create skill directory"}) + return + } + + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + // Skip symlinks in ZIP — prevent directory escape attacks + if f.Mode()&os.ModeSymlink != 0 { + continue + } + // Strip wrapper directory prefix if ZIP had one + entryName := strings.TrimPrefix(f.Name, "./") + if stripPrefix != "" { + entryName = strings.TrimPrefix(entryName, stripPrefix) + if entryName == "" { + continue + } + } + // Skip macOS/system artifacts + if isSystemArtifact(entryName) { + continue + } + // Security: prevent path traversal + name := filepath.Clean(entryName) + if strings.Contains(name, "..") { + continue + } + destPath := filepath.Join(destDir, name) + if !strings.HasPrefix(destPath, destDir+string(filepath.Separator)) { + continue + } + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + continue + } + data, err := readZipFile(f) + if err != nil { + continue + } + os.WriteFile(destPath, []byte(data), 0644) + } + + // Save metadata to DB + desc := description + skill := pg.SkillCreateParams{ + Name: name, + Slug: slug, + Description: &desc, + OwnerID: userID, + Visibility: "internal", + Version: version, + FilePath: destDir, + FileSize: size, + FileHash: &fileHash, + Frontmatter: frontmatter, + } + + id, err := h.skills.CreateSkillManaged(r.Context(), skill) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save skill: " + err.Error()}) + return + } + + h.skills.BumpVersion() + slog.Info("skill uploaded", "id", id, "slug", slug, "version", version, "size", header.Size) + + writeJSON(w, http.StatusCreated, map[string]interface{}{ + "id": id, + "slug": slug, + "version": version, + "name": name, + }) +} diff --git a/internal/http/skills_versions.go b/internal/http/skills_versions.go new file mode 100644 index 00000000..a295f8f4 --- /dev/null +++ b/internal/http/skills_versions.go @@ -0,0 +1,212 @@ +package http + +import ( + "log/slog" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/google/uuid" +) + +// handleListVersions returns all available version numbers for a skill. +func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"}) + return + } + + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) + return + } + + slugDir := filepath.Join(h.baseDir, slug) + entries, err := os.ReadDir(slugDir) + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "versions": []int{currentVersion}, + "current": currentVersion, + }) + return + } + + var versions []int + for _, e := range entries { + if !e.IsDir() { + continue + } + v, err := strconv.Atoi(e.Name()) + if err != nil || v < 1 { + continue + } + versions = append(versions, v) + } + sort.Ints(versions) + if len(versions) == 0 { + versions = []int{currentVersion} + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "versions": versions, + "current": currentVersion, + }) +} + +// handleListFiles returns all files in a skill version directory. +func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"}) + return + } + + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) + return + } + + version := currentVersion + if v := r.URL.Query().Get("version"); v != "" { + parsed, err := strconv.Atoi(v) + if err != nil || parsed < 1 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"}) + return + } + version = parsed + } + + versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version)) + if _, err := os.Stat(versionDir); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "version not found"}) + return + } + + type fileEntry struct { + Path string `json:"path"` + Name string `json:"name"` + IsDir bool `json:"isDir"` + Size int64 `json:"size"` + } + + var files []fileEntry + filepath.WalkDir(versionDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + rel, _ := filepath.Rel(versionDir, path) + if rel == "." { + return nil + } + // Skip system artifacts (__MACOSX, .DS_Store, etc.) + if isSystemArtifact(rel) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + // Skip symlinks — prevent escape from skill directory + if d.Type()&os.ModeSymlink != 0 { + return nil + } + entry := fileEntry{ + Path: rel, + Name: d.Name(), + IsDir: d.IsDir(), + } + if !d.IsDir() { + if info, err := d.Info(); err == nil { + entry.Size = info.Size() + } + } + files = append(files, entry) + return nil + }) + + if files == nil { + files = []fileEntry{} + } + writeJSON(w, http.StatusOK, map[string]interface{}{"files": files}) +} + +// handleReadFile reads a single file from a skill version directory. +func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill ID"}) + return + } + + relPath := r.PathValue("path") + if relPath == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is required"}) + return + } + if strings.Contains(relPath, "..") { + slog.Warn("security.skill_files_traversal", "path", relPath) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + if !ok { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"}) + return + } + + version := currentVersion + if v := r.URL.Query().Get("version"); v != "" { + parsed, err := strconv.Atoi(v) + if err != nil || parsed < 1 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version"}) + return + } + version = parsed + } + + versionDir := filepath.Join(h.baseDir, slug, strconv.Itoa(version)) + absPath := filepath.Join(versionDir, filepath.Clean(relPath)) + + // Verify resolved path is within the version directory + if !strings.HasPrefix(absPath, versionDir+string(filepath.Separator)) { + slog.Warn("security.skill_files_escape", "resolved", absPath, "root", versionDir) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + // Use Lstat to detect symlinks — reject them to prevent directory escape + info, err := os.Lstat(absPath) + if err != nil || info.IsDir() { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"}) + return + } + if info.Mode()&os.ModeSymlink != 0 { + slog.Warn("security.skill_files_symlink", "path", absPath) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) + return + } + + // Skip system artifacts + if isSystemArtifact(relPath) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"}) + return + } + + data, err := os.ReadFile(absPath) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read file"}) + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "content": string(data), + "path": relPath, + "size": info.Size(), + }) +} diff --git a/internal/http/summoner.go b/internal/http/summoner.go index 8c1d6f0c..8713350f 100644 --- a/internal/http/summoner.go +++ b/internal/http/summoner.go @@ -2,12 +2,8 @@ package http import ( "context" - "errors" - "fmt" "log/slog" - "net" "regexp" - "strings" "time" "github.com/google/uuid" @@ -16,7 +12,6 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // Summoning event type constants. @@ -220,502 +215,3 @@ func (s *AgentSummoner) loadExistingFiles(ctx context.Context, agentID uuid.UUID } return m } - -// RegenerateAgent updates context files based on an edit prompt. -// Reads existing files, sends them + edit instructions to LLM, stores results. -// Synchronous — caller should run in goroutine if needed. -func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, editPrompt string) { - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) - defer cancel() - - s.ensureUserPredefined(ctx, agentID) - - s.emitEvent(agentID, SummonEventStarted, "", "") - - // Read existing files for context - existing, err := s.agents.GetAgentContextFiles(ctx, agentID) - if err != nil { - slog.Warn("summoning: failed to read existing files", "agent", agentID, "error", err) - s.emitEvent(agentID, SummonEventFailed, "", err.Error()) - s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) - return - } - - prompt := s.buildEditPrompt(existing, editPrompt) - - files, err := s.generateFiles(ctx, providerName, model, prompt) - if err != nil { - slog.Warn("summoning: regeneration failed", "agent", agentID, "error", err) - s.emitEvent(agentID, SummonEventFailed, "", err.Error()) - // Use fresh context — the original may have timed out, but we still need to update status. - s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) - return - } - - s.storeFiles(ctx, agentID, files) - - // Update frontmatter + display_name if IDENTITY.md was regenerated - updates := map[string]any{} - if fm, ok := files[frontmatterKey]; ok && fm != "" { - updates["frontmatter"] = fm - } - if name := extractIdentityName(files[bootstrap.IdentityFile]); name != "" { - updates["display_name"] = name - } - if len(updates) > 0 { - if err := s.agents.Update(ctx, agentID, updates); err != nil { - slog.Warn("summoning: failed to save agent metadata", "agent", agentID, "error", err) - } - } - - s.setAgentStatus(ctx, agentID, store.AgentStatusActive) - s.emitEvent(agentID, SummonEventCompleted, "", "") - - slog.Info("summoning: regeneration completed", "agent", agentID, "files", len(files)) -} - -// isRetryableError returns true for timeout and context-cancellation errors -// that warrant falling back to the 2-call approach. -func isRetryableError(err error) bool { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - return true - } - var netErr net.Error - if errors.As(err, &netErr) && netErr.Timeout() { - return true - } - return false -} - -// isGenerated checks if a context file has been generated (differs from the default template). -func (s *AgentSummoner) isGenerated(existingMap map[string]string, fileName string) bool { - content, ok := existingMap[fileName] - if !ok || content == "" { - return false - } - template, err := bootstrap.ReadTemplate(fileName) - if err != nil { - return false - } - return content != template -} - -// generateFiles calls the LLM and parses the XML-tagged response into file map. -func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, prompt string) (map[string]string, error) { - provider, err := s.resolveProvider(providerName) - if err != nil { - return nil, fmt.Errorf("resolve provider: %w", err) - } - - // Use a unique session key so CLI-based providers get an isolated workdir - // (prevents polluting/reading CLAUDE.md from active chat sessions). - summonSessionKey := "summon-" + uuid.New().String() - - slog.Info("summoning: calling LLM", "provider", providerName, "model", model, "prompt_len", len(prompt)) - - resp, err := provider.Chat(ctx, providers.ChatRequest{ - Messages: []providers.Message{ - {Role: "system", Content: "You are a file generator. Output ONLY the requested XML-tagged files. No extra commentary."}, - {Role: "user", Content: prompt}, - }, - Model: model, - Options: map[string]interface{}{ - "max_tokens": 8192, - "temperature": 0.7, - providers.OptSessionKey: summonSessionKey, - providers.OptDisableTools: true, - }, - }) - if err != nil { - return nil, fmt.Errorf("%s: %w", providerName, err) - } - - slog.Info("summoning: raw LLM response", "provider", providerName, "length", len(resp.Content), - "preview_start", truncateUTF8(resp.Content, 500), - "preview_end", truncateUTF8(suffixString(resp.Content, 500), 500)) - - files := parseFileResponse(resp.Content) - if len(files) == 0 { - return nil, fmt.Errorf("LLM returned no parseable files (response length: %d)", len(resp.Content)) - } - - return files, nil -} - -// storeFiles saves generated files to agent_context_files and emits progress events. -func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files map[string]string) { - for _, name := range summoningFiles { - content, ok := files[name] - if !ok || content == "" { - continue - } - if err := s.agents.SetAgentContextFile(ctx, agentID, name, content); err != nil { - slog.Warn("summoning: failed to store file", "agent", agentID, "file", name, "error", err) - continue - } - s.emitEvent(agentID, SummonEventFileGenerated, name, "") - } -} - -func (s *AgentSummoner) resolveProvider(name string) (providers.Provider, error) { - if s.providerReg == nil { - return nil, fmt.Errorf("no provider registry") - } - - provider, err := s.providerReg.Get(name) - if err != nil { - // Fallback to first available provider - names := s.providerReg.List() - if len(names) == 0 { - return nil, fmt.Errorf("no providers configured") - } - provider, err = s.providerReg.Get(names[0]) - if err != nil { - return nil, err - } - slog.Warn("summoning: provider not found, using fallback", "wanted", name, "using", names[0]) - } - return provider, nil -} - -// ensureUserPredefined seeds USER_PREDEFINED.md template if it doesn't exist yet. -// Backfills agents created before this feature was added. -func (s *AgentSummoner) ensureUserPredefined(ctx context.Context, agentID uuid.UUID) { - existing, err := s.agents.GetAgentContextFiles(ctx, agentID) - if err != nil { - return - } - for _, f := range existing { - if f.FileName == bootstrap.UserPredefinedFile { - return // already exists - } - } - if tpl, err := bootstrap.ReadTemplate(bootstrap.UserPredefinedFile); err == nil { - _ = s.agents.SetAgentContextFile(ctx, agentID, bootstrap.UserPredefinedFile, tpl) - } -} - -func (s *AgentSummoner) setAgentStatus(ctx context.Context, agentID uuid.UUID, status string) { - if err := s.agents.Update(ctx, agentID, map[string]any{"status": status}); err != nil { - slog.Warn("summoning: failed to update agent status", "agent", agentID, "status", status, "error", err) - } -} - -func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg string) { - if s.msgBus == nil { - return - } - payload := map[string]interface{}{ - "type": eventType, - "agent_id": agentID.String(), - } - if fileName != "" { - payload["file"] = fileName - } - if errMsg != "" { - payload["error"] = errMsg - } - s.msgBus.Broadcast(bus.Event{ - Name: protocol.EventAgentSummoning, - Payload: payload, - }) -} - -// buildSoulPrompt constructs the prompt for SOUL.md generation. -func (s *AgentSummoner) buildSoulPrompt(description string) string { - var sb strings.Builder - sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate the SOUL.md file that defines its personality.\n\n") - - fmt.Fprintf(&sb, "\n%s\n\n\n", description) - - soulTemplate, err := bootstrap.ReadTemplate(bootstrap.SoulFile) - if err != nil { - slog.Warn("summoning: failed to read SOUL.md template", "error", err) - } - if soulTemplate != "" { - fmt.Fprintf(&sb, "\n\n", soulTemplate) - } - - sb.WriteString(`IMPORTANT RULES: - -1. Language: Write ALL content in the SAME LANGUAGE as the . If description is in Vietnamese, write in Vietnamese. If in English, write in English. BUT keep ALL headings and section titles in English exactly as in the templates. - -2. SOUL.md section guide — each section has a specific purpose: - - "## Core Truths" — universal personality traits. KEEP the general advice. Do NOT inject agent-specific references here. - - "## Boundaries" — rules and limits. CUSTOMIZE only if the description mentions specific boundaries. - - "## Vibe" — communication style and personality ONLY. How the agent talks, its tone, its attitude. Do NOT put technical knowledge here. - - "## Style" — communication preferences: tone, humor level, emoji usage, opinion strength, response length, formality. Generate SPECIFIC values based on the description. E.g. a cute sweet bot → warm tone, frequent emoji, playful humor. A formal business bot → professional tone, no emoji, measured opinions. These are knobs the user can later customize per agent. - - "## Expertise" — domain-specific knowledge, technical skills, specialized instructions, keywords, parameters. If the description mentions any specialized domain (e.g. image generation, coding, writing), put that knowledge HERE. Remove the placeholder text. If no domain expertise, omit this section entirely. - - "## Continuity" — keep as-is (just translate if needed). - - KEEP the exact English headings. Do NOT add the agent's name into Core Truths or Boundaries. - -3. Generate a short expertise summary (1-2 sentences, under 200 characters) for delegation discovery. - -Output format: - - -(short expertise summary here) - - - -(content here) -`) - - return sb.String() -} - -// buildIdentityPrompt constructs the prompt for IDENTITY.md + USER_PREDEFINED.md generation, -// using the already-generated SOUL.md as context for consistency. -func (s *AgentSummoner) buildIdentityPrompt(description, soulContent string) string { - var sb strings.Builder - sb.WriteString("You are setting up a new AI assistant. The SOUL.md (personality) has already been generated. Now generate IDENTITY.md and optionally USER_PREDEFINED.md based on the description and soul.\n\n") - - fmt.Fprintf(&sb, "\n%s\n\n\n", description) - - if soulContent != "" { - fmt.Fprintf(&sb, "\n%s\n\n\n", soulContent) - } - - identityTemplate, err := bootstrap.ReadTemplate(bootstrap.IdentityFile) - if err != nil { - slog.Warn("summoning: failed to read IDENTITY.md template", "error", err) - } - userPredefinedTemplate, err := bootstrap.ReadTemplate(bootstrap.UserPredefinedFile) - if err != nil { - slog.Warn("summoning: failed to read USER_PREDEFINED.md template", "error", err) - } - - sb.WriteString("\n") - if identityTemplate != "" { - fmt.Fprintf(&sb, "\n%s\n\n", identityTemplate) - } - if userPredefinedTemplate != "" { - fmt.Fprintf(&sb, "\n%s\n\n", userPredefinedTemplate) - } - sb.WriteString("\n\n") - - sb.WriteString(`IMPORTANT RULES: - -1. Language: Write ALL content in the SAME LANGUAGE as the . Keep headings in English. - -2. IDENTITY.md rules: - - KEEP the exact English heading: "# IDENTITY.md - Who Am I?" - - Fill in ONLY the field values: Name, Creature, Purpose, Vibe, Emoji based on the description and soul. - - The Name, Creature, and Vibe should MATCH the personality defined in the soul. - - Purpose: mission statement — what this agent does, key resources, focus areas. Can be multiple lines. Include URLs or references mentioned in the description. - - REMOVE all template placeholder/instruction text (the italic hints in parentheses). - - Leave Avatar blank. - - Keep the footer note section as-is. - -3. USER_PREDEFINED.md (OPTIONAL — only generate if relevant): - - Generate this file if the description mentions ANY of: owner/creator info (name, username, role), target users/audience, user groups, communication policies, language requirements, or group-specific context. - - This is the RIGHT place for information about specific people (owner, creator, team members, contacts) — do NOT put personal/people info in IDENTITY.md. - - If the description is purely about the agent's personality/expertise with no people or user-context, OMIT this file entirely. - - Content: owner/creator info, baseline rules for ALL users — default language, communication norms, audience assumptions, boundaries that individual users cannot override. - - Keep headings in English. Write content in the same language as the description. - -Output format: - - -(content here) - - - -(content here — or omit this entire block if not relevant) -`) - - return sb.String() -} - -// buildCreatePrompt constructs the prompt for generating all files in a single LLM call. -// Used by the optimistic single-call path; generates frontmatter + SOUL.md + IDENTITY.md + USER_PREDEFINED.md. -func (s *AgentSummoner) buildCreatePrompt(description string) string { - var sb strings.Builder - sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate the required files.\n\n") - - fmt.Fprintf(&sb, "\n%s\n\n\n", description) - - soulTemplate, err := bootstrap.ReadTemplate(bootstrap.SoulFile) - if err != nil { - slog.Warn("summoning: failed to read SOUL.md template", "error", err) - } - identityTemplate, err := bootstrap.ReadTemplate(bootstrap.IdentityFile) - if err != nil { - slog.Warn("summoning: failed to read IDENTITY.md template", "error", err) - } - userPredefinedTemplate, err := bootstrap.ReadTemplate(bootstrap.UserPredefinedFile) - if err != nil { - slog.Warn("summoning: failed to read USER_PREDEFINED.md template", "error", err) - } - - sb.WriteString("\n") - if soulTemplate != "" { - fmt.Fprintf(&sb, "\n%s\n\n", soulTemplate) - } - if identityTemplate != "" { - fmt.Fprintf(&sb, "\n%s\n\n", identityTemplate) - } - if userPredefinedTemplate != "" { - fmt.Fprintf(&sb, "\n%s\n\n", userPredefinedTemplate) - } - sb.WriteString("\n\n") - - sb.WriteString(`IMPORTANT RULES: - -1. Language: Write ALL content in the SAME LANGUAGE as the . If description is in Vietnamese, write in Vietnamese. If in English, write in English. BUT keep ALL headings and section titles in English exactly as in the templates. - -2. SOUL.md section guide — each section has a specific purpose: - - "## Core Truths" — universal personality traits. KEEP the general advice. Do NOT inject agent-specific references here. - - "## Boundaries" — rules and limits. CUSTOMIZE only if the description mentions specific boundaries. - - "## Vibe" — communication style and personality ONLY. How the agent talks, its tone, its attitude. Do NOT put technical knowledge here. - - "## Style" — communication preferences: tone, humor level, emoji usage, opinion strength, response length, formality. Generate SPECIFIC values based on the description. E.g. a cute sweet bot → warm tone, frequent emoji, playful humor. A formal business bot → professional tone, no emoji, measured opinions. These are knobs the user can later customize per agent. - - "## Expertise" — domain-specific knowledge, technical skills, specialized instructions, keywords, parameters. If the description mentions any specialized domain (e.g. image generation, coding, writing), put that knowledge HERE. Remove the placeholder text. If no domain expertise, omit this section entirely. - - "## Continuity" — keep as-is (just translate if needed). - - KEEP the exact English headings. Do NOT add the agent's name into Core Truths or Boundaries. - -3. IDENTITY.md rules: - - KEEP the exact English heading: "# IDENTITY.md - Who Am I?" - - Fill in ONLY the field values: Name, Creature, Purpose, Vibe, Emoji based on the description. - - Purpose: mission statement — what this agent does, key resources, focus areas. Can be multiple lines. Include URLs or references mentioned in the description. - - REMOVE all template placeholder/instruction text (the italic hints in parentheses). - - Leave Avatar blank. - - Keep the footer note section as-is. - -4. Generate a short expertise summary (1-2 sentences, under 200 characters) for delegation discovery. - -5. USER_PREDEFINED.md (OPTIONAL — only generate if relevant): - - Generate this file if the description mentions ANY of: owner/creator info (name, username, role), target users/audience, user groups, communication policies, language requirements, or group-specific context. - - This is the RIGHT place for information about specific people (owner, creator, team members, contacts) — do NOT put personal/people info in SOUL.md or IDENTITY.md. - - If the description is purely about the agent's personality/expertise with no people or user-context, OMIT this file entirely. - - Content: owner/creator info, baseline rules for ALL users — default language, communication norms, audience assumptions, boundaries that individual users cannot override. - - Keep headings in English. Write content in the same language as the description. - -Output format — generate in this EXACT order: - - -(short expertise summary here) - - - -(content here) - - - -(content here) - - - -(content here — or omit this entire block if not relevant) -`) - - return sb.String() -} - -// buildEditPrompt constructs the prompt for editing existing SOUL.md, IDENTITY.md, and USER_PREDEFINED.md. -func (s *AgentSummoner) buildEditPrompt(existing []store.AgentContextFileData, editPrompt string) string { - var sb strings.Builder - sb.WriteString("You are updating an existing AI assistant's configuration files.\n\nHere are the current files:\n\n\n") - for _, f := range existing { - if f.Content == "" { - continue - } - // Only include editable files - if f.FileName != bootstrap.SoulFile && f.FileName != bootstrap.IdentityFile && f.FileName != bootstrap.UserPredefinedFile { - continue - } - fmt.Fprintf(&sb, "\n%s\n\n", f.FileName, f.Content) - } - sb.WriteString("\n\n") - fmt.Fprintf(&sb, "\n%s\n\n\n", editPrompt) - sb.WriteString(`IMPORTANT RULES: - -1. Language: Write ALL content in the SAME LANGUAGE as the existing files. Keep headings in English. - -2. SOUL.md section guide — place content in the RIGHT section: - - "## Core Truths" — universal personality traits. Do NOT add domain-specific content here. - - "## Boundaries" — rules and limits. - - "## Vibe" — communication style and personality ONLY. Tone, attitude, how the agent talks. NOT technical knowledge. - - "## Style" — communication preferences (tone, humor, emoji, opinions, length, formality). Update if the edit changes personality or communication style. - - "## Expertise" — domain-specific knowledge, technical skills, keywords, parameters, specialized instructions. If the edit adds domain knowledge (e.g. image generation techniques, coding standards, writing styles), it goes HERE. Create this section if it doesn't exist yet (between Vibe and Continuity). - - "## Continuity" — memory/persistence rules. Usually unchanged. - -3. Output the COMPLETE updated file content, not just the changed parts. The output will REPLACE the entire file. - -4. Only output files that actually need changes. Omit unchanged files entirely. - -5. If the edit changes the agent's expertise, also update the frontmatter summary. - -6. USER_PREDEFINED.md: If the edit mentions owner/creator info (name, username, role), people information, user-facing policies, communication rules, audience targeting, or group-specific context, update this file. This is the RIGHT place for information about specific people — do NOT put personal/people info in SOUL.md or IDENTITY.md. If it doesn't exist yet in current_files and the edit introduces people or user/group context, generate it. Omit if unchanged and not newly needed. - -Output format: - - -(updated expertise summary, or omit if unchanged) - - - -(complete updated content, or omit if unchanged) - - - -(complete updated content, or omit if unchanged) - - - -(complete updated content, or omit if unchanged/not needed) - -`) - return sb.String() -} - -// extractIdentityName extracts the Name field from IDENTITY.md content. -// Matches format: - **Name:** value -func extractIdentityName(content string) string { - if content == "" { - return "" - } - m := identityNameRe.FindStringSubmatch(content) - if len(m) < 2 { - return "" - } - return strings.TrimSpace(m[1]) -} - -// suffixString returns the last n runes of s. -func suffixString(s string, n int) string { - runes := []rune(s) - if len(runes) <= n { - return s - } - return string(runes[len(runes)-n:]) -} - -// truncateUTF8 truncates s to at most maxLen runes, appending "…" if truncated. -func truncateUTF8(s string, maxLen int) string { - runes := []rune(s) - if len(runes) <= maxLen { - return s - } - return string(runes[:maxLen]) + "…" -} - -// parseFileResponse extracts file contents and frontmatter from XML-tagged LLM output. -// Frontmatter is stored under the special key "__frontmatter__". -func parseFileResponse(content string) map[string]string { - files := make(map[string]string) - matches := fileTagRe.FindAllStringSubmatch(content, -1) - for _, m := range matches { - name := strings.TrimSpace(m[1]) - body := strings.TrimSpace(m[2]) - if name != "" && body != "" { - files[name] = body - } - } - // Extract frontmatter tag if present - if fm := frontmatterTagRe.FindStringSubmatch(content); len(fm) > 1 { - if trimmed := strings.TrimSpace(fm[1]); trimmed != "" { - files[frontmatterKey] = trimmed - } - } - return files -} diff --git a/internal/http/summoner_prompts.go b/internal/http/summoner_prompts.go new file mode 100644 index 00000000..306ad598 --- /dev/null +++ b/internal/http/summoner_prompts.go @@ -0,0 +1,257 @@ +package http + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// buildSoulPrompt constructs the prompt for SOUL.md generation. +func (s *AgentSummoner) buildSoulPrompt(description string) string { + var sb strings.Builder + sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate the SOUL.md file that defines its personality.\n\n") + + fmt.Fprintf(&sb, "\n%s\n\n\n", description) + + soulTemplate, err := bootstrap.ReadTemplate(bootstrap.SoulFile) + if err != nil { + slog.Warn("summoning: failed to read SOUL.md template", "error", err) + } + if soulTemplate != "" { + fmt.Fprintf(&sb, "\n\n", soulTemplate) + } + + sb.WriteString(`IMPORTANT RULES: + +1. Language: Write ALL content in the SAME LANGUAGE as the . If description is in Vietnamese, write in Vietnamese. If in English, write in English. BUT keep ALL headings and section titles in English exactly as in the templates. + +2. SOUL.md section guide — each section has a specific purpose: + - "## Core Truths" — universal personality traits. KEEP the general advice. Do NOT inject agent-specific references here. + - "## Boundaries" — rules and limits. CUSTOMIZE only if the description mentions specific boundaries. + - "## Vibe" — communication style and personality ONLY. How the agent talks, its tone, its attitude. Do NOT put technical knowledge here. + - "## Style" — communication preferences: tone, humor level, emoji usage, opinion strength, response length, formality. Generate SPECIFIC values based on the description. E.g. a cute sweet bot → warm tone, frequent emoji, playful humor. A formal business bot → professional tone, no emoji, measured opinions. These are knobs the user can later customize per agent. + - "## Expertise" — domain-specific knowledge, technical skills, specialized instructions, keywords, parameters. If the description mentions any specialized domain (e.g. image generation, coding, writing), put that knowledge HERE. Remove the placeholder text. If no domain expertise, omit this section entirely. + - "## Continuity" — keep as-is (just translate if needed). + - KEEP the exact English headings. Do NOT add the agent's name into Core Truths or Boundaries. + +3. Generate a short expertise summary (1-2 sentences, under 200 characters) for delegation discovery. + +Output format: + + +(short expertise summary here) + + + +(content here) +`) + + return sb.String() +} + +// buildIdentityPrompt constructs the prompt for IDENTITY.md + USER_PREDEFINED.md generation, +// using the already-generated SOUL.md as context for consistency. +func (s *AgentSummoner) buildIdentityPrompt(description, soulContent string) string { + var sb strings.Builder + sb.WriteString("You are setting up a new AI assistant. The SOUL.md (personality) has already been generated. Now generate IDENTITY.md and optionally USER_PREDEFINED.md based on the description and soul.\n\n") + + fmt.Fprintf(&sb, "\n%s\n\n\n", description) + + if soulContent != "" { + fmt.Fprintf(&sb, "\n%s\n\n\n", soulContent) + } + + identityTemplate, err := bootstrap.ReadTemplate(bootstrap.IdentityFile) + if err != nil { + slog.Warn("summoning: failed to read IDENTITY.md template", "error", err) + } + userPredefinedTemplate, err := bootstrap.ReadTemplate(bootstrap.UserPredefinedFile) + if err != nil { + slog.Warn("summoning: failed to read USER_PREDEFINED.md template", "error", err) + } + + sb.WriteString("\n") + if identityTemplate != "" { + fmt.Fprintf(&sb, "\n%s\n\n", identityTemplate) + } + if userPredefinedTemplate != "" { + fmt.Fprintf(&sb, "\n%s\n\n", userPredefinedTemplate) + } + sb.WriteString("\n\n") + + sb.WriteString(`IMPORTANT RULES: + +1. Language: Write ALL content in the SAME LANGUAGE as the . Keep headings in English. + +2. IDENTITY.md rules: + - KEEP the exact English heading: "# IDENTITY.md - Who Am I?" + - Fill in ONLY the field values: Name, Creature, Purpose, Vibe, Emoji based on the description and soul. + - The Name, Creature, and Vibe should MATCH the personality defined in the soul. + - Purpose: mission statement — what this agent does, key resources, focus areas. Can be multiple lines. Include URLs or references mentioned in the description. + - REMOVE all template placeholder/instruction text (the italic hints in parentheses). + - Leave Avatar blank. + - Keep the footer note section as-is. + +3. USER_PREDEFINED.md (OPTIONAL — only generate if relevant): + - Generate this file if the description mentions ANY of: owner/creator info (name, username, role), target users/audience, user groups, communication policies, language requirements, or group-specific context. + - This is the RIGHT place for information about specific people (owner, creator, team members, contacts) — do NOT put personal/people info in IDENTITY.md. + - If the description is purely about the agent's personality/expertise with no people or user-context, OMIT this file entirely. + - Content: owner/creator info, baseline rules for ALL users — default language, communication norms, audience assumptions, boundaries that individual users cannot override. + - Keep headings in English. Write content in the same language as the description. + +Output format: + + +(content here) + + + +(content here — or omit this entire block if not relevant) +`) + + return sb.String() +} + +// buildCreatePrompt constructs the prompt for generating all files in a single LLM call. +// Used by the optimistic single-call path; generates frontmatter + SOUL.md + IDENTITY.md + USER_PREDEFINED.md. +func (s *AgentSummoner) buildCreatePrompt(description string) string { + var sb strings.Builder + sb.WriteString("You are setting up a new AI assistant. Based on the description below, generate the required files.\n\n") + + fmt.Fprintf(&sb, "\n%s\n\n\n", description) + + soulTemplate, err := bootstrap.ReadTemplate(bootstrap.SoulFile) + if err != nil { + slog.Warn("summoning: failed to read SOUL.md template", "error", err) + } + identityTemplate, err := bootstrap.ReadTemplate(bootstrap.IdentityFile) + if err != nil { + slog.Warn("summoning: failed to read IDENTITY.md template", "error", err) + } + userPredefinedTemplate, err := bootstrap.ReadTemplate(bootstrap.UserPredefinedFile) + if err != nil { + slog.Warn("summoning: failed to read USER_PREDEFINED.md template", "error", err) + } + + sb.WriteString("\n") + if soulTemplate != "" { + fmt.Fprintf(&sb, "\n%s\n\n", soulTemplate) + } + if identityTemplate != "" { + fmt.Fprintf(&sb, "\n%s\n\n", identityTemplate) + } + if userPredefinedTemplate != "" { + fmt.Fprintf(&sb, "\n%s\n\n", userPredefinedTemplate) + } + sb.WriteString("\n\n") + + sb.WriteString(`IMPORTANT RULES: + +1. Language: Write ALL content in the SAME LANGUAGE as the . If description is in Vietnamese, write in Vietnamese. If in English, write in English. BUT keep ALL headings and section titles in English exactly as in the templates. + +2. SOUL.md section guide — each section has a specific purpose: + - "## Core Truths" — universal personality traits. KEEP the general advice. Do NOT inject agent-specific references here. + - "## Boundaries" — rules and limits. CUSTOMIZE only if the description mentions specific boundaries. + - "## Vibe" — communication style and personality ONLY. How the agent talks, its tone, its attitude. Do NOT put technical knowledge here. + - "## Style" — communication preferences: tone, humor level, emoji usage, opinion strength, response length, formality. Generate SPECIFIC values based on the description. E.g. a cute sweet bot → warm tone, frequent emoji, playful humor. A formal business bot → professional tone, no emoji, measured opinions. These are knobs the user can later customize per agent. + - "## Expertise" — domain-specific knowledge, technical skills, specialized instructions, keywords, parameters. If the description mentions any specialized domain (e.g. image generation, coding, writing), put that knowledge HERE. Remove the placeholder text. If no domain expertise, omit this section entirely. + - "## Continuity" — keep as-is (just translate if needed). + - KEEP the exact English headings. Do NOT add the agent's name into Core Truths or Boundaries. + +3. IDENTITY.md rules: + - KEEP the exact English heading: "# IDENTITY.md - Who Am I?" + - Fill in ONLY the field values: Name, Creature, Purpose, Vibe, Emoji based on the description. + - Purpose: mission statement — what this agent does, key resources, focus areas. Can be multiple lines. Include URLs or references mentioned in the description. + - REMOVE all template placeholder/instruction text (the italic hints in parentheses). + - Leave Avatar blank. + - Keep the footer note section as-is. + +4. Generate a short expertise summary (1-2 sentences, under 200 characters) for delegation discovery. + +5. USER_PREDEFINED.md (OPTIONAL — only generate if relevant): + - Generate this file if the description mentions ANY of: owner/creator info (name, username, role), target users/audience, user groups, communication policies, language requirements, or group-specific context. + - This is the RIGHT place for information about specific people (owner, creator, team members, contacts) — do NOT put personal/people info in SOUL.md or IDENTITY.md. + - If the description is purely about the agent's personality/expertise with no people or user-context, OMIT this file entirely. + - Content: owner/creator info, baseline rules for ALL users — default language, communication norms, audience assumptions, boundaries that individual users cannot override. + - Keep headings in English. Write content in the same language as the description. + +Output format — generate in this EXACT order: + + +(short expertise summary here) + + + +(content here) + + + +(content here) + + + +(content here — or omit this entire block if not relevant) +`) + + return sb.String() +} + +// buildEditPrompt constructs the prompt for editing existing SOUL.md, IDENTITY.md, and USER_PREDEFINED.md. +func (s *AgentSummoner) buildEditPrompt(existing []store.AgentContextFileData, editPrompt string) string { + var sb strings.Builder + sb.WriteString("You are updating an existing AI assistant's configuration files.\n\nHere are the current files:\n\n\n") + for _, f := range existing { + if f.Content == "" { + continue + } + // Only include editable files + if f.FileName != bootstrap.SoulFile && f.FileName != bootstrap.IdentityFile && f.FileName != bootstrap.UserPredefinedFile { + continue + } + fmt.Fprintf(&sb, "\n%s\n\n", f.FileName, f.Content) + } + sb.WriteString("\n\n") + fmt.Fprintf(&sb, "\n%s\n\n\n", editPrompt) + sb.WriteString(`IMPORTANT RULES: + +1. Language: Write ALL content in the SAME LANGUAGE as the existing files. Keep headings in English. + +2. SOUL.md section guide — place content in the RIGHT section: + - "## Core Truths" — universal personality traits. Do NOT add domain-specific content here. + - "## Boundaries" — rules and limits. + - "## Vibe" — communication style and personality ONLY. Tone, attitude, how the agent talks. NOT technical knowledge. + - "## Style" — communication preferences (tone, humor, emoji, opinions, length, formality). Update if the edit changes personality or communication style. + - "## Expertise" — domain-specific knowledge, technical skills, keywords, parameters, specialized instructions. If the edit adds domain knowledge (e.g. image generation techniques, coding standards, writing styles), it goes HERE. Create this section if it doesn't exist yet (between Vibe and Continuity). + - "## Continuity" — memory/persistence rules. Usually unchanged. + +3. Output the COMPLETE updated file content, not just the changed parts. The output will REPLACE the entire file. + +4. Only output files that actually need changes. Omit unchanged files entirely. + +5. If the edit changes the agent's expertise, also update the frontmatter summary. + +6. USER_PREDEFINED.md: If the edit mentions owner/creator info (name, username, role), people information, user-facing policies, communication rules, audience targeting, or group-specific context, update this file. This is the RIGHT place for information about specific people — do NOT put personal/people info in SOUL.md or IDENTITY.md. If it doesn't exist yet in current_files and the edit introduces people or user/group context, generate it. Omit if unchanged and not newly needed. + +Output format: + + +(updated expertise summary, or omit if unchanged) + + + +(complete updated content, or omit if unchanged) + + + +(complete updated content, or omit if unchanged) + + + +(complete updated content, or omit if unchanged/not needed) + +`) + return sb.String() +} diff --git a/internal/http/summoner_regenerate.go b/internal/http/summoner_regenerate.go new file mode 100644 index 00000000..da9a08f8 --- /dev/null +++ b/internal/http/summoner_regenerate.go @@ -0,0 +1,218 @@ +package http + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// RegenerateAgent updates context files based on an edit prompt. +// Reads existing files, sends them + edit instructions to LLM, stores results. +// Synchronous — caller should run in goroutine if needed. +func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, editPrompt string) { + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) + defer cancel() + + s.ensureUserPredefined(ctx, agentID) + + s.emitEvent(agentID, SummonEventStarted, "", "") + + // Read existing files for context + existing, err := s.agents.GetAgentContextFiles(ctx, agentID) + if err != nil { + slog.Warn("summoning: failed to read existing files", "agent", agentID, "error", err) + s.emitEvent(agentID, SummonEventFailed, "", err.Error()) + s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) + return + } + + prompt := s.buildEditPrompt(existing, editPrompt) + + files, err := s.generateFiles(ctx, providerName, model, prompt) + if err != nil { + slog.Warn("summoning: regeneration failed", "agent", agentID, "error", err) + s.emitEvent(agentID, SummonEventFailed, "", err.Error()) + // Use fresh context — the original may have timed out, but we still need to update status. + s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) + return + } + + s.storeFiles(ctx, agentID, files) + + // Update frontmatter + display_name if IDENTITY.md was regenerated + updates := map[string]any{} + if fm, ok := files[frontmatterKey]; ok && fm != "" { + updates["frontmatter"] = fm + } + if name := extractIdentityName(files[bootstrap.IdentityFile]); name != "" { + updates["display_name"] = name + } + if len(updates) > 0 { + if err := s.agents.Update(ctx, agentID, updates); err != nil { + slog.Warn("summoning: failed to save agent metadata", "agent", agentID, "error", err) + } + } + + s.setAgentStatus(ctx, agentID, store.AgentStatusActive) + s.emitEvent(agentID, SummonEventCompleted, "", "") + + slog.Info("summoning: regeneration completed", "agent", agentID, "files", len(files)) +} + +// isRetryableError returns true for timeout and context-cancellation errors +// that warrant falling back to the 2-call approach. +func isRetryableError(err error) bool { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return true + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + return false +} + +// isGenerated checks if a context file has been generated (differs from the default template). +func (s *AgentSummoner) isGenerated(existingMap map[string]string, fileName string) bool { + content, ok := existingMap[fileName] + if !ok || content == "" { + return false + } + template, err := bootstrap.ReadTemplate(fileName) + if err != nil { + return false + } + return content != template +} + +// generateFiles calls the LLM and parses the XML-tagged response into file map. +func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, prompt string) (map[string]string, error) { + provider, err := s.resolveProvider(providerName) + if err != nil { + return nil, fmt.Errorf("resolve provider: %w", err) + } + + // Use a unique session key so CLI-based providers get an isolated workdir + // (prevents polluting/reading CLAUDE.md from active chat sessions). + summonSessionKey := "summon-" + uuid.New().String() + + slog.Info("summoning: calling LLM", "provider", providerName, "model", model, "prompt_len", len(prompt)) + + resp, err := provider.Chat(ctx, providers.ChatRequest{ + Messages: []providers.Message{ + {Role: "system", Content: "You are a file generator. Output ONLY the requested XML-tagged files. No extra commentary."}, + {Role: "user", Content: prompt}, + }, + Model: model, + Options: map[string]interface{}{ + "max_tokens": 8192, + "temperature": 0.7, + providers.OptSessionKey: summonSessionKey, + providers.OptDisableTools: true, + }, + }) + if err != nil { + return nil, fmt.Errorf("%s: %w", providerName, err) + } + + slog.Info("summoning: raw LLM response", "provider", providerName, "length", len(resp.Content), + "preview_start", truncateUTF8(resp.Content, 500), + "preview_end", truncateUTF8(suffixString(resp.Content, 500), 500)) + + files := parseFileResponse(resp.Content) + if len(files) == 0 { + return nil, fmt.Errorf("LLM returned no parseable files (response length: %d)", len(resp.Content)) + } + + return files, nil +} + +// storeFiles saves generated files to agent_context_files and emits progress events. +func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files map[string]string) { + for _, name := range summoningFiles { + content, ok := files[name] + if !ok || content == "" { + continue + } + if err := s.agents.SetAgentContextFile(ctx, agentID, name, content); err != nil { + slog.Warn("summoning: failed to store file", "agent", agentID, "file", name, "error", err) + continue + } + s.emitEvent(agentID, SummonEventFileGenerated, name, "") + } +} + +func (s *AgentSummoner) resolveProvider(name string) (providers.Provider, error) { + if s.providerReg == nil { + return nil, fmt.Errorf("no provider registry") + } + + provider, err := s.providerReg.Get(name) + if err != nil { + // Fallback to first available provider + names := s.providerReg.List() + if len(names) == 0 { + return nil, fmt.Errorf("no providers configured") + } + provider, err = s.providerReg.Get(names[0]) + if err != nil { + return nil, err + } + slog.Warn("summoning: provider not found, using fallback", "wanted", name, "using", names[0]) + } + return provider, nil +} + +// ensureUserPredefined seeds USER_PREDEFINED.md template if it doesn't exist yet. +// Backfills agents created before this feature was added. +func (s *AgentSummoner) ensureUserPredefined(ctx context.Context, agentID uuid.UUID) { + existing, err := s.agents.GetAgentContextFiles(ctx, agentID) + if err != nil { + return + } + for _, f := range existing { + if f.FileName == bootstrap.UserPredefinedFile { + return // already exists + } + } + if tpl, err := bootstrap.ReadTemplate(bootstrap.UserPredefinedFile); err == nil { + _ = s.agents.SetAgentContextFile(ctx, agentID, bootstrap.UserPredefinedFile, tpl) + } +} + +func (s *AgentSummoner) setAgentStatus(ctx context.Context, agentID uuid.UUID, status string) { + if err := s.agents.Update(ctx, agentID, map[string]any{"status": status}); err != nil { + slog.Warn("summoning: failed to update agent status", "agent", agentID, "status", status, "error", err) + } +} + +func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg string) { + if s.msgBus == nil { + return + } + payload := map[string]interface{}{ + "type": eventType, + "agent_id": agentID.String(), + } + if fileName != "" { + payload["file"] = fileName + } + if errMsg != "" { + payload["error"] = errMsg + } + s.msgBus.Broadcast(bus.Event{ + Name: protocol.EventAgentSummoning, + Payload: payload, + }) +} diff --git a/internal/http/summoner_utils.go b/internal/http/summoner_utils.go new file mode 100644 index 00000000..2d2aba59 --- /dev/null +++ b/internal/http/summoner_utils.go @@ -0,0 +1,57 @@ +package http + +import ( + "strings" +) + +// extractIdentityName extracts the Name field from IDENTITY.md content. +// Matches format: - **Name:** value +func extractIdentityName(content string) string { + if content == "" { + return "" + } + m := identityNameRe.FindStringSubmatch(content) + if len(m) < 2 { + return "" + } + return strings.TrimSpace(m[1]) +} + +// suffixString returns the last n runes of s. +func suffixString(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[len(runes)-n:]) +} + +// truncateUTF8 truncates s to at most maxLen runes, appending "…" if truncated. +func truncateUTF8(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen]) + "…" +} + +// parseFileResponse extracts file contents and frontmatter from XML-tagged LLM output. +// Frontmatter is stored under the special key "__frontmatter__". +func parseFileResponse(content string) map[string]string { + files := make(map[string]string) + matches := fileTagRe.FindAllStringSubmatch(content, -1) + for _, m := range matches { + name := strings.TrimSpace(m[1]) + body := strings.TrimSpace(m[2]) + if name != "" && body != "" { + files[name] = body + } + } + // Extract frontmatter tag if present + if fm := frontmatterTagRe.FindStringSubmatch(content); len(fm) > 1 { + if trimmed := strings.TrimSpace(fm[1]); trimmed != "" { + files[frontmatterKey] = trimmed + } + } + return files +} diff --git a/internal/store/pg/cron.go b/internal/store/pg/cron.go index 0227925e..e3f9bc48 100644 --- a/internal/store/pg/cron.go +++ b/internal/store/pg/cron.go @@ -1,17 +1,11 @@ package pg import ( - "context" "database/sql" - "encoding/json" - "fmt" "log/slog" "sync" "time" - "github.com/adhocore/gronx" - "github.com/google/uuid" - "github.com/nextlevelbuilder/goclaw/internal/cron" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -48,396 +42,6 @@ func (s *PGCronStore) SetRetryConfig(cfg cron.RetryConfig) { s.retryCfg = cfg } -func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) { - if schedule.TZ != "" { - if _, err := time.LoadLocation(schedule.TZ); err != nil { - return nil, fmt.Errorf("invalid timezone: %s", schedule.TZ) - } - } - - payload := store.CronPayload{ - Kind: "agent_turn", Message: message, Deliver: deliver, Channel: channel, To: to, - } - payloadJSON, _ := json.Marshal(payload) - - id := uuid.Must(uuid.NewV7()) - now := time.Now() - scheduleKind := schedule.Kind - deleteAfterRun := schedule.Kind == "at" - - var cronExpr, tz *string - var runAt *time.Time - if schedule.Expr != "" { - cronExpr = &schedule.Expr - } - if schedule.AtMS != nil { - t := time.UnixMilli(*schedule.AtMS) - runAt = &t - } - if schedule.TZ != "" { - tz = &schedule.TZ - } - - var agentUUID *uuid.UUID - if agentID != "" { - aid, err := uuid.Parse(agentID) - if err == nil { - agentUUID = &aid - } - } - - var userIDPtr *string - if userID != "" { - userIDPtr = &userID - } - - var intervalMS *int64 - if schedule.EveryMS != nil { - intervalMS = schedule.EveryMS - } - - nextRun := computeNextRun(&schedule, now) - - _, err := s.db.Exec( - `INSERT INTO cron_jobs (id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, - interval_ms, payload, delete_after_run, next_run_at, created_at, updated_at) - VALUES ($1, $2, $3, $4, true, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, - id, agentUUID, userIDPtr, name, scheduleKind, cronExpr, runAt, tz, - intervalMS, payloadJSON, deleteAfterRun, nextRun, now, now, - ) - if err != nil { - return nil, fmt.Errorf("create cron job: %w", err) - } - - s.cacheLoaded = false // invalidate cache - - job, _ := s.GetJob(id.String()) - return job, nil -} - -func (s *PGCronStore) GetJob(jobID string) (*store.CronJob, bool) { - id, err := uuid.Parse(jobID) - if err != nil { - return nil, false - } - job, err := s.scanJob(id) - if err != nil { - return nil, false - } - return job, true -} - -func (s *PGCronStore) ListJobs(includeDisabled bool, agentID, userID string) []store.CronJob { - q := `SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, - interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error, - created_at, updated_at FROM cron_jobs WHERE 1=1` - - var args []interface{} - argIdx := 1 - - if !includeDisabled { - q += fmt.Sprintf(" AND enabled = $%d", argIdx) - args = append(args, true) - argIdx++ - } - if agentID != "" { - if aid, err := uuid.Parse(agentID); err == nil { - q += fmt.Sprintf(" AND agent_id = $%d", argIdx) - args = append(args, aid) - argIdx++ - } - } - if userID != "" { - q += fmt.Sprintf(" AND user_id = $%d", argIdx) - args = append(args, userID) - argIdx++ - } - - q += " ORDER BY created_at DESC" - - rows, err := s.db.Query(q, args...) - if err != nil { - return nil - } - defer rows.Close() - - var result []store.CronJob - for rows.Next() { - job, err := scanCronRow(rows) - if err != nil { - continue - } - result = append(result, *job) - } - return result -} - -func (s *PGCronStore) RemoveJob(jobID string) error { - id, err := uuid.Parse(jobID) - if err != nil { - return fmt.Errorf("invalid job ID: %s", jobID) - } - _, err = s.db.Exec("DELETE FROM cron_jobs WHERE id = $1", id) - if err != nil { - return err - } - s.cacheLoaded = false - return nil -} - -func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store.CronJob, error) { - id, err := uuid.Parse(jobID) - if err != nil { - return nil, fmt.Errorf("invalid job ID: %s", jobID) - } - - updates := make(map[string]interface{}) - if patch.Name != "" { - updates["name"] = patch.Name - } - if patch.Enabled != nil { - updates["enabled"] = *patch.Enabled - } - if patch.Schedule != nil { - // Fetch current schedule to merge with patch (partial updates) - var curKind string - var curExpr, curTZ *string - var curIntervalMS *int64 - var curRunAt *time.Time - s.db.QueryRow( - "SELECT schedule_kind, cron_expression, timezone, interval_ms, run_at FROM cron_jobs WHERE id = $1", id, - ).Scan(&curKind, &curExpr, &curTZ, &curIntervalMS, &curRunAt) - - // Resolve the effective schedule kind - newKind := patch.Schedule.Kind - if newKind == "" { - newKind = curKind // keep current kind if not specified - } - updates["schedule_kind"] = newKind - - // Set type-specific fields and clear others - switch newKind { - case "cron": - if patch.Schedule.Expr != "" { - updates["cron_expression"] = patch.Schedule.Expr - } - if patch.Schedule.TZ != "" { - if _, err := time.LoadLocation(patch.Schedule.TZ); err != nil { - return nil, fmt.Errorf("invalid timezone: %s", patch.Schedule.TZ) - } - updates["timezone"] = patch.Schedule.TZ - } - // Clear other type fields when switching to cron - if curKind != "cron" { - updates["interval_ms"] = nil - updates["run_at"] = nil - } - case "every": - if patch.Schedule.EveryMS != nil { - updates["interval_ms"] = *patch.Schedule.EveryMS - } - // Clear other type fields when switching to every - if curKind != "every" { - updates["cron_expression"] = nil - updates["timezone"] = nil - updates["run_at"] = nil - } - case "at": - if patch.Schedule.AtMS != nil { - t := time.UnixMilli(*patch.Schedule.AtMS) - updates["run_at"] = t - } - // Clear other type fields when switching to at - if curKind != "at" { - updates["cron_expression"] = nil - updates["timezone"] = nil - updates["interval_ms"] = nil - } - } - - // Build merged schedule for recomputing next_run_at - merged := store.CronSchedule{Kind: newKind} - switch newKind { - case "cron": - if patch.Schedule.Expr != "" { - merged.Expr = patch.Schedule.Expr - } else if curExpr != nil { - merged.Expr = *curExpr - } - if patch.Schedule.TZ != "" { - merged.TZ = patch.Schedule.TZ - } else if curTZ != nil && newKind == curKind { - merged.TZ = *curTZ - } - case "every": - if patch.Schedule.EveryMS != nil { - merged.EveryMS = patch.Schedule.EveryMS - } else if curIntervalMS != nil { - merged.EveryMS = curIntervalMS - } - case "at": - if patch.Schedule.AtMS != nil { - merged.AtMS = patch.Schedule.AtMS - } else if curRunAt != nil { - ms := curRunAt.UnixMilli() - merged.AtMS = &ms - } - } - - // Validate the merged schedule before applying - switch merged.Kind { - case "cron": - if merged.Expr == "" { - return nil, fmt.Errorf("cron schedule requires expr") - } - gx := gronx.New() - if !gx.IsValid(merged.Expr) { - return nil, fmt.Errorf("invalid cron expression: %s", merged.Expr) - } - case "every": - if merged.EveryMS == nil || *merged.EveryMS <= 0 { - return nil, fmt.Errorf("every schedule requires positive everyMs") - } - case "at": - if merged.AtMS == nil { - return nil, fmt.Errorf("at schedule requires atMs") - } - } - - next := computeNextRun(&merged, time.Now()) - updates["next_run_at"] = next - } - if patch.DeleteAfterRun != nil { - updates["delete_after_run"] = *patch.DeleteAfterRun - } - - // Update agent_id column - if patch.AgentID != nil { - if *patch.AgentID == "" { - updates["agent_id"] = nil - } else if aid, parseErr := uuid.Parse(*patch.AgentID); parseErr == nil { - updates["agent_id"] = aid - } - } - - // Update payload JSONB — fetch current, merge patch fields, re-serialize - needsPayloadUpdate := patch.Message != "" || patch.Deliver != nil || patch.Channel != nil || patch.To != nil - if needsPayloadUpdate { - var payloadJSON []byte - if scanErr := s.db.QueryRow("SELECT payload FROM cron_jobs WHERE id = $1", id).Scan(&payloadJSON); scanErr == nil { - var payload store.CronPayload - json.Unmarshal(payloadJSON, &payload) - - if patch.Message != "" { - payload.Message = patch.Message - } - if patch.Deliver != nil { - payload.Deliver = *patch.Deliver - } - if patch.Channel != nil { - payload.Channel = *patch.Channel - } - if patch.To != nil { - payload.To = *patch.To - } - - merged, _ := json.Marshal(payload) - updates["payload"] = merged - } - } - - updates["updated_at"] = time.Now() - - if err := execMapUpdate(context.Background(), s.db, "cron_jobs", id, updates); err != nil { - return nil, err - } - - s.cacheLoaded = false - job, _ := s.scanJob(id) - return job, nil -} - -func (s *PGCronStore) EnableJob(jobID string, enabled bool) error { - id, err := uuid.Parse(jobID) - if err != nil { - return fmt.Errorf("invalid job ID: %s", jobID) - } - _, err = s.db.Exec("UPDATE cron_jobs SET enabled = $1, updated_at = $2 WHERE id = $3", enabled, time.Now(), id) - if err != nil { - return err - } - s.cacheLoaded = false - return nil -} - -func (s *PGCronStore) GetRunLog(jobID string, limit, offset int) ([]store.CronRunLogEntry, int) { - if limit <= 0 { - limit = 20 - } - if offset < 0 { - offset = 0 - } - - const cols = "job_id, status, error, summary, ran_at, COALESCE(duration_ms, 0), COALESCE(input_tokens, 0), COALESCE(output_tokens, 0)" - - var total int - var rows *sql.Rows - var err error - if jobID != "" { - id, parseErr := uuid.Parse(jobID) - if parseErr != nil { - return nil, 0 - } - s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs WHERE job_id = $1", id).Scan(&total) - rows, err = s.db.Query( - "SELECT "+cols+" FROM cron_run_logs WHERE job_id = $1 ORDER BY ran_at DESC LIMIT $2 OFFSET $3", - id, limit, offset) - } else { - s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs").Scan(&total) - rows, err = s.db.Query( - "SELECT "+cols+" FROM cron_run_logs ORDER BY ran_at DESC LIMIT $1 OFFSET $2", limit, offset) - } - if err != nil { - return nil, 0 - } - defer rows.Close() - - var result []store.CronRunLogEntry - for rows.Next() { - var jobUUID uuid.UUID - var status string - var errStr, summary *string - var ranAt time.Time - var durationMS int64 - var inputTokens, outputTokens int - if err := rows.Scan(&jobUUID, &status, &errStr, &summary, &ranAt, &durationMS, &inputTokens, &outputTokens); err != nil { - continue - } - result = append(result, store.CronRunLogEntry{ - Ts: ranAt.UnixMilli(), - JobID: jobUUID.String(), - Status: status, - Error: derefStr(errStr), - Summary: derefStr(summary), - DurationMS: durationMS, - InputTokens: inputTokens, - OutputTokens: outputTokens, - }) - } - return result, total -} - -func (s *PGCronStore) Status() map[string]interface{} { - s.mu.Lock() - defer s.mu.Unlock() - var count int64 - s.db.QueryRow("SELECT COUNT(*) FROM cron_jobs WHERE enabled = true").Scan(&count) - return map[string]interface{}{ - "enabled": s.running, - "jobs": count, - } -} - func (s *PGCronStore) Start() error { s.mu.Lock() defer s.mu.Unlock() @@ -482,36 +86,3 @@ func (s *PGCronStore) emitEvent(event store.CronEvent) { fn(event) } } - -func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) { - job, ok := s.GetJob(jobID) - if !ok { - return false, "", fmt.Errorf("job %s not found", jobID) - } - - s.mu.Lock() - handler := s.onJob - s.mu.Unlock() - - if handler == nil { - return false, "", fmt.Errorf("no job handler configured") - } - - // Mark job as running before execution - if id, parseErr := uuid.Parse(jobID); parseErr == nil { - s.db.Exec("UPDATE cron_jobs SET last_status = 'running', updated_at = $1 WHERE id = $2", time.Now(), id) - } - s.mu.Lock() - s.cacheLoaded = false - s.mu.Unlock() - - s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name}) - - // Use executeOneJob for proper state updates, run logging, and retry - s.executeOneJob(*job, handler) - s.mu.Lock() - s.cacheLoaded = false - s.mu.Unlock() - return true, "", nil -} - diff --git a/internal/store/pg/cron_crud.go b/internal/store/pg/cron_crud.go new file mode 100644 index 00000000..79a9b611 --- /dev/null +++ b/internal/store/pg/cron_crud.go @@ -0,0 +1,161 @@ +package pg + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) { + if schedule.TZ != "" { + if _, err := time.LoadLocation(schedule.TZ); err != nil { + return nil, fmt.Errorf("invalid timezone: %s", schedule.TZ) + } + } + + payload := store.CronPayload{ + Kind: "agent_turn", Message: message, Deliver: deliver, Channel: channel, To: to, + } + payloadJSON, _ := json.Marshal(payload) + + id := uuid.Must(uuid.NewV7()) + now := time.Now() + scheduleKind := schedule.Kind + deleteAfterRun := schedule.Kind == "at" + + var cronExpr, tz *string + var runAt *time.Time + if schedule.Expr != "" { + cronExpr = &schedule.Expr + } + if schedule.AtMS != nil { + t := time.UnixMilli(*schedule.AtMS) + runAt = &t + } + if schedule.TZ != "" { + tz = &schedule.TZ + } + + var agentUUID *uuid.UUID + if agentID != "" { + aid, err := uuid.Parse(agentID) + if err == nil { + agentUUID = &aid + } + } + + var userIDPtr *string + if userID != "" { + userIDPtr = &userID + } + + var intervalMS *int64 + if schedule.EveryMS != nil { + intervalMS = schedule.EveryMS + } + + nextRun := computeNextRun(&schedule, now) + + _, err := s.db.Exec( + `INSERT INTO cron_jobs (id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, + interval_ms, payload, delete_after_run, next_run_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, true, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + id, agentUUID, userIDPtr, name, scheduleKind, cronExpr, runAt, tz, + intervalMS, payloadJSON, deleteAfterRun, nextRun, now, now, + ) + if err != nil { + return nil, fmt.Errorf("create cron job: %w", err) + } + + s.cacheLoaded = false // invalidate cache + + job, _ := s.GetJob(id.String()) + return job, nil +} + +func (s *PGCronStore) GetJob(jobID string) (*store.CronJob, bool) { + id, err := uuid.Parse(jobID) + if err != nil { + return nil, false + } + job, err := s.scanJob(id) + if err != nil { + return nil, false + } + return job, true +} + +func (s *PGCronStore) ListJobs(includeDisabled bool, agentID, userID string) []store.CronJob { + q := `SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, + interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error, + created_at, updated_at FROM cron_jobs WHERE 1=1` + + var args []interface{} + argIdx := 1 + + if !includeDisabled { + q += fmt.Sprintf(" AND enabled = $%d", argIdx) + args = append(args, true) + argIdx++ + } + if agentID != "" { + if aid, err := uuid.Parse(agentID); err == nil { + q += fmt.Sprintf(" AND agent_id = $%d", argIdx) + args = append(args, aid) + argIdx++ + } + } + if userID != "" { + q += fmt.Sprintf(" AND user_id = $%d", argIdx) + args = append(args, userID) + argIdx++ + } + + q += " ORDER BY created_at DESC" + + rows, err := s.db.Query(q, args...) + if err != nil { + return nil + } + defer rows.Close() + + var result []store.CronJob + for rows.Next() { + job, err := scanCronRow(rows) + if err != nil { + continue + } + result = append(result, *job) + } + return result +} + +func (s *PGCronStore) RemoveJob(jobID string) error { + id, err := uuid.Parse(jobID) + if err != nil { + return fmt.Errorf("invalid job ID: %s", jobID) + } + _, err = s.db.Exec("DELETE FROM cron_jobs WHERE id = $1", id) + if err != nil { + return err + } + s.cacheLoaded = false + return nil +} + +func (s *PGCronStore) EnableJob(jobID string, enabled bool) error { + id, err := uuid.Parse(jobID) + if err != nil { + return fmt.Errorf("invalid job ID: %s", jobID) + } + _, err = s.db.Exec("UPDATE cron_jobs SET enabled = $1, updated_at = $2 WHERE id = $3", enabled, time.Now(), id) + if err != nil { + return err + } + s.cacheLoaded = false + return nil +} diff --git a/internal/store/pg/cron_exec.go b/internal/store/pg/cron_exec.go new file mode 100644 index 00000000..47aa7568 --- /dev/null +++ b/internal/store/pg/cron_exec.go @@ -0,0 +1,111 @@ +package pg + +import ( + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) { + job, ok := s.GetJob(jobID) + if !ok { + return false, "", fmt.Errorf("job %s not found", jobID) + } + + s.mu.Lock() + handler := s.onJob + s.mu.Unlock() + + if handler == nil { + return false, "", fmt.Errorf("no job handler configured") + } + + // Mark job as running before execution + if id, parseErr := uuid.Parse(jobID); parseErr == nil { + s.db.Exec("UPDATE cron_jobs SET last_status = 'running', updated_at = $1 WHERE id = $2", time.Now(), id) + } + s.mu.Lock() + s.cacheLoaded = false + s.mu.Unlock() + + s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name}) + + // Use executeOneJob for proper state updates, run logging, and retry + s.executeOneJob(*job, handler) + s.mu.Lock() + s.cacheLoaded = false + s.mu.Unlock() + return true, "", nil +} + +func (s *PGCronStore) GetRunLog(jobID string, limit, offset int) ([]store.CronRunLogEntry, int) { + if limit <= 0 { + limit = 20 + } + if offset < 0 { + offset = 0 + } + + const cols = "job_id, status, error, summary, ran_at, COALESCE(duration_ms, 0), COALESCE(input_tokens, 0), COALESCE(output_tokens, 0)" + + var total int + var rows *sql.Rows + var err error + if jobID != "" { + id, parseErr := uuid.Parse(jobID) + if parseErr != nil { + return nil, 0 + } + s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs WHERE job_id = $1", id).Scan(&total) + rows, err = s.db.Query( + "SELECT "+cols+" FROM cron_run_logs WHERE job_id = $1 ORDER BY ran_at DESC LIMIT $2 OFFSET $3", + id, limit, offset) + } else { + s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs").Scan(&total) + rows, err = s.db.Query( + "SELECT "+cols+" FROM cron_run_logs ORDER BY ran_at DESC LIMIT $1 OFFSET $2", limit, offset) + } + if err != nil { + return nil, 0 + } + defer rows.Close() + + var result []store.CronRunLogEntry + for rows.Next() { + var jobUUID uuid.UUID + var status string + var errStr, summary *string + var ranAt time.Time + var durationMS int64 + var inputTokens, outputTokens int + if err := rows.Scan(&jobUUID, &status, &errStr, &summary, &ranAt, &durationMS, &inputTokens, &outputTokens); err != nil { + continue + } + result = append(result, store.CronRunLogEntry{ + Ts: ranAt.UnixMilli(), + JobID: jobUUID.String(), + Status: status, + Error: derefStr(errStr), + Summary: derefStr(summary), + DurationMS: durationMS, + InputTokens: inputTokens, + OutputTokens: outputTokens, + }) + } + return result, total +} + +func (s *PGCronStore) Status() map[string]interface{} { + s.mu.Lock() + defer s.mu.Unlock() + var count int64 + s.db.QueryRow("SELECT COUNT(*) FROM cron_jobs WHERE enabled = true").Scan(&count) + return map[string]interface{}{ + "enabled": s.running, + "jobs": count, + } +} diff --git a/internal/store/pg/cron_update.go b/internal/store/pg/cron_update.go new file mode 100644 index 00000000..ac6b69d0 --- /dev/null +++ b/internal/store/pg/cron_update.go @@ -0,0 +1,185 @@ +package pg + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/adhocore/gronx" + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store.CronJob, error) { + id, err := uuid.Parse(jobID) + if err != nil { + return nil, fmt.Errorf("invalid job ID: %s", jobID) + } + + updates := make(map[string]interface{}) + if patch.Name != "" { + updates["name"] = patch.Name + } + if patch.Enabled != nil { + updates["enabled"] = *patch.Enabled + } + if patch.Schedule != nil { + // Fetch current schedule to merge with patch (partial updates) + var curKind string + var curExpr, curTZ *string + var curIntervalMS *int64 + var curRunAt *time.Time + s.db.QueryRow( + "SELECT schedule_kind, cron_expression, timezone, interval_ms, run_at FROM cron_jobs WHERE id = $1", id, + ).Scan(&curKind, &curExpr, &curTZ, &curIntervalMS, &curRunAt) + + // Resolve the effective schedule kind + newKind := patch.Schedule.Kind + if newKind == "" { + newKind = curKind // keep current kind if not specified + } + updates["schedule_kind"] = newKind + + // Set type-specific fields and clear others + switch newKind { + case "cron": + if patch.Schedule.Expr != "" { + updates["cron_expression"] = patch.Schedule.Expr + } + if patch.Schedule.TZ != "" { + if _, err := time.LoadLocation(patch.Schedule.TZ); err != nil { + return nil, fmt.Errorf("invalid timezone: %s", patch.Schedule.TZ) + } + updates["timezone"] = patch.Schedule.TZ + } + // Clear other type fields when switching to cron + if curKind != "cron" { + updates["interval_ms"] = nil + updates["run_at"] = nil + } + case "every": + if patch.Schedule.EveryMS != nil { + updates["interval_ms"] = *patch.Schedule.EveryMS + } + // Clear other type fields when switching to every + if curKind != "every" { + updates["cron_expression"] = nil + updates["timezone"] = nil + updates["run_at"] = nil + } + case "at": + if patch.Schedule.AtMS != nil { + t := time.UnixMilli(*patch.Schedule.AtMS) + updates["run_at"] = t + } + // Clear other type fields when switching to at + if curKind != "at" { + updates["cron_expression"] = nil + updates["timezone"] = nil + updates["interval_ms"] = nil + } + } + + // Build merged schedule for recomputing next_run_at + merged := store.CronSchedule{Kind: newKind} + switch newKind { + case "cron": + if patch.Schedule.Expr != "" { + merged.Expr = patch.Schedule.Expr + } else if curExpr != nil { + merged.Expr = *curExpr + } + if patch.Schedule.TZ != "" { + merged.TZ = patch.Schedule.TZ + } else if curTZ != nil && newKind == curKind { + merged.TZ = *curTZ + } + case "every": + if patch.Schedule.EveryMS != nil { + merged.EveryMS = patch.Schedule.EveryMS + } else if curIntervalMS != nil { + merged.EveryMS = curIntervalMS + } + case "at": + if patch.Schedule.AtMS != nil { + merged.AtMS = patch.Schedule.AtMS + } else if curRunAt != nil { + ms := curRunAt.UnixMilli() + merged.AtMS = &ms + } + } + + // Validate the merged schedule before applying + switch merged.Kind { + case "cron": + if merged.Expr == "" { + return nil, fmt.Errorf("cron schedule requires expr") + } + gx := gronx.New() + if !gx.IsValid(merged.Expr) { + return nil, fmt.Errorf("invalid cron expression: %s", merged.Expr) + } + case "every": + if merged.EveryMS == nil || *merged.EveryMS <= 0 { + return nil, fmt.Errorf("every schedule requires positive everyMs") + } + case "at": + if merged.AtMS == nil { + return nil, fmt.Errorf("at schedule requires atMs") + } + } + + next := computeNextRun(&merged, time.Now()) + updates["next_run_at"] = next + } + if patch.DeleteAfterRun != nil { + updates["delete_after_run"] = *patch.DeleteAfterRun + } + + // Update agent_id column + if patch.AgentID != nil { + if *patch.AgentID == "" { + updates["agent_id"] = nil + } else if aid, parseErr := uuid.Parse(*patch.AgentID); parseErr == nil { + updates["agent_id"] = aid + } + } + + // Update payload JSONB — fetch current, merge patch fields, re-serialize + needsPayloadUpdate := patch.Message != "" || patch.Deliver != nil || patch.Channel != nil || patch.To != nil + if needsPayloadUpdate { + var payloadJSON []byte + if scanErr := s.db.QueryRow("SELECT payload FROM cron_jobs WHERE id = $1", id).Scan(&payloadJSON); scanErr == nil { + var payload store.CronPayload + json.Unmarshal(payloadJSON, &payload) + + if patch.Message != "" { + payload.Message = patch.Message + } + if patch.Deliver != nil { + payload.Deliver = *patch.Deliver + } + if patch.Channel != nil { + payload.Channel = *patch.Channel + } + if patch.To != nil { + payload.To = *patch.To + } + + merged, _ := json.Marshal(payload) + updates["payload"] = merged + } + } + + updates["updated_at"] = time.Now() + + if err := execMapUpdate(context.Background(), s.db, "cron_jobs", id, updates); err != nil { + return nil, err + } + + s.cacheLoaded = false + job, _ := s.scanJob(id) + return job, nil +}