feat: 增加 SafeW 已监控群列表接口

通过 getUpdates 将群写入 Redis,供创建/编辑渠道时选择 chat_id,避免前端重复传递 token。
This commit is contained in:
2026-08-15 00:40:40 +08:00
parent 0d0cd0c510
commit bd8a9ff96d
14 changed files with 2265 additions and 4 deletions
+181 -1
View File
@@ -90,11 +90,73 @@ func (s *SafeWSender) Send(title, content string, config json.RawMessage) error
}
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 + "/sendMessage"
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
}
func parseSafewChatID(raw json.RawMessage) (string, error) {
@@ -136,3 +198,121 @@ func escapeMarkdownV2(s string) string {
}
return b.String()
}
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
}
+120
View File
@@ -0,0 +1,120 @@
package adapter
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"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)
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 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)
}
}
+94
View File
@@ -0,0 +1,94 @@
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()
}
+22
View File
@@ -0,0 +1,22 @@
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")
}
}
+85 -2
View File
@@ -2,11 +2,16 @@ package handler
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"aiaa-notification-service/internal/adapter"
"aiaa-notification-service/internal/cache"
"aiaa-notification-service/internal/model"
"aiaa-notification-service/internal/safew"
"aiaa-notification-service/internal/store"
"github.com/gin-gonic/gin"
@@ -15,10 +20,11 @@ import (
type ChannelHandler struct {
store *store.Store
cache *cache.Cache
chats *safew.Watcher
}
func NewChannelHandler(s *store.Store, c *cache.Cache) *ChannelHandler {
return &ChannelHandler{store: s, cache: c}
func NewChannelHandler(s *store.Store, c *cache.Cache, w *safew.Watcher) *ChannelHandler {
return &ChannelHandler{store: s, cache: c, chats: w}
}
type createChannelReq struct {
@@ -96,3 +102,80 @@ func (h *ChannelHandler) Delete(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
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
}
+72
View File
@@ -0,0 +1,72 @@
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")
}
}
+88
View File
@@ -0,0 +1,88 @@
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
}
+117
View File
@@ -0,0 +1,117 @@
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
}
+76
View File
@@ -0,0 +1,76 @@
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()
}