Compare commits
9
Commits
7ae9e5515e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12713257d0 | ||
|
|
632069216d | ||
|
|
717d2c70be | ||
|
|
ea7f57d984 | ||
|
|
2ac582ecfd | ||
|
|
ad585bf101 | ||
|
|
25c8904b96 | ||
|
|
ae9a44a95a | ||
|
|
f5f19fe7ac |
@@ -6,3 +6,8 @@ GOTIFY_TOKEN=your-gotify-token
|
|||||||
|
|
||||||
# 服务监听端口
|
# 服务监听端口
|
||||||
PORT=:8082
|
PORT=:8082
|
||||||
|
|
||||||
|
# MySQL 连接串(可选,留空则不记录 prompt)
|
||||||
|
# 格式: user:password@tcp(host:port)/dbname?parseTime=true
|
||||||
|
# 注意:如果密码包含特殊字符(如 @、括号等),需要用双引号包围整个 DSN
|
||||||
|
MYSQL_DSN="user:password@tcp(host:port)/dbname?parseTime=true"
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.22-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
# 使用国内镜像源加速 apk
|
# 使用国内镜像源加速 apk
|
||||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **实时通知**:当 Claude Code 需要关注时,立即收到手机推送
|
- **实时通知**:当 Claude Code 需要关注时,立即收到手机推送
|
||||||
- **多事件支持**:Notification(权限请求、等待输入等)和 Stop(任务完成)
|
- **多事件支持**:Notification(权限请求、等待输入等)、Stop(任务完成)、PreToolUse(提问通知)
|
||||||
- **轻量部署**:单个 Docker 容器,资源占用极低
|
- **轻量部署**:单个 Docker 容器,资源占用极低
|
||||||
- **灵活配置**:通过环境变量自定义 Gotify 服务器和 Token
|
- **灵活配置**:通过环境变量自定义 Gotify 服务器和 Token
|
||||||
|
|
||||||
@@ -64,14 +64,44 @@ docker-compose up -d
|
|||||||
"hooks": {
|
"hooks": {
|
||||||
"Notification": [
|
"Notification": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"matcher": "",
|
||||||
"command": "curl -s -X POST http://47.121.181.112:8082/hooks -H 'Content-Type: application/json' -d @-"
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://47.121.181.112:8082/hooks"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Stop": [
|
"Stop": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"hooks": [
|
||||||
"command": "curl -s -X POST http://47.121.181.112:8082/hooks -H 'Content-Type: application/json' -d @-"
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -96,6 +126,39 @@ docker-compose up -d
|
|||||||
| 正常完成 | 任务完成 | 5 | Claude 完成了本轮回复 |
|
| 正常完成 | 任务完成 | 5 | Claude 完成了本轮回复 |
|
||||||
| 循环检测 | 循环停止 | 8 | Stop hook 连续触发多次,已自动停止 |
|
| 循环检测 | 循环停止 | 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`),便于按项目筛选
|
||||||
|
|
||||||
## 配置说明
|
## 配置说明
|
||||||
|
|
||||||
| 环境变量 | 必填 | 说明 |
|
| 环境变量 | 必填 | 说明 |
|
||||||
@@ -103,6 +166,7 @@ docker-compose up -d
|
|||||||
| `GOTIFY_URL` | 是 | Gotify 服务器地址 |
|
| `GOTIFY_URL` | 是 | Gotify 服务器地址 |
|
||||||
| `GOTIFY_TOKEN` | 是 | Gotify 应用 Token |
|
| `GOTIFY_TOKEN` | 是 | Gotify 应用 Token |
|
||||||
| `PORT` | 否 | 服务监听端口(默认 `:8082`) |
|
| `PORT` | 否 | 服务监听端口(默认 `:8082`) |
|
||||||
|
| `MYSQL_DSN` | 否 | MySQL 连接串,留空则不记录 prompt(格式: `user:password@tcp(host:port)/dbname?parseTime=true`) |
|
||||||
|
|
||||||
## API 端点
|
## API 端点
|
||||||
|
|
||||||
@@ -164,6 +228,16 @@ curl -X POST http://47.121.181.112:8082/hooks \
|
|||||||
curl -X POST http://47.121.181.112:8082/hooks \
|
curl -X POST http://47.121.181.112:8082/hooks \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"session_id":"test","cwd":"/tmp","hook_event_name":"Stop"}'
|
-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 程序"}}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
@@ -173,6 +247,7 @@ cc-hook/
|
|||||||
├── .env.example # 环境变量示例
|
├── .env.example # 环境变量示例
|
||||||
├── .gitignore # Git 忽略规则
|
├── .gitignore # Git 忽略规则
|
||||||
├── config.go # 配置管理(环境变量读取)
|
├── config.go # 配置管理(环境变量读取)
|
||||||
|
├── db.go # MySQL 连接与 prompt 存储
|
||||||
├── gotify.go # Gotify HTTP 客户端
|
├── gotify.go # Gotify HTTP 客户端
|
||||||
├── handler.go # Hook 事件处理器
|
├── handler.go # Hook 事件处理器
|
||||||
├── main.go # HTTP 服务入口
|
├── main.go # HTTP 服务入口
|
||||||
@@ -183,10 +258,8 @@ cc-hook/
|
|||||||
|
|
||||||
## 扩展计划
|
## 扩展计划
|
||||||
|
|
||||||
- [ ] 数据库记录:将 Hook 事件写入数据库,便于复盘和分析
|
- [x] 数据库记录:将 UserPromptSubmit 的 prompt 写入 MySQL,便于复盘提示词质量
|
||||||
- [ ] 更多事件支持:Tool Use、Error 等事件类型
|
- [x] 更多事件支持:PreToolUse(AskUserQuestion 提问通知)
|
||||||
- [ ] 消息模板:自定义通知消息格式
|
|
||||||
- [ ] 多 Gotify 支持:同时推送到多个 Gotify 服务器
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Config struct {
|
|||||||
GotifyURL string
|
GotifyURL string
|
||||||
GotifyToken string
|
GotifyToken string
|
||||||
Port string
|
Port string
|
||||||
|
MySQLDSN string // MySQL 连接串,留空则不启用数据库记录
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() Config {
|
func LoadConfig() Config {
|
||||||
@@ -22,6 +23,7 @@ func LoadConfig() Config {
|
|||||||
GotifyURL: getEnv("GOTIFY_URL", ""),
|
GotifyURL: getEnv("GOTIFY_URL", ""),
|
||||||
GotifyToken: getEnv("GOTIFY_TOKEN", ""),
|
GotifyToken: getEnv("GOTIFY_TOKEN", ""),
|
||||||
Port: getEnv("PORT", ":8082"),
|
Port: getEnv("PORT", ":8082"),
|
||||||
|
MySQLDSN: getEnv("MYSQL_DSN", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
module cc-hook
|
module cc-hook
|
||||||
|
|
||||||
go 1.22.2
|
go 1.24.0
|
||||||
|
|
||||||
require github.com/joho/godotenv v1.5.1
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,2 +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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
|||||||
+54
-2
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClaudeCodeHookEvent represents the JSON payload from Claude Code hooks
|
// ClaudeCodeHookEvent represents the JSON payload from Claude Code hooks
|
||||||
@@ -16,14 +17,16 @@ type ClaudeCodeHookEvent struct {
|
|||||||
ToolInput map[string]interface{} `json:"tool_input,omitempty"`
|
ToolInput map[string]interface{} `json:"tool_input,omitempty"`
|
||||||
Matcher string `json:"matcher,omitempty"`
|
Matcher string `json:"matcher,omitempty"`
|
||||||
StopHookActive bool `json:"stop_hook_active,omitempty"`
|
StopHookActive bool `json:"stop_hook_active,omitempty"`
|
||||||
|
Prompt string `json:"prompt,omitempty"` // UserPromptSubmit 事件的 prompt 文本
|
||||||
}
|
}
|
||||||
|
|
||||||
type HookHandler struct {
|
type HookHandler struct {
|
||||||
config Config
|
config Config
|
||||||
|
db *PromptDB // 可能为 nil,未配置 MySQL 时跳过数据库写入
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHookHandler(config Config) *HookHandler {
|
func NewHookHandler(config Config, db *PromptDB) *HookHandler {
|
||||||
return &HookHandler{config: config}
|
return &HookHandler{config: config, db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HookHandler) HandleHook(w http.ResponseWriter, r *http.Request) {
|
func (h *HookHandler) HandleHook(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -49,6 +52,13 @@ func (h *HookHandler) HandleHook(w http.ResponseWriter, r *http.Request) {
|
|||||||
title, message, priority = h.handleNotification(event)
|
title, message, priority = h.handleNotification(event)
|
||||||
case "Stop":
|
case "Stop":
|
||||||
title, message, priority = h.handleStop(event)
|
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:
|
default:
|
||||||
log.Printf("ignored event: %s", event.HookEventName)
|
log.Printf("ignored event: %s", event.HookEventName)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -90,6 +100,24 @@ func (h *HookHandler) handleNotification(event ClaudeCodeHookEvent) (title, mess
|
|||||||
return title, message, priority
|
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) {
|
func (h *HookHandler) handleStop(event ClaudeCodeHookEvent) (title, message string, priority int) {
|
||||||
if event.StopHookActive {
|
if event.StopHookActive {
|
||||||
// Avoid infinite loop: Stop hook triggered too many times
|
// Avoid infinite loop: Stop hook triggered too many times
|
||||||
@@ -104,3 +132,27 @@ func (h *HookHandler) handleStop(event ClaudeCodeHookEvent) (title, message stri
|
|||||||
|
|
||||||
return title, message, priority
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,25 +1,71 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
config := LoadConfig()
|
config := LoadConfig()
|
||||||
|
|
||||||
handler := NewHookHandler(config)
|
// 可选初始化 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")
|
||||||
|
}
|
||||||
|
|
||||||
http.HandleFunc("/hooks", handler.HandleHook)
|
handler := NewHookHandler(config, db)
|
||||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/hooks", handler.HandleHook)
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte(`{"status":"ok"}`))
|
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("cc-hook service starting on %s", config.Port)
|
||||||
log.Printf("gotify endpoint: %s", config.GotifyURL)
|
log.Printf("gotify endpoint: %s", config.GotifyURL)
|
||||||
|
|
||||||
if err := http.ListenAndServe(config.Port, nil); err != nil {
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
log.Fatalf("server failed: %v", err)
|
log.Fatalf("server failed: %v", err)
|
||||||
}
|
}
|
||||||
|
log.Println("server stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user