Files
Cyrene_v2/plugins/channels/kook/adapter.go
T
Aska 65091f6497
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: KOOK自动获取bot身份 + action/narration剥出气泡 + 渠道配置表单回填
agent-116 KOOK token即用+自动获取:
- kook/identity.go: Connect 调 GET /user/me 拿 bot 身份(bot_id/bot_username/bot_nickname), 失败容错
- 渠道列表 enrichAccountsWithBot 附 bot 身份; base_url空用默认/receive_mode默认ws(只填token可跑)

agent-117 action/narration 剥出气泡(主人需求: 动作/旁白不进气泡,按位置上/下):
- parseContent.splitBubble(content)->{body,head,tail}: body=纯文本(去标签), head=开头/中间action/narr(气泡上), tail=结尾(气泡下)
- renderContent 只渲染text(去内联灰斜体); chat.tsx BubbleNotes 泡外 *动作/旁白* 灰斜体小字(气泡外依附), SplitChatBubble 先剥splitBubble再splitParagraphs(body), <p>/\n\n/毛玻璃/SourceLabel不破坏
- 注: 非chat mode renderContent 现在丢弃action/narr(可接受)

Channels 配置表单回填(修save后输入框空):
- openConfig 从 data.accounts[0].config 回填 token(掩码提示)/base_url/平台字段/黑白名单/限流, 不再硬清空
全仓 build/vet/test绿+前端build绿
2026-09-07 15:31:14 +08:00

334 lines
9.1 KiB
Go

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
botMu sync.RWMutex
botID string
botUsername string
botNickname string
}
// 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,
// WebhookVerifyToken and WebhookEncryptKey are sensitive; callers that persist
// this map must treat them as secrets (the admin read path masks these keys via
// the "token"/"key" substring rules).
func (a *Adapter) Config() map[string]any {
id, username, nickname := a.botIdentitySnapshot()
return map[string]any{
"bot_token": a.cfg.BotToken,
"base_url": a.baseURL,
"receive_mode": a.cfg.normalizedReceiveMode(),
"webhook_path": a.cfg.WebhookPath,
"webhook_verify_token": a.cfg.WebhookVerifyToken,
"webhook_encrypt_key": a.cfg.WebhookEncryptKey,
"bot_id": id,
"bot_username": username,
"bot_nickname": nickname,
}
}
// 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
}
mode := a.cfg.normalizedReceiveMode()
a.mu.Unlock()
// Webhook mode: the Cyrene gateway mounts InboundHandler to receive KOOK
// callbacks (KOOK posts to the configured route). No gateway WebSocket is
// dialed — webhook and websocket are mutually exclusive. The bot identity
// lookup is best-effort (a bad token or network hiccup logs a warning and
// leaves the identity empty, never aborting the connect), and Mark connected
// so the REST reply path and HealthCheck see a live session.
if mode == modeWebhook {
a.refreshBotIdentity(ctx)
a.mu.Lock()
a.connected = true
a.mu.Unlock()
a.log.Info("connected", "mode", mode, "token_len", len(a.cfg.BotToken))
return nil
}
if _, err := a.fetchGatewayURL(ctx); err != nil {
return fmt.Errorf("kook: connect: %w", err)
}
// Best-effort bot identity (user/me) so the admin list shows who the bot is.
// Failure here is non-fatal: the gateway URL above already validated the token.
a.refreshBotIdentity(ctx)
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", "mode", mode, "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()
}