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
@@ -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