From e36db8a56c4d95ef447b2a983d5aebec74c3d6b3 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 16 Aug 2026 11:55:57 +0800 Subject: [PATCH] docs(message-gateway): add Wavelet inbound channel gateway design spec --- .../2026-08-16-message-gateway-design.md | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-16-message-gateway-design.md diff --git a/docs/superpowers/specs/2026-08-16-message-gateway-design.md b/docs/superpowers/specs/2026-08-16-message-gateway-design.md new file mode 100644 index 00000000..cd249f3b --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-message-gateway-design.md @@ -0,0 +1,268 @@ +# Message Gateway Design (Wavelet) + +**Date:** 2026-08-16 +**Status:** Approved for implementation planning +**Scope:** Wavelet framework only (sub-project 1 of 3) +**Product:** Wavelet scaffold — reusable inbound/outbound messaging channels + +## 1. Goal + +Give Wavelet a Hermes-style **message gateway**: admins configure Telegram and QQ bots in the admin UI; end users bind their private-chat identity with a one-time short code on the profile page. Inbound private messages become **domain events**. Products (Clipper later) subscribe and decide what to persist. + +This spec does **not** write Clipper `c_items`. That is sub-project 3 after Wavelet is merged into Clipper. + +### In scope (Wavelet) + +- `pkg/message_gateway` + `channel/telegram` + `channel/qq` +- Admin UI `/admin/message-gateway` (empty state, add channel, per-type forms, cards) +- User profile card: bind / list / unbind +- Worker-hosted connections (Telegram long poll, QQ official WebSocket) +- Pairing codes, bindings, encrypted credentials +- Domain event on authorized inbound messages +- Text + image + file inbound/outbound at the adapter layer (v1 private chat only) + +### Out of scope + +- Group / guild / channel / @mention routing +- Hermes sessions, slash commands, STT, cron, circuit-breaker fleet, 20 platforms +- Telegram webhook mode (long poll only) +- Writing business tables (`c_*`) or calling `upload.Ingest` from `pkg/` +- Changing `pkg/push` (outbound notification remains a separate system) +- Multi-tenant hosted bots (`owner_scope=user`) — column exists, v1 only inserts `system` + +### Later sub-projects (not this spec) + +2. Clipper `git fetch upstream && git merge upstream/main` +3. Clipper listener: inbound event → `upload.Ingest` + `c_items` with `source=telegram|qq` + +## 2. Decisions (locked) + +| Topic | Choice | +|---|---| +| Ownership | Instance-level shared bots; users pair to them | +| Pairing | 8-char one-time code, 15 minutes, consumed on success | +| Profile | Show bound platform user ID; user can unbind | +| Surface | Private chat / C2C only; groups ignored + logged | +| Runtime | Worker process starts the gateway; API does not poll | +| Events | Bound inbound → `internal/listener` domain event | +| Telegram SDK | `gopkg.in/telebot.v4` (tucnak/telebot current module) | +| QQ SDK | Official QQ Bot Go SDK (`botgo` / [docs](https://bot.q.qq.com/wiki/develop/gosdk/)) | +| Hermes | Adapter shape only (`Connect`/`Disconnect`/`Send`/inbound event/capabilities) | + +## 3. Architecture + +``` +Admin UI ──CRUD──► API ──w_message_channels──► Worker Gateway + │ +User DM ──telebot / botgo──► Channel adapter ─────────┤ + ▼ + unbound? mint pairing code, reply + bound? emit InboundMessage event + │ +User profile ──bind/unbind──► API ──w_message_bindings / pairing_codes + │ + product listener (Clipper later) +``` + +`pkg/message_gateway` has **no** Gin, GORM, sessions, or `internal/apps` imports. Persistence lives in `internal/repository`. Encryption of credentials uses existing Wavelet secret helpers from apps/repository, not from `pkg`. + +### 3.1 Packages + +``` +pkg/message_gateway/ + types.go # Message, Attachment, Capability, ChannelType + channel.go # Channel interface + registry.go # Register / Lookup + pairing.go # GenerateCode (alphabet, length) — no storage + channel/telegram/ # telebot private chat + channel/qq/ # official botgo C2C +internal/apps/admin/message_gateway/ +internal/apps/message_gateway/ # user bind/unbind +internal/listener/ # InboundMessage event type + dispatch +internal/model/ # channel, binding, pairing row types +internal/repository/ # CRUD +``` + +Frontend: + +``` +frontend/lib/services/message-gateway/ +frontend/app/(main)/admin/message-gateway/ + page.tsx + components/channel-card.tsx + components/add-channel-dialog.tsx + channels/telegram/form.tsx + channels/qq/form.tsx +frontend/components/common/settings/ # profile bind card +``` + +Sidebar admin item: `titleKey: 'messageGateway'`, url `/admin/message-gateway`, placed next to push. + +### 3.2 Channel interface + +```go +type Channel interface { + Type() string + Connect(ctx context.Context) error + Disconnect(ctx context.Context) error + Send(ctx context.Context, to Recipient, msg OutboundMessage) error + Capabilities() Capability +} + +type Capability struct { + Text, Image, File, Reply bool + Group bool // always false in v1 adapters +} + +type InboundMessage struct { + ChannelID uint64 + ChannelType string + PlatformUserID string + ChatID string + MessageID string + Text string + Attachments []Attachment // local temp paths + mime; no upload package + BindingUserID *uint64 // nil if unbound +} + +type Handler func(ctx context.Context, msg InboundMessage) error +``` + +Gateway runner (Worker) constructs adapters from DB rows, calls `Connect`, and registers one `Handler` that implements pairing + event emit. + +### 3.3 Process model + +- `internal/cmd` Worker path: after bootstrap, `messagegateway.Start(ctx)`. +- API process never starts adapters. +- Enable / disable / credential change: Worker watches DB (poll every few seconds or Redis pub/sub already used by the platform). v1 may poll `updated_at` every 5s. Hot-reload only the changed channel. +- Single Worker assumed. If a second Worker starts, it tries a Redis/DB lock keyed by `channel_id` + token fingerprint; failure → skip that channel and log. +- Per-channel reconnect with exponential backoff; one channel crash must not stop others. + +## 4. Data model + +All tables `w_*`. Dual goose SQL (Postgres + SQLite). No physical FKs. + +### 4.1 `w_message_channels` + +| Column | Type | Notes | +|---|---|---| +| id | snowflake | PK | +| name | string | admin display name | +| type | string | `telegram` \| `qq` | +| owner_scope | string | v1 always `system` | +| owner_id | bigint null | reserved; null in v1 | +| enabled | bool | | +| credentials | text | encrypted JSON; never returned raw | +| extra | text | optional JSON (base URL, sandbox host) | +| created_at, updated_at | timestamptz | | + +### 4.2 `w_message_bindings` + +| Column | Type | Notes | +|---|---|---| +| id | snowflake | PK | +| user_id | bigint | Wavelet user | +| channel_id | bigint | | +| platform_user_id | string | Telegram user id / QQ OpenID | +| created_at | timestamptz | | + +Unique `(channel_id, platform_user_id)`. A user may bind many channels; a platform identity binds to at most one user per channel. + +### 4.3 `w_message_pairing_codes` + +| Column | Type | Notes | +|---|---|---| +| code | string | PK, 8 chars | +| channel_id | bigint | | +| platform_user_id | string | | +| expires_at | timestamptz | now + 15m | +| created_at | timestamptz | | + +Delete row on successful bind. Expired rows ignored and cleaned by a small periodic delete (may live in the Worker loop). + +**Code alphabet:** `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (no `0O1I`). Format displayed as `XXXX-XXXX`. + +## 5. Pairing and profile + +1. Unbound private message arrives. +2. Worker upserts a pairing row (if an unexpired code already exists for that pair, reuse it). +3. Bot replies with the code and “Settings → Profile → Bind a bot”. +4. User opens profile card, picks an **enabled** channel, submits the code. +5. API validates, inserts binding, deletes the code. +6. Profile lists bindings: channel name, type, **platform user ID**, unbind button. + +Unauthorized/unbound never emit `InboundMessage` to product listeners. + +## 6. Admin UI and credentials + +Empty state + “Add channel”. Dialog: choose `telegram` | `qq`, then that type’s form. + +| Type | Required | Optional | +|---|---|---| +| telegram | name, bot token | API base URL (default `https://api.telegram.org`) | +| qq | name, App ID, App Secret | sandbox / portal host (default `q.qq.com`) | + +Cards: name, type, enabled toggle, connected/disconnected (best-effort from Worker), edit, delete. Edit never echoes raw secrets; empty secret field means “keep current”. + +Create: validate field shape, optional SDK probe (`getMe` / token), then insert. Probe failure → 400, no row. + +## 7. HTTP API + +Admin (admin middleware), prefix `/api/v1/admin/message-gateway`: + +- `GET /channels` — list (secrets masked) +- `GET /channels/definitions` — form schema per type +- `POST /channels` — create +- `PATCH /channels/:id` — update +- `DELETE /channels/:id` — delete (cascade bindings + pairing rows in logics) +- `POST /channels/:id/test` — optional probe + +User (login required), prefix `/api/v1/message-gateway`: + +- `GET /bindings` — current user’s bindings +- `POST /bindings` — `{ channel_id, code }` +- `DELETE /bindings/:id` — unbind own row only + +Errors via `response.Abort*`. Invalid/expired code → 400. Platform identity already bound → 409. + +## 8. Inbound media and reply + +Adapters accept private-chat **text, images, files**. Voice/STT is out of scope; a voice message is treated as a file attachment if the SDK delivers bytes, otherwise ignored with a log. + +Attachments are written under `t.TempDir()`-equivalent process temp (`os.MkdirTemp`) and passed as filesystem paths on `InboundMessage`. The product listener (Clipper later) must `upload.Ingest` and then delete the temp file. Gateway deletes leftovers older than 1 hour. + +If the product handler returns an error, the bot sends a generic “could not save your message” (no internal error text). Success ACK is a single short reply when `Capabilities().Reply` is true. ACK can be disabled later via extra JSON; v1 always ACKs. + +Group / non-C2C updates: log and drop. Do not mint pairing codes. + +## 9. Domain event + +Name: `message_gateway.inbound` (exact string in `internal/listener`). + +Payload: `InboundMessage` plus `BindingUserID` set. Register in `internal/platform/bootstrap` (no `init()`). Wavelet ships a no-op or log-only listener. Clipper will register the clip writer in sub-project 3. + +## 10. Error handling + +- Adapter panics: recover in the gateway runner, mark channel disconnected, backoff reconnect. +- Media download failure: keep text; attachment entry has `Error` string; still emit if bound. +- Encrypt/decrypt failure: treat channel as disabled, log, do not start adapter. +- i18n: all new UI strings in `zh-CN.json` + `en.json` (`admin.messageGateway`, `settings.botBinding`). + +## 11. Testing and done criteria + +| Gate | Pass | +|---|---| +| Fake channel + pairing | generate, reuse unexpired, expire, consume, conflict 409 | +| Telegram adapter unit | mock telebot updates: text, photo, document; groups dropped | +| QQ adapter unit | mock C2C event; non-C2C dropped | +| API tests | admin CRUD, bind/unbind, bad code | +| `go test ./...` | pass | +| Frontend | `pnpm tsc --noEmit --jsx preserve`, `check-i18n-keys.mjs` | +| Manual | add TG channel → DM gets code → profile bind → second DM emits event in logs | + +## 12. Risks + +- **Official QQ Bot** requires a published/sandbox app at q.qq.com; local dev may only test Telegram. +- **telebot v4 module path** is `gopkg.in/telebot.v4`; pin a version in `go.mod`. +- **One poller per token:** documented; second Worker skips the channel. +- **Token leak in logs:** never log raw credentials or pairing codes at info level (debug only, redacted).