# SafeW Chat List 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:** Add two admin APIs that return SafeW groups already seen via `getUpdates`, persisted in Redis, with a per-token background long-poller (`timeout=30`). **Architecture:** Parse group chats from `getUpdates`. Redis Hash keyed by `sha256(token)` stores chats and offset. List handlers drain with `timeout=0` then return Redis. A lazy in-process Watcher long-polls `timeout=30`. Same token is serialized with a Redis lock. **Tech Stack:** Go 1.22+, gin, go-redis, stdlib `net/http/httptest`. No new dependencies. No real bot tokens in repo. ## Global Constraints - Endpoints: `POST /api/v1/channels/safew/chats`, `GET /api/v1/channels/:id/chats?q=` - Admin Bearer auth (existing middleware) - `id` is always a JSON string; never decode chat id as `float64` - Default types: `group` and `supergroup` only - Redis keys: `safew:chats:{sha256hex}`, `safew:offset:{sha256hex}`, `safew:poll:{sha256hex}` — token never in the key - List path uses `getUpdates` `timeout=0`; Watcher uses `timeout=30` - Empty list is `200` `{"data":[],"total":0}` - Invalid token: HTTP `401` with SafeW `description` - Do not commit unless the user explicitly asks - Do not write the user's bot token into any file --- ## File Structure | File | Responsibility | |------|----------------| | `internal/adapter/safew.go` | `SafewChat`, parse updates, `PollGroupChats`, `FilterSafewChats`, `SafewAuthError` | | `internal/adapter/safew_chats_test.go` | Parse / filter / poll unit tests | | `internal/cache/safew_chats.go` | Token hash, Redis merge/list/offset/lock | | `internal/safew/store.go` | `ChatStore` interface + memory fake for tests | | `internal/safew/watcher.go` | Per-token goroutine, `Ensure` / `Stop` / `Refresh` / `List` | | `internal/safew/watcher_test.go` | Watcher + Refresh with fake store + fake poller | | `internal/handler/channel.go` | Two HTTP handlers | | `internal/handler/channel_chats_test.go` | POST missing token / fake list; GET type/token checks | | `cmd/server/main.go` | Routes + Watcher wiring + shutdown Stop | | `README.md` / `docs/httpie/curls.md` | API docs | --- ### Task 1: Parse updates and filter chats **Files:** - Modify: `internal/adapter/safew.go` - Create: `internal/adapter/safew_chats_test.go` **Interfaces:** - Produces: - `type SafewChat struct { ID string; Type string; Title string; Username *string }` with JSON tags `id,type,title,username` - `func GroupsFromUpdates(raw []byte) ([]SafewChat, int64, error)` — `raw` is the full getUpdates JSON body (`{"ok":true,"result":[...]}`); returns unique groups and max `update_id` (0 if none) - `func FilterSafewChats(chats []SafewChat, q string) []SafewChat` - [ ] **Step 1: Write the failing tests** Create `internal/adapter/safew_chats_test.go`: ```go package adapter import ( "encoding/json" "testing" ) func TestGroupsFromUpdatesKeepsGroupsDropsPrivate(t *testing.T) { body := []byte(`{ "ok": true, "result": [ {"update_id": 100000001, "message": {"chat": {"id": 10000778141, "type": "group", "title": "测试AI"}}}, {"update_id": 100000002, "message": {"chat": {"id": 11, "type": "private", "first_name": "u"}}}, {"update_id": 100000003, "my_chat_member": {"chat": {"id": 22, "type": "supergroup", "title": "SG", "username": "sg_name"}}} ] }`) chats, maxID, err := GroupsFromUpdates(body) if err != nil { t.Fatal(err) } if maxID != 100000003 { t.Fatalf("maxID=%d", maxID) } if len(chats) != 2 { t.Fatalf("len=%d want 2: %#v", len(chats), chats) } byID := map[string]SafewChat{} for _, c := range chats { byID[c.ID] = c } g := byID["10000778141"] if g.Type != "group" || g.Title != "测试AI" { t.Fatalf("group: %#v", g) } raw, _ := json.Marshal(g) if !json.Valid(raw) || string(raw)[0] != '{' { t.Fatal(string(raw)) } if got := string(mustJSONField(t, raw, "id")); got != `"10000778141"` && false { t.Fatal(got) } var m map[string]any _ = json.Unmarshal(raw, &m) if _, ok := m["id"].(string); !ok { t.Fatalf("id JSON type = %T, want string", m["id"]) } sg := byID["22"] if sg.Username == nil || *sg.Username != "sg_name" { t.Fatalf("username: %#v", sg) } } func TestFilterSafewChats(t *testing.T) { chats := []SafewChat{ {ID: "10000778141", Type: "group", Title: "测试AI"}, {ID: "99", Type: "group", Title: "ops"}, } got := FilterSafewChats(chats, "测试") if len(got) != 1 || got[0].ID != "10000778141" { t.Fatalf("%#v", got) } got = FilterSafewChats(chats, "10000778141") if len(got) != 1 || got[0].Title != "测试AI" { t.Fatalf("%#v", got) } got = FilterSafewChats(chats, "") if len(got) != 2 { t.Fatalf("empty q should keep all, got %d", len(got)) } } func mustJSONField(t *testing.T, raw []byte, key string) string { t.Helper() var m map[string]json.RawMessage if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } return string(m[key]) } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/adapter/ -run 'TestGroupsFromUpdates|TestFilterSafewChats' -count=1` Expected: FAIL `undefined: GroupsFromUpdates` - [ ] **Step 3: Implement parse + filter** Append to `internal/adapter/safew.go` (keep existing send code). Add imports: `strconv`. ```go type SafewChat struct { ID string `json:"id"` Type string `json:"type"` Title string `json:"title"` Username *string `json:"username"` } func GroupsFromUpdates(body []byte) ([]SafewChat, int64, error) { dec := json.NewDecoder(bytes.NewReader(body)) dec.UseNumber() var wrap struct { OK bool `json:"ok"` Result []json.RawMessage `json:"result"` } if err := dec.Decode(&wrap); err != nil { return nil, 0, fmt.Errorf("safew updates decode: %w", err) } seen := map[string]SafewChat{} var maxID int64 for _, item := range wrap.Result { id, chat, err := parseUpdateItem(item) if err != nil { return nil, 0, err } if id > maxID { maxID = id } if chat == nil { continue } if chat.Type != "group" && chat.Type != "supergroup" { continue } seen[chat.ID] = *chat } out := make([]SafewChat, 0, len(seen)) for _, c := range seen { out = append(out, c) } return out, maxID, nil } func parseUpdateItem(item json.RawMessage) (int64, *SafewChat, error) { dec := json.NewDecoder(bytes.NewReader(item)) dec.UseNumber() var u map[string]json.RawMessage if err := dec.Decode(&u); err != nil { return 0, nil, err } var updateID int64 if raw, ok := u["update_id"]; ok { var n json.Number if err := json.Unmarshal(raw, &n); err == nil { updateID, _ = n.Int64() } } for _, key := range []string{"message", "edited_message", "channel_post", "edited_channel_post", "my_chat_member", "chat_member"} { raw, ok := u[key] if !ok { continue } chat := chatFromNested(raw) if chat != nil { return updateID, chat, nil } } return updateID, nil, nil } func chatFromNested(raw json.RawMessage) *SafewChat { dec := json.NewDecoder(bytes.NewReader(raw)) dec.UseNumber() var obj map[string]json.RawMessage if err := dec.Decode(&obj); err != nil { return nil } chatRaw, ok := obj["chat"] if !ok { return nil } dec = json.NewDecoder(bytes.NewReader(chatRaw)) dec.UseNumber() var c struct { ID json.Number `json:"id"` Type string `json:"type"` Title string `json:"title"` Username *string `json:"username"` } if err := dec.Decode(&c); err != nil { return nil } id := strings.TrimSpace(c.ID.String()) if id == "" { return nil } return &SafewChat{ID: id, Type: c.Type, Title: c.Title, Username: c.Username} } func FilterSafewChats(chats []SafewChat, q string) []SafewChat { q = strings.TrimSpace(strings.ToLower(q)) if q == "" { return chats } var out []SafewChat for _, c := range chats { uname := "" if c.Username != nil { uname = *c.Username } if strings.Contains(strings.ToLower(c.Title), q) || strings.Contains(strings.ToLower(uname), q) || strings.Contains(strings.ToLower(c.ID), q) { out = append(out, c) } } return out } ``` Unused `strconv` — do **not** add that import. - [ ] **Step 4: Run tests and make sure they pass** Run: `go test ./internal/adapter/ -count=1` Expected: `PASS` (existing send tests + new tests) --- ### Task 2: PollGroupChats (httptest) **Files:** - Modify: `internal/adapter/safew.go` - Modify: `internal/adapter/safew_chats_test.go` **Interfaces:** - Consumes: `GroupsFromUpdates` - Produces: - `type SafewAuthError struct { Description string }` with `Error() string` - `func (s *SafeWSender) PollGroupChats(token string, offset int64, timeout int) (chats []SafewChat, nextOffset int64, err error)` - `nextOffset` is `max(update_id)+1` when any updates; otherwise unchanged `offset` - [ ] **Step 1: Write the failing poll tests** Append to `internal/adapter/safew_chats_test.go`: ```go func TestPollGroupChatsSuccess(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/bottok/getUpdates" { t.Errorf("path=%s", r.URL.Path) } raw, _ := io.ReadAll(r.Body) var body map[string]any _ = json.Unmarshal(raw, &body) if body["timeout"] != float64(0) { t.Errorf("timeout=%v", body["timeout"]) } if body["offset"] != float64(5) { t.Errorf("offset=%v", body["offset"]) } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true,"result":[{"update_id":10,"message":{"chat":{"id":10000778141,"type":"group","title":"测试AI"}}}]}`)) })) defer srv.Close() s := &SafeWSender{apiBase: srv.URL} chats, next, err := s.PollGroupChats("tok", 5, 0) if err != nil { t.Fatal(err) } if next != 11 { t.Fatalf("next=%d want 11", next) } if len(chats) != 1 || chats[0].ID != "10000778141" { t.Fatalf("%#v", chats) } } func TestPollGroupChatsUnauthorized(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"ok":false,"error_code":401,"description":"BOT_TOKEN_INVALID"}`)) })) defer srv.Close() s := &SafeWSender{apiBase: srv.URL} _, _, err := s.PollGroupChats("bad", 0, 0) if err == nil { t.Fatal("expected error") } ae, ok := err.(*SafewAuthError) if !ok { t.Fatalf("type %T %v", err, err) } if !strings.Contains(ae.Description, "BOT_TOKEN_INVALID") { t.Fatalf("%q", ae.Description) } } ``` Add imports in that file: `io`, `net/http`, `net/http/httptest`, `strings`. - [ ] **Step 2: Run tests to verify they fail** Run: `go test ./internal/adapter/ -run TestPollGroupChats -count=1` Expected: FAIL `undefined: PollGroupChats` - [ ] **Step 3: Implement poll** In `internal/adapter/safew.go`, change `endpoint` to go through `methodURL`: ```go func (s *SafeWSender) endpoint(token string) string { return s.methodURL(token, "sendMessage") } func (s *SafeWSender) methodURL(token, method string) string { base := s.apiBase if base == "" { base = safewAPIBase } return strings.TrimRight(base, "/") + "/bot" + token + "/" + method } type SafewAuthError struct { Description string } func (e *SafewAuthError) Error() string { if e.Description == "" { return "safew unauthorized" } return e.Description } func (s *SafeWSender) PollGroupChats(token string, offset int64, timeout int) ([]SafewChat, int64, error) { token = strings.TrimSpace(token) if token == "" { return nil, offset, fmt.Errorf("safew: token is required") } reqBody, _ := json.Marshal(map[string]any{ "timeout": timeout, "offset": offset, "limit": 100, }) resp, err := http.Post(s.methodURL(token, "getUpdates"), "application/json", bytes.NewReader(reqBody)) if err != nil { return nil, offset, fmt.Errorf("safew getUpdates: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, offset, fmt.Errorf("safew getUpdates read: %w", err) } if resp.StatusCode == http.StatusUnauthorized { return nil, offset, &SafewAuthError{Description: safewErrorDescription(respBody)} } if resp.StatusCode >= 400 { desc := safewErrorDescription(respBody) if desc != "" { return nil, offset, fmt.Errorf("safew getUpdates status %d: %s", resp.StatusCode, desc) } return nil, offset, fmt.Errorf("safew getUpdates status %d", resp.StatusCode) } var api safewAPIResponse if err := json.Unmarshal(respBody, &api); err == nil && !api.OK { if resp.StatusCode == 401 || strings.Contains(strings.ToLower(api.Description), "token") { return nil, offset, &SafewAuthError{Description: api.Description} } return nil, offset, fmt.Errorf("safew: %s", api.Description) } chats, maxID, err := GroupsFromUpdates(respBody) if err != nil { return nil, offset, err } next := offset if maxID > 0 { next = maxID + 1 } return chats, next, nil } ``` Need `net/http` import in `safew.go` (already present). - [ ] **Step 4: Run adapter tests** Run: `go test ./internal/adapter/ -count=1` Expected: `PASS` --- ### Task 3: Redis chat store + ChatStore interface **Files:** - Create: `internal/cache/safew_chats.go` - Create: `internal/cache/safew_chats_test.go` (hash only) - Create: `internal/safew/store.go` **Interfaces:** - Produces: - `func TokenHash(token string) string` in package `cache` — sha256 hex of token - `cache.Cache` methods: `MergeSafewChats`, `ListSafewChats`, `GetSafewOffset`, `SetSafewOffset`, `TrySafewPollLock`, `UnlockSafewPoll` - `type safew.ChatStore interface` wrapping those operations by **token string** (hash inside Redis impl) - [ ] **Step 1: Write hash test** `internal/cache/safew_chats_test.go`: ```go package cache import ( "encoding/hex" "testing" ) func TestTokenHashStableAndNotPlainToken(t *testing.T) { h := TokenHash("secret-token") if h == "secret-token" || h == "" { t.Fatalf("hash=%q", h) } if _, err := hex.DecodeString(h); err != nil { t.Fatalf("not hex: %v", err) } if TokenHash("secret-token") != h { t.Fatal("not stable") } if TokenHash("other") == h { t.Fatal("collision") } } ``` - [ ] **Step 2: Run to verify fail** Run: `go test ./internal/cache/ -run TestTokenHash -count=1` Expected: FAIL `undefined: TokenHash` - [ ] **Step 3: Implement hash + Redis + interface** `internal/cache/safew_chats.go`: ```go package cache import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "time" "aiaa-notification-service/internal/adapter" "github.com/redis/go-redis/v9" ) func TokenHash(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func safewChatsKey(hash string) string { return "safew:chats:" + hash } func safewOffsetKey(hash string) string { return "safew:offset:" + hash } func safewPollKey(hash string) string { return "safew:poll:" + hash } func (c *Cache) MergeSafewChats(ctx context.Context, token string, chats []adapter.SafewChat) error { if c == nil || c.rdb == nil { return fmt.Errorf("redis unavailable") } if len(chats) == 0 { return nil } hash := TokenHash(token) vals := make([]any, 0, len(chats)*2) for _, ch := range chats { b, err := json.Marshal(ch) if err != nil { return err } vals = append(vals, ch.ID, b) } return c.rdb.HSet(ctx, safewChatsKey(hash), vals...).Err() } func (c *Cache) ListSafewChats(ctx context.Context, token string) ([]adapter.SafewChat, error) { if c == nil || c.rdb == nil { return nil, fmt.Errorf("redis unavailable") } m, err := c.rdb.HGetAll(ctx, safewChatsKey(TokenHash(token))).Result() if err != nil { return nil, err } out := make([]adapter.SafewChat, 0, len(m)) for _, raw := range m { var ch adapter.SafewChat if err := json.Unmarshal([]byte(raw), &ch); err != nil { return nil, err } out = append(out, ch) } return out, nil } func (c *Cache) GetSafewOffset(ctx context.Context, token string) (int64, error) { if c == nil || c.rdb == nil { return 0, fmt.Errorf("redis unavailable") } n, err := c.rdb.Get(ctx, safewOffsetKey(TokenHash(token))).Int64() if err == redis.Nil { return 0, nil } return n, err } func (c *Cache) SetSafewOffset(ctx context.Context, token string, offset int64) error { if c == nil || c.rdb == nil { return fmt.Errorf("redis unavailable") } return c.rdb.Set(ctx, safewOffsetKey(TokenHash(token)), offset, 0).Err() } func (c *Cache) TrySafewPollLock(ctx context.Context, token string, ttl time.Duration) (bool, error) { if c == nil || c.rdb == nil { return false, fmt.Errorf("redis unavailable") } ok, err := c.rdb.SetNX(ctx, safewPollKey(TokenHash(token)), "1", ttl).Result() return ok, err } func (c *Cache) UnlockSafewPoll(ctx context.Context, token string) error { if c == nil || c.rdb == nil { return nil } return c.rdb.Del(ctx, safewPollKey(TokenHash(token))).Err() } ``` `internal/safew/store.go`: ```go package safew import ( "context" "sync" "time" "aiaa-notification-service/internal/adapter" ) type ChatStore interface { MergeSafewChats(ctx context.Context, token string, chats []adapter.SafewChat) error ListSafewChats(ctx context.Context, token string) ([]adapter.SafewChat, error) GetSafewOffset(ctx context.Context, token string) (int64, error) SetSafewOffset(ctx context.Context, token string, offset int64) error TrySafewPollLock(ctx context.Context, token string, ttl time.Duration) (bool, error) UnlockSafewPoll(ctx context.Context, token string) error } type MemStore struct { mu sync.Mutex chats map[string]map[string]adapter.SafewChat offset map[string]int64 locks map[string]bool } func NewMemStore() *MemStore { return &MemStore{ chats: map[string]map[string]adapter.SafewChat{}, offset: map[string]int64{}, locks: map[string]bool{}, } } func (m *MemStore) MergeSafewChats(_ context.Context, token string, chats []adapter.SafewChat) error { m.mu.Lock() defer m.mu.Unlock() if m.chats[token] == nil { m.chats[token] = map[string]adapter.SafewChat{} } for _, c := range chats { m.chats[token][c.ID] = c } return nil } func (m *MemStore) ListSafewChats(_ context.Context, token string) ([]adapter.SafewChat, error) { m.mu.Lock() defer m.mu.Unlock() var out []adapter.SafewChat for _, c := range m.chats[token] { out = append(out, c) } if out == nil { out = []adapter.SafewChat{} } return out, nil } func (m *MemStore) GetSafewOffset(_ context.Context, token string) (int64, error) { m.mu.Lock() defer m.mu.Unlock() return m.offset[token], nil } func (m *MemStore) SetSafewOffset(_ context.Context, token string, offset int64) error { m.mu.Lock() defer m.mu.Unlock() m.offset[token] = offset return nil } func (m *MemStore) TrySafewPollLock(_ context.Context, token string, _ time.Duration) (bool, error) { m.mu.Lock() defer m.mu.Unlock() if m.locks[token] { return false, nil } m.locks[token] = true return true, nil } func (m *MemStore) UnlockSafewPoll(_ context.Context, token string) error { m.mu.Lock() defer m.mu.Unlock() delete(m.locks, token) return nil } ``` MemStore keys by raw token (tests only). Redis impl hashes. That is OK: production always uses `cache.Cache`. - [ ] **Step 4: Run tests** Run: `go test ./internal/cache/ ./internal/safew/ -count=1` Expected: cache hash test PASS; safew package may have no tests yet (`?` or PASS) --- ### Task 4: Watcher + Refresh + List **Files:** - Create: `internal/safew/watcher.go` - Create: `internal/safew/watcher_test.go` **Interfaces:** - Consumes: `ChatStore`, `PollGroupChats` via `type Poller func(token string, offset int64, timeout int) ([]adapter.SafewChat, int64, error)` - Produces: - `type Watcher struct` - `func NewWatcher(store ChatStore, poll Poller) *Watcher` - `func (w *Watcher) Ensure(token string)` - `func (w *Watcher) Stop()` - `func (w *Watcher) Refresh(ctx context.Context, token string) error` — timeout=0 drain - `func (w *Watcher) List(ctx context.Context, token, q string) ([]adapter.SafewChat, error)` - [ ] **Step 1: Write failing watcher tests** ```go package safew import ( "context" "errors" "testing" "time" "aiaa-notification-service/internal/adapter" ) func TestRefreshMergesAndAdvancesOffset(t *testing.T) { st := NewMemStore() poll := func(token string, offset int64, timeout int) ([]adapter.SafewChat, int64, error) { if timeout != 0 { t.Fatalf("timeout=%d", timeout) } if offset != 0 { t.Fatalf("offset=%d", offset) } return []adapter.SafewChat{{ID: "10000778141", Type: "group", Title: "测试AI"}}, 11, nil } w := NewWatcher(st, poll) ctx := context.Background() if err := w.Refresh(ctx, "tok"); err != nil { t.Fatal(err) } list, err := w.List(ctx, "tok", "测试") if err != nil { t.Fatal(err) } if len(list) != 1 || list[0].ID != "10000778141" { t.Fatalf("%#v", list) } off, _ := st.GetSafewOffset(ctx, "tok") if off != 11 { t.Fatalf("offset=%d", off) } } func TestRefreshAuthError(t *testing.T) { st := NewMemStore() w := NewWatcher(st, func(string, int64, int) ([]adapter.SafewChat, int64, error) { return nil, 0, &adapter.SafewAuthError{Description: "BOT_TOKEN_INVALID"} }) err := w.Refresh(context.Background(), "bad") if err == nil { t.Fatal("expected auth error") } if _, ok := err.(*adapter.SafewAuthError); !ok { t.Fatalf("%T", err) } } func TestEnsurePollsInBackground(t *testing.T) { st := NewMemStore() got := make(chan int, 1) w := NewWatcher(st, func(token string, offset int64, timeout int) ([]adapter.SafewChat, int64, error) { if timeout != 30 { return nil, offset, errors.New("not background") } select { case got <- timeout: default: } return []adapter.SafewChat{{ID: "1", Type: "group", Title: "g"}}, offset + 1, nil }) w.bgIdle = 10 * time.Millisecond w.Ensure("tok") select { case <-got: case <-time.After(2 * time.Second): t.Fatal("background poll not called") } w.Stop() } ``` - [ ] **Step 2: Run to verify fail** Run: `go test ./internal/safew/ -count=1` Expected: FAIL `undefined: NewWatcher` - [ ] **Step 3: Implement Watcher** `internal/safew/watcher.go`: ```go package safew import ( "context" "log/slog" "sync" "time" "aiaa-notification-service/internal/adapter" ) type Poller func(token string, offset int64, timeout int) ([]adapter.SafewChat, int64, error) type Watcher struct { store ChatStore poll Poller bgIdle time.Duration mu sync.Mutex running map[string]context.CancelFunc stopped bool } func NewWatcher(store ChatStore, poll Poller) *Watcher { return &Watcher{ store: store, poll: poll, bgIdle: time.Second, running: map[string]context.CancelFunc{}, } } func (w *Watcher) Ensure(token string) { if token == "" || w == nil { return } w.mu.Lock() defer w.mu.Unlock() if w.stopped { return } if _, ok := w.running[token]; ok { return } ctx, cancel := context.WithCancel(context.Background()) w.running[token] = cancel go w.loop(ctx, token) } func (w *Watcher) Stop() { w.mu.Lock() w.stopped = true for _, cancel := range w.running { cancel() } w.running = map[string]context.CancelFunc{} w.mu.Unlock() } func (w *Watcher) Refresh(ctx context.Context, token string) error { return w.pollOnce(ctx, token, 0) } func (w *Watcher) List(ctx context.Context, token, q string) ([]adapter.SafewChat, error) { chats, err := w.store.ListSafewChats(ctx, token) if err != nil { return nil, err } return adapter.FilterSafewChats(chats, q), nil } func (w *Watcher) loop(ctx context.Context, token string) { for { if err := w.pollOnce(ctx, token, 30); err != nil { if ctx.Err() != nil { return } slog.Warn("safew watcher poll", "error", err) } idle := w.bgIdle if idle <= 0 { idle = time.Second } select { case <-ctx.Done(): return case <-time.After(idle): } } } func (w *Watcher) pollOnce(ctx context.Context, token string, timeout int) error { ok, err := w.store.TrySafewPollLock(ctx, token, 35*time.Second) if err != nil { return err } if !ok { return nil } defer func() { _ = w.store.UnlockSafewPoll(ctx, token) }() offset, err := w.store.GetSafewOffset(ctx, token) if err != nil { return err } chats, next, err := w.poll(token, offset, timeout) if err != nil { return err } if err := w.store.MergeSafewChats(ctx, token, chats); err != nil { return err } if next != offset { return w.store.SetSafewOffset(ctx, token, next) } return nil } ``` - [ ] **Step 4: Run safew tests** Run: `go test ./internal/safew/ -count=1` Expected: `PASS` If `TestEnsurePollsInBackground` is flaky, increase wait or call `loop` once via exported test hook — do not lengthen beyond 2s. --- ### Task 5: HTTP handlers, routes, docs **Files:** - Modify: `internal/handler/channel.go` - Create: `internal/handler/channel_chats_test.go` - Modify: `cmd/server/main.go` - Modify: `README.md` - Modify: `docs/httpie/curls.md` **Interfaces:** - Consumes: `*safew.Watcher` (`Ensure`, `Refresh`, `List`) - Produces: the two admin endpoints - [ ] **Step 1: Write handler tests** `internal/handler/channel_chats_test.go`: ```go package handler import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "aiaa-notification-service/internal/adapter" "aiaa-notification-service/internal/model" "aiaa-notification-service/internal/safew" "github.com/gin-gonic/gin" ) func TestListSafewChatsPOSTMissingToken(t *testing.T) { gin.SetMode(gin.TestMode) h := &ChannelHandler{chats: safew.NewWatcher(safew.NewMemStore(), func(string, int64, int) ([]adapter.SafewChat, int64, error) { return nil, 0, nil })} r := gin.New() r.POST("/api/v1/channels/safew/chats", h.ListSafewChats) req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/safew/chats", bytes.NewReader([]byte(`{}`))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("code=%d body=%s", w.Code, w.Body.String()) } } func TestListSafewChatsPOSTOk(t *testing.T) { gin.SetMode(gin.TestMode) st := safew.NewMemStore() _ = st.MergeSafewChats(nil, "tok", []adapter.SafewChat{{ID: "10000778141", Type: "group", Title: "测试AI"}}) h := &ChannelHandler{chats: safew.NewWatcher(st, func(string, int64, int) ([]adapter.SafewChat, int64, error) { return nil, 0, nil })} r := gin.New() r.POST("/api/v1/channels/safew/chats", h.ListSafewChats) req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/safew/chats", bytes.NewReader([]byte(`{"token":"tok","q":"测试"}`))) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("code=%d body=%s", w.Code, w.Body.String()) } var resp struct { Data []adapter.SafewChat `json:"data"` Total int `json:"total"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if resp.Total != 1 || resp.Data[0].ID != "10000778141" { t.Fatalf("%#v", resp) } } func TestSafewTokenFromChannel(t *testing.T) { raw := json.RawMessage(`{"token":"abc","chat_id":"1"}`) ch := &model.Channel{Type: "safew", Config: &raw} tok, err := safewTokenFromChannel(ch) if err != nil || tok != "abc" { t.Fatalf("%q %v", tok, err) } ch.Type = "bark" if _, err := safewTokenFromChannel(ch); err == nil { t.Fatal("expected not safew") } } ``` `MergeSafewChats(nil, ...)` — MemStore ignores ctx, OK. If compile requires context, use `context.Background()`. - [ ] **Step 2: Run to verify fail** Run: `go test ./internal/handler/ -count=1` Expected: FAIL `undefined: ListSafewChats` or `unknown field chats` - [ ] **Step 3: Implement handlers and wire main** Update `ChannelHandler`: ```go type ChannelHandler struct { store *store.Store cache *cache.Cache chats *safew.Watcher } func NewChannelHandler(s *store.Store, c *cache.Cache, w *safew.Watcher) *ChannelHandler { return &ChannelHandler{store: s, cache: c, chats: w} } ``` Add handlers (same file): ```go type listSafewChatsReq struct { Token string `json:"token"` Q string `json:"q"` } func (h *ChannelHandler) ListSafewChats(c *gin.Context) { var req listSafewChatsReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } h.respondSafewChats(c, strings.TrimSpace(req.Token), req.Q) } func (h *ChannelHandler) ListChannelSafewChats(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) ch, err := h.store.GetChannel(c.Request.Context(), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } token, err := safewTokenFromChannel(ch) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } h.respondSafewChats(c, token, c.Query("q")) } func (h *ChannelHandler) respondSafewChats(c *gin.Context, token, q string) { if token == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "safew token is required"}) return } if h.chats == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "safew chat list unavailable"}) return } h.chats.Ensure(token) if err := h.chats.Refresh(c.Request.Context(), token); err != nil { if _, ok := err.(*adapter.SafewAuthError); ok { c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } slog.Warn("safew refresh", "error", err) } list, err := h.chats.List(c.Request.Context(), token, q) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if list == nil { list = []adapter.SafewChat{} } c.JSON(http.StatusOK, gin.H{"data": list, "total": len(list)}) } func safewTokenFromChannel(ch *model.Channel) (string, error) { if ch.Type != "safew" { return "", fmt.Errorf("channel is not safew") } if ch.Config == nil { return "", fmt.Errorf("safew token is required") } var cfg struct { Token string `json:"token"` } if err := json.Unmarshal(*ch.Config, &cfg); err != nil { return "", fmt.Errorf("safew token is required") } token := strings.TrimSpace(cfg.Token) if token == "" { return "", fmt.Errorf("safew token is required") } return token, nil } ``` Add imports in `channel.go`: `fmt`, `log/slog`, `strings`, `adapter`, `safew`. **Every** existing `NewChannelHandler(st, redisCache)` call must become `NewChannelHandler(st, redisCache, watcher)`. In `cmd/server/main.go`: After Redis is connected: ```go poller := func(token string, offset int64, timeout int) ([]adapter.SafewChat, int64, error) { return (&adapter.SafeWSender{}).PollGroupChats(token, offset, timeout) } var watcherStore safew.ChatStore if redisCache != nil { watcherStore = redisCache } else { watcherStore = safew.NewMemStore() slog.Warn("safew chats using in-memory store") } safewWatcher := safew.NewWatcher(watcherStore, poller) defer safewWatcher.Stop() channelH := handler.NewChannelHandler(st, redisCache, safewWatcher) ``` Register routes **before** `GET /channels/:id`: ```go admin.POST("/channels/safew/chats", channelH.ListSafewChats) admin.GET("/channels/:id/chats", channelH.ListChannelSafewChats) admin.POST("/channels", channelH.Create) admin.GET("/channels", channelH.List) admin.GET("/channels/:id", channelH.Get) ``` On shutdown, `safewWatcher.Stop()` via defer is enough. Grep for `NewChannelHandler` and update all call sites. README: after SafeW config block, before `#### GET /api/v1/channels`, insert the two endpoints with the JSON examples from the spec. `docs/httpie/curls.md`: after 创建 Channel(SafeW), add: ```bash curl -X POST 'http://localhost:8080/api/v1/channels/safew/chats' \ -H 'Authorization: Bearer admin-sk-change-me' \ -H 'Content-Type: application/json' \ -d '{"token":"","q":"测试AI"}' curl -X GET 'http://localhost:8080/api/v1/channels/1/chats?q=' \ -H 'Authorization: Bearer admin-sk-change-me' ``` - [ ] **Step 4: Run all internal tests** Run: `go test ./internal/... -count=1` Expected: `PASS` Also: `go build ./cmd/server` Expected: success