Ctrl K
ComunidadGuías…

docs/zh/api-notifications.mdx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
---
title: "通知与集成"
description: "World Monitor 通知系统 API 完整参考:涵盖通知渠道注册与管理、webhook 投递重试机制、用户订阅偏好设置,以及 Telegram、Slack、Discord 与 YouTube 等第三方集成端点,让实时警报能够精准推送至团队协作工具、社区频道与个人移动设备。"
---

## 通知渠道

用户可注册多个投递渠道(webhook、Telegram、Slack、Discord、电子邮件),并将告警规则绑定到这些渠道上。

摘要与简报通知使用 [新闻摘要与简报方法论](/zh/methodology/news-digest-and-briefing) 中记录的同一故事池和编辑护栏。

### `GET /api/notification-channels`

列出调用方已注册的渠道和告警规则。

```json
{
  "channels": [
    { "id": "chn_01", "type": "webhook", "url": "https://hooks.example.com/...", "active": true },
    { "id": "chn_02", "type": "telegram", "chatId": "@alerts_xyz", "active": true }
  ],
  "alertRules": [
    { "id": "rul_01", "channelId": "chn_01", "trigger": "brief_ready", "filter": null }
  ]
}
```

### `POST /api/notification-channels`

基于 action 分发的写入端点。请求体中的 `action` 字段决定执行哪一类变更:

| action | 用途 |
|--------|---------|
| `create-pairing-token` | 生成一次性配对令牌(可选 `variant`),供移动端 / Tauri 客户端绑定推送渠道。 |
| `set-channel` | 注册或更新渠道。对于 `webhook` 渠道,`webhookEnvelope` URL 仅接受 HTTPS,不得解析到私有/回环地址,并在存储前使用 AES-256-GCM 加密。可选字段:`email`、`webhookLabel`(截断至 100 字符)。 |
| `set-web-push` | 为已登录用户注册浏览器 Web Push 订阅。 |
| `delete-channel` | 按类型移除渠道(`email`、`webhook`、`telegram`、`web-push` 等)。 |
| `set-alert-rules` | 一次性替换调用方的告警规则集合。 |
| `set-quiet-hours` | 设置"勿扰"时间窗口。 |
| `set-digest-settings` | 配置摘要发送节奏和渠道路由。 |

所有 action 均要求 Clerk bearer + PRO,且此处的 PRO 特指**已计费的权益记录**(`tier >= 1`)。与网关的 tier-1 REST 权限检查不同,仅有 `pro` 角色但没有权益记录的 Clerk 会话**不满足**要求:通知投递在 Convex 内部还会被再次校验(`assertProEntitlement`),因此边缘层直接返回清晰的 `403 pro_required`,而不是让请求在更深层以更无用的错误失败。无效的 action 返回 `400 Unknown action`。请求通过 `RELAY_SHARED_SECRET` 转发至 Convex。

仅当权益被确认为非 Pro 时,没有已计费权益记录的调用方才会收到 `403 pro_required`。当权益校验本身处于不确定状态时,本端点的权限检查改为遵循共享的计费校验契约:对 `entitlement_verification_unavailable` / `renewal_verification_pending` / `renewal_verification_failed` 返回带 `Retry-After` 和 `X-Billing-Verification` 的 `503`;对已确认失效的订阅返回 `403 subscription_lapsed`。参见[错误处理](/zh/usage-errors)。

客户端在向用户暴露失败之前,应至少按 `Retry-After` 重试一次该 `503` —— 仪表盘自身的服务层(`src/services/notification-channels.ts`)会执行且仅执行一次有界重试。对 `entitlement_verification_unavailable` 而言,早于 `Retry-After` 的重试是无意义的:该结果在服务端会被短时负缓存,提前重试只会拿到同一个结果。另外两个 `renewal_verification_*` 代码不会被负缓存,但其延迟对应真实的计费提供方复核或冷却期,因此提前重试同样只会得到相同状态。

- **幂等性**: `POST /api/notification-channels` 支持可选的 `Idempotency-Key`。使用相同 key 和相同请求体重试时,会重放原始响应,而不是再次应用渠道动作。 例外是成功的 webhook `set-channel`:其响应包含一次性签名密钥,因此不会被缓存,重试会重新注册该 URL 并签发新密钥。

## 咽喉要道中断告警

只有当规则的 `eventTypes` 明确列出 `chokepoint_disruption` 时,规则才会收到该事件。`eventTypes` 为空的规则(其他情况下表示接收所有事件类型)不会收到它。列出该类型也不会缩小规则的其他告警范围。

- **触发时机**:每次 6 分钟的咽喉要道数据刷新之后检查。咽喉要道的 `disruptionScore` 由其常设威胁等级(战区 70、严重 40、高 30、升高 15、正常 0)加上实时信号(航行警告、AIS 中断、通行量下降)组成。当分数至少为 50(仪表盘上的红色区间),且实时信号至少贡献 15 分时触发告警。因此像霍尔木兹这样的战区咽喉要道会因新的动态而告警,而不会仅因常设等级告警。
- **重新武装**:告警之后,只有当分数降到 40 以下或实时信号降到 5 分以下,且该次读数中航行警告和 AIS 数据源可用(若告警包含通行量下降,还需通行数据可用)时,才能再次告警。每个咽喉要道最多每 12 小时告警一次。
- **严重级别**:始终为 `critical`。与其他队列事件一样,只投递给实时规则,不进入摘要。
- **范围**:`chokepoints` 接受[咽喉要道注册表](/zh/methodology/chokepoints)中的 id(`suez`、`hormuz_strait`、`bab_el_mandeb`、`panama` 等),未知 id 会被丢弃;若列表中没有任何已知 id,则返回 `400 CHOKEPOINTS_INVALID`。列表为空或缺省表示所有咽喉要道。该事件不带国家信息,因此 `countries` 范围不会过滤它。
- **载荷**:`title`、`source`(`Chokepoint Monitor`)、`chokepointId`、`chokepointName`、`disruptionScore`、`status`(`red`)、`activeWarnings`、`congestionLevel`、`triggeredAt`,以及标识一次告警、在中继重发时保持不变的 `coalesceKey`。Webhook 投递还会附带仪表盘 URL `link`。

`POST /api/notify` 会拒绝该事件类型:只有中继会发布它。

## Webhook 投递契约

当告警触发时,已注册的 webhook URL 将收到:

- **Method**: `POST`
- **Headers**:
  - `Content-Type: application/json`
  - `X-WM-Delivery-Id: <uuid>`,每次投递尝试唯一
  - `X-WM-Event: <eventType>`,与请求体中的 `eventType` 相同
  - `X-WM-Timestamp: <unix 秒>`
  - `X-WM-Signature: sha256=<hex HMAC-SHA256(signingSecret, timestamp + "." + deliveryId + "." + rawBody)>`
- **Body**(信封 v1)。通知中继发出的实时告警:
  ```json
  {
    "version": "1",
    "eventType": "rss_alert",
    "severity": "high",
    "timestamp": 1776578400000,
    "payload": { "title": "...", "source": "...", "link": "https://..." },
    "variant": "full"
  }
  ```
  摘要生产者发出的摘要:
  ```json
  {
    "version": "1",
    "eventType": "digest",
    "stories": [{ "title": "...", "severity": "high", "phase": "...", "sources": ["..."] }],
    "summary": "...",
    "storyCount": 12
  }
  ```

### 签名密钥

每次通过 `set-channel` 保存 webhook URL 时,响应都会包含一个新的 `signingSecret`(`whsec_...`)。它只返回一次,服务端仅保存加密副本。再次保存该 URL 会签发新密钥并使旧密钥失效,这也是轮换密钥的方式。

### 校验投递

1. 在解析之前读取原始请求体。
2. 计算 `hex(HMAC-SHA256(signingSecret, X-WM-Timestamp + "." + X-WM-Delivery-Id + "." + rawBody))`。
3. 除非 `X-WM-Signature` 为 `sha256=` 加上恰好 64 个十六进制字符,否则拒绝该投递;然后使用常量时间比较,将这些字节与你计算的结果比较。
4. 拒绝 `X-WM-Timestamp` 与本地时钟相差超过 5 分钟的投递,并按 `X-WM-Delivery-Id` 去重。

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, headers, signingSecret) {
  const ts = headers['x-wm-timestamp'];
  const id = headers['x-wm-delivery-id'];
  const sig = headers['x-wm-signature'] ?? '';
  if (!ts || !id || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const match = /^sha256=([0-9a-f]{64})$/.exec(sig);
  if (!match) return false;
  const expected = createHmac('sha256', signingSecret).update(`${ts}.${id}.${rawBody}`).digest();
  return timingSafeEqual(Buffer.from(match[1], 'hex'), expected);
}
```

在引入签名之前注册的 webhook 没有密钥,投递时不带 `X-WM-Signature`。设置页会将其标记为 **Unsigned**;移除后重新添加该 URL 即可获得密钥。

<Warning>
信封版本在**两个生产者之间共享**(`notification-relay`、`seed-digest-notifications`)。升级版本需协同更新。
</Warning>

### `POST /api/notify`

面向 PRO 调用方的认证事件发布端点。要求 Clerk bearer 认证和有效的 PRO 权益,随后将接受的事件入队到通知队列。中继内部控制事件(如 `flush_quiet_held` 和 `channel_welcome`)为保留事件,会被拒绝。

错误码:401(缺少/无效 JWT)、403 `pro_required`;与本文档中所有 Pro 门控端点一样,当权益校验本身处于不确定状态时遵循共享的计费校验契约:对 `entitlement_verification_unavailable` / `renewal_verification_pending` / `renewal_verification_failed` 返回带 `Retry-After` 和 `X-Billing-Verification` 的 `503`;对已确认失效的订阅返回 `403 subscription_lapsed`。参见[错误处理](/zh/usage-errors)。

- **幂等性**: 支持可选的 `Idempotency-Key`。使用相同 key 和相同请求体重试时,会重放原始入队响应,而不是再次发布通知。

## Telegram

### `GET /api/telegram-feed`

主题标签页式 Telegram Intel 面板的第一方浏览器路径。接受 `limit`、`topic` 和 `channel` 参数;不存在按用户(`userId`)的调用形式。需要仪表盘会话凭证(`wms_`),响应为 `private, max-age=30` — 不可被公开缓存,无凭证的请求返回 `401` 并附带 `no-store`。消息正文为 R4。

程序化访问与合作方访问请改用已认证的 RPC [`GET /api/intelligence/v1/list-telegram-feed`](/zh/panels/telegram-intel)。

## YouTube

### `GET /api/youtube/embed?videoId=...`

带 CSP 兼容封装的 SSR YouTube embed iframe。用于绕过桌面应用中的 WKWebView 自动播放限制。

### `GET /api/youtube/live?channel=<handle>` 或 `?videoId=<11-char-id>`

为频道管理返回 YouTube 视频名称。`videoId`(11 字符 YouTube id)直接通过 YouTube oEmbed 返回视频的 `title` 和作者(`channelName`),响应缓存 1 小时。oEmbed 失败时返回 `200`,`title` 和 `channelName` 为 null,且不缓存。两个参数同时提供时,按 `videoId` 查询作答。

频道直播检测已停用。只提供合法 `channel`(带/不带 `@` 前缀的 handle,或 `UC…` 频道 id)而不提供 `videoId` 时,返回 `410 {"error":"channel_live_detection_retired"}`,缓存 1 天,不会请求 YouTube 或 Railway 中继。两个参数至少需提供其一;否则返回 `400 Missing channel or videoId parameter`;handle、频道 id 或视频 id 格式不合法时返回 `400`。

## Slack 集成

### `POST /api/slack/oauth/start`

需认证(Clerk JWT + PRO)。请求体为空。服务端生成一次性 CSRF state token,以该 state 为键将调用方的 userId 存入 Upstash(10 分钟 TTL),并返回 Slack 授权 URL,供前端在弹窗中打开。

```json
{ "oauthUrl": "https://slack.com/oauth/v2/authorize?client_id=...&scope=incoming-webhook&..." }
```

错误码:401(缺少/无效 JWT)、403 `pro_required`、503(OAuth 未配置或 Upstash 不可用)。此处的 503 **也可能**是可重试的计费校验拒绝 —— `entitlement_verification_unavailable` / `renewal_verification_pending` / `renewal_verification_failed`,并携带 `Retry-After` 和 `X-Billing-Verification`;已确认失效的订阅为 `403 subscription_lapsed`。由于配置错误导致的 503 不可重试,请依据 `code` 字段而非仅凭状态码分支。参见[错误处理](/zh/usage-errors)。

### `GET /api/slack/oauth/callback`

无需认证 — Slack 重定向后弹窗会跳转到此。校验 state token,用 `code` 换取 incoming-webhook URL,使用 AES-256-GCM 加密该 webhook 并存入 Convex。返回一段极简 HTML 页面,通过 `postMessage` 通知 opener 并关闭。

## Discord 集成

### `POST /api/discord/oauth/start`

需认证(Clerk JWT + PRO)。与 Slack 的 start 路由形态一致 — 返回 `{ oauthUrl }` 供弹窗使用,错误码集合也相同,包括上文描述的计费校验 503/403 代码。

### `GET /api/discord/oauth/callback`

无需认证。用 `code` 换取信息,存储 guild webhook,并通过 `postMessage` 通知 opener。