bd8a9ff96d
通过 getUpdates 将群写入 Redis,供创建/编辑渠道时选择 chat_id,避免前端重复传递 token。
1192 lines
31 KiB
Markdown
1192 lines
31 KiB
Markdown
# 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":"<bot 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
|