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()
|
||||
}
|
||||
Reference in new issue
Block a user