feat: initial MessageWeave release
This commit is contained in:
commit
dceecaf2f4
|
|
@ -0,0 +1,10 @@
|
||||||
|
target/
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
Dockerfile*
|
||||||
|
docs/
|
||||||
|
AGENTS.md
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# stalwart-bot production bootstrap (C-REDIS-ONLY-STATE)
|
||||||
|
#
|
||||||
|
# The process requires only this environment variable. Redis ACL credentials are
|
||||||
|
# both the application connection credential and the one-shot bootstrap trust root.
|
||||||
|
# Never commit or log a real URL/password.
|
||||||
|
REDIS_URL=redis://:REPLACE_WITH_REDIS_ACL_PASSWORD@redis.example.com:6379/0
|
||||||
|
# Required 32-byte random hex key (64 hex characters). Never store in Redis or logs.
|
||||||
|
CONFIG_ENCRYPTION_KEY=REPLACE_WITH_64_HEX_CHAR_RANDOM_KEY
|
||||||
|
|
||||||
|
# PORT/RUN_MODE are optional platform overrides (defaults: 8080/webhook). Business
|
||||||
|
# secrets (Telegram/JMAP/LLM/auth) must be supplied once via HTTPS /api/bootstrap;
|
||||||
|
# they are then persisted in Redis and are never echoed by the API.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: rust:1-slim-bookworm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# rust:1-slim-bookworm exposes rustup under /usr/local/cargo/bin but does
|
||||||
|
# not guarantee that directory is in a non-login shell's PATH.
|
||||||
|
- name: Install Rust toolchain components
|
||||||
|
run: |
|
||||||
|
export PATH="/usr/local/cargo/bin:$PATH"
|
||||||
|
rustup toolchain install stable --profile minimal
|
||||||
|
rustup default stable
|
||||||
|
rustup component add rustfmt clippy
|
||||||
|
- name: Format check
|
||||||
|
run: export PATH="/usr/local/cargo/bin:$PATH"; cargo fmt --all -- --check
|
||||||
|
- name: Clippy
|
||||||
|
run: export PATH="/usr/local/cargo/bin:$PATH"; cargo clippy --all-targets -- -D warnings
|
||||||
|
- name: Tests
|
||||||
|
run: export PATH="/usr/local/cargo/bin:$PATH"; cargo test
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/target/
|
||||||
|
/Cargo.lock
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,25 @@
|
||||||
|
[package]
|
||||||
|
name = "stalwart-bot"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
async-trait = "0.1"
|
||||||
|
axum = "0.8"
|
||||||
|
# Read-only HTTP JMAP only; keep WebSocket/EventSource out of the dependency graph.
|
||||||
|
jmap-client = { version = "=0.4.2", default-features = false, features = ["async", "rustls"] }
|
||||||
|
redis = { version = "0.27", default-features = false, features = ["aio", "tokio-comp", "streams", "script"] }
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
|
||||||
|
ring = "0.17.14"
|
||||||
|
secrecy = "0.8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
subtle = "2"
|
||||||
|
thiserror = "1"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
url = "2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
# C-DEBIAN-SLIM: Debian builder and runtime; no Alpine/musl dependencies.
|
||||||
|
FROM rust:1-slim-bookworm AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY web ./web
|
||||||
|
RUN cargo build --release --locked
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install --no-install-recommends -y ca-certificates tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd --system --create-home --home-dir /nonroot --shell /usr/sbin/nologin stalwart-bot
|
||||||
|
COPY --from=builder /build/target/release/stalwart-bot /usr/local/bin/stalwart-bot
|
||||||
|
USER stalwart-bot
|
||||||
|
ENV PORT=8080 \
|
||||||
|
RUN_MODE=webhook
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/stalwart-bot"]
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
# stalwart-bot
|
||||||
|
|
||||||
|
一个把 **Stalwart 邮箱**接入 **Telegram** 的个人邮件助手:新邮件来了在 Telegram 提醒你,还能按需查看/总结邮件内容。
|
||||||
|
|
||||||
|
> ⚠️ **当前仍属早期阶段,还不能真正使用。**
|
||||||
|
> 目前只有底层功能骨架;**真实邮箱的联调还没验证**。仓库已提供 Debian Dockerfile,但仍不建议生产部署。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 这个项目是什么
|
||||||
|
|
||||||
|
- 你在自己的服务器上运行 [Stalwart](https://stalwart.dev) 邮箱。
|
||||||
|
- 用本工具把邮箱接到 Telegram 机器人:收到邮件时推送通知(只含发件人/主题/时间,不含正文)。
|
||||||
|
- 你可以在 Telegram 里要求查看某封邮件的原文、或让 AI 帮忙总结/翻译。
|
||||||
|
|
||||||
|
### 隐私原则
|
||||||
|
- **查看原文不会经过任何 AI**,直接读邮箱。
|
||||||
|
- **只有在你明确要求并确认后**,才把正文发给 AI 分析。
|
||||||
|
- 分析结果不会被保存。
|
||||||
|
- AI 可以完全不配置、完全关闭。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署前,你需要准备这几样东西
|
||||||
|
|
||||||
|
1. **一个 Stalwart 邮箱账户**,并为其生成一个 **应用专用密码(App Password)**,而不是账户主密码。
|
||||||
|
2. **一个 Telegram 机器人**(通过 @BotFather 创建),拿到它的 **Bot Token**。
|
||||||
|
3. **一个 Redis 数据库**(你托管,建议开启 AOF 持久化)。机器人所有状态都放在这里。
|
||||||
|
4. **一个公网 HTTPS 地址**。平台会把这个地址指向机器人容器(HTTPS 由平台自动提供)。
|
||||||
|
5. **一个定时任务(外部 Cron / 调度器)**:每隔 5–10 分钟访问一次 `/reconcile` 地址,让机器人定期和邮箱对账。机器人自己不会设置定时器。
|
||||||
|
|
||||||
|
### 需要登记的地址
|
||||||
|
- 告诉 Telegram:把 webhook 指向 `你的公网地址/webhook/tg`
|
||||||
|
- 告诉 Stalwart:把 Push 回调指向 `你的公网地址/push/jmap`
|
||||||
|
|
||||||
|
> 如果你使用「多实例 + Cloudflare Worker 负载均衡」(可选,见 `docs/deployment.md` §10),则把上面三个 URL 指向 **Worker 的稳定域名**(`https://<your-worker>.workers.dev/webhook/tg` 等),不再指向各后端的平台地址。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 在 Docker 管理器中创建容器
|
||||||
|
|
||||||
|
创建一个新容器,填好镜像、端口、内存等常规项后,只需注入 Redis 连接凭据:
|
||||||
|
|
||||||
|
| 环境变量 | 必须? | 填什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| `REDIS_URL` | ✔ | Redis 连接串(含密码),例如 `redis://:密码@redis.example.com:6379/0` |
|
||||||
|
|
||||||
|
首次启动后,通过受保护的 HTTPS `/api/bootstrap` 提交 Telegram/JMAP/LLM 和业务鉴权配置。配置密钥写入 Redis,API 永不回显;管理员 PUT 成功后会校验并原子热重建客户端,后续请求即时生效,失败保留旧实例。
|
||||||
|
|
||||||
|
AI 摘要默认关闭。Telegram 用户可发送 `/ai on`(1小时)、`临时一次`、`1小时`、`今天`、`7天` 或 `直到我撤销` 明确授权,再发送 `/summary <email_id>`;授权到期不会自动续期,`/ai off` 立即撤销。未授权时仅返回邮件元数据,LLM 失败则回退为本地截断摘要。
|
||||||
|
|
||||||
|
> Redis 未初始化时服务进入 configuration-only 模式,仅提供 SPA、bootstrap 和探针。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 启动后怎么看它是否正常
|
||||||
|
|
||||||
|
- 浏览器或命令行访问 **`你的公网地址/healthz`**:能返回正常状态,说明机器人进程活着。
|
||||||
|
- 目前 `/ready` 只是占位,**还不能用它判断邮箱/Redis 是否正常**(这个检查以后才会补上)。
|
||||||
|
|
||||||
|
## 配置管理
|
||||||
|
|
||||||
|
打开服务根地址 **`你的公网地址/`**,输入 `REDIS_URL` 中 Redis ACL 用户的密码,创建有效期 15 分钟的管理会话。会话与表单中的密钥只保存在当前页面内存;刷新或关闭页面后需要重新授权。页面不使用浏览器存储或 Cookie 保存会话、密钥和配置。
|
||||||
|
|
||||||
|
页面分开管理两类配置:运行参数(JMAP、Telegram、LLM 请求超时与最大重试次数)可通过 API 读取和保存;业务配置(JMAP、Telegram、LLM、chat allowlist 与各鉴权 token)由 `PUT /api/business-config` 完整替换。为避免密钥回显,服务端没有业务配置读取接口,因此每次完整替换都必须重新输入必填项。保存收到 HTTP 204 后页面会提示新配置已热加载,并清空密钥输入框。
|
||||||
|
|
||||||
|
Redis 尚未连接或初始化时,管理会话或配置 API 会返回 `503`;页面会说明不可用状态,并在运行参数读取成功前禁用其保存。经 Cloudflare Worker 访问时使用 Worker 根地址;管理 API 路径也已纳入受限路由白名单。
|
||||||
|
|
||||||
|
## 隐私与运维红线(对用户透明)
|
||||||
|
- **无数据库、无本地文件**:机器人所有状态都放在你的 Redis 里;容器自身不写任何本地文件、不挂本地卷,重启后由 Redis + 邮箱对账恢复。
|
||||||
|
- **日志只走标准输出**:容器只往 stdout/stderr 打印日志,由你的平台/采集器负责收集与落盘;机器人自己**不写日志文件**。
|
||||||
|
- **日志里不会写敏感内容**:邮箱正文、密钥、AI 请求/响应、附件内容**都不会**出现在日志或 Redis 里,只记录结构化事件、计数、时间戳、脱敏摘要。
|
||||||
|
- 详见 `docs/deployment.md` §0 与 §8.2 的检查清单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目前能做到什么 / 还差什么
|
||||||
|
|
||||||
|
**已经有了**
|
||||||
|
- 入口框架、三处安全鉴权、配置加载。
|
||||||
|
- 配置管理 SPA:使用短期 Redis admin session;可编辑完整业务配置并热加载,也可读取和保存 `/api/config` 运行参数。
|
||||||
|
- 读取 Stalwart 邮箱的基础代码(列文件夹、列邮件、读原文)。
|
||||||
|
- **可选**:一个免费 Cloudflare Worker 前置负载均衡(多实例 HA),见 `docs/deployment.md` §10 与 `cloudflare-worker/` 子目录。
|
||||||
|
|
||||||
|
**还差 / 尚未验证**
|
||||||
|
- ⚠️ **真实邮箱联调没做**:连真实 Stalwart 的测试因还没有测试环境而跳过,**没有宣称“真机通过”**。
|
||||||
|
- **Dockerfile 已提供**(Debian slim 多阶段构建);真实邮箱联调仍未验证,不能按上面的“创建容器”用于生产。
|
||||||
|
- Telegram 通知推送、AI 总结、发送邮件等功能**都还没做**。
|
||||||
|
|
||||||
|
所以:**现在还没有可以下载使用的镜像,请自行构建并等待后续版本。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 给开发/技术人员
|
||||||
|
|
||||||
|
架构、设计、部署细节见:
|
||||||
|
|
||||||
|
- [架构与设计](docs/design.md)
|
||||||
|
- [部署与运维](docs/deployment.md)
|
||||||
|
- [给 AI 编码助手的规则](AGENTS.md)
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
(待定)
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
.build-check/
|
||||||
|
.wrangler/
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Stalwart-bot Cloudflare Worker(ARCH-LB-WORKER)
|
||||||
|
|
||||||
|
统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。
|
||||||
|
**透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。
|
||||||
|
|
||||||
|
> 设计依据:`docs/design.md §10 / NFR-HA-MULTI-INSTANCE`、`docs/deployment.md §10`。
|
||||||
|
> 安全基线:`AGENTS.md §2/§4`。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- `wrangler.toml` — Wrangler 示例配置(name/main/compat/vars;后端 secret 走 `wrangler secret`)
|
||||||
|
- `src/index.js` — Worker 入口(路由分发、/healthz 聚合、safelist 校验)
|
||||||
|
- `src/backends.js` — origin 解析 + https/凭据/query 校验(C-HTTPS-INBOUND)
|
||||||
|
- `src/proxy.js` — 透传转发 + 有界故障转移(SAF-LB-PASSTHRU)
|
||||||
|
- `src/health.js` — 健康聚合探针(MOD-HEALTH-AGG / C-NO-DB)
|
||||||
|
- `test/*.test.js` — 单元/集成测试(零依赖,`node --test`)
|
||||||
|
- `package.json` — npm 元数据(`dev`/`deploy` 调 wrangler;`test` 纯 node)
|
||||||
|
|
||||||
|
## 零依赖设计
|
||||||
|
|
||||||
|
运行时仅依赖 ES2022 标准 + 平台/Node 的 `fetch`/`Headers`/`Request`/`Response`/`URL`。
|
||||||
|
`npm run test` 直接 `node --test test/`,**无需安装**任何生产依赖;
|
||||||
|
`wrangler` 仅在 `check`/`deploy`/`dev` 中作 devDependency(CI 可选装)。
|
||||||
|
|
||||||
|
## 语义
|
||||||
|
|
||||||
|
### 透传(A,SAF-LB-PASSTHRU)
|
||||||
|
- 原样转发 method / headers(含鉴权头)/ body;不鉴权改写。
|
||||||
|
- 所有后端实例共享同一组 secret(`C-LB-SHARED-SECRETS`):同一 `TELEGRAM_WEBHOOK_SECRET`、
|
||||||
|
`JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 §10 仅用于多实例互斥,非鉴权)
|
||||||
|
+ 相同 App Password(各后端实例的 `JMAP` 可不同,§10.2)。
|
||||||
|
|
||||||
|
### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL)
|
||||||
|
- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`。
|
||||||
|
- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。
|
||||||
|
- method 不符 → **405**。
|
||||||
|
|
||||||
|
### 有界故障转移(§10.4,deployment.md)
|
||||||
|
- 每次请求只试 `min(LB_MAX_ATTEMPTS, origins.length)` 次;默认 2(首次 + 1 次换实例)。
|
||||||
|
- **仅** 超时(AbortError)或 5xx 触发换下一个实例;4xx / 2xx / 3xx 直接返回。
|
||||||
|
- 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。
|
||||||
|
- 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。
|
||||||
|
- 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz` 聚合视图;转发目前**不做健康路由过滤**(§10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。
|
||||||
|
|
||||||
|
### 健康聚合(MOD-HEALTH-AGG;C-NO-DB / C-REDIS-ONLY-STATE)
|
||||||
|
- `GET /healthz`(由 Worker 自身承载):按 TTL 缓存(默认 30s)探测各后端 `/healthz`,
|
||||||
|
返回 `{status: ok|down, available, total, backends:[{origin, up, status}]}`;
|
||||||
|
有 ≥1 后端 up → 200;全部 down → 503。
|
||||||
|
- **不代理 Redis/JMAP**:Worker 不做数据库侧检查;后端 `GATE-READY-DEPS` 由 `/ready`(透传)承担。
|
||||||
|
|
||||||
|
### 无长连接 / 无密钥日志(§4 红线)
|
||||||
|
- 不使用 WebSocket/SSE/长轮询(`C-NO-LONG-CONN`):纯请求-响应转发,body 一次性 `arrayBuffer` 回灌。
|
||||||
|
- 日志只打「方法 / 路径 / origin / 失败类别」;**绝不**打印 header / body / 鉴权 secret / App Password(`SAF-LOG-PURITY`)。
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `BACKEND_ORIGINS_JSON` | 是(secret) | JSON 数组,形如 `["https://a.platform1.example","https://b.platform2.example"]`;必须全部 https、无内嵌凭据/query/fragment/路径。 |
|
||||||
|
| `LB_REQUEST_TIMEOUT_MS` | 否 | 单 origin 请求超时;默认 `10000`。建议 > 最坏 cold start + 最长 JMAP 拉取。 |
|
||||||
|
| `LB_MAX_ATTEMPTS` | 否 | 每请求最多 origin 尝试次数;默认 `2`(= 1 次故障转移)。 |
|
||||||
|
| `LB_HEALTH_TTL_MS` | 否 | 健康探测缓存 TTL;默认 `30000`。 |
|
||||||
|
|
||||||
|
> secret 通过 `wrangler secret put BACKEND_ORIGINS_JSON` 注入;`wrangler.toml` 里 `vars` 保持为空,不写明文。
|
||||||
|
|
||||||
|
## 本地 / CI 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cloudflare-worker
|
||||||
|
|
||||||
|
# 单元/集成测试(零依赖):
|
||||||
|
npm run test
|
||||||
|
# 等价:node --test test/
|
||||||
|
|
||||||
|
# 语法检查 + wrangler dry-run(需 npx wrangler,会拉 devDependency):
|
||||||
|
npm run check
|
||||||
|
# 等价:node --check src/*.js && node --check test/*.test.js && wrangler deploy --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cloudflare-worker
|
||||||
|
# 安装 dev dep(仅 wrangler):
|
||||||
|
npm install
|
||||||
|
# 注入 secret:
|
||||||
|
npx wrangler secret put BACKEND_ORIGINS_JSON # 粘贴 ["https://a.example","https://b.example"]
|
||||||
|
# 可选调参:
|
||||||
|
npx wrangler secret put LB_REQUEST_TIMEOUT_MS # 建议 10000
|
||||||
|
npx wrangler secret put LB_MAX_ATTEMPTS # 默认 2
|
||||||
|
# 部署:
|
||||||
|
npx wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
健康探针:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://lb.stalwart-bot.example/healthz
|
||||||
|
# => {"status":"ok","available":2,"total":2,"backends":[{"origin":"https://a.example","up":true,"status":200},...]}
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "@stalwart-bot/lb-worker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Stalwart-bot Cloudflare Worker: 统一 HTTPS 入口 + 多后端 origin 故障转移(透传模型,SAF-LB-PASSTHRU)。零运行时依赖(仅 ES2022 + Fetch API),可直接 node --test。",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/",
|
||||||
|
"check": "node --check src/*.js && node --check test/*.test.js && wrangler deploy --dry-run --outdir=.build-check",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"dev": "wrangler dev --local"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^3.57.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
/**
|
||||||
|
* origin 配置解析 + 校验(稳定 ID:C-HTTPS-INBOUND / SAF-LB-PASSTHRU / C-NO-DB)。
|
||||||
|
* 后端 origin 仅允许 https(C-HTTPS-INBOUND);不代理 Redis/JMAP(C-NO-DB)。
|
||||||
|
* 仅 ES2022,零依赖,可 node 直接执行。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 允许透传的入站路由 safelist(C-LB-SINGLE-REG-URL;/healthz 由 Worker 自身聚合,不在列):未知路径 → 404。
|
||||||
|
* SPA 静态资源为公开 GET;所有管理 API 仍由后端校验 Redis bootstrap 凭据或 admin session。 */
|
||||||
|
export const SAFE_ROUTES = Object.freeze([
|
||||||
|
"/",
|
||||||
|
"/assets/config.js",
|
||||||
|
"/assets/styles.css",
|
||||||
|
"/api/config",
|
||||||
|
"/api/business-config",
|
||||||
|
"/api/admin/session",
|
||||||
|
"/api/admin/session/revoke",
|
||||||
|
"/webhook/tg",
|
||||||
|
"/push/jmap",
|
||||||
|
"/reconcile",
|
||||||
|
"/ready",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 需要透传到后端 origin 的路由(不含 Worker 聚合端点)。 */
|
||||||
|
export const PROXIED_ROUTES = Object.freeze([...SAFE_ROUTES]);
|
||||||
|
|
||||||
|
export class BackendConfigError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = "BackendConfigError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HTTP_STATUS = 80;
|
||||||
|
const HTTPS_STATUS = 443;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验并归一化单个 backend origin。
|
||||||
|
* 规则(C-HTTPS-INBOUND,对齐 §10 的 fail-closed):
|
||||||
|
* - 必须是 https;
|
||||||
|
* - 必须无内嵌凭据(禁 user:pass@host);
|
||||||
|
* - 必须无 query / fragment(origin 不应带这些);
|
||||||
|
* - 路径必须为空(origin 根),禁止 /path 形式(防止把路由写进 origin)。
|
||||||
|
* @param {string} raw
|
||||||
|
* @returns {{ url: string, host: string, port: number }}
|
||||||
|
*/
|
||||||
|
export function parseBackendOrigin(raw) {
|
||||||
|
if (typeof raw !== "string" || raw.trim() === "") {
|
||||||
|
throw new BackendConfigError("backend origin must be a non-empty https URL");
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw.trim());
|
||||||
|
} catch {
|
||||||
|
throw new BackendConfigError(`backend origin is not a valid URL: ${raw}`);
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "https:") {
|
||||||
|
throw new BackendConfigError(
|
||||||
|
`backend origin must use https (C-HTTPS-INBOUND): ${raw}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parsed.username !== "" || parsed.password !== "") {
|
||||||
|
throw new BackendConfigError(
|
||||||
|
`backend origin must not embed credentials (C-HTTPS-INBOUND): ${parsed.host}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parsed.search !== "" || parsed.hash !== "") {
|
||||||
|
throw new BackendConfigError(
|
||||||
|
`backend origin must not contain query/fragment: ${parsed.host}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parsed.pathname !== "" && parsed.pathname !== "/") {
|
||||||
|
throw new BackendConfigError(
|
||||||
|
`backend origin must be a bare origin (no path): ${raw}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 记录端口以便健康探测/请求拼 URL;缺省 443。
|
||||||
|
const port = parsed.port !== "" ? Number(parsed.port) : HTTPS_STATUS;
|
||||||
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||||
|
throw new BackendConfigError(`backend origin has an invalid port: ${raw}`);
|
||||||
|
}
|
||||||
|
return { url: parsed.origin, host: parsed.hostname, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 `BACKEND_ORIGINS_JSON` 环境变量:JSON 字符串数组。
|
||||||
|
* 全部校验通过后返回去重(保持顺序)的 origins;无有效条目则报错。
|
||||||
|
* @param {unknown} raw 可能为 JSON 字符串或已解析数组
|
||||||
|
* @returns {Array<{url:string, host:string, port:number, source:string}>}
|
||||||
|
*/
|
||||||
|
export function parseBackendOrigins(raw) {
|
||||||
|
let list;
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
list = raw;
|
||||||
|
} else if (typeof raw === "string") {
|
||||||
|
try {
|
||||||
|
list = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
throw new BackendConfigError("BACKEND_ORIGINS_JSON is not valid JSON");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new BackendConfigError("BACKEND_ORIGINS_JSON must be a JSON array of https origins");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(list) || list.length === 0) {
|
||||||
|
throw new BackendConfigError("BACKEND_ORIGINS_JSON must be a non-empty array");
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const entry of list) {
|
||||||
|
const origin = parseBackendOrigin(entry);
|
||||||
|
if (!seen.has(origin.url)) {
|
||||||
|
seen.add(origin.url);
|
||||||
|
out.push(origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HTTP_STATUS, HTTPS_STATUS };
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* 健康聚合探针(稳定 ID:MOD-HEALTH-AGG │ C-NO-DB │ C-REDIS-ONLY-STATE)。
|
||||||
|
*
|
||||||
|
* 语义:
|
||||||
|
* - Worker 暴露 LB 级 `GET /healthz`,聚合探测各后端 origin 的 `/healthz`(后端公开探针,无敏感信息)。
|
||||||
|
* - 缓存 TTL(默认 30秒)避免健康探测放大;TTL 内复用上次结果,过期后才重探。
|
||||||
|
* - 探测失败/超时 → 该 origin 记为 down;`available` 统计存活数。
|
||||||
|
* - 仅 HTTP 层健康(<500 记为 up):**不**检查/代理 Redis、JMAP(C-NO-DB / C-REDIS-ONLY-STATE),
|
||||||
|
* 就绪依赖检查仍由后端 `/ready`(GATE-READY-DEPS)承担。
|
||||||
|
*
|
||||||
|
* 可测性:fetch / 计时器 / 时钟均依赖注入。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_HEALTH_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聚合探测所有后端 origin 的 `/healthz`。
|
||||||
|
* @param {string[]} origins 已校验的 origin 列表
|
||||||
|
* @param {object} ctx { fetch, timeoutMs, ttlMs?, now?, cache?, setTimeout?, clearTimeout? }
|
||||||
|
* @returns {Promise<{available:number,total:number,backends:Array<{origin:string,up:boolean,status:number|null}>}>}
|
||||||
|
*/
|
||||||
|
export async function aggregateHealth(origins, ctx) {
|
||||||
|
const {
|
||||||
|
fetch,
|
||||||
|
timeoutMs,
|
||||||
|
ttlMs = DEFAULT_HEALTH_TTL_MS,
|
||||||
|
now = () => Date.now(),
|
||||||
|
cache,
|
||||||
|
setTimeout: st = globalThis.setTimeout,
|
||||||
|
clearTimeout: ct = globalThis.clearTimeout,
|
||||||
|
} = ctx;
|
||||||
|
|
||||||
|
const backends = [];
|
||||||
|
let available = 0;
|
||||||
|
|
||||||
|
for (const origin of origins) {
|
||||||
|
const key = `${origin}/healthz`;
|
||||||
|
const cached = cache ? cache.get(key) : undefined;
|
||||||
|
let up = false;
|
||||||
|
let status = null;
|
||||||
|
|
||||||
|
if (cached && ttlMs > 0 && now() - cached.at < ttlMs) {
|
||||||
|
up = cached.up;
|
||||||
|
status = cached.status;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = st(() => controller.abort(), timeoutMs);
|
||||||
|
const resp = await fetch(key, { method: "GET", signal: controller.signal });
|
||||||
|
ct(timer);
|
||||||
|
status = resp.status;
|
||||||
|
up = resp.status < 500;
|
||||||
|
} catch {
|
||||||
|
up = false;
|
||||||
|
status = null;
|
||||||
|
}
|
||||||
|
if (cache) cache.set(key, { at: now(), up, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (up) available += 1;
|
||||||
|
backends.push({ origin, up, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { available, total: origins.length, backends };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装 LB 级 `/healthz` 响应:有 ≥1 后端存活 → 200;全 down → 503;无配置 → 200 + 明确标记。
|
||||||
|
* @param {{available:number,total:number,backends:unknown[]}} summary
|
||||||
|
* @returns {Response}
|
||||||
|
*/
|
||||||
|
export function healthResponse(summary) {
|
||||||
|
const healthy = summary.total > 0 && summary.available > 0;
|
||||||
|
const status =
|
||||||
|
summary.total === 0 ? "no-backends" : healthy ? "ok" : "down";
|
||||||
|
return new Response(JSON.stringify({ status, ...summary }), {
|
||||||
|
status: summary.total === 0 ? 200 : healthy ? 200 : 503,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
/**
|
||||||
|
* Cloudflare Worker 入口 — Stalwart-bot 多实例 LB/HA(ARCH-LB-WORKER / deployment.md §10)。
|
||||||
|
*
|
||||||
|
* 稳定 ID(AGENTS.md §5/§7):
|
||||||
|
* - SAF-LB-PASSTHRU:透传 headers/body(含鉴权头),不做鉴权改写。
|
||||||
|
* - C-HTTPS-INBOUND:仅 https 后端 origin。
|
||||||
|
* - ARCH-LB-WORKER / C-LB-SINGLE-REG-URL:safelist 路由,未知路径 404、method 不符 405。
|
||||||
|
* - MOD-HEALTH-AGG:/healthz 由 Worker 聚合,不落后端。
|
||||||
|
* - C-NO-DB / C-REDIS-ONLY-STATE:不代理 Redis/JMAP;不检查数据库侧可用性。
|
||||||
|
* - C-NO-LONG-CONN:纯请求-响应,body 一次性读入回灌;无 WS/SSE/长轮询。
|
||||||
|
* - C-NO-SECRET-IN-IMAGE:日志仅「方法/路径/origin/失败类别」,不含 secret;origin 清单走 wrangler secret。
|
||||||
|
*
|
||||||
|
* 路由模型:
|
||||||
|
* - GET /、/assets/config.js、/assets/styles.css → 内嵌 SPA 静态资源透传
|
||||||
|
* - GET|PUT /api/config、PUT /api/business-config、POST /api/admin/session[/revoke]
|
||||||
|
* → 透传至后端;后端校验 bootstrap 凭据或短期 admin session
|
||||||
|
* - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG)
|
||||||
|
* - POST /webhook/tg|/push/jmap|/reconcile、GET /ready → 透传 + 有界故障转移
|
||||||
|
* - 其它 → 404 / 405
|
||||||
|
*
|
||||||
|
* 日志红线:仅打「方法 / 路径 / origin / 失败类别」,绝不打印 header/body/secret。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseBackendOrigins, PROXIED_ROUTES } from "./backends.js";
|
||||||
|
import { proxyWithFailover } from "./proxy.js";
|
||||||
|
import { aggregateHealth, healthResponse, DEFAULT_HEALTH_TTL_MS } from "./health.js";
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
|
const DEFAULT_MAX_ATTEMPTS = 2; // 首次 + 1 次故障转移(§10.4 建议)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康探测缓存(模块作用域 = CF Worker isolate 内跨请求复用)。
|
||||||
|
* 仅存「origin → {at,up,status}」,不含任何 header/body/secret(SAF-LOG-PURITY 精神)。
|
||||||
|
* TTL 由 LB_HEALTH_TTL_MS 控制;isolate 冷启时为空,首个 /healthz 全量探测。
|
||||||
|
*/
|
||||||
|
const healthCache = new Map();
|
||||||
|
|
||||||
|
/** 各透传路由允许的 method(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL;其余 405,不透传至后端)。 */
|
||||||
|
const ROUTE_METHODS = Object.freeze({
|
||||||
|
"/": ["GET"],
|
||||||
|
"/assets/config.js": ["GET"],
|
||||||
|
"/assets/styles.css": ["GET"],
|
||||||
|
"/api/config": ["GET", "PUT"],
|
||||||
|
"/api/business-config": ["PUT"],
|
||||||
|
"/api/admin/session": ["POST"],
|
||||||
|
"/api/admin/session/revoke": ["POST"],
|
||||||
|
"/webhook/tg": ["POST"],
|
||||||
|
"/push/jmap": ["POST"],
|
||||||
|
"/reconcile": ["POST"],
|
||||||
|
"/ready": ["GET"],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worker 主处理器(纯函数,便于单测)。
|
||||||
|
* @param {Request} request
|
||||||
|
* @param {object} env { BACKEND_ORIGINS_JSON, LB_REQUEST_TIMEOUT_MS?, LB_MAX_ATTEMPTS? }
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
export async function handleFetch(request, env) {
|
||||||
|
const path = new URL(request.url).pathname;
|
||||||
|
|
||||||
|
// LB 级健康聚合探针(不含敏感信息,SAF-PROBE-PUBLIC 精神)。
|
||||||
|
if (path === "/healthz" && request.method === "GET") {
|
||||||
|
return handleHealth(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
// safelist 路由校验:未知路径 404,不透传至后端(C-LB-SINGLE-REG-URL)。
|
||||||
|
if (!PROXIED_ROUTES.includes(path)) {
|
||||||
|
return text(`route not proxied: ${path}`, 404);
|
||||||
|
}
|
||||||
|
const allowed = ROUTE_METHODS[path];
|
||||||
|
if (!allowed || !allowed.includes(request.method)) {
|
||||||
|
return text(`method not allowed for ${path}`, 405, { allow: allowed?.join(", ") });
|
||||||
|
}
|
||||||
|
|
||||||
|
let origins;
|
||||||
|
try {
|
||||||
|
origins = parseBackendOrigins(env.BACKEND_ORIGINS_JSON).map((o) => o.url);
|
||||||
|
} catch {
|
||||||
|
return text("misconfigured backends", 503); // fail-closed,不泄漏 secret
|
||||||
|
}
|
||||||
|
if (origins.length === 0) {
|
||||||
|
return text("no backends configured", 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
|
||||||
|
const maxAttempts = intFromEnv(env.LB_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS);
|
||||||
|
|
||||||
|
// 透传转发 + 有界故障转移(仅超时/5xx)。
|
||||||
|
return proxyWithFailover(origins, request, {
|
||||||
|
maxAttempts,
|
||||||
|
timeoutMs,
|
||||||
|
fetch: lazyFetch(),
|
||||||
|
setTimeout: globalThis.setTimeout,
|
||||||
|
clearTimeout: globalThis.clearTimeout,
|
||||||
|
rng: Math.random,
|
||||||
|
log: (msg) => {
|
||||||
|
try {
|
||||||
|
console.error(`[lb] ${request.method} ${path}: ${msg}`);
|
||||||
|
} catch {
|
||||||
|
/* 日志不可用不致命 */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 透传路由的统一错误/兜底日志不打印任何 header/body。 */
|
||||||
|
async function handleHealth(env) {
|
||||||
|
let origins;
|
||||||
|
try {
|
||||||
|
origins = parseBackendOrigins(env.BACKEND_ORIGINS_JSON).map((o) => o.url);
|
||||||
|
} catch {
|
||||||
|
return healthResponse({ available: 0, total: 0, backends: [] });
|
||||||
|
}
|
||||||
|
if (origins.length === 0) {
|
||||||
|
return healthResponse({ available: 0, total: 0, backends: [] });
|
||||||
|
}
|
||||||
|
const summary = await aggregateHealth(origins, {
|
||||||
|
fetch: lazyFetch(),
|
||||||
|
timeoutMs: intFromEnv(env.LB_REQUEST_TIMEOUT_MS, DEFAULT_TIMEOUT_MS),
|
||||||
|
ttlMs: intFromEnv(env.LB_HEALTH_TTL_MS, DEFAULT_HEALTH_TTL_MS),
|
||||||
|
cache: healthCache,
|
||||||
|
});
|
||||||
|
return healthResponse(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 惰性绑定 globalThis.fetch,避免解构时丢失 this。 */
|
||||||
|
function lazyFetch() {
|
||||||
|
return (...args) => globalThis.fetch(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(body, status, extraHeaders = {}) {
|
||||||
|
return new Response(body, {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "text/plain; charset=utf-8", ...extraHeaders },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function intFromEnv(raw, fallback) {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||||||
|
return Math.floor(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cloudflare Workers module 入口。 */
|
||||||
|
export default {
|
||||||
|
async fetch(request, env, _ctx) {
|
||||||
|
return handleFetch(request, env);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* 请求转发与有界故障转移(稳定 ID:SAF-LB-PASSTHRU / C-NO-LONG-CONN / C-NO-SECRET-IN-IMAGE / deployment.md §10.4)。
|
||||||
|
*
|
||||||
|
* 语义:
|
||||||
|
* - 透传(A):原样转发 method / headers / body(含鉴权头),不做鉴权改写(SAF-LB-PASSTHRU)。
|
||||||
|
* - 仅对「超时 / 5xx」做有界重试:换下一个 origin 再试(deployment.md §10.4 建议 1 次);
|
||||||
|
* 非 5xx / 非超时直接返回。
|
||||||
|
* - 全失败 → 返回 5xx(交由 Telegram 自动重投,不丢消息)。
|
||||||
|
* - 无 WebSocket/SSE/长轮询(C-NO-LONG-CONN):纯请求-响应,body 一次性读入后回灌。
|
||||||
|
*
|
||||||
|
* 可测性:所有 I/O(fetch/计时器/随机)通过依赖注入传入,便于 mock。
|
||||||
|
* 「无密钥日志」红线(SAF-LOG-PURITY):只打错误类别与 origin(已校验、非敏感),
|
||||||
|
* **不打印** header/body/secret;不产生任何文件写入,日志仅 stdout。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Request} Req
|
||||||
|
* @typedef {Response} Res
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接后端 URL:origin + path + query(保留 query,丢弃 fragment)。
|
||||||
|
* @param {string} origin 已校验的 https origin
|
||||||
|
* @param {Req} request
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function joinUrl(origin, request) {
|
||||||
|
const u = new URL(request.url);
|
||||||
|
return `${origin}${u.pathname}${u.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次性读取请求 body 为可复用的 Uint8Array(供每个故障转移尝试回灌,
|
||||||
|
* 避免 Request.body 流在第二次尝试时被“已消费”而抛 TypeError)。
|
||||||
|
* @param {Req} request
|
||||||
|
* @returns {Promise<{headers: Headers, body: Uint8Array | undefined}>}
|
||||||
|
*/
|
||||||
|
export async function snapshotBody(request) {
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
||||||
|
const body = hasBody ? new Uint8Array(await request.arrayBuffer()) : undefined;
|
||||||
|
return { headers, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否「可故障转移」:5xx 状态码、超时/网络类错误(err)。 */
|
||||||
|
export function isRetryableError(err, status) {
|
||||||
|
if (err) {
|
||||||
|
return isRetryableNetworkError(err);
|
||||||
|
}
|
||||||
|
return status >= 500 && status < 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否「可故障转移」的网络错误:
|
||||||
|
* 超时(AbortError)或 fetch/连接层网络错误(Node: `fetch failed`/`ECONNREFUSED` 等)。
|
||||||
|
* 其它错误(如 body 读取失败、配置错误)**不**触发故障转移,避免无谓重试。
|
||||||
|
*/
|
||||||
|
function isRetryableNetworkError(err) {
|
||||||
|
if (!err) return false;
|
||||||
|
const name = String(err.name || "");
|
||||||
|
const msg = String(err.message || "");
|
||||||
|
if (name === "AbortError") return true;
|
||||||
|
if (/fetch failed|network error|socket hang up|ECONNREFUSED|ECONNRESET|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/i.test(msg)) return true;
|
||||||
|
// 递归 cause 链(Node 的 fetch 网络错误把 errno 藏在 cause)。
|
||||||
|
if (err.cause && err.cause !== err) return isRetryableNetworkError(err.cause);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次 origin 尝试:透传转发。超时/网络错误会 throw(由调用方处理)。
|
||||||
|
* 使用预读的 body 快照(Uint8Array),避免 Request.body 流在第二次尝试时“已消费”。
|
||||||
|
* @param {string} origin
|
||||||
|
* @param {Req} request
|
||||||
|
* @param {object} io { fetch, setTimeout, clearTimeout, timeoutMs }
|
||||||
|
* @param {{headers: Headers, body: Uint8Array | undefined}} snapshot 由 snapshotBody 产出
|
||||||
|
* @returns {Promise<Res>}
|
||||||
|
*/
|
||||||
|
export async function proxyRequest(origin, request, io, snapshot) {
|
||||||
|
const { fetch, setTimeout: st, clearTimeout: ct, timeoutMs } = io;
|
||||||
|
const url = joinUrl(origin, request);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = st(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: request.method,
|
||||||
|
headers: snapshot.headers,
|
||||||
|
body: snapshot.body ? new Uint8Array(snapshot.body) : undefined,
|
||||||
|
redirect: "follow",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return resp;
|
||||||
|
} finally {
|
||||||
|
ct(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有界故障转移:按序尝试 origins(起点随机化实现双活分摊,确定性可注入);
|
||||||
|
* 仅超时/5xx 才换下一个实例;全失败 → 5xx 聚合响应。
|
||||||
|
* @param {string[]} origins 有序 origin 列表(≥1)
|
||||||
|
* @param {Req} request 入站请求
|
||||||
|
* @param {object} opts { maxAttempts, timeoutMs, fetch, setTimeout, clearTimeout, rng?, log? }
|
||||||
|
* @returns {Promise<Res>}
|
||||||
|
*/
|
||||||
|
export async function proxyWithFailover(origins, request, opts) {
|
||||||
|
const { maxAttempts, timeoutMs, fetch, setTimeout, clearTimeout, rng, log } = opts;
|
||||||
|
const start = origins.length > 1 && rng ? Math.floor(rng() * origins.length) : 0;
|
||||||
|
const queue = origins.slice(start).concat(origins.slice(0, start));
|
||||||
|
const attempts = Math.max(1, Math.min(maxAttempts, queue.length));
|
||||||
|
let lastFailure = "no backends configured";
|
||||||
|
// 一次性读 body 快照:让每次故障转移尝试可回灌同一请求体(body 流只能读一次)。
|
||||||
|
const snapshot = await snapshotBody(request);
|
||||||
|
|
||||||
|
for (let i = 0; i < attempts; i += 1) {
|
||||||
|
const origin = queue[i];
|
||||||
|
const io = { fetch, setTimeout, clearTimeout, timeoutMs };
|
||||||
|
try {
|
||||||
|
const resp = await proxyRequest(origin, request, io, snapshot);
|
||||||
|
// 非 5xx / 非网络错误:直接返回(4xx、2xx、3xx 均原样透传,不故障转移)。
|
||||||
|
if (!isRetryableError(null, resp.status)) {
|
||||||
|
return mark(resp, origin);
|
||||||
|
}
|
||||||
|
// 5xx:释放 body,换下一个实例。
|
||||||
|
resp.body?.cancel?.().catch(() => {});
|
||||||
|
lastFailure = `backend ${origin} returned HTTP ${resp.status}`;
|
||||||
|
if (log) log(`retrying ${request.method} ${new URL(request.url).pathname}: ${lastFailure}`);
|
||||||
|
} catch (err) {
|
||||||
|
lastFailure = `backend ${origin} unavailable (${err && err.name ? err.name : "error"})`;
|
||||||
|
if (log) log(lastFailure);
|
||||||
|
// 仅「可故障转移」的网络类失败(超时/连接错误)才换实例;其它错误 fail-fast,避免无限重试。
|
||||||
|
if (!isRetryableError(err, 0)) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 全部 origin 失败 → 5xx(交由 Telegram/Stalwart 自动重投兜底,不丢消息)。
|
||||||
|
return new Response(`all backends failed: ${lastFailure}`, {
|
||||||
|
status: 503,
|
||||||
|
statusText: "All Backends Unavailable",
|
||||||
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在响应上加轻量可观测头(不改 body/状态语义;origin 非敏感,含在响应头中便于排障)。 */
|
||||||
|
function mark(resp, origin) {
|
||||||
|
resp.headers.set("x-lb-backend", origin);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/**
|
||||||
|
* 单元测试:origin configuration 校验(C-HTTPS-INBOUND / C-LB-SINGLE-REG-URL)。
|
||||||
|
* 运行:cd cloudflare-worker && node --test
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseBackendOrigin, parseBackendOrigins, SAFE_ROUTES, PROXIED_ROUTES } from "../src/backends.js";
|
||||||
|
|
||||||
|
test("http origin is rejected (https-only, fail-closed)", () => {
|
||||||
|
assert.throws(() => parseBackendOrigin("http://bot.example"), /https/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-https / invalid origins are rejected", () => {
|
||||||
|
for (const bad of ["", " ", "not a url", "://x", "ws://bot.example", "wss://bot.example", "http://x"]) {
|
||||||
|
assert.throws(() => parseBackendOrigin(bad), Error, `must reject: ${deep(bad)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("embedded credentials / query / fragment / path are rejected", () => {
|
||||||
|
assert.throws(() => parseBackendOrigin("https://user:pass@bot.example"), /credentials/);
|
||||||
|
assert.throws(() => parseBackendOrigin("https://bot.example?x=1"), /query|fragment/);
|
||||||
|
assert.throws(() => parseBackendOrigin("https://bot.example#frag"), /query|fragment/);
|
||||||
|
assert.throws(() => parseBackendOrigin("https://bot.example/api"), /bare origin/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valid https origins normalize to origin form", () => {
|
||||||
|
const a = parseBackendOrigin("https://bot.example/");
|
||||||
|
assert.equal(a.url, "https://bot.example");
|
||||||
|
assert.equal(a.host, "bot.example");
|
||||||
|
assert.equal(a.port, 443);
|
||||||
|
const b = parseBackendOrigin("https://bot.example:8443");
|
||||||
|
assert.equal(b.url, "https://bot.example:8443");
|
||||||
|
assert.equal(b.port, 8443);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("BACKEND_ORIGINS_JSON parses, dedups, validates all entries", () => {
|
||||||
|
const raw = JSON.stringify(["https://a.example", "https://b.example", "https://a.example"]);
|
||||||
|
const list = parseBackendOrigins(raw);
|
||||||
|
assert.deepEqual(list.map((o) => o.url), ["https://a.example", "https://b.example"]);
|
||||||
|
assert.throws(() => parseBackendOrigins(JSON.stringify(["https://a.example", "http://c.example"])), /https/);
|
||||||
|
assert.throws(() => parseBackendOrigins("nope"), /valid JSON/);
|
||||||
|
assert.throws(() => parseBackendOrigins("[]"), /non-empty/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)", () => {
|
||||||
|
for (const r of [
|
||||||
|
"/", "/assets/config.js", "/assets/styles.css", "/ready", "/webhook/tg", "/push/jmap", "/reconcile",
|
||||||
|
"/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke",
|
||||||
|
]) {
|
||||||
|
assert.ok(PROXIED_ROUTES.includes(r), `proxied: ${r}`);
|
||||||
|
}
|
||||||
|
for (const r of ["/healthz", "/admin", "/debug", "/jmap/session", "/ready/extra"]) {
|
||||||
|
assert.ok(!PROXIED_ROUTES.includes(r), `must be blocked: ${r}`);
|
||||||
|
assert.ok(!SAFE_ROUTES.includes(r), `must not be a safe route: ${r}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 让断言失败信息可读,且不泄漏敏感值。 */
|
||||||
|
function deep(v) {
|
||||||
|
return typeof v === "string" && v.length > 48 ? `${v.slice(0, 12)}…(${v.length})` : String(v);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
/**
|
||||||
|
* 单元测试:健康聚合(MOD-HEALTH-AGG / C-NO-DB / C-REDIS-ONLY-STATE)。
|
||||||
|
* 运行:cd cloudflare-worker && node --test
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { aggregateHealth, healthResponse } from "../src/health.js";
|
||||||
|
|
||||||
|
function mockCtx(plan, opts = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const cache = opts.cache ?? new Map();
|
||||||
|
let time = 1_000_000;
|
||||||
|
const fetch = async (url) => {
|
||||||
|
calls.push(url);
|
||||||
|
const step = plan[url] ?? { status: 500 };
|
||||||
|
if (step.error) throw step.error;
|
||||||
|
return new Response(step.body ?? "ok", { status: step.status });
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
fetch,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
ttlMs: opts.ttlMs ?? 30_000,
|
||||||
|
now: () => time,
|
||||||
|
advance: (ms) => {
|
||||||
|
time += ms;
|
||||||
|
},
|
||||||
|
setTimeout: () => 0,
|
||||||
|
clearTimeout: () => {},
|
||||||
|
cache,
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("aggregateHealth: all up => 200 ok summary", async () => {
|
||||||
|
const ctx = mockCtx({
|
||||||
|
"https://a.example/healthz": { status: 200 },
|
||||||
|
"https://b.example/healthz": { status: 200 },
|
||||||
|
});
|
||||||
|
const res = await aggregateHealth(["https://a.example", "https://b.example"], ctx);
|
||||||
|
assert.equal(res.available, 2);
|
||||||
|
assert.equal(res.total, 2);
|
||||||
|
const resp = healthResponse(res);
|
||||||
|
assert.equal(resp.status, 200);
|
||||||
|
const body = await resp.json();
|
||||||
|
assert.equal(body.status, "ok");
|
||||||
|
assert.equal(body.available, 2);
|
||||||
|
assert.equal(body.total, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aggregateHealth: partial down => still reports status (partial ok)", async () => {
|
||||||
|
const ctx = mockCtx({
|
||||||
|
"https://a.example/healthz": { status: 200 },
|
||||||
|
"https://b.example/healthz": { error: new Error("conn refused") },
|
||||||
|
});
|
||||||
|
const res = await aggregateHealth(["https://a.example", "https://b.example"], ctx);
|
||||||
|
assert.equal(res.available, 1);
|
||||||
|
assert.equal(res.total, 2);
|
||||||
|
const resp = healthResponse(res);
|
||||||
|
assert.equal(resp.status, 200, "at least one up => 200 ok");
|
||||||
|
const body = await resp.json();
|
||||||
|
assert.equal(body.status, "ok");
|
||||||
|
assert.equal(body.available, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aggregateHealth: all down => 503", async () => {
|
||||||
|
const ctx = mockCtx({
|
||||||
|
"https://a.example/healthz": { status: 503 },
|
||||||
|
"https://b.example/healthz": { error: new Error("conn refused") },
|
||||||
|
});
|
||||||
|
const res = await aggregateHealth(["https://a.example", "https://b.example"], ctx);
|
||||||
|
assert.equal(res.available, 0);
|
||||||
|
const resp = healthResponse(res);
|
||||||
|
assert.equal(resp.status, 503);
|
||||||
|
const body = await resp.json();
|
||||||
|
assert.equal(body.status, "down");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aggregateHealth: respects cache TTL to avoid probe storm", async () => {
|
||||||
|
const ctx = mockCtx(
|
||||||
|
{
|
||||||
|
"https://a.example/healthz": { status: 200 },
|
||||||
|
},
|
||||||
|
{ ttlMs: 10_000 },
|
||||||
|
);
|
||||||
|
await aggregateHealth(["https://a.example"], ctx);
|
||||||
|
assert.equal(ctx.calls.length, 1);
|
||||||
|
|
||||||
|
// 未过 TTL:命中缓存,不再发起 fetch。
|
||||||
|
ctx.advance(5_000);
|
||||||
|
await aggregateHealth(["https://a.example"], ctx);
|
||||||
|
assert.equal(ctx.calls.length, 1);
|
||||||
|
|
||||||
|
// 超过 TTL:发起新 fetch。
|
||||||
|
ctx.advance(6_000);
|
||||||
|
await aggregateHealth(["https://a.example"], ctx);
|
||||||
|
assert.equal(ctx.calls.length, 2);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
/**
|
||||||
|
* 集成测试:Cloudflare Worker 主入口 handle fetch(ARCH-LB-WORKER / SAF-LB-PASSTHRU)。
|
||||||
|
* 验证:路由分发、/healthz 聚合、未白名单 404、method 拦截、配置异常 fail-closed、鉴权头透传。
|
||||||
|
* 运行:cd cloudflare-worker && node --test
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { handleFetch } from "../src/index.js";
|
||||||
|
|
||||||
|
function makeEnv(overrides = {}) {
|
||||||
|
return {
|
||||||
|
BACKEND_ORIGINS_JSON: JSON.stringify(["https://a.example", "https://b.example"]),
|
||||||
|
LB_REQUEST_TIMEOUT_MS: "5000",
|
||||||
|
LB_MAX_ATTEMPTS: "2",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安装/卸载 globalThis.fetch mock。 */
|
||||||
|
function stubFetch(impl) {
|
||||||
|
const orig = globalThis.fetch;
|
||||||
|
globalThis.fetch = impl;
|
||||||
|
return () => {
|
||||||
|
globalThis.fetch = orig;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Worker GET /healthz aggregates backend probes without touching proxied routes", async () => {
|
||||||
|
const restore = stubFetch(async (url) => new Response("ok", { status: 200 }));
|
||||||
|
try {
|
||||||
|
const res = await handleFetch(new Request("https://lb.example/healthz", { method: "GET" }), makeEnv());
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.status, "ok");
|
||||||
|
assert.equal(body.available, 2);
|
||||||
|
assert.equal(body.total, 2);
|
||||||
|
assert.ok(Array.isArray(body.backends));
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Worker: unknown route => 404 (C-LB-SINGLE-REG-URL)", async () => {
|
||||||
|
const res = await handleFetch(
|
||||||
|
new Request("https://lb.example/unknown", { method: "POST", body: "{}" }),
|
||||||
|
makeEnv(),
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
assert.match(await res.text(), /route not proxied/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Worker: wrong method on safelist route => 405", async () => {
|
||||||
|
const res = await handleFetch(
|
||||||
|
new Request("https://lb.example/webhook/tg", { method: "GET" }),
|
||||||
|
makeEnv(),
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 405);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Worker: business config PUT is proxied with the session bearer and empty 204 response", async () => {
|
||||||
|
const restore = stubFetch(async (url, init) => {
|
||||||
|
assert.match(url, /^https:\/\/(a|b)\.example\/api\/business-config$/);
|
||||||
|
assert.equal(init.method, "PUT");
|
||||||
|
assert.equal(init.headers.get("authorization"), "Bearer opaque-session");
|
||||||
|
assert.equal(await new Response(init.body).text(), "{\"llm_enabled\":false}");
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await handleFetch(
|
||||||
|
new Request("https://lb.example/api/business-config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { authorization: "Bearer opaque-session", "content-type": "application/json" },
|
||||||
|
body: "{\"llm_enabled\":false}",
|
||||||
|
}),
|
||||||
|
makeEnv(),
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 204);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Worker: misconfigured origins => 503 fail-closed (no env leak)", async () => {
|
||||||
|
const res = await handleFetch(
|
||||||
|
new Request("https://lb.example/reconcile", { method: "POST", body: "{}" }),
|
||||||
|
makeEnv({ BACKEND_ORIGINS_JSON: "not-json" }),
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 503);
|
||||||
|
assert.equal(await res.text(), "misconfigured backends");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Worker: valid env, GET /ready proxied to backend with auth header intact", async () => {
|
||||||
|
const restore = stubFetch(async (url, init) => {
|
||||||
|
assert.match(url, /^https:\/\/(a|b)\.example\/ready$/);
|
||||||
|
return new Response("ready", { status: 200, headers: { "content-type": "text/plain" } });
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await handleFetch(
|
||||||
|
new Request("https://lb.example/ready", {
|
||||||
|
method: "GET",
|
||||||
|
headers: { authorization: "Bearer lb-test" },
|
||||||
|
}),
|
||||||
|
makeEnv(),
|
||||||
|
);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
/**
|
||||||
|
* 单元测试:透传转发 + 有界故障转移 + 全失败 5xx(SAF-LB-PASSTHRU / SAF-LOG-PURITY / C-NO-SECRET-IN-IMAGE)。
|
||||||
|
* 运行:cd cloudflare-worker && node --test
|
||||||
|
*/
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { proxyWithFailover, joinUrl, isRetryableError } from "../src/proxy.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可控 fetch mock:steps[i] 每次调用消费一个;
|
||||||
|
* - {status, body?} → 返回该 Response;
|
||||||
|
* - {error: Error} → 抛出。
|
||||||
|
*/
|
||||||
|
function mockFetch(steps) {
|
||||||
|
const calls = [];
|
||||||
|
const fetch = async (url, init) => {
|
||||||
|
const i = calls.length;
|
||||||
|
calls.push({ url, init });
|
||||||
|
const step = steps[Math.min(i, steps.length - 1)];
|
||||||
|
if (step.error) throw step.error;
|
||||||
|
return new Response(step.body ?? "ok", { status: step.status, headers: { "content-type": "text/plain" } });
|
||||||
|
};
|
||||||
|
return { fetch, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试用计时器:不真触发超时。 */
|
||||||
|
const noOpTimers = { setTimeout: () => 0, clearTimeout: () => {} };
|
||||||
|
|
||||||
|
function req(path = "/reconcile") {
|
||||||
|
return new Request(`https://lb.example${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: "Bearer secret-token", "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ id: 42 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortError() {
|
||||||
|
const err = new Error("The operation was aborted");
|
||||||
|
err.name = "AbortError";
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("isRetryableError: only 5xx (status) and timeout/network (err) are retryable", () => {
|
||||||
|
assert.equal(isRetryableError(new Error("boom"), 0), false, "generic error not retryable");
|
||||||
|
assert.equal(isRetryableError(abortError(), 0), true, "AbortError retryable");
|
||||||
|
assert.equal(isRetryableError(new Error("fetch failed"), 0), true, "network-class message retryable");
|
||||||
|
assert.equal(isRetryableError(new Error("bad inner state"), 0), false, "non-network error not retryable");
|
||||||
|
assert.equal(isRetryableError(null, 503), true, "5xx retryable");
|
||||||
|
assert.equal(isRetryableError(null, 404), false, "4xx not retryable");
|
||||||
|
assert.equal(isRetryableError(null, 200), false, "2xx not retryable");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transparent proxy forwards headers + body + method (SAF-LB-PASSTHRU)", async () => {
|
||||||
|
const { fetch, calls } = mockFetch([{ status: 202, body: "accepted" }]);
|
||||||
|
const res = await proxyWithFailover(["https://a.example"], req("/webhook/tg"), {
|
||||||
|
...noOpTimers,
|
||||||
|
fetch,
|
||||||
|
maxAttempts: 2,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
rng: () => 0.5,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 202);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].url, "https://a.example/webhook/tg");
|
||||||
|
assert.equal(calls[0].init.method, "POST");
|
||||||
|
assert.equal(calls[0].init.headers.get("authorization"), "Bearer secret-token");
|
||||||
|
assert.equal(calls[0].init.headers.get("content-type"), "application/json");
|
||||||
|
const body = await new Response(new Uint8Array(calls[0].init.body)).text();
|
||||||
|
assert.equal(body, JSON.stringify({ id: 42 }));
|
||||||
|
assert.equal(res.headers.get("x-lb-backend"), "https://a.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET/HEAD do not forward a body", async () => {
|
||||||
|
const { fetch, calls } = mockFetch([{ status: 200 }]);
|
||||||
|
const get = new Request("https://lb.example/ready", { method: "GET", headers: { authorization: "Bearer x" } });
|
||||||
|
await proxyWithFailover(["https://a.example"], get, {
|
||||||
|
...noOpTimers,
|
||||||
|
fetch,
|
||||||
|
maxAttempts: 1,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
rng: () => 0,
|
||||||
|
});
|
||||||
|
assert.equal(calls[0].init.body, undefined, "GET must not carry a body");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("4xx is returned as-is WITHOUT retry and without failing over", async () => {
|
||||||
|
const { fetch, calls } = mockFetch([{ status: 401, body: "unauthorized" }, { status: 200 }]);
|
||||||
|
const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
|
||||||
|
...noOpTimers,
|
||||||
|
fetch,
|
||||||
|
maxAttempts: 2,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
rng: () => 0,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 401);
|
||||||
|
assert.equal(calls.length, 1, "must NOT retry/share next backend on 4xx");
|
||||||
|
assert.equal(calls[0].url, "https://a.example/push/jmap");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("5xx triggers failover to next origin, then returns success", async () => {
|
||||||
|
const { fetch, calls } = mockFetch([{ status: 503, body: "busy" }, { status: 200, body: "ok" }]);
|
||||||
|
const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
|
||||||
|
...noOpTimers,
|
||||||
|
fetch,
|
||||||
|
maxAttempts: 2,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
rng: () => 0, // 确定性起点 a
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(calls[0].url, "https://a.example/push/jmap");
|
||||||
|
assert.equal(calls[1].url, "https://b.example/push/jmap");
|
||||||
|
assert.equal(res.headers.get("x-lb-backend"), "https://b.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("timeout(AbortError) triggers failover and recovery", async () => {
|
||||||
|
const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 200, body: "recovered" }]);
|
||||||
|
const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/reconcile"), {
|
||||||
|
...noOpTimers,
|
||||||
|
fetch,
|
||||||
|
maxAttempts: 2,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
rng: () => 0,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all backends 5xx/timeout => aggregated 503 (Telegram redelivery fallback)", async () => {
|
||||||
|
const { fetch, calls } = mockFetch([{ error: abortError() }, { status: 503, body: "x" }]);
|
||||||
|
const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
|
||||||
|
...noOpTimers,
|
||||||
|
fetch,
|
||||||
|
maxAttempts: 2,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
rng: () => 0,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 503);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.match(await res.text(), /all backends failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bounded attempts: maxAttempts caps the number of origin tries", async () => {
|
||||||
|
const { fetch, calls } = mockFetch([{ error: abortError() }]); // 每次都超时(可故障转移)
|
||||||
|
const res = await proxyWithFailover(["https://a", "https://b", "https://c"], req("/reconcile"), {
|
||||||
|
...noOpTimers,
|
||||||
|
fetch,
|
||||||
|
maxAttempts: 2,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
rng: () => 0,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 503);
|
||||||
|
assert.equal(calls.length, 2, "must stop after maxAttempts=2 (bounded, no infinite retry)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-retryable (non-network) error fails fast without burning other origins", async () => {
|
||||||
|
const { fetch, calls } = mockFetch([{ error: new Error("invalid inner state") }, { status: 200 }]);
|
||||||
|
const res = await proxyWithFailover(["https://a.example", "https://b.example"], req("/push/jmap"), {
|
||||||
|
...noOpTimers,
|
||||||
|
fetch,
|
||||||
|
maxAttempts: 2,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
rng: () => 0,
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 503);
|
||||||
|
assert.equal(calls.length, 1, "non-network error must not failover");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("joinUrl preserves path + query, discards fragment", () => {
|
||||||
|
const r = new Request("https://lb.example/reconcile?cursor=abc#frag", { method: "POST" });
|
||||||
|
assert.equal(joinUrl("https://a.example", r), "https://a.example/reconcile?cursor=abc");
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": [],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUnusedLocals": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.js"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* Wrangler 示例配置 — Stalwart-bot 多实例 LB/HA(ARCH-LB-WORKER / deployment.md §10)。
|
||||||
|
*
|
||||||
|
* 安全约束(AGENTS.md §2/§4,稳定 ID):
|
||||||
|
* - 后端 origin 仅允许 https(C-HTTPS-INBOUND / C-HTTPS-URL);env 校验时拒绝
|
||||||
|
* http/非 https/内嵌凭据。
|
||||||
|
* - 不代理 Redis/JMAP(C-NO-DB / C-REDIS-ONLY-STATE):Worker 只做请求转发,后端连自己的 Redis。
|
||||||
|
* - 不使用 WebSocket/SSE/长轮询(C-NO-LONG-CONN):纯请求-响应转发。
|
||||||
|
* - 后端 secret 走 `wrangler secret`(见 README),不进代码/日志(SAF-LOG-PURITY / C-NO-SECRET-IN-IMAGE;
|
||||||
|
* C-LB-SHARED-SECRETS:所有后端实例共享同一组 secret)。
|
||||||
|
* - 日志无密钥(SAF-LOG-PURITY):只打「方法/路径/origin/失败类别」,origin 清单走 secret。
|
||||||
|
* - 仅允许转发 safelist 路由(C-LB-SINGLE-REG-URL);未知路径 404。
|
||||||
|
*
|
||||||
|
* 部署:
|
||||||
|
* cd cloudflare-worker
|
||||||
|
* npx wrangler secret put BACKEND_ORIGINS_JSON # 见下方示例格式
|
||||||
|
* npx wrangler deploy
|
||||||
|
*/
|
||||||
|
name = "stalwart-bot-lb"
|
||||||
|
main = "src/index.js"
|
||||||
|
compatibility_date = "2025-01-01"
|
||||||
|
compatibility_flags = ["nodejs_compat_v2"]
|
||||||
|
|
||||||
|
# 统一 HTTPS 入站(C-HTTPS-URL;平台代 TLS,Worker 内为短请求模型)。
|
||||||
|
# 后端 origin 为 secret(不经明文 env 流转):
|
||||||
|
# BACKEND_ORIGINS_JSON 形如 ["https://a.platform1.example","https://b.platform2.example"]
|
||||||
|
# LB_HEALTH_TTL_MS 可选,健康缓存 TTL(默认 30000)
|
||||||
|
# LB_REQUEST_TIMEOUT_MS 可选,单 origin 超时(默认 10000,建议 > 最坏 cold start)
|
||||||
|
# LB_MAX_ATTEMPTS 可选,每请求最多 origin 尝试次数(默认 2:首次 + 1 次故障转移,
|
||||||
|
# 对应 deployment.md §10.4「有界重试其它实例(建议 1 次)」)
|
||||||
|
vars = {}
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
# Stalwart-bot 部署方案(通用 HTTPS-only Docker)
|
||||||
|
|
||||||
|
> 部署目标:**通用 Docker 容器平台**(任何能跑 Docker 的 VPS / 云主机 / k8s / 自托管容器平台),
|
||||||
|
> **不绑定** Cloud Run / Lambda / CF Workers / Deno Deploy 等任何具体平台(`NG-SERVERLESS-BIND`)。
|
||||||
|
>
|
||||||
|
> 保持 Docker health / PORT 等通用 HTTP 约定(`C-PORT`)。
|
||||||
|
> 部署形态 = **短请求模型**:Telegram Webhook + Stalwart JMAP Push HTTPS 回调 + 外部 HTTPS Cron 对账。
|
||||||
|
> 硬约束见 [§0](#0-硬约束);非目标见 [§1](#1-非目标non-goals);
|
||||||
|
> 架构 / 产品行为以 [design.md](design.md) 为准,本文只写部署/运维。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 硬约束
|
||||||
|
|
||||||
|
> 下述约束均为**不可违反**项(对应 AGENTS.md 硬边界)。跨文档引用一律用稳定 ID,索引见 AGENTS.md「跨文档引用索引表」。
|
||||||
|
|
||||||
|
| ID | 约束 |
|
||||||
|
|---|---|
|
||||||
|
| **C-DOCKER** | 必须是 Docker 容器;禁止非 Docker 直装 |
|
||||||
|
| **C-DEBIAN-SLIM** | Debian 系 slim,禁止 Alpine/musl |
|
||||||
|
| **C-NO-SECRET-IN-IMAGE** | secrets 不进镜像(构建 ARG/ENV 不得含 token/密码) |
|
||||||
|
| **C-RUSTLS** | rustls + rustls-native-roots,不依赖系统 OpenSSL |
|
||||||
|
| **C-HTTPS-INBOUND** | 只提供 HTTPS 入站:容器内仅监听明文 HTTP,TLS 由平台入口/反向代理统一终止;无代理场景才允许容器内自服务 TLS |
|
||||||
|
| **C-HTTPS-URL** | **公网 HTTPS 入口由平台提供**:平台/反代给 bot 一个公网 HTTPS URL(如 `https://bot.example.com`),Telegram Webhook 与 Stalwart Push 回调都指向该 URL 的路径;bot 自身不申请证书、不监听 443 |
|
||||||
|
| **C-NO-TCP-EXPOSE** | 不依赖 TCP 端口暴露:全容器**单监听端口 `PORT`(默认 8080)**;对外只走 HTTP 路由(webhook / push / reconcile / health);不暴露任何附加 TCP 端口(如 admin 端口) |
|
||||||
|
| **C-NO-LONG-CONN** | 不使用任何长连接:无 JMAP EventSource/SSE、无 WebSocket、无 Telegram 长轮询。实时性仅由 **Push HTTPS 回调 + 外部 HTTPS Cron 对账** 保证 |
|
||||||
|
| **C-REDIS-ONLY-STATE** | 无状态形态只依赖**外部 Redis**:会话、去重、Redis Streams、熔断计数、**sinceState 游标** 全部走 Redis;**不用 SQLite、不用本地卷**(`NG-SQLITE-PERSIST` / `NG-LOCAL-VOLUME`) |
|
||||||
|
| **C-REDIS-MANAGED-AOF** | Redis 由**用户托管**(自建或托管服务)并**开启 AOF 持久化**:Bot 不自建、不管理 Redis 进程;AOF 保证 Streams 队列 / 去重表 / sinceState 重启不丢 |
|
||||||
|
| **C-NO-DB** | **生产不使用任何数据库**:无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一的状态存储(`C-REDIS-ONLY-STATE`)。应用不得自带、不自建、不连接第二个数据库实例 |
|
||||||
|
| **C-NO-LOCAL-WRITE** | **禁止本地文件/目录写入**:无日志文件、无数据文件、无临时缓存、不挂载本地卷(`NG-LOCAL-VOLUME`/`NG-SQLITE-PERSIST`) |
|
||||||
|
| **C-LOG-STDOUT-ONLY** | **日志只写 stdout/stderr**(容器平台/运行时负责采集落盘);禁用 `rolling-file`、`FileAppender` 等文件日志后端;日志中**不得**出现密钥、邮件正文、AI 请求/响应内容(`SAF-LOG-PURITY`) |
|
||||||
|
| **SAF-LOG-PURITY** | 日志/Redis 写入内容**仅限**:结构化事件 ID、状态机转移、计数、时间戳、脱敏后的请求摘要(request id、状态码、耗时);禁止 `password`/`token`/`secret`/`Authorization` 原文、JMAP 正文、LLM prompt/completion、附件内容 |
|
||||||
|
| **C-NO-STATEFUL-RECOVERY** | **禁止依赖进程内状态做生产恢复**:任何"重启续跑"(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账(`FLOW-RECONCILE`/`C-REDIS-ONLY-STATE`)实现;进程内缓存仅为性能优化,丢失必须安全可重入 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 非目标(Non-Goals)
|
||||||
|
|
||||||
|
> 以下均为**历史早期参考**,**非目标、不再支持**。不得在实现中复活为运行模式。
|
||||||
|
|
||||||
|
| ID | 非目标 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **NG-SERVER-MODE** | `RUN_MODE=server` 常驻运行模式 | 早期 EventSource+长轮询 常驻设计,已删除 |
|
||||||
|
| **NG-POLLING-SSE** | JMAP EventSource/SSE 长连接订阅 | 与 `C-NO-LONG-CONN` 冲突;实时通道只用 Push 回调 |
|
||||||
|
| **NG-LONG-POLLING** | Telegram 长轮询运行模式 | 与 `C-NO-LONG-CONN` 冲突;只用 Webhook |
|
||||||
|
| **NG-SQLITE-PERSIST** | SQLite 会话 / sinceState 本地持久化 | 状态只走外部 Redis(`C-REDIS-ONLY-STATE`) |
|
||||||
|
| **NG-LOCAL-VOLUME** | `/app/data` 等本地卷持久化 | 无本地持久层(`C-REDIS-ONLY-STATE`) |
|
||||||
|
| **NG-SERVERLESS-BIND** | 绑定 Cloud Run / Lambda / CF Workers / Deno 等具体平台 | 部署目标是通用 Docker 容器平台;不绑定平台 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Debian Slim 选型
|
||||||
|
|
||||||
|
(保留既有内容:debian:bookworm-slim 的运行基础、非 root 用户、时区、CA 证书等通用约定)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Rust 多阶段构建与运行
|
||||||
|
|
||||||
|
### 3.1 构建阶段
|
||||||
|
(保留既有 rust:bookworm 构建 + 缓存分层约定;产物为单个静态编译二进制 `stalwart-bot`)
|
||||||
|
|
||||||
|
> **实现现状**(阶段0 + 阶段1,`ARCH-STAGE0`/`ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1`):自动依赖含 `axum 0.8`(单端口入口)/`serde`/`serde_json`/`thiserror`/`secrecy`/`subtle`(常数时间比较)/`url`/`tokio`/`tracing` 等;**`jmap-client =0.4.2` 已引入**(阶段1,`default-features=false, features=["async","rustls"]`,**禁用 WebSocket feature**,`C-NO-LONG-CONN`);三条入口路由的鉴权已 fail-closed 落地(`R1`/`SAF-AUTH-*`),**JMAP 只读 adapter(G1/D-G1-1)代码已实现、待真实 `cargo test -- --ignored jmap::` 验证**,其余业务体仍为**占位**;`teloxide`(阶段2)/`redis`(阶段3/4)/`reqwest`(阶段3.5) 尚未引入;配置为环境变量手工解析(`ARCH-CONFIG-ENV`,无 figment/TOML)。CI 门禁见 §8(`cargo fmt --check`、`cargo clippy --all-targets -- -D warnings`、`cargo test`,在 Debian 容器内,`GATE-P0`)。
|
||||||
|
|
||||||
|
### 3.2 运行阶段(收敛为单端口)
|
||||||
|
- `EXPOSE 8080`(遵循 `C-NO-TCP-EXPOSE`,不再暴露 9191 admin 端口)
|
||||||
|
- `ENV PORT=8080`、`RUN_MODE=webhook`(默认)
|
||||||
|
- 健康检查走同一 HTTP 监听(见 §7),不依赖独立端口
|
||||||
|
- **不声明 VOLUME**,无 `/app/data` 本地卷(`NG-LOCAL-VOLUME`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 运行时用户 / 证书 / TLS 与公网 HTTPS 入口
|
||||||
|
|
||||||
|
- 非 root 用户运行(保留既有约定)
|
||||||
|
- **公网 HTTPS 入口(`C-HTTPS-URL` 已确认)**:运维在平台上给 bot 配一个公网 HTTPS URL(如 `https://bot.example.com`),平台 ingress/反代把 `https://…/webhook/tg`、/push/jmap、/reconcile 路由到容器 `PORT`。**bot 自身不申请证书、不监听 443**;证书由平台/反代管理(`C-HTTPS-INBOUND`)。
|
||||||
|
- 需要在 Stalwart 与 Telegram 两侧登记这个公网 URL:Telegram `setWebhook` 指向 `/webhook/tg`;Stalwart PushSubscription 的 `url` 指向 `/push/jmap`。
|
||||||
|
- **多实例 LB/HA 时登记的是 Worker URL 而非各后端 URL**(`C-LB-SINGLE-REG-URL`,§10):Telegram / Stalwart / Cron 只认 Worker 的稳定域名;后端平台入口不对外登记。
|
||||||
|
- Secrets 运行期注入(环境变量/容器平台 secret),见 §5(同文件内章节链接)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Secret 管理与环境变量
|
||||||
|
|
||||||
|
> 迁移后生产环境仅需 `REDIS_URL` 与 `CONFIG_ENCRYPTION_KEY`;下表中的业务环境变量是历史阶段说明,不应再注入生产容器。业务密钥通过受保护的 `/api/bootstrap` 或管理员 PUT 写入 Redis,并在成功后热重建客户端。
|
||||||
|
|
||||||
|
(保留既有 Secrets 注入约定:禁入镜像、`secrecy` 包裹、日志屏蔽;新增运行模式相关变量)
|
||||||
|
|
||||||
|
| 变量 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `BOT_TOKEN` | ✅ | Telegram Bot Token |
|
||||||
|
| `TELEGRAM_CHAT_ID` | ✅ | Worker 元数据通知目标 chat id(与入站白名单分离) |
|
||||||
|
| `JMAP_SESSION_URL` | ✅ | Stalwart JMAP session URL(`REQ-JMAP-SESSION-URL`):填**服务基地址**(`https://mail.example.com`)或**完整** `…/.well-known/jmap` 均可;代码归一化为 origin/base 后再交 `jmap-client`,**不产生重复路径**。仅 HTTPS;**禁止 URL 内嵌凭据**(`SAF-JMAP-URL`) |
|
||||||
|
| `JMAP_USERNAME` / `JMAP_PASSWORD` | ✅ | **Stalwart 认证 = App Password + Basic**(已确认 `C-AUTH-APP-BASIC`):账号填邮箱,密码填在 Stalwart 生成的**应用专用密码**(可独立吊销/设到期);不用主密码、不用 OAuth |
|
||||||
|
| `CHAT_ALLOWLIST` | ✅ | 聊天白名单(**硬约束 `SAF-CHAT-ALLOWLIST`**):逗号分隔整数 chat id;处理任何事件前先校验,非白名单直接拒绝 |
|
||||||
|
| `REDIS_URL` | ✅ | **外部 Redis(用户托管 + AOF)**(`C-REDIS-ONLY-STATE`/`C-REDIS-MANAGED-AOF`):session / dedup / Streams / fuse / sinceState 全部在此;Redis 进程不在本 compose 内 |
|
||||||
|
| `PORT` | 默认 8080 | 单监听端口(`C-NO-TCP-EXPOSE`) |
|
||||||
|
| `RUN_MODE` | 默认 `webhook` | `webhook` / `reconcile` 二选一(`NG-SERVER-MODE` 已删除) |
|
||||||
|
| `RECONCILE_TOKEN` | ✅ | `/reconcile` 的 `Authorization: Bearer <token>` 承载令牌(`SAF-AUTH-RECONCILE`)。因 `/reconcile` 路由**始终挂载**,此变量为**必填**(`SecretString`) |
|
||||||
|
| `WORKER_TOKEN` | ✅ | `/worker` 的有界处理令牌;管理 API 兼容接受该 Bearer 值,SPA 使用短期 Redis admin session |
|
||||||
|
| `TG_WEBHOOK_SECRET` | ✅ | `/webhook/tg` 校验请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`)。须与 Telegram `setWebhook` 的 `secret_token` **完全一致**(`SecretString`) |
|
||||||
|
| `JMAP_PUSH_VERIFICATION` | ✅ | `/push/jmap` 校验请求体 JSON 字段 `verificationCode`(`SAF-AUTH-JMAP-PUSH`)。须与 Stalwart PushSubscription 的 verification code **完全一致**(`SecretString`) |
|
||||||
|
| `LLM_*` | 可选 | OpenAI-compatible 环境变量(design.md `REQ-LLM-OPENAI-COMPAT`);**仅当用户明确允许时才把邮件正文外发 AI**(`REQ-AI-EXTERNAL-CONSENT`) |
|
||||||
|
|
||||||
|
AI 授权期限由用户选择(临时一次、1小时、今天、7天或直到撤销),Redis 仅保存 chat id、授权状态和带 TTL 的到期时间;不会保存正文或摘要。到期后摘要请求回到元数据模式并提示重新授权。
|
||||||
|
| `ACCOUNT_ID` | 默认空 | **单账户**(`REQ-SINGLE-ACCOUNT`):留空则取 session 主账户;多账户 = 部署多个 bot 实例(各自独立 token/配置),不做多账户单实例 |
|
||||||
|
|
||||||
|
> **三个入口鉴权变量均为必填(fail-closed,`SAF-AUTH-*`)**:`RECONCILE_TOKEN` / `TG_WEBHOOK_SECRET` / `JMAP_PUSH_VERIFICATION` 缺失即**启动失败**(`Config::from_env()` 报缺失),不存在"未配置则放行"的降级路径。三个入口分别校验:`/reconcile`(Bearer 头)、`/webhook/tg`(secret 头)、`/push/jmap`(Body `verificationCode`);校验失败一律返回 `401`,且在鉴权通过前**不产生任何副作用**。`/healthz` 与 `/ready` 为公开探针,不含敏感信息。
|
||||||
|
> 示例占位见仓库根目录 [`.env.example`](../.env.example)(仅占位符,**严禁**放入真实密钥)。
|
||||||
|
|
||||||
|
> **配置管理页面**:服务根路径 `/` 提供嵌入 Rust 二进制的 SPA。输入 Redis URL 的 ACL 密码后,`POST /api/admin/session` 签发 900 秒 admin session;页面只在内存中保存 opaque session。运行参数通过 `/api/config` 读取和保存;完整业务配置通过 `PUT /api/business-config` 替换并热加载。业务配置 API 不提供 GET,密钥不会回显;每次完整替换都需重新输入必填密钥。配置保存在外部 Redis(`C-REDIS-ONLY-STATE`),静态资源编译时随二进制打包,无运行期本地文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 运行模式与短请求模型
|
||||||
|
|
||||||
|
### 6.0 Redis-only 配置迁移与 bootstrap 威胁模型(`C-REDIS-ONLY-STATE`)
|
||||||
|
|
||||||
|
生产进程接受 `REDIS_URL` 与唯一额外启动密钥 `CONFIG_ENCRYPTION_KEY`(32 字节随机
|
||||||
|
高熵 hex,仅应用运行时持有);其中 Redis ACL 认证是唯一 bootstrap 信任根;业务
|
||||||
|
密钥和运行参数不再从环境变量读取。空 Redis 仅提供配置页面及一次性的 bootstrap
|
||||||
|
会话:请求必须以 Redis ACL 密码作为 Bearer 凭据,服务端只做常数时间比较,绝不在
|
||||||
|
响应、日志或配置值中回显该密码。bootstrap 使用原子 `SET NX` 写入完整业务配置,
|
||||||
|
竞争请求只有一个成功;无密码 Redis 或认证失败时拒绝初始化。
|
||||||
|
|
||||||
|
该 ACL 身份必须具备 `config:business`、`config:outbound` 及 admin-session 键的读写权限,
|
||||||
|
因为它同时是应用 Redis 连接凭据与空库 bootstrap 根信任。bootstrap 完成后公网业务配置
|
||||||
|
接口只接受 900 秒 admin session;持有 ACL 密码者仍等同根信任,必须按生产 Redis 凭据
|
||||||
|
同等级保护,不得把该身份误配置为禁止配置键访问。
|
||||||
|
|
||||||
|
成功 bootstrap 后,后续配置读写只能使用 Redis 中保存的管理员会话(短 TTL,注销
|
||||||
|
或配置更新时失效);业务端点使用 Redis 配置中下发的独立令牌。管理员会话只保存
|
||||||
|
不可逆哈希和过期时间,重启后按 Redis TTL 恢复,进程不保存本地状态。GET 永不回显
|
||||||
|
任何密钥,配置缺失或业务依赖未就绪时保持 HTTP/SPA 可用但业务路由返回未就绪。
|
||||||
|
|
||||||
|
`config:business` 使用 AES-256-GCM(版本、随机 nonce、认证 tag 均在密文 envelope 中)
|
||||||
|
写入 Redis;应用启动时使用 `CONFIG_ENCRYPTION_KEY` 解密,密钥永不写 Redis、日志、响应
|
||||||
|
或浏览器存储。该保护只防 Redis 内容泄露,不替代 `rediss`、VPN 或受信网络对 `redis://`
|
||||||
|
窃听/篡改的防护;公网 Redis 必须使用 TLS/VPN/隧道。
|
||||||
|
|
||||||
|
bootstrap 或管理员 PUT 修改 `config:business` 后,服务会先校验并构建全部 JMAP/Telegram/LLM
|
||||||
|
客户端,再原子替换 worker;构建失败保留旧配置与任务。后续请求即时使用新快照,多实例在
|
||||||
|
请求边界按 revision 有界检查并尝试刷新;失败时保留旧实例,避免忙循环。
|
||||||
|
|
||||||
|
TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链路只能位于受控私网,
|
||||||
|
并应校验可信转发头后才允许 bootstrap。容器端口不得直接暴露公网。SPA 仅在当前页面
|
||||||
|
内存保存 opaque admin session,不使用 localStorage、sessionStorage 或 Cookie 持久化。
|
||||||
|
|
||||||
|
> 仅两种运行模式;无长连接(`C-NO-LONG-CONN`)。
|
||||||
|
|
||||||
|
| RUN_MODE | 命令 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `webhook`(默认) | `stalwart-bot webhook`(或单一入口按 env 分派) | 单进程:TG Webhook handler + JMAP Push 回调 handler + Redis Streams worker + `/reconcile` + health |
|
||||||
|
| `reconcile` | `stalwart-bot reconcile` | 一次性对账补差后 exit,由外部调度器/容器任务触发 |
|
||||||
|
|
||||||
|
### 6.1 入口路由(单端口 `PORT`,全部经平台 HTTPS URL 入站)
|
||||||
|
```
|
||||||
|
# 平台公网 URL: https://bot.example.com → 反代 → 容器 127.0.0.1:8080
|
||||||
|
GET / 业务配置与运行参数管理 SPA
|
||||||
|
GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式
|
||||||
|
POST /api/admin/session Bearer Redis ACL 密码;成功返回 900 秒 admin session
|
||||||
|
POST /api/admin/session/revoke Bearer admin session;成功返回 204
|
||||||
|
GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503
|
||||||
|
PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;完整替换,成功返回 204
|
||||||
|
POST /webhook/tg TG Webhook → [鉴权 SAF-AUTH-TG-WEBHOOK: 头 X-Telegram-Bot-Api-Secret-Token]
|
||||||
|
→ 快速 2xx ACK → 幂等去重(MOD-DEDUP) → 命令处理 → 同步回复(快路径)
|
||||||
|
POST /push/jmap Stalwart Push 回调 → [鉴权 SAF-AUTH-JMAP-PUSH: Body verificationCode]
|
||||||
|
→ 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK(慢任务异步)
|
||||||
|
POST /reconcile 外部 HTTPS Cron 触发 → [鉴权 SAF-AUTH-RECONCILE: Authorization: Bearer RECONCILE_TOKEN]
|
||||||
|
→ 对账补差 FLOW-RECONCILE
|
||||||
|
GET /healthz liveness(ARCH-HEALTHZ:进程存活;公开探针 SAF-PROBE-PUBLIC,无鉴权、无敏感信息)
|
||||||
|
GET /ready ⚠️ 阶段0 为**占位 200**(ARCH-READY-PLACEHOLDER)——**不执行** Redis PING / JMAP session / TG getMe;真实 readiness 属后续门禁 GATE-READY-DEPS(公开探针,无鉴权、无敏感信息)
|
||||||
|
```
|
||||||
|
> **三入口鉴权(fail-closed,`SAF-AUTH-*`)**:三条写路径必须先通过鉴权,**失败返回 `401` 且不产生副作用**;比较使用常数时间(`subtle`,防时序侧信道)。secret 未配置 → 启动失败,**无"缺省放行"**。
|
||||||
|
> **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 仅返回健康状态、**不含敏感信息**;**`/ready` 现阶段不检查任何依赖**,不得据此判断 Redis/JMAP/TG 可用性(见 §7 与 design.md §10.0 `GATE-READY-DEPS`)。
|
||||||
|
> **"公网 HTTPS 入口"是什么**(`C-HTTPS-URL`):平台给 bot 一个公网 HTTPS 域名,外部(Telegram / Stalwart / 调度器)通过它访问上面这些路径;容器只处理明文 HTTP,TLS 由平台终止。运维只需在平台上配置域名/证书并确保 4 条路径可达。
|
||||||
|
|
||||||
|
### 6.2 Redis Streams / worker(MOD-STREAMS)
|
||||||
|
- Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞
|
||||||
|
- worker(同容器后台 task 或独立 worker 容器,2 选 1 均由 compose/编排决定):
|
||||||
|
`XREADGROUP → Email/changes → 推送 TG → 推进 sinceState → XACK`
|
||||||
|
- 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底)
|
||||||
|
|
||||||
|
### 6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE)
|
||||||
|
- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。
|
||||||
|
由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`),触发一次对账。
|
||||||
|
- **建议间隔 5–10 分钟**(`NFR-RECONCILE-INTERVAL`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。
|
||||||
|
- 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。
|
||||||
|
- 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。
|
||||||
|
- **Redis 丢失恢复**:sinceState 存 Redis(`MOD-SINCESTATE`,AOF 持久化 `C-REDIS-MANAGED-AOF`);即便 Redis 全丢,对账扫描 JMAP(`Email/query` 最近 N 封 + changes)也能重建游标并补发——**事实源在 JMAP,Redis 只是加速层**。
|
||||||
|
|
||||||
|
### 6.4 可靠性策略(Reliability)
|
||||||
|
|
||||||
|
> 目标:**至少 99.9% 通知可用性**(`NFR-NOTIFY-SLA`),允许少量延迟(不追求秒级保证)。下述机制共同保证"不丢、少重、可恢复"。
|
||||||
|
|
||||||
|
| 机制 | ID | 做法 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Streams ACK / 重试** | `MOD-STREAMS` | worker 用消费组 `XREADGROUP`;处理成功才 `XACK`;未 ACK 消息在被认领后重投(at-least-once)。处理失败时**不 ACK**,自然重试;设最大投递次数上限,超限转死信(`XADD` 到 `dlq`)并告警,避免毒丸阻塞 |
|
||||||
|
| **幂等去重** | `MOD-DEDUP` | 以 `(account, email_id)` 为幂等键,`SET NX`(TTL 覆盖重投窗口)。重复投递直接跳过,保证 at-least-once 下**不重复通知**。TG 侧同理用 `update_id` |
|
||||||
|
| **Push 重试** | `FLOW-NEW-MAIL` | `/push/jmap` 校验/入队后**立即 2xx**;若入队失败(Redis 抖动)返回非 2xx,让 Stalwart 按自身策略重试;配合对账兜底 |
|
||||||
|
| **对账恢复** | `FLOW-RECONCILE` | 外部 Cron 每 5–10 分钟拉 `Email/changes` 补差,覆盖 Push 丢失/Redis 抖动/冷启动窗口;同时用于 Redis 全丢后的游标重建 |
|
||||||
|
| **指标 / 告警** | — | 导出指标:Push 到达数、去重命中率、Streams 积压深度(pending)、DLQ 条数、对账补发条数、JMAP 延迟、TG 推送失败率。对"积压持续增长 / DLQ 非空 / 对账补发异常升高"告警。**注**:`/ready` 现为占位 200(`ARCH-READY-PLACEHOLDER`),**尚不反映依赖健康**;待 `GATE-READY-DEPS` 落地后才可用于依赖告警 |
|
||||||
|
|
||||||
|
### 6.5 99.9% 可用性目标与边界(NFR-NOTIFY-SLA)
|
||||||
|
|
||||||
|
- **定义**:在外部依赖(Stalwart / Telegram / Redis)可用的前提下,通知链路的可用性目标 ≥ **99.9%**(约每月 ≤43 分钟不可用)。
|
||||||
|
- **允许的延迟**:用户已确认**接受少量通知延迟**——正常情况下 Push 回调应为秒级;依赖抖动/需对账兜底时,延迟上界为**对账间隔(≤10 分钟)**。
|
||||||
|
- **非目标边界**:99.9% 是**通知可用性**目标,不含 Stalwart/Telegram/Redis 自身故障时间;三者任一长时间不可用属外部依赖故障,不计入本 bot 的可用性预算(但 bot 应在恢复后经对账自动补齐)。
|
||||||
|
- **降级行为**:Redis 不可用时,去重/会话退化为进程内短窗口、Push 回调返回非 2xx 交 Stalwart 重试;恢复后由对账补齐。**不因 Redis 抖动而漏发最终通知**(以对账为准)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 健康检查(通用 HTTP 约定)
|
||||||
|
|
||||||
|
- `GET /healthz` → liveness(`ARCH-HEALTHZ`;进程存活,长期语义)
|
||||||
|
- `GET /ready` → **阶段0 为占位 `200`**(`ARCH-READY-PLACEHOLDER`),**不做** Redis PING / JMAP session / TG getMe。真实 readiness 检查(依赖不可用 → `503`)为**后续门禁 `GATE-READY-DEPS`**,随 `redis` 接入且 JMAP 只读 adapter 可用后实现。
|
||||||
|
- 两者均为**公开探针**(`SAF-PROBE-PUBLIC`):无鉴权、仅返回健康状态、**不含敏感信息**。
|
||||||
|
- ⚠️ 在 `GATE-READY-DEPS` 落地前,**不得**把 `/ready` 描述/当作"依赖健康检查",编排探针亦不得依赖其探测依赖。
|
||||||
|
- Docker `HEALTHCHECK` 指向同一监听端口(app 内置 `health` 子命令或 wget 同端口),**不依赖独立端口**(`C-NO-TCP-EXPOSE`)
|
||||||
|
- 仅保留通用平台映射(compose `HEALTHCHECK`、k8s probe);不写 Cloud Run/Fly 等特指内容(`NG-SERVERLESS-BIND`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. CI 发布与镜像验收
|
||||||
|
|
||||||
|
### 8.0 代码门禁(`GATE-P0`,进入阶段1前必须通过)
|
||||||
|
- `cargo fmt --check`
|
||||||
|
- `cargo clippy --all-targets -- -D warnings`
|
||||||
|
- `cargo test`
|
||||||
|
|
||||||
|
以上三条**在 Debian `rust:1-slim-bookworm` 容器内执行**(`C-DEBIAN-SLIM`);详见 design.md §10.0。
|
||||||
|
|
||||||
|
(其余 CI:镜像构建 → 镜像扫描 → 非 root → health 端点到容器验证;compose 示例收敛为单端口 + 外部 Redis URL + 无 volume)
|
||||||
|
|
||||||
|
### 8.1 docker-compose 示例(收敛)
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
bot:
|
||||||
|
image: stalwart-bot:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # 平台 ingress/反代映射 HTTPS → 8080(C-HTTPS-INBOUND)
|
||||||
|
environment:
|
||||||
|
PORT: "8080"
|
||||||
|
RUN_MODE: webhook
|
||||||
|
# BOT_TOKEN / JMAP_* / CHAT_ALLOWLIST / REDIS_URL
|
||||||
|
# RECONCILE_TOKEN / TG_WEBHOOK_SECRET / JMAP_PUSH_VERIFICATION(三入口鉴权,均为必填 SAF-AUTH-*)
|
||||||
|
healthcheck: { test: ["CMD", "stalwart-bot", "health", "--addr", "127.0.0.1:8080"], interval: 30s }
|
||||||
|
# REDIS_URL 指向外部、已认证的 Redis;不在此 compose 中运行 Redis。
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:本示例对接 Compose;真实部署可去掉 `ports` 直接挂到平台 ingress(HTTPS-only)。
|
||||||
|
|
||||||
|
### 8.2 镜像与部署验证检查项(红线自检)
|
||||||
|
|
||||||
|
以下检查在每次发布前/CI 中执行,用于固化 §0 新增红线:
|
||||||
|
|
||||||
|
| # | 检查项 | 关联 ID | 命令/方法 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **镜像内无数据库引擎**:不装/不含 SQLite、Postgres、MySQL 二进制或数据文件 | `C-NO-DB` | `docker run --rm <image> sh -c 'command -v sqlite3 psql mysql 2>/dev/null; [ -z "$(find / -maxdepth 4 -name "*.db" -o -name "*.sqlite*" 2>/dev/null)" ]'` 应为空 |
|
||||||
|
| 2 | **容器内不出现本地可写挂载**:compose/k8s 不声明 `volumes:` 用于数据/日志目录 | `C-NO-LOCAL-WRITE` / `NG-LOCAL-VOLUME` | 人工/CI lint 检查 compose 文件、k8s manifest 不含本地 volume 声明 |
|
||||||
|
| 3 | **日志仅 stdout/stderr**:应用启动参数不得含 file/rolling-file 后端;tracing 配置为 `stdout` | `C-LOG-STDOUT-ONLY` | 检查代码:`grep -rEn 'rolling\|FileAppender\|RollingFileAppender' src/` 应为空;`RUST_LOG` 目标不含 `file:` |
|
||||||
|
| 4 | **日志/Redis 无敏感数据**:日志字段白名单 + CI 扫描 | `SAF-LOG-PURITY` | 代码评审 + 静态扫描(如 `gitleaks` 扫描 `Redis` 写入调用,验证 `SETEX`/`SET` 参数无密钥/正文/AI 内容) |
|
||||||
|
| 5 | **重启恢复不依赖进程内状态**:所有"续跑"逻辑必须读 Redis;进程内缓存仅性能优化 | `C-NO-STATEFUL-RECOVERY` | 代码评审:`grep -rn 'static mut\|lazy_static' src/` 不得用于生产恢复路径 |
|
||||||
|
| 6 | **Redis 由外部提供**:容器不运行 `redis-server`;compose 不启动 Redis 服务 | `C-REDIS-MANAGED-AOF` | `docker run --rm <image> sh -c 'command -v redis-server && exit 1 || exit 0'` 应为 0 |
|
||||||
|
|
||||||
|
> 任一检查失败,禁止发布。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 已确认决策与少量待办
|
||||||
|
|
||||||
|
### 9.1 已确认决策(不再作为待确认问题)
|
||||||
|
|
||||||
|
| ID | 决策 |
|
||||||
|
|---|---|
|
||||||
|
| `REQ-SINGLE-ACCOUNT` | **单账户实现**;多账户暂用**多个 bot 实例**(各自 token/配置),不做多账户单实例 |
|
||||||
|
| `C-AUTH-APP-BASIC` | Stalwart 认证 = **App Password + Basic**(不用主密码、不用 OAuth) |
|
||||||
|
| `REQ-AI-EXTERNAL-CONSENT` | **仅当用户明确允许时**才把邮件正文发往外部 AI(默认不外发) |
|
||||||
|
| `C-REDIS-MANAGED-AOF` | Redis 由**用户托管**并**开启 AOF 持久化**(Bot 不自建/不管理 Redis) |
|
||||||
|
| `C-HTTPS-URL` | **公网 HTTPS 入口由平台提供**(平台给 bot 一个 HTTPS URL;bot 不持证书、不监听 443) |
|
||||||
|
| `NFR-RECONCILE-INTERVAL` | **外部 Cron 定期 HTTPS POST `/reconcile`,建议 5–10 分钟**;容器不自建定时器 |
|
||||||
|
| `NFR-NOTIFY-SLA` | **允许少量通知延迟;通知可用性目标 ≥ 99.9%**(边界见 §6.5) |
|
||||||
|
| `ARCH-LB-WORKER` | **多实例高可用**:免费 Cloudflare Worker 作为**唯一对外入口 + 故障转移**,后端为 2+ 个不同 serverless 平台的同镜像实例,共享同一 Redis(详见 §10) |
|
||||||
|
| `SAF-LB-PASSTHRU` | 信任模型 = **透传(A)**:Worker 不改写鉴权信息,**后端必须继续 fail-closed 校验**(小平台无防火墙/ACL,"后端不对公网暴露"不可行) |
|
||||||
|
| `SAF-RECONCILE-LOCK` | `/reconcile` **不扇出**:用 **Redis 锁**保证同一时刻仅一个实例执行 |
|
||||||
|
| `MOD-HEALTH-AGG` | Worker 暴露**聚合健康视图**,供外部监控 |
|
||||||
|
| `NFR-HA-MULTI-INSTANCE` | 双活或主备**均可**;**Redis 单点故障不在本方案范围**(用户外部解决,短暂不可用可接受) |
|
||||||
|
| `C-NO-DB` | **生产不使用任何数据库**:Redis 是唯一状态存储;应用不自建/不连接第二个数据库 |
|
||||||
|
| `C-NO-LOCAL-WRITE` | **禁止本地文件/目录写入**:无日志文件、无数据文件、无临时缓存、不挂载本地卷 |
|
||||||
|
| `C-LOG-STDOUT-ONLY` | **日志只写 stdout/stderr**(平台负责采集落盘);禁用文件日志后端 |
|
||||||
|
| `SAF-LOG-PURITY` | **日志与 Redis 写入不得包含**:密钥、邮件正文、AI 请求/响应、附件内容;仅允许结构化事件、计数、时间戳、脱敏摘要 |
|
||||||
|
| `C-NO-STATEFUL-RECOVERY` | **禁止依赖进程内状态做生产恢复**:重启恢复一律走 Redis + JMAP 对账(`FLOW-RECONCILE`);进程内缓存仅为性能优化,丢失必须安全可重入 |
|
||||||
|
|
||||||
|
### 9.2 仍需确认(部署相关,仅此)
|
||||||
|
|
||||||
|
| # | 待确认 | 关联 ID |
|
||||||
|
|---|---|---|
|
||||||
|
| **Q-DEP-A** | 平台 URL / 域名与证书由谁配置(平台自动证书 or 自管反代),以及 4 条路径的可达性验证方式 | `C-HTTPS-URL` |
|
||||||
|
| **Q-DEP-B** | 外部调度器具体选型(系统 crontab / k8s CronJob / CI scheduled / 第三方 cron)—— 仅影响运维方式,不改变架构 | `NFR-RECONCILE-INTERVAL` |
|
||||||
|
|
||||||
|
> 其余产品/架构问题见 design.md;不再有平台特定部署问题(`NG-SERVERLESS-BIND`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 多实例高可用与 Worker 前置负载均衡(`ARCH-LB-WORKER`)
|
||||||
|
|
||||||
|
> 目标:在**多个 serverless 平台各部署一份相同镜像**(各自不同的 HTTPS 入口),最前面用一个**免费 Cloudflare Worker** 作为**唯一对外入口 + 故障转移**;所有实例共享**同一个外部 Redis**。双活(round-robin)或主备(active-standby)**均可**。业务代码无需改动——之所以可行,是因为既有设计已满足前提(见下)。
|
||||||
|
|
||||||
|
### 10.1 拓扑(`ARCH-LB-WORKER`)
|
||||||
|
|
||||||
|
```
|
||||||
|
Telegram setWebhook ─┐
|
||||||
|
Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯一登记 URL(C-LB-SINGLE-REG-URL)
|
||||||
|
外部 Cron ───────────┘ │ Cloudflare Worker(免费)
|
||||||
|
├─▶ https://a.<platform1>/… ─┐
|
||||||
|
└─▶ https://b.<platform2>/… ─┼─▶ 同一个外部 Redis(C-REDIS-ONLY-STATE)
|
||||||
|
(同一镜像、同一配置、同一组 secret)─┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **只登记 Worker 的稳定 URL**(`C-LB-SINGLE-REG-URL`):Telegram `setWebhook`、Stalwart `PushSubscription.url`、外部 Cron **都指向 Worker**(`https://lb.example.com/webhook/tg`、`/push/jmap`、`/reconcile`)。后端易变的平台入口**不对外登记**。
|
||||||
|
- 后端 = 与 §3 相同的镜像、相同 env、相同 Redis;**不引入长连接**(Worker 纯请求-响应,`C-NO-LONG-CONN`)。
|
||||||
|
|
||||||
|
### 10.2 为什么可直接支持多实例(既有前提)
|
||||||
|
|
||||||
|
| 前提 | ID | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| 状态仅外部 Redis | `C-REDIS-ONLY-STATE` | 任意实例可服务任意请求,**无需粘性会话** |
|
||||||
|
| 幂等去重 | `MOD-DEDUP` | 重复/重试投递**不会重复通知**,故"多实例 + 重试"安全 |
|
||||||
|
| 对账补差 | `FLOW-RECONCILE` | 兜住 Push 丢失 / cold-start 窗口 |
|
||||||
|
|
||||||
|
> 因此 LB/HA 是**纯运维拓扑**变化;领域逻辑与渠道层无需改动。
|
||||||
|
|
||||||
|
### 10.3 信任模型 = **透传(A)**(`SAF-LB-PASSTHRU`)
|
||||||
|
|
||||||
|
- Worker **原样转发** header / body / `verificationCode`,**不做鉴权改写**。
|
||||||
|
- **后端必须继续 fail-closed 校验**三条入口(`SAF-AUTH-*`)——**不可省**。原因:小平台通常**不提供防火墙/ACL**,后端 HTTPS 入口可能被公网直连;因此"仅靠 Worker 防护"不成立(方案 B「后端不对公网暴露」在本场景**不可行**)。
|
||||||
|
- **多实例必须共享同一组 secret**(`C-LB-SHARED-SECRETS`):`TG_WEBHOOK_SECRET` / `JMAP_PUSH_VERIFICATION` / `RECONCILE_TOKEN` 在所有实例上**完全一致**,否则请求落到不同实例会随机 `401`(对端发来的值只有一个)。
|
||||||
|
|
||||||
|
### 10.4 路由与故障转移
|
||||||
|
|
||||||
|
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 `GET /`、SPA 静态资源、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
|
||||||
|
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 仍**透传**给后端,由 `GATE-READY-DEPS` 承担真实依赖探测。
|
||||||
|
- **故障转移(`proxyWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。
|
||||||
|
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。
|
||||||
|
- **全失败兜底**:返回 `503 All Backends Unavailable`,交由 Telegram / Stalwart 自动重投(**不丢消息**)。
|
||||||
|
- **超时预算**(`LB_REQUEST_TIMEOUT_MS`,默认 10s)> 最坏 cold start。
|
||||||
|
- **重复兜底**:Worker 重试 + Telegram 重投造成的重复由 `MOD-DEDUP` 吸收;**幂等键 TTL 必须覆盖** Worker 重试与 Telegram 重投窗口。
|
||||||
|
- **不代理 Redis/JMAP**(`C-NO-DB` / `C-REDIS-ONLY-STATE`):Worker 只做请求转发,后端各连自己的 Redis;Worker 不做数据库侧检查。
|
||||||
|
- **无长连接**(`C-NO-LONG-CONN`):纯请求-响应,body 一次性 `arrayBuffer` 回灌;无 WS/SSE/长轮询。
|
||||||
|
|
||||||
|
### 10.5 各入口差异(重要)
|
||||||
|
|
||||||
|
| 入口 | 是否可扇出/重复 | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/webhook/tg` | 可安全重复(`update_id` 去重) | Worker 选一实例;重试安全 |
|
||||||
|
| `/push/jmap` | 可安全重复(`(account,email_id)` 去重) | Worker 选一实例 |
|
||||||
|
| `/reconcile` | ❌ **不可扇出** | **Redis 锁**(`SAF-RECONCILE-LOCK`)保证**同一时刻仅一个实例执行**,避免重复对账 |
|
||||||
|
| Streams worker | 用**相同消费组名**(`MOD-STREAMS-GROUP`) | Redis `XREADGROUP` **自动分摊**给多实例;at-least-once 下同一消息不会重复处理 |
|
||||||
|
|
||||||
|
### 10.6 聚合健康视图(`MOD-HEALTH-AGG`)
|
||||||
|
|
||||||
|
- Worker 额外暴露**聚合端点**,报告各后端存活数与整体可用性,供外部监控/告警使用;比单实例探针更有意义。
|
||||||
|
|
||||||
|
### 10.7 边界与不在本方案范围
|
||||||
|
|
||||||
|
- **Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`):由用户在外部解决;其短暂不可用**可接受**(Redis 全丢可由 `FLOW-RECONCILE` 从 JMAP 重建 sinceState;dedup/会话丢失只导致**少量重复通知**,符合 `NFR-NOTIFY-SLA`)。
|
||||||
|
- **Cloudflare Worker 免费额度**:预期足够(requests/day、subrequest、CPU/超时上限需上线后实测确认)。
|
||||||
|
- 不引入长连接、不引入平台特定绑定(`C-NO-LONG-CONN`/`NG-SERVERLESS-BIND`)。
|
||||||
|
|
||||||
|
### 10.8 部署 Cloudflare Worker(`ARCH-LB-WORKER` 具体步骤)
|
||||||
|
|
||||||
|
代码位于仓库子目录 [`cloudflare-worker/`](../cloudflare-worker/),**非本 Rust 二进制的一部分**;Worker 只做请求转发,不运行 Redis/JMAP,不引入数据库(`C-NO-DB`)。
|
||||||
|
|
||||||
|
**前置**
|
||||||
|
- 已按 §3 部署 ≥1 个后端镜像(多实例则部署 2+ 个),各后端 `PORT`/路由同 §6.1。
|
||||||
|
- 后端各实例 env 必须共享同一组 secret(`C-LB-SHARED-SECRETS`)。
|
||||||
|
- 已注册 Cloudflare 账号与 Workers 计划。
|
||||||
|
|
||||||
|
**配置**(所有值通过 `wrangler secret` 注入,**禁止**写入 `wrangler.toml` 或 `git` 明文)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cloudflare-worker
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
npx wrangler secret put BACKEND_ORIGINS_JSON # JSON 数组,见下方示例
|
||||||
|
# 可选(带默认值)
|
||||||
|
npx wrangler secret put LB_REQUEST_TIMEOUT_MS # 默认 10000ms
|
||||||
|
npx wrangler secret put LB_MAX_ATTEMPTS # 默认 2(首次 + 1 次故障转移)
|
||||||
|
npx wrangler secret put LB_HEALTH_TTL_MS # 默认 30000ms
|
||||||
|
```
|
||||||
|
|
||||||
|
`BACKEND_ORIGINS_JSON` 示例(**仅允许 `https://` origin**,其它会被 Worker 启动即 503,`C-HTTPS-INBOUND`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"url":"https://stalwart-bot-a.example.com","weight":100},
|
||||||
|
{"url":"https://stalwart-bot-b.example.com","weight":100}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**部署与验证**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cloudflare-worker
|
||||||
|
npx wrangler deploy --env production
|
||||||
|
# 验证 LB 健康聚合
|
||||||
|
curl https://<your-worker>.workers.dev/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
**Telegram / Stalwart / Cron 登记的唯一 URL** 改为 `https://<your-worker>.workers.dev/webhook/tg` 等(`C-LB-SINGLE-REG-URL`);**各后端平台的 HTTPS 入口不再对外登记**(小平台入口可能被公网直连,故**后端鉴权不可省**,`SAF-LB-PASSTHRU`)。
|
||||||
|
|
||||||
|
**验证(发布前必做)**
|
||||||
|
1. `cd cloudflare-worker && npm test`(Node 单测,代理逻辑 + 健康聚合 + 超时/失败重试 + safelist)。
|
||||||
|
2. `npx wrangler deploy --env production` 后 `curl /healthz` 返回 200 且 `available ≥ 1`。
|
||||||
|
3. `curl -X POST https://<worker>/webhook/tg -H "Content-Type: application/json" -d '{"test":1}'` 应得到后端返回(非 503)。
|
||||||
|
4. `curl -X POST https://<worker>/unknown` 应得到 404;`curl -X GET https://<worker>/reconcile` 应得到 405。
|
||||||
|
5. 日志仅出现 `method/path/origin/失败类别`,**绝不**含 header/body/secret(`SAF-LOG-PURITY`)。
|
||||||
|
|
||||||
|
**边界说明**
|
||||||
|
- Worker **未**实现 `/reconcile` 的 Redis 锁(`SAF-RECONCILE-LOCK`)——该锁由**后端**在 `/reconcile` 处理器内部执行;Worker 只做透传。详见 `SAF-RECONCILE-LOCK` 条目。
|
||||||
|
- Worker **不代理** Redis、不检查数据库侧可用性(`C-NO-DB` / `C-REDIS-ONLY-STATE`)。
|
||||||
|
- Worker **不落地** JMAP/Telegram 的 secret(`TELEGRAM_WEBHOOK_SECRET`/`JMAP_PUSH_VERIFICATION` 等属于**后端**容器,见 `deployment.md §5` 与 `.env.example`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:稳定 ID 引用索引
|
||||||
|
|
||||||
|
> 跨文档稳定 ID 的**唯一权威索引表**在 `AGENTS.md` §7「跨文档引用索引表」。
|
||||||
|
> 本文使用到的 ID(`C-*` / `NG-*` / `MOD-*` / `FLOW-*` 等)均在其中登记定义点位置与一句话说明;内容搬家时**只改 AGENTS.md 索引表**的"文件/锚点"列,所有引用本身零改动。
|
||||||
|
|
@ -0,0 +1,808 @@
|
||||||
|
# Stalwart JMAP ↔ Telegram Bot — Rust 方案设计
|
||||||
|
|
||||||
|
> 状态:**调研与方案设计**(未写代码)
|
||||||
|
> 作者:Cowork(team: stalwart-bot)
|
||||||
|
> 日期:2026-09-21
|
||||||
|
> 目标读者:Codex CLI(lead)、后续 AI coding agent、最终用户评审
|
||||||
|
>
|
||||||
|
> **相关文档**(职责分离,避免重复堆砌):
|
||||||
|
> - [AGENTS.md](../AGENTS.md) — 给后续 AI coding agent 的硬性安全边界、实现顺序、禁止事项、测试验收与文档引用关系
|
||||||
|
> - [docs/deployment.md](deployment.md) — 部署运维与发布:通用 HTTPS-only Docker 容器、Secrets、Redis(状态唯一载体)、短请求 Webhook/Push/对账路由、健康检查、CI 发布与 4 项待确认
|
||||||
|
>
|
||||||
|
> 本文档只保留与**产品行为、代码架构、模块接口、状态机、数据流、错误处理、测试和实施阶段**直接相关的内容。部署运维、Docker/通用容器平台细节与给 coding agent 的通用操作指令已分别移至 `deployment.md` 与 `AGENTS.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 文档导航
|
||||||
|
|
||||||
|
1. [任务与范围](#1-任务与范围)
|
||||||
|
2. [jmap-client 能力分析](#2-jmap-client-能力分析)
|
||||||
|
3. [认证与邮箱操作适配](#3-认证与邮箱操作适配)
|
||||||
|
4. [Telegram Rust 框架选型](#4-telegram-rust-框架选型)
|
||||||
|
5. [整体架构与数据流](#5-整体架构与数据流)
|
||||||
|
6. [模块划分](#6-模块划分)
|
||||||
|
7. [配置与安全](#7-配置与安全)
|
||||||
|
8. [错误处理与可观测性](#8-错误处理与可观测性)
|
||||||
|
9. [测试策略](#9-测试策略)
|
||||||
|
10. [分阶段实施计划](#10-分阶段实施计划)
|
||||||
|
11. [待确认问题(产品/架构类)](#11-待确认问题产品架构类)
|
||||||
|
12. [AI 辅助能力:架构、确认门槛、失败回退](#12-ai-辅助能力架构确认门槛失败回退)
|
||||||
|
|
||||||
|
> 部署/平台类决策已确认(单账户、App Password+Basic、Redis 托管+AOF、平台 HTTPS URL、外部 Cron)并收敛;剩余待办仅 `Q-DEP-A`/`Q-DEP-B`,见 deployment.md §9。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 任务与范围
|
||||||
|
|
||||||
|
### 1.1 目标
|
||||||
|
构建一个 Rust 写的 Telegram 机器人,作为 Stalwart JMAP 邮箱的**个人邮件助手**:
|
||||||
|
|
||||||
|
- 通过 Telegram 命令查询/阅读邮件、查看文件夹、发送邮件、管理关键词等。
|
||||||
|
- 利用 JMAP 的 **Push HTTPS 回调**(+ 外部 Cron 对账兜底)在新邮件到达时**主动推送到 Telegram**。(EventSource/SSE/长轮询为非目标,见 deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`。)
|
||||||
|
- 单用户或多账户部署(默认面向单账户自托管场景)。
|
||||||
|
|
||||||
|
### 1.2 范围(本阶段)
|
||||||
|
- ✅ 调研 + 方案设计(本文档)
|
||||||
|
- ❌ 不创建/修改项目代码、不初始化 cargo 工程
|
||||||
|
- ✅ 输出可供 lead 与用户评审的详细方案,标注关键不确定项
|
||||||
|
|
||||||
|
### 1.3 调研依据
|
||||||
|
- [`stalwartlabs/jmap-client`](https://github.com/stalwartlabs/jmap-client)(main 分支,截至本次调研)
|
||||||
|
- 项目目录:`/home/okabe/Repo/stalwart-bot/`(greenfield,首次构建需装 Rust 工具链,见 §10.0 与 AGENTS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. jmap-client 能力分析
|
||||||
|
|
||||||
|
来源:直接阅读 `stalwartlabs/jmap-client` 仓库 `src/lib.rs`、`src/client.rs`、`src/email/`、`src/email_submission/`、`src/event_source/`、`Cargo.toml`、`README.md`、`examples/`。
|
||||||
|
|
||||||
|
### 2.1 crate 概况
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| crate 名 | `jmap-client`(crates.io) |
|
||||||
|
| 协议覆盖 | JMAP Core (RFC 8620)、Mail (RFC 8621)、WebSocket (RFC 8887)、Sieve (draft-12) |
|
||||||
|
| 异步运行时 | tokio + reqwest |
|
||||||
|
| 许可证 | Apache-2.0 OR MIT |
|
||||||
|
| `forbid(unsafe_code)` | 是(lib.rs 顶部声明)✅ |
|
||||||
|
| 默认 features | **0.4.2 实测 `default = ["async", "websockets", "aws_lc_rs"]`**(含 WebSocket 栈 `tokio-tungstenite`)。⚠️ `default-features = true` 会**隐式启用 WebSocket**,违反 `C-NO-LONG-CONN`。**本项目实际选用 `default-features = false, features = ["async", "rustls"]`(无 `websockets`)**,以 `Cargo.toml` 为准。 |
|
||||||
|
|
||||||
|
### 2.2 模块结构(`src/`)
|
||||||
|
```
|
||||||
|
lib.rs 顶层:URI / Method / DataType / PushObject / Error 枚举
|
||||||
|
client.rs Client:认证、connect、session、send_request、event_source、ws
|
||||||
|
core/ request / response / get / set / query / query_changes / changes / error / session
|
||||||
|
email/ mod + helpers(email_get/email_query/email_import/email_copy/email_set/email_parse…)
|
||||||
|
email_submission/ helpers(email_submission_get/query/set → 发送邮件 + 状态查询)
|
||||||
|
mailbox/ helpers(mailbox_create/query/get/set/destroy…)
|
||||||
|
thread/ thread/get
|
||||||
|
identity/ Identity(发件身份)get/set
|
||||||
|
blob/ blob 上传/复制(附件)
|
||||||
|
sieve/ SieveScript(服务端过滤脚本)
|
||||||
|
vacation_response/
|
||||||
|
principal/ Principal(账户/共享)
|
||||||
|
push_subscription/ PushSubscription(HTTP push 回调注册)
|
||||||
|
event_source/ SSE 流:mod / parser / stream
|
||||||
|
client_ws/ WebSocket 客户端(feature = "websockets")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 关键能力映射到本 Bot
|
||||||
|
| Bot 需求 | jmap-client API | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| 登录/会话 | `Client::new().credentials(...).connect(url)` | 支持 Basic 与 Bearer;connect 解析 session URL、capabilities |
|
||||||
|
| 列文件夹 | `mailbox_query` / `mailbox_get` | 带 `role` 可识别 INBOX/重要/草稿等 |
|
||||||
|
| 列邮件 | `email_query`(Filter + Comparator + anchor 分页) | Filter: `subject`/`from`/`to`/`in_mailbox`/`has_keyword`/`after`/`before`… |
|
||||||
|
| 读邮件正文 | `email_get` + `Property` 选择 | `BodyStructure`/`BodyValues`/`Preview`/`TextBody`/`HtmlBody` |
|
||||||
|
| 读附件 | `Blob/get`(blobId)或 `email_parse` | 大附件需分片/流式下载 |
|
||||||
|
| 发邮件(2 步) | ① `email_set`/`email_import` 建 draft ② `email_submission_set` 发送 | submission 关联 identityId |
|
||||||
|
| 删除/归档 | `email_set`(keywords `$seen`/`$flagged`)、`mailbox_destroy` | JMAP 无真"删除",靠 keyword/搬家 |
|
||||||
|
| 搜索 | `email_query` Filter + `SearchSnippet/get` 高亮 | |
|
||||||
|
| 实时通知(**唯一**通道) | `push_subscription` 注册 HTTPS 回调 → `StateChange` | 需公网 HTTPS 入口(deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`);+ 外部 Cron 对账兜底 |
|
||||||
|
| SSE / WebSocket(非目标) | `event_source` / `client_ws` | 本部署**不使用**(deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`);仅列 crate 能力供调研 |
|
||||||
|
|
||||||
|
### 2.4 认证机制(`client.rs`)
|
||||||
|
- `Credentials::Basic { username, secret }` — 用户名/密码,Stalwart 原生支持。
|
||||||
|
- `Credentials::Bearer { token, .. }` — OAuth2 access_token;可选 `refresh_token` + `refresh_url` + `refresh_grace`,client 会在过期前自动刷新。
|
||||||
|
- `connect()` 作用:GET session URL → 解析 `accounts`/`capabilities`/`download_url`/`upload_url`/`event_source_url`,缓存 account_id。
|
||||||
|
- 支持自定义 `reqwest::Client`(`Client::new().client(reqwest_client)`):可注入代理、TLS 配置、超时、UA。
|
||||||
|
- 支持 `accept_long_responses` / `event_source(ping, ..)` 用于长连接保活。
|
||||||
|
|
||||||
|
### 2.5 错误模型(`Error` 枚举)
|
||||||
|
```
|
||||||
|
Transport(reqwest::Error) 网络/TLS/超时
|
||||||
|
Parse(serde_json::Error) 序列化
|
||||||
|
Internal(String) 客户端内部
|
||||||
|
Problem(Box<ProblemDetails>) JMAP problem-details
|
||||||
|
Server(String)
|
||||||
|
Method(MethodError) JMAP method-level 错误(NotJSON/Forbidden/RateLimit/StateMismatch…)
|
||||||
|
Set(SetError<String>) /set 级别错误(每条记录的 creation/update/destroy 失败)
|
||||||
|
WebSocket(...) ws 错误(feature 开启时)
|
||||||
|
```
|
||||||
|
- `MethodErrorType` 细粒度:`ServerUnavailable`/`ServerFail`/`RateLimit`/`InvalidArguments`/`Forbidden`/`StateMismatch`/`TooManyChanges`… → 可直接驱动 Bot 的重试/限流/状态重置策略。
|
||||||
|
|
||||||
|
### 2.6 实时通道决策(已定,见 deployment.md)
|
||||||
|
|
||||||
|
> 部署目标 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`)。
|
||||||
|
> 据此**唯一通道 = JMAP Push HTTPS 回调**(`push_subscription` + verificationCode 握手)+ 外部 Cron 对账兜底。
|
||||||
|
> EventSource/SSE 与 WebSocket 均标记为**非目标**(`NG-POLLING-SSE`),仅保留下表作 crate 能力调研参考。
|
||||||
|
|
||||||
|
| 通道 | 是否采用 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Push Subscription(HTTP 回调)** | ✅ **唯一采用** | 短请求模型,契合无状态 + 无长连接;`jmap-client` 提供 create/verify/update/destroy |
|
||||||
|
| EventSource/SSE | ❌ 非目标 | 长连接,与 `C-NO-LONG-CONN` 冲突(`NG-POLLING-SSE`) |
|
||||||
|
| WebSocket(RFC 8887) | ❌ 非目标 | 长连接,与 `C-NO-LONG-CONN` 冲突;且需服务端支持 |
|
||||||
|
|
||||||
|
对账兜底:外部 HTTPS Cron 周期调 `/reconcile`(deployment.md `FLOW-RECONCILE`),用 `Email/changes` + Redis `sinceState` 补差,兼做 Redis 丢失后的游标重建。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 认证与邮箱操作适配
|
||||||
|
|
||||||
|
### 3.1 认证策略(已确认:App Password + Basic)
|
||||||
|
- **确认采用**(`C-AUTH-APP-BASIC`):`Credentials::Basic`(Stalwart 账户邮箱 + **应用专用密码 App Password**)。App Password 可独立吊销/设到期,不用主密码。配置注入,不入代码。
|
||||||
|
- **不采用**:OAuth/OIDC `Bearer`(无 OIDC 需求时增加复杂度,不选);主密码 Basic。
|
||||||
|
- **安全**:密码仅存配置或密钥管理器;运行期用 `secrecy::SecretString` 包裹,日志永不打印明文(见 §7.2)。
|
||||||
|
- 运行期注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
|
||||||
|
- **单账户**(`REQ-SINGLE-ACCOUNT`):本实例只接一个 Stalwart 账户;多账户 = 部署多个 bot 实例。
|
||||||
|
|
||||||
|
### 3.2 邮箱操作适配层(JMAP ↔ Bot 语义)
|
||||||
|
建议在 `domain::jmap` 模块封装一层领域语义,对上层只暴露业务动词:
|
||||||
|
|
||||||
|
| Bot 动词 | 封装函数 | 内部 JMAP |
|
||||||
|
|---|---|---|
|
||||||
|
| `list_folders()` | → `Vec<Folder>` | `mailbox_query` + `mailbox_get`(缓存 role→id) |
|
||||||
|
| `list_emails(folder, page)` | → `Vec<EmailSummary>` | `email_query`(anchor 分页) + `email_get`([Subject, From, Preview, ReceivedAt, HasAttachment]) |
|
||||||
|
| `read_email(id, want_body)` | → `EmailBody { text, html, attachments }` | `email_get`([BodyStructure, BodyValues, BlobIds]);**多 part 原文**(`REQ-JMAP-RAW-MULTIPART`):按 `text_body` 顺序筛选"有 `part_id` 且 `bodyValue`"的 part 后**拼接**为 `text`;若无可用部分 → 返回**明确错误**(不静默返回空串)。附件用 `Blob/get` |
|
||||||
|
| `send_email(to, subject, body, attachments)` | → `EmailId` | ① `email_import`/`email_set` 建 draft ② `email_submission_set`(onSend) |
|
||||||
|
| `set_flag(id, keyword)` | → () | `email_set` keywords |
|
||||||
|
| `search(query)` | → `Vec<EmailSummary>` | `email_query`(Filter::and[…]) + `SearchSnippet/get` |
|
||||||
|
|
||||||
|
> 设计要点:`email_query` 的 `anchor`+`position` 分页是 JMAP 标准做法,比传统 offset 更稳;`sinceState` + `changes` 用于增量同步,避免重复拉全量。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Telegram Rust 框架选型
|
||||||
|
|
||||||
|
### 4.1 候选评估
|
||||||
|
| 框架 | crate | 维护状态 | 特性 | 适配度 | 结论 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **teloxide** | `teloxide` | 活跃,最新 0.17,下载量大 | dptree 分发、对话 FSM、`webhooks`+`webhooks-axum`、Redis 会话存储、`throttle`、`macros`、`tracing`、`rustls` | ★★★★★ | **首选** |
|
||||||
|
| grammers | `grammers` / `grammerslib` | 维护一般 | MTProto(非 Bot API),无需 Telegram Bot Token | ★★ | 仅在不能用 Bot API 时 |
|
||||||
|
| telegram-bot (旧) | `telegram-bot` | 基本停更 | reqwest + futures | ★ | 不推荐 |
|
||||||
|
|
||||||
|
### 4.2 选定 teloxide 的理由
|
||||||
|
1. 与 jmap-client 同为 tokio + reqwest 生态,运行时与 TLS 栈(rustls)可复用。
|
||||||
|
2. 内建 Dispatcher + UpdateKind 枚举匹配命令,与 Bot 的命令路由天然契合。
|
||||||
|
3. 支持 `webhooks-axum`(生产 Webhook 形态;**不使用长轮询** `NG-LONG-POLLING`)与 Redis 会话存储(记住用户上下文:当前选中的文件夹/分页游标;不用 SQLite)。
|
||||||
|
4. `throttle` feature 天然契合 Telegram 的 30 msg/s 速率限制。
|
||||||
|
5. `tracing` feature 与本项目观测性统一。
|
||||||
|
6. `macros` feature 可用 `#[teloxide::command]` 自动解析命令参数,减少样板。
|
||||||
|
|
||||||
|
### 4.3 teloxide 关键 feature 选择(**计划,阶段2 引入**)
|
||||||
|
|
||||||
|
> ⚠️ 阶段0 **未引入 teloxide**(`ARCH-DEPS-STAGE0`);下方为阶段2 引入时的目标 feature 集,不得据此认为 `Cargo.toml` 已含 teloxide。
|
||||||
|
|
||||||
|
```
|
||||||
|
# 阶段2 计划(尚未加入 Cargo.toml)
|
||||||
|
teloxide = { version = "0.17", features = [
|
||||||
|
"macros", "rustls", "rustls-native-roots", # TLS 与 jmap-client 一致
|
||||||
|
"redis-session", # 无状态会话:走外部 Redis(deployment.md C-REDIS-ONLY-STATE)
|
||||||
|
"throttle", # 遵守 TG 速率限制
|
||||||
|
"tracing",
|
||||||
|
] }
|
||||||
|
# 部署形态 = Webhook(非长轮询,deployment.md NG-LONG-POLLING);按需启用 "webhooks-axum"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 备选/降级方案
|
||||||
|
- 若 teloxide 升级/破坏性改动 → 可退到直接基于 `teloxide-core`(更薄、API 稳定)。
|
||||||
|
- 若需更高吞吐(多账户)→ 用 `webhooks-axum` + 共享 `axum::Router`(与阶段0 的单端口 axum 入口叠加)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 整体架构与数据流
|
||||||
|
|
||||||
|
### 5.1 高层拓扑
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────┐
|
||||||
|
│ Telegram │
|
||||||
|
└───────────────────────┬───────────────────────────┘
|
||||||
|
Webhook(HTTPS) │
|
||||||
|
┌───────────────────────▼───────────────────────────┐
|
||||||
|
│ HTTP 入口 (axum/teloxide-webhooks,单端口 PORT) │
|
||||||
|
│ POST /webhook/tg · POST /push/jmap · │
|
||||||
|
│ POST /reconcile · GET /healthz /ready │
|
||||||
|
│ GET / + admin session + config APIs (管理 SPA) │
|
||||||
|
└───────────────────────┬───────────────────────────┘
|
||||||
|
命令 │ 通知 ↓(Redis Streams worker)
|
||||||
|
┌───────────────────────▼───────────────────────────┐
|
||||||
|
│ bot 层 (handlers) │
|
||||||
|
│ /list /read /send /search /folders /flag ... │
|
||||||
|
└───────────────────────┬───────────────────────────┘
|
||||||
|
领域 │
|
||||||
|
┌───────────────────────▼───────────────────────────┐
|
||||||
|
│ jmap 适配层 (JmapService) │
|
||||||
|
│ list_folders/list_emails/read_email/send_email… │
|
||||||
|
│ + sinceState 缓存 → 外部 Redis (MOD-SINCESTATE) │
|
||||||
|
└───────────────────────┬───────────────────────────┘
|
||||||
|
JMAP │ HTTPS(短请求)
|
||||||
|
┌───────────────────────▼───────────────────────────┐
|
||||||
|
│ Stalwart JMAP Server │
|
||||||
|
│ session_url / email/* / mailbox/* / submission/* │
|
||||||
|
└───────────────────────▲───────────────────────────┘
|
||||||
|
Push 回调(HTTPS)│ + 外部 Cron 对账(FLOW-RECONCILE)
|
||||||
|
┌───────────────────────┴───────────────────────────┐
|
||||||
|
│ push handler → Redis Streams(MOD-STREAMS) │
|
||||||
|
│ worker: Email/changes → 通知 → 发往 TG → 推进游标│
|
||||||
|
└───────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
> 部署形态 = **通用 HTTPS-only Docker、无长连接**(deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`):
|
||||||
|
> Telegram 仅 Webhook;JMAP 实时仅 Push 回调;慢任务异步入 Redis Streams;对账由**外部 HTTPS Cron** 触发。
|
||||||
|
> EventSource/SSE 与长轮询均为非目标(`NG-POLLING-SSE`/`NG-LONG-POLLING`)。
|
||||||
|
> 渠道解耦(见 §5.2):领域层(`domain/`)与渠道层(`channel/`)以领域 Command / Notification 交互,不出现 Telegram/teloxide 类型;钉钉/飞书仅保留扩展位,不提前实现。
|
||||||
|
|
||||||
|
### 5.2 渠道抽象与多通道扩展策略
|
||||||
|
- **现状**:首个(也是当前唯一)渠道是 Telegram。目标是保留平行扩展钉钉/飞书的能力,**但不提前实现**。
|
||||||
|
- **原则**:领域层与渠道层解耦——邮件/JMAP/AI/意图状态机**不得依赖 Telegram 类型**。
|
||||||
|
- **抽象(不过度设计)**:
|
||||||
|
|
||||||
|
| 抽象 | 职责 | 首个实现 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Channel` | 渠道生命周期:接收输入、分发命令、配置端点 | `TelegramChannel`(teloxide Dispatcher) |
|
||||||
|
| `Notifier` | 主动推送:把领域 `Notification` 发送到用户 | `TelegramNotifier`(teloxide requester) |
|
||||||
|
| `MessageAdapter` | 领域数据 ↔ 渠道消息渲染(文本/按钮/转义) | `TelegramMessageAdapter`(`render.rs`) |
|
||||||
|
|
||||||
|
- 领域与渠道之间用**领域 Command / 领域 Notification** 数据结构传递,渠道只在边缘做适配(解析→领域 Command;领域 Notification→渲染)。
|
||||||
|
- **约束**:
|
||||||
|
- `jmap` / `llm` / 意图状态机 / `notify::core` 的公开接口只接受/返回领域类型;
|
||||||
|
- `teloxide` 类型只允许出现在 `channel/telegram/` 内;
|
||||||
|
- 新增渠道 = 新 adapter + 装配,不改领域层。
|
||||||
|
- **不做什么**:不定义多态配置注册表、不预先抽象"渠道能力矩阵"、不建 plugins 机制;按需再演进(YAGNI)。
|
||||||
|
|
||||||
|
### 5.3 运行模型(短请求,无长连接)
|
||||||
|
- 单一二进制 `stalwart-bot`,`#[tokio::main]`。
|
||||||
|
- 启动时:
|
||||||
|
1. 加载配置(`config::Config`)。
|
||||||
|
2. 构造 `JmapService`(`Client::connect` 完成 session 解析、account_id 缓存、mailbox role→id 映射预热);sinceState 从外部 Redis 恢复(`MOD-SINCESTATE`)。
|
||||||
|
3. 启动 **HTTP 入口**(axum,单端口 `PORT`):`/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`;其中三条写路径先经 `SAF-AUTH-*` 入口鉴权(fail-closed,§7.3),`/healthz`、`/ready` 为公开探针(`SAF-PROBE-PUBLIC`;**`/ready` 现阶段为占位 200**,`ARCH-READY-PLACEHOLDER`)。
|
||||||
|
4. 启动 **Redis Streams worker**(后台 task)消费 Push 事件 → `Email/changes` → 通知 → 发往 TG → 推进 sinceState → XACK。
|
||||||
|
5. **不持有任何长连接、不自建定时器**(`C-NO-LONG-CONN`);对账由**外部 HTTPS Cron** 触发 `/reconcile`(`FLOW-RECONCILE`)。
|
||||||
|
- 通知发送与命令处理共享 `Arc<JmapService>`,内部 `tokio::sync::RwLock` 保护可变缓存;跨请求状态一律落外部 Redis(`C-REDIS-ONLY-STATE`)。
|
||||||
|
- **多实例与负载均衡(部署形态,`ARCH-LB-WORKER`)**:由于状态全在外部 Redis(`C-REDIS-ONLY-STATE`)且投递幂等(`MOD-DEDUP`),**同一镜像可跨多个 serverless 平台实例化**,前面用免费 Cloudflare Worker 做唯一入口与故障转移(`C-LB-SINGLE-REG-URL`);`/reconcile` 用 Redis 锁单实例执行(`SAF-RECONCILE-LOCK`),Streams 用同一消费组自动分摊(`MOD-STREAMS-GROUP`)。详见 deployment.md §10。**领域/渠道逻辑无需改动。**
|
||||||
|
- **生产红线(`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`,详见 deployment.md §0 / §8.2)**:
|
||||||
|
- **生产不使用任何数据库**(`C-NO-DB`):无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一生产状态存储。
|
||||||
|
- **禁止本地文件/目录写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷。
|
||||||
|
- **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):容器/平台负责采集落盘;禁用文件日志后端。
|
||||||
|
- **日志与 Redis 写入内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏摘要;**禁止**密钥原文、JMAP 邮件正文、AI 请求/响应、附件内容。
|
||||||
|
- **禁止依赖进程内状态做生产恢复**(`C-NO-STATEFUL-RECOVERY`):重启续跑(去重、sinceState、Streams 断点、熔断计数、会话)一律由外部 Redis + JMAP 对账实现;进程内缓存仅为性能优化,丢失必须安全可重入。
|
||||||
|
- 优雅退出:信号 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(**不写本地文件**,`C-NO-LOCAL-WRITE`)。
|
||||||
|
- **阶段0 现状**(`ARCH-STAGE0`):完成 ①加载配置(env)、③HTTP 入口路由,并**已实现三入口鉴权(`R1`/`SAF-AUTH-*`,fail-closed)**;业务体仍为无副作用占位。②④(JmapService / Redis Streams worker)为阶段1/4 目标,尚未实现。`GATE-P0` 未全项通过前**不应暴露公网**。
|
||||||
|
|
||||||
|
### 5.4 数据流:新邮件推送(关键路径,FLOW-NEW-MAIL)
|
||||||
|
```
|
||||||
|
Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state})
|
||||||
|
→ 校验 verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK
|
||||||
|
→ worker: XREADGROUP → 用 sinceState(存 Redis, MOD-SINCESTATE) 调 Email/changes → 取 created[] 的 id
|
||||||
|
→ email_get([From, Subject, Preview, ReceivedAt, HasAttachment])
|
||||||
|
→ notify::core 组装领域 Notification(仅元数据 + 行内按钮意图,绝不含正文)
|
||||||
|
→ channel::Notifier.send(chat, notification)(Telegram adapter 渲染并发送)
|
||||||
|
→ 更新 sinceState(写外部 Redis)→ XACK
|
||||||
|
→ 若 Redis 丢失:由外部 Cron 对账(FLOW-RECONCILE) 重建游标并补发
|
||||||
|
```
|
||||||
|
> 关键:`Email/changes` 在 `created` 列出新建邮件 id,避免全量 `email_query`;`sinceState` 存外部 Redis(`C-REDIS-ONLY-STATE`),Redis 丢失时由对账从 JMAP 重建,**事实源在 JMAP**。
|
||||||
|
> 通知只含发件人/主题/时间(+附件数),正文绝不出现在通知里(见 AGENTS.md 安全边界 `SAF-NOTIFY-META`)。
|
||||||
|
> 无长连接:Push 为短请求回调,对账由**外部 HTTPS Cron**触发(`C-NO-LONG-CONN`),非容器内自持定时器。
|
||||||
|
|
||||||
|
### 5.5 数据流:命令 `/read <seq>`(长邮件分支)
|
||||||
|
```
|
||||||
|
TG /read 3 → handler 取会话里的 folder+page 游标
|
||||||
|
→ JmapService.list_emails(folder, page=3)
|
||||||
|
→ 取第 3 封 id → read_email(id, body=true) # JMAP 直取,绝不经 LLM
|
||||||
|
→ 格式化:
|
||||||
|
正文 ≤ long_email_char_limit(默认 4000) → 正常发送(HTML escape)
|
||||||
|
正文 > 阈值 → 不发全文,发 Preview(120) + 发件人/主题/附件概览 + 选项:
|
||||||
|
"[AI 总结](约300字,需确认) / [继续查看原文(截断,标注‘完整请电脑查看’)]"
|
||||||
|
→ reply
|
||||||
|
("AI 总结"分支必须用户确认后才把正文交 LLM 生成 ~300 字摘要;见 §12.3)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 会话状态机(对话 FSM)
|
||||||
|
命令路由基于 teloxide `Dialogue`/对话 FSM(多步交互如 `/send`、`/summarize` 确认)。AI 与附件相关流程必须经过显式确认态:
|
||||||
|
|
||||||
|
| 状态 | 含义 | 进入 | 离开 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Idle` | 空闲 | 任意完成态 | 收到消息 |
|
||||||
|
| `AwaitClarify` | 目标/意图不明,等用户选择 | 意图或邮件目标不唯一 | 用户给出明确选择 |
|
||||||
|
| `AwaitConfirm` | 等确认(AI 分析 / 附件下载) | 用户发起分析/下载但未确认 | 确认 / 取消 |
|
||||||
|
| `Analyzing` | AI 请求 in-flight | 用户确认分析 | 成功 / 失败 |
|
||||||
|
| `AwaitFallback` | AI 失败,等确认回退 | 连续 3 次失败熔断 | 用户确认 / 取消 |
|
||||||
|
|
||||||
|
不变量:`AwaitConfirm`/`Analyzing`/`AwaitFallback` 涉及 AI 或附件下载,**未到确认态不得调用 LLM 或拉取附件**。会话状态为短期状态,统一走**外部 Redis 短期 TTL**(`C-REDIS-ONLY-STATE`;不使用 SQLite,丢失可接受),运维 TTL 见 deployment.md(`C-REDIS-ONLY-STATE`)。
|
||||||
|
> 渠道中立:FSM 状态与事件用领域类型(`domain::state`),不依赖 teloxide;teloxide `Dialogue` 仅作为 Telegram adapter 的会话存储实现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 模块划分
|
||||||
|
|
||||||
|
目标 cargo 工程结构(**阶段0 已落地** `main.rs`/`config.rs`/`error.rs`/`domain.rs`/`channel.rs`/`notify.rs`;下方 `jmap/`、`llm/`、`channel/telegram/`、`notify/*` 为**后续阶段目标**,详见 §10):
|
||||||
|
```
|
||||||
|
stalwart-bot/
|
||||||
|
├── Cargo.toml
|
||||||
|
├── .gitignore # 排除 target/ 等(阶段0 P0 门禁 GATE-P0)
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # tokio main:装配置;按 RUN_MODE 分派 webhook / reconcile
|
||||||
|
│ ├── config.rs # 环境变量解析(ARCH-CONFIG-ENV:std::env,非 figment/TOML)
|
||||||
|
│ ├── error.rs # BotError 统一错误(见 §8)
|
||||||
|
│ ├── domain/ # 领域层(不依赖任何渠道类型,见 §5.2)
|
||||||
|
│ │ ├── command.rs # 领域 Command(List/Read/Analyze/Confirm…,渠道无关)
|
||||||
|
│ │ ├── notification.rs # 领域 Notification(NewEmail/ResultMsg/ConfirmRequest…)
|
||||||
|
│ │ ├── state.rs # 会话状态机(§5.6:Idle/AwaitClarify/AwaitConfirm/Analyzing/AwaitFallback)
|
||||||
|
│ │ ├── jmap/ # JmapService(领域封装,§3.2)—— 阶段1+
|
||||||
|
│ │ │ ├── client.rs # 包装 jmap_client::Client(构造、重连、自定义 reqwest)
|
||||||
|
│ │ │ ├── model.rs # Folder / EmailSummary / EmailBody 领域模型
|
||||||
|
│ │ │ └── state.rs # mailbox role→id 缓存、sinceState 游标(写外部 Redis,MOD-SINCESTATE;不用 SQLite/本地卷)
|
||||||
|
│ │ └── llm/ # LlmService + policy + fallback + audit(§12.1)—— 阶段3.5
|
||||||
|
│ ├── channel/ # 渠道层(每渠道一个 submodule,见 §5.2)
|
||||||
|
│ │ ├── mod.rs # Channel / Notifier / MessageAdapter traits(thin)—— 阶段0 边界已落地
|
||||||
|
│ │ └── telegram/ # 首个也是当前唯一渠道(adapter #1)—— 阶段2+
|
||||||
|
│ │ ├── mod.rs # TelegramChannel / TelegramNotifier 装配(teloxide Dispatcher)
|
||||||
|
│ │ ├── commands.rs # 领域 Command ↔ teloxide 命令映射(/folders /list /read /send /search /flag)
|
||||||
|
│ │ ├── render.rs # TelegramMessageAdapter:领域 Notification → TG 消息/行内按钮(HTML escape!)
|
||||||
|
│ │ └── session.rs # teloxide 会话键(当前 folder/page/last_email_id)
|
||||||
|
│ │ # 预留扩展位:dingtalk/、feishu/(不实现,见 §5.2)
|
||||||
|
│ ├── notify/
|
||||||
|
│ │ ├── core.rs # 新邮件处理:JMAP 增量 → 领域 Notification(渠道无关)
|
||||||
|
│ │ ├── push_handler.rs # POST /push/jmap:校验 verificationCode → 去重 → 入 Redis Streams → 2xx
|
||||||
|
│ │ ├── worker.rs # Redis Streams 消费:Email/changes → 通知 → 推进 sinceState → XACK
|
||||||
|
│ │ └── reconcile.rs # /reconcile:外部 Cron 触发对账补差(FLOW-RECONCILE,Redis 丢失恢复)
|
||||||
|
│ └── util/
|
||||||
|
│ ├── retry.rs # 指数退避(Transport/RateLimit/ServerUnavailable)
|
||||||
|
│ └── shutdown.rs # CancellationToken 协调
|
||||||
|
└── tests/
|
||||||
|
├── jmap_mock.rs # 对 JmapService 的契约测试(mock Push 回调 + changes 响应)
|
||||||
|
├── channel_mock.rs # MockChannel/Notifier 契约:领域流程不依赖 Telegram
|
||||||
|
└── telegram_dispatch.rs # teloxide 测试模式(mock Bot,仅 channel::telegram)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1 模块职责矩阵
|
||||||
|
|
||||||
|
> 阶段0 已落地:`main` / `config` / `error` / `domain` / `channel` / `notify`(axum 入口,见 §10.0)。阶段1 起 `jmap-client 0.4.2` **已引入**(`ARCH-DEPS-STAGE1`)。下表含**后续阶段**将引入的模块与依赖(teloxide / redis / reqwest 等**尚未加入 Cargo.toml**,标注「计划」)。
|
||||||
|
|
||||||
|
| 模块 | 依赖 | 输出 | 可测性 | 现状 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `config` | 标准库 `env`(`ARCH-CONFIG-ENV`) | `Config` 结构 | 纯函数,易测 | 阶段0 已实现(手工 `from_env`,无 figment) |
|
||||||
|
| `domain::jmap`(含 `client`) | **jmap-client 0.4.2(已引入)** | 领域动词(§3.2);`client` = 真实只读 adapter(`MOD-JMAP-CLIENT`) | mock JMAP 响应 + `#[ignore]` 真机测试 | 阶段1(G1/D-G1-1 **代码已实现,待真实 `cargo test -- --ignored jmap::` 验证**) |
|
||||||
|
| `domain::llm` | reqwest/axum(计划) | 摘要/意图/草稿 | mock OpenAI 端点 | 阶段3.5 |
|
||||||
|
| `domain::state` | — | 意图路由 + 会话 FSM(零渠道依赖) | 表驱动纯单测 | 阶段3+ |
|
||||||
|
| `channel`(traits) | — | Channel/Notifier/MessageAdapter | 契约测试(MockChannel) | 阶段0 边界已实现 |
|
||||||
|
| `channel::telegram` | teloxide(计划) | TG adapter:事件→领域 Command;领域 Notification→渲染 | teloxide mock Bot / MockChannel | 阶段2+ |
|
||||||
|
| `notify`(入口 + Push/worker/reconcile) | axum(已实现占位);Redis Streams(计划) | 路由占位 → 增量 → 领域 Notification | 驱动假 StateChange 流 | 阶段0 路由占位;阶段4 实现 |
|
||||||
|
| `util::retry` | — | 重试策略 | 纯逻辑 | 阶段1+ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 配置与安全
|
||||||
|
|
||||||
|
### 7.1 历史配置读取(已迁移至 Redis)
|
||||||
|
|
||||||
|
> **迁移目标(`C-REDIS-ONLY-STATE`)**:生产启动环境仅保留 `REDIS_URL`。空 Redis
|
||||||
|
> 的首次配置必须通过 Redis ACL 密码认证的一次性 bootstrap/admin 会话完成;不得
|
||||||
|
> 提供未鉴权写入口。ACL 密码仅用于常数时间 bootstrap 校验,不回显、不记录、不写入
|
||||||
|
> 业务配置。bootstrap 成功后管理员会话哈希及 TTL 保存在 Redis,重启可恢复;业务
|
||||||
|
> token 从 Redis 在启动时装载,密钥 GET 永不回显;bootstrap/管理员 PUT 成功后先构建并原子
|
||||||
|
> 替换客户端,后续请求即时使用新配置,失败保留旧实例。该迁移替代下述阶段0环境变量清单,
|
||||||
|
> 阶段0列表仅作为历史兼容说明。
|
||||||
|
|
||||||
|
> **实际实现**(`ARCH-CONFIG-ENV`):阶段0 起配置**只从环境变量读取**,由 `config::Config::from_env()` 手工解析(`std::env::var`),**不使用 figment、不使用 TOML 配置文件**。缺失必填项即启动失败;布尔值接受 `true/1/yes` 与 `false/0/no`。
|
||||||
|
|
||||||
|
| 变量 | 必填 | 默认 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PORT` | 否 | `8080` | 单监听端口(`C-NO-TCP-EXPOSE`) |
|
||||||
|
| `RUN_MODE` | 否 | `webhook` | `webhook` / `reconcile`(`NG-SERVER-MODE` 已删除) |
|
||||||
|
| `BOT_TOKEN` | 是 | — | Telegram Bot Token(`SecretString`) |
|
||||||
|
| `TG_WEBHOOK_SECRET` | 是 | — | `/webhook/tg` 鉴权:请求头 `X-Telegram-Bot-Api-Secret-Token`(`SAF-AUTH-TG-WEBHOOK`;`SecretString`) |
|
||||||
|
| `CHAT_ALLOWLIST` | 是 | — | 逗号分隔整数 chat id;**白名单硬约束**(`SAF-CHAT-ALLOWLIST`) |
|
||||||
|
| `JMAP_SESSION_URL` | 是 | — | Stalwart JMAP session URL(`REQ-JMAP-SESSION-URL`):可填**服务基地址** `https://host[:port]` 或**完整** `…/.well-known/jmap`;代码归一化为 origin/base 后再交 `jmap-client`(其自动追加 `.well-known/jmap`),**不得重复路径**。仅 HTTPS、**禁止 URL 内嵌凭据**(`SAF-JMAP-URL`) |
|
||||||
|
| `JMAP_USERNAME` | 是 | — | Stalwart 账户(邮箱) |
|
||||||
|
| `JMAP_PASSWORD` | 是 | — | **App Password**(`C-AUTH-APP-BASIC`;`SecretString`) |
|
||||||
|
| `JMAP_PUSH_VERIFICATION` | 是 | — | `/push/jmap` 鉴权:请求体 `verificationCode`(`SAF-AUTH-JMAP-PUSH`;`SecretString`) |
|
||||||
|
| `REDIS_URL` | 是 | — | 外部 Redis(用户托管 + AOF,`C-REDIS-MANAGED-AOF`;`SecretString`) |
|
||||||
|
| `RECONCILE_TOKEN` | 是 | — | `/reconcile` 鉴权:`Authorization: Bearer`(`SAF-AUTH-RECONCILE`;`SecretString`)。`/reconcile` 路由始终挂载,故**必填** |
|
||||||
|
| `ACCOUNT_ID` | 否 | 空 | 单账户(`REQ-SINGLE-ACCOUNT`);留空则取 session 主账户 |
|
||||||
|
| `LLM_ENABLED` | 否 | `false` | 关闭时不校验 `LLM_*`(`REQ-LLM-OPENAI-COMPAT`) |
|
||||||
|
| `LLM_ALLOW_NET` | 否 | `false` | AI 出网开关(`REQ-AI-EXTERNAL-CONSENT`) |
|
||||||
|
| `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | `LLM_ENABLED=true` 时必填 | — | OpenAI-compatible |
|
||||||
|
| `LLM_MAX_RETRIES` | 否 | `3` | 熔断阈值(`REQ-AI-FUSE`) |
|
||||||
|
| `LLM_SUMMARY_TARGET_CHARS` | 否 | `300` | 摘要目标字数(`REQ-LONG-EMAIL`) |
|
||||||
|
|
||||||
|
> 阶段0 `config.rs` 已建立上述字段骨架(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`),未使用的占位以局部 `#[expect(dead_code, reason="稳定ID+阶段0占位")]` 标注。未来若引入 TOML/figment 需另立决策;当前文档不假设配置文件存在。
|
||||||
|
> **入口鉴权三密钥(`TG_WEBHOOK_SECRET`/`JMAP_PUSH_VERIFICATION`/`RECONCILE_TOKEN`)均为必填**(fail-closed,`SAF-AUTH-*`):缺失即 `Config::from_env()` 报错启动失败,无"未配置则放行"降级。示例占位见仓库根目录 `.env.example`(仅占位符,禁放真实密钥)。
|
||||||
|
> **JMAP session URL 归一化(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:`JMAP_SESSION_URL` 既接受**服务基地址**(`https://mail.example.com`)也接受**完整 session URL**(`https://mail.example.com/.well-known/jmap`)。代码在调用 `jmap_client::Client::connect` **之前统一归一化为 origin/base**——`jmap-client` 会自动追加 `/.well-known/jmap`,故**不会出现重复路径**(如 `…/.well-known/jmap/.well-known/jmap`)。约束:**仅 HTTPS**(http 拒绝);**禁止 URL 内嵌用户名/密码**(凭据只经 `JMAP_USERNAME`/`JMAP_PASSWORD`,`C-AUTH-APP-BASIC`);拒绝危险 query。(状态:D-G1-1 **代码已实现,待真实 `cargo test -- --ignored jmap::` 验证**。)
|
||||||
|
|
||||||
|
### 7.2 密钥管理
|
||||||
|
- **禁止**把 token/密码写入仓库或任何配置文件(本项目配置走环境变量,`ARCH-CONFIG-ENV`)。
|
||||||
|
- 环境变量由 `config` 层直接读取(`std::env::var`,`ARCH-CONFIG-ENV`);缺失必填项即启动失败(**无** `${VAR}` 插值/figment)。
|
||||||
|
- 运行期用 `secrecy::SecretString` 包裹,`Debug` 实现打 `***`。
|
||||||
|
- 日志过滤:`tracing` 字段层屏蔽 `Authorization`/`password`/`token`。
|
||||||
|
- 运行期 secret 注入方式(env / `*_FILE` / 编排器 secret)见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
|
||||||
|
|
||||||
|
### 7.3 访问控制
|
||||||
|
- **入口鉴权(硬约束 `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**:三条写路径必须先鉴权,**fail-closed**——
|
||||||
|
- `/reconcile`:请求头 `Authorization: Bearer <RECONCILE_TOKEN>`;
|
||||||
|
- `/webhook/tg`:请求头 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`;
|
||||||
|
- `/push/jmap`:请求体 `verificationCode == JMAP_PUSH_VERIFICATION`。
|
||||||
|
比较使用**常数时间**算法(`subtle`,防时序侧信道);校验失败一律 `401` 且**在鉴权通过前不产生任何副作用/状态变更**。三密钥必填,缺失即启动失败(§7.1)。
|
||||||
|
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`(`ARCH-HEALTHZ`)与 `/ready`(`ARCH-READY-PLACEHOLDER`)为**公开探针**——无鉴权、只返回健康状态、**不含任何敏感信息**(不回显配置/密钥/内部错误细节)。
|
||||||
|
- `/healthz` = liveness(进程存活),语义长期稳定。
|
||||||
|
- `/ready` **当前阶段为占位**:固定返回 `200`,**不执行** Redis PING / JMAP session / Telegram getMe 等依赖探测(`ARCH-READY-PLACEHOLDER`)。真实依赖探测属后续门禁 `GATE-READY-DEPS`(`redis` 接入且 JMAP session 可用后实现,见 §10.0)。
|
||||||
|
- **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;**强制拒绝逻辑随阶段2 渠道接入落地**。
|
||||||
|
- **命令最小化**:仅暴露必要命令;`/send` 需二次确认。
|
||||||
|
- **速率**:teloxide `throttle` + `notify` 自有令牌桶(推送不能挤占用户命令回复)。
|
||||||
|
|
||||||
|
### 7.3.1 配置管理 API
|
||||||
|
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
|
||||||
|
- `POST /api/admin/session` 接受 `Authorization: Bearer <REDIS_URL ACL password>`,返回 `{ "session": "<opaque>", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。
|
||||||
|
- 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。
|
||||||
|
- `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jmap_timeout_ms": 15000,
|
||||||
|
"telegram_timeout_ms": 10000,
|
||||||
|
"llm_timeout_ms": 30000,
|
||||||
|
"max_retries": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 三个 timeout 单位均为毫秒,范围 `100..=300000`;`max_retries` 范围 `0..=5`,表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值 `15000 / 10000 / 30000 / 3`;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。
|
||||||
|
- `PUT /api/business-config` 接受完整 `BusinessConfigWire`,完整替换加密保存的业务配置,先构建客户端再切换运行 worker;成功返回 `204 No Content`,不会返回配置或密钥。Wire 字段为 `bot_token: string`、`telegram_chat_id: i64`、`chat_allowlist: i64[]`、`telegram_webhook_secret: string`、`jmap_session_url: string`、`jmap_username: string`、`jmap_password: string`、`jmap_push_verification: string`、`account_id: string | null`、`llm_enabled: boolean`、`llm_allow_net: boolean`、`llm_api_key: string | null`、`llm_base_url: string | null`、`llm_model: string | null`、`reconcile_token: string`、`worker_token: string`。该 API 故意没有 GET;SPA 不回填业务配置,每次完整替换需重输必填密钥。
|
||||||
|
- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`、`400`、`422` 与 `503`(Redis 写入或依赖客户端构建失败);其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。
|
||||||
|
|
||||||
|
### 7.4 TLS(代码/依赖行为)
|
||||||
|
- TLS provider 由依赖 feature 决定:**jmap-client 0.4.2 默认含 `aws_lc_rs`(并引入 `rustls`)**,`default-features = true` 时并非"rustls 默认"。**本项目实际 `default-features = false, features = ["async","rustls"]`**(不启用 `aws_lc_rs`/`websockets`),最终以 `Cargo.toml` 为准。
|
||||||
|
- 证书校验**默认强制**,仅测试可关。
|
||||||
|
- 镜像内系统 CA 信任库、内部 CA 注入、入站 webhook TLS 终止等运维细节见 deployment.md。
|
||||||
|
|
||||||
|
### 7.5 生产红线:无数据库 / 无本地写入 / 标准输出日志(deployment.md §0/§8.2)
|
||||||
|
|
||||||
|
本节汇总跨代码与部署的绝对红线,**不得**在代码中引入任何"看起来方便"的本地状态:
|
||||||
|
|
||||||
|
| 红线 ID | 含义 | 违反示例(禁止) |
|
||||||
|
|---|---|---|
|
||||||
|
| `C-NO-DB` | 生产不使用任何数据库;Redis 是唯一生产状态存储 | 加 SQLite 去重表、加 Postgres 会话 |
|
||||||
|
| `C-NO-LOCAL-WRITE` | 禁止本地文件/目录写入;不挂本地卷 | 写 `./logs/app.log`、写 `./tmp/…`、写本地 `sinceState.json` |
|
||||||
|
| `C-LOG-STDOUT-ONLY` | 日志只写 stdout/stderr;平台负责采集 | 引入 `tracing-appender`、`rolling-file`、`FileAppender` |
|
||||||
|
| `SAF-LOG-PURITY` | 日志与 Redis 写入内容仅限结构化事件、计数、时间戳、脱敏摘要 | 日志打印 JMAP 正文、AI prompt/completion、密钥原文、附件内容 |
|
||||||
|
| `C-NO-STATEFUL-RECOVERY` | 生产恢复不依赖进程内状态 | 用 `static Mutex<HashSet>` 存 dedup、用内存 LRU 存 sinceState 作为唯一恢复源 |
|
||||||
|
|
||||||
|
**验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 错误处理与可观测性
|
||||||
|
|
||||||
|
### 8.1 统一错误枚举
|
||||||
|
```rust
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum BotError {
|
||||||
|
#[error("jmap: {0}")]
|
||||||
|
Jmap(#[from] jmap_client::Error),
|
||||||
|
#[error("telegram: {0}")]
|
||||||
|
Telegram(#[from] teloxide::errors::RequestError),
|
||||||
|
#[error("config: {0}")]
|
||||||
|
Config(String),
|
||||||
|
#[error("storage: {0}")]
|
||||||
|
Storage(#[from] redis::RedisError),
|
||||||
|
#[error("rate limited, retry later")]
|
||||||
|
RateLimited,
|
||||||
|
#[error("unauthorized chat {0}")]
|
||||||
|
Unauthorized(i64),
|
||||||
|
#[error("llm: {0}")]
|
||||||
|
Llm(LlmErr), // 见 §12.7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 错误 → 用户消息 映射
|
||||||
|
| 底层错误 | Bot 行为 |
|
||||||
|
|---|---|
|
||||||
|
| `Transport` / `ServerUnavailable` / `RateLimit` | 指数退避重试(`util::retry`),超阈值给用户"暂时不可用,稍后重试" |
|
||||||
|
| `Forbidden` / `Unauthorized chat` | 静默拒绝;日志告警 |
|
||||||
|
| `Method(InvalidArguments)` | 回复"参数有误 + 正确用法" |
|
||||||
|
| `Set(creation/update/destroy)` | 回复具体记录级失败原因 |
|
||||||
|
| `StateMismatch` | 重置 sinceState → 全量补一次(防丢/防重) |
|
||||||
|
| `Llm(*)` | 见 §12.7(熔断/回退/徽标) |
|
||||||
|
|
||||||
|
### 8.3 可观测性
|
||||||
|
- `tracing`(teloxide feature 已开启)+ `tracing-subscriber`(fmt + EnvFilter)。
|
||||||
|
- 关键 span:`jmap.request`、`notify.push_received`、`notify.reconcile`、`telegram.command`、`llm.call`(记 prompt 哈希/response 长度/latency/status,**不记 prompt 明文**)。
|
||||||
|
- 指标(可选 `metrics` crate):Push 回调到达数、去重命中率、Redis Streams 积压深度(pending)、DLQ 条数、对账补差条数、JMAP 请求延迟、推送失败率。
|
||||||
|
- 优雅退出:信号处理 + `CancellationToken`,退出前 flush Redis Streams 待处理条目(不涉及本地文件)。
|
||||||
|
- **可靠性目标与策略**(Streams ACK/retry、幂等去重、Push 重试、对账恢复、指标/告警、**≥99.9% 通知可用性及边界**)见 deployment.md §6.4/§6.5(`NFR-NOTIFY-SLA`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 测试策略
|
||||||
|
|
||||||
|
### 9.1 层次
|
||||||
|
| 层 | 工具 | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| 单元 | `cargo test` | `config` 解析、`util::retry` 退避、`formatting` HTML escape |
|
||||||
|
| 契约 | mock | `jmap::JmapService` 用 mock Push 回调 + changes 响应 + 预置 JMAP JSON 验证领域动词;`llm` 用 mock OpenAI 端点 |
|
||||||
|
| 集成 | teloxide mock Bot + mock JMAP | 命令端到端:发 `/list` → 验证回复来自 mock 数据 |
|
||||||
|
| 真实(手动) | Stalwart 实例 | 标 `#[ignore]`,`cargo test -- --ignored` 按需跑 |
|
||||||
|
|
||||||
|
### 9.2 mock 策略
|
||||||
|
- `JmapService` 持有 trait `JmapBackend`(`list_emails/read_email/send_email/changes…`),生产实现包装 `jmap_client::Client`,测试用 `MockBackend`(基于 `Vec<EmailSummary>`)。
|
||||||
|
- Push 回调 payload 用 `futures::stream::iter` 造假 `StateChange`,验证 worker 的 `Email/changes` 增量逻辑与去重(`mod_dedup` + `mod_streams`)。
|
||||||
|
- teloxide 提供 `Requester` mock,验证 `send_message` 被以期望内容调用(仅 `channel::telegram` 测试使用)。
|
||||||
|
- 渠道契约:领域流程用 `MockChannel`(实现 `Channel/Notifier` trait)驱动,验证领域输出与渠道渲染解耦、领域层无 teloxide 依赖。
|
||||||
|
- `llm::client` 注入 mock OpenAI `/chat/completions` 端点。
|
||||||
|
|
||||||
|
### 9.3 关键不变量测试
|
||||||
|
- `sinceState` 存外部 Redis:模拟重启后从正确游标续传;模拟 Redis 清空后由对账(`reconcile`,`FLOW-RECONCILE`)从 JMAP 恢复游标。
|
||||||
|
- 通知去重:同一 `email_id` 不重复推送。
|
||||||
|
- 正文转义:含 `<script>` 的邮件正文在 HTML 模式下被转义。
|
||||||
|
- **安全边界断言(硬性,见 AGENTS.md)**:
|
||||||
|
- VIEW/查看原文路径:mock AI 端点零请求(LLM client 未被调用);
|
||||||
|
- 新邮件通知:消息内无正文内容(断言泄漏);
|
||||||
|
- 长邮件:正文 > 4000 字符不发送全文;
|
||||||
|
- AI 调用前置:未确认前 LLM 零调用;
|
||||||
|
- 分析结果不落盘:处理后无新增磁盘/Redis 写入路径;
|
||||||
|
- AI 3 次失败 → 熔断确认 → 回退带"AI 不可用"徽标。
|
||||||
|
- **入口鉴权(`SAF-AUTH-*`)**:`/reconcile`、`/webhook/tg`、`/push/jmap` 在**缺少或错误的**凭证下返回 `401`,并断言鉴权失败时**无副作用**(无 Redis 写入、无 JMAP/AI 调用);正确凭证放行。
|
||||||
|
- **健康探针(`SAF-PROBE-PUBLIC`)**:`/healthz`、`/ready` 返回 `200` 且**响应体不含敏感信息**;`/ready` 在阶段0 断言为**占位**(不做依赖探测,测试**不得**断言 Redis/JMAP/TG 检查已发生)。
|
||||||
|
- **渠道解耦**:领域模块(jmap/llm/intent/notify::core)编译不依赖 teloxide(crate 分层 + clippy/评审约束);`channel::telegram` 是唯一导入 teloxide 的模块。
|
||||||
|
- **JMAP session URL(`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**:基地址与完整 `…/.well-known/jmap` 两种输入**均接受且归一化结果一致**,传给 `Client::connect` 的 URL **不含重复 `/.well-known/jmap`**;`http://` 被拒绝;**内嵌凭据(`https://user:pass@host`)被拒绝**;危险 query 被拒绝。
|
||||||
|
- **JMAP 多 part 原文(`REQ-JMAP-RAW-MULTIPART`)**:`read_email` 对多 part 正文按 `text_body` 顺序拼接"有 `part_id` 且有 `bodyValue`"的部分;构造"无可用部分"用例断言返回**明确错误**(非空串)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 分阶段实施计划
|
||||||
|
|
||||||
|
> 前提:先 `rustup` 装工具链(stable)。CI 与发布流见 deployment.md。
|
||||||
|
|
||||||
|
### 10.0 阶段 0:脚手架与 HTTPS 入口骨架(已完成,待过 P0 门禁)
|
||||||
|
|
||||||
|
> **阶段0 实际交付**(`ARCH-STAGE0`):`cargo` 工程 + 模块骨架(`main` / `config` / `error` / `domain` / `channel` / `notify`),单端口 axum 入口与 5 条路由占位(`/webhook/tg`、`/push/jmap`、`/reconcile`、`/healthz`、`/ready`),env 配置骨架、最小测试。其中三条写路径的入口鉴权已 fail-closed 落地(`R1`/`SAF-AUTH-*`);**`/healthz` 为 liveness;`/ready` 为占位 200(`ARCH-READY-PLACEHOLDER`),不做依赖探测**(真实探测见后续门禁 `GATE-READY-DEPS`)。
|
||||||
|
|
||||||
|
- **实际依赖**(Cargo.toml 现状,`ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1`):`axum 0.8`(单一 HTTPS 入口)、`async-trait`、`secrecy`、`subtle`(常数时间鉴权比较)、`serde`、`serde_json`、`thiserror`、`tokio`、`tracing`、`tracing-subscriber`、`url`(`JMAP_SESSION_URL` 归一化解析)、`jmap-client =0.4.2`(`default-features = false, features = ["async","rustls"]`);dev-dependencies:`tower 0.5`(路由测试)。
|
||||||
|
- **尚未引入**(计划见后续阶段,文档不得声称已用):`teloxide`(阶段2)、`redis`(阶段3/4)、`reqwest`(阶段3.5)。**未使用 figment**:配置为手工 `std::env` 解析(`ARCH-CONFIG-ENV`,§7.1)。
|
||||||
|
- **阶段1 依赖现况**(`ARCH-DEPS-STAGE1`):`jmap-client` **已引入**(当前 `=0.4.2`,`default-features = false, features = ["async","rustls"]`,版本与 features **以 `Cargo.toml` 为准**)。⚠️ 其默认 features `["async","websockets","aws_lc_rs"]` **含 WebSocket 栈**,故必须关闭默认 features 且不选 `websockets`;JMAP 仅走 **HTTPS 短请求**(Core/Mail),以遵守 `C-NO-LONG-CONN`(无 WS/SSE/长轮询)。
|
||||||
|
- `ACCOUNT_ID` **可选**(`REQ-SINGLE-ACCOUNT`):**留空 → 取 JMAP session 的默认/主账户**;显式值经校验后使用;多账户 = 多个 bot 实例。
|
||||||
|
- axum 采用 **0.8**(`ARCH-AXUM-08`);如后续审核决定调整版本,以 Cargo.toml 为准并同步本节。
|
||||||
|
- 阶段0 **不实现** SSE/WebSocket/长轮询/SQLite/本地卷(`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`);**入口鉴权(`R1`/`SAF-AUTH-*`)已作为 fail-closed 硬门禁落地**(见 §7.3、§10.1 注),鉴权之后的业务体仍为占位。
|
||||||
|
|
||||||
|
**阶段0 P0 门禁(`GATE-P0`)——进入阶段1前必须全部通过:**
|
||||||
|
1. `cargo fmt --check` 通过(无格式差异)。
|
||||||
|
2. `cargo clippy --all-targets -- -D warnings` 通过(零告警;占位符用**局部** `#[expect(dead_code, reason="稳定ID+阶段0占位")]`,禁 crate 级 `allow`)。
|
||||||
|
3. `cargo test` 通过(含路由/配置最小测试)。
|
||||||
|
4. 上述三条**在 Debian `rust:1-slim-bookworm` 容器内**执行通过(`C-DEBIAN-SLIM`)。
|
||||||
|
5. 配置读取为 env-only(无 figment/TOML);`SecretString` 包裹秘密且 `Debug` 不泄密;`JMAP_PASSWORD` 安全访问器保留。
|
||||||
|
6. `RUN_MODE` 被实际消费(`webhook`/`reconcile` 至少显式分派,保持无副作用)。
|
||||||
|
7. `.gitignore` 存在(排除 `target/` 等;**不**擅自初始化 git)。
|
||||||
|
8. 所有新增代码有引用稳定 ID 的必要注释;单 `.rs` ≤ 500 行。
|
||||||
|
|
||||||
|
**后续门禁(`GATE-READY-DEPS`)——`redis` 接入且 JMAP 只读 adapter 可用后必须实现:**
|
||||||
|
- `/ready` 由**占位 200**(`ARCH-READY-PLACEHOLDER`)升级为**真实依赖探测**:Redis PING + JMAP session(Basic)连通性(+ 视需要 Telegram getMe);
|
||||||
|
- 依赖不可用时返回 `503`,用于编排器的 readiness/流量摘除;
|
||||||
|
- `/healthz` 保持纯 liveness;两者继续满足 `SAF-PROBE-PUBLIC`(公开、无敏感信息)。
|
||||||
|
- **在 `GATE-READY-DEPS` 落地前,文档/测试/编排探针描述不得声称 `/ready` 已检查依赖。**
|
||||||
|
|
||||||
|
**生产红线(贯穿所有阶段)**:
|
||||||
|
- **无数据库**(`C-NO-DB`):Redis 是唯一生产状态存储;不引入 SQLite/Postgres/MySQL/嵌入式数据库。
|
||||||
|
- **无本地文件写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷。
|
||||||
|
- **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):平台负责采集;禁用文件日志后端。
|
||||||
|
- **日志/Redis 内容约束**(`SAF-LOG-PURITY`):仅限结构化事件、计数、时间戳、脱敏摘要;禁止密钥、JMAP 邮件正文、AI 请求/响应、附件内容。
|
||||||
|
- **无进程内恢复**(`C-NO-STATEFUL-RECOVERY`):重启续跑一律走 Redis + JMAP 对账(`FLOW-RECONCILE`);进程内缓存仅性能优化,丢失安全可重入。
|
||||||
|
|
||||||
|
**阶段1 推进边界(`BOUND-STAGE1`):**
|
||||||
|
- 仅在 `GATE-P0` 全项通过后进入阶段1(JMAP 只读)。
|
||||||
|
- **R1(入口真实鉴权)已在阶段0 后置修复中落地**(`SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`):`/reconcile` 校验 `Authorization: Bearer RECONCILE_TOKEN`、`/webhook/tg` 校验 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`、`/push/jmap` 校验 `verificationCode == JMAP_PUSH_VERIFICATION`;常数时间比较,失败 `401` 且无副作用,三密钥必填(缺失即启动失败)。阶段1 不再重复实现 R1。
|
||||||
|
- **仍需注意**:`GATE-P0` 未全项通过前**不应**将容器暴露到公网(鉴权已 fail-closed,但依赖/占位与 CI 门禁尚未验收)。
|
||||||
|
- 阶段1 只做 JMAP 只读(`list_folders`/`list_emails`/`read_email`),不引入发送/推送/AI。
|
||||||
|
|
||||||
|
### 10.1 阶段 1:JMAP 只读(1.5d)
|
||||||
|
|
||||||
|
> 前置:`GATE-P0` 全项通过(`BOUND-STAGE1`)。
|
||||||
|
|
||||||
|
- 依赖 `jmap-client 0.4.2`(**已引入**,`ARCH-DEPS-STAGE1`;版本/features 以 `Cargo.toml` 为准,**禁用 WebSocket feature**);`config.rs` 复用既有 env 骨架(**非** `bot.example.toml`,见 `ARCH-CONFIG-ENV`)。
|
||||||
|
- `domain::jmap::client` = 真实只读 adapter(`MOD-JMAP-CLIENT`,**G1/D-G1-1 代码已实现,待真实 `cargo test -- --ignored jmap::` 验证;未实际运行前不得声称真机通过**):已实现 = 包装 `Client::connect`(Basic 认证 `C-AUTH-APP-BASIC`)、**URL 归一化**(`JMAP_SESSION_URL` 接受服务基地址或完整 `…/.well-known/jmap`,归一化为 origin/base 后传入,避免重复路径;仅 HTTPS、禁内嵌凭据与 query,`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)、**account 选择**:`ACCOUNT_ID` 留空取 session 主账户、显式值校验后使用(`REQ-SINGLE-ACCOUNT`)。
|
||||||
|
- `domain::jmap::JmapService::list_folders / list_emails / read_email`(query/get;`list_emails` 含 `limit` 边界;`received_at` 解析;`read_email` 多 part 拼接见 `REQ-JMAP-RAW-MULTIPART`)。
|
||||||
|
- **R1 入口鉴权已就绪**:`/reconcile`/`/webhook/tg`/`/push/jmap` 三入口鉴权(`SAF-AUTH-*`)已在阶段0 落地并通过单测;阶段1 仅需在其后接入真实业务逻辑(不要在阶段1 放宽/绕过鉴权)。
|
||||||
|
- `#[ignore]` 真实测试(`GATE-G1-JMAP-READONLY`):经**环境变量**驱动,运行命令 `cargo test -- --ignored jmap::`(`--ignored` 是 libtest 参数,必须置于 `--` 之后);仅编译不执行用 `cargo test --no-run`(编译含 `#[ignore]` 的测试目标)。**缺环境时清晰跳过且不泄露任何密钥**;CI 默认不跑真机用例,mock 测试继续保留。**真机用例通过与否须以实际 `--ignored` 运行为准——不得在未运行的情况下声称"真机通过"。**
|
||||||
|
- 验收:`cargo test`(mock)全绿;`cargo test -- --ignored jmap::`(有真机环境时)能连真实服务器列文件夹/邮件并读原文(未实际运行前不得声称已通过)。
|
||||||
|
|
||||||
|
### 10.2 阶段 2:渠道适配骨架(2d)
|
||||||
|
- `channel/` 定义 Channel / Notifier / MessageAdapter 抽象 + 领域 Command/Notification 类型。
|
||||||
|
- `channel::telegram` 装配 Dispatcher + chat 白名单中间件 + throttle(teloxide 仅存在于该模块)。
|
||||||
|
- 命令:`/start /folders /list /read`(只读闭环)。
|
||||||
|
- `render.rs`:领域 Notification → TG Markdown/HTML 渲染 + 转义(adapter 内部)。
|
||||||
|
- 会话存储(外部 Redis,`C-REDIS-ONLY-STATE`;**不用 SQLite**)记当前 folder/page。
|
||||||
|
- 验收:本地 **webhook 形态**(`webhooks-axum` 或纯 axum),测试客户端发命令能读邮件;领域模块不 import teloxide。
|
||||||
|
|
||||||
|
### 10.3 阶段 3:发送邮件 + 状态管理(1.5d)
|
||||||
|
- `domain::jmap::JmapService::send_email`(draft + submission_set)。
|
||||||
|
- `/send` 用 teloxide 对话 FSM 多步收集 to/subject/body。
|
||||||
|
- `/flag /unseen`(keywords)。
|
||||||
|
- 验收:手机发邮件、标记已读。
|
||||||
|
|
||||||
|
### 10.4 阶段 3.5:LLM 门面 + 回退(1.5d)
|
||||||
|
见 §12.9。
|
||||||
|
|
||||||
|
### 10.5 阶段 4:实时推送(Push 回调 + Streams worker + 外部 Cron 对账)(2d)
|
||||||
|
- `notify::push_handler`:注册 `push_subscription` + verificationCode 握手,`POST /push/jmap` 校验→去重→入 Redis Streams→2xx。
|
||||||
|
- `notify::worker`:Redis Streams 消费(`mod_streams`)→ `Email/changes` 增量 → 通知 → 推进 sinceState → XACK。
|
||||||
|
- `notify::reconcile`:`/reconcile` + `reconcile` 子命令,外部 HTTPS Cron 触发(`FLOW-RECONCILE`);兼做 Redis 丢失后的游标重建。
|
||||||
|
- `sinceState` 写外部 Redis(`mod_sincestate`,`C-REDIS-ONLY-STATE`;不用 SQLite/文件 `NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`)。
|
||||||
|
- 验收:向 Stalwart 发测试邮件,经 Push 回调 + worker,Telegram 在 ~秒级收到推送;重启不重发;清空 Redis 后由对账补发。
|
||||||
|
|
||||||
|
### 10.6 阶段 5:搜索 + 搜索片段 + 打磨(1.5d)
|
||||||
|
- `/search` + `SearchSnippet/get` 高亮。
|
||||||
|
- 错误消息打磨、速率/退避实测、`clippy`/`fmt`、README。
|
||||||
|
|
||||||
|
### 10.7 总估时
|
||||||
|
~10.5 人日(不含等待用户确认与真实联调排障)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 待确认问题(产品/架构类)
|
||||||
|
|
||||||
|
> ⚠️ 部署/平台类决策已确认(单账户 / App Password+Basic / Redis 托管+AOF / 平台 HTTPS URL / 外部 Cron 对账),仅余 `Q-DEP-A`/`Q-DEP-B` 运维待办,见 deployment.md §9。
|
||||||
|
|
||||||
|
### 11.1 认证方式(已确认)
|
||||||
|
- **已确认**:**App Password + Basic**(`C-AUTH-APP-BASIC`)。不用主密码、不用 OAuth2 Bearer(无 OIDC 需求)。
|
||||||
|
- 影响:`Credentials::Basic` 构造;无需 OAuth client / token 自动刷新模块。
|
||||||
|
|
||||||
|
### 11.2 实时通道(已定,见 deployment.md)
|
||||||
|
- 唯一通道 = **JMAP Push HTTPS 回调**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。
|
||||||
|
- **Q2**:Stalwart 实例是否启用 JMAP `PushSubscription` capability(`urn:ietf:params:jmap:push`)?需确认,决定 Push 回调可用性。
|
||||||
|
- **Q3**:Stalwart 对 Push 回调的重试策略/次数?决定去重 TTL 与对账间隔取值(见 deployment.md)。
|
||||||
|
|
||||||
|
### 11.3 部署形态(已确认,架构相关)
|
||||||
|
- **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。
|
||||||
|
- 部署形态 = **webhook-only + 通用 HTTPS-only Docker + 外部 Cron 对账**(deployment.md `C-NO-LONG-CONN`/`NG-LONG-POLLING`/`NFR-RECONCILE-INTERVAL`)。
|
||||||
|
- **多实例 LB/HA(已确认,`ARCH-LB-WORKER`)**:可选在多个 serverless 平台部署同镜像、共享同一 Redis,前置免费 Cloudflare Worker 做唯一入口与故障转移;Worker 代码位于子目录 [`cloudflare-worker/`](../cloudflare-worker/)(非本 Rust 二进制),部署见 `docs/deployment.md` §10.8。信任模型为**透传**(后端仍 fail-closed 校验,`SAF-LB-PASSTHRU`),后端间**共享同一组 secret**(`C-LB-SHARED-SECRETS`),`/reconcile` **Redis 锁**单实例(`SAF-RECONCILE-LOCK`),Worker 提供**聚合健康视图**(`MOD-HEALTH-AGG`)。**Redis 单点故障不在本方案范围**(`NFR-HA-MULTI-INSTANCE`,用户外部解决)。双活或主备均可。详见 `docs/deployment.md` §10。
|
||||||
|
|
||||||
|
### 11.4 邮件正文呈现
|
||||||
|
- **Q6**:Telegram 消息用 HTML 还是 MarkdownV2?HTML 转义更可控(推荐 HTML),但 MarkdownV2 视觉更好。
|
||||||
|
- **Q7**:长邮件策略——已确认"禁止直接发送完整原文,超过阈值(默认 4000)发预览 + `[AI 总结][继续查看原文]` 选项",AI 摘要目标 ~300 字、代码侧不做字符级硬截断(见 §12.3/§12.2)。阈值默认值是否合适见 Q29。
|
||||||
|
- **Q8**:附件是否在 Telegram 直接下载发送?(Telegram 限 50MB 文件;大附件建议只给下载链接 via `download_url`)。
|
||||||
|
|
||||||
|
### 11.5 推送范围与去重
|
||||||
|
- **Q9**:推送所有新邮件,还是仅特定文件夹/发件人/关键词?(Stalwart 端可用 Sieve 过滤,Bot 端也可二次过滤。)
|
||||||
|
- **Q10**:是否需要"摘要聚合"(如每早 8 点汇总未读)?由**外部 HTTPS Cron 调度器**触发 `/reconcile` 或新增汇总路由实现(**不在容器内自持定时器**,`C-NO-LONG-CONN`;+0.5d)。
|
||||||
|
|
||||||
|
### 11.6 Stalwart 版本/能力
|
||||||
|
- **Q11**:Stalwart 版本是否启用 Sieve(draft-12)能力?若启用,部分"自动归档/标记"可下沉到服务端 Sieve,Bot 只做通知,减少往返。
|
||||||
|
- **Q12**:是否需要 `Identity` 多发件身份切换?(若只有一个身份可忽略。)
|
||||||
|
|
||||||
|
### 11.7 工程取向
|
||||||
|
- **Q13**:是否要求 `#![forbid(unsafe_code)]`(与 jmap-client 一致)?建议是。
|
||||||
|
- **Q15**:日志/指标是否需要对接外部(Loki/Prometheus)?默认只本地 stdout。
|
||||||
|
- (CI 平台为通用容器 CI,`NG-SERVERLESS-BIND`;具体见 deployment.md。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. AI 辅助能力:架构、确认门槛、失败回退
|
||||||
|
|
||||||
|
> **用户已确认的 7 项需求(本节据此设计,后续所有表述以此为准)**:
|
||||||
|
> 1. **AI 仅在被明确要求并确认后接触正文**(`REQ-AI-CONFIRM` / `REQ-AI-EXTERNAL-CONSENT`):只有当用户明确发起"分析/总结/翻译"意图**并再次确认**后,才允许把正文喂给 LLM;**仅当用户明确允许时才向外部 AI 发送邮件正文**(默认不外发);其余场景(包括查看原文)**一律不经 AI**,由 JMAP 直取。
|
||||||
|
> 2. **查看原文始终 JMAP 直取**(`REQ-VIEW-DIRECT`):`/read` 与任何"看正文"动作走 `JmapService::read_email`,不走 LLM。
|
||||||
|
> 3. **长邮件禁止直接发送完整原文**(`REQ-LONG-EMAIL`):正文超过阈值(默认 4000 字符,见 §12.3,可调)时,Bot **不直接发送全文**,而是提示"正文较长,请电脑查看"或"选择 AI 总结";AI 摘要目标约 **300 字**,**不做代码侧硬性数学截断**(由 LLM 自然生成 ~300 字)。
|
||||||
|
> 4. **分析/摘要结果不持久化**(`REQ-ANALYSIS-EPHEMERAL`):不写缓存、不入 Redis、不留磁盘;每次请求即取即弃(无状态天然友好)。
|
||||||
|
> 5. **附件按需拉取(保留)**(`REQ-ATTACH-ONDEMAND`):不预载;`Blob/get` 仅在用户点击下载时触发,单文件 ≤50MB 发文件,超限给 `download_url` 链接。
|
||||||
|
> 6. **AI 失败约 3 次后需用户确认的非 AI 回退(保留)**(`REQ-AI-FUSE`):连续 3 次失败 → 熔断 + 向用户弹确认;选择"关闭 60s" 冷却期内直接走规则模板 + 原文直取路径;选择"恢复 AI" 则做一次探测。
|
||||||
|
> 7. **OpenAI-compatible 环境变量(保留)**(`REQ-LLM-OPENAI-COMPAT`):`LLM_API_KEY / LLM_BASE_URL / LLM_MODEL` 等。
|
||||||
|
>
|
||||||
|
> 本节仅做设计,不写业务代码。
|
||||||
|
|
||||||
|
### 12.1 模块划分(增量,接 §6)
|
||||||
|
```
|
||||||
|
src/domain/llm/ # 零渠道依赖(领域层,见 §5.2)
|
||||||
|
├── mod.rs # LlmService(门面:summarize / intent / draft;仅确认后调用)
|
||||||
|
├── client.rs # 包装 axum + reqwest::Client,OpenAI /chat/completions
|
||||||
|
├── config.rs # 从环境装载 + 校验
|
||||||
|
├── policy.rs # 熔断/退避/限次(对齐需求 6)
|
||||||
|
├── fallback.rs # 非 AI 回退:规则模板 + 原文直取路径(对齐需求 1/2)
|
||||||
|
└── audit.rs # tracing span:prompt hash / token / latency / status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 OpenAI-compatible 环境变量(需求 7)
|
||||||
|
```bash
|
||||||
|
export LLM_API_KEY=...
|
||||||
|
export LLM_BASE_URL=https://api.openai.com/v1 # 可指向任何 OpenAI-compatible 端点
|
||||||
|
export LLM_MODEL=... # 如 gpt-4o-mini
|
||||||
|
# 可选
|
||||||
|
export LLM_TEMPERATURE=0.2
|
||||||
|
export LLM_MAX_TOKENS=1024
|
||||||
|
export LLM_TIMEOUT_SECS=30
|
||||||
|
export LLM_MAX_RETRIES=3 # 对应需求 6 的"约 3 次"
|
||||||
|
export LLM_ENABLED=true|false # 一键降级开关
|
||||||
|
export LLM_ALLOW_NET=true|false # 默认 false;开启才允许 LLM 出网
|
||||||
|
# 摘要目标长度(写入 prompt 的系统指令,由 LLM 自然控制;代码侧不做字符截断)
|
||||||
|
export LLM_SUMMARY_TARGET_CHARS=300
|
||||||
|
```
|
||||||
|
- 客户端:**axum**(或裸 `reqwest` + `serde`),`POST {LLM_BASE_URL}/chat/completions`,`Authorization: Bearer $LLM_API_KEY`。
|
||||||
|
- **仅在用户明确要求分析/总结/翻译并确认后**才调用 LLM;此时把**最小必需上下文**(Subject + 正文 + 用户指令)交给 LLM,prompt 系统指令写明"请生成约 300 字中文摘要"。
|
||||||
|
- 摘要结果**不持久化**(需求 4):不写 Redis、不落盘,处理完即弃;无状态天然友好。
|
||||||
|
- 默认关闭网出能力(`LLM_ALLOW_NET=false`)降低数据外泄风险;密钥不入仓库;`config::Config` 启动时强校验,缺失且 `LLM_ENABLED=true` → 启动失败或降级为禁用并告警。
|
||||||
|
- 运行期 secret 注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
|
||||||
|
|
||||||
|
### 12.3 正文获取策略与长邮件处理(需求 1/2/3/5)
|
||||||
|
- **查看原文 = JMAP 直取**:`/read` 与任何"看正文"动作走 `JmapService::read_email`,**不经 LLM**;LLM 不在查看路径上。
|
||||||
|
- **AI 接触正文的前置门槛**:只有当用户发起明确的"分析/总结/翻译"意图**并再次确认**(例如点 `[AI 总结]` 按钮 / 执行 `/summarize`)后,才把正文作为 LLM 输入。未经确认 → LLM 看不到正文。
|
||||||
|
- **长邮件禁止直接发送完整原文**(需求 3):
|
||||||
|
- 当正文长度 > 阈值(默认 **4000 字符**,由 §12.2 配置,可调),Bot **不发送全文**,而是回复一段简短预览(如 Preview 120 字符 + 发件人/主题/附件概览)并给出选项:
|
||||||
|
> "邮件正文较长(约 N 字)。在手机上不便完整阅读,建议在电脑端查看。如需在此查看:`[AI 总结]`(约 300 字)`[继续查看原文]`(截断)"
|
||||||
|
- "AI 总结"路径:经用户确认 → 把正文交 LLM → 生成约 300 字摘要(目标由 prompt 控制,**代码侧不做字符级硬截断**)。
|
||||||
|
- "继续查看原文"路径:仍 JMAP 直取,发送截断版(明确标注"已截断,完整请电脑查看"),**绝不直接发送完整原文**。
|
||||||
|
- **附件按需**(需求 5 保留):Telegram 端先给"📎 3 个附件(PDF, 2.1MB)"概览 + 下载按钮;用户点击才触发 `Blob/get` 流式下载 → `Telegram::send_document`,单文件 ≤50MB,超限只给 `download_url` 链接。避免启动期预载全部 blob,减少 JMAP 出站与内存峰值。
|
||||||
|
- AI 摘要**永远不**把附件内容喂入 prompt(除非后续单独功能开启且白名单限定),保持隐私默认。
|
||||||
|
|
||||||
|
### 12.3.1 AI 授权期限与 Redis TTL(`REQ-AI-CONSENT`)
|
||||||
|
- AI 授权必须由用户明确选择期限;可选临时一次(实现为一次授权窗口,默认 1 小时)、1 小时、今天(86400 秒)、7 天(604800 秒)或直到撤销(最长 365 天,即 31536000 秒)。
|
||||||
|
- Redis 的 consent key 仅保存 chat id 对应的到期 Unix 时间,并使用**与所选期限完全相同**的 `SET EX` 秒数;不使用隐式 30 天默认值,也不保存正文或摘要。
|
||||||
|
- 到期由 Redis TTL 和读取时的到期校验共同保证,摘要请求回到元数据模式并提示“授权已到期”;用户重新选择期限后才可再次授权。`/ai off` 立即删除 key。
|
||||||
|
|
||||||
|
### 12.4 失败检测与熔断(需求 6)
|
||||||
|
```
|
||||||
|
LlmService.call(kind: LlmKind, input, ctx)
|
||||||
|
├─ 调用 axum → Result<Response, LlmErr>
|
||||||
|
│ 成功 → 正常路径
|
||||||
|
│ 失败(4xx/5xx/超时/网络/401) → 记入 policy
|
||||||
|
└─ policy.tick() 计数:
|
||||||
|
失败次数 >= 3(LLM_MAX_RETRIES) 且 在 TTL 窗口内
|
||||||
|
→ 触发"熔断 + 需确认回退"(进入半开状态)
|
||||||
|
```
|
||||||
|
- 触发后向用户发一条**显式确认消息**(`/llm-fallback` 风格的 inline-button):
|
||||||
|
> "AI 最近 3 次调用失败,我切换到规则模板回复。是否继续? `[恢复AI(重试)]` `[关闭AI 60s]`"
|
||||||
|
- 用户选择后:
|
||||||
|
- "恢复 AI" → 走一次探测请求,成功则重置计数、退出熔断;失败则维持熔断;
|
||||||
|
- "关闭 60s" → 进入冷却期,期间所有 LLM 调用直接走 fallback 路径。
|
||||||
|
- 熔断状态**持久化**到 Redis(TTL = 冷却期),无状态化:实例重启/迁移后能从 Redis 恢复;Redis 丢失时退化到进程内短窗口(接受 60s 内的漏判)。Redis 运维与存活窗口见 deployment.md。
|
||||||
|
|
||||||
|
### 12.5 非 AI 回退(需求 6,与"原文直取"一致)
|
||||||
|
- `fallback.rs` 提供与 AI 接口相同的签名(`fn summarize(input:&Input) -> Output`),内部走**规则 + 原文直取**:
|
||||||
|
- 摘要回退:截取 `Subject + Preview(120 chars)`,不解析 HTML;
|
||||||
|
- 意图识别回退:用关键字/正则匹配(如"归档"、"删除"、"回复")走确定性分支;
|
||||||
|
- 草稿/格式化回退:纯模板字符串替换。
|
||||||
|
- 所有回退响应均**显式标注**"AI 不可用"徽标,避免用户误以为是 AI 生成。
|
||||||
|
- 回退路径**不经过 LLM 客户端**,保证零 LLM 依赖;且**不触发 3 次熔断逻辑**(熔断只统计 LLM 调用)。
|
||||||
|
|
||||||
|
### 12.6 无状态/容器化适配
|
||||||
|
- **分析/摘要结果不持久化**(需求 4):不写 Redis、不落盘、不做结果缓存;每次请求即取即弃。**删除"摘要结果入 Redis 缓存"方案**——多实例/冷启动只影响重复调用成本,不缓存保证数据最少留存。(与 `C-REDIS-ONLY-STATE` / `SAF-LOG-PURITY` 一致:Redis 上 LLM 相关状态仅熔断计数;**不写** prompt/completion 明文。)
|
||||||
|
- **熔断计数存 Redis**(`INCR + EXPIRE`),跨实例共享(这是 Redis 上唯一与 LLM 相关的状态)。
|
||||||
|
- `LLM_SUMMARY_TARGET_CHARS`、`LLM_MAX_RETRIES`、熔断冷却时间通过 config 下发,不写死。
|
||||||
|
|
||||||
|
### 12.7 错误处理与可观测性(接 §8)
|
||||||
|
新增 `BotError::Llm(LlmErr)`;`LlmErr` 细分 `Unauthorized`(401/403)、`RateLimited`(429)、`Timeout`、`BadResponse`、`Transport`。用户消息映射:
|
||||||
|
- 熔断中 + 未确认 → 静默走回退,附"AI 不可用"徽标;
|
||||||
|
- 恢复失败 → 再次提示用户确认;
|
||||||
|
- 审计:`tracing` span `llm.call` 记录 prompt 哈希、response 长度、latency、status,不记录 prompt 明文(合规)。
|
||||||
|
|
||||||
|
### 12.8 新增待确认问题
|
||||||
|
- **Q25**:LLM 提供方是 OpenAI 官方还是自托管/Ollama/vLLM?`LLM_BASE_URL` 是否需要 TLS 互信?出网白名单是否允许?
|
||||||
|
- **Q26**:`LLM_ALLOW_NET` 默认 false 是否可接受?(即 AI 调用本身也视为敏感出网操作,需用户显式开关。)
|
||||||
|
- **Q27**:熔断冷却期(60s 还是 5min)与"约 3 次"阈值需确认;"约 3 次"是否含 429 限流?
|
||||||
|
- **Q28**:附件"按需"指**下载并作为 Telegram 文件回传**,还是**仅给 Stalwart `download_url` 链接**?后者更省流量与成本,前者体验更顺。
|
||||||
|
- **Q29**:长邮件"禁止直接发送完整原文"的阈值(默认 4000 字符)是否合适?"AI 总结"目标 ~300 字是否满足预期(过短/过长可调整,由 prompt 自然控制)?
|
||||||
|
- **Q30**:`LLM_ENABLED=false` 时是否**启动即禁用 AI 且不写熔断**(最简降级),还是仍保留探测?推荐前者。
|
||||||
|
|
||||||
|
### 12.9 实施计划增量(接 §10)
|
||||||
|
在阶段 3 与 4 之间插入 **阶段 3.5:LLM 门面 + 回退(1.5d)**
|
||||||
|
- `llm::client` + `axum`/reqwest 调用;
|
||||||
|
- `llm::policy` 3 次熔断 + Redis 共享计数(熔断是 Redis 上唯一的 LLM 相关状态;**摘要结果不持久化**);
|
||||||
|
- `llm::fallback` 规则模板 + 原文直取;
|
||||||
|
- 长邮件策略:正文 > 4000 字符 → 不发送完整原文,改发预览 + `[AI 总结][继续查看原文]` 选项(对齐 §12.3);
|
||||||
|
- `telegram/formatting` 增加"AI 不可用"徽标;
|
||||||
|
- 验收:
|
||||||
|
- `LLM_ENABLED=true` 且用户明确确认分析 → 正常 ~300 字摘要(不做字符级硬截断);
|
||||||
|
- 长邮件默认**不发送完整原文**(仅预览 + 选项);
|
||||||
|
- 模拟 3 次 500 → 弹确认;选"关闭 60s" → 60s 内全走回退并带徽标;
|
||||||
|
- `LLM_ENABLED=false` → 直接全走回退;
|
||||||
|
- 查看原文(`/read`)路径**绝不**触发 LLM。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附:调研依据(可复核)
|
||||||
|
- `stalwartlabs/jmap-client` main 分支:`src/lib.rs`(URI/Method/DataType/Error)、`src/client.rs`(认证/连接/event_source)、`src/email/`、`src/email_submission/helpers.rs`、`src/event_source/`、`src/push_subscription/`(create/verify/update_types/destroy)、`src/core/error.rs`、`Cargo.toml`、`README.md`、`examples/`。
|
||||||
|
- crates.io:`teloxide` 0.17(features 列表)、`jmap-client` 元数据。
|
||||||
|
- `stalwartlabs/mail-server` main 分支:`crates/common/src/auth/credential.rs`(Password/AppPassword/ApiKey)、`crates/http/src/auth/authenticate.rs`(AccessScope 权限裁剪)、`crates/jmap/src/push/`、`api/v1/openapi.yml`(`securitySchemes`: basicAuth/bearerAuth/liveToken 60s)。
|
||||||
|
- 项目目录 `/home/okabe/Repo/stalwart-bot/`:greenfield(首次构建需 `rustup`)。
|
||||||
|
- 部署/平台相关调研依据(lambda_runtime/worker/aws-sdk 等)见 deployment.md。
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
//! HTTPS OpenAI-compatible summary client. It never persists prompts or results.
|
||||||
|
use crate::state::{runtime_provider, OutboundConfig, RuntimeConfigProvider};
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AiError {
|
||||||
|
InvalidEndpoint,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LlmClient {
|
||||||
|
http: reqwest::Client,
|
||||||
|
endpoint: String,
|
||||||
|
key: SecretString,
|
||||||
|
model: String,
|
||||||
|
max_chars: usize,
|
||||||
|
runtime: RuntimeConfigProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LlmClient {
|
||||||
|
#[cfg_attr(
|
||||||
|
not(test),
|
||||||
|
expect(
|
||||||
|
dead_code,
|
||||||
|
reason = "stable client constructor retained for adapter tests"
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub fn new(
|
||||||
|
endpoint: String,
|
||||||
|
key: SecretString,
|
||||||
|
model: String,
|
||||||
|
max_retries: u8,
|
||||||
|
max_chars: usize,
|
||||||
|
) -> Result<Self, AiError> {
|
||||||
|
Self::new_with_timeout(endpoint, key, model, max_retries, max_chars, 30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_timeout(
|
||||||
|
endpoint: String,
|
||||||
|
key: SecretString,
|
||||||
|
model: String,
|
||||||
|
max_retries: u8,
|
||||||
|
max_chars: usize,
|
||||||
|
timeout_ms: u64,
|
||||||
|
) -> Result<Self, AiError> {
|
||||||
|
let url = url::Url::parse(&endpoint).map_err(|_| AiError::InvalidEndpoint)?;
|
||||||
|
if url.scheme() != "https" {
|
||||||
|
return Err(AiError::InvalidEndpoint);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
http: reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_millis(timeout_ms.max(100)))
|
||||||
|
.build()
|
||||||
|
.map_err(|_| AiError::Request)?,
|
||||||
|
endpoint: endpoint.trim_end_matches('/').into(),
|
||||||
|
key,
|
||||||
|
model,
|
||||||
|
max_chars: max_chars.max(1),
|
||||||
|
runtime: runtime_provider(OutboundConfig {
|
||||||
|
llm_timeout_ms: timeout_ms.max(100),
|
||||||
|
max_retries: max_retries.min(5),
|
||||||
|
..OutboundConfig::default()
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_runtime(
|
||||||
|
endpoint: String,
|
||||||
|
key: SecretString,
|
||||||
|
model: String,
|
||||||
|
max_chars: usize,
|
||||||
|
runtime: RuntimeConfigProvider,
|
||||||
|
) -> Result<Self, AiError> {
|
||||||
|
let url = url::Url::parse(&endpoint).map_err(|_| AiError::InvalidEndpoint)?;
|
||||||
|
if url.scheme() != "https" {
|
||||||
|
return Err(AiError::InvalidEndpoint);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
endpoint: endpoint.trim_end_matches('/').into(),
|
||||||
|
key,
|
||||||
|
model,
|
||||||
|
max_chars: max_chars.max(1),
|
||||||
|
runtime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn summarize(&self, body: &str) -> Result<String, AiError> {
|
||||||
|
let request = ChatRequest {
|
||||||
|
model: &self.model,
|
||||||
|
messages: vec![ChatMessage {
|
||||||
|
role: "user",
|
||||||
|
content: body,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let (timeout_ms, max_retries) = self
|
||||||
|
.runtime
|
||||||
|
.read()
|
||||||
|
.map(|config| (config.llm_timeout_ms, config.max_retries.min(5)))
|
||||||
|
.unwrap_or((30_000, 3));
|
||||||
|
for _ in 0..=max_retries {
|
||||||
|
let response = match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(timeout_ms.max(100)),
|
||||||
|
self.http
|
||||||
|
.post(format!("{}/chat/completions", self.endpoint))
|
||||||
|
.bearer_auth(self.key.expose_secret())
|
||||||
|
.json(&request)
|
||||||
|
.send(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(response)) => response,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let status = response.status();
|
||||||
|
if status.is_success() {
|
||||||
|
let value = response
|
||||||
|
.json::<ChatResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| AiError::Response)?;
|
||||||
|
let text = value
|
||||||
|
.choices
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(|choice| choice.message.content)
|
||||||
|
.ok_or(AiError::Response)?;
|
||||||
|
return Ok(text.chars().take(self.max_chars).collect());
|
||||||
|
}
|
||||||
|
if !(status.as_u16() == 429 || status.is_server_error()) {
|
||||||
|
return Err(AiError::Request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(AiError::Response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ChatRequest<'a> {
|
||||||
|
model: &'a str,
|
||||||
|
messages: Vec<ChatMessage<'a>>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ChatMessage<'a> {
|
||||||
|
role: &'a str,
|
||||||
|
content: &'a str,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChatResponse {
|
||||||
|
choices: Vec<Choice>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Choice {
|
||||||
|
message: ChatMessageOwned,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ChatMessageOwned {
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn llm_requires_https() {
|
||||||
|
assert!(matches!(
|
||||||
|
LlmClient::new(
|
||||||
|
"http://localhost".into(),
|
||||||
|
SecretString::new("x".into()),
|
||||||
|
"m".into(),
|
||||||
|
1,
|
||||||
|
300,
|
||||||
|
),
|
||||||
|
Err(AiError::InvalidEndpoint)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::domain::{Notification, UserCommand};
|
||||||
|
|
||||||
|
#[expect(dead_code, reason = "稳定ID+阶段0占位:渠道抽象供后续 adapter 使用")]
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Channel: Send + Sync {
|
||||||
|
async fn receive(&self, command: UserCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(dead_code, reason = "稳定ID+阶段0占位:通知抽象供后续 adapter 使用")]
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Notifier: Send + Sync {
|
||||||
|
async fn notify(&self, notification: Notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(dead_code, reason = "稳定ID+阶段0占位:渠道消息适配抽象")]
|
||||||
|
pub trait MessageAdapter: Send + Sync {
|
||||||
|
fn render_notification(&self, notification: &Notification) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Telegram is intentionally only an adapter boundary in stage 0 (no bot commands yet).
|
||||||
|
pub mod telegram {
|
||||||
|
use crate::domain::Notification;
|
||||||
|
use crate::state::RuntimeConfigProvider;
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
pub struct TelegramClient {
|
||||||
|
http: reqwest::Client,
|
||||||
|
token: SecretString,
|
||||||
|
endpoint: String,
|
||||||
|
runtime: RuntimeConfigProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SendMessage<'a> {
|
||||||
|
chat_id: i64,
|
||||||
|
text: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TelegramClient {
|
||||||
|
pub fn with_runtime(token: SecretString, runtime: RuntimeConfigProvider) -> Self {
|
||||||
|
Self::with_endpoint_inner(token, runtime, "https://api.telegram.org".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_endpoint_inner(
|
||||||
|
token: SecretString,
|
||||||
|
runtime: RuntimeConfigProvider,
|
||||||
|
endpoint: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
http: reqwest::Client::new(),
|
||||||
|
token,
|
||||||
|
endpoint,
|
||||||
|
runtime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only endpoint injection; production always uses Telegram's official HTTPS host.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn with_endpoint(
|
||||||
|
token: SecretString,
|
||||||
|
runtime: RuntimeConfigProvider,
|
||||||
|
endpoint: String,
|
||||||
|
) -> Self {
|
||||||
|
Self::with_endpoint_inner(token, runtime, endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends metadata only; body text is intentionally not accepted here.
|
||||||
|
pub async fn send_notification(
|
||||||
|
&self,
|
||||||
|
chat_id: i64,
|
||||||
|
notification: &Notification,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let text = format!(
|
||||||
|
"From: {}\nSubject: {}\nReceived: {}",
|
||||||
|
notification.sender, notification.subject, notification.received_at
|
||||||
|
);
|
||||||
|
self.send_text(chat_id, &text).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_text(&self, chat_id: i64, text: &str) -> Result<(), String> {
|
||||||
|
let (timeout_ms, max_retries) = self
|
||||||
|
.runtime
|
||||||
|
.read()
|
||||||
|
.map(|config| (config.telegram_timeout_ms, config.max_retries.min(5)))
|
||||||
|
.unwrap_or((10_000, 3));
|
||||||
|
let mut last_error = None;
|
||||||
|
for _ in 0..=max_retries {
|
||||||
|
let request = self
|
||||||
|
.http
|
||||||
|
.post(format!(
|
||||||
|
"{}/bot{}/sendMessage",
|
||||||
|
self.endpoint,
|
||||||
|
self.token.expose_secret()
|
||||||
|
))
|
||||||
|
.json(&SendMessage { chat_id, text })
|
||||||
|
.send();
|
||||||
|
let result = match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(timeout_ms.max(100)),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result
|
||||||
|
.and_then(|response| response.error_for_status())
|
||||||
|
.map_err(|_| "telegram request failed".to_owned()),
|
||||||
|
Err(_) => Err("telegram request timed out".to_owned()),
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(error) => last_error = Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_error.unwrap_or_else(|| "telegram request failed".to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::state::{runtime_provider, OutboundConfig};
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
async fn mock_server(
|
||||||
|
status: &'static str,
|
||||||
|
delay_ms: u64,
|
||||||
|
requests: Arc<AtomicUsize>,
|
||||||
|
) -> String {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((mut socket, _)) = listener.accept().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
requests.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let mut buffer = [0_u8; 1024];
|
||||||
|
let _ = socket.read(&mut buffer).await;
|
||||||
|
if delay_ms > 0 {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
||||||
|
}
|
||||||
|
let response = format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\n\r\n");
|
||||||
|
let _ = socket.write_all(response.as_bytes()).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
format!("http://{address}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client(endpoint: String, retries: u8, timeout_ms: u64) -> TelegramClient {
|
||||||
|
let runtime = runtime_provider(OutboundConfig {
|
||||||
|
telegram_timeout_ms: timeout_ms,
|
||||||
|
max_retries: retries,
|
||||||
|
..OutboundConfig::default()
|
||||||
|
});
|
||||||
|
TelegramClient::with_endpoint(SecretString::new("test-token".into()), runtime, endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_endpoint_success_sends_message() {
|
||||||
|
let requests = Arc::new(AtomicUsize::new(0));
|
||||||
|
let endpoint = mock_server("200 OK", 0, requests.clone()).await;
|
||||||
|
client(endpoint, 0, 1_000)
|
||||||
|
.send_text(42, "hello")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(requests.load(Ordering::Relaxed), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn error_response_retries_with_bound() {
|
||||||
|
let requests = Arc::new(AtomicUsize::new(0));
|
||||||
|
let endpoint = mock_server("500 Internal Server Error", 0, requests.clone()).await;
|
||||||
|
assert!(client(endpoint, 2, 1_000)
|
||||||
|
.send_text(42, "hello")
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(requests.load(Ordering::Relaxed), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn timeout_retries_with_bound() {
|
||||||
|
let requests = Arc::new(AtomicUsize::new(0));
|
||||||
|
let endpoint = mock_server("200 OK", 250, requests.clone()).await;
|
||||||
|
assert!(client(endpoint, 1, 100)
|
||||||
|
.send_text(42, "hello")
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
// The first request is guaranteed to time out; reqwest may cancel the
|
||||||
|
// second connection before the tiny test server accepts it.
|
||||||
|
assert!(requests.load(Ordering::Relaxed) >= 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,468 @@
|
||||||
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
|
|
||||||
|
use crate::error::BotError;
|
||||||
|
|
||||||
|
/// Returns the SHA-256 digest used for Redis admin-session records. The bearer token itself
|
||||||
|
/// never enters Redis (SAF-ADMIN-SESSION, C-REDIS-ONLY-STATE).
|
||||||
|
pub(crate) fn session_digest(token: &str) -> String {
|
||||||
|
let digest = ring::digest::digest(&ring::digest::SHA256, token.as_bytes());
|
||||||
|
digest
|
||||||
|
.as_ref()
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encryption_key_from_env() -> Result<[u8; 32], BotError> {
|
||||||
|
let value = std::env::var("CONFIG_ENCRYPTION_KEY")
|
||||||
|
.map_err(|_| BotError::Config("missing CONFIG_ENCRYPTION_KEY".into()))?;
|
||||||
|
if value.len() != 64 {
|
||||||
|
return Err(BotError::Config(
|
||||||
|
"CONFIG_ENCRYPTION_KEY must be 32-byte hex".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut key = [0_u8; 32];
|
||||||
|
for (index, slot) in key.iter_mut().enumerate() {
|
||||||
|
*slot = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)
|
||||||
|
.map_err(|_| BotError::Config("CONFIG_ENCRYPTION_KEY must be hex".into()))?;
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime-only settings. SecretString prevents accidental formatting/logging of credentials.
|
||||||
|
/// C-AUTH-APP-BASIC and REQ-SINGLE-ACCOUNT are intentionally represented explicitly.
|
||||||
|
pub struct Config {
|
||||||
|
pub port: u16,
|
||||||
|
pub run_mode: String,
|
||||||
|
pub telegram: TelegramConfig,
|
||||||
|
pub jmap: JmapConfig,
|
||||||
|
pub redis_url: SecretString,
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
pub llm: LlmConfig,
|
||||||
|
pub auth: AuthSecrets,
|
||||||
|
pub worker_token: SecretString,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime-only endpoint credentials; never format or log these values.
|
||||||
|
/// C-NO-SECRET-IN-IMAGE and the stage-1 R1 boundary require process injection.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthSecrets {
|
||||||
|
pub reconcile_token: SecretString,
|
||||||
|
pub telegram_webhook_secret: SecretString,
|
||||||
|
pub jmap_push_verification: SecretString,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TelegramConfig {
|
||||||
|
pub bot_token: SecretString,
|
||||||
|
pub chat_allowlist: Vec<i64>,
|
||||||
|
pub chat_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JmapConfig {
|
||||||
|
pub session_url: String,
|
||||||
|
pub username: String,
|
||||||
|
pub app_password: SecretString,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LlmConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub allow_net: bool,
|
||||||
|
pub api_key: Option<SecretString>,
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub summary_target_chars: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All business configuration persisted below the Redis `config:business` namespace.
|
||||||
|
/// Secrets intentionally have no `Debug` implementation and are never returned by the
|
||||||
|
/// public configuration API (C-REDIS-ONLY-STATE, SAF-NO-SECRET-ECHO).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct BusinessConfig {
|
||||||
|
pub bot_token: SecretString,
|
||||||
|
pub telegram_chat_id: i64,
|
||||||
|
pub chat_allowlist: Vec<i64>,
|
||||||
|
pub telegram_webhook_secret: SecretString,
|
||||||
|
pub jmap_session_url: String,
|
||||||
|
pub jmap_username: String,
|
||||||
|
pub jmap_password: SecretString,
|
||||||
|
pub jmap_push_verification: SecretString,
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
pub llm_enabled: bool,
|
||||||
|
pub llm_allow_net: bool,
|
||||||
|
pub llm_api_key: Option<SecretString>,
|
||||||
|
pub llm_base_url: Option<String>,
|
||||||
|
pub llm_model: Option<String>,
|
||||||
|
pub reconcile_token: SecretString,
|
||||||
|
pub worker_token: SecretString,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redis wire representation. This type is private to the persistence adapter; callers must
|
||||||
|
/// never serialize it into an HTTP response (SAF-NO-SECRET-ECHO).
|
||||||
|
#[derive(Clone, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub(crate) struct BusinessConfigWire {
|
||||||
|
pub bot_token: String,
|
||||||
|
pub telegram_chat_id: i64,
|
||||||
|
pub chat_allowlist: Vec<i64>,
|
||||||
|
pub telegram_webhook_secret: String,
|
||||||
|
pub jmap_session_url: String,
|
||||||
|
pub jmap_username: String,
|
||||||
|
pub jmap_password: String,
|
||||||
|
pub jmap_push_verification: String,
|
||||||
|
pub account_id: Option<String>,
|
||||||
|
pub llm_enabled: bool,
|
||||||
|
pub llm_allow_net: bool,
|
||||||
|
pub llm_api_key: Option<String>,
|
||||||
|
pub llm_base_url: Option<String>,
|
||||||
|
pub llm_model: Option<String>,
|
||||||
|
pub reconcile_token: String,
|
||||||
|
pub worker_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BusinessConfig> for BusinessConfigWire {
|
||||||
|
fn from(value: BusinessConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
bot_token: value.bot_token.expose_secret().to_owned(),
|
||||||
|
telegram_chat_id: value.telegram_chat_id,
|
||||||
|
chat_allowlist: value.chat_allowlist,
|
||||||
|
telegram_webhook_secret: value.telegram_webhook_secret.expose_secret().to_owned(),
|
||||||
|
jmap_session_url: value.jmap_session_url,
|
||||||
|
jmap_username: value.jmap_username,
|
||||||
|
jmap_password: value.jmap_password.expose_secret().to_owned(),
|
||||||
|
jmap_push_verification: value.jmap_push_verification.expose_secret().to_owned(),
|
||||||
|
account_id: value.account_id,
|
||||||
|
llm_enabled: value.llm_enabled,
|
||||||
|
llm_allow_net: value.llm_allow_net,
|
||||||
|
llm_api_key: value.llm_api_key.map(|s| s.expose_secret().to_owned()),
|
||||||
|
llm_base_url: value.llm_base_url,
|
||||||
|
llm_model: value.llm_model,
|
||||||
|
reconcile_token: value.reconcile_token.expose_secret().to_owned(),
|
||||||
|
worker_token: value.worker_token.expose_secret().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<BusinessConfigWire> for BusinessConfig {
|
||||||
|
type Error = BotError;
|
||||||
|
|
||||||
|
fn try_from(value: BusinessConfigWire) -> Result<Self, Self::Error> {
|
||||||
|
validate_nonblank("BOT_TOKEN", &value.bot_token)?;
|
||||||
|
validate_nonblank("JMAP_SESSION_URL", &value.jmap_session_url)?;
|
||||||
|
validate_nonblank("JMAP_USERNAME", &value.jmap_username)?;
|
||||||
|
validate_nonblank("JMAP_PASSWORD", &value.jmap_password)?;
|
||||||
|
validate_nonblank("TG_WEBHOOK_SECRET", &value.telegram_webhook_secret)?;
|
||||||
|
validate_nonblank("JMAP_PUSH_VERIFICATION", &value.jmap_push_verification)?;
|
||||||
|
validate_nonblank("RECONCILE_TOKEN", &value.reconcile_token)?;
|
||||||
|
validate_nonblank("WORKER_TOKEN", &value.worker_token)?;
|
||||||
|
if let Some(key) = value.llm_api_key.as_deref() {
|
||||||
|
validate_nonblank("LLM_API_KEY", key)?;
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
bot_token: SecretString::new(value.bot_token),
|
||||||
|
telegram_chat_id: value.telegram_chat_id,
|
||||||
|
chat_allowlist: value.chat_allowlist,
|
||||||
|
telegram_webhook_secret: SecretString::new(value.telegram_webhook_secret),
|
||||||
|
jmap_session_url: value.jmap_session_url,
|
||||||
|
jmap_username: value.jmap_username,
|
||||||
|
jmap_password: SecretString::new(value.jmap_password),
|
||||||
|
jmap_push_verification: SecretString::new(value.jmap_push_verification),
|
||||||
|
account_id: value.account_id,
|
||||||
|
llm_enabled: value.llm_enabled,
|
||||||
|
llm_allow_net: value.llm_allow_net,
|
||||||
|
llm_api_key: value.llm_api_key.map(SecretString::new),
|
||||||
|
llm_base_url: value.llm_base_url,
|
||||||
|
llm_model: value.llm_model,
|
||||||
|
reconcile_token: SecretString::new(value.reconcile_token),
|
||||||
|
worker_token: SecretString::new(value.worker_token),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_business_wire(wire: BusinessConfigWire) -> Result<(), BotError> {
|
||||||
|
let config: BusinessConfig = wire.try_into()?;
|
||||||
|
let session = url::Url::parse(&config.jmap_session_url)
|
||||||
|
.map_err(|_| BotError::Config("JMAP_SESSION_URL must be a valid URL".into()))?;
|
||||||
|
if session.scheme() != "https"
|
||||||
|
|| session.host_str().is_none()
|
||||||
|
|| session.username() != ""
|
||||||
|
|| session.password().is_some()
|
||||||
|
|| session.query().is_some()
|
||||||
|
|| session.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err(BotError::Config(
|
||||||
|
"JMAP_SESSION_URL must be HTTPS without credentials or query".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if config.llm_enabled {
|
||||||
|
let base = config
|
||||||
|
.llm_base_url
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| BotError::Config("LLM_BASE_URL required when LLM enabled".into()))?;
|
||||||
|
if config.llm_api_key.is_none() || config.llm_model.as_deref().is_none() {
|
||||||
|
return Err(BotError::Config(
|
||||||
|
"LLM credentials required when LLM enabled".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let endpoint = url::Url::parse(base)
|
||||||
|
.map_err(|_| BotError::Config("LLM_BASE_URL must be a valid URL".into()))?;
|
||||||
|
if endpoint.scheme() != "https" {
|
||||||
|
return Err(BotError::Config("LLM_BASE_URL must use HTTPS".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Configuration-only startup used before Redis bootstrap. No business route can pass
|
||||||
|
/// authentication with these empty sentinels; the process only serves SPA/bootstrap.
|
||||||
|
pub fn redis_only(redis_url: SecretString) -> Self {
|
||||||
|
let empty = || SecretString::new(String::new());
|
||||||
|
Self {
|
||||||
|
port: 8080,
|
||||||
|
run_mode: "webhook".into(),
|
||||||
|
telegram: TelegramConfig {
|
||||||
|
bot_token: empty(),
|
||||||
|
chat_allowlist: Vec::new(),
|
||||||
|
chat_id: 0,
|
||||||
|
},
|
||||||
|
jmap: JmapConfig {
|
||||||
|
session_url: String::new(),
|
||||||
|
username: String::new(),
|
||||||
|
app_password: empty(),
|
||||||
|
},
|
||||||
|
redis_url,
|
||||||
|
account_id: None,
|
||||||
|
llm: LlmConfig {
|
||||||
|
enabled: false,
|
||||||
|
allow_net: false,
|
||||||
|
api_key: None,
|
||||||
|
base_url: None,
|
||||||
|
model: None,
|
||||||
|
summary_target_chars: 300,
|
||||||
|
},
|
||||||
|
auth: AuthSecrets {
|
||||||
|
reconcile_token: empty(),
|
||||||
|
telegram_webhook_secret: empty(),
|
||||||
|
jmap_push_verification: empty(),
|
||||||
|
},
|
||||||
|
worker_token: empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the runtime adapter configuration from the Redis business namespace.
|
||||||
|
/// This path is used after bootstrap and intentionally does not consult process env.
|
||||||
|
pub fn from_business(redis_url: SecretString, value: BusinessConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
port: 8080,
|
||||||
|
run_mode: "webhook".into(),
|
||||||
|
telegram: TelegramConfig {
|
||||||
|
bot_token: value.bot_token,
|
||||||
|
chat_allowlist: value.chat_allowlist,
|
||||||
|
chat_id: value.telegram_chat_id,
|
||||||
|
},
|
||||||
|
jmap: JmapConfig {
|
||||||
|
session_url: value.jmap_session_url,
|
||||||
|
username: value.jmap_username,
|
||||||
|
app_password: value.jmap_password,
|
||||||
|
},
|
||||||
|
redis_url,
|
||||||
|
account_id: value.account_id,
|
||||||
|
llm: LlmConfig {
|
||||||
|
enabled: value.llm_enabled,
|
||||||
|
allow_net: value.llm_allow_net,
|
||||||
|
api_key: value.llm_api_key,
|
||||||
|
base_url: value.llm_base_url,
|
||||||
|
model: value.llm_model,
|
||||||
|
summary_target_chars: 300,
|
||||||
|
},
|
||||||
|
auth: AuthSecrets {
|
||||||
|
reconcile_token: value.reconcile_token,
|
||||||
|
telegram_webhook_secret: value.telegram_webhook_secret,
|
||||||
|
jmap_push_verification: value.jmap_push_verification,
|
||||||
|
},
|
||||||
|
worker_token: value.worker_token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_business_json(
|
||||||
|
redis_url: SecretString,
|
||||||
|
value: serde_json::Value,
|
||||||
|
) -> Result<Self, BotError> {
|
||||||
|
let wire: BusinessConfigWire = serde_json::from_value(value)
|
||||||
|
.map_err(|_| BotError::Config("invalid Redis business configuration".into()))?;
|
||||||
|
Self::from_business_value(redis_url, wire)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_business_value(
|
||||||
|
redis_url: SecretString,
|
||||||
|
wire: BusinessConfigWire,
|
||||||
|
) -> Result<Self, BotError> {
|
||||||
|
Ok(Self::from_business(redis_url, wire.try_into()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_env() -> Result<Self, BotError> {
|
||||||
|
let port = std::env::var("PORT")
|
||||||
|
.unwrap_or_else(|_| "8080".into())
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| BotError::Config("PORT must be a valid u16".into()))?;
|
||||||
|
let allowlist = std::env::var("CHAT_ALLOWLIST")
|
||||||
|
.or_else(|_| std::env::var("TELEGRAM_CHAT_ID"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.split(',')
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
.map(|v| v.trim().parse::<i64>())
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|_| BotError::Config("CHAT_ALLOWLIST must contain integer IDs".into()))?;
|
||||||
|
let enabled = env_bool("LLM_ENABLED", false)?;
|
||||||
|
let llm = LlmConfig {
|
||||||
|
enabled,
|
||||||
|
allow_net: env_bool("LLM_ALLOW_NET", false)?,
|
||||||
|
api_key: std::env::var("LLM_API_KEY").ok().map(SecretString::new),
|
||||||
|
base_url: std::env::var("LLM_BASE_URL").ok(),
|
||||||
|
model: std::env::var("LLM_MODEL").ok(),
|
||||||
|
summary_target_chars: std::env::var("LLM_SUMMARY_TARGET_CHARS")
|
||||||
|
.unwrap_or_else(|_| "300".into())
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| {
|
||||||
|
BotError::Config("LLM_SUMMARY_TARGET_CHARS must be an integer".into())
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
if enabled && (llm.api_key.is_none() || llm.base_url.is_none() || llm.model.is_none()) {
|
||||||
|
return Err(BotError::Config(
|
||||||
|
"LLM_ENABLED requires LLM_API_KEY, LLM_BASE_URL and LLM_MODEL".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
port,
|
||||||
|
run_mode: std::env::var("RUN_MODE").unwrap_or_else(|_| "webhook".into()),
|
||||||
|
telegram: TelegramConfig {
|
||||||
|
bot_token: required_secret("BOT_TOKEN")?,
|
||||||
|
chat_allowlist: allowlist,
|
||||||
|
chat_id: std::env::var("TELEGRAM_CHAT_ID")
|
||||||
|
.map_err(|_| BotError::Config("missing TELEGRAM_CHAT_ID".into()))?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| BotError::Config("TELEGRAM_CHAT_ID must be an integer".into()))?,
|
||||||
|
},
|
||||||
|
jmap: JmapConfig {
|
||||||
|
session_url: required_nonblank("JMAP_SESSION_URL")?,
|
||||||
|
username: required_nonblank("JMAP_USERNAME")?,
|
||||||
|
app_password: required_secret("JMAP_PASSWORD")?,
|
||||||
|
},
|
||||||
|
redis_url: required_secret("REDIS_URL")?,
|
||||||
|
account_id: std::env::var("ACCOUNT_ID").ok().filter(|v| !v.is_empty()),
|
||||||
|
llm,
|
||||||
|
auth: AuthSecrets {
|
||||||
|
reconcile_token: required_secret("RECONCILE_TOKEN")?,
|
||||||
|
telegram_webhook_secret: required_secret("TG_WEBHOOK_SECRET")?,
|
||||||
|
jmap_push_verification: required_secret("JMAP_PUSH_VERIFICATION")?,
|
||||||
|
},
|
||||||
|
worker_token: required_secret("WORKER_TOKEN")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exposes a secret only to the client adapter at the point of use; never log this value.
|
||||||
|
#[expect(
|
||||||
|
dead_code,
|
||||||
|
reason = "稳定ID+阶段0占位:JMAP adapter 后续使用密钥访问器"
|
||||||
|
)]
|
||||||
|
pub fn jmap_password(&self) -> &str {
|
||||||
|
self.jmap.app_password.expose_secret()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_nonblank(name: &str) -> Result<String, BotError> {
|
||||||
|
let value = std::env::var(name).map_err(|_| BotError::Config(format!("missing {name}")))?;
|
||||||
|
validate_nonblank(name, value.as_str())?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_secret(name: &str) -> Result<SecretString, BotError> {
|
||||||
|
let value = required_nonblank(name)?;
|
||||||
|
Ok(SecretString::new(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_nonblank(name: &str, value: &str) -> Result<(), BotError> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err(BotError::Config(format!("missing or blank {name}")));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_bool(name: &str, default: bool) -> Result<bool, BotError> {
|
||||||
|
match std::env::var(name).ok().as_deref() {
|
||||||
|
None => Ok(default),
|
||||||
|
Some("true" | "1" | "yes") => Ok(true),
|
||||||
|
Some("false" | "0" | "no") => Ok(false),
|
||||||
|
Some(_) => Err(BotError::Config(format!("{name} must be boolean"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_auth_secret_is_rejected_without_exposing_value() {
|
||||||
|
let error = required_secret("S1_TEST_AUTH_SECRET").expect_err("unset must fail");
|
||||||
|
assert!(error.to_string().contains("S1_TEST_AUTH_SECRET"));
|
||||||
|
assert!(!error.to_string().contains("secret"));
|
||||||
|
std::env::set_var("S1_TEST_AUTH_SECRET", " \t");
|
||||||
|
let error = required_secret("S1_TEST_AUTH_SECRET").expect_err("blank must fail");
|
||||||
|
assert!(error.to_string().contains("S1_TEST_AUTH_SECRET"));
|
||||||
|
std::env::remove_var("S1_TEST_AUTH_SECRET");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn required_config_values_reject_blank_without_exposing_values() {
|
||||||
|
for name in [
|
||||||
|
"BOT_TOKEN",
|
||||||
|
"JMAP_SESSION_URL",
|
||||||
|
"JMAP_USERNAME",
|
||||||
|
"JMAP_PASSWORD",
|
||||||
|
"REDIS_URL",
|
||||||
|
] {
|
||||||
|
let error = validate_nonblank(name, " \t").expect_err("blank must fail");
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
format!("configuration error: missing or blank {name}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_rejects_blank_auth_secret() {
|
||||||
|
for (name, value) in [
|
||||||
|
("BOT_TOKEN", "bot"),
|
||||||
|
("JMAP_SESSION_URL", "https://mail.invalid/.well-known/jmap"),
|
||||||
|
("JMAP_USERNAME", "user"),
|
||||||
|
("JMAP_PASSWORD", "password"),
|
||||||
|
("REDIS_URL", "redis://invalid"),
|
||||||
|
("TG_WEBHOOK_SECRET", "telegram"),
|
||||||
|
("JMAP_PUSH_VERIFICATION", "verification"),
|
||||||
|
("TELEGRAM_CHAT_ID", "123"),
|
||||||
|
] {
|
||||||
|
std::env::set_var(name, value);
|
||||||
|
}
|
||||||
|
std::env::set_var("RECONCILE_TOKEN", " \t");
|
||||||
|
let error = match Config::from_env() {
|
||||||
|
Err(error) => error,
|
||||||
|
Ok(_) => panic!("blank auth secret must fail startup"),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"configuration error: missing or blank RECONCILE_TOKEN"
|
||||||
|
);
|
||||||
|
for name in [
|
||||||
|
"BOT_TOKEN",
|
||||||
|
"JMAP_SESSION_URL",
|
||||||
|
"JMAP_USERNAME",
|
||||||
|
"JMAP_PASSWORD",
|
||||||
|
"REDIS_URL",
|
||||||
|
"RECONCILE_TOKEN",
|
||||||
|
"TG_WEBHOOK_SECRET",
|
||||||
|
"JMAP_PUSH_VERIFICATION",
|
||||||
|
"TELEGRAM_CHAT_ID",
|
||||||
|
] {
|
||||||
|
std::env::remove_var(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
//! Channel-independent domain values. Telegram/teloxide types must not cross this boundary.
|
||||||
|
|
||||||
|
pub mod jmap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UserCommand {
|
||||||
|
pub text: String,
|
||||||
|
pub chat_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Notification {
|
||||||
|
pub sender: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub received_at: String,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
//! Read-only JMAP domain boundary (REQ-SINGLE-ACCOUNT, REQ-LONG-EMAIL).
|
||||||
|
//!
|
||||||
|
//! The adapter returns channel-neutral values. In particular, `read_email` returns
|
||||||
|
//! the original text supplied by JMAP; truncation and Telegram formatting belong to
|
||||||
|
//! the caller. There are deliberately no mutation, attachment, AI, or streaming APIs.
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors exposed by the domain boundary. Client details are retained for callers,
|
||||||
|
/// while credentials are never included in these messages.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum JmapError {
|
||||||
|
#[error("JMAP client error")]
|
||||||
|
Client(#[from] jmap_client::Error),
|
||||||
|
#[error("JMAP object was not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("invalid JMAP request: {0}")]
|
||||||
|
InvalidRequest(&'static str),
|
||||||
|
#[error("JMAP field is missing: {0}")]
|
||||||
|
MissingField(&'static str),
|
||||||
|
#[error("JMAP request timed out")]
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Folder {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub total_emails: usize,
|
||||||
|
pub unread_emails: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata only: no body or attachment is loaded by this operation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EmailMetadata {
|
||||||
|
pub id: String,
|
||||||
|
pub subject: Option<String>,
|
||||||
|
pub sender: Option<String>,
|
||||||
|
pub received_at: Option<i64>,
|
||||||
|
pub preview: Option<String>,
|
||||||
|
pub size: Option<usize>,
|
||||||
|
pub has_attachment: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Original message content plus length information for the presentation layer.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EmailContent {
|
||||||
|
pub metadata: EmailMetadata,
|
||||||
|
pub text: String,
|
||||||
|
pub character_count: usize,
|
||||||
|
pub is_long: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait JmapBackend: Send + Sync {
|
||||||
|
async fn list_folders(&self, account_id: &str) -> Result<Vec<Folder>, JmapError>;
|
||||||
|
async fn list_emails(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<EmailMetadata>, JmapError>;
|
||||||
|
async fn read_email(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
email_id: &str,
|
||||||
|
) -> Result<Option<EmailContent>, JmapError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-account service. `long_email_limit` only annotates output; it never
|
||||||
|
/// truncates source content, leaving display policy to the channel adapter.
|
||||||
|
pub struct JmapService<B> {
|
||||||
|
backend: B,
|
||||||
|
account_id: String,
|
||||||
|
long_email_limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B> JmapService<B> {
|
||||||
|
pub fn account_id(&self) -> &str {
|
||||||
|
&self.account_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new(backend: B, account_id: impl Into<String>) -> Result<Self, JmapError> {
|
||||||
|
Self::with_long_email_limit(backend, account_id, 4_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_long_email_limit(
|
||||||
|
backend: B,
|
||||||
|
account_id: impl Into<String>,
|
||||||
|
long_email_limit: usize,
|
||||||
|
) -> Result<Self, JmapError> {
|
||||||
|
let account_id = account_id.into();
|
||||||
|
if account_id.trim().is_empty() {
|
||||||
|
return Err(JmapError::InvalidRequest("account_id"));
|
||||||
|
}
|
||||||
|
if long_email_limit == 0 {
|
||||||
|
return Err(JmapError::InvalidRequest("long_email_limit"));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
backend,
|
||||||
|
account_id,
|
||||||
|
long_email_limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: JmapBackend> JmapService<B> {
|
||||||
|
pub async fn list_folders(&self) -> Result<Vec<Folder>, JmapError> {
|
||||||
|
self.backend.list_folders(&self.account_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_emails(
|
||||||
|
&self,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<EmailMetadata>, JmapError> {
|
||||||
|
if limit == 0 {
|
||||||
|
return Err(JmapError::InvalidRequest("limit"));
|
||||||
|
}
|
||||||
|
self.backend
|
||||||
|
.list_emails(&self.account_id, folder_id, limit)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_email(&self, email_id: &str) -> Result<Option<EmailContent>, JmapError> {
|
||||||
|
if email_id.trim().is_empty() {
|
||||||
|
return Err(JmapError::InvalidRequest("email_id"));
|
||||||
|
}
|
||||||
|
let Some(mut content) = self.backend.read_email(&self.account_id, email_id).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
content.character_count = content.text.chars().count();
|
||||||
|
content.is_long = content.character_count > self.long_email_limit;
|
||||||
|
Ok(Some(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct MockBackend;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl JmapBackend for MockBackend {
|
||||||
|
async fn list_folders(&self, account_id: &str) -> Result<Vec<Folder>, JmapError> {
|
||||||
|
assert_eq!(account_id, "account-1");
|
||||||
|
Ok(vec![Folder {
|
||||||
|
id: "inbox".into(),
|
||||||
|
name: "Inbox".into(),
|
||||||
|
role: Some("inbox".into()),
|
||||||
|
total_emails: 1,
|
||||||
|
unread_emails: 1,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
async fn list_emails(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<EmailMetadata>, JmapError> {
|
||||||
|
assert_eq!(
|
||||||
|
(account_id, folder_id, limit),
|
||||||
|
("account-1", Some("inbox"), 10)
|
||||||
|
);
|
||||||
|
Ok(vec![EmailMetadata {
|
||||||
|
id: "email-1".into(),
|
||||||
|
subject: Some("hello".into()),
|
||||||
|
sender: None,
|
||||||
|
received_at: None,
|
||||||
|
preview: Some("hello".into()),
|
||||||
|
size: Some(5),
|
||||||
|
has_attachment: false,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
async fn read_email(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
email_id: &str,
|
||||||
|
) -> Result<Option<EmailContent>, JmapError> {
|
||||||
|
assert_eq!((account_id, email_id), ("account-1", "email-1"));
|
||||||
|
Ok(Some(EmailContent {
|
||||||
|
metadata: EmailMetadata {
|
||||||
|
id: email_id.into(),
|
||||||
|
subject: None,
|
||||||
|
sender: None,
|
||||||
|
received_at: None,
|
||||||
|
preview: None,
|
||||||
|
size: None,
|
||||||
|
has_attachment: false,
|
||||||
|
},
|
||||||
|
text: "原文内容".into(),
|
||||||
|
character_count: 0,
|
||||||
|
is_long: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn service_keeps_original_content_and_marks_long_messages() {
|
||||||
|
let service = JmapService::with_long_email_limit(MockBackend, "account-1", 2).unwrap();
|
||||||
|
assert_eq!(service.list_folders().await.unwrap().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
service.list_emails(Some("inbox"), 10).await.unwrap().len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let email = service.read_email("email-1").await.unwrap().unwrap();
|
||||||
|
assert_eq!(email.text, "原文内容");
|
||||||
|
assert_eq!(email.character_count, 4);
|
||||||
|
assert!(email.is_long);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_rejects_invalid_limits_and_account() {
|
||||||
|
assert!(JmapService::new(MockBackend, " ").is_err());
|
||||||
|
assert!(JmapService::with_long_email_limit(MockBackend, "a", 0).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,386 @@
|
||||||
|
//! Concrete read-only adapter (MOD-JMAP-CLIENT, GATE-G1-JMAP-READONLY).
|
||||||
|
|
||||||
|
use super::{EmailContent, EmailMetadata, Folder, JmapBackend, JmapError};
|
||||||
|
use crate::state::{runtime_provider, OutboundConfig, RuntimeConfigProvider};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use jmap_client::{email, mailbox, Get};
|
||||||
|
|
||||||
|
pub struct JmapClientBackend {
|
||||||
|
client: jmap_client::client::Client,
|
||||||
|
account_id: String,
|
||||||
|
runtime: RuntimeConfigProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapClientBackend {
|
||||||
|
pub async fn connect(
|
||||||
|
session_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
account_id: Option<&str>,
|
||||||
|
) -> Result<Self, JmapError> {
|
||||||
|
Self::connect_with_runtime(
|
||||||
|
session_url,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
account_id,
|
||||||
|
runtime_provider(OutboundConfig::default()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect_with_runtime(
|
||||||
|
session_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
account_id: Option<&str>,
|
||||||
|
runtime: RuntimeConfigProvider,
|
||||||
|
) -> Result<Self, JmapError> {
|
||||||
|
let session_base = normalize_session_url(session_url)?;
|
||||||
|
let timeout_ms = runtime
|
||||||
|
.read()
|
||||||
|
.map(|config| config.jmap_timeout_ms)
|
||||||
|
.unwrap_or(15_000);
|
||||||
|
let timeout = std::time::Duration::from_millis(timeout_ms.max(100));
|
||||||
|
let connect = jmap_client::client::Client::new()
|
||||||
|
.credentials((username, password))
|
||||||
|
.connect(&session_base);
|
||||||
|
let mut client = tokio::time::timeout(timeout, connect)
|
||||||
|
.await
|
||||||
|
.map_err(|_| JmapError::Timeout)??;
|
||||||
|
let selected = select_account(
|
||||||
|
account_id,
|
||||||
|
client.default_account_id(),
|
||||||
|
client.session().accounts(),
|
||||||
|
)?;
|
||||||
|
client.set_default_account_id(selected.clone());
|
||||||
|
Ok(Self {
|
||||||
|
client,
|
||||||
|
account_id: selected,
|
||||||
|
runtime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn account_id(&self) -> &str {
|
||||||
|
&self.account_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl JmapBackend for JmapClientBackend {
|
||||||
|
async fn list_folders(&self, account_id: &str) -> Result<Vec<Folder>, JmapError> {
|
||||||
|
if account_id != self.account_id {
|
||||||
|
return Err(JmapError::InvalidRequest("account_id"));
|
||||||
|
}
|
||||||
|
let ids = tokio::time::timeout(
|
||||||
|
self.timeout(),
|
||||||
|
self.client.mailbox_query(
|
||||||
|
None::<mailbox::query::Filter>,
|
||||||
|
None::<Vec<jmap_client::core::query::Comparator<mailbox::query::Comparator>>>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| JmapError::Timeout)??
|
||||||
|
.take_ids();
|
||||||
|
let mut folders = Vec::with_capacity(ids.len());
|
||||||
|
for id in ids {
|
||||||
|
if let Some(mailbox) = tokio::time::timeout(
|
||||||
|
self.timeout(),
|
||||||
|
self.client.mailbox_get(&id, None::<Vec<mailbox::Property>>),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| JmapError::Timeout)??
|
||||||
|
{
|
||||||
|
folders.push(Folder {
|
||||||
|
id: mailbox
|
||||||
|
.id()
|
||||||
|
.ok_or(JmapError::MissingField("mailbox id"))?
|
||||||
|
.to_owned(),
|
||||||
|
name: mailbox.name().unwrap_or_default().to_owned(),
|
||||||
|
role: role_name(mailbox.role()),
|
||||||
|
total_emails: mailbox.total_emails(),
|
||||||
|
unread_emails: mailbox.unread_emails(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_emails(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
folder_id: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<EmailMetadata>, JmapError> {
|
||||||
|
if account_id != self.account_id || limit == 0 {
|
||||||
|
return Err(JmapError::InvalidRequest("email query"));
|
||||||
|
}
|
||||||
|
let filter: Option<email::query::Filter> = folder_id.map(email::query::Filter::in_mailbox);
|
||||||
|
let ids = tokio::time::timeout(
|
||||||
|
self.timeout(),
|
||||||
|
self.client.email_query(
|
||||||
|
filter,
|
||||||
|
None::<Vec<jmap_client::core::query::Comparator<email::query::Comparator>>>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| JmapError::Timeout)??
|
||||||
|
.take_ids();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for id in ids.into_iter().take(limit) {
|
||||||
|
if let Some(email) = tokio::time::timeout(
|
||||||
|
self.timeout(),
|
||||||
|
self.client.email_get(&id, None::<Vec<email::Property>>),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| JmapError::Timeout)??
|
||||||
|
{
|
||||||
|
out.push(metadata(&email));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_email(
|
||||||
|
&self,
|
||||||
|
account_id: &str,
|
||||||
|
email_id: &str,
|
||||||
|
) -> Result<Option<EmailContent>, JmapError> {
|
||||||
|
if account_id != self.account_id {
|
||||||
|
return Err(JmapError::InvalidRequest("account_id"));
|
||||||
|
}
|
||||||
|
let mut request = self.client.build();
|
||||||
|
let get = request.get_email().ids([email_id]).properties([
|
||||||
|
email::Property::Subject,
|
||||||
|
email::Property::Preview,
|
||||||
|
email::Property::TextBody,
|
||||||
|
email::Property::BodyValues,
|
||||||
|
email::Property::Size,
|
||||||
|
email::Property::ReceivedAt,
|
||||||
|
email::Property::HasAttachment,
|
||||||
|
]);
|
||||||
|
get.arguments().fetch_text_body_values(true);
|
||||||
|
let Some(email) = tokio::time::timeout(self.timeout(), request.send_get_email())
|
||||||
|
.await
|
||||||
|
.map_err(|_| JmapError::Timeout)??
|
||||||
|
.take_list()
|
||||||
|
.pop()
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let text = join_body_values(email.text_body().into_iter().flatten().map(|part| {
|
||||||
|
part.part_id()
|
||||||
|
.and_then(|id| email.body_value(id))
|
||||||
|
.map(|body| body.value())
|
||||||
|
}))?;
|
||||||
|
Ok(Some(EmailContent {
|
||||||
|
metadata: metadata(&email),
|
||||||
|
character_count: text.chars().count(),
|
||||||
|
is_long: false,
|
||||||
|
text,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapClientBackend {
|
||||||
|
fn timeout(&self) -> std::time::Duration {
|
||||||
|
std::time::Duration::from_millis(
|
||||||
|
self.runtime
|
||||||
|
.read()
|
||||||
|
.map(|config| config.jmap_timeout_ms)
|
||||||
|
.unwrap_or(15_000)
|
||||||
|
.max(100),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_body_values<'a>(
|
||||||
|
values: impl Iterator<Item = Option<&'a str>>,
|
||||||
|
) -> Result<String, JmapError> {
|
||||||
|
let text = values.flatten().collect::<String>();
|
||||||
|
if text.is_empty() {
|
||||||
|
Err(JmapError::MissingField("text body value"))
|
||||||
|
} else {
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_name(role: mailbox::Role) -> Option<String> {
|
||||||
|
match role {
|
||||||
|
mailbox::Role::Archive => Some("archive".into()),
|
||||||
|
mailbox::Role::Drafts => Some("drafts".into()),
|
||||||
|
mailbox::Role::Important => Some("important".into()),
|
||||||
|
mailbox::Role::Inbox => Some("inbox".into()),
|
||||||
|
mailbox::Role::Junk => Some("junk".into()),
|
||||||
|
mailbox::Role::Sent => Some("sent".into()),
|
||||||
|
mailbox::Role::Trash => Some("trash".into()),
|
||||||
|
mailbox::Role::Other(value) => Some(value),
|
||||||
|
mailbox::Role::None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_session_url(value: &str) -> Result<String, JmapError> {
|
||||||
|
let parsed =
|
||||||
|
url::Url::parse(value).map_err(|_| JmapError::InvalidRequest("JMAP_SESSION_URL"))?;
|
||||||
|
if parsed.scheme() != "https" {
|
||||||
|
return Err(JmapError::InvalidRequest("JMAP_SESSION_URL must use HTTPS"));
|
||||||
|
}
|
||||||
|
if !parsed.username().is_empty()
|
||||||
|
|| parsed.password().is_some()
|
||||||
|
|| parsed.query().is_some()
|
||||||
|
|| parsed.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err(JmapError::InvalidRequest(
|
||||||
|
"JMAP_SESSION_URL must not contain credentials or query",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !matches!(parsed.path(), "" | "/" | "/.well-known/jmap") {
|
||||||
|
return Err(JmapError::InvalidRequest("invalid JMAP_SESSION_URL path"));
|
||||||
|
}
|
||||||
|
Ok(parsed.origin().ascii_serialization())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_account<'a>(
|
||||||
|
requested: Option<&str>,
|
||||||
|
primary: &'a str,
|
||||||
|
mut accounts: impl Iterator<Item = &'a String>,
|
||||||
|
) -> Result<String, JmapError> {
|
||||||
|
let selected = requested
|
||||||
|
.filter(|id| !id.trim().is_empty())
|
||||||
|
.unwrap_or(primary);
|
||||||
|
if selected.is_empty() {
|
||||||
|
return Err(JmapError::MissingField("primary account"));
|
||||||
|
}
|
||||||
|
if !accounts.any(|id| id == selected) {
|
||||||
|
return Err(JmapError::InvalidRequest(
|
||||||
|
"ACCOUNT_ID is not in session accounts",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(selected.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(email: &jmap_client::email::Email<Get>) -> EmailMetadata {
|
||||||
|
EmailMetadata {
|
||||||
|
id: email.id().unwrap_or_default().to_owned(),
|
||||||
|
subject: email.subject().map(str::to_owned),
|
||||||
|
sender: email
|
||||||
|
.from()
|
||||||
|
.and_then(|addresses| addresses.first())
|
||||||
|
.map(|address| address.email().to_owned()),
|
||||||
|
received_at: email.received_at(),
|
||||||
|
preview: email.preview().map(str::to_owned),
|
||||||
|
size: Some(email.size()),
|
||||||
|
has_attachment: email.has_attachment(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod real_server_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Opt-in smoke test for a real Stalwart instance; credentials are never printed.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires an explicitly configured JMAP test server"]
|
||||||
|
async fn session_list_and_read_smoke() {
|
||||||
|
let Some(url) = std::env::var_os("JMAP_SESSION_URL") else {
|
||||||
|
eprintln!("skipped: JMAP_SESSION_URL is not configured");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(user) = std::env::var_os("JMAP_USERNAME") else {
|
||||||
|
eprintln!("skipped: JMAP_USERNAME is not configured");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(password) = std::env::var_os("JMAP_PASSWORD") else {
|
||||||
|
eprintln!("skipped: JMAP_PASSWORD is not configured");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let account = std::env::var("ACCOUNT_ID").ok();
|
||||||
|
let backend = JmapClientBackend::connect(
|
||||||
|
url.to_str().unwrap_or_default(),
|
||||||
|
user.to_str().unwrap_or_default(),
|
||||||
|
password.to_str().unwrap_or_default(),
|
||||||
|
account.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("configured JMAP session must connect");
|
||||||
|
let folders = backend.list_folders(backend.account_id()).await.unwrap();
|
||||||
|
let emails = backend
|
||||||
|
.list_emails(backend.account_id(), None, 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if let Some(email) = emails.first() {
|
||||||
|
let _ = backend
|
||||||
|
.read_email(backend.account_id(), &email.id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert!(!folders.is_empty() || emails.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::domain::jmap::JmapService;
|
||||||
|
|
||||||
|
fn accounts(values: &[&str]) -> impl Iterator<Item = &'static String> {
|
||||||
|
let owned = values
|
||||||
|
.iter()
|
||||||
|
.map(|value| (*value).to_owned())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Box::leak(owned.into_boxed_slice()).iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn account_selection_supports_primary_and_validates_explicit() {
|
||||||
|
assert_eq!(
|
||||||
|
select_account(None, "primary", accounts(&["primary"])).unwrap(),
|
||||||
|
"primary"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
select_account(
|
||||||
|
Some("secondary"),
|
||||||
|
"primary",
|
||||||
|
accounts(&["primary", "secondary"])
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"secondary"
|
||||||
|
);
|
||||||
|
assert!(select_account(Some("missing"), "primary", accounts(&["primary"])).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_url_and_limit_boundaries_are_rejected() {
|
||||||
|
assert!(normalize_session_url("http://mail.example").is_err());
|
||||||
|
assert_eq!(
|
||||||
|
normalize_session_url("https://mail.example/.well-known/jmap").unwrap(),
|
||||||
|
"https://mail.example"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_session_url("https://mail.example").unwrap(),
|
||||||
|
"https://mail.example"
|
||||||
|
);
|
||||||
|
assert!(normalize_session_url("https://u:p@mail.example").is_err());
|
||||||
|
assert!(normalize_session_url("https://mail.example/?token=x").is_err());
|
||||||
|
assert!(normalize_session_url("https://mail.example/#fragment").is_err());
|
||||||
|
assert!(normalize_session_url("https://mail.example/mail/jmap").is_err());
|
||||||
|
assert!(JmapService::with_long_email_limit((), "account", 0).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn body_values_join_in_order_and_empty_is_explicit_error() {
|
||||||
|
assert_eq!(
|
||||||
|
join_body_values([Some("first "), None, Some("second")].into_iter()).unwrap(),
|
||||||
|
"first second"
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
join_body_values([None, None].into_iter()),
|
||||||
|
Err(JmapError::MissingField("text body value"))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roles_are_normalized() {
|
||||||
|
assert_eq!(role_name(mailbox::Role::Inbox).as_deref(), Some("inbox"));
|
||||||
|
assert_eq!(role_name(mailbox::Role::None), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum BotError {
|
||||||
|
#[error("configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("JSON error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
#[error("state error")]
|
||||||
|
State(#[from] crate::state::StateError),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
mod ai;
|
||||||
|
mod channel;
|
||||||
|
mod config;
|
||||||
|
pub mod domain;
|
||||||
|
mod error;
|
||||||
|
mod notify;
|
||||||
|
pub mod state;
|
||||||
|
mod web;
|
||||||
|
mod worker;
|
||||||
|
|
||||||
|
use secrecy::ExposeSecret;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use crate::channel::telegram::TelegramClient;
|
||||||
|
use crate::domain::jmap::{client::JmapClientBackend, JmapService};
|
||||||
|
use config::{encryption_key_from_env, Config};
|
||||||
|
use notify::{
|
||||||
|
router_with_worker_state_runtime_bootstrap, MetadataWorker, WorkerHandle, WorkerHandler,
|
||||||
|
};
|
||||||
|
use state::{runtime_provider, RedisState};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), error::BotError> {
|
||||||
|
tracing_subscriber::fmt().with_env_filter("info").init();
|
||||||
|
// The Redis URL is the only bootstrap credential in the migration path. Until
|
||||||
|
// the bootstrap route is wired, retain the legacy parser solely to obtain it.
|
||||||
|
let bootstrap_config = match Config::from_env() {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(_) => {
|
||||||
|
let redis_url = std::env::var("REDIS_URL")
|
||||||
|
.map_err(|_| error::BotError::Config("missing REDIS_URL".into()))?;
|
||||||
|
Config::redis_only(secrecy::SecretString::new(redis_url))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let encryption_key = encryption_key_from_env()?;
|
||||||
|
let redis_url = bootstrap_config.redis_url.clone();
|
||||||
|
// RUN_MODE is intentionally explicit even while both modes share the stage-0
|
||||||
|
// no-op router; later stages attach webhook/reconcile side effects here.
|
||||||
|
match bootstrap_config.run_mode.as_str() {
|
||||||
|
"webhook" => tracing::info!("RUN_MODE=webhook"),
|
||||||
|
"reconcile" => tracing::info!("RUN_MODE=reconcile"),
|
||||||
|
mode => {
|
||||||
|
return Err(error::BotError::Config(format!(
|
||||||
|
"RUN_MODE must be webhook or reconcile, got {mode}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let addr: SocketAddr = format!("0.0.0.0:{}", bootstrap_config.port)
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?;
|
||||||
|
tracing::info!(
|
||||||
|
port = bootstrap_config.port,
|
||||||
|
"starting webhook HTTP entrypoint"
|
||||||
|
);
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
let redis =
|
||||||
|
RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key))
|
||||||
|
.await?;
|
||||||
|
let bootstrap_token = url::Url::parse(redis_url.expose_secret())
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.password().map(ToOwned::to_owned))
|
||||||
|
.map(secrecy::SecretString::new)
|
||||||
|
.unwrap_or_else(|| secrecy::SecretString::new(String::new()));
|
||||||
|
let worker_state: std::sync::Arc<dyn state::ReliableState> = std::sync::Arc::new(
|
||||||
|
RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key))
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
let config = match worker_state
|
||||||
|
.get_business_config()
|
||||||
|
.await
|
||||||
|
.map_err(|_| error::BotError::Config("business config unavailable".into()))?
|
||||||
|
{
|
||||||
|
Some(value) => Config::from_business_json(redis_url.clone(), value)?,
|
||||||
|
None => bootstrap_config,
|
||||||
|
};
|
||||||
|
let outbound = worker_state
|
||||||
|
.get_outbound_config()
|
||||||
|
.await
|
||||||
|
.map_err(|_| error::BotError::Config("runtime outbound config unavailable".into()))?;
|
||||||
|
let runtime = runtime_provider(outbound.clone());
|
||||||
|
let llm = if config.llm.enabled && config.llm.allow_net {
|
||||||
|
Some(std::sync::Arc::new(
|
||||||
|
ai::LlmClient::with_runtime(
|
||||||
|
config.llm.base_url.clone().unwrap_or_default(),
|
||||||
|
config
|
||||||
|
.llm
|
||||||
|
.api_key
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| secrecy::SecretString::new("".into())),
|
||||||
|
config.llm.model.clone().unwrap_or_default(),
|
||||||
|
config.llm.summary_target_chars,
|
||||||
|
runtime.clone(),
|
||||||
|
)
|
||||||
|
.map_err(|_| error::BotError::Config("LLM endpoint must use HTTPS".into()))?,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let allowlist: std::collections::HashSet<i64> =
|
||||||
|
config.telegram.chat_allowlist.iter().copied().collect();
|
||||||
|
let chat_id = config.telegram.chat_id;
|
||||||
|
let worker: Arc<dyn WorkerHandler> = if config.telegram.bot_token.expose_secret().is_empty() {
|
||||||
|
Arc::new(notify::NoopWorker)
|
||||||
|
} else {
|
||||||
|
match JmapClientBackend::connect_with_runtime(
|
||||||
|
&config.jmap.session_url,
|
||||||
|
&config.jmap.username,
|
||||||
|
config.jmap.app_password.expose_secret(),
|
||||||
|
config.account_id.as_deref(),
|
||||||
|
runtime.clone(),
|
||||||
|
// All subsequent JMAP calls read this provider, so SPA updates apply
|
||||||
|
// without restarting the process.
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(backend) => {
|
||||||
|
let account = backend.account_id().to_owned();
|
||||||
|
match JmapService::new(backend, account) {
|
||||||
|
Ok(jmap) => Arc::new(MetadataWorker::new(
|
||||||
|
jmap,
|
||||||
|
TelegramClient::with_runtime(config.telegram.bot_token, runtime.clone()),
|
||||||
|
chat_id,
|
||||||
|
worker_state,
|
||||||
|
llm,
|
||||||
|
)) as Arc<dyn WorkerHandler>,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"JMAP service unavailable; starting configuration-only mode"
|
||||||
|
);
|
||||||
|
Arc::new(notify::NoopWorker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!("JMAP connection unavailable; starting configuration-only mode");
|
||||||
|
Arc::new(notify::NoopWorker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let app = router_with_worker_state_runtime_bootstrap(
|
||||||
|
config.auth,
|
||||||
|
config.worker_token,
|
||||||
|
redis,
|
||||||
|
allowlist,
|
||||||
|
Arc::new(WorkerHandle::new(worker)),
|
||||||
|
runtime,
|
||||||
|
bootstrap_token,
|
||||||
|
)
|
||||||
|
.merge(web::router());
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,846 @@
|
||||||
|
//! Redis-only reliability primitives (C-REDIS-ONLY-STATE, MOD-DEDUP, MOD-STREAMS).
|
||||||
|
//! These APIs are deliberately short-request operations; no subscriptions or polling.
|
||||||
|
|
||||||
|
use crate::config::BusinessConfig;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use ring::{
|
||||||
|
aead,
|
||||||
|
rand::{SecureRandom, SystemRandom},
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
sync::{Arc, Mutex, RwLock},
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum StateError {
|
||||||
|
#[error("redis operation failed")]
|
||||||
|
Redis(#[from] redis::RedisError),
|
||||||
|
#[error("state lock poisoned")]
|
||||||
|
Poisoned,
|
||||||
|
#[error("configuration encryption failed")]
|
||||||
|
Encryption,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct StreamMessage {
|
||||||
|
pub id: String,
|
||||||
|
pub payload: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-secret outbound tuning persisted in Redis for SPA/API management.
|
||||||
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
|
||||||
|
pub struct OutboundConfig {
|
||||||
|
pub jmap_timeout_ms: u64,
|
||||||
|
pub telegram_timeout_ms: u64,
|
||||||
|
pub llm_timeout_ms: u64,
|
||||||
|
pub max_retries: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type RuntimeConfigProvider = Arc<RwLock<OutboundConfig>>;
|
||||||
|
pub type RuntimeBusinessConfigProvider = Arc<RwLock<Option<BusinessConfig>>>;
|
||||||
|
|
||||||
|
pub fn runtime_provider(config: OutboundConfig) -> RuntimeConfigProvider {
|
||||||
|
Arc::new(RwLock::new(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn business_runtime_provider(config: Option<BusinessConfig>) -> RuntimeBusinessConfigProvider {
|
||||||
|
Arc::new(RwLock::new(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OutboundConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
jmap_timeout_ms: 15_000,
|
||||||
|
telegram_timeout_ms: 10_000,
|
||||||
|
llm_timeout_ms: 30_000,
|
||||||
|
max_retries: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ReliableState: Send + Sync {
|
||||||
|
/// Atomic SET NX EX claim for Telegram update or `(account,email)` dedup key.
|
||||||
|
async fn claim_dedup(&self, key: &str, ttl_seconds: u64) -> Result<bool, StateError>;
|
||||||
|
/// Checks a durable idempotency commit without creating one.
|
||||||
|
async fn dedup_exists(&self, key: &str) -> Result<bool, StateError>;
|
||||||
|
/// Roll back a dedup claim when the following enqueue operation fails.
|
||||||
|
async fn release_dedup(&self, key: &str) -> Result<(), StateError>;
|
||||||
|
/// XADD enqueue; consumers perform XREADGROUP in a later worker phase.
|
||||||
|
async fn enqueue(&self, stream: &str, payload: &str) -> Result<String, StateError>;
|
||||||
|
/// XACK after successful processing.
|
||||||
|
async fn ack(&self, stream: &str, group: &str, message_id: &str) -> Result<(), StateError>;
|
||||||
|
/// XADD to a dead-letter stream after bounded retry policy decides to stop retrying.
|
||||||
|
async fn dead_letter(&self, stream: &str, payload: &str) -> Result<String, StateError>;
|
||||||
|
/// SET NX EX lock for reconcile single-flight.
|
||||||
|
async fn acquire_lock(&self, key: &str, ttl_seconds: u64) -> Result<bool, StateError>;
|
||||||
|
/// Release a lock after the bounded reconcile attempt has completed.
|
||||||
|
async fn release_lock(&self, key: &str) -> Result<(), StateError>;
|
||||||
|
async fn read_batch(
|
||||||
|
&self,
|
||||||
|
stream: &str,
|
||||||
|
group: &str,
|
||||||
|
consumer: &str,
|
||||||
|
count: usize,
|
||||||
|
) -> Result<Vec<StreamMessage>, StateError>;
|
||||||
|
async fn retry_or_dlq(
|
||||||
|
&self,
|
||||||
|
stream: &str,
|
||||||
|
dlq: &str,
|
||||||
|
group: &str,
|
||||||
|
message: &StreamMessage,
|
||||||
|
max_attempts: u32,
|
||||||
|
) -> Result<bool, StateError>;
|
||||||
|
async fn set_ai_consent(&self, chat_id: i64, ttl_seconds: u64) -> Result<(), StateError>;
|
||||||
|
async fn ai_consent_until(&self, chat_id: i64) -> Result<Option<i64>, StateError>;
|
||||||
|
async fn clear_ai_consent(&self, chat_id: i64) -> Result<(), StateError>;
|
||||||
|
async fn get_outbound_config(&self) -> Result<OutboundConfig, StateError>;
|
||||||
|
async fn set_outbound_config(&self, config: &OutboundConfig) -> Result<(), StateError>;
|
||||||
|
/// Atomically read the encrypted-at-rest business configuration namespace.
|
||||||
|
/// Callers must redact secrets before returning it to HTTP clients.
|
||||||
|
async fn get_business_config(&self) -> Result<Option<serde_json::Value>, StateError>;
|
||||||
|
/// Replace the business configuration namespace; bootstrap callers must enforce
|
||||||
|
/// SET-NX semantics before invoking this operation (C-REDIS-ONLY-STATE).
|
||||||
|
async fn set_business_config(&self, config: &serde_json::Value) -> Result<(), StateError>;
|
||||||
|
/// Atomically initialize the business namespace; returns false when already set.
|
||||||
|
async fn initialize_business_config(
|
||||||
|
&self,
|
||||||
|
config: &serde_json::Value,
|
||||||
|
) -> Result<bool, StateError>;
|
||||||
|
/// Stores only the opaque session digest, never the bearer token itself.
|
||||||
|
async fn put_admin_session(&self, digest: &str, ttl_seconds: u64) -> Result<(), StateError>;
|
||||||
|
async fn admin_session_valid(&self, digest: &str) -> Result<bool, StateError>;
|
||||||
|
async fn revoke_admin_session(&self, digest: &str) -> Result<(), StateError>;
|
||||||
|
async fn business_config_revision(&self) -> Result<u64, StateError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RedisState {
|
||||||
|
connection: redis::aio::MultiplexedConnection,
|
||||||
|
encryption_key: Option<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedisState {
|
||||||
|
/// Connects to externally managed REDIS_URL; URL/password are never logged.
|
||||||
|
pub async fn connect(redis_url: &str) -> Result<Self, StateError> {
|
||||||
|
Self::connect_with_encryption(redis_url, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect_with_encryption(
|
||||||
|
redis_url: &str,
|
||||||
|
encryption_key: Option<[u8; 32]>,
|
||||||
|
) -> Result<Self, StateError> {
|
||||||
|
let client = redis::Client::open(redis_url)?;
|
||||||
|
Ok(Self {
|
||||||
|
connection: client.get_multiplexed_async_connection().await?,
|
||||||
|
encryption_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ReliableState for RedisState {
|
||||||
|
async fn claim_dedup(&self, key: &str, ttl_seconds: u64) -> Result<bool, StateError> {
|
||||||
|
set_nx_ex(self.connection.clone(), key, ttl_seconds).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_dedup(&self, key: &str) -> Result<(), StateError> {
|
||||||
|
delete_key(self.connection.clone(), key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dedup_exists(&self, key: &str) -> Result<bool, StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let exists: i64 = redis::cmd("EXISTS")
|
||||||
|
.arg(key)
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(exists != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enqueue(&self, stream: &str, payload: &str) -> Result<String, StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
Ok(redis::cmd("XADD")
|
||||||
|
.arg(stream)
|
||||||
|
.arg("MAXLEN")
|
||||||
|
.arg("~")
|
||||||
|
.arg(10_000)
|
||||||
|
.arg("*")
|
||||||
|
.arg("payload")
|
||||||
|
.arg(payload)
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ack(&self, stream: &str, group: &str, message_id: &str) -> Result<(), StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let _: i64 = redis::cmd("XACK")
|
||||||
|
.arg(stream)
|
||||||
|
.arg(group)
|
||||||
|
.arg(message_id)
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dead_letter(&self, stream: &str, payload: &str) -> Result<String, StateError> {
|
||||||
|
self.enqueue(stream, payload).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn acquire_lock(&self, key: &str, ttl_seconds: u64) -> Result<bool, StateError> {
|
||||||
|
set_nx_ex(self.connection.clone(), key, ttl_seconds).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_lock(&self, key: &str) -> Result<(), StateError> {
|
||||||
|
delete_key(self.connection.clone(), key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_batch(
|
||||||
|
&self,
|
||||||
|
stream: &str,
|
||||||
|
group: &str,
|
||||||
|
consumer: &str,
|
||||||
|
count: usize,
|
||||||
|
) -> Result<Vec<StreamMessage>, StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
// The worker is deployable from an empty Redis: create its group lazily.
|
||||||
|
// BUSYGROUP is harmless when another instance created it first.
|
||||||
|
let _: Result<String, redis::RedisError> = redis::cmd("XGROUP")
|
||||||
|
.arg("CREATE")
|
||||||
|
.arg(stream)
|
||||||
|
.arg(group)
|
||||||
|
.arg("0-0")
|
||||||
|
.arg("MKSTREAM")
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await;
|
||||||
|
// Recover messages left in the consumer group's PEL after a crash or
|
||||||
|
// restart before reading new entries. A bounded idle window prevents
|
||||||
|
// two short-lived workers from stealing active work.
|
||||||
|
let reclaimed: redis::streams::StreamAutoClaimReply = redis::cmd("XAUTOCLAIM")
|
||||||
|
.arg(stream)
|
||||||
|
.arg(group)
|
||||||
|
.arg(consumer)
|
||||||
|
// A worker handles at most ten events per request; keep this
|
||||||
|
// above the worst bounded JMAP/Telegram batch duration.
|
||||||
|
.arg(300_000)
|
||||||
|
.arg("0-0")
|
||||||
|
.arg("COUNT")
|
||||||
|
.arg(count.max(1))
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
let reclaimed_messages = reclaimed
|
||||||
|
.claimed
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(stream_id_to_message)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !reclaimed_messages.is_empty() {
|
||||||
|
return Ok(reclaimed_messages);
|
||||||
|
}
|
||||||
|
let reply: redis::streams::StreamReadReply = redis::cmd("XREADGROUP")
|
||||||
|
.arg("GROUP")
|
||||||
|
.arg(group)
|
||||||
|
.arg(consumer)
|
||||||
|
.arg("COUNT")
|
||||||
|
.arg(count.max(1))
|
||||||
|
.arg("STREAMS")
|
||||||
|
.arg(stream)
|
||||||
|
.arg(">")
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(reply
|
||||||
|
.keys
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|key| key.ids.into_iter().filter_map(stream_id_to_message))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn retry_or_dlq(
|
||||||
|
&self,
|
||||||
|
stream: &str,
|
||||||
|
dlq: &str,
|
||||||
|
group: &str,
|
||||||
|
message: &StreamMessage,
|
||||||
|
max_attempts: u32,
|
||||||
|
) -> Result<bool, StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let retry_key = format!("retry:{stream}:{}", message.id);
|
||||||
|
// Keep increment, DLQ append, and source ACK in one Redis script so a
|
||||||
|
// restart cannot leave an acknowledged event out of both queues.
|
||||||
|
let script = redis::Script::new(
|
||||||
|
r#"local n = redis.call('INCR', KEYS[1])
|
||||||
|
redis.call('EXPIRE', KEYS[1], 86400)
|
||||||
|
if n < tonumber(ARGV[1]) then return 0 end
|
||||||
|
redis.call('XADD', ARGV[2], 'MAXLEN', '~', 10000, '*', 'payload', ARGV[3])
|
||||||
|
redis.call('XACK', ARGV[4], ARGV[5], ARGV[6])
|
||||||
|
return 1"#,
|
||||||
|
);
|
||||||
|
let moved: i64 = script
|
||||||
|
.key(retry_key)
|
||||||
|
.arg(max_attempts.max(1))
|
||||||
|
.arg(dlq)
|
||||||
|
.arg(&message.payload)
|
||||||
|
.arg(stream)
|
||||||
|
.arg(group)
|
||||||
|
.arg(&message.id)
|
||||||
|
.invoke_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(moved != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_ai_consent(&self, chat_id: i64, ttl_seconds: u64) -> Result<(), StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let key = format!("consent:ai:{chat_id}");
|
||||||
|
let expiry = unix_now().saturating_add(ttl_seconds as i64);
|
||||||
|
redis::cmd("SET")
|
||||||
|
.arg(key)
|
||||||
|
.arg(expiry)
|
||||||
|
.arg("EX")
|
||||||
|
.arg(ttl_seconds.max(1))
|
||||||
|
.query_async::<()>(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ai_consent_until(&self, chat_id: i64) -> Result<Option<i64>, StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let value: Option<i64> = redis::cmd("GET")
|
||||||
|
.arg(format!("consent:ai:{chat_id}"))
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(value.filter(|expiry| *expiry > unix_now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_ai_consent(&self, chat_id: i64) -> Result<(), StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
redis::cmd("DEL")
|
||||||
|
.arg(format!("consent:ai:{chat_id}"))
|
||||||
|
.query_async::<()>(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_outbound_config(&self) -> Result<OutboundConfig, StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let raw: Option<String> = redis::cmd("GET")
|
||||||
|
.arg("config:outbound")
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
raw.map(|value| {
|
||||||
|
serde_json::from_str(&value).map_err(|_| {
|
||||||
|
redis::RedisError::from((redis::ErrorKind::TypeError, "invalid outbound config"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
.map(|config| config.unwrap_or_default())
|
||||||
|
.map_err(StateError::Redis)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_outbound_config(&self, config: &OutboundConfig) -> Result<(), StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let value = serde_json::to_string(config).map_err(|_| {
|
||||||
|
redis::RedisError::from((redis::ErrorKind::TypeError, "serialize outbound config"))
|
||||||
|
})?;
|
||||||
|
redis::cmd("SET")
|
||||||
|
.arg("config:outbound")
|
||||||
|
.arg(value)
|
||||||
|
.query_async::<()>(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_business_config(&self) -> Result<Option<serde_json::Value>, StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let raw: Option<String> = redis::cmd("GET")
|
||||||
|
.arg("config:business")
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
raw.map(|value| decrypt_config(self.encryption_key.as_ref(), &value))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|_| StateError::Encryption)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_business_config(&self, config: &serde_json::Value) -> Result<(), StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let value = encrypt_config(self.encryption_key.as_ref(), config)?;
|
||||||
|
redis::cmd("SET")
|
||||||
|
.arg("config:business")
|
||||||
|
.arg(value)
|
||||||
|
.query_async::<()>(&mut connection)
|
||||||
|
.await?;
|
||||||
|
redis::cmd("INCR")
|
||||||
|
.arg("config:business:revision")
|
||||||
|
.query_async::<u64>(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn initialize_business_config(
|
||||||
|
&self,
|
||||||
|
config: &serde_json::Value,
|
||||||
|
) -> Result<bool, StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let value = encrypt_config(self.encryption_key.as_ref(), config)?;
|
||||||
|
let result: Option<String> = redis::cmd("SET")
|
||||||
|
.arg("config:business")
|
||||||
|
.arg(value)
|
||||||
|
.arg("NX")
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
if result.is_some() {
|
||||||
|
redis::cmd("SET")
|
||||||
|
.arg("config:business:revision")
|
||||||
|
.arg(1_u64)
|
||||||
|
.arg("NX")
|
||||||
|
.query_async::<()>(&mut connection)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(result.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_admin_session(&self, digest: &str, ttl_seconds: u64) -> Result<(), StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
redis::cmd("SET")
|
||||||
|
.arg("config:admin_session")
|
||||||
|
.arg(digest)
|
||||||
|
.arg("EX")
|
||||||
|
.arg(ttl_seconds.max(1))
|
||||||
|
.query_async::<()>(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn admin_session_valid(&self, digest: &str) -> Result<bool, StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let current: Option<String> = redis::cmd("GET")
|
||||||
|
.arg("config:admin_session")
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(current.as_deref() == Some(digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_admin_session(&self, _digest: &str) -> Result<(), StateError> {
|
||||||
|
delete_key(self.connection.clone(), "config:admin_session").await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn business_config_revision(&self) -> Result<u64, StateError> {
|
||||||
|
let mut connection = self.connection.clone();
|
||||||
|
let value: Option<u64> = redis::cmd("GET")
|
||||||
|
.arg("config:business:revision")
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(value.unwrap_or(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_id_to_message(item: redis::streams::StreamId) -> Option<StreamMessage> {
|
||||||
|
let payload = item
|
||||||
|
.map
|
||||||
|
.get("payload")
|
||||||
|
.and_then(|value| redis::from_redis_value(value).ok());
|
||||||
|
payload.map(|payload| StreamMessage {
|
||||||
|
id: item.id,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_nx_ex(
|
||||||
|
mut connection: redis::aio::MultiplexedConnection,
|
||||||
|
key: &str,
|
||||||
|
ttl_seconds: u64,
|
||||||
|
) -> Result<bool, StateError> {
|
||||||
|
let result: Option<String> = redis::cmd("SET")
|
||||||
|
.arg(key)
|
||||||
|
.arg("1")
|
||||||
|
.arg("NX")
|
||||||
|
.arg("EX")
|
||||||
|
.arg(ttl_seconds)
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(result.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct MemoryState {
|
||||||
|
inner: Arc<Mutex<MemoryInner>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct MemoryInner {
|
||||||
|
dedup: HashSet<String>,
|
||||||
|
locks: HashSet<String>,
|
||||||
|
streams: HashMap<String, Vec<(String, String)>>,
|
||||||
|
retries: HashMap<(String, String), u32>,
|
||||||
|
consent: HashMap<i64, i64>,
|
||||||
|
next_id: u64,
|
||||||
|
outbound: OutboundConfig,
|
||||||
|
business_config: Option<serde_json::Value>,
|
||||||
|
admin_session: Option<(String, i64)>,
|
||||||
|
business_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ReliableState for MemoryState {
|
||||||
|
async fn claim_dedup(&self, key: &str, _ttl_seconds: u64) -> Result<bool, StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
Ok(inner.dedup.insert(key.to_owned()))
|
||||||
|
}
|
||||||
|
async fn release_dedup(&self, key: &str) -> Result<(), StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
inner.dedup.remove(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn dedup_exists(&self, key: &str) -> Result<bool, StateError> {
|
||||||
|
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
Ok(inner.dedup.contains(key))
|
||||||
|
}
|
||||||
|
async fn enqueue(&self, stream: &str, payload: &str) -> Result<String, StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
inner.next_id += 1;
|
||||||
|
let id = format!("{}-0", inner.next_id);
|
||||||
|
inner
|
||||||
|
.streams
|
||||||
|
.entry(stream.to_owned())
|
||||||
|
.or_default()
|
||||||
|
.push((id.clone(), payload.to_owned()));
|
||||||
|
if let Some(messages) = inner.streams.get_mut(stream) {
|
||||||
|
let excess = messages.len().saturating_sub(10_000);
|
||||||
|
if excess > 0 {
|
||||||
|
messages.drain(..excess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
async fn ack(&self, stream: &str, _group: &str, message_id: &str) -> Result<(), StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
if let Some(messages) = inner.streams.get_mut(stream) {
|
||||||
|
messages.retain(|(id, _)| id != message_id);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn dead_letter(&self, stream: &str, payload: &str) -> Result<String, StateError> {
|
||||||
|
self.enqueue(stream, payload).await
|
||||||
|
}
|
||||||
|
async fn acquire_lock(&self, key: &str, _ttl_seconds: u64) -> Result<bool, StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
Ok(inner.locks.insert(key.to_owned()))
|
||||||
|
}
|
||||||
|
async fn release_lock(&self, key: &str) -> Result<(), StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
inner.locks.remove(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn read_batch(
|
||||||
|
&self,
|
||||||
|
stream: &str,
|
||||||
|
_group: &str,
|
||||||
|
_consumer: &str,
|
||||||
|
count: usize,
|
||||||
|
) -> Result<Vec<StreamMessage>, StateError> {
|
||||||
|
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
Ok(inner
|
||||||
|
.streams
|
||||||
|
.get(stream)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.take(count.max(1))
|
||||||
|
.map(|(id, payload)| StreamMessage {
|
||||||
|
id: id.clone(),
|
||||||
|
payload: payload.clone(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
async fn retry_or_dlq(
|
||||||
|
&self,
|
||||||
|
stream: &str,
|
||||||
|
dlq: &str,
|
||||||
|
_group: &str,
|
||||||
|
message: &StreamMessage,
|
||||||
|
max_attempts: u32,
|
||||||
|
) -> Result<bool, StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
let key = (stream.to_owned(), message.id.clone());
|
||||||
|
let attempts = {
|
||||||
|
let entry = inner.retries.entry(key).or_default();
|
||||||
|
*entry += 1;
|
||||||
|
*entry
|
||||||
|
};
|
||||||
|
if attempts >= max_attempts {
|
||||||
|
inner.next_id += 1;
|
||||||
|
let id = format!("{}-0", inner.next_id);
|
||||||
|
inner
|
||||||
|
.streams
|
||||||
|
.entry(dlq.to_owned())
|
||||||
|
.or_default()
|
||||||
|
.push((id, message.payload.clone()));
|
||||||
|
if let Some(messages) = inner.streams.get_mut(stream) {
|
||||||
|
messages.retain(|(source_id, _)| source_id != &message.id);
|
||||||
|
}
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let _ = stream;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_ai_consent(&self, chat_id: i64, ttl_seconds: u64) -> Result<(), StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
inner
|
||||||
|
.consent
|
||||||
|
.insert(chat_id, unix_now().saturating_add(ttl_seconds as i64));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ai_consent_until(&self, chat_id: i64) -> Result<Option<i64>, StateError> {
|
||||||
|
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
Ok(inner
|
||||||
|
.consent
|
||||||
|
.get(&chat_id)
|
||||||
|
.copied()
|
||||||
|
.filter(|expiry| *expiry > unix_now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_ai_consent(&self, chat_id: i64) -> Result<(), StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
inner.consent.remove(&chat_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_outbound_config(&self) -> Result<OutboundConfig, StateError> {
|
||||||
|
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
Ok(inner.outbound.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_outbound_config(&self, config: &OutboundConfig) -> Result<(), StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
inner.outbound = config.clone();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_business_config(&self) -> Result<Option<serde_json::Value>, StateError> {
|
||||||
|
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
Ok(inner.business_config.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_business_config(&self, config: &serde_json::Value) -> Result<(), StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
inner.business_config = Some(config.clone());
|
||||||
|
inner.business_revision = inner.business_revision.saturating_add(1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn initialize_business_config(
|
||||||
|
&self,
|
||||||
|
config: &serde_json::Value,
|
||||||
|
) -> Result<bool, StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
if inner.business_config.is_some() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
inner.business_config = Some(config.clone());
|
||||||
|
inner.business_revision = 1;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_admin_session(&self, digest: &str, ttl_seconds: u64) -> Result<(), StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
inner.admin_session = Some((
|
||||||
|
digest.to_owned(),
|
||||||
|
unix_now().saturating_add(ttl_seconds as i64),
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn admin_session_valid(&self, digest: &str) -> Result<bool, StateError> {
|
||||||
|
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
Ok(inner
|
||||||
|
.admin_session
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|(value, expiry)| value == digest && *expiry > unix_now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_admin_session(&self, digest: &str) -> Result<(), StateError> {
|
||||||
|
let mut inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
if inner
|
||||||
|
.admin_session
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|(value, _)| value == digest)
|
||||||
|
{
|
||||||
|
inner.admin_session = None;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn business_config_revision(&self) -> Result<u64, StateError> {
|
||||||
|
let inner = self.inner.lock().map_err(|_| StateError::Poisoned)?;
|
||||||
|
Ok(inner.business_revision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |duration| duration.as_secs() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_AAD: &[u8] = b"stalwart-bot/config:business/v1";
|
||||||
|
|
||||||
|
fn encrypt_config(
|
||||||
|
key: Option<&[u8; 32]>,
|
||||||
|
config: &serde_json::Value,
|
||||||
|
) -> Result<String, StateError> {
|
||||||
|
let key = key.ok_or(StateError::Encryption)?;
|
||||||
|
let unbound =
|
||||||
|
aead::UnboundKey::new(&aead::AES_256_GCM, key).map_err(|_| StateError::Encryption)?;
|
||||||
|
let sealing = aead::LessSafeKey::new(unbound);
|
||||||
|
let mut nonce = [0_u8; 12];
|
||||||
|
SystemRandom::new()
|
||||||
|
.fill(&mut nonce)
|
||||||
|
.map_err(|_| StateError::Encryption)?;
|
||||||
|
let mut body = serde_json::to_vec(config).map_err(|_| StateError::Encryption)?;
|
||||||
|
sealing
|
||||||
|
.seal_in_place_append_tag(
|
||||||
|
aead::Nonce::assume_unique_for_key(nonce),
|
||||||
|
aead::Aad::from(CONFIG_AAD),
|
||||||
|
&mut body,
|
||||||
|
)
|
||||||
|
.map_err(|_| StateError::Encryption)?;
|
||||||
|
Ok(format!("v1:{}:{}", hex_encode(&nonce), hex_encode(&body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt_config(key: Option<&[u8; 32]>, value: &str) -> Result<serde_json::Value, StateError> {
|
||||||
|
let key = key.ok_or(StateError::Encryption)?;
|
||||||
|
let mut parts = value.split(':');
|
||||||
|
if parts.next() != Some("v1") {
|
||||||
|
return Err(StateError::Encryption);
|
||||||
|
}
|
||||||
|
let nonce = hex_decode(parts.next().ok_or(StateError::Encryption)?, 12)?;
|
||||||
|
let mut body = hex_decode(parts.next().ok_or(StateError::Encryption)?, usize::MAX)?;
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return Err(StateError::Encryption);
|
||||||
|
}
|
||||||
|
let unbound =
|
||||||
|
aead::UnboundKey::new(&aead::AES_256_GCM, key).map_err(|_| StateError::Encryption)?;
|
||||||
|
let opening = aead::LessSafeKey::new(unbound);
|
||||||
|
let plain = opening
|
||||||
|
.open_in_place(
|
||||||
|
aead::Nonce::assume_unique_for_key(
|
||||||
|
nonce.try_into().map_err(|_| StateError::Encryption)?,
|
||||||
|
),
|
||||||
|
aead::Aad::from(CONFIG_AAD),
|
||||||
|
&mut body,
|
||||||
|
)
|
||||||
|
.map_err(|_| StateError::Encryption)?;
|
||||||
|
serde_json::from_slice(plain).map_err(|_| StateError::Encryption)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_decode(value: &str, max_len: usize) -> Result<Vec<u8>, StateError> {
|
||||||
|
if !value.len().is_multiple_of(2) || value.len() / 2 > max_len {
|
||||||
|
return Err(StateError::Encryption);
|
||||||
|
}
|
||||||
|
(0..value.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|index| {
|
||||||
|
u8::from_str_radix(&value[index..index + 2], 16).map_err(|_| StateError::Encryption)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_key(
|
||||||
|
mut connection: redis::aio::MultiplexedConnection,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<(), StateError> {
|
||||||
|
let _: i64 = redis::cmd("DEL")
|
||||||
|
.arg(key)
|
||||||
|
.query_async(&mut connection)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypted_config_roundtrip_and_tamper_fail_closed() {
|
||||||
|
let key = [7_u8; 32];
|
||||||
|
let value = serde_json::json!({"secret": "value"});
|
||||||
|
let encoded = encrypt_config(Some(&key), &value).unwrap();
|
||||||
|
assert_eq!(decrypt_config(Some(&key), &encoded).unwrap(), value);
|
||||||
|
let mut tampered = encoded.into_bytes();
|
||||||
|
let index = tampered.len() - 1;
|
||||||
|
tampered[index] = if tampered[index] == b'0' { b'1' } else { b'0' };
|
||||||
|
let tampered = String::from_utf8(tampered).unwrap();
|
||||||
|
assert!(decrypt_config(Some(&key), &tampered).is_err());
|
||||||
|
assert!(decrypt_config(Some(&[8_u8; 32]), &tampered).is_err());
|
||||||
|
assert!(encrypt_config(None, &value).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn memory_state_is_atomic_and_supports_stream_lifecycle() {
|
||||||
|
let state = MemoryState::default();
|
||||||
|
assert!(state.claim_dedup("tg:42", 60).await.unwrap());
|
||||||
|
assert!(!state.claim_dedup("tg:42", 60).await.unwrap());
|
||||||
|
assert!(state.dedup_exists("tg:42").await.unwrap());
|
||||||
|
state.release_dedup("tg:42").await.unwrap();
|
||||||
|
assert!(!state.dedup_exists("tg:42").await.unwrap());
|
||||||
|
// A short in-flight lease can be cleared after a pre-send crash,
|
||||||
|
// while the post-send commit remains durable for ACK recovery.
|
||||||
|
assert!(state
|
||||||
|
.claim_dedup("delivery:inflight:events:1-0", 60)
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
state
|
||||||
|
.release_dedup("delivery:inflight:events:1-0")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!state
|
||||||
|
.dedup_exists("delivery:inflight:events:1-0")
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
assert!(state
|
||||||
|
.claim_dedup("delivery:committed:events:1-0", 604_800)
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
assert!(state
|
||||||
|
.dedup_exists("delivery:committed:events:1-0")
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
assert!(state.acquire_lock("reconcile", 30).await.unwrap());
|
||||||
|
assert!(!state.acquire_lock("reconcile", 30).await.unwrap());
|
||||||
|
assert!(state.ai_consent_until(7).await.unwrap().is_none());
|
||||||
|
state.set_ai_consent(7, 3600).await.unwrap();
|
||||||
|
assert!(state.ai_consent_until(7).await.unwrap().is_some());
|
||||||
|
state.clear_ai_consent(7).await.unwrap();
|
||||||
|
assert!(state.ai_consent_until(7).await.unwrap().is_none());
|
||||||
|
let id = state.enqueue("events", "payload").await.unwrap();
|
||||||
|
let message = StreamMessage {
|
||||||
|
id: id.clone(),
|
||||||
|
payload: "payload".into(),
|
||||||
|
};
|
||||||
|
assert!(!state
|
||||||
|
.retry_or_dlq("events", "events-dlq", "workers", &message, 2)
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
assert!(state
|
||||||
|
.retry_or_dlq("events", "events-dlq", "workers", &message, 2)
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
assert!(state
|
||||||
|
.read_batch("events", "workers", "c", 10)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.read_batch("events-dlq", "workers", "c", 10)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
state.ack("events-dlq", "workers", &id).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
//! Embedded configuration SPA (C-NO-LOCAL-WRITE, C-REDIS-ONLY-STATE).
|
||||||
|
//!
|
||||||
|
//! The UI is compiled into the executable so serving it creates no runtime files.
|
||||||
|
//! It sends the in-memory WORKER_TOKEN only to the same-origin `/api/config` routes;
|
||||||
|
//! the Worker forwards its Authorization header under SAF-LB-PASSTHRU, and configuration
|
||||||
|
//! persistence remains the backend's Redis responsibility.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{header, HeaderValue, Response, StatusCode},
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
const INDEX: &str = include_str!("../web/index.html");
|
||||||
|
const SCRIPT: &str = include_str!("../web/config.js");
|
||||||
|
const STYLES: &str = include_str!("../web/styles.css");
|
||||||
|
|
||||||
|
pub fn router() -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(index))
|
||||||
|
.route("/assets/config.js", get(script))
|
||||||
|
.route("/assets/styles.css", get(styles))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index() -> Response<Body> {
|
||||||
|
static_response("text/html; charset=utf-8", INDEX, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn script() -> Response<Body> {
|
||||||
|
static_response("text/javascript; charset=utf-8", SCRIPT, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn styles() -> Response<Body> {
|
||||||
|
static_response("text/css; charset=utf-8", STYLES, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_response(content_type: &'static str, body: &'static str, html: bool) -> Response<Body> {
|
||||||
|
let csp = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
|
||||||
|
let mut response = Response::new(Body::from(body));
|
||||||
|
*response.status_mut() = StatusCode::OK;
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||||
|
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
headers.insert(
|
||||||
|
header::X_CONTENT_TYPE_OPTIONS,
|
||||||
|
HeaderValue::from_static("nosniff"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::REFERRER_POLICY,
|
||||||
|
HeaderValue::from_static("no-referrer"),
|
||||||
|
);
|
||||||
|
if html {
|
||||||
|
headers.insert(
|
||||||
|
header::CONTENT_SECURITY_POLICY,
|
||||||
|
HeaderValue::from_static(csp),
|
||||||
|
);
|
||||||
|
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,492 @@
|
||||||
|
//! Bounded metadata notification worker (MOD-TELEGRAM-NOTIFY).
|
||||||
|
use crate::ai::LlmClient;
|
||||||
|
use crate::channel::telegram::TelegramClient;
|
||||||
|
use crate::config::BusinessConfig;
|
||||||
|
use crate::domain::jmap::{JmapBackend, JmapService};
|
||||||
|
use crate::domain::Notification;
|
||||||
|
use crate::state::ReliableState;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::{
|
||||||
|
future::Future,
|
||||||
|
pin::Pin,
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait WorkerHandler: Send + Sync {
|
||||||
|
async fn process(&self, stream: &str, payload: &str) -> Result<(), ()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically replaceable worker handle. In-flight requests keep their old worker;
|
||||||
|
/// subsequent bounded requests observe the newest successfully-built instance.
|
||||||
|
pub struct WorkerHandle {
|
||||||
|
current: RwLock<Arc<dyn WorkerHandler>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerHandle {
|
||||||
|
pub fn new(worker: Arc<dyn WorkerHandler>) -> Self {
|
||||||
|
Self {
|
||||||
|
current: RwLock::new(worker),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace(&self, worker: Arc<dyn WorkerHandler>) -> Result<(), ()> {
|
||||||
|
self.current
|
||||||
|
.write()
|
||||||
|
.map(|mut current| *current = worker)
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coordinates an all-or-nothing worker/config swap. The caller builds every client before
|
||||||
|
/// invoking `reload`; a failed factory therefore leaves both the old worker and snapshot intact.
|
||||||
|
pub struct ReloadCoordinator {
|
||||||
|
worker: Arc<WorkerHandle>,
|
||||||
|
config: RwLock<Option<BusinessConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReloadCoordinator {
|
||||||
|
pub fn new(worker: Arc<WorkerHandle>, config: Option<BusinessConfig>) -> Self {
|
||||||
|
Self {
|
||||||
|
worker,
|
||||||
|
config: RwLock::new(config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(
|
||||||
|
not(test),
|
||||||
|
expect(dead_code, reason = "stable reload API retained for coordinator tests")
|
||||||
|
)]
|
||||||
|
pub async fn reload_async<F>(&self, config: BusinessConfig, factory: F) -> Result<(), ()>
|
||||||
|
where
|
||||||
|
F: FnOnce(
|
||||||
|
&BusinessConfig,
|
||||||
|
)
|
||||||
|
-> Pin<Box<dyn Future<Output = Result<Arc<dyn WorkerHandler>, ()>> + Send>>,
|
||||||
|
{
|
||||||
|
let next = factory(&config).await?;
|
||||||
|
self.worker.replace(next)?;
|
||||||
|
*self.config.write().map_err(|_| ())? = Some(config);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit an already-built worker and its matching snapshot. Construction must happen
|
||||||
|
/// before this method so failed client creation cannot disturb the active instance.
|
||||||
|
pub fn commit(&self, config: BusinessConfig, worker: Arc<dyn WorkerHandler>) -> Result<(), ()> {
|
||||||
|
self.worker.replace(worker)?;
|
||||||
|
*self.config.write().map_err(|_| ())? = Some(config);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WorkerHandler for WorkerHandle {
|
||||||
|
async fn process(&self, stream: &str, payload: &str) -> Result<(), ()> {
|
||||||
|
let worker = self.current.read().map_err(|_| ())?.clone();
|
||||||
|
worker.process(stream, payload).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NoopWorker;
|
||||||
|
#[async_trait]
|
||||||
|
impl WorkerHandler for NoopWorker {
|
||||||
|
async fn process(&self, _stream: &str, _payload: &str) -> Result<(), ()> {
|
||||||
|
Err(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JMAP bodies are read only to obtain metadata; body text never reaches Telegram.
|
||||||
|
pub struct MetadataWorker<B> {
|
||||||
|
jmap: JmapService<B>,
|
||||||
|
telegram: Arc<TelegramClient>,
|
||||||
|
chat_id: i64,
|
||||||
|
state: Arc<dyn ReliableState>,
|
||||||
|
llm: Option<Arc<LlmClient>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: JmapBackend> MetadataWorker<B> {
|
||||||
|
pub fn new(
|
||||||
|
jmap: JmapService<B>,
|
||||||
|
telegram: TelegramClient,
|
||||||
|
chat_id: i64,
|
||||||
|
state: Arc<dyn ReliableState>,
|
||||||
|
llm: Option<Arc<LlmClient>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
jmap,
|
||||||
|
telegram: Arc::new(telegram),
|
||||||
|
chat_id,
|
||||||
|
state,
|
||||||
|
llm,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<B: JmapBackend> WorkerHandler for MetadataWorker<B> {
|
||||||
|
async fn process(&self, stream: &str, payload: &str) -> Result<(), ()> {
|
||||||
|
if stream == "stalwart:telegram" {
|
||||||
|
return self.process_telegram(payload).await;
|
||||||
|
}
|
||||||
|
if stream != "stalwart:jmap" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let event: WorkerEvent = serde_json::from_str(payload).map_err(|_| ())?;
|
||||||
|
let (Some(account), Some(email)) = (event.account_id.as_deref(), event.email_id.as_deref())
|
||||||
|
else {
|
||||||
|
return Err(());
|
||||||
|
};
|
||||||
|
if account != self.jmap.account_id() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let content = self
|
||||||
|
.jmap
|
||||||
|
.read_email(email)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?
|
||||||
|
.ok_or(())?;
|
||||||
|
let metadata = content.metadata;
|
||||||
|
self.telegram
|
||||||
|
.send_notification(
|
||||||
|
self.chat_id,
|
||||||
|
&Notification {
|
||||||
|
sender: metadata.sender.unwrap_or_else(|| "unknown".into()),
|
||||||
|
subject: metadata.subject.unwrap_or_else(|| "(no subject)".into()),
|
||||||
|
received_at: metadata
|
||||||
|
.received_at
|
||||||
|
.map(|v| v.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: JmapBackend> MetadataWorker<B> {
|
||||||
|
async fn process_telegram(&self, payload: &str) -> Result<(), ()> {
|
||||||
|
let update: TelegramUpdate = serde_json::from_str(payload).map_err(|_| ())?;
|
||||||
|
let Some(message) = update.message else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(text) = message.text.as_deref() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let intent = parse_intent(text);
|
||||||
|
if matches!(&intent, Intent::Help) {
|
||||||
|
return self
|
||||||
|
.telegram
|
||||||
|
.send_text(message.chat.id, help_message())
|
||||||
|
.await
|
||||||
|
.map_err(|_| ());
|
||||||
|
}
|
||||||
|
if let Intent::Consent { ttl, label } = &intent {
|
||||||
|
if *ttl == 0 {
|
||||||
|
self.state
|
||||||
|
.clear_ai_consent(message.chat.id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
return self
|
||||||
|
.telegram
|
||||||
|
.send_text(message.chat.id, "AI摘要授权已撤销")
|
||||||
|
.await
|
||||||
|
.map_err(|_| ());
|
||||||
|
}
|
||||||
|
self.state
|
||||||
|
.set_ai_consent(message.chat.id, *ttl)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
return self
|
||||||
|
.telegram
|
||||||
|
.send_text(
|
||||||
|
message.chat.id,
|
||||||
|
&format!(
|
||||||
|
"AI摘要授权已开启:{},到期时间 Unix {};仍需使用 /summary <email_id> 请求",
|
||||||
|
label,
|
||||||
|
unix_now().saturating_add(*ttl as i64)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ());
|
||||||
|
}
|
||||||
|
let Some(email_id) = intent.email_id() else {
|
||||||
|
if matches!(&intent, Intent::Query) {
|
||||||
|
self.telegram
|
||||||
|
.send_text(message.chat.id, "请提供邮件 ID,例如:摘要 e-123")
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let content = self
|
||||||
|
.jmap
|
||||||
|
.read_email(&email_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?
|
||||||
|
.ok_or(())?;
|
||||||
|
let allowed = self
|
||||||
|
.state
|
||||||
|
.ai_consent_until(message.chat.id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())?
|
||||||
|
.is_some();
|
||||||
|
let summary = if allowed {
|
||||||
|
match &self.llm {
|
||||||
|
Some(llm) => llm
|
||||||
|
.summarize(&content.text)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| fallback(&content.text)),
|
||||||
|
None => fallback(&content.text),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{} — {}\n授权已到期或尚未授权;如需 AI 摘要,请重新选择授权期限。",
|
||||||
|
content.metadata.sender.unwrap_or_default(),
|
||||||
|
content.metadata.subject.unwrap_or_default()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.telegram
|
||||||
|
.send_text(message.chat.id, &summary)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback(body: &str) -> String {
|
||||||
|
body.chars().take(300).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |duration| duration.as_secs() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum Intent {
|
||||||
|
Help,
|
||||||
|
Consent { ttl: u64, label: &'static str },
|
||||||
|
Summary(String),
|
||||||
|
Query,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Intent {
|
||||||
|
fn email_id(&self) -> Option<String> {
|
||||||
|
match self {
|
||||||
|
Self::Summary(id) => Some(id.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_intent(input: &str) -> Intent {
|
||||||
|
let text = input.trim().to_ascii_lowercase();
|
||||||
|
if text == "/help" || text == "help" || text.contains("帮助") || text.contains("怎么用") {
|
||||||
|
return Intent::Help;
|
||||||
|
}
|
||||||
|
if text.contains("临时") || text.contains("一次") {
|
||||||
|
return Intent::Consent {
|
||||||
|
ttl: 3600,
|
||||||
|
label: "临时1小时",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if text.contains("今天") {
|
||||||
|
return Intent::Consent {
|
||||||
|
ttl: 86_400,
|
||||||
|
label: "今天",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if text.contains("7天") {
|
||||||
|
return Intent::Consent {
|
||||||
|
ttl: 7 * 86_400,
|
||||||
|
label: "7天",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if text.contains("直到我撤销") || text.contains("长期") {
|
||||||
|
return Intent::Consent {
|
||||||
|
ttl: 365 * 86_400,
|
||||||
|
label: "直到撤销(最长365天)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if text == "/ai on"
|
||||||
|
|| text == "/ai yes"
|
||||||
|
|| text.contains("开启 ai")
|
||||||
|
|| text.contains("同意摘要")
|
||||||
|
|| text.contains("允许 ai")
|
||||||
|
{
|
||||||
|
return Intent::Consent {
|
||||||
|
ttl: 3600,
|
||||||
|
label: "1小时",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if text == "/ai off"
|
||||||
|
|| text.contains("关闭 ai")
|
||||||
|
|| text.contains("撤销授权")
|
||||||
|
|| text.contains("停止摘要")
|
||||||
|
{
|
||||||
|
return Intent::Consent {
|
||||||
|
ttl: 0,
|
||||||
|
label: "已撤销",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let asks_summary =
|
||||||
|
text.starts_with("/summary") || text.contains("摘要") || text.contains("总结");
|
||||||
|
let asks_query =
|
||||||
|
text.contains("查询邮件") || text.contains("查看邮件") || text.contains("查邮件");
|
||||||
|
let id = text.split_whitespace().last().filter(|id| {
|
||||||
|
id.len() > 1
|
||||||
|
&& id
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||||
|
});
|
||||||
|
if asks_summary {
|
||||||
|
return id
|
||||||
|
.map(|v| Intent::Summary(v.to_owned()))
|
||||||
|
.unwrap_or(Intent::Query);
|
||||||
|
}
|
||||||
|
if asks_query {
|
||||||
|
return id
|
||||||
|
.map(|v| Intent::Summary(v.to_owned()))
|
||||||
|
.unwrap_or(Intent::Query);
|
||||||
|
}
|
||||||
|
Intent::Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
fn help_message() -> &'static str {
|
||||||
|
"使用说明:\n邮件到达后会先发送发件人、主题和时间等元数据通知。\n\n授权示例:/ai on(1小时)、临时一次、1小时、今天、7天、直到我撤销。\n/ai off:立即撤销 AI 正文授权。\n/summary <email_id>:请求指定邮件摘要;未授权或授权到期时只返回元数据并提示重新授权。\n/help(或 help):显示本说明。\n\n隐私:AI 默认关闭,只有你明确开启后才会发送正文;授权有期限且不会自动续期;正文和 AI 结果不会持久化,也不会写入日志。\n如果未配置 AI 或 AI 调用失败,将回退为本地截取摘要。"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TelegramUpdate {
|
||||||
|
#[serde(default)]
|
||||||
|
message: Option<TelegramMessage>,
|
||||||
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TelegramMessage {
|
||||||
|
chat: TelegramChat,
|
||||||
|
#[serde(default)]
|
||||||
|
text: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TelegramChat {
|
||||||
|
id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{help_message, parse_intent, Intent};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_is_safe_and_actionable() {
|
||||||
|
let text = help_message();
|
||||||
|
for command in ["/ai on", "/ai off", "/summary <email_id>", "/help"] {
|
||||||
|
assert!(text.contains(command));
|
||||||
|
}
|
||||||
|
assert!(text.contains("默认关闭"));
|
||||||
|
assert!(text.contains("不会持久化"));
|
||||||
|
assert!(!text.contains("BOT_TOKEN"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn natural_language_intents_keep_commands_compatible() {
|
||||||
|
assert_eq!(parse_intent("请帮我看看怎么用"), Intent::Help);
|
||||||
|
assert!(matches!(
|
||||||
|
parse_intent("我同意摘要"),
|
||||||
|
Intent::Consent { ttl: 3600, .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_intent("关闭 AI"),
|
||||||
|
Intent::Consent { ttl: 0, .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_intent("今天允许 AI"),
|
||||||
|
Intent::Consent { ttl: 86_400, .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_intent("授权 7天"),
|
||||||
|
Intent::Consent { ttl: 604_800, .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse_intent("直到我撤销"),
|
||||||
|
Intent::Consent {
|
||||||
|
ttl: 31_536_000,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
parse_intent("请总结 e-123"),
|
||||||
|
Intent::Summary("e-123".into())
|
||||||
|
);
|
||||||
|
assert_eq!(parse_intent("查询邮件"), Intent::Query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct WorkerEvent {
|
||||||
|
#[serde(rename = "accountId")]
|
||||||
|
account_id: Option<String>,
|
||||||
|
#[serde(rename = "emailId")]
|
||||||
|
email_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod reload_tests {
|
||||||
|
use super::*;
|
||||||
|
use secrecy::SecretString;
|
||||||
|
|
||||||
|
fn config() -> BusinessConfig {
|
||||||
|
BusinessConfig {
|
||||||
|
bot_token: SecretString::new("bot".into()),
|
||||||
|
telegram_chat_id: 1,
|
||||||
|
chat_allowlist: vec![],
|
||||||
|
telegram_webhook_secret: SecretString::new("hook".into()),
|
||||||
|
jmap_session_url: "https://mail.example.test".into(),
|
||||||
|
jmap_username: "user".into(),
|
||||||
|
jmap_password: SecretString::new("pass".into()),
|
||||||
|
jmap_push_verification: SecretString::new("verify".into()),
|
||||||
|
account_id: None,
|
||||||
|
llm_enabled: false,
|
||||||
|
llm_allow_net: false,
|
||||||
|
llm_api_key: None,
|
||||||
|
llm_base_url: None,
|
||||||
|
llm_model: None,
|
||||||
|
reconcile_token: SecretString::new("reconcile".into()),
|
||||||
|
worker_token: SecretString::new("worker".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_build_keeps_existing_worker() {
|
||||||
|
let handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker)));
|
||||||
|
let coordinator = ReloadCoordinator::new(handle.clone(), Some(config()));
|
||||||
|
let result = coordinator
|
||||||
|
.reload_async(config(), |_| Box::pin(async { Err(()) }))
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(handle.process("x", "y").await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn successful_build_replaces_worker_atomically() {
|
||||||
|
let handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker)));
|
||||||
|
let coordinator = ReloadCoordinator::new(handle.clone(), None);
|
||||||
|
let replacement: Arc<dyn WorkerHandler> = Arc::new(OkWorker);
|
||||||
|
coordinator
|
||||||
|
.reload_async(config(), move |_| {
|
||||||
|
let replacement = replacement.clone();
|
||||||
|
Box::pin(async move { Ok(replacement) })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(handle.process("x", "y").await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OkWorker;
|
||||||
|
#[async_trait]
|
||||||
|
impl WorkerHandler for OkWorker {
|
||||||
|
async fn process(&self, _stream: &str, _payload: &str) -> Result<(), ()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,573 @@
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const RUNTIME_DEFAULTS = Object.freeze({
|
||||||
|
jmap_timeout_ms: 15000,
|
||||||
|
telegram_timeout_ms: 10000,
|
||||||
|
llm_timeout_ms: 30000,
|
||||||
|
max_retries: 3,
|
||||||
|
});
|
||||||
|
const RUNTIME_LIMITS = Object.freeze({
|
||||||
|
jmap_timeout_ms: { min: 100, max: 300000 },
|
||||||
|
telegram_timeout_ms: { min: 100, max: 300000 },
|
||||||
|
llm_timeout_ms: { min: 100, max: 300000 },
|
||||||
|
max_retries: { min: 0, max: 5 },
|
||||||
|
});
|
||||||
|
const RUNTIME_FIELDS = Object.freeze([
|
||||||
|
["jmap_timeout_ms", "jmap-timeout"],
|
||||||
|
["telegram_timeout_ms", "telegram-timeout"],
|
||||||
|
["llm_timeout_ms", "llm-timeout"],
|
||||||
|
["max_retries", "max-retries"],
|
||||||
|
]);
|
||||||
|
const I64_MIN = -(2n ** 63n);
|
||||||
|
const I64_MAX = (2n ** 63n) - 1n;
|
||||||
|
|
||||||
|
const bootstrapInput = document.querySelector("#bootstrap-input");
|
||||||
|
const authForm = document.querySelector("#auth-form");
|
||||||
|
const authCard = document.querySelector("#auth-card");
|
||||||
|
const configPanel = document.querySelector("#config-panel");
|
||||||
|
const businessForm = document.querySelector("#business-form");
|
||||||
|
const businessFields = document.querySelector("#business-fields");
|
||||||
|
const runtimeForm = document.querySelector("#runtime-form");
|
||||||
|
const runtimeFields = document.querySelector("#runtime-fields");
|
||||||
|
const connectButton = document.querySelector("#connect-button");
|
||||||
|
const reloadButton = document.querySelector("#reload-button");
|
||||||
|
const logoutButton = document.querySelector("#logout-button");
|
||||||
|
const defaultsButton = document.querySelector("#defaults-button");
|
||||||
|
const businessSaveButton = document.querySelector("#business-save-button");
|
||||||
|
const runtimeSaveButton = document.querySelector("#runtime-save-button");
|
||||||
|
const businessSaveState = document.querySelector("#business-save-state");
|
||||||
|
const runtimeSaveState = document.querySelector("#runtime-save-state");
|
||||||
|
const sessionExpiry = document.querySelector("#session-expiry");
|
||||||
|
const llmEnabled = document.querySelector("#llm-enabled");
|
||||||
|
const llmApiKey = document.querySelector("#llm-api-key");
|
||||||
|
const llmBaseUrl = document.querySelector("#llm-base-url");
|
||||||
|
const llmModel = document.querySelector("#llm-model");
|
||||||
|
const notice = document.querySelector("#notice");
|
||||||
|
|
||||||
|
// C-NO-LOCAL-WRITE / C-REDIS-ONLY-STATE: credentials and configuration stay in page memory.
|
||||||
|
let adminSession = "";
|
||||||
|
let expiryTimer = 0;
|
||||||
|
let busyAction = "";
|
||||||
|
let runtimeLoaded = false;
|
||||||
|
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(status, message) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotice(kind, message) {
|
||||||
|
notice.className = `notice notice-${kind}`;
|
||||||
|
notice.textContent = message;
|
||||||
|
notice.setAttribute("role", kind === "error" ? "alert" : "status");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButtons() {
|
||||||
|
const busy = Boolean(busyAction);
|
||||||
|
connectButton.disabled = busy;
|
||||||
|
reloadButton.disabled = busy || !adminSession;
|
||||||
|
logoutButton.disabled = busy || !adminSession;
|
||||||
|
businessSaveButton.disabled = busy || !adminSession;
|
||||||
|
runtimeSaveButton.disabled = busy || !runtimeLoaded || !adminSession;
|
||||||
|
defaultsButton.disabled = busy || !runtimeLoaded || !adminSession;
|
||||||
|
businessSaveButton.classList.toggle("is-busy", busy && busyAction === "business-save");
|
||||||
|
runtimeSaveButton.classList.toggle("is-busy", busy && busyAction === "runtime-save");
|
||||||
|
connectButton.textContent = busy && busyAction === "login" ? "正在授权…" : "创建会话";
|
||||||
|
reloadButton.textContent = busy && busyAction === "runtime-load" ? "正在读取…" : "重新读取运行参数";
|
||||||
|
businessSaveButton.querySelector(".button-label").textContent = busy && busyAction === "business-save" ? "正在保存并热加载…" : "保存业务配置并热加载";
|
||||||
|
runtimeSaveButton.querySelector(".button-label").textContent = busy && busyAction === "runtime-save" ? "正在保存…" : "保存运行参数";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(action = "") {
|
||||||
|
busyAction = action;
|
||||||
|
updateButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResponseText(text, status) {
|
||||||
|
if (status === 204 || text.trim() === "") return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error("配置服务返回了无法识别的数据格式。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, method = "GET", body = undefined, bearer = adminSession) {
|
||||||
|
const headers = new Headers({ Accept: "application/json" });
|
||||||
|
if (bearer) headers.set("Authorization", `Bearer ${bearer}`);
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "omit",
|
||||||
|
redirect: "error",
|
||||||
|
referrerPolicy: "no-referrer",
|
||||||
|
};
|
||||||
|
if (body !== undefined) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
options.body = typeof body === "string" ? body : JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(path, options);
|
||||||
|
} catch {
|
||||||
|
throw new Error("无法连接配置服务。请确认服务可用后重试。");
|
||||||
|
}
|
||||||
|
const responseText = await response.text().catch(() => "");
|
||||||
|
if (!response.ok) throw new ApiError(response.status, responseText);
|
||||||
|
return parseResponseText(responseText, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConfigView() {
|
||||||
|
authCard.hidden = true;
|
||||||
|
configPanel.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAuthView() {
|
||||||
|
configPanel.hidden = true;
|
||||||
|
authCard.hidden = false;
|
||||||
|
bootstrapInput.value = "";
|
||||||
|
bootstrapInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSecretFields() {
|
||||||
|
for (const input of document.querySelectorAll('input[type="password"]')) input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearForms() {
|
||||||
|
businessForm.reset();
|
||||||
|
runtimeForm.reset();
|
||||||
|
runtimeLoaded = false;
|
||||||
|
runtimeFields.disabled = true;
|
||||||
|
businessFields.disabled = true;
|
||||||
|
runtimeSaveState.textContent = "读取成功后可编辑运行参数。";
|
||||||
|
businessSaveState.textContent = "完成管理会话验证后可填写完整配置。";
|
||||||
|
updateLLMRequirements();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSession() {
|
||||||
|
adminSession = "";
|
||||||
|
if (expiryTimer) window.clearTimeout(expiryTimer);
|
||||||
|
expiryTimer = 0;
|
||||||
|
sessionExpiry.textContent = "会话仅保存在当前页面内存";
|
||||||
|
clearForms();
|
||||||
|
clearSecretFields();
|
||||||
|
setBusy("");
|
||||||
|
showAuthView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expireSession(message) {
|
||||||
|
clearSession();
|
||||||
|
showNotice("error", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSessionExpiry(seconds) {
|
||||||
|
if (expiryTimer) window.clearTimeout(expiryTimer);
|
||||||
|
const duration = Math.max(1, Math.min(Number(seconds) || 900, 900));
|
||||||
|
sessionExpiry.textContent = `会话约 ${Math.ceil(duration / 60)} 分钟后过期;只保存在页面内存`;
|
||||||
|
expiryTimer = window.setTimeout(() => {
|
||||||
|
expireSession("管理会话已过期,已清除页面中的会话和表单内容。请重新授权。");
|
||||||
|
}, duration * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuntimeConfig(value) {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error("配置服务返回了无法识别的运行参数。");
|
||||||
|
}
|
||||||
|
const config = {};
|
||||||
|
for (const [key] of RUNTIME_FIELDS) {
|
||||||
|
const item = value[key];
|
||||||
|
const limit = RUNTIME_LIMITS[key];
|
||||||
|
if (!Number.isInteger(item) || item < limit.min || item > limit.max) {
|
||||||
|
throw new Error("配置服务返回了超出范围的运行参数,请检查后端配置。");
|
||||||
|
}
|
||||||
|
config[key] = item;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillRuntimeFields(config) {
|
||||||
|
for (const [key, id] of RUNTIME_FIELDS) document.getElementById(id).value = String(config[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRuntimePreview() {
|
||||||
|
fillRuntimeFields(RUNTIME_DEFAULTS);
|
||||||
|
runtimeLoaded = false;
|
||||||
|
runtimeFields.disabled = true;
|
||||||
|
runtimeSaveState.textContent = "默认值预览;读取后才能保存。";
|
||||||
|
updateButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateRuntimeConfig(config) {
|
||||||
|
fillRuntimeFields(config);
|
||||||
|
runtimeLoaded = true;
|
||||||
|
runtimeFields.disabled = false;
|
||||||
|
runtimeSaveState.textContent = "已读取 Redis 中的运行参数。";
|
||||||
|
runtimeSaveState.classList.remove("is-dirty");
|
||||||
|
updateButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeErrorMessage(error, phase) {
|
||||||
|
if (error.status === 401) return "管理会话已失效,请重新授权。";
|
||||||
|
if (error.status === 400) return "运行参数请求格式无效(HTTP 400)。";
|
||||||
|
if (error.status === 422) return "运行参数超出允许范围(超时 100–300,000 毫秒,重试 0–5 次)。";
|
||||||
|
if (error.status === 503) return phase === "save"
|
||||||
|
? "Redis 不可用或初始化尚未完成;保存状态无法确认。已禁用运行参数保存,请恢复连接后重新读取。"
|
||||||
|
: "Redis 不可用或初始化尚未完成。当前只显示默认值预览,运行参数保存已禁用;请稍后重试。";
|
||||||
|
if (error.status !== undefined) return `运行参数请求失败(HTTP ${error.status})。请稍后重试。`;
|
||||||
|
return error.message || "运行参数请求失败,请检查服务状态后重试。";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRuntimeConfig() {
|
||||||
|
if (!adminSession || busyAction) return;
|
||||||
|
showRuntimePreview();
|
||||||
|
setBusy("runtime-load");
|
||||||
|
showNotice("info", "正在从 Redis 读取运行参数…");
|
||||||
|
try {
|
||||||
|
const config = normalizeRuntimeConfig(await request("/api/config"));
|
||||||
|
activateRuntimeConfig(config);
|
||||||
|
showNotice("success", "运行参数已读取。业务配置仍需手动完整填写。");
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 401) {
|
||||||
|
expireSession("管理会话已失效,页面内容已清除。请重新创建会话。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runtimeLoaded = false;
|
||||||
|
runtimeFields.disabled = true;
|
||||||
|
runtimeSaveState.textContent = "读取未成功,保存已禁用。";
|
||||||
|
updateButtons();
|
||||||
|
showNotice("error", runtimeErrorMessage(error, "load"));
|
||||||
|
} finally {
|
||||||
|
if (adminSession) setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFieldError(input, message) {
|
||||||
|
input.setCustomValidity(message);
|
||||||
|
input.reportValidity();
|
||||||
|
input.focus();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCustomValidity(form) {
|
||||||
|
for (const input of form.querySelectorAll("input, textarea")) input.setCustomValidity("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlank(value) {
|
||||||
|
return value.trim() === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseI64(input, label) {
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!/^-?\d+$/.test(value)) {
|
||||||
|
setFieldError(input, `${label} 必须是十进制整数。`);
|
||||||
|
throw new Error("invalid-field");
|
||||||
|
}
|
||||||
|
const parsed = BigInt(value);
|
||||||
|
if (parsed < I64_MIN || parsed > I64_MAX) {
|
||||||
|
setFieldError(input, `${label} 超出有符号 64 位整数范围。`);
|
||||||
|
throw new Error("invalid-field");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAllowlist(input) {
|
||||||
|
const entries = input.value.split(/[\s,]+/).filter(Boolean);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
setFieldError(input, "至少填写一个 chat ID。空白和逗号均可用作分隔符。");
|
||||||
|
throw new Error("invalid-field");
|
||||||
|
}
|
||||||
|
const ids = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!/^-?\d+$/.test(entry)) {
|
||||||
|
setFieldError(input, `白名单中的“${entry}”不是整数 chat ID。`);
|
||||||
|
throw new Error("invalid-field");
|
||||||
|
}
|
||||||
|
const id = BigInt(entry);
|
||||||
|
if (id < I64_MIN || id > I64_MAX) {
|
||||||
|
setFieldError(input, `白名单中的“${entry}”超出有符号 64 位整数范围。`);
|
||||||
|
throw new Error("invalid-field");
|
||||||
|
}
|
||||||
|
if (seen.has(id.toString())) {
|
||||||
|
setFieldError(input, `白名单中有重复的 chat ID:${entry}。`);
|
||||||
|
throw new Error("invalid-field");
|
||||||
|
}
|
||||||
|
seen.add(id.toString());
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHttpsUrl(input, label, { noCredentials = false, noQuery = false } = {}) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(input.value.trim());
|
||||||
|
} catch {
|
||||||
|
setFieldError(input, `${label} 必须是有效的 HTTPS URL。`);
|
||||||
|
throw new Error("invalid-field");
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "https:" || !parsed.hostname) {
|
||||||
|
setFieldError(input, `${label} 必须使用 HTTPS。`);
|
||||||
|
throw new Error("invalid-field");
|
||||||
|
}
|
||||||
|
if (noCredentials && (parsed.username || parsed.password)) {
|
||||||
|
setFieldError(input, `${label} 不能包含用户名或密码。`);
|
||||||
|
throw new Error("invalid-field");
|
||||||
|
}
|
||||||
|
const rawValue = input.value.trim();
|
||||||
|
if (noQuery && (rawValue.includes("?") || rawValue.includes("#"))) {
|
||||||
|
setFieldError(input, `${label} 不能包含查询参数或 #片段。`);
|
||||||
|
throw new Error("invalid-field");
|
||||||
|
}
|
||||||
|
return input.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireText(id, label, secret = false) {
|
||||||
|
const input = document.getElementById(id);
|
||||||
|
const value = secret ? input.value : input.value.trim();
|
||||||
|
if (isBlank(value)) {
|
||||||
|
setFieldError(input, `${label} 不能为空。`);
|
||||||
|
throw new Error("invalid-field");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBusinessConfig() {
|
||||||
|
clearCustomValidity(businessForm);
|
||||||
|
const enabled = llmEnabled.checked;
|
||||||
|
llmApiKey.required = enabled;
|
||||||
|
llmBaseUrl.required = enabled;
|
||||||
|
llmModel.required = enabled;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const llmBaseValue = llmBaseUrl.value.trim();
|
||||||
|
const llmModelValue = llmModel.value.trim();
|
||||||
|
const llmKeyValue = llmApiKey.value;
|
||||||
|
if (enabled && !llmBaseValue) return setFieldError(llmBaseUrl, "启用 LLM 时必须填写 Base URL。");
|
||||||
|
if (enabled && !llmModelValue) return setFieldError(llmModel, "启用 LLM 时必须填写模型名称。 ");
|
||||||
|
if (enabled && isBlank(llmKeyValue)) return setFieldError(llmApiKey, "启用 LLM 时必须填写 API key。");
|
||||||
|
const llmBase = llmBaseValue
|
||||||
|
? (enabled ? validateHttpsUrl(llmBaseUrl, "LLM Base URL") : llmBaseValue)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const jmapUrlInput = document.getElementById("jmap-session-url");
|
||||||
|
const business = {
|
||||||
|
bot_token: requireText("telegram-bot-token", "Telegram Bot Token", true),
|
||||||
|
telegram_chat_id: parseI64(document.getElementById("telegram-chat-id"), "目标 Chat ID"),
|
||||||
|
chat_allowlist: parseAllowlist(document.getElementById("chat-allowlist")),
|
||||||
|
telegram_webhook_secret: requireText("telegram-webhook-secret", "Telegram Webhook Secret", true),
|
||||||
|
jmap_session_url: validateHttpsUrl(jmapUrlInput, "JMAP Session URL", { noCredentials: true, noQuery: true }),
|
||||||
|
jmap_username: requireText("jmap-username", "邮箱用户名"),
|
||||||
|
jmap_password: requireText("jmap-password", "JMAP 应用专用密码", true),
|
||||||
|
jmap_push_verification: requireText("jmap-push-verification", "JMAP Push 验证码", true),
|
||||||
|
account_id: document.getElementById("account-id").value.trim() || null,
|
||||||
|
llm_enabled: enabled,
|
||||||
|
llm_allow_net: document.getElementById("llm-allow-net").checked,
|
||||||
|
llm_api_key: isBlank(llmKeyValue) ? null : llmKeyValue,
|
||||||
|
llm_base_url: llmBase,
|
||||||
|
llm_model: llmModelValue || null,
|
||||||
|
reconcile_token: requireText("reconcile-token", "Reconcile Token", true),
|
||||||
|
worker_token: requireText("worker-token", "Worker Token", true),
|
||||||
|
};
|
||||||
|
return business;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message === "invalid-field") return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyWithBigInt(value) {
|
||||||
|
if (typeof value === "bigint") return value.toString();
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stringifyWithBigInt).join(",")}]`;
|
||||||
|
if (value !== null && typeof value === "object") {
|
||||||
|
return `{${Object.entries(value).map(([key, item]) => `${JSON.stringify(key)}:${stringifyWithBigInt(item)}`).join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function businessErrorMessage(error) {
|
||||||
|
if (error.status === 401) return "管理会话已失效。页面已清除会话和表单内容,请重新授权。";
|
||||||
|
if (error.status === 400) return "后端无法解析提交的 JSON(HTTP 400);请检查字段后重试。";
|
||||||
|
if (error.status === 422) return "业务配置校验失败(HTTP 422)。请检查必填项、HTTPS URL、LLM 设置和 chat ID 白名单。服务端没有返回密钥或错误详情。";
|
||||||
|
if (error.status === 503) return "业务配置未获成功确认(HTTP 503)。Redis 可能尚未就绪,或后端无法建立 JMAP/LLM 客户端;页面保留本次输入供检查和重试。";
|
||||||
|
if (error.status !== undefined) return `业务配置请求失败(HTTP ${error.status})。请稍后重试。`;
|
||||||
|
return `${error.message || "业务配置请求失败。"} 请求结果可能无法确认;检查后可重新提交完整配置。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimePayloadFromForm() {
|
||||||
|
clearCustomValidity(runtimeForm);
|
||||||
|
const config = {};
|
||||||
|
for (const [key, id] of RUNTIME_FIELDS) {
|
||||||
|
const input = document.getElementById(id);
|
||||||
|
const value = input.value.trim() === "" ? Number.NaN : Number(input.value);
|
||||||
|
const limit = RUNTIME_LIMITS[key];
|
||||||
|
if (!Number.isInteger(value)) return setFieldError(input, "请输入整数。"), null;
|
||||||
|
if (value < limit.min || value > limit.max) {
|
||||||
|
return setFieldError(input, `请输入 ${limit.min} 到 ${limit.max} 之间的整数。`), null;
|
||||||
|
}
|
||||||
|
config[key] = value;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLLMRequirements() {
|
||||||
|
const enabled = llmEnabled.checked;
|
||||||
|
llmApiKey.required = enabled;
|
||||||
|
llmBaseUrl.required = enabled;
|
||||||
|
llmModel.required = enabled;
|
||||||
|
document.querySelector("#llm-key-required").textContent = enabled ? "必填" : "可选";
|
||||||
|
document.querySelector("#llm-url-required").textContent = enabled ? "必填" : "可选";
|
||||||
|
document.querySelector("#llm-model-required").textContent = enabled ? "必填" : "可选";
|
||||||
|
}
|
||||||
|
|
||||||
|
authForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busyAction) return;
|
||||||
|
let bootstrapCredential = bootstrapInput.value;
|
||||||
|
bootstrapInput.value = "";
|
||||||
|
if (isBlank(bootstrapCredential)) {
|
||||||
|
showNotice("error", "请输入 Redis ACL 管理凭据。输入不会写入浏览器存储。");
|
||||||
|
bootstrapInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy("login");
|
||||||
|
showNotice("info", "正在创建短期管理会话…");
|
||||||
|
try {
|
||||||
|
const sessionInfo = await request("/api/admin/session", "POST", undefined, bootstrapCredential);
|
||||||
|
bootstrapCredential = "";
|
||||||
|
if (!sessionInfo || typeof sessionInfo.session !== "string" || !sessionInfo.session) {
|
||||||
|
throw new Error("管理接口返回了无效的会话响应。");
|
||||||
|
}
|
||||||
|
adminSession = sessionInfo.session;
|
||||||
|
scheduleSessionExpiry(sessionInfo.expires_in);
|
||||||
|
showConfigView();
|
||||||
|
businessFields.disabled = false;
|
||||||
|
businessSaveState.textContent = "完整填写 BusinessConfigWire 后可提交。密钥不会回显。";
|
||||||
|
showRuntimePreview();
|
||||||
|
setBusy("");
|
||||||
|
showNotice("success", "管理会话已创建。运行参数正在读取;业务配置需要完整重新填写。 ");
|
||||||
|
await loadRuntimeConfig();
|
||||||
|
} catch (error) {
|
||||||
|
bootstrapCredential = "";
|
||||||
|
setBusy("");
|
||||||
|
if (error.status === 401) showNotice("error", "Redis 管理凭据无效,或服务尚未启用管理会话。请检查 Redis URL ACL 密码。 ");
|
||||||
|
else if (error.status === 503) showNotice("error", "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。 ");
|
||||||
|
else if (error.status !== undefined) showNotice("error", `无法创建管理会话(HTTP ${error.status})。`);
|
||||||
|
else showNotice("error", error.message || "无法连接管理服务,请检查网络后重试。");
|
||||||
|
bootstrapInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
reloadButton.addEventListener("click", loadRuntimeConfig);
|
||||||
|
|
||||||
|
logoutButton.addEventListener("click", async () => {
|
||||||
|
if (!adminSession || busyAction) return;
|
||||||
|
setBusy("logout");
|
||||||
|
const session = adminSession;
|
||||||
|
try {
|
||||||
|
await request("/api/admin/session/revoke", "POST", undefined, session);
|
||||||
|
} catch {
|
||||||
|
// Local credentials are cleared even when the revocation request cannot reach Redis.
|
||||||
|
}
|
||||||
|
clearSession();
|
||||||
|
showNotice("info", "已退出;管理会话和表单中的密钥已从页面内存清除。");
|
||||||
|
});
|
||||||
|
|
||||||
|
defaultsButton.addEventListener("click", () => {
|
||||||
|
if (!runtimeLoaded || busyAction) return;
|
||||||
|
fillRuntimeFields(RUNTIME_DEFAULTS);
|
||||||
|
runtimeSaveState.textContent = "已恢复默认预览值;保存后才会写入 Redis。";
|
||||||
|
runtimeSaveState.classList.add("is-dirty");
|
||||||
|
showNotice("info", "运行参数已恢复为默认值预览。点击“保存运行参数”后才会更新 Redis。");
|
||||||
|
});
|
||||||
|
|
||||||
|
businessForm.addEventListener("input", (event) => {
|
||||||
|
event.target.setCustomValidity("");
|
||||||
|
if (!adminSession) return;
|
||||||
|
businessSaveState.textContent = "表单包含未提交内容;每次提交会完整替换现有业务配置。";
|
||||||
|
businessSaveState.classList.add("is-dirty");
|
||||||
|
});
|
||||||
|
|
||||||
|
runtimeForm.addEventListener("input", (event) => {
|
||||||
|
event.target.setCustomValidity("");
|
||||||
|
if (!runtimeLoaded) return;
|
||||||
|
runtimeSaveState.textContent = "有尚未保存的运行参数修改。";
|
||||||
|
runtimeSaveState.classList.add("is-dirty");
|
||||||
|
});
|
||||||
|
|
||||||
|
llmEnabled.addEventListener("change", updateLLMRequirements);
|
||||||
|
|
||||||
|
businessForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!adminSession || busyAction) return;
|
||||||
|
const config = readBusinessConfig();
|
||||||
|
if (!config) return;
|
||||||
|
setBusy("business-save");
|
||||||
|
showNotice("info", "正在提交完整业务配置;后端会先构建客户端,再保存并切换运行实例…");
|
||||||
|
try {
|
||||||
|
await request("/api/business-config", "PUT", stringifyWithBigInt(config));
|
||||||
|
clearSecretFields();
|
||||||
|
businessSaveState.textContent = "业务配置已保存,新配置已热加载。";
|
||||||
|
businessSaveState.classList.remove("is-dirty");
|
||||||
|
showNotice("success", "业务配置已保存并热加载。密钥输入已清空;下次完整替换时需要重新填写。");
|
||||||
|
updateButtons();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 401) {
|
||||||
|
expireSession("管理会话已失效,已清除页面中的会话与配置。请重新授权。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
businessSaveState.textContent = "未收到保存成功确认;请检查服务状态。";
|
||||||
|
showNotice("error", businessErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
if (adminSession) setBusy("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runtimeForm.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!adminSession || !runtimeLoaded || busyAction) return;
|
||||||
|
const config = runtimePayloadFromForm();
|
||||||
|
if (!config) return;
|
||||||
|
setBusy("runtime-save");
|
||||||
|
showNotice("info", "正在保存运行参数到 Redis…");
|
||||||
|
try {
|
||||||
|
const saved = normalizeRuntimeConfig(await request("/api/config", "PUT", config));
|
||||||
|
activateRuntimeConfig(saved);
|
||||||
|
showNotice("success", "运行参数已保存到 Redis,并立即应用于后续请求。");
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status === 401) {
|
||||||
|
expireSession("管理会话已失效,已清除页面中的会话与配置。请重新授权。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error.status >= 500 || error.status === undefined) {
|
||||||
|
runtimeLoaded = false;
|
||||||
|
runtimeFields.disabled = true;
|
||||||
|
runtimeSaveState.textContent = "保存状态无法确认;重新读取成功前已禁用保存。";
|
||||||
|
updateButtons();
|
||||||
|
}
|
||||||
|
showNotice("error", runtimeErrorMessage(error, "save"));
|
||||||
|
} finally {
|
||||||
|
if (adminSession) setBusy("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", () => {
|
||||||
|
adminSession = "";
|
||||||
|
if (expiryTimer) window.clearTimeout(expiryTimer);
|
||||||
|
clearForms();
|
||||||
|
clearSecretFields();
|
||||||
|
configPanel.hidden = true;
|
||||||
|
authCard.hidden = false;
|
||||||
|
bootstrapInput.value = "";
|
||||||
|
setBusy("");
|
||||||
|
});
|
||||||
|
|
||||||
|
showRuntimePreview();
|
||||||
|
updateLLMRequirements();
|
||||||
|
updateButtons();
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<meta name="referrer" content="no-referrer" />
|
||||||
|
<title>业务配置 · Stalwart Bot</title>
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css" />
|
||||||
|
<script src="/assets/config.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient ambient-one" aria-hidden="true"></div>
|
||||||
|
<div class="ambient ambient-two" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="/" aria-label="Stalwart Bot 配置首页">
|
||||||
|
<span class="brand-mark" aria-hidden="true">S</span>
|
||||||
|
<span class="brand-copy"><strong>stalwart bot</strong><span>业务配置</span></span>
|
||||||
|
</a>
|
||||||
|
<span class="topbar-note"><span class="secure-dot"></span>安全配置通道</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<section class="intro" aria-labelledby="page-title">
|
||||||
|
<p class="eyebrow">服务管理 <span>·</span> CONFIGURATION</p>
|
||||||
|
<h1 id="page-title">管理你的服务连接</h1>
|
||||||
|
<p class="intro-copy">配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="notice" class="notice notice-info" role="status" aria-live="polite">
|
||||||
|
使用 Redis 管理凭据创建短期管理会话。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="auth-card" class="panel auth-panel" aria-labelledby="auth-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div class="icon-tile icon-violet" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M7.5 10V7.5a4.5 4.5 0 0 1 9 0V10M6 10h12v10H6z"/><path d="M12 14v2"/></svg>
|
||||||
|
</div>
|
||||||
|
<div><p class="eyebrow">需要授权</p><h2 id="auth-title">创建管理会话</h2></div>
|
||||||
|
</div>
|
||||||
|
<p class="panel-copy">输入部署时 <code>REDIS_URL</code> 中 Redis ACL 用户的密码。服务端会签发有效期 15 分钟的管理会话;密码和会话只在当前页面内存中使用。</p>
|
||||||
|
<form id="auth-form" class="auth-form" autocomplete="off">
|
||||||
|
<label for="bootstrap-input">Redis 管理凭据</label>
|
||||||
|
<div class="token-row">
|
||||||
|
<input id="bootstrap-input" name="bootstrap-credential" type="password" autocomplete="new-password" spellcheck="false" required placeholder="输入 Redis URL 中的 ACL 密码" aria-describedby="bootstrap-hint" />
|
||||||
|
<button id="connect-button" class="button button-primary" type="submit">创建会话</button>
|
||||||
|
</div>
|
||||||
|
<p id="bootstrap-hint" class="field-hint">不会写入 localStorage、sessionStorage 或 Cookie。关闭或刷新页面后需要重新授权。</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="config-panel" class="config-panel" aria-label="业务配置与运行参数" hidden>
|
||||||
|
<div class="panel config-toolbar">
|
||||||
|
<div class="session-state"><span class="status-pulse" aria-hidden="true"></span><div><strong>管理会话有效</strong><span id="session-expiry">会话仅保存在当前页面内存</span></div></div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<button id="reload-button" class="button button-quiet" type="button">重新读取运行参数</button>
|
||||||
|
<button id="logout-button" class="button button-quiet" type="button">退出并清除会话</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel config-section" aria-labelledby="business-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><p class="eyebrow">完整替换</p><h2 id="business-title">业务连接与鉴权</h2></div>
|
||||||
|
<span class="persist-label"><span class="persist-dot"></span>Redis 加密保存</span>
|
||||||
|
</div>
|
||||||
|
<div class="callout callout-warning">
|
||||||
|
<strong>业务配置不会从服务器读取。</strong>为避免密钥回显,API 不提供业务配置 GET。每次提交都必须重新填写完整配置,包括所有必填密钥;可选的 account ID 与 LLM 字段留空时会发送为 <code>null</code>。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="business-form" novalidate>
|
||||||
|
<fieldset id="business-fields" disabled>
|
||||||
|
<legend class="visually-hidden">完整业务配置</legend>
|
||||||
|
|
||||||
|
<section class="form-section" aria-labelledby="jmap-title">
|
||||||
|
<div class="form-section-heading"><span class="section-number">01</span><div><h3 id="jmap-title">JMAP 邮箱</h3><p>服务端必须使用 HTTPS,URL 中不能包含凭据、查询参数或片段。</p></div></div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field field-wide"><label for="jmap-session-url">JMAP Session URL <span class="required-mark">必填</span></label><input id="jmap-session-url" name="jmap_session_url" type="url" inputmode="url" autocomplete="url" required placeholder="https://mail.example.com/.well-known/jmap" /><small>HTTPS 地址,不含用户名、密码、查询参数或 #片段。</small></div>
|
||||||
|
<div class="field"><label for="jmap-username">邮箱用户名 <span class="required-mark">必填</span></label><input id="jmap-username" name="jmap_username" type="text" autocomplete="username" required /></div>
|
||||||
|
<div class="field"><label for="jmap-password">应用专用密码 <span class="required-mark">必填</span></label><input id="jmap-password" name="jmap_password" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
|
||||||
|
<div class="field"><label for="account-id">JMAP Account ID <span class="optional-mark">可选</span></label><input id="account-id" name="account_id" type="text" autocomplete="off" placeholder="留空使用 session 主账户" /></div>
|
||||||
|
<div class="field"><label for="jmap-push-verification">JMAP Push 验证码 <span class="required-mark">必填</span></label><input id="jmap-push-verification" name="jmap_push_verification" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section" aria-labelledby="telegram-title">
|
||||||
|
<div class="form-section-heading"><span class="section-number">02</span><div><h3 id="telegram-title">Telegram 与访问鉴权</h3><p>仅允许白名单中的 chat ID 触发机器人操作。</p></div></div>
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field"><label for="telegram-bot-token">Telegram Bot Token <span class="required-mark">必填</span></label><input id="telegram-bot-token" name="bot_token" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
|
||||||
|
<div class="field"><label for="telegram-chat-id">目标 Chat ID <span class="required-mark">必填</span></label><input id="telegram-chat-id" name="telegram_chat_id" type="text" inputmode="numeric" autocomplete="off" required placeholder="例如 -1001234567890" /><small>接受完整有符号 64 位整数。</small></div>
|
||||||
|
<div class="field field-wide"><label for="chat-allowlist">Chat allowlist <span class="required-mark">至少一项</span></label><textarea id="chat-allowlist" name="chat_allowlist" rows="3" required spellcheck="false" placeholder="每行或逗号分隔一个 chat ID"></textarea><small>整数列表;保存前会检查格式与重复项。</small></div>
|
||||||
|
<div class="field"><label for="telegram-webhook-secret">Telegram Webhook Secret <span class="required-mark">必填</span></label><input id="telegram-webhook-secret" name="telegram_webhook_secret" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
|
||||||
|
<div class="field"><label for="reconcile-token">Reconcile Bearer Token <span class="required-mark">必填</span></label><input id="reconcile-token" name="reconcile_token" type="password" autocomplete="new-password" spellcheck="false" required placeholder="每次完整保存时重新输入" /></div>
|
||||||
|
<div class="field field-wide"><label for="worker-token">Worker Token <span class="required-mark">必填</span></label><input id="worker-token" name="worker_token" type="password" autocomplete="new-password" spellcheck="false" required placeholder="/worker 与兼容管理 API 使用;每次完整保存时重新输入" /><small>此值与当前管理会话不同。页面不会读取或回显已有 token。</small></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section" aria-labelledby="llm-title">
|
||||||
|
<div class="form-section-heading"><span class="section-number">03</span><div><h3 id="llm-title">LLM 服务</h3><p>启用后要求 HTTPS Base URL、模型名和 API key。</p></div></div>
|
||||||
|
<div class="toggle-grid">
|
||||||
|
<label class="toggle-card" for="llm-enabled"><input id="llm-enabled" name="llm_enabled" type="checkbox" /><span><strong>启用 LLM</strong><small>允许机器人调用兼容 OpenAI 的模型服务。</small></span></label>
|
||||||
|
<label class="toggle-card" for="llm-allow-net"><input id="llm-allow-net" name="llm_allow_net" type="checkbox" /><span><strong>允许网络调用</strong><small>关闭时不会创建 LLM 客户端。</small></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="field-grid llm-fields">
|
||||||
|
<div class="field"><label for="llm-base-url">LLM Base URL <span id="llm-url-required" class="optional-mark">可选</span></label><input id="llm-base-url" name="llm_base_url" type="url" inputmode="url" autocomplete="url" placeholder="https://api.example.com/v1" /><small>启用 LLM 时必须使用 HTTPS。</small></div>
|
||||||
|
<div class="field"><label for="llm-model">模型名称 <span id="llm-model-required" class="optional-mark">可选</span></label><input id="llm-model" name="llm_model" type="text" autocomplete="off" placeholder="留空表示 null" /></div>
|
||||||
|
<div class="field field-wide"><label for="llm-api-key">LLM API Key <span id="llm-key-required" class="optional-mark">可选</span></label><input id="llm-api-key" name="llm_api_key" type="password" autocomplete="new-password" spellcheck="false" placeholder="启用 LLM 时必填;不会回显" /></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<p id="business-save-state" class="save-state" aria-live="polite">完成管理会话验证后可填写完整配置。</p>
|
||||||
|
<button id="business-save-button" class="button button-primary" type="submit" disabled><span class="button-label">保存业务配置并热加载</span><span class="button-spinner" aria-hidden="true"></span></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel config-section runtime-section" aria-labelledby="runtime-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><p class="eyebrow">出站请求</p><h2 id="runtime-title">超时与重试</h2></div>
|
||||||
|
<span class="persist-label"><span class="persist-dot"></span>Redis 保存</span>
|
||||||
|
</div>
|
||||||
|
<p class="section-copy">运行参数由 <code>GET /api/config</code> 读取、由 <code>PUT /api/config</code> 更新;新值保存后立即用于后续请求。</p>
|
||||||
|
<form id="runtime-form" novalidate>
|
||||||
|
<fieldset id="runtime-fields" disabled>
|
||||||
|
<legend class="visually-hidden">运行参数</legend>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<article class="setting-card"><label for="jmap-timeout">JMAP 超时</label><p class="setting-description">单次 JMAP 请求的等待上限。</p><div class="number-control"><input id="jmap-timeout" name="jmap_timeout_ms" type="number" min="100" max="300000" step="1" required /><span>毫秒</span></div><small>100–300,000;默认 15,000。</small></article>
|
||||||
|
<article class="setting-card"><label for="telegram-timeout">Telegram 超时</label><p class="setting-description">单次 Telegram 请求的等待上限。</p><div class="number-control"><input id="telegram-timeout" name="telegram_timeout_ms" type="number" min="100" max="300000" step="1" required /><span>毫秒</span></div><small>100–300,000;默认 10,000。</small></article>
|
||||||
|
<article class="setting-card"><label for="llm-timeout">LLM 超时</label><p class="setting-description">单次模型请求的等待上限。</p><div class="number-control"><input id="llm-timeout" name="llm_timeout_ms" type="number" min="100" max="300000" step="1" required /><span>毫秒</span></div><small>100–300,000;默认 30,000。</small></article>
|
||||||
|
<article class="setting-card"><label for="max-retries">最大重试次数</label><p class="setting-description">失败后额外尝试的次数,0 表示不重试。</p><div class="number-control"><input id="max-retries" name="max_retries" type="number" min="0" max="5" step="1" required /><span>次</span></div><small>0–5;默认 3。</small></article>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div class="form-footer">
|
||||||
|
<p id="runtime-save-state" class="save-state" aria-live="polite">读取成功后可编辑运行参数。</p>
|
||||||
|
<div class="form-actions"><button id="defaults-button" class="button button-quiet" type="button" disabled>恢复默认值</button><button id="runtime-save-button" class="button button-primary" type="submit" disabled><span class="button-label">保存运行参数</span><span class="button-spinner" aria-hidden="true"></span></button></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="privacy-note"><span class="privacy-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 2.8 8 7 10 4.2-2 7-5.5 7-10V6l-7-3Z"/><path d="m9 12 2 2 4-4"/></svg></span><p><strong>密钥不会回显或持久化到浏览器。</strong>业务配置只提交至同源 API,并由后端加密写入 Redis;本页面不读取邮箱内容、不使用本地文件或数据库。</p></aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer"><span>STALWART BOT</span><span>配置由你的服务端管理</span></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: #202239;
|
||||||
|
background: #f7f7fb;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
--ink: #202239;
|
||||||
|
--muted: #73758a;
|
||||||
|
--line: #ececf3;
|
||||||
|
--purple: #6656d8;
|
||||||
|
--purple-dark: #5544c6;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { min-width: 320px; min-height: 100vh; margin: 0; overflow-x: hidden; background: linear-gradient(148deg, #fafaff 0%, #f7f7fb 48%, #f5f6fa 100%); }
|
||||||
|
button, input, textarea { font: inherit; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:disabled, input:disabled, textarea:disabled { cursor: not-allowed; }
|
||||||
|
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid #9f93fa; outline-offset: 3px; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
.ambient { position: fixed; z-index: -1; pointer-events: none; border-radius: 50%; filter: blur(2px); }
|
||||||
|
.ambient-one { top: 7rem; right: -13rem; width: 31rem; height: 31rem; background: radial-gradient(circle, rgba(185, 177, 255, .2), rgba(185, 177, 255, 0) 68%); }
|
||||||
|
.ambient-two { top: 44rem; left: -17rem; width: 36rem; height: 36rem; background: radial-gradient(circle, rgba(125, 210, 196, .13), rgba(125, 210, 196, 0) 70%); }
|
||||||
|
|
||||||
|
.topbar { width: min(1100px, calc(100% - 48px)); height: 86px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(224, 224, 238, .72); }
|
||||||
|
.brand { display: inline-flex; align-items: center; gap: 12px; color: inherit; text-decoration: none; }
|
||||||
|
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; background: linear-gradient(145deg, #7466e5, #5443c5); color: white; font-size: 21px; font-weight: 760; box-shadow: 0 5px 12px rgba(93, 75, 203, .2); }
|
||||||
|
.brand-copy { display: grid; gap: 1px; }
|
||||||
|
.brand-copy strong { font-size: 14px; letter-spacing: -.2px; }
|
||||||
|
.brand-copy > span { color: #85869a; font-size: 11px; letter-spacing: .05em; }
|
||||||
|
.topbar-note { display: inline-flex; align-items: center; gap: 8px; color: #77798e; font-size: 12px; }
|
||||||
|
.secure-dot, .persist-dot { width: 7px; height: 7px; border-radius: 50%; background: #48ae86; box-shadow: 0 0 0 3px rgba(72, 174, 134, .12); }
|
||||||
|
|
||||||
|
.layout { width: min(960px, calc(100% - 40px)); margin: 0 auto; padding: 54px 0 56px; }
|
||||||
|
.intro { margin: 0 0 26px; }
|
||||||
|
.eyebrow { margin: 0 0 9px; color: #817cbe; font-size: 10px; font-weight: 750; letter-spacing: .14em; text-transform: uppercase; }
|
||||||
|
.eyebrow span { padding: 0 4px; color: #c8c5e6; }
|
||||||
|
h1, h2, h3, p { margin-top: 0; }
|
||||||
|
h1 { margin-bottom: 12px; font-size: clamp(29px, 5vw, 40px); line-height: 1.16; letter-spacing: -.045em; font-weight: 720; }
|
||||||
|
.intro-copy { max-width: 700px; margin-bottom: 0; color: var(--muted); font-size: 14px; line-height: 1.75; }
|
||||||
|
|
||||||
|
.notice { display: flex; align-items: flex-start; gap: 11px; margin: 0 0 17px; padding: 14px 16px; border: 1px solid; border-radius: 11px; font-size: 13px; line-height: 1.6; }
|
||||||
|
.notice::before { width: 18px; height: 18px; flex: 0 0 18px; display: grid; place-items: center; margin-top: 1px; border-radius: 50%; font-weight: 750; }
|
||||||
|
.notice-info { color: #5d5e7b; background: #f0efff; border-color: #e3e0ff; }
|
||||||
|
.notice-info::before { content: "i"; color: white; background: #8a7de4; font-family: Georgia, serif; font-style: italic; }
|
||||||
|
.notice-success { color: #2a745b; background: #eff9f4; border-color: #d8f0e4; }
|
||||||
|
.notice-success::before { content: "✓"; color: white; background: #52ad85; font-size: 11px; }
|
||||||
|
.notice-error { color: #965345; background: #fff5f1; border-color: #f6dfd7; }
|
||||||
|
.notice-error::before { content: "!"; color: white; background: #dc806e; font-size: 12px; }
|
||||||
|
|
||||||
|
.panel { border: 1px solid rgba(228, 228, 239, .92); border-radius: 16px; background: rgba(255, 255, 255, .94); box-shadow: 0 12px 38px rgba(41, 42, 79, .055), 0 2px 5px rgba(41, 42, 79, .025); }
|
||||||
|
.auth-panel { padding: 27px 29px 25px; }
|
||||||
|
.panel-heading { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
|
||||||
|
.icon-tile { width: 43px; height: 43px; display: grid; place-items: center; border-radius: 13px; }
|
||||||
|
.icon-tile svg, .privacy-icon svg { width: 21px; height: 21px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.icon-violet { color: #6455ce; background: #f0efff; }
|
||||||
|
.panel-heading .eyebrow { margin-bottom: 3px; }
|
||||||
|
h2 { margin-bottom: 0; font-size: 20px; line-height: 1.3; letter-spacing: -.025em; }
|
||||||
|
h3 { margin-bottom: 4px; color: #35364e; font-size: 15px; }
|
||||||
|
.panel-copy { max-width: 760px; margin-bottom: 22px; color: var(--muted); font-size: 13px; line-height: 1.7; }
|
||||||
|
code { padding: 2px 5px; border-radius: 4px; background: #f1f0fa; color: #534a9c; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
|
.auth-form > label, .field > label, .setting-card > label { display: block; margin-bottom: 7px; color: #47485f; font-size: 12px; font-weight: 700; }
|
||||||
|
.token-row { display: flex; gap: 10px; }
|
||||||
|
.token-row input, .field input, .field textarea { width: 100%; min-width: 0; min-height: 42px; padding: 10px 12px; border: 1px solid #dedeea; border-radius: 8px; background: #fff; color: var(--ink); font-size: 13px; transition: border-color .15s, box-shadow .15s; }
|
||||||
|
.token-row input { height: 44px; flex: 1; }
|
||||||
|
.field textarea { min-height: 82px; resize: vertical; line-height: 1.55; }
|
||||||
|
.token-row input::placeholder, .field input::placeholder, .field textarea::placeholder { color: #a5a5b4; }
|
||||||
|
.token-row input:focus, .field input:focus, .field textarea:focus { outline: none; border-color: #8f82e8; box-shadow: 0 0 0 3px rgba(123, 108, 224, .12); }
|
||||||
|
.field-hint, .field small, .setting-card small { display: block; margin: 7px 0 0; color: #9293a4; font-size: 11px; line-height: 1.5; }
|
||||||
|
|
||||||
|
.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 0 15px; border: 1px solid transparent; border-radius: 8px; font-size: 12px; font-weight: 680; transition: color .16s, background .16s, border-color .16s, box-shadow .16s, transform .16s; white-space: nowrap; }
|
||||||
|
.button:active:not(:disabled) { transform: translateY(1px); }
|
||||||
|
.button-primary { color: white; background: var(--purple); box-shadow: 0 4px 9px rgba(98, 81, 211, .19); }
|
||||||
|
.button-primary:hover:not(:disabled) { background: var(--purple-dark); box-shadow: 0 6px 13px rgba(98, 81, 211, .23); }
|
||||||
|
.button-primary:disabled { color: #fff; background: #b7b2d9; box-shadow: none; }
|
||||||
|
.button-quiet { color: #77788e; background: white; border-color: #e4e4ee; }
|
||||||
|
.button-quiet:hover:not(:disabled) { color: #4f48a1; background: #f8f7ff; border-color: #d8d4f2; }
|
||||||
|
.button-quiet:disabled { color: #b6b6c2; background: #fbfbfd; }
|
||||||
|
.button-spinner { display: none; width: 13px; height: 13px; border: 2px solid rgba(255, 255, 255, .4); border-top-color: white; border-radius: 50%; animation: spin .8s linear infinite; }
|
||||||
|
.is-busy .button-spinner { display: inline-block; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.config-panel { display: grid; gap: 16px; }
|
||||||
|
.config-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 20px; }
|
||||||
|
.session-state { display: inline-flex; align-items: center; gap: 10px; }
|
||||||
|
.status-pulse { width: 8px; height: 8px; border-radius: 50%; background: #48ad83; box-shadow: 0 0 0 4px rgba(72, 173, 131, .11); }
|
||||||
|
.session-state div { display: grid; gap: 2px; }
|
||||||
|
.session-state strong { color: #4b4d61; font-size: 11px; }
|
||||||
|
.session-state div span { color: #9999a9; font-size: 10px; }
|
||||||
|
.toolbar-actions { display: flex; gap: 7px; }
|
||||||
|
.toolbar-actions .button { min-height: 34px; padding: 0 10px; font-size: 11px; }
|
||||||
|
.config-section { overflow: hidden; }
|
||||||
|
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding: 23px 25px 17px; }
|
||||||
|
.section-heading .eyebrow { margin-bottom: 4px; }
|
||||||
|
.section-heading h2 { font-size: 20px; }
|
||||||
|
.persist-label { display: inline-flex; align-items: center; gap: 8px; padding-bottom: 3px; color: #7a7b8c; font-size: 11px; white-space: nowrap; }
|
||||||
|
.persist-dot { width: 6px; height: 6px; box-shadow: none; }
|
||||||
|
.callout { margin: 0 25px 5px; padding: 13px 15px; border: 1px solid; border-radius: 9px; font-size: 12px; line-height: 1.65; }
|
||||||
|
.callout-warning { color: #796132; background: #fffbef; border-color: #f2e9c8; }
|
||||||
|
.callout strong { color: #654d1e; }
|
||||||
|
.section-copy { margin: -3px 25px 18px; color: var(--muted); font-size: 12px; line-height: 1.65; }
|
||||||
|
|
||||||
|
fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||||
|
fieldset:disabled .field input, fieldset:disabled .field textarea { background: #fafafd; color: #858697; }
|
||||||
|
.form-section { padding: 22px 25px 24px; border-top: 1px solid #efeff5; }
|
||||||
|
.form-section-heading { display: flex; align-items: flex-start; gap: 13px; margin-bottom: 17px; }
|
||||||
|
.form-section-heading > div { min-width: 0; }
|
||||||
|
.form-section-heading p { margin-bottom: 0; color: #858697; font-size: 11px; line-height: 1.55; }
|
||||||
|
.section-number { width: 31px; height: 31px; flex: 0 0 31px; display: grid; place-items: center; border-radius: 9px; background: #f2f0ff; color: #6b5fd1; font-size: 10px; font-weight: 750; letter-spacing: .06em; }
|
||||||
|
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 13px; }
|
||||||
|
.field { min-width: 0; }
|
||||||
|
.field-wide { grid-column: 1 / -1; }
|
||||||
|
.required-mark, .optional-mark { margin-left: 4px; color: #a6685f; font-size: 10px; font-weight: 600; }
|
||||||
|
.optional-mark { color: #9293a4; }
|
||||||
|
.toggle-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; margin-bottom: 17px; }
|
||||||
|
.toggle-card { min-width: 0; display: flex; align-items: flex-start; gap: 10px; padding: 13px; border: 1px solid #ececf3; border-radius: 9px; background: #fcfcfe; cursor: pointer; }
|
||||||
|
.toggle-card input { width: 16px; height: 16px; flex: 0 0 16px; margin: 2px 0 0; accent-color: var(--purple); }
|
||||||
|
.toggle-card span { display: grid; gap: 3px; }
|
||||||
|
.toggle-card strong { color: #47485f; font-size: 12px; }
|
||||||
|
.toggle-card small { color: #8a8b9c; font-size: 10px; line-height: 1.5; }
|
||||||
|
.form-footer { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 25px; border-top: 1px solid #efeff5; background: #fcfcfe; }
|
||||||
|
.save-state { margin: 0; color: #8a8b9c; font-size: 11px; line-height: 1.5; }
|
||||||
|
.save-state.is-dirty { color: #9c712e; }
|
||||||
|
.form-actions { display: flex; gap: 8px; }
|
||||||
|
.runtime-section .settings-grid { padding: 0 25px 22px; }
|
||||||
|
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.setting-card { min-width: 0; padding: 16px; border: 1px solid #eeeef4; border-radius: 11px; background: #fff; }
|
||||||
|
.setting-card > label { margin-bottom: 5px; color: #35364e; font-size: 13px; }
|
||||||
|
.setting-description { min-height: 32px; margin-bottom: 10px; color: #858697; font-size: 11px; line-height: 1.5; }
|
||||||
|
.number-control { height: 40px; display: flex; align-items: center; border: 1px solid #e2e2eb; border-radius: 7px; background: #fff; transition: border-color .15s, box-shadow .15s; }
|
||||||
|
.number-control:focus-within { border-color: #8f82e8; box-shadow: 0 0 0 3px rgba(123, 108, 224, .1); }
|
||||||
|
.number-control input { width: 100%; min-width: 0; height: 100%; padding: 0 11px; border: 0; border-radius: inherit; outline: none; background: transparent; color: #34354c; font-size: 14px; font-weight: 650; font-variant-numeric: tabular-nums; }
|
||||||
|
.number-control input:focus { box-shadow: none; }
|
||||||
|
.number-control > span { padding-right: 11px; color: #999aa9; font-size: 10px; white-space: nowrap; }
|
||||||
|
.privacy-note { display: flex; gap: 11px; align-items: flex-start; margin-top: 18px; padding: 0 4px; color: #858697; }
|
||||||
|
.privacy-icon { width: 20px; flex: 0 0 20px; color: #8b84c8; }
|
||||||
|
.privacy-icon svg { width: 18px; height: 18px; }
|
||||||
|
.privacy-note p { margin: 0; font-size: 11px; line-height: 1.65; }
|
||||||
|
.privacy-note strong { color: #64657a; font-weight: 680; }
|
||||||
|
.footer { width: min(1100px, calc(100% - 48px)); display: flex; justify-content: space-between; margin: 0 auto; padding: 18px 0 23px; border-top: 1px solid rgba(224, 224, 238, .72); color: #a5a5b2; font-size: 9px; letter-spacing: .08em; }
|
||||||
|
.footer span:last-child { letter-spacing: 0; }
|
||||||
|
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); border: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.topbar { width: calc(100% - 34px); height: 72px; }
|
||||||
|
.topbar-note { font-size: 10px; }
|
||||||
|
.layout { width: calc(100% - 32px); padding-top: 40px; }
|
||||||
|
.intro { margin-bottom: 21px; }
|
||||||
|
.intro-copy { font-size: 13px; }
|
||||||
|
.auth-panel { padding: 22px 19px 20px; }
|
||||||
|
.token-row { flex-direction: column; }
|
||||||
|
.token-row .button { width: 100%; }
|
||||||
|
.config-toolbar { align-items: flex-start; padding: 13px 15px; }
|
||||||
|
.session-state div span { max-width: 155px; line-height: 1.4; }
|
||||||
|
.toolbar-actions { flex-direction: column; }
|
||||||
|
.toolbar-actions .button { min-height: 30px; }
|
||||||
|
.section-heading { align-items: flex-start; padding: 20px 17px 15px; }
|
||||||
|
.section-heading h2 { font-size: 18px; }
|
||||||
|
.persist-label { gap: 6px; font-size: 10px; }
|
||||||
|
.callout { margin: 0 17px 5px; }
|
||||||
|
.form-section { padding: 19px 17px 21px; }
|
||||||
|
.field-grid, .toggle-grid, .settings-grid { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.field-wide { grid-column: auto; }
|
||||||
|
.form-footer { align-items: flex-start; flex-direction: column; padding: 14px 17px; }
|
||||||
|
.form-footer > .button, .form-actions { width: 100%; }
|
||||||
|
.form-actions .button { flex: 1; }
|
||||||
|
.runtime-section .settings-grid { padding: 0 14px 17px; }
|
||||||
|
.section-copy { margin: -3px 17px 16px; }
|
||||||
|
.footer { width: calc(100% - 34px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.config-toolbar { flex-direction: column; }
|
||||||
|
.toolbar-actions { width: 100%; flex-direction: row; }
|
||||||
|
.toolbar-actions .button { flex: 1; white-space: normal; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue