「接她的话」缺的 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 同样失败)。
473 lines
16 KiB
Go
473 lines
16 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.
|
||
//
|
||
// 它**不等 WS 回执**:既有语义(写完即成功)一个字节都不改 —— 换回平台消息 id 的
|
||
// 调用方走 SendMessageWithReceipt(可选 seam,见 outbound_id.go)。
|
||
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.sendOne(ctx, conn, msg, false); err != nil {
|
||
return fmt.Errorf("qq-obv11: send message #%d: %w", i, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SendMessageWithReceipt 实现 adapter.OutboundReceiptAdapter(可选 seam):投递语义与
|
||
// SendMessage **完全一致**(同一套目标解析 / 限流 / 错误),额外逐条回一条投递凭据 ——
|
||
// 平台给这条消息分配的 id,或明确「不知道」(见 adapter.SendIDStatus)。
|
||
//
|
||
// 逐条对齐:返回值 Receipts[i] 对应 resp.Messages[i];中途失败时返回**已投递**的那几条
|
||
// 凭据 + error,调用方按 len(Receipts) 取。
|
||
func (a *Adapter) SendMessageWithReceipt(ctx context.Context, resp adapter.UnifiedResponse) (adapter.SendOutcome, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return adapter.SendOutcome{}, fmt.Errorf("qq-obv11: send message: %w", err)
|
||
}
|
||
conn := a.currentConn()
|
||
if conn == nil && a.currentAPI() == nil {
|
||
return adapter.SendOutcome{}, fmt.Errorf("qq-obv11: send message: %w", adapter.ErrNotConnected)
|
||
}
|
||
out := adapter.SendOutcome{Receipts: make([]adapter.SendReceipt, 0, len(resp.Messages))}
|
||
for i, msg := range resp.Messages {
|
||
receipt, err := a.sendOne(ctx, conn, msg, true)
|
||
if err != nil {
|
||
return out, fmt.Errorf("qq-obv11: send message #%d: %w", i, err)
|
||
}
|
||
out.Receipts = append(out.Receipts, receipt)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Compile-time conformance with the optional outbound-receipt seam.
|
||
var _ adapter.OutboundReceiptAdapter = (*Adapter)(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).
|
||
//
|
||
// 它走**既有**的出站路径,不带凭据:主动推送的平台消息 id 采集不在本切片范围。
|
||
// 下一片(M13)的「她自己最近若干条出站 id」环形表若要覆盖主动推送,需要在这里
|
||
// 也开一条凭据路径(同样的 sendOne + wantReceipt=true)。
|
||
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)
|
||
}
|
||
_, err := a.sendOne(ctx, conn, msg, false)
|
||
return err
|
||
}
|
||
|
||
// 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 connects and
|
||
// reconnects after drops (exponential backoff, see reconnect.go), 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 == modeClient {
|
||
// ws+client:首连失败与断线重连**共用这一条路径**——Connect 首连失败
|
||
// 只是记一条 WARN 就返回(不致命),真正的重试在这里按退避进行。
|
||
// 先等待、再拨号:日志里承诺的 retryIn 就是这次等待。等待结束后再查一次
|
||
// ctx:与 Disconnect 赛跑时宁可少拨一次,也不发起注定失败的拨号。
|
||
if !sleepCtx(ctx, a.reconnect.wait(a.policy)) || ctx.Err() != nil {
|
||
return
|
||
}
|
||
if err := a.connectClient(ctx); err != nil {
|
||
a.noteConnectFailure(err)
|
||
continue
|
||
}
|
||
a.reconnect.reset()
|
||
} 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. **消息之外的一切上报同样被归一化后投递**
|
||
// (design/26 §1.1 / §3.1:notice / request / meta_event / message_sent 与未知
|
||
// post_type 一律不许静默丢弃)——它们带 ContentType=ContentAction 的结构化标记,
|
||
// 由 channelbridge 的「传入前审查」决定是否 / 如何进入她的上下文,并由留存层落库计数
|
||
// (design/26 §5)。丢弃前一律留一条**脱敏结构 DEBUG**(logInboundFrame),成功投递的
|
||
// 另加一条归一化结果 DEBUG(logNormalizedEvent):上游到底有没有把这一帧推过来,
|
||
// 是排障时最先要回答的问题,而「被丢弃」与「从未到达」在日志里必须能分开。
|
||
//
|
||
// 唯一的例外是**我们自己主动 action 的回执**(空 post_type + echo):它交给等待中的
|
||
// 主动 action(见 probe.go),不是上游推来的事件,因此不进事件流水线(但它照样落
|
||
// 脱敏结构日志)。载荷解析失败的帧**同样是事件**(decode_error 兜底),照样投递。
|
||
func (a *Adapter) handlePayload(ctx context.Context, payload []byte) {
|
||
// 先给 action 回执(空 post_type + echo)一个归宿:WS 上的 get_status 探活
|
||
// (probe.go)与 send_msg 出站回执(outbound_id.go)都靠 echo 关联同一张等待表。
|
||
// 命中即由调用方消费;没有等待者的散落回执仍落回下面的脱敏结构 DEBUG。
|
||
if a.routeActionResponse(payload) {
|
||
return
|
||
}
|
||
ev, err := parseEvent(payload)
|
||
if err != nil {
|
||
a.log.Debug("undecodable inbound frame",
|
||
"err", err, "bytes", len(payload))
|
||
a.dispatch(ctx, decodeErrorMessage(payload))
|
||
return
|
||
}
|
||
a.logInboundFrame(ev)
|
||
if isActionReceipt(ev) {
|
||
return
|
||
}
|
||
if ev.PostType == postTypeMessage {
|
||
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)
|
||
return
|
||
}
|
||
a.logNormalizedEvent(ctx, ev)
|
||
msg := a.toEventMessage(ev, payload)
|
||
if err := msg.Validate(); err != nil {
|
||
a.log.Warn("invalid non-message event dropped", "post_type", ev.PostType, "err", err)
|
||
return
|
||
}
|
||
a.dispatch(ctx, msg)
|
||
}
|
||
|
||
// dispatch routes an inbound message to the registered push handler, falling
|
||
// back to the ReadMessages sink. 附件字节在**路由之前**内联(切片 IM-1):WS 两端
|
||
// 与 HTTP 上报三条入站路径都经这里,下游因此永远拿到「已带字节 / 明确没有字节」的
|
||
// 消息;失败只留日志,绝不挡住投递。
|
||
func (a *Adapter) dispatch(ctx context.Context, msg adapter.UnifiedMessage) {
|
||
a.inlineInboundMedia(ctx, &msg)
|
||
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)
|
||
}
|
||
}
|
||
|
||
// sendOne 构造并派发一条 send_msg 请求(先过限流与发送间隔)。出站通道按 transport
|
||
// 分流:WS 两端把 JSON 帧写到长连接上;HTTP 端把同一条 action 请求 POST 给 OneBot 的
|
||
// HTTP API 并等响应(见 dispatchSend)。
|
||
//
|
||
// wantReceipt **只**决定「要不要把平台消息 id 取回来」:
|
||
// - false(既有路径):WS 写完即返回,一个字节的行为都不改;
|
||
// - true(M12 的出站 id seam):HTTP 从响应信封读 `data.message_id`,WS 登记 echo
|
||
// 等待者并等回执。
|
||
//
|
||
// 两条路径共用这一个函数,因此目标解析、限流、错误包装不可能各写一份而互相漂移。
|
||
func (a *Adapter) sendOne(ctx context.Context, conn *wsConn, msg adapter.UnifiedMessage, wantReceipt bool) (adapter.SendReceipt, error) {
|
||
segs, err := outboundSegments(msg)
|
||
if err != nil {
|
||
return adapter.SendReceipt{}, err
|
||
}
|
||
messageType, userID, groupID, err := resolveTarget(msg)
|
||
if err != nil {
|
||
return adapter.SendReceipt{}, err
|
||
}
|
||
echo := a.nextSendEcho()
|
||
payload, err := buildSendReq(messageType, userID, groupID, segs, echo)
|
||
if err != nil {
|
||
return adapter.SendReceipt{}, err
|
||
}
|
||
if a.cfg.SendInterval > 0 {
|
||
if err := a.waitSendInterval(ctx); err != nil {
|
||
return adapter.SendReceipt{}, err
|
||
}
|
||
}
|
||
if err := a.tryAcquireSend(msg); err != nil {
|
||
return adapter.SendReceipt{}, err
|
||
}
|
||
receipt, err := a.dispatchSend(ctx, conn, payload, echo, wantReceipt)
|
||
if err != nil {
|
||
return adapter.SendReceipt{}, err
|
||
}
|
||
a.mu.Lock()
|
||
a.lastSend = time.Now()
|
||
a.mu.Unlock()
|
||
return receipt, nil
|
||
}
|
||
|
||
// dispatchSend 把一条已构造好的 send_msg 派发到当前可用的出站通道,并在要的时候
|
||
// 把平台消息 id 取回来。
|
||
func (a *Adapter) dispatchSend(ctx context.Context, conn *wsConn, payload []byte, echo string, wantReceipt bool) (adapter.SendReceipt, error) {
|
||
if api := a.currentAPI(); api != nil {
|
||
// HTTP 端:Cyrene→OneBot 走 OneBot 的 HTTP API,等响应判成败(裁决 1.4)。
|
||
// 失败不把适配器标记为断开——HTTP 没有会话可重连,下次发送会重试。
|
||
//
|
||
// 响应信封本来就等回来了,id 只是**顺手读出来**:不额外多一次请求、不多一次等待。
|
||
resp, err := api.post(ctx, payload)
|
||
if err != nil {
|
||
return adapter.SendReceipt{}, err
|
||
}
|
||
if !wantReceipt {
|
||
return adapter.SendReceipt{}, nil
|
||
}
|
||
receipt := receiptFromResponse(resp)
|
||
a.logSendReceipt(ctx, receipt)
|
||
return receipt, nil
|
||
}
|
||
if !wantReceipt {
|
||
if err := conn.WriteMessage(opText, payload); err != nil {
|
||
a.setDisconnected()
|
||
return adapter.SendReceipt{}, fmt.Errorf("qq-obv11: write send_msg: %w", err)
|
||
}
|
||
return adapter.SendReceipt{}, nil
|
||
}
|
||
resp, err := a.sendOverWSAndAwait(ctx, conn, payload, echo)
|
||
if err != nil {
|
||
return adapter.SendReceipt{}, err
|
||
}
|
||
a.logEchoFailure(resp)
|
||
receipt := receiptFromResponse(resp)
|
||
a.logSendReceipt(ctx, receipt)
|
||
return receipt, 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.lastConnectErr = ""
|
||
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
|
||
}
|
||
}
|