GitHub webhook: delivery-id dedup persisted in webhook_deliveries, /fix-review creates a queued task with audit (no model run in the webhook itself), check_run/check_suite parsing without mutating task state (CI backfill still via pr-checks). Control plane: Postgres pg_dump backup branch + POST /admin/backup (maintainer+, password only via PGPASSWORD env), per-key/IP rate limit (default 120/min, 429), health returns backend + db_ok, metrics by_status always has all seven status keys. Worker: per-task timeout (default 1800s, --task-timeout), timeout is failed not interrupted, limited retries (default 1, backoff, fallback failures not re-retried), stale codex- container cleanup on loop/CLI start. Tests: cloud 271 (267 pass, 4 skipped are pre-existing real- Postgres tests), host 27 pass.
341 lines
12 KiB
Python
341 lines
12 KiB
Python
"""外部集成 webhook 与推送通知模块(云端架构实施)。
|
||
|
||
零第三方依赖,只用 Python 标准库。
|
||
|
||
本模块只做两件事:
|
||
|
||
1. 入站 webhook 的签名校验与事件解析(GitHub、Slack)。
|
||
2. 出站状态通知的占位分发(webhook + Slack)。
|
||
|
||
边界(不要超出本模块宣称的范围):
|
||
|
||
- Web Push / APNs / FCM 明确是后续需要证书的一步,本版只有
|
||
webhook + Slack 占位,不要编造已接通手机推送。
|
||
- 密钥走环境变量是调用方的事:本模块的校验函数只接受参数,
|
||
不自己读生产密钥,也不把任何密钥写入文件。
|
||
- 本模块不联网、不调用真实 GitHub/Slack、不跑真实模型回合。
|
||
"""
|
||
|
||
import hashlib
|
||
import hmac
|
||
import sqlite3
|
||
import time
|
||
|
||
from cloud.db import connect as _db_connect
|
||
|
||
__all__ = [
|
||
"verify_github_signature",
|
||
"parse_github_pr_comment",
|
||
"parse_github_check_run",
|
||
"WebhookDeliveryStore",
|
||
"verify_slack_signature",
|
||
"parse_slack_command",
|
||
"SlackUserMap",
|
||
"ALLOWED_NOTIFICATION_EVENTS",
|
||
"NotificationService",
|
||
]
|
||
|
||
|
||
def verify_github_signature(secret, payload_bytes, signature_header):
|
||
"""校验 GitHub webhook 签名。
|
||
|
||
signature_header 形如 ``"sha256=<hex>"``,用
|
||
``hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest()``
|
||
比对,比较用 ``hmac.compare_digest``。缺 secret 或缺 header 返回 False。
|
||
"""
|
||
if not secret or not signature_header:
|
||
return False
|
||
if isinstance(payload_bytes, str):
|
||
payload_bytes = payload_bytes.encode("utf-8")
|
||
if not isinstance(payload_bytes, (bytes, bytearray)):
|
||
return False
|
||
if not isinstance(signature_header, str):
|
||
return False
|
||
try:
|
||
expected = "sha256=" + hmac.new(
|
||
secret.encode("utf-8"), bytes(payload_bytes), hashlib.sha256
|
||
).hexdigest()
|
||
except (AttributeError, TypeError, ValueError):
|
||
return False
|
||
return hmac.compare_digest(expected, signature_header)
|
||
|
||
|
||
def parse_github_pr_comment(payload):
|
||
"""解析 GitHub issue_comment 事件,返回 fix-review 触发点或 None。
|
||
|
||
这是 PR 评论 → fix-review 的触发点,真正执行 fix-review 由控制面调
|
||
host.py 完成,本函数只解析不执行。
|
||
|
||
只有当 action == "created" 且 comment body 里含 "/fix-review" 时返回::
|
||
|
||
{"type": "fix_review", "repo": ..., "pr_number": ...,
|
||
"comment": ..., "author": ...}
|
||
|
||
其中 repo 取 payload repository full_name,pr_number 取 issue number,
|
||
comment 取 body,author 取 comment user login。其他情况返回 None。
|
||
"""
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
if payload.get("action") != "created":
|
||
return None
|
||
comment = payload.get("comment")
|
||
if not isinstance(comment, dict):
|
||
return None
|
||
body = comment.get("body")
|
||
if not isinstance(body, str) or "/fix-review" not in body:
|
||
return None
|
||
repository = payload.get("repository") or {}
|
||
issue = payload.get("issue") or {}
|
||
user = comment.get("user") or {}
|
||
return {
|
||
"type": "fix_review",
|
||
"repo": repository.get("full_name"),
|
||
"pr_number": issue.get("number"),
|
||
"comment": body,
|
||
"author": user.get("login"),
|
||
}
|
||
|
||
|
||
def parse_github_check_run(payload):
|
||
"""解析 GitHub check_run / check_suite 事件,返回结构化结果或 None。
|
||
|
||
只在结论已定的时候返回:check_run / check_suite 的 conclusion
|
||
为 "success" 或 "failure" 时返回::
|
||
|
||
{"type": "check_run", "repo": ..., "head_sha": ...,
|
||
"conclusion": ..., "check_name": ...}
|
||
|
||
其中 repo 取 repository full_name,head_sha 取 check_run /
|
||
check_suite 的 head_sha,check_name 取 check_run 的 name
|
||
(check_suite 没有单项名时为 None)。结论为 None / 进行中
|
||
(如 queued / in_progress / neutral / cancelled 等)时返回 None,
|
||
调用方不应据此建任务或改任务状态——CI 状态回填由 pr-checks
|
||
主动拉取,webhook 只做通知解析。
|
||
"""
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
for key in ("check_run", "check_suite"):
|
||
run = payload.get(key)
|
||
if not isinstance(run, dict):
|
||
continue
|
||
conclusion = run.get("conclusion")
|
||
if conclusion not in ("success", "failure"):
|
||
return None
|
||
repository = payload.get("repository") or {}
|
||
return {
|
||
"type": "check_run",
|
||
"repo": repository.get("full_name"),
|
||
"head_sha": run.get("head_sha"),
|
||
"conclusion": conclusion,
|
||
"check_name": run.get("name"),
|
||
}
|
||
return None
|
||
|
||
|
||
class WebhookDeliveryStore:
|
||
"""GitHub webhook delivery 去重(持久化,不只放内存)。
|
||
|
||
表 webhook_deliveries:delivery_id(主键,X-GitHub-Delivery 头)、
|
||
event、repo、created_at(epoch 秒)。claim() 第一次返回 True,
|
||
同一 delivery_id 第二次返回 False,调用方应直接回 200 且标记
|
||
duplicate,不重复建任务、不重复记审计。INSERT 冲突时先按
|
||
IntegrityError 处理,再回查一次确认(PG 适配层抛的完整性错误
|
||
类型与 SQLite 不同,这里按“插入失败后回查存在即重复”处理,
|
||
不依赖具体异常类名)。
|
||
"""
|
||
|
||
def __init__(self, db_path):
|
||
self.db_path = db_path
|
||
self._conn = _db_connect(db_path)
|
||
self._conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS webhook_deliveries("
|
||
"delivery_id TEXT PRIMARY KEY, "
|
||
"event TEXT, "
|
||
"repo TEXT, "
|
||
"created_at REAL)"
|
||
)
|
||
self._conn.commit()
|
||
|
||
def claim(self, delivery_id, event=None, repo=None):
|
||
"""认领一次 delivery:首次 True,重复 False。空 id 不认领。"""
|
||
if not delivery_id:
|
||
return True
|
||
row = self._conn.execute(
|
||
"SELECT delivery_id FROM webhook_deliveries WHERE delivery_id = ?",
|
||
(delivery_id,),
|
||
).fetchone()
|
||
if row is not None:
|
||
return False
|
||
try:
|
||
self._conn.execute(
|
||
"INSERT INTO webhook_deliveries"
|
||
"(delivery_id, event, repo, created_at)"
|
||
" VALUES(?, ?, ?, ?)",
|
||
(delivery_id, event, repo, time.time()),
|
||
)
|
||
self._conn.commit()
|
||
except Exception:
|
||
try:
|
||
self._conn.rollback()
|
||
except Exception:
|
||
pass
|
||
row = self._conn.execute(
|
||
"SELECT delivery_id FROM webhook_deliveries"
|
||
" WHERE delivery_id = ?",
|
||
(delivery_id,),
|
||
).fetchone()
|
||
if row is not None:
|
||
return False
|
||
raise
|
||
return True
|
||
|
||
def close(self):
|
||
"""关闭数据库连接(可选,单测/退出时用)。"""
|
||
self._conn.close()
|
||
|
||
|
||
def verify_slack_signature(signing_secret, timestamp, body, signature_header):
|
||
"""校验 Slack 请求签名(防重放)。
|
||
|
||
Slack 规则:basestring = ``f"v0:{timestamp}:{body}"``,hmac sha256,
|
||
header 形如 ``"v0=<hex>"``。时间戳与当前时间差超过 300 秒返回 False
|
||
(防重放);缺参返回 False。
|
||
"""
|
||
if not signing_secret or timestamp is None or body is None:
|
||
return False
|
||
if not signature_header:
|
||
return False
|
||
try:
|
||
ts_int = int(timestamp)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
if abs(time.time() - ts_int) > 300:
|
||
return False
|
||
try:
|
||
if isinstance(body, (bytes, bytearray)):
|
||
basestring = b"v0:" + str(ts_int).encode("utf-8") + b":" + bytes(body)
|
||
expected = "v0=" + hmac.new(
|
||
signing_secret.encode("utf-8"), basestring, hashlib.sha256
|
||
).hexdigest()
|
||
else:
|
||
basestring = f"v0:{timestamp}:{body}"
|
||
expected = "v0=" + hmac.new(
|
||
signing_secret.encode("utf-8"),
|
||
basestring.encode("utf-8"),
|
||
hashlib.sha256,
|
||
).hexdigest()
|
||
except (AttributeError, TypeError, ValueError):
|
||
return False
|
||
if not isinstance(signature_header, str):
|
||
return False
|
||
return hmac.compare_digest(expected, signature_header)
|
||
|
||
|
||
def parse_slack_command(text):
|
||
"""解析 Slack 斜杠命令。
|
||
|
||
- ``"/codex 做一个加法函数"`` ->
|
||
``{"command": "codex", "prompt": "做一个加法函数"}``
|
||
- ``"/codex status <任务名>"`` ->
|
||
``{"command": "status", "task": "<任务名>"}``
|
||
- 空或非 ``/codex`` 开头 -> ``{"command": None, "prompt": text}``
|
||
"""
|
||
if not text or not isinstance(text, str):
|
||
return {"command": None, "prompt": text}
|
||
stripped = text.strip()
|
||
if stripped == "/codex" or stripped.startswith("/codex "):
|
||
rest = stripped[len("/codex"):].strip()
|
||
if rest == "status" or rest.startswith("status "):
|
||
task = rest[len("status"):].strip()
|
||
return {"command": "status", "task": task}
|
||
return {"command": "codex", "prompt": rest}
|
||
return {"command": None, "prompt": text}
|
||
|
||
|
||
class SlackUserMap:
|
||
"""Slack 用户 ID 到内部 user_id 的映射(SQLite)。"""
|
||
|
||
def __init__(self, db_path):
|
||
self.db_path = db_path
|
||
self._conn = _db_connect(db_path)
|
||
self._conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS slack_users ("
|
||
"slack_id TEXT PRIMARY KEY, "
|
||
"user_id TEXT, "
|
||
"created_at REAL)"
|
||
)
|
||
self._conn.commit()
|
||
|
||
def map(self, slack_id, user_id):
|
||
"""把 slack_id 映射到 user_id(已存在则覆盖)。"""
|
||
self._conn.execute(
|
||
"INSERT OR REPLACE INTO slack_users "
|
||
"(slack_id, user_id, created_at) VALUES (?, ?, ?)",
|
||
(slack_id, user_id, time.time()),
|
||
)
|
||
self._conn.commit()
|
||
|
||
def lookup(self, slack_id):
|
||
"""返回 slack_id 对应的 user_id,未映射返回 None。"""
|
||
row = self._conn.execute(
|
||
"SELECT user_id FROM slack_users WHERE slack_id = ?",
|
||
(slack_id,),
|
||
).fetchone()
|
||
return row[0] if row else None
|
||
|
||
def close(self):
|
||
"""关闭数据库连接(可选,单测/退出时用)。"""
|
||
self._conn.close()
|
||
|
||
|
||
# 只发状态变化的事件集合:进行中的状态(如 running)不触发通知。
|
||
ALLOWED_NOTIFICATION_EVENTS = {
|
||
"completed",
|
||
"failed",
|
||
"goal-unmet",
|
||
"merge-failed",
|
||
"review-requested",
|
||
}
|
||
|
||
|
||
class NotificationService:
|
||
"""状态通知分发(webhook + Slack 占位)。
|
||
|
||
Web Push / APNs / FCM 明确是后续需要证书的一步,本版只有 webhook +
|
||
Slack 占位,不要编造已接通手机推送。
|
||
|
||
sender 都是可调用对象,单测可注入假 sender,默认 None 时不发、
|
||
不报错。sender 被调用时收到一个 dict payload::
|
||
|
||
{"event_type": ..., "task_id": ..., "project_id": ..., "detail": ...}
|
||
"""
|
||
|
||
def __init__(self, webhook_sender=None, slack_sender=None):
|
||
self.webhook_sender = webhook_sender
|
||
self.slack_sender = slack_sender
|
||
|
||
def notify(self, event_type, task_id, project_id, detail=""):
|
||
"""发送一条状态通知。
|
||
|
||
event_type 不在 ALLOWED_NOTIFICATION_EVENTS 里时返回
|
||
``{"sent": False, "reason": "event not allowed"}`` 且不调用 sender;
|
||
否则依次调 webhook_sender 和 slack_sender(不为 None 的才调),
|
||
返回 ``{"sent": True, "channels": [...已调的通道名...]}``。
|
||
"""
|
||
if event_type not in ALLOWED_NOTIFICATION_EVENTS:
|
||
return {"sent": False, "reason": "event not allowed"}
|
||
payload = {
|
||
"event_type": event_type,
|
||
"task_id": task_id,
|
||
"project_id": project_id,
|
||
"detail": detail,
|
||
}
|
||
channels = []
|
||
if self.webhook_sender is not None:
|
||
self.webhook_sender(payload)
|
||
channels.append("webhook")
|
||
if self.slack_sender is not None:
|
||
self.slack_sender(payload)
|
||
channels.append("slack")
|
||
return {"sent": True, "channels": channels}
|