Files
AskaEth b504df01e0 feat(channel): 出站消息 id 采集(可选 seam + qq_obv11 两分支取回)
「接她的话」缺的 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 同样失败)。
2026-10-13 20:38:12 +08:00

455 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
}