// Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 package qq import ( "context" "fmt" "strings" "sync" "time" "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/Rain-kl/Wavelet/pkg/message_gateway" "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" "golang.org/x/oauth2" ) // qqEvent is a testable inbound envelope. type qqEvent struct { Kind string UserID string Text string MessageID string } // Adapter is an official QQ Bot C2C channel. type Adapter struct { cfg message_gateway.ChannelConfig onInbound message_gateway.Handler api openapi.OpenAPI tokenSrc oauth2.TokenSource cancel context.CancelFunc mu sync.Mutex disconnected bool } // New constructs a QQ adapter. func New(cfg message_gateway.ChannelConfig, onInbound message_gateway.Handler) (message_gateway.Channel, error) { if strings.TrimSpace(cfg.Credentials["app_id"]) == "" || strings.TrimSpace(cfg.Credentials["app_secret"]) == "" { return nil, fmt.Errorf("qq: app_id and app_secret are required") } return &Adapter{cfg: cfg, onInbound: onInbound}, nil } // Type returns qq. func (a *Adapter) Type() string { return message_gateway.ChannelTypeQQ } // Capabilities reports C2C text/media support. func (a *Adapter) Capabilities() message_gateway.Capability { return message_gateway.Capability{Text: true, Image: true, File: true, Reply: true} } // Connect starts the official WebSocket session (C2C intent). func (a *Adapter) Connect(ctx context.Context) error { credentials := &token.QQBotCredentials{ AppID: a.cfg.Credentials["app_id"], AppSecret: a.cfg.Credentials["app_secret"], } tokSrc := token.NewQQBotTokenSource(credentials) runCtx, cancel := context.WithCancel(ctx) if err := token.StartRefreshAccessToken(runCtx, tokSrc); err != nil { cancel() return fmt.Errorf("qq: refresh token: %w", err) } var api openapi.OpenAPI if strings.EqualFold(strings.TrimSpace(a.cfg.Extra["sandbox"]), "true") { api = botgo.NewSandboxOpenAPI(credentials.AppID, tokSrc).WithTimeout(5 * time.Second) } else { api = botgo.NewOpenAPI(credentials.AppID, tokSrc).WithTimeout(5 * time.Second) } wsAP, err := api.WS(ctx, nil, "") if err != nil { cancel() return fmt.Errorf("qq: websocket ap: %w", err) } intent := event.RegisterHandlers(event.C2CMessageEventHandler(func(_ *dto.WSPayload, data *dto.WSC2CMessageData) error { authorID := "" if data != nil && data.Author != nil { authorID = data.Author.ID } text := "" id := "" if data != nil { text = data.Content id = data.ID } a.handleEvent(qqEvent{Kind: "c2c", UserID: authorID, Text: text, MessageID: id}) return nil })) a.mu.Lock() a.api = api a.tokenSrc = tokSrc a.cancel = cancel a.disconnected = false a.mu.Unlock() go func() { if err := botgo.NewSessionManager().Start(wsAP, tokSrc, &intent); err != nil { logger.ErrorF(runCtx, "qq session stopped: %v", err) } }() return nil } // Disconnect stops token refresh and drops further inbound events. func (a *Adapter) Disconnect(ctx context.Context) error { a.mu.Lock() defer a.mu.Unlock() a.disconnected = true if a.cancel != nil { a.cancel() a.cancel = nil } return nil } // Send posts a C2C text reply. func (a *Adapter) Send(ctx context.Context, to message_gateway.Recipient, msg message_gateway.OutboundMessage) error { a.mu.Lock() api := a.api a.mu.Unlock() if api == nil { return fmt.Errorf("qq: not connected") } _, err := api.PostC2CMessage(ctx, to.PlatformUserID, &dto.MessageToCreate{ Content: msg.Text, MsgID: msg.ReplyToID, }) return err } func (a *Adapter) handleEvent(ev qqEvent) { if ev.Kind != "c2c" { return } a.mu.Lock() disconnected := a.disconnected a.mu.Unlock() if disconnected || a.onInbound == nil { return } _ = a.onInbound(context.Background(), message_gateway.InboundMessage{ ChannelID: a.cfg.ID, ChannelType: message_gateway.ChannelTypeQQ, PlatformUserID: ev.UserID, ChatID: ev.UserID, MessageID: ev.MessageID, Text: ev.Text, }) }