Files
OpenFlare/docs/superpowers/specs/2026-08-16-message-gateway-design.md
T

269 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).