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

362 lines
14 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.
"""推送订阅与分发模块(云端架构实施)。
零第三方依赖,只用 Python 标准库。本模块只做两件事:
1. 推送订阅的增删查(SQLite 表 ``push_subscriptions``)。
2. 状态变化事件的分发(只发状态变化,不发进行中状态)。
边界(不要超出本模块宣称的范围):
- 合法事件类型与 ``webhooks.ALLOWED_NOTIFICATION_EVENTS`` 一致:
``completed``、``failed``、``goal-unmet``、``merge-failed``、
``review-requested``。其他事件类型拒绝或忽略,并写明原因,
绝不静默丢弃。
- 偏好过滤:``silent``(静默)不发;失败类事件(``failed``、
``goal-unmet``、``merge-failed``)即使偏好是 ``digest``(摘要)
也即时发;非失败类(``completed``、``review-requested``)在
``digest`` 偏好下本次不立即发,返回结果里明确列入
``skipped_for_digest``(待摘要列表),不是静默丢弃。
- 发送端可注入:``webhook`` / ``slack`` 走可注入的 ``sender``。
``sender`` 为 ``None`` 时对这两个通道也返回“无 sender”错误,
不静默成功。``sender`` 可以是可调用对象(收到一个 dict,见
:func:`dispatch_event`),也可以是按通道分的 dict
(``{"webhook": callable, "slack": callable}``),或带 ``send``
方法的对象。
- ``webpush`` / ``apns`` / ``fcm`` 只有接口,没有真实发送端:
一律明确报错“缺凭证(VAPID / APNs 证书 / FCM key)”,不编造
真实推送、不生成或提交任何私钥到仓库、不向真实推送服务发
任何请求。真实凭证与真实发送端是后续待接线的工作,见模块
末尾的缺口说明。
- 本模块不联网、不调用真实推送服务、不跑真实模型回合。
密钥、token、证书一律不由本模块读取、写入或返回。
"""
import sqlite3
from cloud.db import connect as _db_connect
import time
import uuid
from contextlib import contextmanager
__all__ = [
"CHANNELS",
"PREFERENCES",
"ALLOWED_PUSH_EVENTS",
"FAILURE_EVENTS",
"MISSING_CREDENTIAL_REASONS",
"init_push_db",
"create_subscription",
"delete_subscription",
"list_subscriptions",
"dispatch_event",
]
CHANNELS = ("webhook", "slack", "webpush", "apns", "fcm")
PREFERENCES = ("instant", "digest", "silent")
# 只发状态变化的事件集合,与 webhooks.ALLOWED_NOTIFICATION_EVENTS 一致。
ALLOWED_PUSH_EVENTS = frozenset(
{"completed", "failed", "goal-unmet", "merge-failed", "review-requested"}
)
# 失败类事件:即使偏好是 digest 也即时发。
FAILURE_EVENTS = frozenset({"failed", "goal-unmet", "merge-failed"})
# webpush/apns/fcm 缺凭证时的明确报错文案(不含任何真实凭证)。
MISSING_CREDENTIAL_REASONS = {
"webpush": "missing credential: VAPID keys not configured",
"apns": "missing credential: APNs certificate not configured",
"fcm": "missing credential: FCM key not configured",
}
def _is_connection(db):
return isinstance(db, sqlite3.Connection)
@contextmanager
def _connect(db):
"""按 db 类型取连接:Connection 直接用(不关闭);路径则新建并关闭。"""
if _is_connection(db):
yield db
elif isinstance(db, (str, bytes)) or hasattr(db, "__fspath__"):
conn = _db_connect(str(db))
try:
yield conn
finally:
conn.close()
else:
raise TypeError("db must be a sqlite3 connection or a db path")
def init_push_db(db):
"""创建 push_subscriptions 表(已存在则不动)。"""
with _connect(db) as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS push_subscriptions("
"id TEXT PRIMARY KEY, "
"project_id TEXT NOT NULL, "
"user_id TEXT NOT NULL, "
"channel TEXT NOT NULL, "
"target TEXT NOT NULL, "
"preference TEXT NOT NULL, "
"created_at REAL, "
"UNIQUE(project_id, user_id, channel, target))"
)
conn.commit()
def _row_to_dict(row):
return {
"id": row[0],
"project_id": row[1],
"user_id": row[2],
"channel": row[3],
"target": row[4],
"preference": row[5],
"created_at": row[6],
}
def _validate_subscription_fields(project_id, user_id, channel, target,
preference):
if not project_id or not isinstance(project_id, str):
raise ValueError("project_id required")
if not user_id or not isinstance(user_id, str):
raise ValueError("user_id required")
if channel not in CHANNELS:
raise ValueError("channel must be one of %s" % (CHANNELS,))
if not target or not isinstance(target, str):
raise ValueError("target required")
if preference not in PREFERENCES:
raise ValueError("preference must be one of %s" % (PREFERENCES,))
def create_subscription(db, project_id, user_id, channel, target,
preference="instant"):
"""创建一条推送订阅,返回订阅 dict。
同一 (project_id, user_id, channel, target) 已存在时返回已有订阅,
不建重复行。channel 取值 webhook/slack/webpush/apns/fcm;
preference 取值 instant/digest/silent;非法取值抛 ValueError。
"""
_validate_subscription_fields(
project_id, user_id, channel, target, preference)
init_push_db(db)
with _connect(db) as conn:
existing = conn.execute(
"SELECT id, project_id, user_id, channel, target, preference,"
" created_at FROM push_subscriptions"
" WHERE project_id = ? AND user_id = ? AND channel = ?"
" AND target = ?",
(project_id, user_id, channel, target),
).fetchone()
if existing is not None:
return _row_to_dict(existing)
sub_id = uuid.uuid4().hex
now = time.time()
conn.execute(
"INSERT INTO push_subscriptions(id, project_id, user_id,"
" channel, target, preference, created_at)"
" VALUES(?, ?, ?, ?, ?, ?, ?)",
(sub_id, project_id, user_id, channel, target, preference, now),
)
conn.commit()
row = conn.execute(
"SELECT id, project_id, user_id, channel, target, preference,"
" created_at FROM push_subscriptions WHERE id = ?",
(sub_id,),
).fetchone()
return _row_to_dict(row)
def delete_subscription(db, subscription_id=None, *, project_id=None,
user_id=None, channel=None, target=None):
"""删除订阅,返回 True(删到至少一条)或 False(无匹配)。
优先按 subscription_id 删除;未传 id 时按给出的
project_id/user_id/channel/target 过滤删除(至少要给一个过滤条件,
否则不删任何行并返回 False,避免误删整表)。
"""
init_push_db(db)
with _connect(db) as conn:
if subscription_id is not None:
cur = conn.execute(
"DELETE FROM push_subscriptions WHERE id = ?",
(subscription_id,),
)
else:
conditions = []
params = []
for column, value in (
("project_id", project_id),
("user_id", user_id),
("channel", channel),
("target", target),
):
if value is not None:
conditions.append("%s = ?" % column)
params.append(value)
if not conditions:
return False
cur = conn.execute(
"DELETE FROM push_subscriptions WHERE "
+ " AND ".join(conditions),
params,
)
conn.commit()
return cur.rowcount > 0
def list_subscriptions(db, project_id, user_id=None):
"""列出某项目的订阅;传 user_id 时只列该用户的。
返回按 created_at、id 排序的订阅 dict 列表。只查本项目,
跨项目订阅绝不会混入(见单测)。
"""
if not project_id or not isinstance(project_id, str):
raise ValueError("project_id required")
init_push_db(db)
with _connect(db) as conn:
if user_id is None:
rows = conn.execute(
"SELECT id, project_id, user_id, channel, target,"
" preference, created_at FROM push_subscriptions"
" WHERE project_id = ? ORDER BY created_at, id",
(project_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, project_id, user_id, channel, target,"
" preference, created_at FROM push_subscriptions"
" WHERE project_id = ? AND user_id = ?"
" ORDER BY created_at, id",
(project_id, user_id),
).fetchall()
return [_row_to_dict(row) for row in rows]
def _resolve_channel_sender(sender, channel):
"""从注入的 sender 里取出某通道的发送函数,取不到返回 None。"""
if sender is None:
return None
if isinstance(sender, dict):
candidate = sender.get(channel)
if candidate is None:
return None
if callable(candidate):
return candidate
send = getattr(candidate, "send", None)
return send if callable(send) else None
if callable(sender):
return sender
send = getattr(sender, "send", None)
return send if callable(send) else None
def dispatch_event(db, event_type, project_id, payload, sender=None):
"""分发一次状态变化事件,只发本项目的订阅。
参数:
db: sqlite3 连接或数据库路径。
event_type: 合法类型见 ALLOWED_PUSH_EVENTS;其他类型拒绝,
返回 ``{"sent": [], ..., "reason": "event not allowed"}``
且不调用任何 sender。
project_id: 只查该项目的订阅,跨项目不混。
payload: 原事件载荷(dict 或其他),原样放入发送给 sender
的消息 dict 的 ``payload`` 字段。
sender: 可注入发送端(见模块 docstring)。webhook/slack
通道在 sender 为 None 时返回“无 sender”错误,不静默成功;
webpush/apns/fcm 通道不走 sender,一律返回缺凭证错误。
返回 dict,键固定为::
{
"event_type": ...,
"project_id": ...,
"sent": [{"subscription_id", "user_id", "channel", "target"}],
"skipped_for_digest": [订阅 dict(待摘要,本次未立即发)],
"skipped_silent": [订阅 dict(静默偏好,本次未发)],
"errors": [{"subscription_id", "user_id", "channel", "target",
"reason": ...}],
}
非法事件类型时额外含 ``"reason": "event not allowed"``。
sender 抛异常时记入 errors,不中断其他订阅的分发。
"""
result = {
"event_type": event_type,
"project_id": project_id,
"sent": [],
"skipped_for_digest": [],
"skipped_silent": [],
"errors": [],
}
if event_type not in ALLOWED_PUSH_EVENTS:
result["reason"] = "event not allowed"
result["errors"] = [{
"subscription_id": None,
"user_id": None,
"channel": None,
"target": None,
"reason": "event not allowed: %r not in %s"
% (event_type, sorted(ALLOWED_PUSH_EVENTS)),
}]
return result
if not project_id or not isinstance(project_id, str):
result["reason"] = "project_id required"
result["errors"] = [{
"subscription_id": None,
"user_id": None,
"channel": None,
"target": None,
"reason": "project_id required",
}]
return result
subscriptions = list_subscriptions(db, project_id)
is_failure = event_type in FAILURE_EVENTS
for sub in subscriptions:
base = {
"subscription_id": sub["id"],
"user_id": sub["user_id"],
"channel": sub["channel"],
"target": sub["target"],
}
# 偏好过滤:silent 永远不发;digest 只对非失败类延迟。
if sub["preference"] == "silent":
result["skipped_silent"].append(sub)
continue
if sub["preference"] == "digest" and not is_failure:
result["skipped_for_digest"].append(sub)
continue
# webpush/apns/fcm:只有接口,缺凭证,明确报错,不走 sender、
# 不联网、不编造推送。
if sub["channel"] in MISSING_CREDENTIAL_REASONS:
result["errors"].append(
dict(base, reason=MISSING_CREDENTIAL_REASONS[sub["channel"]]))
continue
# webhook/slack:必须有可注入 sender,否则明确报错。
channel_sender = _resolve_channel_sender(sender, sub["channel"])
if channel_sender is None:
result["errors"].append(
dict(base, reason="no sender for channel %s"
% sub["channel"]))
continue
message = {
"event_type": event_type,
"project_id": project_id,
"payload": payload,
"channel": sub["channel"],
"target": sub["target"],
"user_id": sub["user_id"],
"subscription_id": sub["id"],
}
try:
channel_sender(message)
except Exception as exc: # noqa: BLE001 -- 分发失败记 errors,不中断
result["errors"].append(
dict(base, reason="sender failed: %s" % exc))
continue
result["sent"].append(base)
return result