Files
Aska 764d592859
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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
2026-09-07 12:01:35 +08:00

120 lines
3.2 KiB
Go

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 ""
}