「接她的话」缺的 A 层:平台给她自己那条出站消息分配的 id,此前在解析的那一刻 就被丢掉(HTTP 只校 retcode、WS 写完不等回声、契约层没有 id 出口)。 - internal/channel/adapter/outbound.go(新):SendReceipt/SendOutcome/SendIDStatus + 可选接口 OutboundReceiptAdapter + 调用方入口 SendWithReceipt。**不加进 PlatformAdapter** —— 四个未接线适配器与全部桩实现一行不改(向后兼容硬指标); 也不用回调:id 是某一次投递的属性,必须随那次投递返回。 - qq_obv11:HTTP 从响应信封 data.message_id 取;WS 登记 send_msg 的 echo 等待者 (复用 probe.go 的 action 等待表与 routeActionResponse,不另开通道)。取不到 一律空 id + 三态可分辨(reported/absent/unreported),绝不编造。 - send_msg echo 键改用单调计数器(旧 UnixNano 在 Windows 时钟粒度下会撞键, 撞键即拿不到自己的回执)。 - 既有 SendMessage/SendProactive 路径行为不变(wantReceipt=false 不等待)。 验收:go build ./... + go vet ./... + go test ./... 全绿(cmd/cyrene、internal/webui 的 setup 失败是 worktree 缺 dist 的既有环境限制,base 同样失败)。
455 lines
18 KiB
Go
455 lines
18 KiB
Go
// Package qq_obv11 实现内置的 QQ OneBot v11 渠道适配器。它按 `transport`(ws|http)
|
||
// × `mode`(client|server) 的**四个端**(裁决 1.4)与 OneBot 实现通信:
|
||
//
|
||
// - ws + client:Cyrene 主动拨号 OneBot 的 WS 端点,收发共用一个长连接;
|
||
// - ws + server:OneBot 反连 Cyrene 的 `listen_addr`,收发共用一个长连接;
|
||
// - http + client:Cyrene 作为 HTTP 客户端调用 OneBot 的 HTTP API(出站);
|
||
// - http + server:Cyrene 监听 `listen_addr`,接收 OneBot POST 上来的事件上报(入站)。
|
||
//
|
||
// 原生 CQ 码 / 数组段消息统一归一成 adapter.UnifiedMessage,回复走同一个端并受限频
|
||
// 约束。设计依据是 DevDocs/design/08-渠道接入.md §1/§3/§4;本包只依赖
|
||
// internal/channel/adapter、internal/logger 与标准库,WS 帧编解码自带(模块不引入
|
||
// 第三方 websocket 依赖)。
|
||
package qq_obv11
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
)
|
||
|
||
const (
|
||
// adapterLogSource 是本包产出的日志的来源标签(AGENTS §10:日志必须标来源)。
|
||
adapterLogSource = "plugins/channels/qq_obv11"
|
||
|
||
// defaultMaxMessageLength bounds a single outgoing QQ message length.
|
||
defaultMaxMessageLength = 4000
|
||
// defaultRecommendBurstMax is the safe burst size for one reply.
|
||
defaultRecommendBurstMax = 4
|
||
// defaultMaxProactivePerWindow caps proactive sends within the window.
|
||
defaultMaxProactivePerWindow = 5
|
||
// defaultProactiveWindow is the sliding window for the send rate limit.
|
||
defaultProactiveWindow = 10 * time.Second
|
||
|
||
// transportWS / transportHTTP 是 OneBot v11 的两种通信介质(裁决 1.4)。
|
||
transportWS = "ws"
|
||
transportHTTP = "http"
|
||
// modeServer / modeClient 是每种介质下的两个角色:谁向谁发数据(裁决 1.4)。
|
||
modeServer = "server"
|
||
modeClient = "client"
|
||
// defaultReportPath 是 http+server 接收上报的默认路径。
|
||
defaultReportPath = "/"
|
||
)
|
||
|
||
// Config configures a qq_obv11 Adapter.
|
||
type Config struct {
|
||
// Transport 选择通信介质:"ws"(默认,保持既有部署行为)或 "http"(裁决 1.4)。
|
||
// 空值与未知值一律回落 "ws",绝不静默改走 HTTP。
|
||
Transport string `json:"transport"`
|
||
// Mode 选择该介质下的角色(谁向谁发数据),"server"(默认)或 "client":
|
||
// ws —— server 等 OneBot 反连本适配器,client 由本适配器拨号 OneBot;
|
||
// http —— server 收 OneBot 的 HTTP 上报,client 调用 OneBot 的 HTTP API。
|
||
Mode string `json:"mode"`
|
||
// ListenAddr is the TCP address to bind in server mode (host:port).
|
||
// **必须由配置显式给出**:OneBot 反连端口是 AGENTS §9 登记的唯一豁免,
|
||
// 空值让 Connect 直接失败,绝不默认开监听。
|
||
ListenAddr string `json:"listen_addr"`
|
||
// RemoteURL 是 ws+client 拨号的 ws://(或 wss://)端点。
|
||
RemoteURL string `json:"remote_url"`
|
||
// APIBaseURL 是 OneBot HTTP API 基址(如 http://127.0.0.1:3000):http+client
|
||
// 必填;http+server 可选,给了才有出站(回消息)能力。必须显式配置——不从
|
||
// RemoteURL 推推导(v1「剥掉最后一段路径」的推导是有损的,见 design/08 对照)。
|
||
APIBaseURL string `json:"api_base_url"`
|
||
// ReportPath 是 http+server 接收上报的路径。空值默认 "/"。
|
||
ReportPath string `json:"report_path"`
|
||
// AccessToken is the optional OneBot access token for all four ends.
|
||
AccessToken string `json:"access_token"`
|
||
// SelfID is the bot's own QQ number when known.
|
||
SelfID string `json:"self_id"`
|
||
// MaxProactivePerWindow caps sends within ProactiveWindow. Zero uses the
|
||
// default.
|
||
MaxProactivePerWindow int
|
||
// ProactiveWindow is the rate-limit sliding window. Zero uses the default.
|
||
ProactiveWindow time.Duration
|
||
// SendInterval is a minimum delay between consecutive sends, when set.
|
||
SendInterval time.Duration
|
||
// DialContext dials the WS endpoint in client mode. Nil uses net.Dialer.
|
||
DialContext DialContextFunc
|
||
// Logger is the base logger. Nil builds one from internal/logger.
|
||
Logger *slog.Logger
|
||
}
|
||
|
||
// Adapter is the OneBot v11 implementation of adapter.PlatformAdapter.
|
||
//
|
||
// It is concurrency-safe: one goroutine runs the read loop while multiple
|
||
// goroutines may send (writes are serialized on the wsConn and sends are rate
|
||
// limited). HTTP 传输没有长连接:入站由上报 handler 直接派发,出站走 httpAPI 的
|
||
// 请求-响应路径。
|
||
type Adapter struct {
|
||
cfg Config
|
||
log *slog.Logger
|
||
|
||
mu sync.RWMutex
|
||
conn *wsConn
|
||
ln net.Listener
|
||
srv *http.Server
|
||
httpAPI *obHTTPClient
|
||
started bool
|
||
connected bool
|
||
// lastConnectErr 是最近一次连接失败的**脱敏**原因,供 ConnectOutcome 如实回答
|
||
// 「为什么没连上」(见 connect_outcome.go)。连上或断开即清空:一条已经过期的
|
||
// 原因不该继续挂在当前状态旁边。由 mu 保护。
|
||
lastConnectErr string
|
||
cancel context.CancelFunc
|
||
lifeCtx context.Context
|
||
newConn chan struct{}
|
||
|
||
handlerMu sync.RWMutex
|
||
handler adapter.MessageHandler
|
||
emitMu sync.Mutex
|
||
emit func(adapter.UnifiedMessage)
|
||
|
||
limiter *rateLimiter
|
||
|
||
groupMu sync.RWMutex
|
||
groups map[string]string
|
||
|
||
lastSend time.Time
|
||
|
||
// reconnect / policy 只服务 ws+client 的首连与重连退避(见 reconnect.go)。
|
||
reconnect reconnectState
|
||
policy reconnectPolicy
|
||
|
||
// account 是**账号级**在线状态(get_status 探活所得,见 probe.go)。它与
|
||
// connected(Cyrene 与 OneBot 实现的 WS/会话是否通)是两件事——2026-10-05
|
||
// 事故正是把两者混为一谈:WS 通 ≠ QQ 账号在线。由 mu 保护。
|
||
account accountStatus
|
||
|
||
// actionMu / actionWait 是 WS 上「出站 action 请求 → echo 回执」的等待表:探活
|
||
// (get_status,probe.go)与出站回执采集(send_msg,outbound_id.go)共用同一张表、
|
||
// 同一条 routeActionResponse —— echo 前缀不同(status_ / send_),永不撞键。
|
||
// probeEvery 是两次 get_status 探活之间的正常间隔(失败按 policy 退避)。见 probe.go。
|
||
actionMu sync.Mutex
|
||
actionWait map[string]chan *obAPIResponse
|
||
probeEvery time.Duration
|
||
probeTimeout time.Duration
|
||
// sendReceiptTimeout 是 SendMessageWithReceipt 在 WS 上等待 send_msg 回执的上限
|
||
// (见 outbound_id.go;测试可收紧)。HTTP 端不涉及:响应本来就要等。
|
||
sendReceiptTimeout time.Duration
|
||
// probeSeq 是 WS 探活 echo 的单调序号(见 probe.go 的 nextProbeEcho):唯一性不依赖
|
||
// 时钟精度,两次探活绝不会撞在同一个 echo 上。
|
||
probeSeq atomic.Uint64
|
||
// sendSeq 是 WS 出站 send_msg echo 的单调序号(见 outbound_id.go 的 nextSendEcho),
|
||
// 与 probeSeq 同理:同一毫秒内的多次发送绝不能撞在同一个 echo 上,否则先登记的那次
|
||
// 发送会拿不到自己的回执(旧实现用 UnixNano,Windows 时钟粒度下真会撞)。
|
||
sendSeq atomic.Uint64
|
||
}
|
||
|
||
// Compile-time conformance with the channel contract.
|
||
var _ adapter.PlatformAdapter = (*Adapter)(nil)
|
||
|
||
// Option customizes an Adapter at construction time.
|
||
type Option func(*Adapter)
|
||
|
||
// WithLogger injects the logger the adapter logs through — the wiring path the composition
|
||
// root uses (cmd/cyrene/channels.go), mirroring kook/telegram/webhook. A Config decoded from
|
||
// channels.json never carries a logger, so without this option an adapter would fall back to
|
||
// resolveLogger's default and every DEBUG record (the inbound frame forensics) would vanish.
|
||
// It takes precedence over Config.Logger when both are given.
|
||
func WithLogger(l *slog.Logger) Option {
|
||
return func(a *Adapter) {
|
||
if l != nil {
|
||
a.log = logger.WithSource(l, adapterLogSource)
|
||
}
|
||
}
|
||
}
|
||
|
||
// NewAdapter builds a qq_obv11 Adapter from cfg, applying safe defaults. 默认值口径
|
||
// (裁决 1.4):transport=ws、mode=server,即**不给出任何新配置时行为与改造前一致**。
|
||
func NewAdapter(cfg Config, opts ...Option) *Adapter {
|
||
cfg.Transport = normalizeTransport(cfg.Transport)
|
||
cfg.Mode = normalizeMode(cfg.Mode)
|
||
cfg.ReportPath = normalizeReportPath(cfg.ReportPath)
|
||
if cfg.MaxProactivePerWindow <= 0 {
|
||
cfg.MaxProactivePerWindow = defaultMaxProactivePerWindow
|
||
}
|
||
if cfg.ProactiveWindow <= 0 {
|
||
cfg.ProactiveWindow = defaultProactiveWindow
|
||
}
|
||
a := &Adapter{
|
||
cfg: cfg,
|
||
groups: map[string]string{},
|
||
newConn: make(chan struct{}, 1),
|
||
limiter: newRateLimiter(cfg.MaxProactivePerWindow, cfg.ProactiveWindow),
|
||
policy: defaultReconnectPolicy(),
|
||
actionWait: map[string]chan *obAPIResponse{},
|
||
probeEvery: defaultProbeInterval,
|
||
probeTimeout: defaultProbeTimeout,
|
||
sendReceiptTimeout: defaultSendReceiptTimeout,
|
||
}
|
||
for _, opt := range opts {
|
||
opt(a)
|
||
}
|
||
a.log = resolveLogger(a.log, cfg.Logger)
|
||
return a
|
||
}
|
||
|
||
// resolveLogger 选定适配器的日志器(并统一加来源标注)。优先级:WithLogger 选项 →
|
||
// Config.Logger → 兜底。
|
||
//
|
||
// 兜底**不静默**:它用默认 INFO logger 并当场记一条 WARN。理由是入站帧取证
|
||
// (inbound_debug.go,级固定 DEBUG)是排查「群友 @ 没回」的地基——静默兜底会让「没人
|
||
// 注入 logger」在日志里毫无痕迹,与「上游压根没上报」无法区分(2026-10-05 实测:组合根
|
||
// 漏传 logger,全部归档里 `msg="inbound frame"` 一条都没有)。因此宁可留一条噪音,也要
|
||
// 让「Debug 会被丢弃」显式可见。兜底只服务裸适配器(测试/库内直构),生产路径由组合根
|
||
// 经 WithLogger 正式接线。
|
||
func resolveLogger(option, cfg *slog.Logger) *slog.Logger {
|
||
if option != nil {
|
||
return option
|
||
}
|
||
if cfg != nil {
|
||
return logger.WithSource(cfg, adapterLogSource)
|
||
}
|
||
fallback := logger.WithSource(logger.New(logger.Options{}), adapterLogSource)
|
||
fallback.Warn("qq-obv11: no logger injected, Debug forensics (inbound frame) will be dropped",
|
||
"hint", "inject one via qq_obv11.WithLogger from the composition root")
|
||
return fallback
|
||
}
|
||
|
||
// Name returns the platform identifier "qq-obv11".
|
||
func (a *Adapter) Name() string { return platformName }
|
||
|
||
// Config returns a snapshot of the adapter configuration as a plain map.
|
||
func (a *Adapter) Config() map[string]any {
|
||
return map[string]any{
|
||
"transport": a.cfg.Transport,
|
||
"mode": a.cfg.Mode,
|
||
"listenAddr": a.cfg.ListenAddr,
|
||
"remoteURL": configRemoteURL(a.cfg),
|
||
"apiBaseURL": a.cfg.APIBaseURL,
|
||
"reportPath": a.cfg.ReportPath,
|
||
"accessToken": a.cfg.AccessToken,
|
||
"selfID": a.cfg.SelfID,
|
||
"maxProactivePerWindow": a.cfg.MaxProactivePerWindow,
|
||
"proactiveWindowSeconds": a.cfg.ProactiveWindow.Seconds(),
|
||
"sendIntervalSeconds": a.cfg.SendInterval.Seconds(),
|
||
}
|
||
}
|
||
|
||
// Connect establishes the session for the configured transport × mode. It is
|
||
// idempotent. ws+client 的首连失败**不致命**(对面可能比 Cyrene 后启动):
|
||
// Connect 照常返回 nil,重试交给 readLoop 的同一条重连路径。
|
||
func (a *Adapter) Connect(ctx context.Context) error {
|
||
a.mu.Lock()
|
||
if a.started {
|
||
a.mu.Unlock()
|
||
return nil
|
||
}
|
||
a.mu.Unlock()
|
||
|
||
var err error
|
||
switch {
|
||
case a.cfg.Transport == transportHTTP:
|
||
err = a.connectHTTP(ctx)
|
||
case a.cfg.Mode == modeClient:
|
||
err = a.connectFirstClient(ctx)
|
||
default:
|
||
err = a.connectServer(ctx)
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("qq-obv11: connect: %w", err)
|
||
}
|
||
|
||
httpTransport := a.cfg.Transport == transportHTTP
|
||
a.mu.Lock()
|
||
a.started = true
|
||
if httpTransport {
|
||
// HTTP 没有持久连接可观察:监听已绑定 / 出站客户端已装配即视为可服务,
|
||
// 真实可达性交给 HealthCheck 的 get_login_info 探活。
|
||
a.connected = true
|
||
}
|
||
a.lifeCtx, a.cancel = context.WithCancel(context.Background())
|
||
a.mu.Unlock()
|
||
|
||
// WS 两端共用一个长连接读循环;HTTP 的入站由上报 handler 直接进 handlePayload。
|
||
if !httpTransport {
|
||
go a.readLoop(a.lifeCtx)
|
||
}
|
||
// 账号在线探活(get_status)与连接生命周期同生共死:lifeCtx 取消即干净退出。
|
||
// 先报一行状态再起循环:日志必须能一眼分辨「探活没跑(含原因)」与「跑了」。
|
||
a.logProbeStart()
|
||
if a.probeEnabled() {
|
||
go a.runProbeLoop(a.lifeCtx)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Disconnect tears down the session. It is idempotent.
|
||
func (a *Adapter) Disconnect(ctx context.Context) error {
|
||
a.mu.Lock()
|
||
started := a.started
|
||
a.started = false
|
||
cancel := a.cancel
|
||
a.cancel = nil
|
||
a.lifeCtx = nil
|
||
srv := a.srv
|
||
ln := a.ln
|
||
conn := a.conn
|
||
api := a.httpAPI
|
||
a.conn = nil
|
||
a.connected = false
|
||
a.ln = nil
|
||
a.srv = nil
|
||
a.httpAPI = nil
|
||
// 断开即清空上一次失败原因:会话已被拆掉,「为什么没连上」不再是当前事实。
|
||
a.lastConnectErr = ""
|
||
// 断开即丢弃账号在线状态:新会话的 known=false 由下一次探活重新确立,绝不沿用
|
||
// 上一轮的陈旧结论(AccountStatus 的诚实口径)。
|
||
a.account = accountStatus{}
|
||
a.mu.Unlock()
|
||
|
||
if !started && cancel == nil {
|
||
return nil
|
||
}
|
||
if cancel != nil {
|
||
cancel()
|
||
}
|
||
if conn != nil {
|
||
_ = conn.Close()
|
||
}
|
||
// 出站 HTTP 客户端没有长连接,只需释放连接池里的 keep-alive 连接。
|
||
if api != nil {
|
||
api.Close()
|
||
}
|
||
if srv != nil {
|
||
shutdownCtx, c := context.WithTimeout(context.Background(), 3*time.Second)
|
||
defer c()
|
||
if serr := srv.Shutdown(shutdownCtx); serr != nil && !errors.Is(serr, context.DeadlineExceeded) {
|
||
return fmt.Errorf("qq-obv11: server shutdown: %w", serr)
|
||
}
|
||
}
|
||
if ln != nil {
|
||
_ = ln.Close()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// IsConnected reports whether a live OneBot session exists. HTTP 传输没有长连接,
|
||
// 它反映的是「适配器已就绪」(监听已绑定 / 出站客户端已装配)。
|
||
//
|
||
// ⚠️ 它**只**回答「Cyrene 与 OneBot 实现(适配器服务器)通不通」——WS 两端是长连接
|
||
// 是否建立。它**不**回答「QQ 账号是否在线」:NapCat 等实现即使掉了账号,正向 WS 仍
|
||
// 可能保持连接(2026-10-05 事故现场)。账号在线状态见 AccountStatus(get_status 探活)。
|
||
func (a *Adapter) IsConnected() bool {
|
||
a.mu.RLock()
|
||
defer a.mu.RUnlock()
|
||
return a.connected
|
||
}
|
||
|
||
// HealthCheck verifies the session is usable. HTTP 传输额外做一次 get_login_info
|
||
// 探活:没有长连接可观察,只有真实请求才能说明 OneBot 实现可达。
|
||
func (a *Adapter) HealthCheck(ctx context.Context) error {
|
||
if !a.IsConnected() {
|
||
return fmt.Errorf("qq-obv11: health check: %w", adapter.ErrNotConnected)
|
||
}
|
||
if api := a.currentAPI(); api != nil {
|
||
return api.probe(ctx)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// currentAPI 返回已装配的 OneBot HTTP API 出站客户端;未装配时(ws 两端,或
|
||
// http+server 只收上报、没配 api_base_url)为 nil。
|
||
func (a *Adapter) currentAPI() *obHTTPClient {
|
||
a.mu.RLock()
|
||
defer a.mu.RUnlock()
|
||
return a.httpAPI
|
||
}
|
||
|
||
// connectHTTP 装配 HTTP 传输(裁决 1.4)。OneBot v11 的 HTTP 通信天然分两半:动作
|
||
// (Cyrene→OneBot,client 端,走 OneBot 的 HTTP API)与上报(OneBot→Cyrene,server
|
||
// 端,走本适配器的监听端口)。`mode` 决定哪一半是**必需**的——server 必须有显式
|
||
// listen_addr(AGENTS §9 豁免口径:绝不默认开端口),client 必须有 api_base_url;
|
||
// 另一半只要对应字段也给齐就一并启用,于是单个适配器即可完成「收上报 + 回消息」闭环。
|
||
func (a *Adapter) connectHTTP(ctx context.Context) error {
|
||
// 先做纯配置校验并构造出站客户端(无副作用),再绑监听端口:任何一端配置有误都
|
||
// 不能留下半开的端口——桥接器对 Connect 失败不会回头调用 Disconnect。
|
||
var api *obHTTPClient
|
||
if a.cfg.APIBaseURL != "" {
|
||
client, err := newOBHTTPClient(a.cfg.APIBaseURL, a.cfg.AccessToken)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
api = client
|
||
} else if a.cfg.Mode == modeClient {
|
||
return errors.New("qq-obv11: http client mode requires apiBaseUrl")
|
||
}
|
||
|
||
if a.cfg.ListenAddr != "" {
|
||
if err := a.connectHTTPServer(ctx); err != nil {
|
||
return err
|
||
}
|
||
} else if a.cfg.Mode == modeServer {
|
||
return errors.New("qq-obv11: http server mode requires listenAddr")
|
||
}
|
||
|
||
if api != nil {
|
||
a.mu.Lock()
|
||
a.httpAPI = api
|
||
a.mu.Unlock()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// configRemoteURL 是 Config() 上报的「远端地址」:channelbridge 的凭据门按
|
||
// remoteURL(client 模式)/ listenAddr(server 模式)判断该渠道是否值得连接
|
||
// (internal/services/channelbridge/connect.go:143-147)。HTTP client 端的远端就是
|
||
// OneBot 的 HTTP API 基址,因此 RemoteURL 为空时回退到 APIBaseURL——否则
|
||
// transport=http + mode=client 会被误判成「无凭据」而永不连接。
|
||
func configRemoteURL(cfg Config) string {
|
||
if cfg.RemoteURL != "" {
|
||
return cfg.RemoteURL
|
||
}
|
||
return cfg.APIBaseURL
|
||
}
|
||
|
||
// normalizeTransport 归一化通信介质:空值保持现状 ws,未知值同样回落 ws,
|
||
// 不静默按 http 处理。
|
||
func normalizeTransport(raw string) string {
|
||
if strings.EqualFold(strings.TrimSpace(raw), transportHTTP) {
|
||
return transportHTTP
|
||
}
|
||
return transportWS
|
||
}
|
||
|
||
// normalizeMode 归一化角色:空值与未知值回落 server(与既有默认一致)。
|
||
func normalizeMode(raw string) string {
|
||
if strings.EqualFold(strings.TrimSpace(raw), modeClient) {
|
||
return modeClient
|
||
}
|
||
return modeServer
|
||
}
|
||
|
||
// normalizeReportPath 归一化上报路径:空值默认 "/",并保证以 "/" 开头。
|
||
func normalizeReportPath(raw string) string {
|
||
p := strings.TrimSpace(raw)
|
||
switch {
|
||
case p == "":
|
||
return defaultReportPath
|
||
case !strings.HasPrefix(p, "/"):
|
||
return "/" + p
|
||
default:
|
||
return p
|
||
}
|
||
}
|