Files
2026-06-02 23:14:41 +08:00

275 lines
7.2 KiB
Go

package core
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// HookEventType enumerates the lifecycle events that can trigger hooks.
type HookEventType string
const (
HookEventMessageReceived HookEventType = "message.received"
HookEventMessageSent HookEventType = "message.sent"
HookEventSessionStarted HookEventType = "session.started"
HookEventSessionEnded HookEventType = "session.ended"
HookEventCronTriggered HookEventType = "cron.triggered"
HookEventPermissionRequested HookEventType = "permission.requested"
HookEventError HookEventType = "error"
)
// HookHandlerType is the execution strategy for a hook.
type HookHandlerType string
const (
HookHandlerCommand HookHandlerType = "command"
HookHandlerHTTP HookHandlerType = "http"
)
// HookConfig is the user-facing configuration for a single hook rule.
type HookConfig struct {
Event string `toml:"event" json:"event"`
Type string `toml:"type" json:"type"` // "command" or "http"
Command string `toml:"command" json:"command,omitempty"`
URL string `toml:"url" json:"url,omitempty"`
Timeout int `toml:"timeout" json:"timeout,omitempty"` // seconds; 0 = default (10s cmd, 5s http)
Async *bool `toml:"async" json:"async,omitempty"` // nil = true (async by default)
}
func (h *HookConfig) isAsync() bool {
return h.Async == nil || *h.Async
}
func (h *HookConfig) timeoutDuration() time.Duration {
if h.Timeout > 0 {
return time.Duration(h.Timeout) * time.Second
}
if h.Type == "http" {
return 5 * time.Second
}
return 10 * time.Second
}
// HookEvent is the payload delivered to hook handlers.
type HookEvent struct {
Event HookEventType `json:"event"`
Timestamp time.Time `json:"timestamp"`
Project string `json:"project"`
SessionKey string `json:"session_key,omitempty"`
Platform string `json:"platform,omitempty"`
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
Content string `json:"content,omitempty"`
Error string `json:"error,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// HookManager dispatches lifecycle events to configured hook handlers.
type HookManager struct {
hooks []HookConfig
project string
mu sync.RWMutex
client *http.Client
}
// NewHookManager creates a manager for the given project name.
func NewHookManager(project string, hooks []HookConfig) *HookManager {
valid := make([]HookConfig, 0, len(hooks))
for _, h := range hooks {
if err := validateHookConfig(h); err != nil {
slog.Warn("hooks: skipping invalid config", "project", project, "error", err)
continue
}
valid = append(valid, h)
}
return &HookManager{
hooks: valid,
project: project,
client: &http.Client{},
}
}
func validateHookConfig(h HookConfig) error {
if h.Event == "" {
return fmt.Errorf("event is required")
}
switch HookHandlerType(h.Type) {
case HookHandlerCommand:
if h.Command == "" {
return fmt.Errorf("command is required for type=command")
}
case HookHandlerHTTP:
if h.URL == "" {
return fmt.Errorf("url is required for type=http")
}
if !strings.HasPrefix(h.URL, "http://") && !strings.HasPrefix(h.URL, "https://") {
return fmt.Errorf("url must start with http:// or https://")
}
default:
return fmt.Errorf("unknown handler type %q (must be command or http)", h.Type)
}
return nil
}
// Emit dispatches an event to all matching hooks.
func (hm *HookManager) Emit(event HookEvent) {
if hm == nil {
return
}
event.Project = hm.project
if event.Timestamp.IsZero() {
event.Timestamp = time.Now()
}
hm.mu.RLock()
hooks := hm.hooks
hm.mu.RUnlock()
for i := range hooks {
h := &hooks[i]
if !matchEvent(h.Event, string(event.Event)) {
continue
}
if h.isAsync() {
go hm.execute(h, event)
} else {
hm.execute(h, event)
}
}
}
// matchEvent checks if a hook's event pattern matches the fired event.
// Supports exact match and wildcard "*".
func matchEvent(pattern, event string) bool {
if pattern == "*" {
return true
}
return strings.EqualFold(pattern, event)
}
func (hm *HookManager) execute(h *HookConfig, event HookEvent) {
switch HookHandlerType(h.Type) {
case HookHandlerCommand:
hm.executeCommand(h, event)
case HookHandlerHTTP:
hm.executeHTTP(h, event)
}
}
func (hm *HookManager) executeCommand(h *HookConfig, event HookEvent) {
timeout := h.timeoutDuration()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", h.Command)
cmd.WaitDelay = 2 * time.Second
cmd.Env = append(os.Environ(), eventToEnv(event)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Warn("hooks: command failed",
"project", hm.project, "event", event.Event,
"command", truncateStr(h.Command, 80),
"error", err,
"output", truncateStr(strings.TrimSpace(string(output)), 500),
)
return
}
slog.Debug("hooks: command executed",
"project", hm.project, "event", event.Event,
"command", truncateStr(h.Command, 80),
)
}
func (hm *HookManager) executeHTTP(h *HookConfig, event HookEvent) {
body, err := json.Marshal(event)
if err != nil {
slog.Warn("hooks: marshal event failed", "error", err)
return
}
timeout := h.timeoutDuration()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.URL, bytes.NewReader(body))
if err != nil {
slog.Warn("hooks: create request failed", "url", h.URL, "error", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "CC-Connect-Hooks/1.0")
req.Header.Set("X-Hook-Event", string(event.Event))
resp, err := hm.client.Do(req)
if err != nil {
slog.Warn("hooks: http request failed",
"project", hm.project, "event", event.Event,
"url", h.URL, "error", err,
)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
slog.Warn("hooks: http response error",
"project", hm.project, "event", event.Event,
"url", h.URL, "status", resp.StatusCode,
)
return
}
slog.Debug("hooks: http delivered",
"project", hm.project, "event", event.Event,
"url", h.URL, "status", resp.StatusCode,
)
}
// eventToEnv converts a HookEvent to environment variables for shell hooks.
func eventToEnv(e HookEvent) []string {
env := []string{
"CC_HOOK_EVENT=" + string(e.Event),
"CC_HOOK_PROJECT=" + e.Project,
"CC_HOOK_TIMESTAMP=" + e.Timestamp.Format(time.RFC3339),
}
if e.SessionKey != "" {
env = append(env, "CC_HOOK_SESSION_KEY="+e.SessionKey)
}
if e.Platform != "" {
env = append(env, "CC_HOOK_PLATFORM="+e.Platform)
}
if e.UserID != "" {
env = append(env, "CC_HOOK_USER_ID="+e.UserID)
}
if e.UserName != "" {
env = append(env, "CC_HOOK_USER_NAME="+e.UserName)
}
if e.Content != "" {
env = append(env, "CC_HOOK_CONTENT="+e.Content)
}
if e.Error != "" {
env = append(env, "CC_HOOK_ERROR="+e.Error)
}
return env
}
// Hooks returns the current hook configurations (for management API / testing).
func (hm *HookManager) Hooks() []HookConfig {
if hm == nil {
return nil
}
hm.mu.RLock()
defer hm.mu.RUnlock()
out := make([]HookConfig, len(hm.hooks))
copy(out, hm.hooks)
return out
}