痛点:mode=client 时首次拨号失败会让 Connect 直接返回 error ⇒ 渠道起不来; 而「连上后再断」本来就有重连。⇒ 把 readLoop 的入口条件放宽为「conn==nil 也算」, **不新造第二套循环**。 新增 reconnect.go:退避 2s → ×2 → 上限 60s、抖动 ±25%(具名常量),失败计数跨 Connect 与 readLoop 共享。 ws_client:新增 connectFirstClient —— 可重试失败只记 WARN 并返回 nil; 配置缺失(remote_url 空)仍致命(闸门口径不变)。 connected 语义不许撒谎:始终由真实连接决定;HealthCheck 未连上返回 ErrNotConnected; 仅 http 传输保留「已装配即可服务」旧口径(逐字未改)。 ctx 感知等待(sleepCtx)+ 等待后复查 ctx ⇒ Disconnect 能立即中止,不泄漏 goroutine。 server 模式与 http 三端行为逐字不变(既有用例零改动全绿 + TestServerModeUnaffectedByClientRetry)。 测试:client_retry_test.go(428) + reconnect_test.go(83),用可注入 dialer + gate 通道造确定性时序。 变异 5 条(首连 fail-fast / 退避无上限 / 取消失效 / 未连上就报已连 / 抖动未接线)。 诚实边界:60s 上限对聊天渠道可能偏慢(改 30s 是一行常量);仓库内仍存第二份指数退避 (wsutil.Backoff 无抖动能力,收敛需改 internal/**,本切片禁碰)。
371 lines
10 KiB
Go
371 lines
10 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 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.
|
|
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. 附件字节在**路由之前**内联(切片 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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|