Files
Cyrene_v2/plugins/channels/qq_obv11/session.go
T
AskaEth 9253fef92a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(qq_obv11): OneBot v11 四端齐备(裁决 1.4 / 12.4.1)
- 四端=ws×client / ws×server(原有,补语义注释)+ **http×client**(新增 http_client.go:
  调 OneBot HTTP API、请求-响应、retcode 判成败、token 只走 Authorization 头)+
  **http×server**(新增 http_server.go:监听 listen_addr 收上报、fail-closed 鉴权、10MiB 体上限)
- 配置面:适配器新增 transport / api_base_url / report_path(json tag 沿用既有口径);
  env 兜底 CHANNEL_QQ_OBV11_{TRANSPORT,API_BASE_URL,REPORT_PATH};**默认值=现状**(ws+server)
- 出站分流:session.go 发送路径按 transport 走 WS 写帧或 HTTP POST(不改它则 HTTP 端无法出站)
- 端口豁免边界:http+server 沿用 AGENTS §9 已登记的同一豁免(design/13 §0 原文即含 HTTP server),
  且 listen_addr 为空时报错、**绝不开端口**(与 ws 侧同名校验成对)
- 关键取舍(已上报):OneBot 的 HTTP 是「动作 + 上报」两半,mode 决定**哪一半必需**、
  另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」,四端仍各自可独立选择与测试
- 复核:build/vet 绿 + qq_obv11/cmd 包测试绿 + `-race` 干净;10 条新用例(HTTP 两端全部用本地
  httptest 假 OneBot,未连真实 QQ);变异 3 条(伪造 retcode 成功 / 去掉上报鉴权门 / 默认值翻 http)已还原
- 未接:get_group_list(后续)、无 token 时放行(与既有 ws_server 一致,如需 fail-closed 请裁决)
2026-10-04 13:58:18 +08:00

362 lines
9.7 KiB
Go

package qq_obv11
// This file holds the message-flow half of the Adapter: the inbound read loop
// (server/client mode sessions, reconnect, dispatch) and the outbound send path
// (rate limit, chunk to the connection). It is split out of adapter.go to keep
// each file under the project's 500-line limit.
import (
"context"
"fmt"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// ReadMessages registers emit as the pull-based sink and blocks until ctx is
// canceled. Inbound events arrive via the read loop and are dispatched 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()
}
// SendMessage delivers a reply (possibly several messages) to the channel,
// subject to the send rate limit.
func (a *Adapter) SendMessage(ctx context.Context, resp adapter.UnifiedResponse) error {
if err := ctx.Err(); err != nil {
return fmt.Errorf("qq-obv11: send message: %w", err)
}
// HTTP 传输没有 ws 连接:只要出站 HTTP API 客户端已装配(http+client 或
// http+server 配了 api_base_url)就照样能回消息。
conn := a.currentConn()
if conn == nil && a.currentAPI() == nil {
return fmt.Errorf("qq-obv11: send message: %w", adapter.ErrNotConnected)
}
for i, msg := range resp.Messages {
if err := a.sendMessage(ctx, conn, msg); err != nil {
return fmt.Errorf("qq-obv11: send message #%d: %w", i, err)
}
}
return nil
}
// SendProactive pushes a message not triggered by a recent inbound. It is
// rate limited (see design/08 section 3: OneBot needs a rate limit to avoid
// unusual burst patterns).
func (a *Adapter) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error {
if err := ctx.Err(); err != nil {
return fmt.Errorf("qq-obv11: send proactive: %w", err)
}
conn := a.currentConn()
if conn == nil && a.currentAPI() == nil {
return fmt.Errorf("qq-obv11: send proactive: %w", adapter.ErrNotConnected)
}
return a.sendMessage(ctx, conn, msg)
}
// 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 QQ OneBot v11 adapter supports.
func (a *Adapter) Capabilities() adapter.Capabilities {
return adapter.Capabilities{
MaxMessageLength: defaultMaxMessageLength,
Markdown: true,
Image: true,
Voice: true,
Reaction: false,
TypingHint: false,
RecommendBurstMax: defaultRecommendBurstMax,
Proactive: true,
}.Normalize()
}
// ListChannels returns the groups the adapter currently knows about (learned from
// inbound events). 全量好友/群列表要靠 OneBot 的 get_group_list / get_friend_list
// 动作,HTTP 出站客户端(api_base_url)现已具备,但把它接进本方法属后续工作,
// 本次四端改造不扩大范围。
func (a *Adapter) ListChannels(ctx context.Context) ([]adapter.ChannelInfo, error) {
a.groupMu.RLock()
defer a.groupMu.RUnlock()
out := make([]adapter.ChannelInfo, 0, len(a.groups))
for id, name := range a.groups {
out = append(out, adapter.ChannelInfo{
ChannelID: id,
ChannelType: adapter.ChannelGroup,
ChannelName: name,
GroupID: id,
GroupName: name,
})
}
return out, nil
}
// Addr returns the bound listen address in server mode ("" when not serving).
func (a *Adapter) Addr() string {
a.mu.RLock()
defer a.mu.RUnlock()
if a.ln == nil {
return ""
}
return a.ln.Addr().String()
}
// readLoop is the single inbound-reader goroutine. It reads frames from the
// active connection, dispatching message events; in client mode it reconnects
// after drops, in server mode it waits for a new OneBot client.
func (a *Adapter) readLoop(ctx context.Context) {
for {
conn := a.currentConn()
if conn == nil {
if a.cfg.Mode == "client" {
if err := a.connectClient(ctx); err != nil {
a.log.Warn("onebot reconnect failed", "err", err)
if !sleepCtx(ctx, 3*time.Second) {
return
}
continue
}
} else if !a.waitConn(ctx) {
return
}
conn = a.currentConn()
}
a.readConn(ctx, conn)
if ctx.Err() != nil {
return
}
}
}
// readConn reads frames from conn until it closes, the context is canceled, or
// a close frame is received, marking the adapter disconnected on exit.
func (a *Adapter) readConn(ctx context.Context, conn *wsConn) {
watchDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = conn.Close()
case <-watchDone:
}
}()
defer close(watchDone)
for {
opcode, payload, err := conn.ReadMessage()
if err != nil {
a.setDisconnected()
return
}
switch opcode {
case opPing:
_ = conn.WriteMessage(opPong, payload)
case opText, opBinary:
a.handlePayload(ctx, payload)
case opClose:
_ = conn.WriteMessage(opClose, nil)
a.setDisconnected()
return
}
}
}
// handlePayload parses a text frame, converting message events into normalized
// UnifiedMessage and dispatching them.
func (a *Adapter) handlePayload(ctx context.Context, payload []byte) {
ev, err := parseEvent(payload)
if err != nil {
a.log.Debug("non-event payload ignored", "err", err)
return
}
if ev.PostType != "message" {
return
}
msg := a.toUnified(ev, payload)
if err := msg.Validate(); err != nil {
a.log.Warn("invalid unified message dropped", "err", err)
return
}
a.dispatch(ctx, msg)
}
// dispatch routes an inbound message to the registered 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.Lock()
emit := a.emit
a.emitMu.Unlock()
if emit != nil {
emit(msg)
}
}
// sendMessage builds and dispatches a single send_msg request, enforcing the
// rate limit first. 出站通道按 transport 分流:WS 两端把 JSON 帧写到长连接上;
// HTTP 端把同一条 action 请求 POST 给 OneBot 的 HTTP API 并等响应。
func (a *Adapter) sendMessage(ctx context.Context, conn *wsConn, msg adapter.UnifiedMessage) error {
segs, err := outboundSegments(msg)
if err != nil {
return err
}
messageType, userID, groupID, err := resolveTarget(msg)
if err != nil {
return err
}
echo := fmt.Sprintf("echo_%d", time.Now().UnixNano())
payload, err := buildSendReq(messageType, userID, groupID, segs, echo)
if err != nil {
return err
}
if a.cfg.SendInterval > 0 {
if err := a.waitSendInterval(ctx); err != nil {
return err
}
}
if err := a.tryAcquireSend(msg); err != nil {
return err
}
switch api := a.currentAPI(); {
case api != nil:
// HTTP 端:Cyrene→OneBot 走 OneBot 的 HTTP API,等响应判成败(裁决 1.4)。
// 失败不把适配器标记为断开——HTTP 没有会话可重连,下次发送会重试。
if err := api.send(ctx, payload); err != nil {
return err
}
default:
if err := conn.WriteMessage(opText, payload); err != nil {
a.setDisconnected()
return fmt.Errorf("qq-obv11: write send_msg: %w", err)
}
}
a.mu.Lock()
a.lastSend = time.Now()
a.mu.Unlock()
return nil
}
// waitSendInterval sleeps so consecutive sends are at least SendInterval apart.
func (a *Adapter) waitSendInterval(ctx context.Context) error {
for {
a.mu.RLock()
last := a.lastSend
a.mu.RUnlock()
if last.IsZero() {
return nil
}
wait := a.cfg.SendInterval - time.Since(last)
if wait <= 0 {
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("qq-obv11: send interval: %w", ctx.Err())
case <-time.After(wait):
}
}
}
// tryAcquireSend 检查本条出站消息所属会话(群聊/私聊)的发送滑窗,额度用尽时返回
// ErrRateLimited。额度按会话独立计数(裁决 06.4.3):给 A 群发消息不占用 B 群或私聊
// 的额度,键口径与入站滑窗一致(adapter.ConversationKey)。适配器仍保留账号级的
// SendInterval 发送间隔(更保守,只收紧不放宽)。
func (a *Adapter) tryAcquireSend(msg adapter.UnifiedMessage) error {
if !a.limiter.allow(adapter.ConversationKey(msg), time.Now()) {
return fmt.Errorf("qq-obv11: %w", ErrRateLimited)
}
return nil
}
// setConn installs a new active connection, closing any previous one and waking
// the read loop (server mode).
func (a *Adapter) setConn(conn *wsConn) {
a.mu.Lock()
old := a.conn
a.conn = conn
a.connected = true
a.mu.Unlock()
if old != nil && old != conn {
_ = old.Close()
}
select {
case a.newConn <- struct{}{}:
default:
}
}
// setDisconnected marks the session down, dropping the current connection.
func (a *Adapter) setDisconnected() {
a.mu.Lock()
if a.conn != nil {
_ = a.conn.Close()
a.conn = nil
}
a.connected = false
a.mu.Unlock()
}
// currentConn returns the active wsConn, or nil.
func (a *Adapter) currentConn() *wsConn {
a.mu.RLock()
defer a.mu.RUnlock()
return a.conn
}
// waitConn blocks in server mode until a OneBot client connects or ctx ends.
func (a *Adapter) waitConn(ctx context.Context) bool {
for a.currentConn() == nil {
select {
case <-a.newConn:
case <-ctx.Done():
return false
case <-time.After(time.Second):
}
}
return true
}
func (a *Adapter) rememberGroup(id, name string) {
if id == "" {
return
}
a.groupMu.Lock()
a.groups[id] = name
a.groupMu.Unlock()
}
func (a *Adapter) groupName(id string) string {
if id == "" {
return ""
}
a.groupMu.RLock()
defer a.groupMu.RUnlock()
return a.groups[id]
}
func sleepCtx(ctx context.Context, d time.Duration) bool {
select {
case <-ctx.Done():
return false
case <-time.After(d):
return true
}
}