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

11 KiB
Raw Blame History

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)

  1. Clipper git fetch upstream && git merge upstream/main
  2. 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)
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

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).