package robot import ( "context" "strings" "sync" "cyberstrike-ai/internal/config" "github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/event" "github.com/tencent-connect/botgo/openapi" "github.com/tencent-connect/botgo/token" "go.uber.org/zap" ) const ( qqPlatform = "qq" qqMaxMessageRunes = 3500 ) var ( qqHandlerMu sync.Mutex qqHandler MessageHandler qqLogger *zap.Logger qqAPI openapi.OpenAPI ) // StartQQ 启动 QQ 机器人 WebSocket(C2C 与群 @,出站连接,无需公网回调)。 func StartQQ(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { cfg := robotsCfg.QQ if !cfg.Enabled || strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.ClientSecret) == "" { return } go runQQLoop(ctx, cfg, h, logger) } func runQQLoop(ctx context.Context, cfg config.RobotQQConfig, h MessageHandler, logger *zap.Logger) { backoff := reconnectInitial for { if ctx.Err() != nil { logger.Info("QQ 机器人 WebSocket 已按配置关闭") return } err := runQQSession(ctx, cfg, h, logger) if ctx.Err() != nil { return } if err != nil { logger.Warn("QQ 机器人 WebSocket 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) } if !waitReconnect(ctx, &backoff) { return } } } func runQQSession(ctx context.Context, cfg config.RobotQQConfig, h MessageHandler, logger *zap.Logger) error { appID := strings.TrimSpace(cfg.AppID) secret := strings.TrimSpace(cfg.ClientSecret) credentials := &token.QQBotCredentials{AppID: appID, AppSecret: secret} tokenSource := token.NewQQBotTokenSource(credentials) if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil { return err } var api openapi.OpenAPI if cfg.Sandbox { api = botgo.NewSandboxOpenAPI(appID, tokenSource) } else { api = botgo.NewOpenAPI(appID, tokenSource) } qqHandlerMu.Lock() qqHandler = h qqLogger = logger qqAPI = api qqHandlerMu.Unlock() defer func() { qqHandlerMu.Lock() qqHandler = nil qqLogger = nil qqAPI = nil qqHandlerMu.Unlock() }() intents := event.RegisterHandlers( event.C2CMessageEventHandler(handleQQC2CMessage), event.GroupATMessageEventHandler(handleQQGroupATMessage), ) wsInfo, err := api.WS(ctx, nil, "") if err != nil { return err } logger.Info("QQ 机器人 WebSocket 正在连接…", zap.String("app_id", appID), zap.Bool("sandbox", cfg.Sandbox)) done := make(chan error, 1) go func() { done <- botgo.NewSessionManager().Start(wsInfo, tokenSource, &intents) }() select { case <-ctx.Done(): return ctx.Err() case err := <-done: if err != nil { return err } return nil } } func handleQQC2CMessage(payload *dto.WSPayload, data *dto.WSC2CMessageData) error { if data == nil || data.Author == nil { return nil } text := strings.TrimSpace(data.Content) if text == "" { return nil } userOpenID := strings.TrimSpace(data.Author.ID) if userOpenID == "" { return nil } userID := "u:" + userOpenID qqHandlerMu.Lock() h := qqHandler logger := qqLogger api := qqAPI qqHandlerMu.Unlock() if h == nil || api == nil { return nil } logger.Info("QQ 收到 C2C 消息", zap.String("from", userID), zap.String("content", text)) reply := h.HandleMessage(qqPlatform, userID, text) return qqPostC2CReply(context.Background(), api, userOpenID, payload, data.ID, reply, logger) } func handleQQGroupATMessage(payload *dto.WSPayload, data *dto.WSGroupATMessageData) error { if data == nil || data.Author == nil { return nil } text := strings.TrimSpace(data.Content) if text == "" { return nil } userOpenID := strings.TrimSpace(data.Author.ID) groupID := strings.TrimSpace(data.GroupID) if userOpenID == "" { return nil } userID := "g:" + groupID + "|u:" + userOpenID qqHandlerMu.Lock() h := qqHandler logger := qqLogger api := qqAPI qqHandlerMu.Unlock() if h == nil || api == nil { return nil } logger.Info("QQ 收到群 @ 消息", zap.String("from", userID), zap.String("content", text)) reply := h.HandleMessage(qqPlatform, userID, text) return qqPostGroupReply(context.Background(), api, groupID, payload, data.ID, reply, logger) } func qqPostC2CReply(ctx context.Context, api openapi.OpenAPI, userOpenID string, payload *dto.WSPayload, msgID, reply string, logger *zap.Logger) error { reply = trimReply(reply) if reply == "" { return nil } for _, chunk := range splitTextChunks(reply, qqMaxMessageRunes) { msg := &dto.MessageToCreate{ Content: chunk, MsgID: msgID, } if payload != nil && payload.EventID != "" { msg.EventID = payload.EventID } if _, err := api.PostC2CMessage(ctx, userOpenID, msg); err != nil { logger.Warn("QQ 发送 C2C 回复失败", zap.String("to", userOpenID), zap.Error(err)) return err } } return nil } func qqPostGroupReply(ctx context.Context, api openapi.OpenAPI, groupID string, payload *dto.WSPayload, msgID, reply string, logger *zap.Logger) error { reply = trimReply(reply) if reply == "" { return nil } for _, chunk := range splitTextChunks(reply, qqMaxMessageRunes) { msg := &dto.MessageToCreate{ Content: chunk, MsgID: msgID, } if payload != nil && payload.EventID != "" { msg.EventID = payload.EventID } if _, err := api.PostGroupMessage(ctx, groupID, msg); err != nil { logger.Warn("QQ 发送群消息回复失败", zap.String("group", groupID), zap.Error(err)) return err } } return nil }