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