初始化仓库
This commit is contained in:
+274
@@ -0,0 +1,274 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user