Files
codex-app-server/cloud/webhooks.py
T

355 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""外部集成 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 verify_github_signature_any(secrets, payload_bytes, signature_header):
"""多 secret 验签:任一 secret 对上即 True,全不对或无 secret
即 False。过渡期手动 webhook(env secret)与 App webhook
(库里 secret)共存时用;App 装好、手动 webhook 删掉后,
env secret 自然不再被命中,不用改代码。"""
if not secrets:
return False
for secret in secrets:
if secret and verify_github_signature(
secret, payload_bytes, signature_header):
return True
return False
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}