refactor: split large Go files into smaller modules for maintainability

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.
This commit is contained in:
viettranx committed 2026-03-09 10:41:54 +07:00
1 parent 31ba182b40
commit e85624ce96
36 files changed
+3800 -3554

No files matched your search

+178
View File
@@ -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: <description>" — 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
}
+182
View File
@@ -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 ""
}
+7 -403
View File
@@ -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: <description>" — 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
}
+54
View File
@@ -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
}
+4 -203
View File
@@ -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
}
-416
View File
@@ -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
}
+229
View File
@@ -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
}
+210
View File
@@ -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())
}
+120
View File
@@ -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:])
}
+102
View File
@@ -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
}
-74
View File
@@ -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
}
@@ -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
}
-420
View File
@@ -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<<uint(attempt+1))*time.Second, maxChannelBackoff)
slog.Info("zalo_personal restarting channel", "attempt", attempt+1, "delay", delay, "channel", c.Name())
select {
case <-ctx.Done():
return false
case <-c.stopCh:
return false
case <-time.After(delay):
}
if err := c.restart(ctx); err != nil {
slog.Warn("zalo_personal restart failed", "attempt", attempt+1, "error", err)
continue
}
return true
}
slog.Error("zalo_personal channel gave up after max restart attempts", "channel", c.Name())
return false
}
// restart performs a full re-authentication and listener restart.
// Called from the listenLoop goroutine when the listener exhausts retries.
func (c *Channel) restart(ctx context.Context) error {
if ln := c.getListener(); ln != nil {
ln.Stop()
}
sess, err := c.authenticate(ctx)
if err != nil {
return fmt.Errorf("re-auth: %w", err)
}
ln, err := protocol.NewListener(sess)
if err != nil {
return fmt.Errorf("new listener: %w", err)
}
if err := ln.Start(ctx); err != nil {
return fmt.Errorf("start listener: %w", err)
}
c.mu.Lock()
c.sess = sess
c.listener = ln
c.mu.Unlock()
return nil
}
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
}
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
}
+218
View File
@@ -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
}
+129
View File
@@ -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<<uint(attempt+1))*time.Second, maxChannelBackoff)
slog.Info("zalo_personal restarting channel", "attempt", attempt+1, "delay", delay, "channel", c.Name())
select {
case <-ctx.Done():
return false
case <-c.stopCh:
return false
case <-time.After(delay):
}
if err := c.restart(ctx); err != nil {
slog.Warn("zalo_personal restart failed", "attempt", attempt+1, "error", err)
continue
}
return true
}
slog.Error("zalo_personal channel gave up after max restart attempts", "channel", c.Name())
return false
}
// restart performs a full re-authentication and listener restart.
// Called from the listenLoop goroutine when the listener exhausts retries.
func (c *Channel) restart(ctx context.Context) error {
if ln := c.getListener(); ln != nil {
ln.Stop()
}
sess, err := c.authenticate(ctx)
if err != nil {
return fmt.Errorf("re-auth: %w", err)
}
ln, err := protocol.NewListener(sess)
if err != nil {
return fmt.Errorf("new listener: %w", err)
}
if err := ln.Start(ctx); err != nil {
return fmt.Errorf("start listener: %w", err)
}
c.mu.Lock()
c.sess = sess
c.listener = ln
c.mu.Unlock()
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package personal
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels/typing"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol"
)
const maxTextLength = 2000
// 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) 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
}
-382
View File
@@ -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, &params)
}
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, &params)
}
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, &params)
}
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,
}))
}
+164
View File
@@ -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, &params)
}
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,
}))
}
+97
View File
@@ -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, &params)
}
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,
}))
}
+159
View File
@@ -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, &params)
}
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,
}))
}
-330
View File
@@ -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})
}
+178
View File
@@ -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"})
}
+85
View File
@@ -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})
}
+92
View File
@@ -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})
}
-393
View File
@@ -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(),
})
}
+19
View File
@@ -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")
+183
View File
@@ -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,
})
}
+212
View File
@@ -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(),
})
}
-504
View File
@@ -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, "<description>\n%s\n</description>\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, "<template>\n%s\n</template>\n\n", soulTemplate)
}
sb.WriteString(`IMPORTANT RULES:
1. Language: Write ALL content in the SAME LANGUAGE as the <description>. 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:
<frontmatter>
(short expertise summary here)
</frontmatter>
<file name="SOUL.md">
(content here)
</file>`)
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, "<description>\n%s\n</description>\n\n", description)
if soulContent != "" {
fmt.Fprintf(&sb, "<soul>\n%s\n</soul>\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("<templates>\n")
if identityTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"IDENTITY.md\">\n%s\n</file>\n", identityTemplate)
}
if userPredefinedTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"USER_PREDEFINED.md\">\n%s\n</file>\n", userPredefinedTemplate)
}
sb.WriteString("</templates>\n\n")
sb.WriteString(`IMPORTANT RULES:
1. Language: Write ALL content in the SAME LANGUAGE as the <description>. 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:
<file name="IDENTITY.md">
(content here)
</file>
<file name="USER_PREDEFINED.md">
(content here — or omit this entire block if not relevant)
</file>`)
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, "<description>\n%s\n</description>\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("<templates>\n")
if soulTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"SOUL.md\">\n%s\n</file>\n", soulTemplate)
}
if identityTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"IDENTITY.md\">\n%s\n</file>\n", identityTemplate)
}
if userPredefinedTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"USER_PREDEFINED.md\">\n%s\n</file>\n", userPredefinedTemplate)
}
sb.WriteString("</templates>\n\n")
sb.WriteString(`IMPORTANT RULES:
1. Language: Write ALL content in the SAME LANGUAGE as the <description>. 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:
<frontmatter>
(short expertise summary here)
</frontmatter>
<file name="SOUL.md">
(content here)
</file>
<file name="IDENTITY.md">
(content here)
</file>
<file name="USER_PREDEFINED.md">
(content here — or omit this entire block if not relevant)
</file>`)
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<current_files>\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, "<file name=%q>\n%s\n</file>\n", f.FileName, f.Content)
}
sb.WriteString("</current_files>\n\n")
fmt.Fprintf(&sb, "<edit_instructions>\n%s\n</edit_instructions>\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:
<frontmatter>
(updated expertise summary, or omit if unchanged)
</frontmatter>
<file name="SOUL.md">
(complete updated content, or omit if unchanged)
</file>
<file name="IDENTITY.md">
(complete updated content, or omit if unchanged)
</file>
<file name="USER_PREDEFINED.md">
(complete updated content, or omit if unchanged/not needed)
</file>
`)
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
}
+257
View File
@@ -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, "<description>\n%s\n</description>\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, "<template>\n%s\n</template>\n\n", soulTemplate)
}
sb.WriteString(`IMPORTANT RULES:
1. Language: Write ALL content in the SAME LANGUAGE as the <description>. 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:
<frontmatter>
(short expertise summary here)
</frontmatter>
<file name="SOUL.md">
(content here)
</file>`)
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, "<description>\n%s\n</description>\n\n", description)
if soulContent != "" {
fmt.Fprintf(&sb, "<soul>\n%s\n</soul>\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("<templates>\n")
if identityTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"IDENTITY.md\">\n%s\n</file>\n", identityTemplate)
}
if userPredefinedTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"USER_PREDEFINED.md\">\n%s\n</file>\n", userPredefinedTemplate)
}
sb.WriteString("</templates>\n\n")
sb.WriteString(`IMPORTANT RULES:
1. Language: Write ALL content in the SAME LANGUAGE as the <description>. 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:
<file name="IDENTITY.md">
(content here)
</file>
<file name="USER_PREDEFINED.md">
(content here — or omit this entire block if not relevant)
</file>`)
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, "<description>\n%s\n</description>\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("<templates>\n")
if soulTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"SOUL.md\">\n%s\n</file>\n", soulTemplate)
}
if identityTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"IDENTITY.md\">\n%s\n</file>\n", identityTemplate)
}
if userPredefinedTemplate != "" {
fmt.Fprintf(&sb, "<file name=\"USER_PREDEFINED.md\">\n%s\n</file>\n", userPredefinedTemplate)
}
sb.WriteString("</templates>\n\n")
sb.WriteString(`IMPORTANT RULES:
1. Language: Write ALL content in the SAME LANGUAGE as the <description>. 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:
<frontmatter>
(short expertise summary here)
</frontmatter>
<file name="SOUL.md">
(content here)
</file>
<file name="IDENTITY.md">
(content here)
</file>
<file name="USER_PREDEFINED.md">
(content here — or omit this entire block if not relevant)
</file>`)
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<current_files>\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, "<file name=%q>\n%s\n</file>\n", f.FileName, f.Content)
}
sb.WriteString("</current_files>\n\n")
fmt.Fprintf(&sb, "<edit_instructions>\n%s\n</edit_instructions>\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:
<frontmatter>
(updated expertise summary, or omit if unchanged)
</frontmatter>
<file name="SOUL.md">
(complete updated content, or omit if unchanged)
</file>
<file name="IDENTITY.md">
(complete updated content, or omit if unchanged)
</file>
<file name="USER_PREDEFINED.md">
(complete updated content, or omit if unchanged/not needed)
</file>
`)
return sb.String()
}
+218
View File
@@ -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,
})
}
+57
View File
@@ -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
}
-429
View File
@@ -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
}
+161
View File
@@ -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
}
+111
View File
@@ -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,
}
}
+185
View File
@@ -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
}