推送真发送:webhook/slack 内置真实发送端 + worker 终态自动通知

此前订阅与分发框架都在,但分发端点传 sender=None,
webhook/slack 永远报无 sender、从未真发过。本轮:
- push:send_webhook/send_slack(标准库 urllib POST,
  单次 10 秒超时,不重试,失败记 errors);sender=None
  时用内置端,显式 sender 优先;webpush/apns/fcm 仍报
  缺凭证(原因写明:webpush 需浏览器端三字段订阅与标准库
  没有的 P-256 ECDH + AES-GCM;apns 需 HTTP/2;fcm 需
  Firebase 服务账号,三者均无现成接收方,不造假发送)
- worker:run_one 终态(completed/failed,缺 key 与仓库
  失败两条早退路径也在内)后 best-effort 调分发,通知
  失败只记日志、绝不影响任务状态;取消(interrupted)
  不在事件集合里,不通知
- 测试:真实发送端 6 个(本地回环服务器)、worker 终态
  通知 2 个,旧无 sender 断言按新语义改写
This commit is contained in:
host committed 2026-10-26 17:31:30 +08:00
1 parent b7dace55b9
commit fb3a311da8
7 files changed
+429 -29

No files matched your search

+121 -11
View File
@@ -17,21 +17,36 @@
``digest`` 偏好下本次不立即发,返回结果里明确列入
``skipped_for_digest``(待摘要列表),不是静默丢弃。
- 发送端可注入:``webhook`` / ``slack`` 走可注入的 ``sender``。
``sender`` 为 ``None`` 时对这两个通道也返回“无 sender”错误,
不静默成功。``sender`` 可以是可调用对象(收到一个 dict,见
``sender`` 为 ``None`` 时用内置真实发送端(见 :func:`send_webhook` /
:func:`send_slack`,标准库 urllib POST,单次 10 秒超时,不
重试,失败记入 errors);显式传入的 sender 优先于内置端。
``sender`` 可以是可调用对象(收到一个 dict,见
:func:`dispatch_event`),也可以是按通道分的 dict
(``{"webhook": callable, "slack": callable}``),或带 ``send``
方法的对象。
方法的对象。显式 sender 里没有某通道时,该通道仍报
“无 sender”,不静默成功。
- ``webpush`` / ``apns`` / ``fcm`` 只有接口,没有真实发送端:
一律明确报错“缺凭证(VAPID / APNs 证书 / FCM key)”,不编造
真实推送、不生成或提交任何私钥到仓库、不向真实推送服务发
任何请求。真实凭证与真实发送端是后续待接线的工作,见模块
末尾的缺口说明。
- 本模块不联网、不调用真实推送服务、不跑真实模型回合。
密钥、token、证书一律不由本模块读取、写入或返回。
任何请求。原因不是“没空写”:webpush 需要浏览器端订阅
(endpoint + p256dh + auth 三字段,现有订阅模型只有一个
target 文本字段,装不下)与标准库没有的 P-256 ECDH +
AES-GCM;apns 需要标准库没有的 HTTP/2;fcm 需要用户
另建 Firebase 项目与服务账号——三者都没有现成接收方,
本模块不为此造一套半吊子加密或假发送。真实凭证与真实
发送端是后续待接线的工作,见模块末尾的缺口说明。
- 本模块不跑真实模型回合。webhook/slack 的真实发送只 POST
到订阅者自己填的 target URL(创建订阅已要求项目
create_task 权限;能建订阅的成员本来就能经任务执行代码,
因此不对 target 做内网屏蔽——屏蔽既拦不住提权,也会误伤
本地接收器)。密钥、token、证书一律不由本模块读取、写入
或返回。
"""
import json
import sqlite3
import urllib.error
import urllib.request
from cloud.db import connect as _db_connect
from cloud.db import PgConnection as _PgConnection
import time
@@ -44,10 +59,13 @@ __all__ = [
"ALLOWED_PUSH_EVENTS",
"FAILURE_EVENTS",
"MISSING_CREDENTIAL_REASONS",
"DEFAULT_SENDERS",
"init_push_db",
"create_subscription",
"delete_subscription",
"list_subscriptions",
"send_webhook",
"send_slack",
"dispatch_event",
]
@@ -254,6 +272,91 @@ def _resolve_channel_sender(sender, channel):
return send if callable(send) else None
# 真实发送端:单次投递超时(秒),不重试——失败记入 errors,
# 下一次事件自然重发,不在这里循环撞墙。
_PUSH_SEND_TIMEOUT = 10
def _post_json(url, payload):
"""POST JSON 到 url:返回 (ok, detail),2xx 为 ok。
HTTP 状态错误与网络异常一律返回 (False, reason),不抛
(调用方据此记 errors);只有调用方传了非法 URL 时
Request 构造本身可能抛 ValueError,由发送端先校验 scheme
挡住,不走到这里。
"""
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
url, data=data, method="POST",
headers={"Content-Type": "application/json",
"User-Agent": "codex-app-server"})
try:
with urllib.request.urlopen(
request, timeout=_PUSH_SEND_TIMEOUT) as response:
response.read()
return True, "HTTP %s" % response.status
except urllib.error.HTTPError as e:
return False, "HTTP %s" % e.code
except Exception as exc: # noqa: BLE001 -- 网络异常转 reason,不抛
return False, "%s: %s" % (type(exc).__name__, exc)
def _require_http_url(target, channel):
"""发送端 target 校验:只接受 http(s) URL,其他一律 ValueError
(dispatch 记入 errors,不静默成功、不乱 POST)。"""
if not isinstance(target, str) or not (
target.startswith("http://") or target.startswith("https://")):
raise ValueError(
"%s target must be an http(s) URL" % channel)
return target
def send_webhook(message):
"""webhook 通道真实发送:POST JSON 到订阅的 target URL。
body 固定为 {event_type, project_id, task_id, payload},
task_id 从 payload 里取(没有则为 None)。非 2xx 抛
RuntimeError(dispatch 记入 errors,不算 sent)。
"""
target = _require_http_url(message["target"], "webhook")
payload = message["payload"] if isinstance(
message["payload"], dict) else {}
ok, detail = _post_json(target, {
"event_type": message["event_type"],
"project_id": message["project_id"],
"task_id": payload.get("task_id"),
"payload": message["payload"],
})
if not ok:
raise RuntimeError("webhook delivery failed: %s" % detail)
def send_slack(message):
"""slack 通道真实发送:POST Slack incoming webhook 格式
({"text": ...})到 target URL(Slack webhook 地址本身
就是 http(s) URL,与 webhook 通道同一投递路径)。
文本一行讲清事件:任务 id + 事件类型 + 项目,失败类
事件附 error 摘要(截断 500 字)。非 2xx 抛 RuntimeError。
"""
target = _require_http_url(message["target"], "slack")
payload = message["payload"] if isinstance(
message["payload"], dict) else {}
text = "[codex] task %s %s (project %s)" % (
payload.get("task_id", "?"), message["event_type"],
message["project_id"])
if payload.get("error"):
text += "\nerror: %s" % str(payload["error"])[:500]
ok, detail = _post_json(target, {"text": text})
if not ok:
raise RuntimeError("slack delivery failed: %s" % detail)
# sender=None 时的内置真实发送端:只覆盖 webhook/slack;
# webpush/apns/fcm 不在此列,仍走缺凭证报错。
DEFAULT_SENDERS = {"webhook": send_webhook, "slack": send_slack}
def dispatch_event(db, event_type, project_id, payload, sender=None):
"""分发一次状态变化事件,只发本项目的订阅。
@@ -265,9 +368,10 @@ def dispatch_event(db, event_type, project_id, payload, sender=None):
project_id: 只查该项目的订阅,跨项目不混。
payload: 原事件载荷(dict 或其他),原样放入发送给 sender
的消息 dict 的 ``payload`` 字段。
sender: 可注入发送端(见模块 docstring)。webhook/slack
通道在 sender 为 None 时返回“无 sender”错误,不静默成功;
webpush/apns/fcm 通道不走 sender,一律返回缺凭证错误。
sender: 可注入发送端(见模块 docstring);为 None 时用
内置真实发送端(webhook/slack 真 POST,webpush/apns/fcm
仍报缺凭证);显式传入的 sender 优先于内置端,显式
sender 里没有某通道时该通道报“无 sender”,不静默成功。
返回 dict,键固定为::
@@ -314,6 +418,11 @@ def dispatch_event(db, event_type, project_id, payload, sender=None):
}]
return result
if sender is None:
# 内置真实发送端:webhook/slack 真 POST;显式 sender
# 传入时不走这里(注入优先),webpush/apns/fcm 永远
# 不走 sender,仍在循环里报缺凭证。
sender = DEFAULT_SENDERS
subscriptions = list_subscriptions(db, project_id)
is_failure = event_type in FAILURE_EVENTS
for sub in subscriptions:
@@ -336,7 +445,8 @@ def dispatch_event(db, event_type, project_id, payload, sender=None):
result["errors"].append(
dict(base, reason=MISSING_CREDENTIAL_REASONS[sub["channel"]]))
continue
# webhook/slack:必须有可注入 sender,否则明确报错。
# webhook/slack:sender=None 时已换成内置真实发送端;
# 显式 sender 里没有该通道时明确报错,不静默成功。
channel_sender = _resolve_channel_sender(sender, sub["channel"])
if channel_sender is None:
result["errors"].append(