"""外部集成 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 __all__ = [ "verify_github_signature", "parse_github_pr_comment", "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="``,用 ``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 verify_slack_signature(signing_secret, timestamp, body, signature_header): """校验 Slack 请求签名(防重放)。 Slack 规则:basestring = ``f"v0:{timestamp}:{body}"``,hmac sha256, header 形如 ``"v0="``。时间戳与当前时间差超过 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 = sqlite3.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}