Compare commits

..
12 Commits
Author SHA1 Message Date
wonder 12713257d0 fix: 修复 UserPromptSubmit 事件 prompt 为空的问题
UserPromptSubmit 不是工具事件,prompt 文本作为顶层 JSON 字段传递,
而非 tool_input.prompt。新增 Prompt 字段到结构体并从 event.Prompt 读取。
2026-06-26 00:15:04 +08:00
wonder 632069216d fix: 升级 Go 版本至 1.24,修复 .env 特殊字符转义 2026-06-26 00:05:32 +08:00
wonder 717d2c70be feat: 支持 UserPromptSubmit 事件,将 prompt 存入 MySQL
- 新增 db.go:MySQL 连接管理、自动建表、兼容旧表 ALTER
- handler.go:处理 UserPromptSubmit,从 cwd 提取项目名称
- main.go:可选初始化 MySQL,优雅关闭释放连接
- config.go:新增 MYSQL_DSN 配置项
- 表结构:session_id + project_name + prompt + created_at
2026-06-25 23:52:42 +08:00
wonder ea7f57d984 docs: 修正 README 中 Hook 配置格式,与实际配置保持一致 2026-06-23 21:27:55 +08:00
wonder 2ac582ecfd feat: 支持 PreToolUse 事件,AskUserQuestion 触发通知
- handler.go 添加 PreToolUse 事件处理,提取 AskUserQuestion 的 question 字段作为通知内容
- README.md 更新 hooks 配置示例、支持事件文档和测试命令
2026-06-21 22:46:20 +08:00
wonder ad585bf101 fix: deploy.sh 添加可执行权限 2026-06-21 22:19:26 +08:00
wonder 25c8904b96 revert: 恢复 git pull --rebase,部署机自行处理冲突 2026-06-21 22:17:23 +08:00
wonder ae9a44a95a fix: 部署机强制同步远程代码,避免本地修改阻塞 2026-06-21 22:12:45 +08:00
wonder f5f19fe7ac feat: 添加一键部署脚本 deploy.sh 2026-06-21 22:07:41 +08:00
wonder 7ae9e5515e chore: .gitignore 添加编译产物 cc-hook 2026-06-21 22:02:26 +08:00
wonder 075a8bf40b feat: 引入 godotenv 管理环境变量,优化 Dockerfile 构建
- 新增 .env.example 和 .gitignore
- config.go 使用 godotenv 加载 .env,移除硬编码凭据
- docker-compose.yml 改用 env_file 引用 .env
- Dockerfile 使用国内镜像源 + 缓存加速构建
- README.md Hook URL 改为服务器地址,配置说明更新
2026-06-21 22:01:38 +08:00
wonder 2566f9f4aa feat: cc-hook v1.0 - Claude Code 钩子服务
- Go HTTP 服务,监听 :8082
- 支持 Notification 和 Stop 事件
- 转发至 Gotify 推送通知
- Docker 多阶段构建
- 环境变量配置
- 详细中文 README 文档
2026-06-21 21:49:51 +08:00
13 changed files with 791 additions and 1 deletions
+13
View File
@@ -0,0 +1,13 @@
# Gotify 服务器地址
GOTIFY_URL=http://47.121.181.112:40266
# Gotify 应用 Token
GOTIFY_TOKEN=your-gotify-token
# 服务监听端口
PORT=:8082
# MySQL 连接串(可选,留空则不记录 prompt)
# 格式: user:password@tcp(host:port)/dbname?parseTime=true
# 注意:如果密码包含特殊字符(如 @、括号等),需要用双引号包围整个 DSN
MYSQL_DSN="user:password@tcp(host:port)/dbname?parseTime=true"
+2
View File
@@ -0,0 +1,2 @@
.env
cc-hook
+33
View File
@@ -0,0 +1,33 @@
FROM golang:1.24-alpine AS builder
# 使用国内镜像源加速 apk
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 设置 Go 模块代理和国内镜像
ENV GOPROXY=https://goproxy.cn,direct
ENV GO111MODULE=on
WORKDIR /app
# 先复制依赖文件,利用 Docker 缓存层
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# 复制源码并构建
COPY *.go ./
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o cc-hook .
# 运行阶段
FROM alpine:latest
# 运行阶段也使用国内镜像源
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk --no-cache add ca-certificates
COPY --from=builder /app/cc-hook /usr/local/bin/
EXPOSE 8082
CMD ["cc-hook"]
+264 -1
View File
@@ -1,3 +1,266 @@
# cc-hook # cc-hook
🤗 适用于 Claude Code 的钩子,包含任务完成提醒等功能 🤗 Claude Code 钩子服务,将 Claude Code 事件转发至 Gotify 推送通知。
## 功能特性
- **实时通知**:当 Claude Code 需要关注时,立即收到手机推送
- **多事件支持**:Notification(权限请求、等待输入等)、Stop(任务完成)、PreToolUse(提问通知)
- **轻量部署**:单个 Docker 容器,资源占用极低
- **灵活配置**:通过环境变量自定义 Gotify 服务器和 Token
## 架构概览
```mermaid
graph LR
A[Claude Code] -->|POST /hooks| B[cc-hook]
B -->|POST /message| C[Gotify]
C -->|推送通知| D[手机/客户端]
```
## 前置条件
- Docker 和 Docker Compose
- Claude Code 已安装
- Gotify 服务器(自建或公共)
## 快速开始
### 1. 克隆项目
```bash
git clone <your-repo-url> cc-hook
cd cc-hook
```
### 2. 配置环境变量
复制 `.env.example` 并填写实际配置:
```bash
cp .env.example .env
```
编辑 `.env`:
```env
GOTIFY_URL=http://your-gotify-server:port # Gotify 服务器地址
GOTIFY_TOKEN=your-token-here # Gotify 应用 Token
PORT=:8082 # 服务监听端口(默认 8082)
```
### 3. 启动服务
```bash
docker-compose up -d
```
### 4. 配置 Claude Code
将以下内容添加到 `~/.claude/settings.json`:
```json
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "http",
"url": "http://47.121.181.112:8082/hooks"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "http",
"url": "http://47.121.181.112:8082/hooks"
}
]
}
],
"PreToolUse": [
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "http",
"url": "http://47.121.181.112:8082/hooks"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "http",
"url": "http://47.121.181.112:8082/hooks"
}
]
}
]
}
}
```
## 支持的事件
### Notification 事件
| Matcher | 标题 | 优先级 | 说明 |
|---------|------|--------|------|
| `permission_prompt` | 需要权限 | 7 | Claude 需要你批准一个操作 |
| `idle_prompt` | 等待输入 | 5 | Claude 完成工作,等待下一步指令 |
| `auth_success` | 认证成功 | 3 | 身份验证完成 |
| 其他 | 通知 | 5 | 默认通知 |
### Stop 事件
| 条件 | 标题 | 优先级 | 说明 |
|------|------|--------|------|
| 正常完成 | 任务完成 | 5 | Claude 完成了本轮回复 |
| 循环检测 | 循环停止 | 8 | Stop hook 连续触发多次,已自动停止 |
### PreToolUse 事件
| 工具名称 | 标题 | 优先级 | 说明 |
|----------|------|--------|------|
| `AskUserQuestion` | 等待回答 | 6 | Claude 向你提问,等待回答 |
| 其他工具 | 工具调用 | 4 | Claude 正在使用工具 |
### UserPromptSubmit 事件
| 行为 | 说明 |
|------|------|
| 记录 Prompt | 将用户提交的 prompt 存入 MySQL 数据库,便于复盘提示词质量 |
> ⚠️ 需要配置 `MYSQL_DSN` 环境变量才会启用数据库记录,否则仅打印日志。
#### 数据库表结构
```sql
CREATE TABLE prompts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(128) NOT NULL, -- Claude Code 会话 ID
project_name VARCHAR(255) NOT NULL, -- 项目名称(从 cwd 自动提取)
prompt TEXT NOT NULL, -- 用户提交的 prompt
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_session_id (session_id),
INDEX idx_project_name (project_name),
INDEX idx_created_at (created_at)
);
```
- `session_id`:用于关联同一会话的多条 prompt
- `project_name`:从工作目录自动提取(如 `/home/user/my-app` → `my-app`),便于按项目筛选
## 配置说明
| 环境变量 | 必填 | 说明 |
|----------|------|------|
| `GOTIFY_URL` | 是 | Gotify 服务器地址 |
| `GOTIFY_TOKEN` | 是 | Gotify 应用 Token |
| `PORT` | 否 | 服务监听端口(默认 `:8082`) |
| `MYSQL_DSN` | 否 | MySQL 连接串,留空则不记录 prompt(格式: `user:password@tcp(host:port)/dbname?parseTime=true`) |
## API 端点
### POST /hooks
接收 Claude Code Hook 事件并转发至 Gotify。
**请求体示例:**
```json
{
"session_id": "abc123",
"cwd": "/home/user/project",
"hook_event_name": "Notification",
"matcher": "permission_prompt"
}
```
**响应:**
- 成功:`{"status":"ok"}`
- 忽略:`{"status":"ignored"}`
- 错误:HTTP 500
### GET /health
健康检查端点。
**响应:**
```json
{"status":"ok"}
```
## 本地开发
### 直接运行
```bash
# 安装依赖
go mod tidy
# 复制并编辑配置
cp .env.example .env
# 运行(自动加载 .env)
go run .
```
### 测试
```bash
# 健康检查
curl http://47.121.181.112:8082/health
# 模拟 Notification 事件
curl -X POST http://47.121.181.112:8082/hooks \
-H 'Content-Type: application/json' \
-d '{"session_id":"test","cwd":"/tmp","hook_event_name":"Notification","matcher":"permission_prompt"}'
# 模拟 Stop 事件
curl -X POST http://47.121.181.112:8082/hooks \
-H 'Content-Type: application/json' \
-d '{"session_id":"test","cwd":"/tmp","hook_event_name":"Stop"}'
# 模拟 AskUserQuestion 事件
curl -X POST http://47.121.181.112:8082/hooks \
-H 'Content-Type: application/json' \
-d '{"session_id":"test","cwd":"/tmp","hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"question":"你想使用哪种数据库?"}}'
# 模拟 UserPromptSubmit 事件(记录 prompt 到 MySQL)
curl -X POST http://47.121.181.112:8082/hooks \
-H 'Content-Type: application/json' \
-d '{"session_id":"test","cwd":"/tmp","hook_event_name":"UserPromptSubmit","tool_input":{"prompt":"帮我写一个 Hello World 程序"}}'
```
## 项目结构
```
cc-hook/
├── .env.example # 环境变量示例
├── .gitignore # Git 忽略规则
├── config.go # 配置管理(环境变量读取)
├── db.go # MySQL 连接与 prompt 存储
├── gotify.go # Gotify HTTP 客户端
├── handler.go # Hook 事件处理器
├── main.go # HTTP 服务入口
├── Dockerfile # 多阶段 Docker 构建
├── docker-compose.yml # Docker Compose 部署配置
└── go.mod # Go 模块定义
```
## 扩展计划
- [x] 数据库记录:将 UserPromptSubmit 的 prompt 写入 MySQL,便于复盘提示词质量
- [x] 更多事件支持:PreToolUse(AskUserQuestion 提问通知)
## License
MIT
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"os"
"github.com/joho/godotenv"
)
func init() {
// 加载 .env 文件(如果存在),环境变量优先
_ = godotenv.Load()
}
type Config struct {
GotifyURL string
GotifyToken string
Port string
MySQLDSN string // MySQL 连接串,留空则不启用数据库记录
}
func LoadConfig() Config {
return Config{
GotifyURL: getEnv("GOTIFY_URL", ""),
GotifyToken: getEnv("GOTIFY_TOKEN", ""),
Port: getEnv("PORT", ":8082"),
MySQLDSN: getEnv("MYSQL_DSN", ""),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"database/sql"
"fmt"
"log"
"time"
_ "github.com/go-sql-driver/mysql"
)
type PromptDB struct {
db *sql.DB
}
// NewPromptDB 创建 MySQL 连接并自动建表
// dsn 格式: user:password@tcp(host:port)/dbname?parseTime=true
func NewPromptDB(dsn string) (*PromptDB, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open mysql: %w", err)
}
// 连接池配置
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
// 等待连接就绪
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping mysql: %w", err)
}
// 自动建表
if err := migrate(db); err != nil {
return nil, fmt.Errorf("failed to migrate: %w", err)
}
log.Println("mysql connected and migrated")
return &PromptDB{db: db}, nil
}
func migrate(db *sql.DB) error {
query := `
CREATE TABLE IF NOT EXISTS prompts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(128) NOT NULL,
project_name VARCHAR(255) NOT NULL DEFAULT '',
prompt TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_session_id (session_id),
INDEX idx_project_name (project_name),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;`
_, err := db.Exec(query)
if err != nil {
return err
}
// 兼容旧表:如果已存在但缺少 project_name 列,则补充
_, err = db.Exec("ALTER TABLE prompts ADD COLUMN project_name VARCHAR(255) NOT NULL DEFAULT '' AFTER session_id")
if err != nil {
// 忽略 "Duplicate column" 错误,说明列已存在
log.Printf("alter table (expected if column exists): %v", err)
}
return nil
}
// SavePrompt 将一条 prompt 写入数据库
func (p *PromptDB) SavePrompt(sessionID, projectName, prompt string) error {
_, err := p.db.Exec(
"INSERT INTO prompts (session_id, project_name, prompt) VALUES (?, ?, ?)",
sessionID, projectName, prompt,
)
return err
}
// Close 关闭数据库连接
func (p *PromptDB) Close() error {
if p.db != nil {
return p.db.Close()
}
return nil
}
Executable
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
set -e
# cc-hook 一键部署脚本
# 用法: ./deploy.sh
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
info() { echo -e "${GREEN}[✓]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
error() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
# ========== 1. 更新代码 ==========
echo ""
info "拉取最新代码..."
git pull --rebase || error "git pull 失败,请检查网络或冲突"
# ========== 2. 环境检查 ==========
echo ""
info "检查运行环境..."
# 检查 Docker
command -v docker &>/dev/null || error "未安装 Docker,请先安装: https://docs.docker.com/get-docker/"
docker info &>/dev/null || error "Docker 服务未启动,请执行: sudo systemctl start docker"
# 检查 Docker Compose (v2 plugin 或独立二进制)
if docker compose version &>/dev/null; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose &>/dev/null; then
COMPOSE_CMD="docker-compose"
else
error "未安装 Docker Compose,请先安装"
fi
info "Docker Compose: $($COMPOSE_CMD version 2>/dev/null | head -1)"
# 检查 .env 文件
if [ ! -f .env ]; then
if [ -f .env.example ]; then
warn ".env 文件不存在,已从 .env.example 复制,请编辑后重新运行"
cp .env.example .env
exit 1
else
error ".env 文件不存在,且无 .env.example 可参考"
fi
fi
# 校验必要的环境变量
source .env
[ -z "$GOTIFY_URL" ] && error ".env 中缺少 GOTIFY_URL"
[ -z "$GOTIFY_TOKEN" ] && error ".env 中缺少 GOTIFY_TOKEN"
info "环境检查通过"
# ========== 3. 构建部署 ==========
echo ""
info "构建并部署容器..."
$COMPOSE_CMD up -d --build
# ========== 4. 部署验证 ==========
echo ""
info "等待服务启动..."
sleep 3
PORT=${PORT#:} # 去掉前缀的冒号
if curl -sf "http://localhost:${PORT:-8082}/health" >/dev/null 2>&1; then
info "部署完成!服务运行正常 ✓"
echo ""
echo " 健康检查: http://localhost:${PORT:-8082}/health"
echo " 查看日志: $COMPOSE_CMD logs -f cc-hook"
echo " 停止服务: $COMPOSE_CMD down"
else
warn "服务可能未就绪,请检查日志: $COMPOSE_CMD logs cc-hook"
fi
+9
View File
@@ -0,0 +1,9 @@
services:
cc-hook:
build: .
container_name: cc-hook
ports:
- "8082:8082"
env_file:
- .env
restart: unless-stopped
+10
View File
@@ -0,0 +1,10 @@
module cc-hook
go 1.24.0
require github.com/joho/godotenv v1.5.1
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/go-sql-driver/mysql v1.10.0 // indirect
)
+6
View File
@@ -0,0 +1,6 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"fmt"
"net/http"
"net/url"
"strings"
)
func SendMessage(gotifyURL, token, title, message string, priority int) error {
endpoint := fmt.Sprintf("%s/message?token=%s", gotifyURL, token)
form := url.Values{}
form.Set("title", title)
form.Set("message", message)
form.Set("priority", fmt.Sprintf("%d", priority))
resp, err := http.Post(endpoint, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("failed to send gotify message: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("gotify returned status %d", resp.StatusCode)
}
return nil
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
)
// ClaudeCodeHookEvent represents the JSON payload from Claude Code hooks
type ClaudeCodeHookEvent struct {
SessionID string `json:"session_id"`
CWD string `json:"cwd"`
HookEventName string `json:"hook_event_name"`
ToolName string `json:"tool_name,omitempty"`
ToolInput map[string]interface{} `json:"tool_input,omitempty"`
Matcher string `json:"matcher,omitempty"`
StopHookActive bool `json:"stop_hook_active,omitempty"`
Prompt string `json:"prompt,omitempty"` // UserPromptSubmit 事件的 prompt 文本
}
type HookHandler struct {
config Config
db *PromptDB // 可能为 nil,未配置 MySQL 时跳过数据库写入
}
func NewHookHandler(config Config, db *PromptDB) *HookHandler {
return &HookHandler{config: config, db: db}
}
func (h *HookHandler) HandleHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var event ClaudeCodeHookEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
log.Printf("failed to decode hook event: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
log.Printf("received hook: event=%s, matcher=%s", event.HookEventName, event.Matcher)
var title, message string
var priority int
switch event.HookEventName {
case "Notification":
title, message, priority = h.handleNotification(event)
case "Stop":
title, message, priority = h.handleStop(event)
case "PreToolUse":
title, message, priority = h.handlePreToolUse(event)
case "UserPromptSubmit":
h.handleUserPromptSubmit(event)
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"status":"ok"}`)
return
default:
log.Printf("ignored event: %s", event.HookEventName)
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"status":"ignored"}`)
return
}
if err := SendMessage(h.config.GotifyURL, h.config.GotifyToken, title, message, priority); err != nil {
log.Printf("failed to send gotify message: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
log.Printf("notification sent: %s", title)
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"status":"ok"}`)
}
func (h *HookHandler) handleNotification(event ClaudeCodeHookEvent) (title, message string, priority int) {
switch event.Matcher {
case "permission_prompt":
title = "Claude Code - 需要权限"
message = "Claude 需要你批准一个操作"
priority = 7
case "idle_prompt":
title = "Claude Code - 等待输入"
message = "Claude 完成工作,等待你的下一步指令"
priority = 5
case "auth_success":
title = "Claude Code - 认证成功"
message = "身份验证完成"
priority = 3
default:
title = "Claude Code - 通知"
message = "Claude Code 需要你的关注"
priority = 5
}
return title, message, priority
}
func (h *HookHandler) handlePreToolUse(event ClaudeCodeHookEvent) (title, message string, priority int) {
switch event.ToolName {
case "AskUserQuestion":
title = "Claude Code - 等待回答"
if question, ok := event.ToolInput["question"].(string); ok && question != "" {
message = "Claude 向你提问: " + question
} else {
message = "Claude 向你提出了一个问题,等待你的回答"
}
priority = 6
default:
title = "Claude Code - 工具调用"
message = fmt.Sprintf("Claude 正在使用 %s", event.ToolName)
priority = 4
}
return title, message, priority
}
func (h *HookHandler) handleStop(event ClaudeCodeHookEvent) (title, message string, priority int) {
if event.StopHookActive {
// Avoid infinite loop: Stop hook triggered too many times
title = "Claude Code - 循环停止"
message = "Stop hook 连续触发多次,已自动停止"
priority = 8
} else {
title = "Claude Code - 任务完成"
message = "Claude 完成了本轮回复"
priority = 5
}
return title, message, priority
}
// handleUserPromptSubmit 将用户提交的 prompt 写入数据库
func (h *HookHandler) handleUserPromptSubmit(event ClaudeCodeHookEvent) {
if h.db == nil {
log.Printf("mysql not configured, skip saving prompt for session %s", event.SessionID)
return
}
prompt := event.Prompt
if prompt == "" {
log.Printf("empty prompt in UserPromptSubmit, session=%s", event.SessionID)
return
}
// 从 cwd 提取项目名称(最后一级目录)
projectName := filepath.Base(event.CWD)
if err := h.db.SavePrompt(event.SessionID, projectName, prompt); err != nil {
log.Printf("failed to save prompt: %v", err)
return
}
log.Printf("prompt saved: session=%s, project=%s, len=%d", event.SessionID, projectName, len(prompt))
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
config := LoadConfig()
// 可选初始化 MySQL
var db *PromptDB
if config.MySQLDSN != "" {
var err error
db, err = NewPromptDB(config.MySQLDSN)
if err != nil {
log.Fatalf("failed to init mysql: %v", err)
}
log.Println("mysql prompt recording enabled")
} else {
log.Println("mysql not configured, prompt recording disabled")
}
handler := NewHookHandler(config, db)
mux := http.NewServeMux()
mux.HandleFunc("/hooks", handler.HandleHook)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
srv := &http.Server{
Addr: config.Port,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
// 优雅关闭
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("server shutdown error: %v", err)
}
if db != nil {
db.Close()
}
}()
log.Printf("cc-hook service starting on %s", config.Port)
log.Printf("gotify endpoint: %s", config.GotifyURL)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server failed: %v", err)
}
log.Println("server stopped")
}