mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-17 18:48:16 +00:00
75c570e951
- Secure CLI credential injection via AES-256-GCM encrypted env vars - API key management with fine-grained RBAC scopes - resolveAuth/requireAuth middleware across all 25+ HTTP handlers - In-memory API key cache with TTL, negative caching, pubsub invalidation - Sandbox-first execution (fails if unavailable, no silent fallback) - Credential scrubbing, constant-time token comparison, Admin-only CLI creds - SQL migration 000020: secure_cli_binaries + api_keys tables - 14 unit tests for cache and RBAC with race detector Closes #197
210 lines
7.5 KiB
Go
210 lines
7.5 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// PendingMessagesHandler handles pending message HTTP endpoints.
|
|
type PendingMessagesHandler struct {
|
|
store store.PendingMessageStore
|
|
agentStore store.AgentStore
|
|
token string
|
|
providerReg *providers.Registry
|
|
keepRecent int // global keepRecent from config (0 = use default 15)
|
|
maxTokens int // max output tokens for LLM summarization (0 = use default)
|
|
cfgProvider string // config-level provider override (empty = resolve from agent)
|
|
cfgModel string // config-level model override (empty = resolve from agent)
|
|
}
|
|
|
|
func NewPendingMessagesHandler(s store.PendingMessageStore, agentStore store.AgentStore, token string, providerReg *providers.Registry) *PendingMessagesHandler {
|
|
return &PendingMessagesHandler{store: s, agentStore: agentStore, token: token, providerReg: providerReg}
|
|
}
|
|
|
|
// SetKeepRecent sets the global keepRecent value from config.
|
|
func (h *PendingMessagesHandler) SetKeepRecent(n int) { h.keepRecent = n }
|
|
|
|
// SetMaxTokens sets the global maxTokens value from config.
|
|
func (h *PendingMessagesHandler) SetMaxTokens(n int) { h.maxTokens = n }
|
|
|
|
// SetProviderModel sets explicit provider/model from config.
|
|
func (h *PendingMessagesHandler) SetProviderModel(provider, model string) {
|
|
h.cfgProvider = provider
|
|
h.cfgModel = model
|
|
}
|
|
|
|
func (h *PendingMessagesHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/pending-messages", h.authMiddleware(h.handleListGroups))
|
|
mux.HandleFunc("GET /v1/pending-messages/messages", h.authMiddleware(h.handleListMessages))
|
|
mux.HandleFunc("DELETE /v1/pending-messages", h.authMiddleware(h.handleDelete))
|
|
mux.HandleFunc("POST /v1/pending-messages/compact", h.authMiddleware(h.handleCompact))
|
|
}
|
|
|
|
func (h *PendingMessagesHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
|
return requireAuth(h.token, "", next)
|
|
}
|
|
|
|
// GET /v1/pending-messages — list all groups with resolved titles
|
|
func (h *PendingMessagesHandler) handleListGroups(w http.ResponseWriter, r *http.Request) {
|
|
groups, err := h.store.ListGroups(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Resolve group titles from session metadata (best-effort, non-blocking)
|
|
if titles, err := h.store.ResolveGroupTitles(r.Context(), groups); err == nil {
|
|
for i := range groups {
|
|
if t, ok := titles[groups[i].ChannelName+":"+groups[i].HistoryKey]; ok {
|
|
groups[i].GroupTitle = t
|
|
}
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
|
}
|
|
|
|
// GET /v1/pending-messages/messages?channel=X&key=Y — list messages for a group
|
|
func (h *PendingMessagesHandler) handleListMessages(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
channel := r.URL.Query().Get("channel")
|
|
key := r.URL.Query().Get("key")
|
|
if channel == "" || key == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgChannelKeyReq)})
|
|
return
|
|
}
|
|
|
|
msgs, err := h.store.ListByKey(r.Context(), channel, key)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
|
|
}
|
|
|
|
// DELETE /v1/pending-messages?channel=X&key=Y — clear a group
|
|
func (h *PendingMessagesHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
channel := r.URL.Query().Get("channel")
|
|
key := r.URL.Query().Get("key")
|
|
if channel == "" || key == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgChannelKeyReq)})
|
|
return
|
|
}
|
|
|
|
if err := h.store.DeleteByKey(r.Context(), channel, key); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
type compactRequest struct {
|
|
ChannelName string `json:"channel_name"`
|
|
HistoryKey string `json:"history_key"`
|
|
}
|
|
|
|
// POST /v1/pending-messages/compact — LLM-based summarization of old messages, keeping recent ones.
|
|
// Falls back to hard delete if no LLM provider is available.
|
|
func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Request) {
|
|
locale := store.LocaleFromContext(r.Context())
|
|
var req compactRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
|
return
|
|
}
|
|
if req.ChannelName == "" || req.HistoryKey == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "channel_name and history_key")})
|
|
return
|
|
}
|
|
|
|
// Resolve an LLM provider for summarization using the default agent's config
|
|
provider, model := h.resolveProviderAndModel()
|
|
if provider == nil {
|
|
// Fallback: hard delete if no provider available
|
|
slog.Warn("compact.no_provider", "channel", req.ChannelName, "key", req.HistoryKey)
|
|
if err := h.store.DeleteByKey(r.Context(), req.ChannelName, req.HistoryKey); err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "method": "deleted"})
|
|
return
|
|
}
|
|
|
|
// Run compaction in background so the HTTP response returns immediately.
|
|
// The long-running LLM call (30-120s) was blocking the response, which
|
|
// caused browser WebSocket connections to drop (pong timeout).
|
|
keepRecent := h.keepRecent
|
|
if keepRecent <= 0 {
|
|
keepRecent = 15
|
|
}
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
|
defer cancel()
|
|
remaining, err := channels.CompactGroup(ctx, h.store, req.ChannelName, req.HistoryKey, provider, model, keepRecent, h.maxTokens)
|
|
if err != nil {
|
|
slog.Warn("compact.failed", "channel", req.ChannelName, "key", req.HistoryKey, "error", err)
|
|
} else {
|
|
slog.Info("compact.done", "channel", req.ChannelName, "key", req.HistoryKey, "remaining", remaining)
|
|
}
|
|
}()
|
|
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"status": "accepted", "method": "summarizing"})
|
|
}
|
|
|
|
// resolveProviderAndModel resolves the LLM provider+model for pending message compaction.
|
|
// Priority: config provider/model > default agent's provider/model > first available provider.
|
|
func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider, string) {
|
|
if h.providerReg == nil {
|
|
return nil, ""
|
|
}
|
|
|
|
// Config-level provider/model override.
|
|
if h.cfgProvider != "" {
|
|
if p, err := h.providerReg.Get(h.cfgProvider); err == nil {
|
|
model := h.cfgModel
|
|
if model == "" {
|
|
model = p.DefaultModel()
|
|
}
|
|
if model != "" {
|
|
return p, model
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: default agent's provider+model.
|
|
if h.agentStore != nil {
|
|
if ag, err := h.agentStore.GetDefault(context.Background()); err == nil && ag.Provider != "" {
|
|
if p, err := h.providerReg.Get(ag.Provider); err == nil {
|
|
model := ag.Model
|
|
if model == "" {
|
|
model = p.DefaultModel()
|
|
}
|
|
if model != "" {
|
|
return p, model
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: first provider with a valid default model
|
|
for _, name := range h.providerReg.List() {
|
|
p, err := h.providerReg.Get(name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if p.DefaultModel() != "" {
|
|
return p, p.DefaultModel()
|
|
}
|
|
}
|
|
return nil, ""
|
|
}
|