feat(channel): KOOK 渠道适配器 + 框架兼容增强(wsutil/httpclient)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
Aska committed 2026-09-07 12:01:35 +08:00
1 parent eab6b63eb0
commit 764d592859
24 files changed
+2800 -2

No files matched your search

+7
View File
@@ -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
View File
@@ -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 {
+56
View File
@@ -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)
}
}
}
+359
View File
@@ -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())
}
}
+85
View File
@@ -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" {
+298
View File
@@ -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()
}
+55
View File
@@ -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
}
+115
View File
@@ -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)
}
}
+17
View File
@@ -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")
)
+31
View File
@@ -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
}
+57
View File
@@ -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{}
+202
View File
@@ -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
}
+162
View File
@@ -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)
}
}
+119
View File
@@ -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 ""
}
+178
View File
@@ -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")
}
}
+263
View File
@@ -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
}
+220
View File
@@ -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)
}
}