feat(channel): KOOK 渠道适配器 + 框架兼容增强(wsutil/httpclient)
- plugins/channels/kook: config/adapter/gateway/websocket/send/message/errors — 实现 PlatformAdapter 13方法 - WS 有状态网关: 取gateway地址→连WS→信令0/1/2/3/4/6 + 30s心跳/6s超时 + resume(拼接sn/session_id) + 指数退避重连 - 收事件: 信令0 → UnifiedMessage(word注 channel_type/type/target_id/extra.author→字段) - 发消息: /message/create(频道) + /direct-message/create(私聊,修正正确端点,非user-chat) + Bearer鉴权 - 注册 channels.go + CHANNELS_JSON 多账号 + channelbridge.configured 加 kook(bot_token非空才连) - 框架增强: internal/channel/adapter/wsutil(连接管理/心跳/重连/resume骨架,复用gorilla,KOOK用上) + httpclient(Bearer+envelope归一,KOOK用) - 全仓 go build/vet/test 绿, 零新依赖(复用餐gorilla), 未动telegram/qq/weixin/webhook契约 - §4.6 隔离/黑白名单/第三方边界未破坏 注: KOOK WS/WH 双模式待补(ReceiveMode), 实测需主人提供 CHANNEL_KOOK_TOKEN
This commit is contained in:
1 parent
eab6b63eb0
commit
764d592859
24 files changed
+2800
-2
No files matched your search
@@ -90,6 +90,13 @@ SESSION_IDLE_TIMEOUT_MIN=30
|
||||
# ---- 跨域白名单 ----
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# ---- KOOK(KaiHeiLa)渠道 ----
|
||||
# 单账号走环境变量;多账号走下方 CHANNELS_JSON。
|
||||
# CHANNEL_KOOK_TOKEN 为 KOOK 开发者中心机器人 token(敏感,勿写日志/提交)。
|
||||
# CHANNEL_KOOK_BASE_URL 可选,默认 https://www.kookapp.cn/api/v3。
|
||||
# CHANNEL_KOOK_TOKEN=
|
||||
# CHANNEL_KOOK_BASE_URL=
|
||||
|
||||
# ---- IM 多账号 / 黑白名单(可选,design/16 §4.6 §5-6) ----
|
||||
# 单账号仍走上面 CHANNEL_<NAME>_* 环境变量;这里提供独立的 JSON 注册表,
|
||||
# 支持同一平台配多个接入配置(多账号),并配群聊/私聊黑白名单(代码层拦截,
|
||||
|
||||
+13
-1
@@ -15,6 +15,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
|
||||
@@ -53,6 +54,11 @@ func buildChannelAdapters(log *slog.Logger) []adapter.PlatformAdapter {
|
||||
WebhookURL: os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"),
|
||||
}, telegram.WithLogger(log))
|
||||
|
||||
kook := kook.New(kook.Config{
|
||||
BotToken: os.Getenv("CHANNEL_KOOK_TOKEN"),
|
||||
BaseURL: os.Getenv("CHANNEL_KOOK_BASE_URL"),
|
||||
}, kook.WithLogger(log))
|
||||
|
||||
// webhook.WithLogger does not tag provenance, so wrap base with the module
|
||||
// source here to satisfy the logging rule.
|
||||
wh := webhook.New(
|
||||
@@ -66,7 +72,7 @@ func buildChannelAdapters(log *slog.Logger) []adapter.PlatformAdapter {
|
||||
webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook")),
|
||||
)
|
||||
|
||||
out := []adapter.PlatformAdapter{qq, tg, wh}
|
||||
out := []adapter.PlatformAdapter{qq, tg, wh, kook}
|
||||
if weixin != nil {
|
||||
out = append(out, weixin)
|
||||
}
|
||||
@@ -154,6 +160,12 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.
|
||||
return nil, fmt.Errorf("decode qq-obv11 config: %w", err)
|
||||
}
|
||||
inner = qq_obv11.NewAdapter(cfg)
|
||||
case "kook":
|
||||
var cfg kook.Config
|
||||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode kook config: %w", err)
|
||||
}
|
||||
inner = kook.New(cfg, kook.WithLogger(log))
|
||||
case "webhook":
|
||||
var cfg webhook.Config
|
||||
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
func testLog() *slog.Logger {
|
||||
return logger.New(logger.Options{Out: io.Discard})
|
||||
}
|
||||
|
||||
func TestBuildChannelAdapters_IncludesKook(t *testing.T) {
|
||||
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
|
||||
adapters := buildChannelAdapters(testLog())
|
||||
if !hasAdapter(adapters, "kook") {
|
||||
t.Fatalf("expected a kook adapter, got %v", names(adapters))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChannelAdapters_KookMultiAccount(t *testing.T) {
|
||||
t.Setenv("CHANNELS_JSON", `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"allowUsers":["u-1"],"denyGroups":["g-9"]}]}`)
|
||||
adapters := buildChannelAdapters(testLog())
|
||||
if !hasAdapter(adapters, "kook:conf0") {
|
||||
t.Fatalf("expected a kook:conf0 adapter, got %v", names(adapters))
|
||||
}
|
||||
// The named adapter must expose its allow/deny lists via Config().
|
||||
for _, a := range adapters {
|
||||
if a.Name() == "kook:conf0" {
|
||||
cfg := a.Config()
|
||||
if got, _ := cfg["allowUsers"].([]string); len(got) != 1 || got[0] != "u-1" {
|
||||
t.Errorf("allowUsers = %v, want [u-1]", cfg["allowUsers"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasAdapter(adapters []adapter.PlatformAdapter, name string) bool {
|
||||
for _, a := range adapters {
|
||||
if a.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func names(adapters []adapter.PlatformAdapter) []string {
|
||||
out := make([]string, 0, len(adapters))
|
||||
for _, a := range adapters {
|
||||
out = append(out, a.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package httpclient provides a small, reusable HTTP (/JSON) client for IM
|
||||
// channel adapters that talk to a REST API with bearer-token auth and a
|
||||
// common `{code,message,data}` response envelope (KOOK, and similarly shaped
|
||||
// platforms). It is used by the KOOK adapter and is intended to be shared by
|
||||
// future platforms; existing adapters (Telegram, OneBot) that already carry
|
||||
// their own transport are left untouched.
|
||||
//
|
||||
// It depends only on the standard library and internal/logger. The bearer token
|
||||
// is never logged or surfaced in errors.
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// ErrAPI is the sentinel returned when the remote API reports a non-zero code or
|
||||
// a non-2xx HTTP status. Tests compare with errors.Is.
|
||||
var ErrAPI = errors.New("httpclient: api error")
|
||||
|
||||
// maxBodyBytes caps how large a response body the client will read.
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// Client is a bearer-auth JSON HTTP client bound to a base URL.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
baseURL string
|
||||
token string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Option customizes a Client at construction time.
|
||||
type Option func(*Client)
|
||||
|
||||
// WithHTTPClient injects the *http.Client used for requests. Tests pass the
|
||||
// client of an httptest.Server.
|
||||
func WithHTTPClient(h *http.Client) Option {
|
||||
return func(c *Client) { c.http = h }
|
||||
}
|
||||
|
||||
// WithBaseURL sets the URL root that paths are joined to. A base ending in "/"
|
||||
// has its trailing slash trimmed.
|
||||
func WithBaseURL(base string) Option {
|
||||
return func(c *Client) { c.baseURL = strings.TrimRight(base, "/") }
|
||||
}
|
||||
|
||||
// WithToken permits an Authorization: Bearer header on every request. The token
|
||||
// is stored in memory only and never logged.
|
||||
func WithToken(token string) Option {
|
||||
return func(c *Client) { c.token = token }
|
||||
}
|
||||
|
||||
// WithLogger injects the logger. A nil logger stays silent.
|
||||
func WithLogger(l *slog.Logger) Option {
|
||||
return func(c *Client) {
|
||||
if l == nil {
|
||||
c.log = logger.New(logger.Options{Out: io.Discard})
|
||||
} else {
|
||||
c.log = l
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New builds a Client. With no base URL the caller must pass absolute URLs (via
|
||||
// the Do/Get helpers' path being an absolute URL).
|
||||
func New(opts ...Option) *Client {
|
||||
c := &Client{
|
||||
http: &http.Client{},
|
||||
log: logger.New(logger.Options{Out: io.Discard}),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// APIEnvelope is the common `{code,message,data}` response envelope used by the
|
||||
// KOOK REST API. Code==0 means success; a non-zero code is mapped to ErrAPI.
|
||||
type APIEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// DoJSON performs a request with the given method against path (joined to the
|
||||
// base URL) and returns the decoded data payload of the envelope. A non-zero
|
||||
// code or a non-2xx status is wrapped with ErrAPI. A 2xx body that is not an
|
||||
// envelope (e.g. a raw payload) is returned verbatim as data.
|
||||
func (c *Client) DoJSON(ctx context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
return c.do(ctx, method, c.resolve(path), body)
|
||||
}
|
||||
|
||||
// GetJSON performs a GET against path with the given query parameters and
|
||||
// returns the decoded data payload. A nil query adds nothing.
|
||||
func (c *Client) GetJSON(ctx context.Context, path string, query url.Values) (json.RawMessage, error) {
|
||||
target := c.resolve(path)
|
||||
if len(query) > 0 {
|
||||
target += "?" + query.Encode()
|
||||
}
|
||||
return c.do(ctx, http.MethodGet, target, nil)
|
||||
}
|
||||
|
||||
// resolve joins path to the base URL, passing through an absolute path as-is.
|
||||
func (c *Client) resolve(path string) string {
|
||||
if c.baseURL == "" {
|
||||
return path
|
||||
}
|
||||
return c.baseURL + path
|
||||
}
|
||||
|
||||
// do performs the request, injecting auth and decoding the envelope.
|
||||
func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
return nil, fmt.Errorf("httpclient: encode %s request: %w", method, err)
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, target, &buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: build %s %s request: %w", method, target, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: %w", method, target, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, target, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: status %d: %s: %w", method, target, resp.StatusCode, truncate(raw), ErrAPI)
|
||||
}
|
||||
|
||||
var envelope APIEnvelope
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return raw, nil
|
||||
}
|
||||
if envelope.Code != 0 {
|
||||
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w", method, target, envelope.Code, envelope.Message, ErrAPI)
|
||||
}
|
||||
return envelope.Data, nil
|
||||
}
|
||||
|
||||
// truncate caps a byte slice to 512 bytes for inclusion in an error message.
|
||||
func truncate(b []byte) string {
|
||||
if len(b) > 512 {
|
||||
return strings.TrimSpace(string(b[:512])) + "…"
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package httpclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
func discardLogger() *slog.Logger { return logger.New(logger.Options{Out: io.Discard}) }
|
||||
|
||||
func TestGetJSON_EnvelopeAndAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
var auth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
auth = r.Header.Get("Authorization")
|
||||
mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 0, "message": "ok",
|
||||
"data": map[string]any{"url": "wss://gw.example.com/gateway"},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(
|
||||
WithBaseURL(srv.URL),
|
||||
WithToken("secret-token"),
|
||||
WithHTTPClient(srv.Client()),
|
||||
WithLogger(discardLogger()),
|
||||
)
|
||||
data, err := c.GetJSON(context.Background(), "/gateway/index", url.Values{"compress": {"0"}})
|
||||
if err != nil {
|
||||
t.Fatalf("GetJSON: %v", err)
|
||||
}
|
||||
if string(data) != `{"url":"wss://gw.example.com/gateway"}` {
|
||||
t.Errorf("data = %s", string(data))
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if auth != "Bearer secret-token" {
|
||||
t.Errorf("Authorization = %q, want Bearer secret-token", auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoJSON_NonZeroCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 40101, "message": "invalid token", "data": struct{}{}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
|
||||
if !errors.Is(err, ErrAPI) {
|
||||
t.Fatalf("err = %v, want ErrAPI", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoJSON_StatusError(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte("unauthorized"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
|
||||
if !errors.Is(err, ErrAPI) {
|
||||
t.Fatalf("err = %v, want ErrAPI", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJSON_NonEnvelopeBodyPassedThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`[1,2,3]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
|
||||
data, err := c.GetJSON(context.Background(), "/list", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJSON: %v", err)
|
||||
}
|
||||
if string(data) != "[1,2,3]" {
|
||||
t.Errorf("data = %s, want [1,2,3]", string(data))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Package wsutil provides a small, reusable WebSocket gateway client for
|
||||
// stateful IM channels (KOOK, QQ OneBot, and similar). It owns the transport
|
||||
// mechanics that those channels share — dialing a gateway, running a read loop,
|
||||
// sending an application-level heartbeat, and reconnecting with exponential
|
||||
// backoff (optionally splicing resume parameters onto the gateway URL) — while
|
||||
// leaving the platform-specific signaling to the adapter via Handler hooks.
|
||||
//
|
||||
// It depends only on the standard library plus github.com/gorilla/websocket and
|
||||
// internal/logger, never on a specific channel adapter.
|
||||
package wsutil
|
||||
|
||||
import "time"
|
||||
|
||||
// Backoff is a resettable exponential backoff delay scheduler. It yields delays
|
||||
// starting at base and doubling up to cap (for example 2s, 4s, 8s, ... capped at
|
||||
// 60s), which is the WebSocket gateway reconnect pattern used by stateful IM
|
||||
// channels. It is safe for concurrent use by a single goroutine.
|
||||
type Backoff struct {
|
||||
base time.Duration
|
||||
max time.Duration
|
||||
attempts int
|
||||
}
|
||||
|
||||
// NewBackoff creates a backoff with the given base and cap. A non-positive base
|
||||
// defaults to 1s and a non-positive cap to 60s.
|
||||
func NewBackoff(base, cap time.Duration) *Backoff {
|
||||
if base <= 0 {
|
||||
base = time.Second
|
||||
}
|
||||
if cap <= 0 {
|
||||
cap = 60 * time.Second
|
||||
}
|
||||
return &Backoff{base: base, max: cap}
|
||||
}
|
||||
|
||||
// Next returns the delay to sleep before the next attempt and advances the
|
||||
// internal attempt counter. It never exceeds the configured cap.
|
||||
func (b *Backoff) Next() time.Duration {
|
||||
var d time.Duration
|
||||
if b.attempts >= 60 {
|
||||
d = b.max
|
||||
} else {
|
||||
d = b.base << uint(b.attempts)
|
||||
}
|
||||
if d > b.max || d <= 0 {
|
||||
d = b.max
|
||||
}
|
||||
b.attempts++
|
||||
return d
|
||||
}
|
||||
|
||||
// Reset restores the backoff to its initial state after a successful connect, so
|
||||
// a later drop starts the ramp over from the base delay.
|
||||
func (b *Backoff) Reset() { b.attempts = 0 }
|
||||
@@ -0,0 +1,56 @@
|
||||
package wsutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackoffSequence(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := NewBackoff(2*time.Second, 60*time.Second)
|
||||
want := []time.Duration{2, 4, 8, 16, 32, 60, 60, 60}
|
||||
for i, exp := range want {
|
||||
if got := b.Next(); got != exp*time.Second {
|
||||
t.Fatalf("Next[%d] = %v, want %v", i, got, exp*time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := NewBackoff(time.Second, time.Minute)
|
||||
_ = b.Next()
|
||||
_ = b.Next()
|
||||
b.Reset()
|
||||
if got := b.Next(); got != time.Second {
|
||||
t.Errorf("after Reset, Next = %v, want 1s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := NewBackoff(0, 0)
|
||||
if got := b.Next(); got != time.Second {
|
||||
t.Errorf("default base Next = %v, want 1s", got)
|
||||
}
|
||||
if got := b.Next(); got != 2*time.Second {
|
||||
t.Errorf("default Next2 = %v, want 2s", got)
|
||||
}
|
||||
// cap of 60s by default: 1,2,4,8,16,32,60...
|
||||
for i := 0; i < 7; i++ {
|
||||
_ = b.Next()
|
||||
}
|
||||
if got := b.Next(); got != 60*time.Second {
|
||||
t.Errorf("default cap Next = %v, want 60s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffNoOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
b := NewBackoff(time.Second, time.Second)
|
||||
for i := 0; i < 200; i++ {
|
||||
if got := b.Next(); got != time.Second {
|
||||
t.Fatalf("Next[%d] = %v, want 1s (cap)", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package wsutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultHeartbeatInterval is used when a client enables heartbeat without a
|
||||
// configured interval.
|
||||
defaultHeartbeatInterval = 30 * time.Second
|
||||
// defaultPongTimeout is the read deadline margin added to the heartbeat
|
||||
// interval; if the peer sends nothing within interval+pongTimeout the socket
|
||||
// is considered dead and the client reconnects.
|
||||
defaultPongTimeout = 6 * time.Second
|
||||
// writeWait bounds a single control-frame write.
|
||||
writeWait = 10 * time.Second
|
||||
)
|
||||
|
||||
// DialFunc returns the gateway URL to connect to. On a reconnect the platform may
|
||||
// return a resume-augmented URL (splice session/sn via AppendQuery) so the
|
||||
// gateway can resynchronize the offline message window.
|
||||
type DialFunc func(ctx context.Context) (string, error)
|
||||
|
||||
// Handler carries the platform-specific protocol hooks the Client invokes. A
|
||||
// nil field disables that hook. The hooks run on the Client's read goroutine
|
||||
// (OnConnect / OnMessage) or a dedicated heartbeat goroutine (Heartbeat).
|
||||
type Handler struct {
|
||||
// OnConnect is called after the WebSocket connection is established and the
|
||||
// application handshake (for example KOOK's hello) succeeds. Returning an
|
||||
// error aborts the session and triggers a reconnect with backoff.
|
||||
OnConnect func(ctx context.Context, c *Conn) error
|
||||
// OnMessage is called for every application text frame received. Returning an
|
||||
// error forces a reconnect.
|
||||
OnMessage func(ctx context.Context, payload []byte) error
|
||||
// Heartbeat is called on the heartbeat ticker (when HeartbeatInterval > 0) to
|
||||
// send an application-level keepalive frame. A nil Heartbeat disables it.
|
||||
Heartbeat func(ctx context.Context, c *Conn) error
|
||||
}
|
||||
|
||||
// Client is a reusable WebSocket gateway connection manager. It dials a gateway,
|
||||
// runs the read loop, maintains an optional application-level heartbeat with a
|
||||
// liveness read deadline, and reconnects with exponential backoff until the
|
||||
// context is canceled. Only the platform signaling is left to the Handler.
|
||||
type Client struct {
|
||||
dial DialFunc
|
||||
handler Handler
|
||||
dialer *websocket.Dialer
|
||||
header http.Header
|
||||
interval time.Duration
|
||||
pongWait time.Duration
|
||||
backoff *Backoff
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
conn *Conn
|
||||
cancel context.CancelFunc
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Option customizes a Client at construction time.
|
||||
type Option func(*Client)
|
||||
|
||||
// WithDialer injects the WebSocket dialer used for the handshake. Tests pass a
|
||||
// dialer whose net dial is redirected to a fake server.
|
||||
func WithDialer(d *websocket.Dialer) Option {
|
||||
return func(c *Client) { c.dialer = d }
|
||||
}
|
||||
|
||||
// WithHeader injects extra HTTP headers on the handshake (for example an
|
||||
// Authorization: Bearer token).
|
||||
func WithHeader(h http.Header) Option {
|
||||
return func(c *Client) { c.header = h }
|
||||
}
|
||||
|
||||
// WithHeartbeat enables an application-level heartbeat at the given interval.
|
||||
// A nil interval disables the heartbeat ticker (default off).
|
||||
func WithHeartbeat(interval time.Duration) Option {
|
||||
return func(c *Client) { c.interval = interval }
|
||||
}
|
||||
|
||||
// WithPongTimeout sets the read deadline margin added to the heartbeat interval.
|
||||
// A non-positive value uses the default (6s).
|
||||
func WithPongTimeout(d time.Duration) Option {
|
||||
return func(c *Client) { c.pongWait = d }
|
||||
}
|
||||
|
||||
// WithBackoff injects the reconnect backoff scheduler.
|
||||
func WithBackoff(b *Backoff) Option {
|
||||
return func(c *Client) { c.backoff = b }
|
||||
}
|
||||
|
||||
// WithLogger injects the logger used by the client. A nil logger stays silent.
|
||||
func WithLogger(l *slog.Logger) Option {
|
||||
return func(c *Client) {
|
||||
if l == nil {
|
||||
c.log = logger.New(logger.Options{Out: io.Discard})
|
||||
} else {
|
||||
c.log = l
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New builds a Client. dial must be non-nil.
|
||||
func New(dial DialFunc, h Handler, opts ...Option) *Client {
|
||||
if dial == nil {
|
||||
dial = func(context.Context) (string, error) { return "", nil }
|
||||
}
|
||||
c := &Client{
|
||||
dial: dial,
|
||||
handler: h,
|
||||
dialer: &websocket.Dialer{HandshakeTimeout: 15 * time.Second},
|
||||
pongWait: defaultPongTimeout,
|
||||
backoff: NewBackoff(2*time.Second, 60*time.Second),
|
||||
log: logger.New(logger.Options{Out: io.Discard}),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Run executes the connect/reconnect loop until ctx is canceled. It returns
|
||||
// nil when ctx is canceled and a non-nil error on a non-context termination. It
|
||||
// is intended to be run in its own goroutine by the adapter's Connect.
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
url, err := c.dial(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
c.log.Warn("ws gateway dial failed", "err", err)
|
||||
if !c.sleepBackoff(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := c.connectOnce(ctx, url); err != nil {
|
||||
c.log.Warn("ws session ended", "err", err)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// connectOnce dials one URL and runs the connect hook plus the read loop until
|
||||
// the connection drops, the read loop errors, or ctx is canceled.
|
||||
func (c *Client) connectOnce(ctx context.Context, url string) error {
|
||||
if c.header == nil {
|
||||
c.header = http.Header{}
|
||||
}
|
||||
ws, _, err := c.dialer.DialContext(ctx, url, c.header)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
c.log.Warn("ws dial failed", "err", err)
|
||||
c.sleepBackoff(ctx)
|
||||
return err
|
||||
}
|
||||
conn := &Conn{ws: ws}
|
||||
defer conn.Close()
|
||||
|
||||
// A watcher closes the connection when ctx ends so a pending read (including
|
||||
// the OnConnect handshake) unblocks and Run can exit promptly.
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go c.watchCtx(ctx, conn, stop)
|
||||
|
||||
if c.handler.OnConnect != nil {
|
||||
if cerr := c.handler.OnConnect(ctx, conn); cerr != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
c.log.Warn("ws connect hook failed", "err", cerr)
|
||||
c.sleepBackoff(ctx)
|
||||
return cerr
|
||||
}
|
||||
}
|
||||
|
||||
c.setConn(conn)
|
||||
c.backoff.Reset()
|
||||
c.runReadLoop(ctx, conn)
|
||||
c.setConn(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchCtx closes the connection when ctx is canceled so a pending read
|
||||
// unblocks, letting Run exit promptly.
|
||||
func (c *Client) watchCtx(ctx context.Context, conn *Conn, stop <-chan struct{}) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = conn.Close()
|
||||
case <-stop:
|
||||
}
|
||||
}
|
||||
|
||||
// runReadLoop consumes text frames until the read errors, the platform handler
|
||||
// returns an error, or ctx is canceled. When heartbeat is enabled it also runs a
|
||||
// heartbeat ticker and refreshes a read deadline so a silent-but-live socket
|
||||
// stays open while a dead one is detected.
|
||||
func (c *Client) runReadLoop(ctx context.Context, conn *Conn) {
|
||||
hbCtx, hbCancel := context.WithCancel(ctx)
|
||||
defer hbCancel()
|
||||
if c.handler.Heartbeat != nil && c.interval > 0 {
|
||||
go c.heartbeatLoop(hbCtx, conn)
|
||||
}
|
||||
|
||||
// A liveness read deadline is only meaningful when an application heartbeat
|
||||
// keeps the socket active; without one, an idle-but-open connection must stay
|
||||
// open indefinitely.
|
||||
deadline := time.Duration(0)
|
||||
if c.handler.Heartbeat != nil && c.interval > 0 {
|
||||
deadline = c.interval + c.pongWait
|
||||
}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if deadline > 0 {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(deadline))
|
||||
} else {
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
}
|
||||
payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if c.handler.OnMessage != nil {
|
||||
if err := c.handler.OnMessage(ctx, payload); err != nil {
|
||||
if ctx.Err() == nil {
|
||||
c.log.Warn("ws on-message failed", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// heartbeatLoop sends an application-level keepalive on every interval. A
|
||||
// failing heartbeat closes the connection to force a reconnect.
|
||||
func (c *Client) heartbeatLoop(ctx context.Context, conn *Conn) {
|
||||
t := time.NewTicker(c.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := c.handler.Heartbeat(ctx, conn); err != nil {
|
||||
c.log.Warn("ws heartbeat failed", "err", err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sleepBackoff sleeps the current backoff delay or until ctx is canceled,
|
||||
// returning false (and leaving the connection for Run to exit) when ctx fires.
|
||||
func (c *Client) sleepBackoff(ctx context.Context) bool {
|
||||
d := c.backoff.Next()
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// setConn installs the live connection so Close can tear it down.
|
||||
func (c *Client) setConn(conn *Conn) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.conn = conn
|
||||
}
|
||||
|
||||
// Close tears down the client, closing the live connection and its context.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.closed = true
|
||||
conn := c.conn
|
||||
cancel := c.cancel
|
||||
c.mu.Unlock()
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindCancel associates a context cancel func so Close can stop a running Run.
|
||||
func (c *Client) BindCancel(cancel context.CancelFunc) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cancel = cancel
|
||||
}
|
||||
|
||||
// Conn is a thin, concurrency-safe wrapper over one *websocket.Conn. It
|
||||
// serializes writes (gorilla allows one reader plus one writer) and exposes only
|
||||
// the JSON text-frame surface the gateway clients need.
|
||||
type Conn struct {
|
||||
ws *websocket.Conn
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
// ReadMessage reads the next complete message, returning its text payload. It
|
||||
// honors any read deadline set via SetReadDeadline.
|
||||
func (c *Conn) ReadMessage() ([]byte, error) {
|
||||
_, p, err := c.ws.ReadMessage()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// WriteMessage writes a text frame with the given payload.
|
||||
func (c *Conn) WriteMessage(payload []byte) error {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
return c.ws.WriteMessage(websocket.TextMessage, payload)
|
||||
}
|
||||
|
||||
// WriteJSON marshals v and writes it as a text frame.
|
||||
func (c *Conn) WriteJSON(v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.WriteMessage(data)
|
||||
}
|
||||
|
||||
// SetReadDeadline bounds the next ReadMessage by the given absolute time.
|
||||
func (c *Conn) SetReadDeadline(t time.Time) error { return c.ws.SetReadDeadline(t) }
|
||||
|
||||
// Close closes the underlying connection exactly once.
|
||||
func (c *Conn) Close() error { return c.ws.Close() }
|
||||
@@ -0,0 +1,147 @@
|
||||
package wsutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
var wsu = websocket.Upgrader{}
|
||||
|
||||
// TestClient_DialReadReconnect exercises the connect hook, the read loop, and the
|
||||
// reconnect path: the server accepts a connection, sends one frame, drops it;
|
||||
// the client re-reads a second frame on the next connection, then the test cancels
|
||||
// the context and the client exits cleanly.
|
||||
func TestClient_DialReadReconnect(t *testing.T) {
|
||||
var conns atomic.Int64
|
||||
var mu sync.Mutex
|
||||
var got []string
|
||||
received := make(chan string, 2)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := wsu.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
n := conns.Add(1)
|
||||
msg := "msg-" + string(rune('0'+n))
|
||||
_ = conn.WriteMessage(websocket.TextMessage, []byte(msg))
|
||||
if n < 2 {
|
||||
return // close the first connection to trigger a reconnect
|
||||
}
|
||||
// Hold the second connection open until the client cancels the context.
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
url := "ws://" + strings.TrimPrefix(srv.URL, "http://") + "/testws"
|
||||
client := New(
|
||||
func(context.Context) (string, error) { return url, nil },
|
||||
Handler{
|
||||
OnConnect: func(ctx context.Context, c *Conn) error { return nil },
|
||||
OnMessage: func(ctx context.Context, p []byte) error {
|
||||
mu.Lock()
|
||||
got = append(got, string(p))
|
||||
mu.Unlock()
|
||||
received <- string(p)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
WithBackoff(NewBackoff(10*time.Millisecond, 50*time.Millisecond)),
|
||||
WithLogger(logger.New(logger.Options{Out: io.Discard})),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- client.Run(ctx) }()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case m := <-received:
|
||||
if m != "msg-" + string(rune('1'+i)) {
|
||||
t.Errorf("message[%d] = %q", i, m)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("timed out waiting for message %d", i+1)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Error("Run should return a non-nil error on cancel")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not exit after cancel")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("received %d messages, want 2", len(got))
|
||||
}
|
||||
if conns.Load() < 2 {
|
||||
t.Errorf("connection count = %d, want >= 2 (reconnect)", conns.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_HeartbeatVerifiesPing sends an s=2-like heartbeat every 20ms and
|
||||
// asserts the dial URL is still the one provided.
|
||||
func TestClient_OnConnectErrorTriggersRedial(t *testing.T) {
|
||||
var dials atomic.Int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := wsu.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
url := "ws://" + strings.TrimPrefix(srv.URL, "http://") + "/testws"
|
||||
|
||||
// OnConnect always errors so the client keeps rejecting each session and the
|
||||
// dial fires repeatedly.
|
||||
client := New(
|
||||
func(context.Context) (string, error) { dials.Add(1); return url, nil },
|
||||
Handler{
|
||||
OnConnect: func(ctx context.Context, c *Conn) error { return context.DeadlineExceeded },
|
||||
},
|
||||
WithBackoff(NewBackoff(5*time.Millisecond, 20*time.Millisecond)),
|
||||
WithLogger(logger.New(logger.Options{Out: io.Discard})),
|
||||
)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- client.Run(ctx) }()
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run did not exit")
|
||||
}
|
||||
if dials.Load() < 2 {
|
||||
t.Errorf("dials = %d, want >= 2 (reconnect on failed connect hook)", dials.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package wsutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AppendQuery returns rawURL with the given query parameters added, preserving
|
||||
// any existing query string. Parameters with an empty value are omitted. It is
|
||||
// used by stateful gateway clients to splice resume parameters (resume/sn/
|
||||
// session_id) and access tokens onto a gateway URL before reconnecting. Keys are
|
||||
// appended in sorted order so the result is deterministic for tests.
|
||||
func AppendQuery(rawURL string, params map[string]string) string {
|
||||
if len(params) == 0 {
|
||||
return rawURL
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return appendNaive(rawURL, params)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
pairs := existingPairs(u.Query())
|
||||
for _, k := range keys {
|
||||
if params[k] == "" {
|
||||
continue
|
||||
}
|
||||
pairs = append(pairs, url.QueryEscape(k)+"="+url.QueryEscape(params[k]))
|
||||
}
|
||||
u.RawQuery = strings.Join(pairs, "&")
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// existingPairs flattens url.Values into ordered "k=v" pairs so that appending
|
||||
// new parameters produces a deterministic query string. Multi-valued keys keep
|
||||
// their original value order.
|
||||
func existingPairs(v url.Values) []string {
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([]string, 0, len(v))
|
||||
for _, k := range keys {
|
||||
for _, val := range v[k] {
|
||||
pairs = append(pairs, url.QueryEscape(k)+"="+url.QueryEscape(val))
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// appendNaive appends query parameters to a URL that could not be parsed, using
|
||||
// '?' or '&' based on what is already present.
|
||||
func appendNaive(rawURL string, params map[string]string) string {
|
||||
sep := "?"
|
||||
if strings.Contains(rawURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(rawURL)
|
||||
b.WriteString(sep)
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
first := true
|
||||
for _, k := range keys {
|
||||
if params[k] == "" {
|
||||
continue
|
||||
}
|
||||
if !first {
|
||||
b.WriteString("&")
|
||||
}
|
||||
first = false
|
||||
b.WriteString(url.QueryEscape(k) + "=" + url.QueryEscape(params[k]))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package wsutil
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAppendQuery_PreservesExisting(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := AppendQuery("wss://gw.example.com/gateway?compress=0&token=abc", map[string]string{
|
||||
"resume": "1", "sn": "5", "session_id": "sess-123",
|
||||
})
|
||||
want := "wss://gw.example.com/gateway?compress=0&token=abc&resume=1&session_id=sess-123&sn=5"
|
||||
if got != want {
|
||||
t.Errorf("AppendQuery = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendQuery_NoExisting(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := AppendQuery("wss://gw.example.com/gateway", map[string]string{"a": "1", "b": "2"})
|
||||
want := "wss://gw.example.com/gateway?a=1&b=2"
|
||||
if got != want {
|
||||
t.Errorf("AppendQuery = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendQuery_OmitsEmptyValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := AppendQuery("wss://gw.example.com/gateway?x=1", map[string]string{"sn": "", "resume": "1"})
|
||||
want := "wss://gw.example.com/gateway?x=1&resume=1"
|
||||
if got != want {
|
||||
t.Errorf("AppendQuery = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendQuery_EmptyParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := AppendQuery("wss://gw.example.com/gateway?x=1", nil)
|
||||
if got != "wss://gw.example.com/gateway?x=1" {
|
||||
t.Errorf("AppendQuery nil = %q, want unchanged", got)
|
||||
}
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func (b *Bridge) snapshot() []adapter.PlatformAdapter {
|
||||
func (b *Bridge) configured(a adapter.PlatformAdapter) bool {
|
||||
cfg := a.Config()
|
||||
switch basePlatform(a.Name()) {
|
||||
case "telegram", "weixin-ilink":
|
||||
case "telegram", "weixin-ilink", "kook":
|
||||
return cfgStr(cfg, "bot_token") != ""
|
||||
case "qq-obv11":
|
||||
if cfgStr(cfg, "mode") == "client" {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package kook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter/httpclient"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter/wsutil"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// Option customizes an Adapter at construction time.
|
||||
type Option func(*Adapter)
|
||||
|
||||
// WithHTTPClient injects the *http.Client used for REST calls. Tests pass the
|
||||
// client of an httptest.Server.
|
||||
func WithHTTPClient(c *http.Client) Option {
|
||||
return func(a *Adapter) { a.client = c }
|
||||
}
|
||||
|
||||
// WithBaseURL overrides the KOOK REST API root (default https://www.kookapp.cn/api/v3).
|
||||
// Tests point it at an httptest.Server URL.
|
||||
func WithBaseURL(base string) Option {
|
||||
return func(a *Adapter) {
|
||||
if base != "" {
|
||||
a.baseURL = base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger injects the logger used by the adapter. When nil the adapter logs
|
||||
// through a discard logger so a bare adapter stays silent.
|
||||
func WithLogger(l *slog.Logger) Option {
|
||||
return func(a *Adapter) {
|
||||
if l == nil {
|
||||
l = logger.New(logger.Options{Out: io.Discard})
|
||||
}
|
||||
a.log = logger.WithSource(l, "plugins/channels/kook")
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter is the KOOK (KaiHeiLa) implementation of adapter.PlatformAdapter. It
|
||||
// is safe for concurrent use: the gateway client runs in its own goroutine while
|
||||
// REST sends and inbound dispatch use their own mutexes.
|
||||
type Adapter struct {
|
||||
cfg Config
|
||||
baseURL string
|
||||
client *http.Client
|
||||
http *httpclient.Client
|
||||
log *slog.Logger
|
||||
|
||||
wsc *wsutil.Client
|
||||
wsMu sync.Mutex
|
||||
mu sync.RWMutex
|
||||
|
||||
connected bool
|
||||
closed bool
|
||||
cancel context.CancelFunc
|
||||
|
||||
handlerMu sync.RWMutex
|
||||
handler adapter.MessageHandler
|
||||
emitMu sync.RWMutex
|
||||
emit func(adapter.UnifiedMessage)
|
||||
|
||||
sessMu sync.RWMutex
|
||||
sessionID string
|
||||
lastSN int64
|
||||
resumeOK bool
|
||||
|
||||
chatsMu sync.RWMutex
|
||||
chats map[string]adapter.ChannelInfo
|
||||
}
|
||||
|
||||
// Compile-time assertion that Adapter satisfies the shared contract.
|
||||
var _ adapter.PlatformAdapter = (*Adapter)(nil)
|
||||
|
||||
// New builds a KOOK adapter. It performs no network I/O; call Connect to
|
||||
// establish the session.
|
||||
func New(cfg Config, opts ...Option) *Adapter {
|
||||
a := &Adapter{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: defaultHTTPTimeout},
|
||||
baseURL: "",
|
||||
log: logger.WithSource(logger.New(logger.Options{Out: io.Discard}), "plugins/channels/kook"),
|
||||
chats: map[string]adapter.ChannelInfo{},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(a)
|
||||
}
|
||||
if a.baseURL == "" {
|
||||
if cfg.BaseURL != "" {
|
||||
a.baseURL = cfg.BaseURL
|
||||
} else {
|
||||
a.baseURL = defaultBaseURL
|
||||
}
|
||||
}
|
||||
a.http = httpclient.New(
|
||||
httpclient.WithBaseURL(a.baseURL),
|
||||
httpclient.WithToken(cfg.BotToken),
|
||||
httpclient.WithHTTPClient(a.client),
|
||||
httpclient.WithLogger(a.log),
|
||||
)
|
||||
return a
|
||||
}
|
||||
|
||||
// Name returns the platform identifier.
|
||||
func (a *Adapter) Name() string { return platformName }
|
||||
|
||||
// Config returns a snapshot of the adapter's configuration. The BotToken is the
|
||||
// sensitive field; callers that persist this map must treat it as a secret.
|
||||
func (a *Adapter) Config() map[string]any {
|
||||
return map[string]any{
|
||||
"bot_token": a.cfg.BotToken,
|
||||
"base_url": a.baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes the session and starts the gateway client. It validates
|
||||
// the token first via a gateway-address lookup (failing fast on a bad token),
|
||||
// then launches the WS read loop in its own goroutine. It is idempotent.
|
||||
func (a *Adapter) Connect(ctx context.Context) error {
|
||||
a.mu.Lock()
|
||||
if a.connected {
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if a.closed {
|
||||
a.mu.Unlock()
|
||||
return fmt.Errorf("kook: %w", adapter.ErrClosed)
|
||||
}
|
||||
if err := a.cfg.Validate(); err != nil {
|
||||
a.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if _, err := a.fetchGatewayURL(ctx); err != nil {
|
||||
return fmt.Errorf("kook: connect: %w", err)
|
||||
}
|
||||
|
||||
lifeCtx, cancel := context.WithCancel(context.Background())
|
||||
a.mu.Lock()
|
||||
a.cancel = cancel
|
||||
a.connected = true
|
||||
a.mu.Unlock()
|
||||
|
||||
go a.runWS(lifeCtx)
|
||||
a.log.Info("connected", "token_len", len(a.cfg.BotToken))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect tears down the session, canceling the gateway client. It is
|
||||
// idempotent.
|
||||
func (a *Adapter) Disconnect(ctx context.Context) error {
|
||||
a.mu.Lock()
|
||||
if !a.connected {
|
||||
a.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
a.connected = false
|
||||
cancel := a.cancel
|
||||
a.cancel = nil
|
||||
a.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
a.wsMu.Lock()
|
||||
wsc := a.wsc
|
||||
a.wsMu.Unlock()
|
||||
if wsc != nil {
|
||||
_ = wsc.Close()
|
||||
}
|
||||
a.log.Info("disconnected")
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsConnected reports whether the session is currently live.
|
||||
func (a *Adapter) IsConnected() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.connected
|
||||
}
|
||||
|
||||
// HealthCheck verifies the session is usable. The connection state is the
|
||||
// liveness signal; KOOK has no cheap keepalive call used here.
|
||||
func (a *Adapter) HealthCheck(ctx context.Context) error {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.closed {
|
||||
return fmt.Errorf("kook: health check: %w", adapter.ErrClosed)
|
||||
}
|
||||
if !a.connected {
|
||||
return fmt.Errorf("kook: health check: %w", adapter.ErrNotConnected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadMessages registers emit as the pull-based sink and blocks until ctx is
|
||||
// canceled. Inbound events arrive via the gateway read loop and are delivered to
|
||||
// SetMessageHandler (push) when set, otherwise to emit.
|
||||
func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMessage)) {
|
||||
a.emitMu.Lock()
|
||||
a.emit = emit
|
||||
a.emitMu.Unlock()
|
||||
defer func() {
|
||||
a.emitMu.Lock()
|
||||
a.emit = nil
|
||||
a.emitMu.Unlock()
|
||||
}()
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
// SetMessageHandler installs the push-style inbound callback. A nil fn clears it.
|
||||
func (a *Adapter) SetMessageHandler(fn adapter.MessageHandler) {
|
||||
a.handlerMu.Lock()
|
||||
a.handler = fn
|
||||
a.handlerMu.Unlock()
|
||||
}
|
||||
|
||||
// Capabilities declares what the KOOK platform supports. Proactive push is
|
||||
// enabled (KOOK permits arbitrary private/channel sends); markdown is sent as
|
||||
// kmarkdown (type 9). Media reply is not advertised because KOOK only accepts
|
||||
// bot-uploaded media URLs.
|
||||
func (a *Adapter) Capabilities() adapter.Capabilities {
|
||||
return adapter.Capabilities{
|
||||
MaxMessageLength: maxMessageLen,
|
||||
Markdown: true,
|
||||
Image: false,
|
||||
Voice: false,
|
||||
Reaction: false,
|
||||
TypingHint: false,
|
||||
RecommendBurstMax: 1,
|
||||
Proactive: true,
|
||||
}.Normalize()
|
||||
}
|
||||
|
||||
// ListChannels returns the conversations observed so far, ordered by ChannelID.
|
||||
func (a *Adapter) ListChannels(ctx context.Context) ([]adapter.ChannelInfo, error) {
|
||||
a.chatsMu.RLock()
|
||||
defer a.chatsMu.RUnlock()
|
||||
out := make([]adapter.ChannelInfo, 0, len(a.chats))
|
||||
for _, c := range a.chats {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// requireConnected guards operations that need a live session.
|
||||
func (a *Adapter) requireConnected() error {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.closed {
|
||||
return fmt.Errorf("kook: %w", adapter.ErrClosed)
|
||||
}
|
||||
if !a.connected {
|
||||
return fmt.Errorf("kook: %w", adapter.ErrNotConnected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rememberChannel caches a conversation so ListChannels can report it.
|
||||
func (a *Adapter) rememberChannel(msg adapter.UnifiedMessage) {
|
||||
if msg.ChannelID == "" {
|
||||
return
|
||||
}
|
||||
info := adapter.ChannelInfo{
|
||||
ChannelID: msg.ChannelID,
|
||||
ChannelType: msg.ChannelType,
|
||||
GroupID: msg.GroupID,
|
||||
GroupName: msg.GroupName,
|
||||
ChannelName: msg.GroupName,
|
||||
}
|
||||
if msg.ChannelType == adapter.ChannelGroup {
|
||||
if info.ChannelName == "" {
|
||||
info.ChannelName = info.ChannelID
|
||||
}
|
||||
if info.GroupName == "" {
|
||||
info.GroupName = info.GroupID
|
||||
}
|
||||
} else {
|
||||
// A private conversation is the peer user; show their name.
|
||||
info.ChannelName = msg.SenderName
|
||||
if info.ChannelName == "" {
|
||||
info.ChannelName = info.ChannelID
|
||||
}
|
||||
}
|
||||
a.chatsMu.Lock()
|
||||
a.chats[info.ChannelID] = info
|
||||
a.chatsMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package kook implements the built-in KOOK (KaiHeiLa) channel adapter for
|
||||
// Cyrene v2. KOOK is a stateful WebSocket-gateway channel: inbound events are
|
||||
// delivered over a gateway WebSocket (hello/heartbeat/event/resume signaling),
|
||||
// while outbound messages are sent over a bearer-authenticated REST API.
|
||||
//
|
||||
// The adapter conforms to internal/channel/adapter.PlatformAdapter, translating
|
||||
// native KOOK events into the normalized adapter.UnifiedMessage model and
|
||||
// delivering replies via /api/v3/message/create (channels) and
|
||||
// /api/v3/direct-message/create (private chat). Identity is carried as channel
|
||||
// actor metadata (author id + nickname) only — owner/member resolution is the
|
||||
// channel layer's job, never a prompt-text heuristic.
|
||||
//
|
||||
// The design authority is DevDocs/design/08-渠道接入.md §4 (kook) and §7
|
||||
// (framework reusability). It depends on internal/channel/adapter (including the
|
||||
// shared wsutil and httpclient primitives), internal/logger, and the standard
|
||||
// library. The bot token is never logged.
|
||||
package kook
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defaults and knobs for the KOOK integration.
|
||||
const (
|
||||
// defaultBaseURL is the KOOK REST API root.
|
||||
defaultBaseURL = "https://www.kookapp.cn/api/v3"
|
||||
// defaultHTTPTimeout bounds a single REST request.
|
||||
defaultHTTPTimeout = 30 * time.Second
|
||||
// gatewayPath is the gateway-address fetch endpoint relative to baseURL.
|
||||
gatewayPath = "/gateway/index"
|
||||
// maxMessageLen is KOOK's documented per-message content limit.
|
||||
maxMessageLen = 8000
|
||||
// heartbeatInterval is how often the client sends a ping (KOOK: 30s).
|
||||
heartbeatInterval = 30 * time.Second
|
||||
// helloTimeout bounds the wait for the handshake (hello/resume ack).
|
||||
helloTimeout = 6 * time.Second
|
||||
)
|
||||
|
||||
// Config holds the settings required to reach the KOOK API.
|
||||
type Config struct {
|
||||
// BotToken is the secret bot token issued by the KOOK developer center. It is
|
||||
// surfaced through Config() but never written to logs.
|
||||
BotToken string `json:"bot_token"`
|
||||
// BaseURL is the KOOK REST API root; empty uses the production default.
|
||||
BaseURL string `json:"base_url"`
|
||||
}
|
||||
|
||||
// Validate checks the configuration for values needed to connect.
|
||||
func (c Config) Validate() error {
|
||||
if strings.TrimSpace(c.BotToken) == "" {
|
||||
return ErrMissingToken
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package kook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
func TestName(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
if got := a.Name(); got != "kook" {
|
||||
t.Fatalf("Name() = %q, want kook", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "abc123", BaseURL: "https://example.com/api/v3"})
|
||||
got := a.Config()
|
||||
if got["bot_token"] != "abc123" {
|
||||
t.Fatalf("bot_token = %v, want abc123", got["bot_token"])
|
||||
}
|
||||
if got["base_url"] != "https://example.com/api/v3" {
|
||||
t.Fatalf("base_url = %v, want https://example.com/api/v3", got["base_url"])
|
||||
}
|
||||
var round Config
|
||||
if err := adapter.DecodeConfig(got, &round); err != nil {
|
||||
t.Fatalf("DecodeConfig: %v", err)
|
||||
}
|
||||
if round.BotToken != "abc123" || round.BaseURL != "https://example.com/api/v3" {
|
||||
t.Fatalf("round trip mismatch: %+v", round)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := (Config{BotToken: "t"}).Validate(); got != nil {
|
||||
t.Fatalf("Validate with token = %v, want nil", got)
|
||||
}
|
||||
if err := (Config{}).Validate(); !errors.Is(err, ErrMissingToken) {
|
||||
t.Fatalf("Validate blank = %v, want ErrMissingToken", err)
|
||||
}
|
||||
if err := (Config{BotToken: " "}).Validate(); !errors.Is(err, ErrMissingToken) {
|
||||
t.Fatalf("Validate whitespace = %v, want ErrMissingToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilities(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
caps := a.Capabilities()
|
||||
if !caps.Proactive {
|
||||
t.Error("Proactive should be true for kook")
|
||||
}
|
||||
if !caps.Markdown {
|
||||
t.Error("kook should support kmarkdown")
|
||||
}
|
||||
if caps.Image || caps.Voice || caps.Reaction || caps.TypingHint {
|
||||
t.Errorf("expected Image/Voice/Reaction/TypingHint unsupported, got %+v", caps)
|
||||
}
|
||||
if caps.MaxMessageLength != maxMessageLen {
|
||||
t.Errorf("MaxMessageLength = %d, want %d", caps.MaxMessageLength, maxMessageLen)
|
||||
}
|
||||
if caps.RecommendBurstMax <= 0 {
|
||||
t.Errorf("RecommendBurstMax should be positive, got %d", caps.RecommendBurstMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListChannels_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
got, err := a.ListChannels(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListChannels: %v", err)
|
||||
}
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Fatalf("expected empty non-nil slice, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnect_MissingToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{})
|
||||
err := a.Connect(context.Background())
|
||||
if !errors.Is(err, ErrMissingToken) {
|
||||
t.Fatalf("err = %v, want ErrMissingToken", err)
|
||||
}
|
||||
if a.IsConnected() {
|
||||
t.Fatal("should not be connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck_NotConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
err := a.HealthCheck(context.Background())
|
||||
if !errors.Is(err, adapter.ErrNotConnected) {
|
||||
t.Fatalf("err = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_NotConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
err := a.SendMessage(context.Background(), adapter.UnifiedResponse{})
|
||||
if !errors.Is(err, adapter.ErrNotConnected) {
|
||||
t.Fatalf("err = %v, want ErrNotConnected", err)
|
||||
}
|
||||
if err := a.SendProactive(context.Background(), adapter.UnifiedMessage{}); !errors.Is(err, adapter.ErrNotConnected) {
|
||||
t.Fatalf("SendProactive err = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package kook
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors returned by the KOOK adapter. Consumers test for them with
|
||||
// errors.Is; failures that mean "not connected" wrap adapter.ErrNotConnected so
|
||||
// the shared channel sentinel still matches.
|
||||
var (
|
||||
// ErrMissingToken is returned when no BotToken is configured.
|
||||
ErrMissingToken = errors.New("kook: missing bot token")
|
||||
// ErrBadResponse is returned when the KOOK API replies with a non-zero code,
|
||||
// an unexpected payload shape, or no usable gateway URL.
|
||||
ErrBadResponse = errors.New("kook: bad api response")
|
||||
// ErrNoChatTarget is returned when an outbound message carries no usable
|
||||
// channel or user identifier.
|
||||
ErrNoChatTarget = errors.New("kook: message has no target")
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package kook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// gatewayResult is the "data" payload of GET /gateway/index.
|
||||
type gatewayResult struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// fetchGatewayURL retrieves the gateway WebSocket address from the KOOK REST
|
||||
// API. compress=0 requests uncompressed delivery so every server->client frame is
|
||||
// plain JSON text, matching wsutil's reader.
|
||||
func (a *Adapter) fetchGatewayURL(ctx context.Context) (string, error) {
|
||||
data, err := a.http.GetJSON(ctx, gatewayPath, url.Values{"compress": {"0"}})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kook: fetch gateway: %w", err)
|
||||
}
|
||||
var res gatewayResult
|
||||
if err := json.Unmarshal(data, &res); err != nil {
|
||||
return "", fmt.Errorf("kook: decode gateway result: %w", err)
|
||||
}
|
||||
if res.URL == "" {
|
||||
return "", fmt.Errorf("kook: gateway url empty: %w", ErrBadResponse)
|
||||
}
|
||||
return res.URL, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package kook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// discardLogger returns a silent, source-tagged logger for tests.
|
||||
func discardLogger() *slog.Logger {
|
||||
return logger.WithSource(logger.New(logger.Options{Out: io.Discard}), "plugins/channels/kook")
|
||||
}
|
||||
|
||||
// newTestAdapter wires a KOOK adapter to an httptest server whose handler
|
||||
// answers the REST API (and optionally upgrades a WebSocket). The server is
|
||||
// closed on test cleanup.
|
||||
func newTestAdapter(t *testing.T, h http.Handler) (*Adapter, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
a := New(
|
||||
Config{BotToken: "testtoken"},
|
||||
WithBaseURL(srv.URL),
|
||||
WithHTTPClient(srv.Client()),
|
||||
WithLogger(discardLogger()),
|
||||
)
|
||||
return a, srv
|
||||
}
|
||||
|
||||
// decodeBody unmarshals an incoming JSON request body into a map for assertions.
|
||||
func decodeBody(r *http.Request) map[string]any {
|
||||
var m map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// kookOK writes a KOOK success envelope with the given data payload.
|
||||
func kookOK(w http.ResponseWriter, data any) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "message": "ok", "data": data})
|
||||
}
|
||||
|
||||
// kookFail writes a KOOK error envelope with the given code/message.
|
||||
func kookFail(w http.ResponseWriter, code int, msg string) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": code, "message": msg, "data": struct{}{}})
|
||||
}
|
||||
|
||||
// wsUpgrader upgrades an HTTP request to a WebSocket for gateway tests.
|
||||
var wsUpgrader = websocket.Upgrader{}
|
||||
@@ -0,0 +1,202 @@
|
||||
package kook
|
||||
|
||||
// This file carries the KOOK protocol types and the conversion between a native
|
||||
// s=0 event payload and the normalized adapter.UnifiedMessage model. KOOK events
|
||||
// are delivered over the gateway WebSocket: channel_type distinguishes group
|
||||
// (GROUP/BROADCAST) from private (PERSON), type declares the content kind
|
||||
// (1 text / 2 image / 3 video / 4 file / 8 audio / 9 kmarkdown / 10 card /
|
||||
// 255 system), author_id==1 marks a system sender, and extra.author carries the
|
||||
// sender's user object (id/username/nickname).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
const platformName = "kook"
|
||||
|
||||
// KOOK message content kinds (the "type" field of an s=0 event).
|
||||
const (
|
||||
kindText = 1
|
||||
kindImage = 2
|
||||
kindVideo = 3
|
||||
kindFile = 4
|
||||
kindAudio = 8
|
||||
kindKMarkdown = 9
|
||||
kindCard = 10
|
||||
kindSystem = 255
|
||||
)
|
||||
|
||||
// KOOK channel types (the "channel_type" field of an s=0 event).
|
||||
const (
|
||||
channelGroup = "GROUP"
|
||||
channelBroadcast = "BROADCAST"
|
||||
)
|
||||
|
||||
// kookEvent is the "d" payload of an s=0 message event.
|
||||
type kookEvent struct {
|
||||
ChannelType string `json:"channel_type"`
|
||||
Type int `json:"type"`
|
||||
TargetID string `json:"target_id"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
MsgID string `json:"msg_id"`
|
||||
MsgTimestamp int64 `json:"msg_timestamp"`
|
||||
Nonce string `json:"nonce"`
|
||||
Extra json.RawMessage `json:"extra"`
|
||||
}
|
||||
|
||||
// kookExtra carries the per-event auxiliary data (author, guild/channel names,
|
||||
// mentions). KOOK's field set varies by message type, so all fields are optional.
|
||||
type kookExtra struct {
|
||||
Type int `json:"type"`
|
||||
GuildID string `json:"guild_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
Mention []string `json:"mention"`
|
||||
Author *kookUser `json:"author"`
|
||||
}
|
||||
|
||||
// kookUser is the sender's user object carried by extra.author.
|
||||
type kookUser struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
|
||||
// toUnifiedMessage converts a parsed s=0 event payload into a normalized
|
||||
// UnifiedMessage. It returns ok=false for events that carry no deliverable chat
|
||||
// message (system messages, non-chat notifications).
|
||||
func (a *Adapter) toUnifiedMessage(data json.RawMessage, frame []byte) (adapter.UnifiedMessage, bool) {
|
||||
var ev kookEvent
|
||||
if err := json.Unmarshal(data, &ev); err != nil {
|
||||
return adapter.UnifiedMessage{}, false
|
||||
}
|
||||
if ev.Type == kindSystem || ev.AuthorID == "1" {
|
||||
return adapter.UnifiedMessage{}, false
|
||||
}
|
||||
|
||||
content := asString(ev.Content)
|
||||
var ex kookExtra
|
||||
_ = json.Unmarshal(ev.Extra, &ex)
|
||||
|
||||
senderID, senderName := senderOf(&ev, &ex)
|
||||
ct, media := messageKind(ev.Type, content)
|
||||
group := channelIsGroup(ev.ChannelType)
|
||||
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: platformName,
|
||||
ChannelType: group,
|
||||
ChannelID: ev.TargetID,
|
||||
SenderID: senderID,
|
||||
SenderName: senderName,
|
||||
Content: content,
|
||||
ContentType: ct,
|
||||
Media: media,
|
||||
Mentions: mentionsFrom(ex.Mention),
|
||||
MessageID: ev.MsgID,
|
||||
Timestamp: time.UnixMilli(ev.MsgTimestamp),
|
||||
RawData: frame,
|
||||
Sender: adapter.Actor{
|
||||
Role: adapter.RoleUnknown,
|
||||
UserID: senderID,
|
||||
DisplayName: senderName,
|
||||
GroupID: "",
|
||||
GroupName: ex.ChannelName,
|
||||
IsGroup: bool(group == adapter.ChannelGroup),
|
||||
},
|
||||
}
|
||||
|
||||
if group == adapter.ChannelGroup {
|
||||
gid := ex.GuildID
|
||||
if gid == "" {
|
||||
gid = ev.TargetID
|
||||
}
|
||||
msg.GroupID = gid
|
||||
msg.GroupName = ex.ChannelName
|
||||
msg.Sender.GroupID = gid
|
||||
msg.Sender.GroupName = ex.ChannelName
|
||||
a.rememberChannel(msg)
|
||||
}
|
||||
|
||||
// Adjust ChannelID: for private messages target_id is the peer user id, which
|
||||
// is already set above; for group messages target_id is the channel id.
|
||||
return msg, true
|
||||
}
|
||||
|
||||
// senderOf resolves the sender id and display name from the event fields,
|
||||
// preferring the extra.author nickname (design/16 §4.6: third parties must be
|
||||
// shown by nickname) with fallbacks to username and the author_id.
|
||||
func senderOf(ev *kookEvent, ex *kookExtra) (id, name string) {
|
||||
id = ev.AuthorID
|
||||
name = ev.AuthorID
|
||||
if ex.Author == nil {
|
||||
return id, name
|
||||
}
|
||||
if ex.Author.ID != "" {
|
||||
id = ex.Author.ID
|
||||
}
|
||||
switch {
|
||||
case ex.Author.Nickname != "":
|
||||
name = ex.Author.Nickname
|
||||
case ex.Author.Username != "":
|
||||
name = ex.Author.Username
|
||||
default:
|
||||
name = id
|
||||
}
|
||||
return id, name
|
||||
}
|
||||
|
||||
// channelIsGroup maps a KOOK channel_type onto the adapter's ChannelType.
|
||||
func channelIsGroup(t string) adapter.ChannelType {
|
||||
if t == channelGroup || t == channelBroadcast {
|
||||
return adapter.ChannelGroup
|
||||
}
|
||||
return adapter.ChannelDirect
|
||||
}
|
||||
|
||||
// messageKind maps a KOOK content-kind onto the adapter ContentType and the
|
||||
// media list (media URL is carried by the content field for image/video/file/
|
||||
// audio messages).
|
||||
func messageKind(kind int, content string) (adapter.ContentType, []adapter.Media) {
|
||||
switch kind {
|
||||
case kindImage:
|
||||
return adapter.ContentImage, []adapter.Media{{Kind: adapter.MediaImage, URL: content}}
|
||||
case kindVideo:
|
||||
return adapter.ContentFile, []adapter.Media{{Kind: adapter.MediaVideo, URL: content}}
|
||||
case kindFile:
|
||||
return adapter.ContentFile, []adapter.Media{{Kind: adapter.MediaFile, URL: content}}
|
||||
case kindAudio:
|
||||
return adapter.ContentVoice, []adapter.Media{{Kind: adapter.MediaVoice, URL: content}}
|
||||
default:
|
||||
return adapter.ContentText, nil
|
||||
}
|
||||
}
|
||||
|
||||
// mentionsFrom converts an array of mentioned user ids into adapter.Mention.
|
||||
func mentionsFrom(ids []string) []adapter.Mention {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]adapter.Mention, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id != "" {
|
||||
out = append(out, adapter.Mention{UserID: id})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// asString decodes a raw JSON value that is expected to be a string, returning
|
||||
// "" when it is not.
|
||||
func asString(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package kook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
func TestToUnified_GroupText(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
data := json.RawMessage(`{
|
||||
"channel_type":"GROUP","type":1,"target_id":"chan-1","author_id":"u-1",
|
||||
"content":"hello world","msg_id":"m-1","msg_timestamp":1700000000000,"nonce":"",
|
||||
"extra":{"type":1,"guild_id":"g-1","channel_name":"test","mention":[],"author":{"id":"u-1","username":"alice","nickname":"Alice"}}
|
||||
}`)
|
||||
msg, ok := a.toUnifiedMessage(data, []byte(`{"s":0,"d":{}}`))
|
||||
if !ok {
|
||||
t.Fatal("expected a deliverable message")
|
||||
}
|
||||
if msg.Platform != "kook" {
|
||||
t.Errorf("Platform = %q, want kook", msg.Platform)
|
||||
}
|
||||
if msg.ChannelType != adapter.ChannelGroup {
|
||||
t.Errorf("ChannelType = %q, want group", msg.ChannelType)
|
||||
}
|
||||
if msg.ChannelID != "chan-1" {
|
||||
t.Errorf("ChannelID = %q, want chan-1", msg.ChannelID)
|
||||
}
|
||||
if msg.GroupID != "g-1" {
|
||||
t.Errorf("GroupID = %q, want g-1", msg.GroupID)
|
||||
}
|
||||
if msg.GroupName != "test" {
|
||||
t.Errorf("GroupName = %q, want test", msg.GroupName)
|
||||
}
|
||||
if msg.SenderID != "u-1" || msg.SenderName != "Alice" {
|
||||
t.Errorf("Sender = %q/%q, want u-1/Alice", msg.SenderID, msg.SenderName)
|
||||
}
|
||||
if msg.Sender.DisplayName != "Alice" || msg.Sender.UserID != "u-1" {
|
||||
t.Errorf("Actor = %+v, want DisplayName Alice/UserID u-1", msg.Sender)
|
||||
}
|
||||
if msg.Content != "hello world" || msg.ContentType != adapter.ContentText {
|
||||
t.Errorf("Content = %q/%q, want hello world/text", msg.Content, msg.ContentType)
|
||||
}
|
||||
if msg.MessageID != "m-1" || msg.Timestamp.UnixMilli() != 1700000000000 {
|
||||
t.Errorf("MessageID/Timestamp = %q/%d", msg.MessageID, msg.Timestamp.UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
func TestToUnified_PrivateText(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
data := json.RawMessage(`{
|
||||
"channel_type":"PERSON","type":1,"target_id":"user-9","author_id":"user-9",
|
||||
"content":"hi","msg_id":"m-2","msg_timestamp":1700000000000,"nonce":"",
|
||||
"extra":{"type":1,"author":{"id":"user-9","username":"bob","nickname":"Bob"}}
|
||||
}`)
|
||||
msg, ok := a.toUnifiedMessage(data, nil)
|
||||
if !ok {
|
||||
t.Fatal("expected a deliverable message")
|
||||
}
|
||||
if msg.ChannelType != adapter.ChannelDirect {
|
||||
t.Errorf("ChannelType = %q, want direct", msg.ChannelType)
|
||||
}
|
||||
if msg.ChannelID != "user-9" {
|
||||
t.Errorf("ChannelID = %q, want user-9 (peer id)", msg.ChannelID)
|
||||
}
|
||||
if msg.GroupID != "" {
|
||||
t.Errorf("GroupID should be empty for private, got %q", msg.GroupID)
|
||||
}
|
||||
if msg.SenderName != "Bob" {
|
||||
t.Errorf("SenderName = %q, want Bob", msg.SenderName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToUnified_KMarkdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
data := json.RawMessage(`{
|
||||
"channel_type":"GROUP","type":9,"target_id":"chan-2","author_id":"u-2",
|
||||
"content":"*bold*","msg_id":"m-3","msg_timestamp":1700000000000,"nonce":"",
|
||||
"extra":{"type":9,"guild_id":"g-2","channel_name":"c","author":{"id":"u-2","username":"u","nickname":"N"}}
|
||||
}`)
|
||||
msg, ok := a.toUnifiedMessage(data, nil)
|
||||
if !ok {
|
||||
t.Fatal("expected a deliverable message")
|
||||
}
|
||||
if msg.ContentType != adapter.ContentText || msg.Content != "*bold*" {
|
||||
t.Errorf("kmarkdown should map to text, got %q/%q", msg.Content, msg.ContentType)
|
||||
}
|
||||
if msg.GroupID != "g-2" {
|
||||
t.Errorf("GroupID = %q, want g-2", msg.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToUnified_Image(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
data := json.RawMessage(`{
|
||||
"channel_type":"GROUP","type":2,"target_id":"chan-3","author_id":"u-3",
|
||||
"content":"https://img/photo.jpg","msg_id":"m-4","msg_timestamp":1700000000000,"nonce":"",
|
||||
"extra":{"type":2,"guild_id":"g-3","author":{"id":"u-3","username":"u","nickname":"N"}}
|
||||
}`)
|
||||
msg, ok := a.toUnifiedMessage(data, nil)
|
||||
if !ok {
|
||||
t.Fatal("expected a deliverable message")
|
||||
}
|
||||
if msg.ContentType != adapter.ContentImage {
|
||||
t.Errorf("ContentType = %q, want image", msg.ContentType)
|
||||
}
|
||||
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaImage || msg.Media[0].URL != "https://img/photo.jpg" {
|
||||
t.Errorf("Media = %+v, want image URL https://img/photo.jpg", msg.Media)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToUnified_VideoMapsToFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
data := json.RawMessage(`{
|
||||
"channel_type":"GROUP","type":3,"target_id":"c","author_id":"u",
|
||||
"content":"https://img/a.mp4","msg_id":"m","msg_timestamp":1700000000000,"nonce":"",
|
||||
"extra":{"type":3,"guild_id":"g","author":{"id":"u","username":"u","nickname":"N"}}
|
||||
}`)
|
||||
a := New(Config{BotToken: "x"})
|
||||
msg, ok := a.toUnifiedMessage(data, nil)
|
||||
if !ok {
|
||||
t.Fatal("expected a deliverable message")
|
||||
}
|
||||
if len(msg.Media) != 1 || msg.Media[0].Kind != adapter.MediaVideo {
|
||||
t.Fatalf("Media = %+v, want video", msg.Media)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToUnified_SystemSkipped(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
// type 255 system message → not a chat deliverable.
|
||||
if _, ok := a.toUnifiedMessage(json.RawMessage(`{"channel_type":"GROUP","type":255,"target_id":"c","author_id":"1","content":"{}","msg_id":"m","msg_timestamp":1,"extra":{}}`), nil); ok {
|
||||
t.Error("system message should be skipped")
|
||||
}
|
||||
// author_id 1 = system → skipped even for a normal type.
|
||||
if _, ok := a.toUnifiedMessage(json.RawMessage(`{"channel_type":"GROUP","type":1,"target_id":"c","author_id":"1","content":"x","msg_id":"m","msg_timestamp":1,"extra":{}}`), nil); ok {
|
||||
t.Error("author_id 1 (system) should be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMentions(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := New(Config{BotToken: "x"})
|
||||
data := json.RawMessage(`{
|
||||
"channel_type":"GROUP","type":1,"target_id":"c","author_id":"u",
|
||||
"content":"hello","msg_id":"m","msg_timestamp":1700000000000,"nonce":"",
|
||||
"extra":{"type":1,"guild_id":"g","mention":["u-1","u-2"],"author":{"id":"u","username":"u","nickname":"N"}}
|
||||
}`)
|
||||
msg, ok := a.toUnifiedMessage(data, nil)
|
||||
if !ok {
|
||||
t.Fatal("expected a deliverable message")
|
||||
}
|
||||
if len(msg.Mentions) != 2 || msg.Mentions[0].UserID != "u-1" || msg.Mentions[1].UserID != "u-2" {
|
||||
t.Errorf("Mentions = %+v, want u-1/u-2", msg.Mentions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package kook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// REST endpoints for sending KOOK messages.
|
||||
const (
|
||||
channelMessagePath = "/message/create" // channels (GROUP/BROADCAST)
|
||||
privateMessagePath = "/direct-message/create" // private chat
|
||||
)
|
||||
|
||||
// kookPostRequest is the shared body of the two KOOK send endpoints.
|
||||
type kookPostRequest struct {
|
||||
Type int `json:"type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Content string `json:"content"`
|
||||
Quote string `json:"quote,omitempty"`
|
||||
}
|
||||
|
||||
// SendMessage delivers the reply messages in resp. Each message goes to its own
|
||||
// channel (GROUP) or private conversation (PERSON/BROADCAST).
|
||||
func (a *Adapter) SendMessage(ctx context.Context, resp adapter.UnifiedResponse) error {
|
||||
if err := a.requireConnected(); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, m := range resp.Messages {
|
||||
if err := a.sendOne(ctx, m, resp.ReplyTo); err != nil {
|
||||
return fmt.Errorf("kook: send message #%d: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendProactive pushes a message not triggered by a recent inbound. KOOK allows
|
||||
// arbitrary proactive push, so this is only gated by the connection state.
|
||||
func (a *Adapter) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error {
|
||||
if err := a.requireConnected(); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.sendOne(ctx, msg, "")
|
||||
}
|
||||
|
||||
// sendOne delivers a single outbound message to its conversation, choosing the
|
||||
// send endpoint that matches the channel kind and the message type that matches
|
||||
// the content kind.
|
||||
func (a *Adapter) sendOne(ctx context.Context, m adapter.UnifiedMessage, replyTo string) error {
|
||||
group := m.ChannelType == adapter.ChannelGroup
|
||||
target := m.ChannelID
|
||||
if group {
|
||||
if target == "" {
|
||||
target = m.GroupID
|
||||
}
|
||||
} else {
|
||||
if target == "" {
|
||||
target = m.SenderID
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
return fmt.Errorf("kook: %w", ErrNoChatTarget)
|
||||
}
|
||||
|
||||
msgType, content := outboundType(m)
|
||||
req := kookPostRequest{Type: msgType, TargetID: target, Content: content}
|
||||
if replyTo != "" {
|
||||
req.Quote = replyTo
|
||||
}
|
||||
|
||||
path := channelMessagePath
|
||||
if !group {
|
||||
path = privateMessagePath
|
||||
}
|
||||
if _, err := a.http.DoJSON(ctx, http.MethodPost, path, req); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Debug("sent", "type", msgType, "target", target, "group", group)
|
||||
return nil
|
||||
}
|
||||
|
||||
// outboundType maps a unified message to a KOOK message type and content. Text
|
||||
// falls back to kmarkdown (type 9) because the adapter declares the Markdown
|
||||
// capability; media carries the first media URL in the content field.
|
||||
func outboundType(m adapter.UnifiedMessage) (int, string) {
|
||||
switch m.ContentType {
|
||||
case adapter.ContentImage:
|
||||
return kindImage, mediaURL(m.Media)
|
||||
case adapter.ContentVoice:
|
||||
return kindAudio, mediaURL(m.Media)
|
||||
case adapter.ContentFile:
|
||||
if mediaKind(m.Media) == adapter.MediaVideo {
|
||||
return kindVideo, mediaURL(m.Media)
|
||||
}
|
||||
return kindFile, mediaURL(m.Media)
|
||||
default:
|
||||
return kindKMarkdown, m.Content
|
||||
}
|
||||
}
|
||||
|
||||
// mediaURL returns the first non-empty media URL.
|
||||
func mediaURL(media []adapter.Media) string {
|
||||
for _, m := range media {
|
||||
if m.URL != "" {
|
||||
return m.URL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mediaKind returns the kind of the first media entry.
|
||||
func mediaKind(media []adapter.Media) adapter.MediaKind {
|
||||
for _, m := range media {
|
||||
return m.Kind
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package kook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
func TestSendMessage_GroupText(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
var body map[string]any
|
||||
var auth string
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != channelMessagePath {
|
||||
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
auth = r.Header.Get("Authorization")
|
||||
body = decodeBody(r)
|
||||
mu.Unlock()
|
||||
kookOK(w, map[string]any{"msg_id": "x-1", "msg_timestamp": 1})
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
a.connected = true
|
||||
resp := adapter.UnifiedResponse{
|
||||
ReplyTo: "reply-99",
|
||||
Messages: []adapter.UnifiedMessage{
|
||||
{ChannelID: "chan-1", ChannelType: adapter.ChannelGroup, Content: "hello", ContentType: adapter.ContentText},
|
||||
},
|
||||
}
|
||||
if err := a.SendMessage(context.Background(), resp); err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if auth != "Bearer testtoken" {
|
||||
t.Errorf("Authorization = %q, want Bearer testtoken", auth)
|
||||
}
|
||||
if body["target_id"] != "chan-1" {
|
||||
t.Errorf("target_id = %v, want chan-1", body["target_id"])
|
||||
}
|
||||
if body["type"] != float64(9) {
|
||||
t.Errorf("type = %v, want 9 (kmarkdown)", body["type"])
|
||||
}
|
||||
if body["content"] != "hello" {
|
||||
t.Errorf("content = %v, want hello", body["content"])
|
||||
}
|
||||
if body["quote"] != "reply-99" {
|
||||
t.Errorf("quote = %v, want reply-99", body["quote"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_PrivateText(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
var body map[string]any
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != privateMessagePath {
|
||||
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
body = decodeBody(r)
|
||||
mu.Unlock()
|
||||
kookOK(w, map[string]any{"msg_id": "x-2", "msg_timestamp": 1})
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
a.connected = true
|
||||
resp := adapter.UnifiedResponse{
|
||||
Messages: []adapter.UnifiedMessage{
|
||||
{ChannelID: "user-9", ChannelType: adapter.ChannelDirect, Content: "private hi", ContentType: adapter.ContentText},
|
||||
},
|
||||
}
|
||||
if err := a.SendMessage(context.Background(), resp); err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if body["target_id"] != "user-9" || body["content"] != "private hi" {
|
||||
t.Errorf("body = %v, want target_id user-9 content private hi", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_MediaTypeMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ct adapter.ContentType
|
||||
media adapter.Media
|
||||
wantType int
|
||||
}{
|
||||
{name: "image", ct: adapter.ContentImage, media: adapter.Media{Kind: adapter.MediaImage, URL: "https://img/a.jpg"}, wantType: 2},
|
||||
{name: "voice", ct: adapter.ContentVoice, media: adapter.Media{Kind: adapter.MediaVoice, URL: "https://img/a.mp3"}, wantType: 8},
|
||||
{name: "video", ct: adapter.ContentFile, media: adapter.Media{Kind: adapter.MediaVideo, URL: "https://img/a.mp4"}, wantType: 3},
|
||||
{name: "file", ct: adapter.ContentFile, media: adapter.Media{Kind: adapter.MediaFile, URL: "https://img/a.txt"}, wantType: 4},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
var body map[string]any
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != channelMessagePath {
|
||||
http.Error(w, "unexpected path", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
body = decodeBody(r)
|
||||
mu.Unlock()
|
||||
kookOK(w, map[string]any{"msg_id": "x", "msg_timestamp": 1})
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
a.connected = true
|
||||
resp := adapter.UnifiedResponse{
|
||||
Messages: []adapter.UnifiedMessage{
|
||||
{ChannelID: "chan-1", ChannelType: adapter.ChannelGroup, ContentType: tc.ct, Media: []adapter.Media{tc.media}},
|
||||
},
|
||||
}
|
||||
if err := a.SendMessage(context.Background(), resp); err != nil {
|
||||
t.Fatalf("SendMessage: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if body["type"] != float64(tc.wantType) {
|
||||
t.Errorf("type = %v, want %d", body["type"], tc.wantType)
|
||||
}
|
||||
if body["content"] != tc.media.URL {
|
||||
t.Errorf("content = %v, want %s", body["content"], tc.media.URL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendProactive_Private(t *testing.T) {
|
||||
t.Parallel()
|
||||
var mu sync.Mutex
|
||||
var body map[string]any
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != privateMessagePath {
|
||||
http.Error(w, "unexpected path", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
body = decodeBody(r)
|
||||
mu.Unlock()
|
||||
kookOK(w, map[string]any{"msg_id": "x-3", "msg_timestamp": 1})
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
a.connected = true
|
||||
if err := a.SendProactive(context.Background(), adapter.UnifiedMessage{
|
||||
ChannelID: "user-9", ChannelType: adapter.ChannelDirect, Content: "reminder", ContentType: adapter.ContentText,
|
||||
}); err != nil {
|
||||
t.Fatalf("SendProactive: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if body["target_id"] != "user-9" || body["content"] != "reminder" {
|
||||
t.Errorf("body = %v, want target_id user-9 content reminder", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessage_NoTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unexpected", http.StatusNotFound)
|
||||
}))
|
||||
a.connected = true
|
||||
err := a.SendMessage(context.Background(), adapter.UnifiedResponse{
|
||||
Messages: []adapter.UnifiedMessage{{ContentType: adapter.ContentText, Content: "hi"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing target")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package kook
|
||||
|
||||
// This file wires the stateful KOOK gateway WebSocket protocol onto the shared
|
||||
// wsutil client. KOOK is a signal-driven gateway: after connecting, the server
|
||||
// sends s=1 (hello) or s=6 (resume ack); the client heartbeats every ~30s with
|
||||
// s=2 and expects s=3; inbound events arrive as s=0; the server may force a
|
||||
// restart with s=5 (reconnect). The reconnect loop, heartbeat scheduling, and
|
||||
// read-loop skeleton are provided by wsutil; only the signaling lives here.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter/wsutil"
|
||||
)
|
||||
|
||||
// KOOK gateway signal numbers.
|
||||
const (
|
||||
signalEvent = 0
|
||||
signalHello = 1
|
||||
signalPing = 2
|
||||
signalPong = 3
|
||||
signalResume = 4
|
||||
signalReconnect = 5
|
||||
signalResumeAck = 6
|
||||
)
|
||||
|
||||
// kookSignal is the envelope of every gateway frame.
|
||||
type kookSignal struct {
|
||||
S int `json:"s"`
|
||||
D json.RawMessage `json:"d"`
|
||||
SN int64 `json:"sn"`
|
||||
}
|
||||
|
||||
// helloData is the "d" payload of an s=1 hello frame.
|
||||
type helloData struct {
|
||||
Code int `json:"code"`
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
// parseSignal decodes a gateway text frame.
|
||||
func parseSignal(payload []byte) (kookSignal, error) {
|
||||
var sig kookSignal
|
||||
if err := json.Unmarshal(payload, &sig); err != nil {
|
||||
return kookSignal{}, fmt.Errorf("kook: decode signal: %w", err)
|
||||
}
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
// heartbeatFrame builds the s=2 ping payload carrying the current sn.
|
||||
func heartbeatFrame(sn int64) ([]byte, error) {
|
||||
return json.Marshal(map[string]any{"s": signalPing, "sn": sn})
|
||||
}
|
||||
|
||||
// runWS starts the gateway client goroutine. It is idempotent: a second call
|
||||
// while one is running is a no-op. The client tears itself down when ctx ends.
|
||||
func (a *Adapter) runWS(ctx context.Context) {
|
||||
a.wsMu.Lock()
|
||||
if a.wsc != nil {
|
||||
a.wsMu.Unlock()
|
||||
return
|
||||
}
|
||||
client := wsutil.New(
|
||||
a.gatewayURL,
|
||||
wsutil.Handler{
|
||||
OnConnect: a.onConnect,
|
||||
OnMessage: a.onMessage,
|
||||
Heartbeat: a.heartbeat,
|
||||
},
|
||||
wsutil.WithHeartbeat(heartbeatInterval),
|
||||
wsutil.WithPongTimeout(helloTimeout),
|
||||
wsutil.WithLogger(a.log),
|
||||
)
|
||||
a.wsc = client
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
client.BindCancel(cancel)
|
||||
a.wsMu.Unlock()
|
||||
|
||||
_ = client.Run(runCtx)
|
||||
|
||||
a.wsMu.Lock()
|
||||
a.wsc = nil
|
||||
a.wsMu.Unlock()
|
||||
}
|
||||
|
||||
// gatewayURL returns the gateway address to dial. On a first connect (or after a
|
||||
// session reset) it returns a fresh URL; on a drop with an established session it
|
||||
// appends the resume parameters so the gateway can replay missed messages.
|
||||
func (a *Adapter) gatewayURL(ctx context.Context) (string, error) {
|
||||
base, err := a.fetchGatewayURL(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !a.canResume() {
|
||||
return base, nil
|
||||
}
|
||||
return wsutil.AppendQuery(base, map[string]string{
|
||||
"resume": "1",
|
||||
"sn": strconv.FormatInt(a.currentSN(), 10),
|
||||
"session_id": a.currentSession(),
|
||||
}), nil
|
||||
}
|
||||
|
||||
// onConnect performs the application handshake. It accepts either an s=1 hello
|
||||
// (fresh or restarted session) or an s=6 resume ack (reconnected with an intact
|
||||
// session) as the first frame within helloTimeout.
|
||||
func (a *Adapter) onConnect(ctx context.Context, c *wsutil.Conn) error {
|
||||
_ = c.SetReadDeadline(time.Now().Add(helloTimeout))
|
||||
payload, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("kook: hello read: %w", err)
|
||||
}
|
||||
sig, err := parseSignal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch sig.S {
|
||||
case signalHello:
|
||||
var hd helloData
|
||||
if err := json.Unmarshal(sig.D, &hd); err != nil {
|
||||
return fmt.Errorf("kook: hello decode: %w", err)
|
||||
}
|
||||
if hd.Code != 0 {
|
||||
// Rejected (e.g. invalid/expired token or expired session): drop the
|
||||
// whole session state so the next dial is fresh.
|
||||
a.resetSession()
|
||||
return fmt.Errorf("kook: hello rejected code=%d", hd.Code)
|
||||
}
|
||||
a.setSession(hd.SessionID, 0)
|
||||
case signalResumeAck:
|
||||
// Resume accepted; keep the existing session id and sn.
|
||||
a.markResumeOK()
|
||||
default:
|
||||
return fmt.Errorf("kook: expected hello or resume ack, got s=%d", sig.S)
|
||||
}
|
||||
_ = c.SetReadDeadline(time.Time{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// onMessage dispatches every inbound application frame. s=0 events are converted
|
||||
// to UnifiedMessage and delivered; control signals update session state or force
|
||||
// a reconnect.
|
||||
func (a *Adapter) onMessage(ctx context.Context, payload []byte) error {
|
||||
sig, err := parseSignal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch sig.S {
|
||||
case signalEvent:
|
||||
if err := a.handleEvent(ctx, sig.D, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
a.advanceSN(sig.SN)
|
||||
return nil
|
||||
case signalPong, signalResumeAck:
|
||||
return nil
|
||||
case signalReconnect:
|
||||
// Server invalidated the session: clear sn/session and re-dial fresh.
|
||||
a.resetSession()
|
||||
return errors.New("kook: gateway session invalid, reconnecting")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// heartbeat sends the s=2 ping carrying the current sn.
|
||||
func (a *Adapter) heartbeat(ctx context.Context, c *wsutil.Conn) error {
|
||||
data, err := heartbeatFrame(a.currentSN())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.WriteMessage(data)
|
||||
}
|
||||
|
||||
// handleEvent converts a single s=0 event into a UnifiedMessage and dispatches
|
||||
// it. Malformed or non-chat events are logged and skipped, never fatal.
|
||||
func (a *Adapter) handleEvent(ctx context.Context, data json.RawMessage, frame []byte) error {
|
||||
msg, ok := a.toUnifiedMessage(data, frame)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := msg.Validate(); err != nil {
|
||||
a.log.Warn("invalid message dropped", "err", err)
|
||||
return nil
|
||||
}
|
||||
a.dispatch(ctx, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatch routes an inbound message to the push handler, falling back to the
|
||||
// ReadMessages sink.
|
||||
func (a *Adapter) dispatch(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
a.handlerMu.RLock()
|
||||
fn := a.handler
|
||||
a.handlerMu.RUnlock()
|
||||
if fn != nil {
|
||||
fn(ctx, msg)
|
||||
return
|
||||
}
|
||||
a.emitMu.RLock()
|
||||
emit := a.emit
|
||||
a.emitMu.RUnlock()
|
||||
if emit != nil {
|
||||
emit(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// setSession records a fresh session (new id, sn reset) and marks it resumable.
|
||||
func (a *Adapter) setSession(sessionID string, sn int64) {
|
||||
a.sessMu.Lock()
|
||||
defer a.sessMu.Unlock()
|
||||
a.sessionID = sessionID
|
||||
a.lastSN = sn
|
||||
a.resumeOK = sessionID != ""
|
||||
}
|
||||
|
||||
// markResumeOK marks the current session as resumable after a resume ack.
|
||||
func (a *Adapter) markResumeOK() {
|
||||
a.sessMu.Lock()
|
||||
defer a.sessMu.Unlock()
|
||||
a.resumeOK = a.sessionID != ""
|
||||
}
|
||||
|
||||
// resetSession clears all session state so the next connect is a fresh one.
|
||||
func (a *Adapter) resetSession() {
|
||||
a.sessMu.Lock()
|
||||
defer a.sessMu.Unlock()
|
||||
a.sessionID = ""
|
||||
a.lastSN = 0
|
||||
a.resumeOK = false
|
||||
}
|
||||
|
||||
// advanceSN raises the tracked sn when a newer event is seen.
|
||||
func (a *Adapter) advanceSN(sn int64) {
|
||||
a.sessMu.Lock()
|
||||
defer a.sessMu.Unlock()
|
||||
if sn > a.lastSN {
|
||||
a.lastSN = sn
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) currentSession() string {
|
||||
a.sessMu.RLock()
|
||||
defer a.sessMu.RUnlock()
|
||||
return a.sessionID
|
||||
}
|
||||
|
||||
func (a *Adapter) currentSN() int64 {
|
||||
a.sessMu.RLock()
|
||||
defer a.sessMu.RUnlock()
|
||||
return a.lastSN
|
||||
}
|
||||
|
||||
func (a *Adapter) canResume() bool {
|
||||
a.sessMu.RLock()
|
||||
defer a.sessMu.RUnlock()
|
||||
return a.sessionID != "" && a.resumeOK
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package kook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
func TestParseSignal(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := []byte(`{"s":1,"d":{"code":0,"session_id":"sess-123"}}`)
|
||||
sig, err := parseSignal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSignal: %v", err)
|
||||
}
|
||||
if sig.S != signalHello {
|
||||
t.Errorf("S = %d, want %d (hello)", sig.S, signalHello)
|
||||
}
|
||||
if len(sig.D) == 0 {
|
||||
t.Error("D should carry the hello payload")
|
||||
}
|
||||
|
||||
s0 := []byte(`{"s":0,"sn":7,"d":{"channel_type":"GROUP"}}`)
|
||||
sig0, err := parseSignal(s0)
|
||||
if err != nil {
|
||||
t.Fatalf("parseSignal event: %v", err)
|
||||
}
|
||||
if sig0.S != signalEvent || sig0.SN != 7 {
|
||||
t.Errorf("event S/SN = %d/%d, want 0/7", sig0.S, sig0.SN)
|
||||
}
|
||||
|
||||
if sig, err := parseSignal([]byte(`{"s":3}`)); err != nil || sig.S != signalPong {
|
||||
t.Errorf("pong parse = %+v, %v", sig, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatFrame(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, err := heartbeatFrame(42)
|
||||
if err != nil {
|
||||
t.Fatalf("heartbeatFrame: %v", err)
|
||||
}
|
||||
if string(b) != `{"s":2,"sn":42}` {
|
||||
t.Errorf("heartbeat frame = %s, want {\"s\":2,\"sn\":42}", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayURL_FreshNoResume(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != gatewayPath {
|
||||
http.Error(w, "unexpected", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
kookOK(w, map[string]any{"url": "wss://gw.example.com/gateway?compress=0&token=abc"})
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
got, err := a.gatewayURL(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("gatewayURL: %v", err)
|
||||
}
|
||||
if got != "wss://gw.example.com/gateway?compress=0&token=abc" {
|
||||
t.Errorf("fresh url = %q, want unchanged base", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayURL_ResumeAddsParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != gatewayPath {
|
||||
http.Error(w, "unexpected", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
kookOK(w, map[string]any{"url": "wss://gw.example.com/gateway?compress=0&token=abc"})
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
a.setSession("sess-abc", 5)
|
||||
assertCanResume(t, a, true)
|
||||
|
||||
got, err := a.gatewayURL(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("gatewayURL: %v", err)
|
||||
}
|
||||
u, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse resume url: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("compress") != "0" || q.Get("token") != "abc" {
|
||||
t.Errorf("existing query params lost: %q", got)
|
||||
}
|
||||
if q.Get("resume") != "1" || q.Get("sn") != "5" || q.Get("session_id") != "sess-abc" {
|
||||
t.Errorf("resume params = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayURL_ResetDropsResume(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
kookOK(w, map[string]any{"url": "wss://gw.example.com/gateway?compress=0"})
|
||||
})
|
||||
a, _ := newTestAdapter(t, h)
|
||||
a.setSession("sess-abc", 5)
|
||||
a.resetSession()
|
||||
assertCanResume(t, a, false)
|
||||
got, err := a.gatewayURL(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("gatewayURL: %v", err)
|
||||
}
|
||||
if strings.Contains(got, "resume=1") {
|
||||
t.Errorf("reset session should not emit resume params: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCanResume(t *testing.T, a *Adapter, want bool) {
|
||||
t.Helper()
|
||||
if got := a.canResume(); got != want {
|
||||
t.Fatalf("canResume = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnect_GatewayDeliversEvent is an end-to-end smoke of the gateway path: an
|
||||
// httptest server serves the REST gateway lookup, upgrades a WebSocket, sends a
|
||||
// hello then an s=0 event, and the adapter dispatches the normalized message to
|
||||
// the push handler.
|
||||
func TestConnect_GatewayDeliversEvent(t *testing.T) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
delivered []adapter.UnifiedMessage
|
||||
)
|
||||
got := make(chan adapter.UnifiedMessage, 1)
|
||||
|
||||
var srv *httptest.Server
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case gatewayPath:
|
||||
wsURL := "ws://" + strings.TrimPrefix(srv.URL, "http://") + "/ws"
|
||||
kookOK(w, map[string]any{"url": wsURL})
|
||||
case "/ws":
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
t.Errorf("ws upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.WriteJSON(map[string]any{"s": 1, "d": map[string]any{"code": 0, "session_id": "sess-123"}})
|
||||
_ = conn.WriteJSON(map[string]any{"s": 0, "sn": 7, "d": map[string]any{
|
||||
"channel_type": "GROUP", "type": 1, "target_id": "chan-8", "author_id": "u-8",
|
||||
"content": "gateway hi", "msg_id": "m-8", "msg_timestamp": 1700000000000, "nonce": "",
|
||||
"extra": map[string]any{
|
||||
"guild_id": "g-8", "channel_name": "test",
|
||||
"author": map[string]any{"id": "u-8", "username": "u", "nickname": "Nick"},
|
||||
},
|
||||
}})
|
||||
// Hold the connection and consume client frames (heartbeat/close).
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
srv = httptest.NewServer(h)
|
||||
defer srv.Close()
|
||||
|
||||
a := New(
|
||||
Config{BotToken: "testtoken"},
|
||||
WithBaseURL(srv.URL),
|
||||
WithHTTPClient(srv.Client()),
|
||||
WithLogger(discardLogger()),
|
||||
)
|
||||
a.SetMessageHandler(func(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
mu.Lock()
|
||||
delivered = append(delivered, msg)
|
||||
mu.Unlock()
|
||||
select {
|
||||
case got <- msg:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer func() { _ = a.Disconnect(context.Background()) }()
|
||||
|
||||
select {
|
||||
case msg := <-got:
|
||||
if msg.Platform != "kook" {
|
||||
t.Errorf("Platform = %q, want kook", msg.Platform)
|
||||
}
|
||||
if msg.ChannelID != "chan-8" || msg.GroupID != "g-8" {
|
||||
t.Errorf("ChannelID/GroupID = %q/%q, want chan-8/g-8", msg.ChannelID, msg.GroupID)
|
||||
}
|
||||
if msg.SenderName != "Nick" || msg.SenderID != "u-8" {
|
||||
t.Errorf("Sender = %q/%q, want Nick/u-8", msg.SenderName, msg.SenderID)
|
||||
}
|
||||
if msg.Content != "gateway hi" || msg.ContentType != adapter.ContentText {
|
||||
t.Errorf("Content = %q/%q, want gateway hi/text", msg.Content, msg.ContentType)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for gateway event")
|
||||
}
|
||||
|
||||
if sn := a.currentSN(); sn != 7 {
|
||||
t.Errorf("currentSN = %d, want 7", sn)
|
||||
}
|
||||
if sid := a.currentSession(); sid != "sess-123" {
|
||||
t.Errorf("currentSession = %q, want sess-123", sid)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user