feat: add asynchronous /chat HTTP endpoint for external integrations
This commit is contained in:
parent
84e42d6904
commit
27fd786dab
6 changed files with 492 additions and 71 deletions
|
|
@ -1,5 +1,3 @@
|
||||||
version: "2"
|
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
disable:
|
disable:
|
||||||
|
|
|
||||||
2
Makefile
2
Makefile
|
|
@ -273,7 +273,7 @@ test: generate
|
||||||
|
|
||||||
## fmt: Format Go code
|
## fmt: Format Go code
|
||||||
fmt:
|
fmt:
|
||||||
@$(GOLANGCI_LINT) fmt
|
@$(GO) fmt ./...
|
||||||
|
|
||||||
## lint: Run linters
|
## lint: Run linters
|
||||||
lint:
|
lint:
|
||||||
|
|
|
||||||
45
pkg/channels/http/http.go
Normal file
45
pkg/channels/http/http.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("http", NewHTTPChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPChannel struct {
|
||||||
|
*channels.BaseChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPChannel(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
bc := channels.NewBaseChannel("http", nil, b, nil)
|
||||||
|
return &HTTPChannel{
|
||||||
|
BaseChannel: bc,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPChannel) Start(ctx context.Context) error {
|
||||||
|
c.SetRunning(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPChannel) Stop(ctx context.Context) error {
|
||||||
|
c.SetRunning(false)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
||||||
|
logger.InfoCF("channels", "HTTP channel received outbound message", map[string]any{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"content": msg.Content,
|
||||||
|
})
|
||||||
|
// For synchronous HTTP, the response is usually handled by the caller of ProcessDirectWithChannel.
|
||||||
|
// Asynchronous messages (e.g. from subagents) will just be logged here for now.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,8 @@ const DefaultGatewayLogLevel = "warn"
|
||||||
type GatewayConfig struct {
|
type GatewayConfig struct {
|
||||||
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
|
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
|
||||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||||
|
APIKey string `json:"api_key" env:"PICOCLAW_GATEWAY_API_KEY"`
|
||||||
|
ChatEnabled bool `json:"chat_enabled" env:"PICOCLAW_GATEWAY_CHAT_ENABLED"`
|
||||||
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
|
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
|
||||||
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
|
LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/http"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
|
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
|
|
@ -111,27 +112,39 @@ func (p *startupBlockedProvider) GetDefaultModel() string {
|
||||||
|
|
||||||
// Run starts the gateway runtime using the configuration loaded from configPath.
|
// Run starts the gateway runtime using the configuration loaded from configPath.
|
||||||
func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error {
|
func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error {
|
||||||
|
fmt.Printf("🚀 PicoClaw Gateway starting...\n")
|
||||||
|
fmt.Printf("📂 Home Path: %s\n", homePath)
|
||||||
|
fmt.Printf("📄 Config Path: %s\n", configPath)
|
||||||
|
|
||||||
panicPath := filepath.Join(homePath, logPath, panicFile)
|
panicPath := filepath.Join(homePath, logPath, panicFile)
|
||||||
|
fmt.Printf("🔧 Initializing panic log: %s\n", panicPath)
|
||||||
panicFunc, err := logger.InitPanic(panicPath)
|
panicFunc, err := logger.InitPanic(panicPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error initializing panic log: %w", err)
|
fmt.Printf("⚠️ Warning: error initializing panic log (continuing): %v\n", err)
|
||||||
}
|
} else if panicFunc != nil {
|
||||||
defer panicFunc()
|
defer panicFunc()
|
||||||
|
fmt.Println("✓ Panic log initialized")
|
||||||
if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil {
|
|
||||||
logger.Fatal(fmt.Sprintf("error enabling file logging: %v", err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logFilePath := filepath.Join(homePath, logPath, logFile)
|
||||||
|
fmt.Printf("🔧 Enabling file logging: %s\n", logFilePath)
|
||||||
|
if err = logger.EnableFileLogging(logFilePath); err != nil {
|
||||||
|
fmt.Printf("⚠️ Warning: error enabling file logging (continuing): %v\n", err)
|
||||||
|
} else {
|
||||||
defer logger.DisableFileLogging()
|
defer logger.DisableFileLogging()
|
||||||
|
fmt.Println("✓ File logging enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("🔍 Loading configuration...")
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
} else {
|
} else {
|
||||||
logger.SetLevelFromString(config.ResolveGatewayLogLevel(configPath))
|
logger.SetLevelFromString(cfg.Gateway.LogLevel)
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := config.LoadConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatalf("error loading config: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = preCheckConfig(cfg); err != nil {
|
if err = preCheckConfig(cfg); err != nil {
|
||||||
|
|
@ -155,10 +168,14 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
||||||
}
|
}
|
||||||
defer pid.RemovePidFile(homePath)
|
defer pid.RemovePidFile(homePath)
|
||||||
|
|
||||||
|
fmt.Printf("🔍 Creating startup provider for model: %s (allow empty: %v)\n",
|
||||||
|
cfg.Agents.Defaults.GetModelName(), allowEmptyStartup)
|
||||||
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
|
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("❌ Error creating provider: %v\n", err)
|
||||||
return fmt.Errorf("error creating provider: %w", err)
|
return fmt.Errorf("error creating provider: %w", err)
|
||||||
}
|
}
|
||||||
|
fmt.Printf("✓ Provider created (Model ID: %s)\n", modelID)
|
||||||
|
|
||||||
if modelID != "" {
|
if modelID != "" {
|
||||||
cfg.Agents.Defaults.ModelName = modelID
|
cfg.Agents.Defaults.ModelName = modelID
|
||||||
|
|
@ -181,8 +198,10 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
||||||
"skills_available": skillsInfo["available"],
|
"skills_available": skillsInfo["available"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fmt.Println("🚀 Setting up services...")
|
||||||
runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token)
|
runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus, pidData.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("❌ Error starting services: %v\n", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,8 +222,23 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runningServices.HealthServer.SetReloadFunc(reloadTrigger)
|
runningServices.HealthServer.SetReloadFunc(reloadTrigger)
|
||||||
|
runningServices.HealthServer.SetAPIKey(cfg.Gateway.APIKey)
|
||||||
agentLoop.SetReloadFunc(reloadTrigger)
|
agentLoop.SetReloadFunc(reloadTrigger)
|
||||||
|
|
||||||
|
// Setup synchronous /chat endpoint handler
|
||||||
|
if cfg.Gateway.ChatEnabled {
|
||||||
|
runningServices.HealthServer.SetChatFunc(func(ctx context.Context, message, sessionID, chatID string) (string, error) {
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = fmt.Sprintf("chat-%s", time.Now().Format("20060102-150405"))
|
||||||
|
}
|
||||||
|
if chatID == "" {
|
||||||
|
// Default to sessionID to ensure isolation
|
||||||
|
chatID = sessionID
|
||||||
|
}
|
||||||
|
return agentLoop.ProcessDirectWithChannel(ctx, message, sessionID, "http", chatID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
fmt.Println("Press Ctrl+C to stop")
|
fmt.Println("Press Ctrl+C to stop")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,41 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Mux defines the interface required for registering health handlers.
|
||||||
|
type Mux interface {
|
||||||
|
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatRequest is the JSON body for POST /chat.
|
||||||
|
type ChatRequest struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
ChatID string `json:"chat_id,omitempty"` // Alias for session_id to match PicoClaw terminology
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatResponse is the JSON response from /chat.
|
||||||
|
type ChatResponse struct {
|
||||||
|
Response string `json:"response,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatStatus struct {
|
||||||
|
Response string
|
||||||
|
Error error
|
||||||
|
Done bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
server *http.Server
|
server *http.Server
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
@ -19,6 +50,11 @@ type Server struct {
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
reloadFunc func() error
|
reloadFunc func() error
|
||||||
authToken string // optional bearer token for protected endpoints
|
authToken string // optional bearer token for protected endpoints
|
||||||
|
chatFunc func(ctx context.Context, message, sessionID, chatID string) (string, error)
|
||||||
|
apiKey string
|
||||||
|
chatResults map[string]*chatStatus
|
||||||
|
chatResultsMu sync.RWMutex
|
||||||
|
rateLimits sync.Map // key: string (ID or IP), value: time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Check struct {
|
type Check struct {
|
||||||
|
|
@ -32,6 +68,7 @@ type StatusResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Uptime string `json:"uptime"`
|
Uptime string `json:"uptime"`
|
||||||
Checks map[string]Check `json:"checks,omitempty"`
|
Checks map[string]Check `json:"checks,omitempty"`
|
||||||
|
Pid int `json:"pid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(host string, port int, token string) *Server {
|
func NewServer(host string, port int, token string) *Server {
|
||||||
|
|
@ -41,18 +78,24 @@ func NewServer(host string, port int, token string) *Server {
|
||||||
checks: make(map[string]Check),
|
checks: make(map[string]Check),
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
authToken: token,
|
authToken: token,
|
||||||
|
chatResults: make(map[string]*chatStatus),
|
||||||
}
|
}
|
||||||
|
|
||||||
mux.HandleFunc("/health", s.healthHandler)
|
mux.HandleFunc("/health", s.healthHandler)
|
||||||
mux.HandleFunc("/ready", s.readyHandler)
|
mux.HandleFunc("/ready", s.readyHandler)
|
||||||
mux.HandleFunc("/reload", s.reloadHandler)
|
mux.HandleFunc("/reload", s.reloadHandler)
|
||||||
|
mux.HandleFunc("/chat", s.chatHandler)
|
||||||
|
|
||||||
|
// Start task cleanup goroutine
|
||||||
|
go s.taskCleanupLoop()
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", host, port)
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
s.server = &http.Server{
|
s.server = &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
ReadTimeout: 5 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 5 * time.Second,
|
// WriteTimeout must be long enough for LLM inference; 5 min is generous.
|
||||||
|
WriteTimeout: 5 * time.Minute,
|
||||||
}
|
}
|
||||||
|
|
||||||
return s
|
return s
|
||||||
|
|
@ -116,6 +159,62 @@ func (s *Server) SetReloadFunc(fn func() error) {
|
||||||
s.reloadFunc = fn
|
s.reloadFunc = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetChatFunc sets the callback that processes /chat requests.
|
||||||
|
// fn receives the user message and an optional session ID and must return the
|
||||||
|
// agent's reply (or an error). It is called synchronously inside the HTTP
|
||||||
|
// handler, so the write timeout on the server governs the maximum duration.
|
||||||
|
func (s *Server) SetChatFunc(fn func(ctx context.Context, message, sessionID, chatID string) (string, error)) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.chatFunc = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAPIKey sets the expected X-API-Key header value.
|
||||||
|
func (s *Server) SetAPIKey(key string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.apiKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAuthToken sets the expected Bearer token.
|
||||||
|
func (s *Server) SetAuthToken(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.authToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) verifyAuth(r *http.Request) bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
// If no authentication is configured, allow the request.
|
||||||
|
if s.apiKey == "" && s.authToken == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check X-API-Key header.
|
||||||
|
if s.apiKey != "" {
|
||||||
|
gotKey := r.Header.Get("X-API-Key")
|
||||||
|
if subtle.ConstantTimeCompare([]byte(gotKey), []byte(s.apiKey)) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Authorization: Bearer <token> header.
|
||||||
|
if s.authToken != "" {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if len(authHeader) > len(prefix) && strings.EqualFold(authHeader[:len(prefix)], prefix) {
|
||||||
|
gotToken := authHeader[len(prefix):]
|
||||||
|
if subtle.ConstantTimeCompare([]byte(gotToken), []byte(s.authToken)) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
@ -123,21 +222,12 @@ func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use POST"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use POST"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !s.verifyAuth(r) {
|
||||||
// Token check
|
|
||||||
s.mu.RLock()
|
|
||||||
requiredToken := s.authToken
|
|
||||||
s.mu.RUnlock()
|
|
||||||
|
|
||||||
if requiredToken != "" {
|
|
||||||
given := extractBearerToken(r.Header.Get("Authorization"))
|
|
||||||
if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(requiredToken)) != 1 {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
reloadFunc := s.reloadFunc
|
reloadFunc := s.reloadFunc
|
||||||
|
|
@ -170,6 +260,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
resp := StatusResponse{
|
resp := StatusResponse{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Uptime: uptime.String(),
|
Uptime: uptime.String(),
|
||||||
|
Pid: os.Getpid(),
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
|
@ -213,20 +304,284 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandlerMux is the interface for registering HTTP handlers, used by
|
// RegisterOnMux registers /health, /ready, /reload and /chat handlers onto the
|
||||||
// RegisterOnMux so that callers can pass any mux implementation
|
// given mux. This allows the health endpoints to be served by a shared HTTP server.
|
||||||
// (e.g. *http.ServeMux or a custom dynamic mux).
|
func (s *Server) RegisterOnMux(mux Mux) {
|
||||||
type HandlerMux interface {
|
|
||||||
Handle(pattern string, handler http.Handler)
|
|
||||||
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterOnMux registers /health, /ready and /reload handlers onto the given mux.
|
|
||||||
// This allows the health endpoints to be served by a shared HTTP server.
|
|
||||||
func (s *Server) RegisterOnMux(mux HandlerMux) {
|
|
||||||
mux.HandleFunc("/health", s.healthHandler)
|
mux.HandleFunc("/health", s.healthHandler)
|
||||||
mux.HandleFunc("/ready", s.readyHandler)
|
mux.HandleFunc("/ready", s.readyHandler)
|
||||||
mux.HandleFunc("/reload", s.reloadHandler)
|
mux.HandleFunc("/reload", s.reloadHandler)
|
||||||
|
mux.HandleFunc("/chat", s.chatHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatHandler handles POST /chat (initiate async) and GET /chat (poll for result).
|
||||||
|
// POST body: {"message": "...", "session_id": "..." (optional)}
|
||||||
|
// POST response: {"session_id": "...", "status": "pending"}
|
||||||
|
// GET query: ?session_id=...
|
||||||
|
// GET response: {"response": "...", "status": "completed"}
|
||||||
|
func (s *Server) chatHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.verifyAuth(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{Error: "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.checkRateLimit(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{Error: "rate limit exceeded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
s.handlePostChat(w, r)
|
||||||
|
return
|
||||||
|
} else if r.Method == http.MethodGet {
|
||||||
|
s.handleGetChat(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{Error: "method not allowed, use POST or GET"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePostChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.RLock()
|
||||||
|
chatFunc := s.chatFunc
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if chatFunc == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{Error: "chat not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ChatRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{Error: "invalid JSON: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Message == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{Error: "message field is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := req.SessionID
|
||||||
|
if sessionID == "" && req.ChatID != "" {
|
||||||
|
sessionID = req.ChatID
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID := req.ChatID
|
||||||
|
if chatID == "" {
|
||||||
|
// Try to extract ChatID/TenantID from common headers
|
||||||
|
// These are ordered by specificity/reliability
|
||||||
|
headers := []string{
|
||||||
|
"X-PicoClaw-Chat-ID",
|
||||||
|
"X-MS-CONVERSATION-ID", // Teams Conversation ID
|
||||||
|
"X-MS-TENANT-ID", // Teams Tenant ID
|
||||||
|
"X-User-ID",
|
||||||
|
"X-Session-ID",
|
||||||
|
"X-MS-CLIENT-PRINCIPAL-ID", // Azure App Service / Container Apps (EasyAuth)
|
||||||
|
"X-MS-CLIENT-PRINCIPAL-NAME", // Azure App Service Email/Username
|
||||||
|
"Ocp-Apim-Subscription-Id", // Azure APIM (if configured)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, h := range headers {
|
||||||
|
if val := r.Header.Get(h); val != "" {
|
||||||
|
chatID = val
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to SessionID if provided in body, otherwise empty (global)
|
||||||
|
if chatID == "" {
|
||||||
|
chatID = req.SessionID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chatID = s.sanitizeID(chatID)
|
||||||
|
sessionID = s.sanitizeID(sessionID)
|
||||||
|
|
||||||
|
if chatID != "" {
|
||||||
|
logger.InfoCF("api", "Resolved isolation ID for request", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"session_id": sessionID,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Log all headers for debugging (excluding sensitive ones)
|
||||||
|
headers := make(map[string]string)
|
||||||
|
for k, v := range r.Header {
|
||||||
|
if k == "Authorization" || k == "X-Api-Key" || k == "Ocp-Apim-Subscription-Key" {
|
||||||
|
headers[k] = "REDACTED"
|
||||||
|
} else if len(v) > 0 {
|
||||||
|
headers[k] = v[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.DebugCF("api", "Chat request received without explicit ChatID. Checking headers...", map[string]any{
|
||||||
|
"headers": headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = fmt.Sprintf("chat-%d", time.Now().UnixNano())
|
||||||
|
} else {
|
||||||
|
// Even if provided, sanitize the user-provided sessionID again to be sure
|
||||||
|
sessionID = s.sanitizeID(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize status
|
||||||
|
s.chatResultsMu.Lock()
|
||||||
|
s.chatResults[sessionID] = &chatStatus{
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
s.chatResultsMu.Unlock()
|
||||||
|
|
||||||
|
// Start processing in background
|
||||||
|
go func() {
|
||||||
|
// Use a long-running context for the chat call, but don't bind to r.Context()
|
||||||
|
// which will be canceled when this request finishes.
|
||||||
|
ctx := context.Background()
|
||||||
|
logger.Debugf("Starting async chat for session %s", sessionID)
|
||||||
|
reply, err := chatFunc(ctx, req.Message, sessionID, chatID)
|
||||||
|
|
||||||
|
s.chatResultsMu.Lock()
|
||||||
|
defer s.chatResultsMu.Unlock()
|
||||||
|
if result, ok := s.chatResults[sessionID]; ok {
|
||||||
|
result.Response = reply
|
||||||
|
result.Error = err
|
||||||
|
result.Done = true
|
||||||
|
logger.Debugf("Finished async chat for session %s (err=%v)", sessionID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: "pending",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := r.URL.Query().Get("session_id")
|
||||||
|
if sessionID == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{Error: "session_id query parameter is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.chatResultsMu.RLock()
|
||||||
|
result, ok := s.chatResults[sessionID]
|
||||||
|
if !ok {
|
||||||
|
s.chatResultsMu.RUnlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{Error: "session not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read fields while holding the lock to avoid race conditions
|
||||||
|
done := result.Done
|
||||||
|
response := result.Response
|
||||||
|
errVal := result.Error
|
||||||
|
s.chatResultsMu.RUnlock()
|
||||||
|
|
||||||
|
if !done {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: "pending",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errVal != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: "error",
|
||||||
|
Error: errVal.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(ChatResponse{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Status: "completed",
|
||||||
|
Response: response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) taskCleanupLoop() {
|
||||||
|
ticker := time.NewTicker(10 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
s.chatResultsMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for id, status := range s.chatResults {
|
||||||
|
// Keep pending tasks for 2 hours, completed/error for 1 hour
|
||||||
|
expiry := time.Hour
|
||||||
|
if !status.Done {
|
||||||
|
expiry = 2 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.Sub(status.CreatedAt) > expiry {
|
||||||
|
delete(s.chatResults, id)
|
||||||
|
logger.Debugf("Cleaned up expired chat session %s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.chatResultsMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sanitizeID(id string) string {
|
||||||
|
if len(id) > 128 {
|
||||||
|
id = id[:128]
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]rune, 0, len(id))
|
||||||
|
for _, r := range id {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||||
|
result = append(result, r)
|
||||||
|
} else {
|
||||||
|
result = append(result, '_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) checkRateLimit(r *http.Request) bool {
|
||||||
|
// Simple rate limit: 1 request per second per ID or IP
|
||||||
|
// This is defensive against automated spamming.
|
||||||
|
key := r.Header.Get("X-PicoClaw-Chat-ID")
|
||||||
|
if key == "" {
|
||||||
|
key = r.RemoteAddr
|
||||||
|
// Strip port if present
|
||||||
|
if i := strings.LastIndex(key, ":"); i != -1 {
|
||||||
|
key = key[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if val, ok := s.rateLimits.Load(key); ok {
|
||||||
|
lastAccess := val.(time.Time)
|
||||||
|
if time.Since(lastAccess) < time.Second {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.rateLimits.Store(key, time.Now())
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func statusString(ok bool) string {
|
func statusString(ok bool) string {
|
||||||
|
|
@ -235,16 +590,3 @@ func statusString(ok bool) string {
|
||||||
}
|
}
|
||||||
return "fail"
|
return "fail"
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractBearerToken returns the token from an "Authorization: Bearer <t>" header,
|
|
||||||
// or the empty string if the header is missing or malformed.
|
|
||||||
func extractBearerToken(header string) string {
|
|
||||||
const prefix = "Bearer "
|
|
||||||
if len(header) < len(prefix) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if header[:len(prefix)] != prefix {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return header[len(prefix):]
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue