fix(persistence): migrate all pkg/persistence imports to plugins/infra/database and plugins/infra/cache

- Replace db.DB(ctx) with database.DB(ctx) from plugins/infra/database
- Replace db.Redis/db.PrefixedKey/db.GetJSON/db.SetJSON with cachepkg.* from plugins/infra/cache
- Replace pkg/persistence/idgen with pkg/idgen (already exists)
- Replace pkg/persistence/batchwriter with pkg/batchwriter (already exists)
- Replace pkg/persistence/migrator with pkg/migrator (already exists)
- Replace pkg/persistence/logstore with plugins/domain/risk_control/logstore
- Delete defunct pkg/{persistence,cap,message_gateway,push,shared,task}
- Fix vet issues: db alias in domain_test.go, driver_asynq_worker.TaskHandler reference
- Update Makefile architecture guard
- Update docs and skill references
- Update go.mod: gorilla/sessions promotion to direct dependency
This commit is contained in:
ryan
2026-08-28 10:59:24 +08:00
parent fb6a3edb89
commit 416603b616
223 changed files with 1304 additions and 10057 deletions
+1 -2
View File
@@ -141,8 +141,7 @@ Wavelet 是面向未来 5 年生产级云原生与高并发业务中台的 **微
| `w_uploads`<br>`w_upload_stats` | `plugins/domain/upload` | `models/models.go`<br>`repository/repository.go` | `core/contracts.StorageService`<br>`upload.Ingest` 流水线 |
| `w_system_configs`<br>`w_templates` | `plugins/domain/admin` | `models.go`<br>`repository.go` | `ctx.Settings()` / `contracts.ConfigService`<br>Redis Pub/Sub 广播 |
| `w_message_channels`<br>`w_message_bindings`<br>`w_message_pairing_codes`<br>`w_push_events`<br>`w_push_channels`<br>`w_push_histories` | `plugins/domain/message_gateway` | `models.go`<br>`repository.go` | `EventBus` 强类型事件广播订阅 |
| `w_user_access_logs` (分析库/日志库) | `plugins/domain/risk_control` | 委托 `pkg/persistence/logstore` | `logstore.UserAccessLog` 抽象门面 |
| `w_task_executions`<br>`w_schedules` | `pkg/task` & `plugins/drivers` | `pkg/task/types.go` | `ctx.Task()` 与 `ctx.Schedule()` 扩展点 |
| `w_task_executions`<br>`w_schedules` | `plugins/drivers/driver_asynq_*` | `types.go`<br>`schedule.go` | `ctx.Task()` 与 `ctx.Schedule()` 扩展点 |
### 5.3 架构防线与单向依赖保障
1. **测试脚手架绝对解耦**:底层通用的 `pkg/testhelper` 严禁反向引用任何上层业务插件。`testhelper` 维护轻量自包含的测试表脚手架,彻底杜绝包导入循环(Import Cycle)。
@@ -1,650 +0,0 @@
# Message Gateway Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship Wavelet’s inbound message gateway (Telegram + QQ private chat), admin channel cards, profile pairing, and `message_gateway.inbound` events — no Clipper clip ingest.
**Architecture:** `pkg/message_gateway` defines Channel/Registry/pairing with zero Gin/GORM. Worker runs adapters (telebot long poll, official botgo C2C). API does CRUD and bind/unbind. Bound inbound messages emit `listener.EmitMessageGatewayInbound`.
**Tech Stack:** Go 1.25, Gin, GORM, goose, `gopkg.in/telebot.v4`, official QQ `botgo`, Next.js, next-intl.
**Spec:** `docs/superpowers/specs/2026-08-16-message-gateway-design.md`
## Global Constraints
- Wavelet repo only. Do not edit Clipper.
- `pkg/message_gateway` must not import Gin, GORM, sessions, or `internal/apps`.
- Tables `w_*`. No physical FKs. Dual PG + SQLite goose.
- API errors only `response.Abort*`.
- No `init()` cross-module registration; wire in `internal/platform/bootstrap` and `internal/cmd`.
- Routes only via `internal/router/v1`.
- v1: private chat / C2C only; groups logged and dropped.
- Pairing: alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`, length 8, display `XXXX-XXXX`, TTL 15 minutes, one-time.
- Event name string is exactly `message_gateway.inbound`.
- Channel types: `telegram`, `qq`. `owner_scope` always `system` in v1.
- Encrypt credentials with `pkg/util.Encrypt` using `hex.EncodeToString(sha256(session_secret))` as the 64-char key.
- After handlers: `make swagger`. After code: `make format` and `make code-check`.
- Commits: Conventional Commits.
- Tests: `t.TempDir()` only.
- Execution: `superpowers:using-git-worktrees` in Wavelet.
## File map
| Path | Responsibility |
| --- | --- |
| `pkg/message_gateway/types.go` | Capability, Attachment, InboundMessage, OutboundMessage, Recipient |
| `pkg/message_gateway/channel.go` | `Channel` interface, `Handler` |
| `pkg/message_gateway/registry.go` | Register / Lookup factories |
| `pkg/message_gateway/pairing.go` | `GenerateCode`, `NormalizeCode`, `FormatCode` |
| `pkg/message_gateway/channel/telegram` | telebot private-chat adapter |
| `pkg/message_gateway/channel/qq` | botgo C2C adapter |
| `internal/model/message_gateway.go` | GORM models |
| goose `202608160003_create_message_gateway.sql` | PG + SQLite |
| `internal/repository/message_gateway.go` | persistence |
| `internal/listener/message_gateway.go` | event + emit |
| `internal/apps/message_gateway` | user bind logics + handlers |
| `internal/apps/admin/message_gateway` | admin CRUD |
| `internal/apps/message_gateway/runner` | Worker Start / reload / inbound handler |
| `internal/cmd/worker.go` | start runner before Asynq |
| `internal/router/v1/admin.go` + new files | HTTP routes |
| frontend admin + profile + i18n | UI |
---
### Task 1: Pairing helpers and Channel types
**Files:**
- Create: `pkg/message_gateway/types.go`
- Create: `pkg/message_gateway/channel.go`
- Create: `pkg/message_gateway/registry.go`
- Create: `pkg/message_gateway/pairing.go`
- Test: `pkg/message_gateway/pairing_test.go`
- Test: `pkg/message_gateway/registry_test.go`
**Interfaces:**
- Produces: `GenerateCode() (string, error)` returns 8 chars from the alphabet; `NormalizeCode(s string) string` strips `-` and uppercases; `FormatCode(s string) string` → `XXXX-XXXX`; `Register(typ string, fn Factory)`; `Lookup(typ string) (Factory, bool)`; types `Channel`, `Capability`, `InboundMessage`, `OutboundMessage`, `Recipient`, `Attachment`, `Handler`
- [ ] **Step 1: Write pairing tests**
```go
func TestGenerateCode_AlphabetAndLength(t *testing.T) {
code, err := GenerateCode()
if err != nil {
t.Fatal(err)
}
if len(code) != 8 {
t.Fatalf("len=%d", len(code))
}
for _, r := range code {
if !strings.ContainsRune(CodeAlphabet, r) {
t.Fatalf("bad rune %q", r)
}
}
}
func TestNormalizeAndFormat(t *testing.T) {
if got := NormalizeCode("ab-cd-ef-gh"); got != "ABCDEFGH" {
t.Fatalf("got %q", got)
}
if got := FormatCode("ABCDEFGH"); got != "ABCD-EFGH" {
t.Fatalf("got %q", got)
}
}
```
- [ ] **Step 2: Run — must fail compile**
```bash
go test ./pkg/message_gateway -count=1
```
Expected: undefined `GenerateCode`.
- [ ] **Step 3: Implement types, pairing, registry**
`pairing.go`: `const CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"`, `CodeLength = 8`. `GenerateCode` reads `crypto/rand` indexes.
`channel.go`:
```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 Handler func(ctx context.Context, msg InboundMessage) error
type Factory func(cfg ChannelConfig, onInbound Handler) (Channel, error)
```
`ChannelConfig` in types: `ID uint64`, `Type string`, `Name string`, `Credentials map[string]string`, `Extra map[string]string`.
`Attachment`: `Path, FileName, MIME, Error string`.
Registry: mutex map[string]Factory.
- [ ] **Step 4: Tests pass**
```bash
go test ./pkg/message_gateway -count=1
```
- [ ] **Step 5: Commit**
```bash
git add pkg/message_gateway
git commit -m "feat(message-gateway): add channel types, registry, and pairing codes"
```
---
### Task 2: Models and goose migrations
**Files:**
- Create: `internal/model/message_gateway.go`
- Create: `internal/infra/persistence/migrator/goose/postgres/202608160003_create_message_gateway.sql`
- Create: `internal/infra/persistence/migrator/goose/sqlite/202608160003_create_message_gateway.sql`
- Modify: `internal/testhelper/test_helper.go` AutoMigrate list
- Modify: `internal/infra/persistence/migrator/migrator_test.go` only if it asserts table counts that break (do not change `w_system_configs` count)
**Interfaces:**
- Produces: `model.MessageChannel`, `model.MessageBinding`, `model.MessagePairingCode` with `TableName()` `w_message_channels`, `w_message_bindings`, `w_message_pairing_codes`
- Constants: `MessageChannelTypeTelegram = "telegram"`, `MessageChannelTypeQQ = "qq"`, `MessageOwnerScopeSystem = "system"`
- [ ] **Step 1: Add models**
```go
type MessageChannel struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:128;not null"`
Type string `json:"type" gorm:"size:32;not null;index"`
OwnerScope string `json:"owner_scope" gorm:"size:16;not null;default:system"`
OwnerID *uint64 `json:"owner_id,string"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
Credentials string `json:"-" gorm:"type:text"`
Extra string `json:"extra" gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
}
```
Binding: `UserID`, `ChannelID`, `PlatformUserID`. Pairing: `Code` PK, `ChannelID`, `PlatformUserID`, `ExpiresAt`.
- [ ] **Step 2: Goose SQL (both dialects)**
Postgres up:
```sql
CREATE TABLE IF NOT EXISTS w_message_channels (
id BIGINT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
type VARCHAR(32) NOT NULL,
owner_scope VARCHAR(16) NOT NULL DEFAULT 'system',
owner_id BIGINT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
credentials TEXT NOT NULL DEFAULT '',
extra TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_message_channels_type ON w_message_channels (type);
CREATE TABLE IF NOT EXISTS w_message_bindings (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL,
platform_user_id VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_w_message_bindings_channel_platform
ON w_message_bindings (channel_id, platform_user_id);
CREATE INDEX IF NOT EXISTS idx_w_message_bindings_user ON w_message_bindings (user_id);
CREATE TABLE IF NOT EXISTS w_message_pairing_codes (
code VARCHAR(16) PRIMARY KEY,
channel_id BIGINT NOT NULL,
platform_user_id VARCHAR(128) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_message_pairing_lookup
ON w_message_pairing_codes (channel_id, platform_user_id);
```
SQLite twin: `INTEGER`/`TEXT`/`DATETIME` as in existing Wavelet sqlite migrations. Down: drop the three tables.
- [ ] **Step 3: AutoMigrate the three models in testhelper**
- [ ] **Step 4:**
```bash
go test ./internal/model ./internal/infra/persistence/migrator ./internal/testhelper -count=1
```
Expected: PASS (migrator applies `202608160003`).
- [ ] **Step 5: Commit**
```bash
git add internal/model/message_gateway.go \
internal/infra/persistence/migrator/goose \
internal/testhelper/test_helper.go
git commit -m "feat(message-gateway): add w_message_* models and goose migrations"
```
---
### Task 3: Repository
**Files:**
- Create: `internal/repository/message_gateway.go`
- Test: `internal/repository/message_gateway_test.go`
**Interfaces:**
- Consumes: models from Task 2, `db.DB(ctx)` from `internal/infra/persistence`
- Produces:
- `CreateMessageChannel`, `UpdateMessageChannel`, `GetMessageChannel`, `ListMessageChannels`, `DeleteMessageChannel`
- `CreateMessageBinding`, `GetBindingByChannelPlatform(ctx, channelID, platformUserID)`, `ListBindingsByUser`, `DeleteMessageBinding`
- `UpsertPairingCode` (reuse unexpired row for same channel+platform user), `GetPairingCode`, `DeletePairingCode`, `DeleteExpiredPairingCodes`
- [ ] **Step 1: Test upsert reuses unexpired code**
```go
func TestUpsertPairingCode_ReusesUnexpired(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
ctx := context.Background()
first, err := repository.UpsertPairingCode(ctx, 1, "tg-1", "ABCD1234", time.Now().Add(15*time.Minute))
if err != nil { t.Fatal(err) }
second, err := repository.UpsertPairingCode(ctx, 1, "tg-1", "ZZZZ9999", time.Now().Add(15*time.Minute))
if err != nil { t.Fatal(err) }
if first.Code != second.Code || first.Code != "ABCD1234" {
t.Fatalf("reuse failed: %+v %+v", first, second)
}
}
```
- [ ] **Step 2: Run — fail (undefined)**
```bash
go test ./internal/repository -count=1 -run Pairing
```
- [ ] **Step 3: Implement repository using `db.DB(ctx)` only**
`UpsertPairingCode`: `First` where `channel_id` and `platform_user_id` and `expires_at > now`; if found return it; else create with provided code.
`DeleteMessageChannel`: delete pairings and bindings for that channel then the channel (transaction).
- [ ] **Step 4: Tests pass**
- [ ] **Step 5: Commit**
```bash
git add internal/repository/message_gateway.go internal/repository/message_gateway_test.go
git commit -m "feat(message-gateway): add channel, binding, and pairing repositories"
```
---
### Task 4: Domain event
**Files:**
- Create: `internal/listener/message_gateway.go`
- Test: `internal/listener/message_gateway_test.go`
- Modify: `internal/platform/bootstrap/bootstrap.go` — register a log-only handler in `RegisterPushDomainEvents` sibling `RegisterMessageGatewayListeners` (`sync.Once`)
**Interfaces:**
- Produces: `const EventMessageGatewayInbound = "message_gateway.inbound"`
- `type MessageGatewayInbound struct { Msg message_gateway.InboundMessage }`
- `OnMessageGatewayInbound(fn)`
- `EmitMessageGatewayInbound(ctx, msg)` — skip if `msg.BindingUserID == nil`
- [ ] **Step 1: Write emit test**
```go
func TestEmitMessageGatewayInbound_SkipsUnbound(t *testing.T) {
called := 0
OnMessageGatewayInbound(func(ctx context.Context, ev MessageGatewayInbound) { called++ })
EmitMessageGatewayInbound(context.Background(), message_gateway.InboundMessage{Text: "x"})
if called != 0 {
t.Fatal("unbound must not emit")
}
uid := uint64(9)
EmitMessageGatewayInbound(context.Background(), message_gateway.InboundMessage{BindingUserID: &uid, Text: "x"})
if called != 1 {
t.Fatalf("called=%d", called)
}
}
```
- [ ] **Step 2: Run — fail compile**
- [ ] **Step 3: Implement listener + bootstrap log-only handler**
- [ ] **Step 4: Test pass**
- [ ] **Step 5: Commit**
```bash
git commit -m "feat(message-gateway): emit message_gateway.inbound domain events"
```
---
### Task 5: Telegram adapter (private chat)
**Files:**
- Create: `pkg/message_gateway/channel/telegram/adapter.go`
- Test: `pkg/message_gateway/channel/telegram/adapter_test.go`
**Interfaces:**
- Consumes: `ChannelConfig`, `Handler`
- Produces: `func New(cfg ChannelConfig, onInbound Handler) (message_gateway.Channel, error)`
- Credentials key `bot_token`; extra key `base_url`
- `Type() == "telegram"`
- `Capabilities`: Text, Image, File, Reply true; Group false
- [ ] **Step 1: Test group updates are dropped (inject a `handleUpdate` method)**
Export `HandlePrivate` for tests:
```go
func TestHandleUpdate_DropsGroups(t *testing.T) {
var got int
a := &Adapter{onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
got++
return nil
}}
a.handleTeleMessage(fakeGroupMessage())
if got != 0 {
t.Fatalf("group must be ignored")
}
}
func TestHandleUpdate_PrivateText(t *testing.T) {
var got message_gateway.InboundMessage
a := &Adapter{cfg: message_gateway.ChannelConfig{ID: 7, Type: "telegram"}, onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
got = msg
return nil
}}
a.handleTeleMessage(fakePrivateText("hi", 42))
if got.Text != "hi" || got.PlatformUserID != "42" || got.ChannelID != 7 {
t.Fatalf("%+v", got)
}
}
```
Build fakes as small structs / telebot.Message with `Chat.Type = telebot.ChatPrivate` vs `ChatGroup`.
- [ ] **Step 2: Run — fail**
- [ ] **Step 3: Implement with `gopkg.in/telebot.v4`**
`Connect`: `telebot.NewBot(Settings{Token, URL: extra base_url, Poller: &telebot.LongPoller{Timeout: 10s}})`, handle `OnText`, `OnPhoto`, `OnDocument` only if private. Download media to `os.MkdirTemp("wg-tg-*")`. `Disconnect`: `bot.Stop()`.
Register factory in `New` via `message_gateway.Register("telegram", New)` from **apps runner**, not `init()` in pkg if that violates bootstrap rule. Spec said no `init()` for **cross-module** integration. Registering a factory in `telegram` package `init` is OK **or** register explicitly in runner. Prefer **explicit register in runner** to keep pkg side-effect free.
- [ ] **Step 4: `go test ./pkg/message_gateway/channel/telegram -count=1`**
- [ ] **Step 5: Commit**
```bash
git commit -m "feat(message-gateway): add Telegram private-chat telebot adapter"
```
Add `gopkg.in/telebot.v4` with `go get`.
---
### Task 6: QQ adapter (C2C only)
**Files:**
- Create: `pkg/message_gateway/channel/qq/adapter.go`
- Test: `pkg/message_gateway/channel/qq/adapter_test.go`
**Interfaces:**
- Same `New(cfg, onInbound)`
- Credentials: `app_id`, `app_secret`
- Extra: `portal_host` default `q.qq.com`
- Drop non-C2C events in a testable `handleC2C` function
- [ ] **Step 1: Write C2C-only tests**
```go
func TestHandleEvent_DropsNonC2C(t *testing.T) {
var got int
a := &Adapter{onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
got++
return nil
}}
a.handleEvent(qqEvent{Kind: "group", UserID: "u1", Text: "hi"})
if got != 0 {
t.Fatal("non-C2C must be ignored")
}
}
func TestHandleEvent_C2CText(t *testing.T) {
var got message_gateway.InboundMessage
a := &Adapter{cfg: message_gateway.ChannelConfig{ID: 3}, onInbound: func(ctx context.Context, msg message_gateway.InboundMessage) error {
got = msg
return nil
}}
a.handleEvent(qqEvent{Kind: "c2c", UserID: "openid-1", Text: "hello", MessageID: "m1"})
if got.Text != "hello" || got.PlatformUserID != "openid-1" || got.ChannelID != 3 {
t.Fatalf("%+v", got)
}
}
```
Define a local `qqEvent` in the adapter file so tests do not need a live gateway. `Connect` uses official `github.com/tencent-connect/botgo` (or the module path from https://bot.q.qq.com/wiki/develop/gosdk/); pin the resolved module in `go.mod` and name it in the commit body. `Connect` enables C2C intent only. `Send` uses C2C REST.
- [ ] **Step 2: `go test ./pkg/message_gateway/channel/qq -count=1` — fail compile**
- [ ] **Step 3: Implement adapter + `New`**
- [ ] **Step 4: Tests pass**
- [ ] **Step 5: Commit**
```bash
git commit -m "feat(message-gateway): add QQ official C2C botgo adapter"
```
---
### Task 7: Worker gateway runner
**Files:**
- Create: `internal/apps/message_gateway/runner/runner.go`
- Create: `internal/apps/message_gateway/runner/inbound.go`
- Test: `internal/apps/message_gateway/runner/inbound_test.go`
- Modify: `internal/cmd/worker.go` start `runner.Start` in a goroutine **before** `worker.StartWorker()`
- Modify: `internal/platform/bootstrap` if needed to register factories
**Interfaces:**
- Consumes: repository, listener, telegram.New, qq.New, pairing.GenerateCode
- Produces: `Start(ctx context.Context) error` — load enabled channels, lock, Connect; poll `updated_at` every 5s and reload changed IDs
- Inbound handler:
1. Lookup binding by channel+platform user
2. If none: `UpsertPairingCode` + `ch.Send` formatted code + bind instructions
3. If bound: set `BindingUserID`, `EmitMessageGatewayInbound`; on emit error send “could not save”; on success send “received”
- [ ] **Step 1: Test inbound unbound mints/reuses code and does not emit**
Use a `fakeChannel` implementing `Channel` that records `Send` calls, and stub repository via a small `inboundService` struct with function fields so the test does not need a live bot.
```go
type inboundDeps struct {
LookupBinding func(...) (*model.MessageBinding, error)
UpsertCode func(...) (*model.MessagePairingCode, error)
Emit func(context.Context, message_gateway.InboundMessage)
Send func(context.Context, Recipient, OutboundMessage) error
}
func (d inboundDeps) Handle(ctx context.Context, msg message_gateway.InboundMessage) error
```
- [ ] **Step 2–4: Implement Handle + Start**
Credential decrypt: `key := hex.EncodeToString(sum[:])` where `sum := sha256.Sum256([]byte(config.Config.App.SessionSecret))`. If decrypt fails, skip channel and log.
Lock: Redis `SET wg:channel:{id} {node} NX EX 30` refreshed while running; if Redis disabled, start anyway (single-process dev).
- [ ] **Step 5: Commit**
```bash
git commit -m "feat(message-gateway): run adapters on worker and handle pairing inbound"
```
---
### Task 8: Admin HTTP API
**Files:**
- Create: `internal/apps/admin/message_gateway/errs.go`
- Create: `internal/apps/admin/message_gateway/logics.go`
- Create: `internal/apps/admin/message_gateway/handlers.go`
- Create: `internal/apps/admin/message_gateway/routers.go`
- Test: `internal/apps/admin/message_gateway/logics_test.go`
- Modify: `internal/router/v1/admin.go` — `registerAdminMessageGatewayRoutes` under “Messaging”
- Create: `internal/router/v1/message_gateway_admin.go` calling `adminmsg.RegisterRoutes`
**Interfaces:**
- `GET /api/v1/admin/message-gateway/channels`
- `GET /api/v1/admin/message-gateway/channels/definitions`
- `POST /api/v1/admin/message-gateway/channels` body `{name,type,enabled,bot_token|app_id,app_secret,base_url,portal_host}`
- `PATCH /api/v1/admin/message-gateway/channels/:id` empty secrets keep previous
- `DELETE /api/v1/admin/message-gateway/channels/:id`
- `POST /api/v1/admin/message-gateway/channels/:id/test` optional getMe/token probe
- List DTO masks secrets as `********` if non-empty
Definitions JSON:
```go
[]Definition{
{Type:"telegram", Name:"Telegram", Fields:[]Field{{Key:"bot_token", Type:"password", Required:true}, {Key:"base_url", Type:"text"}}},
{Type:"qq", Name:"QQ", Fields:[]Field{{Key:"app_id", Required:true}, {Key:"app_secret", Type:"password", Required:true}, {Key:"portal_host", Type:"text"}}},
}
```
- [ ] **Step 1: Test create telegram without token fails; create with token stores ciphertext not plaintext**
- [ ] **Step 2–4: Implement logics + Abort\* handlers + swagger comments**
- [ ] **Step 5:** `make swagger` then commit
```bash
git commit -m "feat(message-gateway): add admin channel CRUD APIs"
```
---
### Task 9: User bind/unbind API
**Files:**
- Create: `internal/apps/message_gateway/errs.go`
- Create: `internal/apps/message_gateway/logics.go`
- Create: `internal/apps/message_gateway/handlers.go`
- Create: `internal/apps/message_gateway/routers.go`
- Test: `internal/apps/message_gateway/logics_test.go`
- Modify: `internal/router/v1/v1.go` call `RegisterMessageGatewayUserRoutes`
- Create: `internal/router/v1/message_gateway.go`
**Interfaces:**
- `GET /api/v1/message-gateway/bindings` — current user, include channel name/type
- `POST /api/v1/message-gateway/bindings` `{ "channel_id": "...", "code": "ABCD-EFGH" }`
- `DELETE /api/v1/message-gateway/bindings/:id`
- Login required. Bind: `NormalizeCode`, `GetPairingCode`, check expiry and `channel_id`, `GetBindingByChannelPlatform` → 409 if other user, insert binding, delete code.
- Unbind: only if `binding.UserID == current`.
- [ ] **Step 1–4: TDD expired code 400; happy path deletes code**
- [ ] **Step 5: `make swagger` + commit**
```bash
git commit -m "feat(message-gateway): add user bind and unbind APIs"
```
---
### Task 10: Frontend service + admin page
**Files:**
- Create: `frontend/lib/services/message-gateway/types.ts`
- Create: `frontend/lib/services/message-gateway/admin.service.ts`
- Create: `frontend/lib/services/message-gateway/index.ts`
- Modify: `frontend/lib/services/index.ts` export
- Create: `frontend/app/(main)/admin/message-gateway/page.tsx`
- Create: `frontend/app/(main)/admin/message-gateway/components/channel-card.tsx`
- Create: `frontend/app/(main)/admin/message-gateway/components/add-channel-dialog.tsx`
- Create: `frontend/app/(main)/admin/message-gateway/channels/telegram/form.tsx`
- Create: `frontend/app/(main)/admin/message-gateway/channels/qq/form.tsx`
- Modify: `frontend/components/layout/sidebar.tsx` admin item after push
- Modify: `frontend/messages/zh-CN.json` + `en.json` namespace `admin.messageGateway`
**Interfaces:**
- `AdminMessageGatewayService.list/create/update/remove/definitions`
- Empty state + add dialog: type select then `<TelegramForm>` or `<QQForm>`
- Card: name, type, enabled switch, delete
- `useTranslations('admin.messageGateway')` — no hardcoded UI strings
- [ ] **Step 1: Add i18n keys to zh-CN only, run `node frontend/scripts/check-i18n-keys.mjs` — fail**
- [ ] **Step 2: Add matching en keys — checker pass**
- [ ] **Step 3: Implement pages (follow `/admin/push` layout: `w-full py-6`, h1 `text-2xl font-semibold tracking-tight`)**
- [ ] **Step 4:** `cd frontend && pnpm tsc --noEmit --jsx preserve`
- [ ] **Step 5: Commit**
```bash
git commit -m "feat(message-gateway): add admin channel cards and per-type forms"
```
---
### Task 11: Profile bind card
**Files:**
- Create: `frontend/lib/services/message-gateway/user.service.ts`
- Create: `frontend/components/common/settings/bot-binding-card.tsx`
- Modify: `frontend/components/common/settings/profile.tsx` render the card
- Modify: i18n `settings.botBinding`
**Interfaces:**
- List bindings (channel name, type, platform_user_id) + unbind
- Bind dialog: select enabled channel + code input
- All copy via next-intl
- [ ] **Step 1–4: keys + component + typecheck**
- [ ] **Step 5: Commit**
```bash
git commit -m "feat(message-gateway): add profile bot pairing card"
```
---
### Task 12: Repo-wide gates
**Files:** swagger `docs/*` if not already committed
- [ ] **Step 1:** `go test ./... -count=1`
- [ ] **Step 2:** `make swagger && make format && node frontend/scripts/check-i18n-keys.mjs && make code-check`
- [ ] **Step 3:** Confirm `RegisterMessageGatewayUserRoutes` in `v1.go` and admin routes exist; no `init()` in apps
- [ ] **Step 4: Commit leftover format/swagger**
```bash
git commit -m "chore(message-gateway): swagger and format"
```
---
## Spec coverage
| Spec | Task |
|---|---|
| pkg types / pairing / registry | 1 |
| tables | 2 |
| repository | 3 |
| domain event | 4 |
| telegram / qq adapters | 5–6 |
| worker runner + pairing inbound | 7 |
| admin API + UI | 8, 10 |
| user bind + profile | 9, 11 |
| gates | 12 |
| Clipper ingest | **not in this plan** |
@@ -1,253 +0,0 @@
# Frontend i18n Design
Date: 2026-07-24
Status: Approved for implementation planning
Scope: Frontend UI only
## 1. Goals
Add bilingual UI support for Wavelet frontend:
- Languages: `zh-CN` and `en`
- Default locale: `zh-CN`
- Locale resolution: explicit user choice → browser language → default
- Phase 1: infrastructure + core paths only (layout / auth / settings)
- Must remain compatible with `NEXT_STANDALONE_EXPORT` static export
### Non-goals (Phase 1)
- Backend API error / message localization
- Email / push notification localization
- URL locale prefixes (`/en/...`, `/zh-CN/...`) and SEO hreflang
- Full translation of all admin business pages
## 2. Context
Current state:
- Root layout hardcodes `lang='zh-CN'`
- UI copy is mostly Chinese string literals across many TSX files
- Date formatting often hardcodes `zh-CN` / `date-fns/locale` `zhCN`
- No i18n library is installed
- Frontend supports both normal Next rewrites mode and static export embed mode
## 3. Approach
Use **next-intl in non-routing / provider mode**.
Why this approach:
- Mature App Router integration and clear `useTranslations` API
- ICU message format ready when needed
- Avoids locale-prefixed routing, which conflicts with static-export simplicity and current route structure
- Cookie + browser detection matches product preference without SEO path requirements
Rejected alternatives:
- Fully custom Context + JSON: lower dependency cost, but reimplements interpolation/plurals/type safety poorly
- `i18next` + `react-i18next`: powerful, but heavier and less natural for this Next App Router setup
## 4. Architecture
```
RootLayout
html lang={locale}
ThemeProvider
CustomThemeProvider
AppQueryProvider
NextIntlClientProvider(locale, messages)
existing User / Notification / Bell providers
pages + components
```
### Key files
| Path | Responsibility |
| --- | --- |
| `frontend/i18n/config.ts` | Supported locales, default locale, cookie name, normalize helpers |
| `frontend/i18n/request.ts` | `getRequestConfig` for server-side locale/messages resolution when not in export mode |
| `frontend/i18n/client.ts` | Client helpers to read/write locale preference |
| `frontend/messages/zh-CN.json` | Chinese messages |
| `frontend/messages/en.json` | English messages |
| `frontend/components/common/language-switcher.tsx` (or under `layout/`) | Language switch UI |
| `frontend/lib/i18n-format.ts` (optional location under `i18n/`) | Locale-aware date/number formatting helpers |
### Runtime flow
1. Resolve locale: cookie `NEXT_LOCALE` → browser languages → `zh-CN`
2. Load `messages/{locale}.json`
3. Provide locale + messages through `NextIntlClientProvider`
4. Components call `useTranslations('<namespace>')`
5. Language switcher writes cookie and refreshes locale/messages
6. Update `document.documentElement.lang`
## 5. Locale Resolution
Supported locales: `zh-CN`, `en`
Normalization:
- `zh`, `zh-CN`, `zh-Hans*` → `zh-CN`
- `en`, `en-US`, `en-GB`, other `en-*` → `en`
- anything else → `zh-CN`
Priority:
1. User explicit choice stored in cookie `NEXT_LOCALE`
2. Browser language (`Accept-Language` on server, `navigator.languages` on client)
3. Default `zh-CN`
Invalid cookie values are normalized to a supported locale and may be rewritten to a valid value.
## 6. Static Export Compatibility
Constraints:
- No locale-segment routes
- No middleware-based locale rewriting required for correctness
- `build:embed` (`NEXT_STANDALONE_EXPORT=true`) must continue to work
Behavior:
- **Normal SSR/dev**: resolve locale on server when possible to reduce first-paint language flash
- **Static export**: ship both message catalogs; resolve on client from cookie/browser; accept a brief default-language flash similar to theme hydration, using existing `suppressHydrationWarning` patterns where needed
## 7. Message Organization
Single catalog files with nested namespaces:
```json
{
"common": {
"save": "保存",
"cancel": "取消",
"loading": "加载中..."
},
"layout": {
"nav": {
"home": "首页",
"myFiles": "我的文件"
},
"userMenu": {
"settings": "设置",
"logout": "退出登录"
}
},
"auth": {
"login": {
"title": "登录",
"submit": "登录"
}
},
"settings": {
"appearance": {
"language": "语言",
"languageDesc": "选择界面显示语言"
}
}
}
```
Conventions:
- Keys use camelCase and hierarchical grouping
- Prefer complete phrases as values; avoid assembling sentences in components
- Use ICU only when needed (`{name}`, plural forms)
- Backend `error_msg` values are shown as-is in Phase 1
- Frontend-owned toast / validation copy is translated
Both locale files must keep the same key tree. A key-alignment check script is recommended.
## 8. Language Switcher UX
Placement:
- Header toolbar near theme controls
- Appearance settings page as an explicit preference row
UI labels for language options use native names and do not themselves translate:
- `中文`
- `English`
On change:
1. Persist `NEXT_LOCALE`
2. Apply new locale/messages (via refresh or controlled provider update)
3. Sync `document.documentElement.lang`
4. Preserve unrelated UI state where practical (theme, auth session, sidebar collapse)
## 9. Phase 1 Migration Scope
### In scope
- Install and wire `next-intl`
- Message catalogs for core namespaces
- Locale resolution + persistence
- `LanguageSwitcher`
- Translate:
- layout shell: sidebar nav/user menu, header accessible labels / titles
- auth: login / register / OTP labels, buttons, validation messages
- settings: appearance (including language preference), profile, security, notifications, access-token visible copy
- Replace date/number hardcoding only where touched by the above paths
- Ensure `html lang` reflects active locale
### Out of scope
- Remaining admin pages and deep business modules
- Backend localization
- Route prefixing / SEO alternate links
Unmigrated pages may remain Chinese hard-coded; mixed-language UI is acceptable during incremental rollout.
## 10. Formatting Helpers
Introduce locale-aware helpers for dates/numbers used by migrated surfaces, e.g.:
- `formatDateTime(value, locale)`
- `formatNumber(value, locale)`
`date-fns` locale objects should follow active locale (`zhCN` / `enUS`) when a migrated component uses them.
## 11. Error Handling & Fallbacks
| Case | Behavior |
| --- | --- |
| Missing message key | Dev warning; do not crash; show key or fallback language value |
| Unsupported cookie locale | Normalize to supported locale / default |
| Partial migration | Keep hard-coded Chinese on unmigrated screens |
| Backend error strings | Display raw `error_msg` |
## 12. Testing & Acceptance
Manual:
1. No cookie + browser Chinese → Chinese UI
2. No cookie + browser English → English UI
3. Manual switch to English survives refresh
4. Manual switch back to Chinese survives refresh
5. Core paths (layout/auth/settings) have no major residual hard-coded Chinese UI copy
6. `pnpm build` and `pnpm build:embed` both succeed
7. Language switch does not break theme, session, or sidebar state
Automated (recommended):
- Unit tests for `normalizeLocale` / resolution priority
- Script or test asserting `zh-CN.json` and `en.json` key parity
## 13. Rollout Plan (high level)
1. Add i18n infrastructure and empty/core message files
2. Mount provider and language switcher
3. Migrate layout shell copy
4. Migrate auth copy
5. Migrate settings copy + appearance language control
6. Verify SSR and static-export builds
7. Document how later pages should adopt `useTranslations`
## 14. Open Implementation Notes
- Prefer cookie name `NEXT_LOCALE` unless an existing project cookie convention conflicts during implementation
- Prefer minimal surface-area integration with next-intl; avoid introducing locale-based routing APIs that break static export
- Keep `internal/util` and backend packages untouched
- After implementation, follow repo frontend conventions and existing provider composition style
@@ -1,268 +0,0 @@
# 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).