diff --git a/README.md b/README.md index e58d293..70e4ba2 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ the credential for any HTTP endpoint. | [`docs/design.md`](docs/design.md) | Why the system is shaped this way: data flow, JMAP semantics, Redis streams, AI consent rules | | [`docs/deployment.md`](docs/deployment.md) | How to deploy: Dockerfile, secrets, Redis hosting, webhook/push/reconcile routing, multi-instance load balancing | | [`docs/reference.md`](docs/reference.md) | **Single source of truth for verifiable facts** — Redis keys and TTLs, error codes, routes, environment layers, budgets | -| [`docs/roadmap.md`](docs/roadmap.md) | Gaps, blockers and the next phase | +| [`docs/opengaps.md`](docs/opengaps.md) | Gaps, blockers and the next phase | | [`docs/retired.md`](docs/retired.md) | What was tried and dropped — abandoned routes, unreleased designs, and names that never existed | | [`docs/charter.md`](docs/charter.md) | Project charter: goals, locked technology choices, security invariants, prohibitions, and the stable-ID registry | | [`AGENTS.md`](AGENTS.md) | Language-agnostic engineering norms: code style, config and secrets, build environment, testing gates, document governance | diff --git a/README.zh-CN.md b/README.zh-CN.md index 18229ef..0806bc9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -90,7 +90,7 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` ** | [`docs/design.md`](docs/design.md) | 为什么这样设计:数据流、JMAP 语义、Redis streams、AI 授权规则 | | [`docs/deployment.md`](docs/deployment.md) | 怎么部署:Dockerfile、secrets、Redis 托管、webhook/push/对账路由、多实例负载均衡 | | [`docs/reference.md`](docs/reference.md) | **可核对事实的唯一权威来源**——Redis 键与 TTL、错误码、路由、环境变量分层、预算常量 | -| [`docs/roadmap.md`](docs/roadmap.md) | 缺口、阻塞项与下一阶段目标 | +| [`docs/opengaps.md`](docs/opengaps.md) | 缺口、阻塞项与下一阶段目标 | | [`docs/retired.md`](docs/retired.md) | 试过但没用的:废弃路线、未落地的设计与从未存在的名字 | | [`docs/charter.md`](docs/charter.md) | 项目章程:项目目标、锁定的技术选型、安全不变量、禁止事项与稳定 ID 注册表 | | [`AGENTS.md`](AGENTS.md) | 语言无关的工程规范:代码风格、配置与密钥、构建环境、测试门禁、文档治理 | diff --git a/docs/charter.md b/docs/charter.md index aa475da..e304702 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -5,7 +5,7 @@ > > 通用、语言无关的代码编写与环境构建规范见 [`../AGENTS.md`](../AGENTS.md); > 可核对事实(路由 / Redis 键与 TTL / 配置项 / 错误码 / 出站常量)的唯一权威来源见 -> [`reference.md`](reference.md);缺口与阻塞见 [`roadmap.md`](roadmap.md)。 +> [`reference.md`](reference.md);缺口与阻塞见 [`opengaps.md`](opengaps.md)。 > > 三者冲突时:**可核对事实以 `reference.md` 为准,项目约束以本文件为准,质量规则以 `../AGENTS.md` 为准。** @@ -116,7 +116,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \ bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' ``` -**当前基线:61 passed / 0 failed / 1 ignored**(唯一 ignored:`real_server_tests::session_list_and_read_smoke`,需外部真实 JMAP 服务器)。 +**当前基线:69 passed / 0 failed / 1 ignored**(2026-09-29 复测;唯一 ignored:`real_server_tests::session_list_and_read_smoke`,需外部真实 JMAP 服务器)。 **注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 @@ -158,7 +158,7 @@ cd /home/okabe/Repo/messageweave/cloudflare-worker && node --test test/*.test.js 无 Stalwart / Telegram / 托管 Redis 凭据。真实环境集成测试必须用 `#[ignore]` 标注, 缺凭据时静默跳过(不泄露凭据、不判失败)。 -**环境阻塞项**(未验证,不得声称已验证)见 [`roadmap.md`](roadmap.md)「阻塞」区: +**环境阻塞项**(未验证,不得声称已验证)见 [`opengaps.md`](opengaps.md)「阻塞」区: 真实 Stalwart、真实 Telegram Bot、真实 Redis TLS、`Email/changes` 的 `newState` 语义、 Push callback 公网映射。 @@ -173,7 +173,7 @@ Push callback 公网映射。 | `docs/design.md` | 为什么这样设计:数据流、模块边界、状态机、错误处理 | 架构意图 | | `docs/deployment.md` | 怎么部署:Dockerfile、secrets、网关、多实例、cron | 部署与运维 | | `docs/reference.md` | 可核对事实的唯一权威来源:路由、Redis 键与 TTL、配置项、错误码、出站常量 | **可核对事实** | -| `docs/roadmap.md` | 缺口、阻塞、下一阶段目标 | 未决项 | +| `docs/opengaps.md` | 缺口、阻塞、下一阶段目标 | 未决项 | | `docs/retired.md` | 已废弃或已改名方案的记录与替代指向 | 历史决策 | | `docs/charter.md` | 本文件:项目约束、技术选型、实现阶段、安全不变量、稳定 ID 注册表 | 项目约束 | | `web/`(无文档,4 个文件) | 管理 SPA 源:静态配置页与前端逻辑,由 `web/config.test.mjs` 覆盖 | 前端行为(权威事实记在 `docs/design.md` 与 `docs/reference.md`) | @@ -259,7 +259,7 @@ Push callback 公网映射。 | `ARCH-DEPS-STAGE4` | docs/design.md §10.0-1 / §4 | 阶段3.5/4 后依赖现况:`redis 0.27`、`reqwest 0.13` 已引入并实际使用;`teloxide` **未**引入(Telegram 由 `src/channel.rs` 用 reqwest 自研实现);版本一律以 `Cargo.toml` 为准 | 架构 | | `MOD-JMAP-CLIENT` | docs/design.md §6-7 / §4 | `domain::jmap::client` 真实只读 adapter(session/account/mailbox/email 只读) | 组件 | | `MOD-TELEGRAM-NOTIFY` | src/worker.rs / docs/design.md §12 | 有界元数据通知 worker:消费出站队列,把脱敏通知经 Telegram 投递(不承载正文/AI 响应) | 组件 | -| `REQ-JMAP-SESSION-URL` | docs/design.md §7.1 / .env.example | `JMAP_SESSION_URL` 接受服务基地址或完整 /.well-known/jmap,归一化为 origin/base 后再交 jmap-client(无重复路径);**代码已实现(D-G1-1),待真机验证** | 需求 | +| `REQ-JMAP-SESSION-URL` | docs/design.md §7.1 / .env.example | `JMAP_SESSION_URL` 接受服务基地址或完整 /.well-known/jmap,归一化为 origin/base 后再交 jmap-client(无重复路径);**代码已实现(D-G1-1),已在真实账号真机验证**(2026-09-28:`fetchChanges` 持久化 `baseline:` 游标、`Email/query` 搜索回帖,均经用户确认) | 需求 | | `SAF-JMAP-URL` | docs/design.md §7.1 / §4 | JMAP URL 约束:仅 HTTPS、禁止内嵌凭据、拒绝危险 query | 安全 | | `REQ-JMAP-RAW-MULTIPART` | docs/design.md §3.2/§10.1 | `read_email` 多 part 原文:按 text_body 顺序拼接"有 part_id 且 bodyValue"的部分;无可用部分→明确错误 | 需求 | | `GATE-G1-JMAP-READONLY` | docs/design.md §10.1 / §6 | G1 门禁:只读 adapter **代码已实现**(mock + `#[ignore]` 真机测试),**待真实 `cargo test -- --ignored jmap::` 验证** | 流程 | diff --git a/docs/deployment.md b/docs/deployment.md index 8996c33..93d58ba 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -558,7 +558,7 @@ docker run --env-file .env -p 8080:8080 messageweave:latest > **最后一项待确认已清空**:`Q-DEP-A`(平台 URL / 域名与证书配置方)与 `Q-DEP-B`(外部调度器选型) > 均已决策,分别合并进本表的 `C-HTTPS-URL` 与 `NFR-RECONCILE-INTERVAL` 行。两项都是上线时的运维选择, -> 不影响代码结构、不影响门禁;`docs/roadmap.md` 已不再保留「决策待定」块。 +> 不影响代码结构、不影响门禁;`docs/opengaps.md` 已不再保留「决策待定」块。 > > 其余产品/架构问题见 design.md;不再有平台特定部署问题(`NG-SERVERLESS-BIND`)。 diff --git a/docs/design.md b/docs/design.md index fd5e704..4d0e67b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -29,7 +29,7 @@ 11. [历史问题与决策归档(产品/架构类,均已有结论)](#11-历史问题与决策归档产品架构类均已有结论) 12. [AI 辅助能力:架构、确认门槛、失败回退](#12-ai-辅助能力架构确认门槛失败回退) -> 部署/平台类决策已确认(单账户、App Password+Basic、Redis 托管+AOF、平台 HTTPS URL、外部 Cron)并**全部收敛归档**(含 `Q-DEP-A`/`Q-DEP-B`,见 `docs/deployment.md` 的已确认决策一节);**未完成的代码缺口见 `docs/roadmap.md`**。 +> 部署/平台类决策已确认(单账户、App Password+Basic、Redis 托管+AOF、平台 HTTPS URL、外部 Cron)并**全部收敛归档**(含 `Q-DEP-A`/`Q-DEP-B`,见 `docs/deployment.md` 的已确认决策一节);**未完成的代码缺口见 `docs/opengaps.md`**。 --- @@ -346,7 +346,7 @@ message-weave/ | `web` | axum | `/config` 静态页 + `include_str!` 嵌入 + CSP | 前端由 `web/config.test.mjs` 覆盖 | 已实现 | | `debug` | reqwest 0.13;redis 0.27 | 远端诊断面 `/debug/*`(`src/debug.rs`):6 条只读探针 `/debug/ping`、`/debug/config`、`/debug/redis`、`/debug/jmap`、`/debug/telegram`、`/debug/worker` + `POST /debug/notify`(走生产出站路径发一条测试消息,无独立实现) | 仅 `DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子齐备时挂载路由;模块本身无条件编译 | 已实现,不进 Worker 白名单(`SAF-DEBUG-ORIGIN-ONLY`) | -> 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:`docs/roadmap.md`「代码缺口」中 `/search`、Telegram 429 退避、多实例重复投递窗口均已实现,仅剩 1 条按产品决策保留不改(`SAF-DEBUG-ALLOWLIST`)。`worker` 模块的 `/search` 路径已随 `bfe0fd8` 落地。 +> 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:`docs/opengaps.md`「代码缺口」中 `/search`、Telegram 429 退避、多实例重复投递窗口均已实现,仅剩 1 条按产品决策保留不改(`SAF-DEBUG-ALLOWLIST`)。`worker` 模块的 `/search` 路径已随 `bfe0fd8` 落地。 --- @@ -557,7 +557,7 @@ pub enum BotError { - axum 采用 **0.8**(`ARCH-AXUM-08`);如后续审核决定调整版本,以 Cargo.toml 为准并同步本节。 - 当前不实现 SSE/WebSocket/长轮询/SQLite/本地卷(`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);入口鉴权(`R1`/`SAF-AUTH-*`)作为 fail-closed 硬门禁落地,鉴权之后的 Push、Streams worker 和 `/reconcile` 业务路径已实现。 -**阶段0 P0 门禁(`GATE-P0`)——已通过。首次冻结时为 45 passed / 0 failed / 1 ignored,此后随 ④–⑥ 轮实现持续增长,当前基线以 `docs/roadmap.md` 头部为准。以下为阶段0 当时的判据,保留作历史记录:** +**阶段0 P0 门禁(`GATE-P0`)——已通过。首次冻结时为 45 passed / 0 failed / 1 ignored,此后随 ④–⑥ 轮实现持续增长,当前基线为 69 passed / 0 failed / 1 ignored(2026-09-29 复测;唯一 ignored:`real_server_tests::session_list_and_read_smoke`,需外部真实 JMAP 服务器)。以下为阶段0 当时的判据,保留作历史记录:** 1. `cargo fmt --check` 通过(无格式差异)。 2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;禁 crate 级 `allow`)。 3. `cargo test` 通过(含路由/配置最小测试)。 @@ -616,7 +616,7 @@ pub enum BotError { - `send_email`(draft + submission_set)**当前未实现**:`JmapBackend` 只有只读动词。 - 发信流程原本计划用多步 FSM 收集 to/subject/body,**从未实现**(见 `docs/retired.md` 的对话 FSM 一节)。 - `/flag /unseen` 关键词标记**未实现**。 -- 缺口与阶段归属见 `docs/roadmap.md`。 +- 缺口与阶段归属见 `docs/opengaps.md`。 ### 10.4 阶段 3.5:LLM 门面 + 回退(1.5d,未执行) 未实施,见 §12.8 与 `docs/retired.md`。 @@ -659,14 +659,14 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义 - DLQ 条数、inflight 积压深度、对账补齐条数等指标尚未自动上报(§8.3 标记为可选)。当前运维需通过 Redis 直接查看:`XLEN messageweave:dlq:*`、`XPENDING` 等。 - 若需自动化告警,建议在 Uptime Kuma 增加对 `/ready` 或对账入口的健康检查,并手动复核 DLQ 深度。 -**已知边界(当前实现仍存在,见 `docs/roadmap.md`)** +**已知边界(当前实现仍存在,见 `docs/opengaps.md`)** - 多实例重复投递窗口(**已收口,`6c99ce5`**):XAUTOCLAIM 空闲阈值不再按固定值缩放,改由运行超时配置推导——`(max_retries + 1) × (jmap + telegram + llm 超时) × 2` 为单条上限,再乘批大小,下限 300s、上限 6h(见 `docs/reference.md` §6.3)。提前认领窗口在单实例与多实例部署下均关闭;单实例不受影响,多实例最多重复、不丢。 **审计意见 → 收口(2026-09-26)** - 【应修-2】入队失败时 dedup 释放 best-effort 曾可能造成 24h 静默丢事件 → 已修复为 `claim_dedup_and_enqueue`(Lua 原子:`SET NX EX` 成功才 `XADD`),claim 与入队之间无中间失败窗口。 -- 【应修-1】`Email/changes` 依赖 `newState` 续传,`jmap-client 0.4.2` 无 `upToId` → 已改为「同 `sinceState` 下逐次翻倍 `maxChanges` 扩窗(上限 4096),仅在无法扩窗时才推进 `new_state`」,避免按页推进时漏批;`newState` 语义本身仍需真实 Stalwart 复验(见 `docs/roadmap.md`)。 +- 【应修-1】`Email/changes` 依赖 `newState` 续传,`jmap-client 0.4.2` 无 `upToId` → 已改为「同 `sinceState` 下逐次翻倍 `maxChanges` 扩窗(上限 4096),仅在无法扩窗时才推进 `new_state`」,避免按页推进时漏批;`newState` 语义本身仍需真实 Stalwart 复验(见 `docs/opengaps.md`)。 - 其余低风险项均已收口:未知 stream 的空值改为 `Err`(fail-closed,进重试/DLQ);`push:disable` 经 `forget_push_subscription` 清理验证码摘要键;`SET NX EX` TTL 下限收紧为 `.max(1)`;XAUTOCLAIM 空闲阈值按批大小缩放;无 payload 的畸形流条目由 `ack_malformed` 经 `XACK` 移出 PEL;CSPRNG 兜底 owner-token 改为「时间 + PID + 计数器」,不再使用常量。 - 未排期待办(阶段 5「搜索 + 搜索片段」)**已收口(`bfe0fd8`)**:`/search` 走 `email_query`(`Filter::text`) + `SearchSnippet/get`,高亮降级与截断上限见 §2.3、§5.7;正文级高亮在锁定版本做不到(见 §2.3 备注)。阶段 5 待办已清零。 @@ -685,7 +685,7 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义 ### 11.2 实时通道(已定,见 deployment.md) - 通道 = **JMAP Push HTTPS 回调 + 外部 Cron `/reconcile` 对账**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。Push 注册通过受保护的 `POST /api/push/register` 显式触发,外部 Cron 仍是必须的可靠补偿通道。 -- 两条尚需在真实 Stalwart 上校准的点——账号角色是否具备 `PushSubscription` 权限、以及 Stalwart 回调重试次数与 TTL/对账间隔的匹配——已作为验收项登记在 `docs/roadmap.md`,不在本文以问题形式留存。 +- 两条尚需在真实 Stalwart 上校准的点——账号角色是否具备 `PushSubscription` 权限、以及 Stalwart 回调重试次数与 TTL/对账间隔的匹配——已作为验收项登记在 `docs/opengaps.md`,不在本文以问题形式留存。 ### 11.3 部署形态(已确认,架构相关) - **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。 diff --git a/docs/opengaps.md b/docs/opengaps.md new file mode 100644 index 0000000..5b2b711 --- /dev/null +++ b/docs/opengaps.md @@ -0,0 +1,31 @@ +# Open Gaps(`docs/opengaps.md`) + +> 只登记**仍未完成、仍未验证**的事项。已实现能力见 `docs/reference.md`、`docs/design.md`、`docs/deployment.md`; +> 被移除、废弃、明确拒绝的能力见 `docs/retired.md`。门禁命令见 `docs/deployment.md` 的 Gate 一节。 + +## 阶段目标 + +`GATE-P0` 已过(fmt / clippy / check / test,Debian 容器内)。真实邮箱与真实 Telegram Bot 的**出站与查询方向**已在真机联调通过(见下文各条的「已验证」括注)。 +剩余目标收窄为:**在 Telegram 侧执行 `setWebhook`** 与 **`PushSubscription` 回调链路**,消掉下面 5 条阻塞项。 + +## 阻塞(需要真实环境,当前无法验证) + +- **真实 Stalwart `PushSubscription`** — `Email/changes` 与 `Email/query` 搜索已在真实账号跑通(2026-09-28:`POST /reconcile` 持久化了 `baseline:` 游标,而该游标只在 `reconcile()` 返回 `Ok(new_state)` 之后写入(`notify.rs:331`),即 `fetchChanges` 端到端成功)。**未验证**:账号角色是否具备 push 权限、Stalwart 回调重试次数、TTL/对账间隔的匹配。**阻塞原因**:无真实推送回调可观察。禁止使用测试账号或伪造结果。 + +- **真实 Telegram 回调(Telegram→本服务方向)** — 应用侧入站 secret 头校验 + chat 白名单(`notify.rs:225-263`)、出站 `sendMessage`(`channel.rs`)、出站重试预算与重试耗尽终局错误(`channel.rs:182`)已在真实 Bot 上验证(2026-09-28:`POST /debug/notify` 投递成功;伪造 update 全链路「入队→worker→JMAP `Email/query` 搜索→回帖」通过;以上两条均经用户确认收到)。**未验证**:真实的 Telegram→本服务回调从未到达——向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现,**目前尚未执行**,因此 Telegram 侧既不知道本服务的 webhook URL,也不持有 secret 值。另:长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:需在 Telegram 侧执行 `setWebhook`(`url=<公网地址>/webhook/tg`、`secret_token=<业务配置里的值>`,两者必须一致)并触发一次真实回调。 + +- **真实 Redis TLS 连接** — `rediss://` 握手、密码特殊字符 URL 编码、Redis ACL 密码均未实测。**阻塞原因**:无托管 Redis 实例。注:`CONFIG_ENCRYPTION_KEY` 与 `REDIS_URL` 只在启动时读取(`src/config.rs:17`、`src/main.rs:54`),本系统不存在这两项的热更新路径,也不在本系统的待办范围内。 + +- **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批;2026-09-28 在真实账号上跑通 `fetchChanges` 并拿到 `baseline:` 游标,但 `newState` 是否表示"全部待报变更之后"仍需**停机积压后恢复**的场景才能判定,否则积压边界仍无法确证。**阻塞原因**:无可控的真实积压场景。 + +- **Push callback 公网映射 + 单飞锁 TTL** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。 + +## 代码缺口(已定位) + +仅 1 条,按产品决策保留不改;其余已实现,见各自 commit 与 `docs/reference.md`。真正的阻塞是上面的真实环境依赖。 + +- **`/debug/notify` 空白名单不拦截**(`SAF-DEBUG-ALLOWLIST`)— 白名单校验只在白名单**非空**时才生效(`src/debug.rs:232-235`),因此业务白名单未配置时 `chat_id` 可为任意值。**已定位、已文档化、不改代码**:该面默认关闭且需 Bearer,且业务白名单为空时主产品本身即接受任意 chat,沿用同一语义不构成额外泄露面。若日后要收紧,需把语义改为「空白名单则拒绝」,并补单元测试、跑 `GATE-P0`。 + +## 后续(可选项,产品决策后开启) + +- **多账号支持**(`REQ-SINGLE-ACCOUNT`)— 当前单账号由产品决策固定,非阶段目标。 diff --git a/docs/retired.md b/docs/retired.md index ebb4fec..21114ca 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -2,8 +2,8 @@ > 本文只记录**三类**东西:①评估过但没采纳的路线;②真实存在过、后来被删掉的文档;③曾经写进文档、实际上从未存在或从未实现的名字。 > 目的是**留原因、防复发**——以后看到这些名字,能立刻知道该用什么。 -> 当前真实实现与可核对事实见 `docs/reference.md`;缺口与阻塞见 `docs/roadmap.md`;当前设计见 `docs/design.md`。 -> 本文**没有行动项**。若某条变成要做的事,去 `docs/roadmap.md` 登记,本文条目保留为决策依据。 +> 当前真实实现与可核对事实见 `docs/reference.md`;缺口与阻塞见 `docs/opengaps.md`;当前设计见 `docs/design.md`。 +> 本文**没有行动项**。若某条变成要做的事,去 `docs/opengaps.md` 登记,本文条目保留为决策依据。 > 本文**不引用任何不存在的路径**:凡路径,一律指向仓库中真实存在的文件;曾被写进文档但从未落地的路径只以文字描述其形态、不逐字复述,以免又被抄回正文或被人当成真实文件去查阅。 > 验证基线:`b2dbe7c`。 @@ -19,7 +19,7 @@ | `grammers` / 旧 `telegram-bot` crate | 未采用 | 直接封 Bot API + 自研即可满足需求 | `src/channel.rs` | | `BotError::Telegram(#[from] teloxide::errors::RequestError)` | 目标形态变体,未落地 | 依附 teloxide,随其废弃 | 无 | | `BotError::Jmap` / `Storage` / `RateLimited` / `Unauthorized` / `Llm` | 目标形态变体,未落地 | 当时为统一承接 JMAP/Redis/限流/授权/LLM 五类错误而设计;现已由各模块自行处理 | 当前 `BotError` 仅 `Config` / `Io` / `Json` / `State` 四变体(`src/error.rs` 全文) | -| Telegram 服务端 30 msg/s 限流桶 + 429 专用分支 | 目标设计,未实现 | 无真实 Bot 压测数据,不预设实现 | `src/channel.rs` 的 `max_retries`(默认 3,硬上限 5)通用重试;缺口见 `docs/roadmap.md` | +| Telegram 服务端 30 msg/s 限流桶 + 429 专用分支 | 目标设计,未实现 | 无真实 Bot 压测数据,不预设实现 | `src/channel.rs` 的 `max_retries`(默认 3,硬上限 5)通用重试;缺口见 `docs/opengaps.md` | | 多步对话 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`) | 目标设计,未落地 | 当前 AI 授权只需一个布尔+过期时间,多步态属过度设计 | Redis TTL 授权态,键与 TTL 见 `docs/reference.md` 的 AI 授权态一节 | | 把领域 / 渠道 / 通知 / util 各层拆成独立目录(子模块拆分方案) | 目标形态目录,未落地 | 代码量未到需要拆分的规模 | 实际结构见 `docs/design.md` 的工程结构一节:领域层是 `src/domain.rs` + `src/domain/jmap/`(仅 `client.rs`);LLM 在 `src/ai.rs`;通知在 `src/notify.rs`;加密与工具逻辑内联在 `src/state.rs` / `src/config.rs`,没有独立的 util 层,也没有集成测试目录 | | teloxide 风格的出站消息派发集成测试 | 目标形态测试,未落地 | 依附 teloxide 测试模式 | `src/channel.rs` 的单元测试(`#[test]`) | @@ -78,7 +78,7 @@ | `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:718`) | | `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:898-901`) | | `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 | -| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:331`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:385`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/roadmap.md`「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | +| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:331`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:385`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | --- @@ -106,8 +106,8 @@ | 条目 | 类型 | 原因 | 替代或现状 | |---|---|---|---| -| `docs/todo.md` | 已取代(已删除) | 结构不清,与 design/deployment 重叠,且曾承载「本轮已收口」这类历史叙述 | `docs/roadmap.md`(只放缺口、阻塞、阶段目标、决策待定、验收待办) | -| 根目录临时交接件(不留此类文件) | 临时交接件,**从未进入 git 历史** | 交接内容应归位到常驻文档,不留根目录临时件 | 内容并入 `docs/design.md`、`docs/deployment.md`、`docs/reference.md`、`docs/roadmap.md` | +| `docs/todo.md` | 已取代(已删除) | 结构不清,与 design/deployment 重叠,且曾承载「本轮已收口」这类历史叙述 | 先由 `docs/roadmap.md` 取代,后者于 2026-09-29 更名并收窄为 `docs/opengaps.md`(只放仍未完成/未验证的缺口、阻塞与下一阶段目标) | +| 根目录临时交接件(不留此类文件) | 临时交接件,**从未进入 git 历史** | 交接内容应归位到常驻文档,不留根目录临时件 | 内容并入 `docs/design.md`、`docs/deployment.md`、`docs/reference.md`、`docs/opengaps.md` | --- @@ -154,6 +154,6 @@ ## 5. 如何重启其中一条 -1. 先在 `docs/roadmap.md` 登记为缺口并归属阶段。 +1. 先在 `docs/opengaps.md` 登记为缺口并归属阶段。 2. 若涉及 Redis 键、TTL、HTTP 路由或默认值,同轮更新 `docs/reference.md`。 3. 本文对应条目**保留**为决策依据——它记录的是当初为什么不这么做,不要删。 diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index f941c2d..0000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,33 +0,0 @@ -# MessageWeave — Roadmap - -> 只放**缺口**、**阻塞**与**阶段目标**。已实现能力见 `README.md`、`docs/design.md`、`docs/deployment.md`; -> 可核对的事实(Redis 键、TTL、错误码、路由)见 `docs/reference.md`。 -> 验证基线:`b2dbe7c`。门禁命令见 `docs/deployment.md` 的 Gate 一节;当前 `GATE-P0` 全绿(61 passed / 0 failed / 1 ignored)。 - -## 阶段目标 - -`GATE-P0` 已过(fmt / clippy / check / test,Debian 容器内)。下一阶段目标: -在真实邮箱 + 真实 Telegram Bot 上完成端到端联调,消掉下面的阻塞项。 - -## 阻塞(需要真实环境,当前无法验证) - -- **真实 Stalwart PushSubscription** — `Email/changes` 与 `Email/query` 搜索已在真实账号跑通(2026-09-28:`POST /reconcile` 持久化了 `baseline:` 游标,而该游标只在 `reconcile()` 返回 `Ok(new_state)` 之后写入(`notify.rs:331`),即 `fetchChanges` 端到端成功)。**仍未验证**:`PushSubscription` 路径。**阻塞原因**:无真实推送回调可观察。禁止使用测试账号或伪造结果。 -- **真实 Telegram Push 回调(Telegram→本服务方向)** — 应用侧入站 secret 头校验 + chat 白名单(`notify.rs:225-263`)、出站 `sendMessage`(`channel.rs`)、**出站重试预算与重试耗尽终局错误(`channel.rs:182`)** 已在真实 Bot 上验证(2026-09-28:`POST /debug/notify` 投递成功并由用户确认收到;伪造 update 全链路「入队→worker→JMAP 查询→回帖」通过)。**仍未验证**:真实的 Telegram→本服务回调从未到达——向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现,**目前尚未执行**,因此 Telegram 侧既不知道本服务的 webhook URL,也不持有 secret 值。另:长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:需在 Telegram 侧执行 `setWebhook` 并触发一次真实回调。 -- **真实 Redis TLS 连接** — `rediss://` 握手与密码特殊字符 URL 编码未实测。**阻塞原因**:无托管 Redis 实例。注:`CONFIG_ENCRYPTION_KEY` 与 `REDIS_URL` 只在启动时读取(`src/config.rs:17`、`src/main.rs:54`),本系统不存在这两项的热更新路径,也不在路线图上,因此不列入待验证项。 -- **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批;2026-09-28 在真实账号上跑通 `fetchChanges` 并拿到 `baseline:` 游标,但 `newState` 是否表示"全部待报变更之后"仍需**停机积压后恢复**的场景才能判定,否则积压边界仍无法确证。**阻塞原因**:无可控的真实积压场景。 -- **Push callback 公网映射** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。 - -## 代码缺口(已定位) - -前 3 条已实现(见各自 commit),仅 `SAF-DEBUG-ALLOWLIST` 按产品决策保留不改。真正的阻塞是真实环境依赖,见下文「阻塞」区。 - -- **`/search` + `SearchSnippet/get` 高亮(已完成,`bfe0fd8`)** — `Intent::Search` 变体 + `JmapService::search_emails`(`email_query`(`Filter::text`) 取 ID + `SearchSnippet/get` 取高亮);高亮经 `strip_mark_tags`/`unescape_html_entities` 渲染成 Telegram 纯文本;snippet 不支持时降级为纯 ID 列表;`subject`/`preview` 有 120/160 字符截断上限;每条命中带 `email_id` 可直接接 `/summary`。设计见 `docs/design.md` §2.3、§3.2、§5.7。 -- **Telegram 429 退避(已完成,`f4cae00`)** — `src/channel.rs` 的 `max_retries`(默认 3,硬上限 5)现在按 429 的 `Retry-After` 退避,缺该头时指数退避。 -- **多实例重复投递窗口(已完成,`6c99ce5`)** — XAUTOCLAIM 空闲阈值不再按固定 300s 单条上限缩放,改为按运行配置推导:`(max_retries + 1) × (jmap + telegram + llm 超时) × 2` 为单条上限、再乘批大小,下限 300s、上限 6h(完整公式见 `docs/reference.md` §6.3)。单实例与多实例部署下的提前认领窗口关闭。 -- **`/debug/notify` 空白名单不拦截**(`SAF-DEBUG-ALLOWLIST`)— 白名单校验只在白名单**非空**时才生效(`src/debug.rs:232-235`),因此业务白名单未配置时 `chat_id` 可为任意值。**已定位、已文档化、本轮不改代码**:该面默认关闭且需 Bearer,且业务白名单为空时主产品本身即接受任意 chat,沿用同一语义不构成额外泄露面。若日后要收紧,需把语义改为「空白名单则拒绝」,并补单元测试、跑 `GATE-P0`。 - -## 验收待办 - -- **真实平台日志采集验收** — 容器不写日志文件、只走 stdout 已由代码审查确认(`C-LOG-STDOUT-ONLY`);结构化日志字段与脱敏已由代码审查确认(`SAF-LOG-PURITY`)。未验证项:真实平台采集器是否落盘、是否可检索。 -- **`/ready` 端到端依赖探测(已完成)** — JMAP session `GET` 与 Telegram `getMe` 探针已接入 `/ready`(`src/notify.rs` 的 `probe_jmap_session` / `probe_telegram_get_me`,各 `PROBE_TIMEOUT` 3000ms、**并行**(`tokio::join!`),最坏约 3s):配置完整性 + Redis 可达性 + 两个上游全过返 `200` 与就绪报告,任一失败返 `503` + `Retry-After: 30`。两个探针是可复用纯函数,④ 远程 debug 直接复用同一实现,**不要另写一套**。`ARCH-READY-BASELINE` 语义已更新为端到端就绪。剩余的真实环境验证见上面「真实 Stalwart PushSubscription」与「真实 Telegram Push 回调(Telegram→本服务方向)」。 -- **JMAP 只读 adapter 真机验证**(`GATE-G1-JMAP-READONLY`)— 代码已实现(mock + `#[ignore]` 真机测试),待 `cargo test -- --ignored jmap::`。 diff --git a/scripts/docs_check/audit_anchors.py b/scripts/docs_check/audit_anchors.py index 3fce8d7..2d7cc3d 100755 --- a/scripts/docs_check/audit_anchors.py +++ b/scripts/docs_check/audit_anchors.py @@ -43,7 +43,7 @@ ROOT = Path(__file__).resolve().parents[2] DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', - 'docs/roadmap.md', 'docs/charter.md', 'cloudflare-worker/README.md'] + 'docs/opengaps.md', 'docs/charter.md', 'cloudflare-worker/README.md'] # `Ident` or `Module::Ident` inside backticks, optional () after. IDENT = r'[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?' diff --git a/scripts/docs_check/check_sec_refs.py b/scripts/docs_check/check_sec_refs.py index 8eda2b8..6916d64 100755 --- a/scripts/docs_check/check_sec_refs.py +++ b/scripts/docs_check/check_sec_refs.py @@ -15,7 +15,7 @@ import sys ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) DOCS = ["AGENTS.md", "README.md", "README.zh-CN.md", "docs/design.md", "docs/deployment.md", "docs/reference.md", "docs/retired.md", - "docs/roadmap.md", "docs/charter.md", "cloudflare-worker/README.md"] + "docs/opengaps.md", "docs/charter.md", "cloudflare-worker/README.md"] HEADING = re.compile(r"^(#{2,4})\s+(\d+(?:\.\d+)?\.?)\s") # §2.3, §5.7, §3 (bare), also `§2.3/§3.4` chains handled by finditer @@ -23,7 +23,7 @@ SEC = re.compile(r"§(\d+(?:\.\d+)?)") # an explicit target file sitting immediately before the §ref TARGET_BEFORE = re.compile( r"(?:docs/)?(AGENTS\.md|README\.md|README\.zh-CN\.md|design\.md|" - r"deployment\.md|reference\.md|retired\.md|roadmap\.md|charter\.md)\s*[`\s]*" + r"deployment\.md|reference\.md|retired\.md|opengaps\.md|charter\.md)\s*[`\s]*" r"§(\d+(?:\.\d+)?)") diff --git a/scripts/docs_check/check_tables.py b/scripts/docs_check/check_tables.py index 25f8db6..24b7ac9 100755 --- a/scripts/docs_check/check_tables.py +++ b/scripts/docs_check/check_tables.py @@ -4,7 +4,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', - 'docs/roadmap.md', 'docs/charter.md', 'cloudflare-worker/README.md'] + 'docs/opengaps.md', 'docs/charter.md', 'cloudflare-worker/README.md'] CODE = re.compile(r'`[^`\n]*`') SEP = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$') diff --git a/scripts/docs_check/validate_docs.py b/scripts/docs_check/validate_docs.py index 500ba48..3a1c5b0 100755 --- a/scripts/docs_check/validate_docs.py +++ b/scripts/docs_check/validate_docs.py @@ -3,7 +3,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', - 'docs/roadmap.md', 'docs/charter.md', 'cloudflare-worker/README.md'] + 'docs/opengaps.md', 'docs/charter.md', 'cloudflare-worker/README.md'] errors = [] def slug(t):