| 1 | // Package qq 实现 QQ 官方 Bot API v2 适配器。 |
| 2 | // 参考 Hermes Agent 的 qqbot adapter 实现: |
| 3 | // - app token 获取与刷新 |
| 4 | // - WebSocket gateway 连接、heartbeat、resume |
| 5 | // - REST API 回复消息 |
| 6 | // - C2C / group / guild / direct message 支持 |
| 7 | // - inline keyboard 审批 |
| 8 | package qq |
| 9 | |
| 10 | import ( |
| 11 | "context" |
| 12 | "log/slog" |
| 13 | "sync" |
| 14 | "time" |
| 15 | |
| 16 | "reasonix/internal/bot" |
| 17 | "reasonix/internal/config" |
| 18 | |
| 19 | "golang.org/x/net/websocket" |
| 20 | ) |
| 21 | |
| 22 | // New 创建 QQ Bot 适配器。 |
| 23 | func New(cfg config.QQBotConfig, logger *slog.Logger) bot.Adapter { |
| 24 | return &adapter{ |
| 25 | cfg: cfg, |
| 26 | logger: logger.With("platform", "qq"), |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | type adapter struct { |
| 31 | cfg config.QQBotConfig |
| 32 | logger *slog.Logger |
| 33 | msgCh chan bot.InboundMessage |
| 34 | cancel context.CancelFunc |
| 35 | loopWG sync.WaitGroup |
| 36 | |
| 37 | // gateway 状态 |
| 38 | connMu sync.Mutex |
| 39 | conn *websocket.Conn // live gateway connection, closed by Stop to unblock reads |
| 40 | sessionID string |
| 41 | seq int64 |
| 42 | token string |
| 43 | tokenExpiry time.Time |
| 44 | tokenMu sync.Mutex |
| 45 | |
| 46 | sendMu sync.Mutex |
| 47 | nextOutboundMsgSeq int |
| 48 | markdownDisabled bool |
| 49 | } |
| 50 | |
| 51 | func (a *adapter) Platform() bot.Platform { return bot.PlatformQQ } |
| 52 | func (a *adapter) Name() string { return "qq" } |
| 53 | |
| 54 | func (a *adapter) Start(ctx context.Context) error { |
| 55 | a.msgCh = make(chan bot.InboundMessage, 64) |
| 56 | startupCtx, startupCancel := context.WithTimeout(ctx, qqStartupValidationTimeout) |
| 57 | defer startupCancel() |
| 58 | token, err := a.getAccessToken(startupCtx) |
| 59 | if err != nil { |
| 60 | return err |
| 61 | } |
| 62 | if _, err := a.getGatewayURL(startupCtx, token); err != nil { |
| 63 | return err |
| 64 | } |
| 65 | ctx, a.cancel = context.WithCancel(ctx) |
| 66 | |
| 67 | a.loopWG.Add(1) |
| 68 | go func() { |
| 69 | defer a.loopWG.Done() |
| 70 | a.gatewayLoop(ctx) |
| 71 | }() |
| 72 | return nil |
| 73 | } |
| 74 | |
| 75 | // Stop 取消 gateway context、关闭当前 WebSocket 连接并等待 gatewayLoop 退出。 |
| 76 | // websocket 的阻塞读不响应 context,只有关闭连接才能解除阻塞;不等待就返回 |
| 77 | // 会在宿主重建 bot runtime 后留下仍占用 QQ gateway session 的僵尸连接。 |
| 78 | func (a *adapter) Stop() error { |
| 79 | if a.cancel != nil { |
| 80 | a.cancel() |
| 81 | } |
| 82 | a.closeConn() |
| 83 | a.loopWG.Wait() |
| 84 | return nil |
| 85 | } |
| 86 | |
| 87 | func (a *adapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) { |
| 88 | return a.sendMessage(ctx, msg) |
| 89 | } |
| 90 | |
| 91 | func (a *adapter) SendTyping(ctx context.Context, chatID string) error { |
| 92 | return nil // QQ Bot 暂不支持 typing 指示器 |
| 93 | } |
| 94 | |
| 95 | func (a *adapter) Messages() <-chan bot.InboundMessage { |
| 96 | return a.msgCh |
| 97 | } |
| 98 |