Files
ragflow/internal/channels/qqbot.go
jay77721 cef23ce686 fix(channels): rename stringValue to anyToString in qqbot.go to resolve redeclaration (#17610)
## Summary
- Fixes a compile error in `internal/channels/qqbot.go`: the local
`stringValue(value any)` helper collided with the existing
`stringValue(value *string)` in `feishu.go` within the same package
(`stringValue redeclared in this block`).
- Rename the qqbot variant to `anyToString` (definition + 13 call
sites). The two helpers do different jobs (any->string via `fmt.Sprint`
vs. `*string` deref), so they are not merged.
2026-07-31 12:17:25 +08:00

591 lines
14 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package channels
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"runtime/debug"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"ragflow/internal/channels/core"
)
const (
defaultQQBotBaseURL = "https://api.sgroup.qq.com"
defaultQQBotTokenURL = "https://bots.qq.com/app/getAppAccessToken"
qqBotRequestTimeout = 60 * time.Second
qqBotReconnectDelay = 3 * time.Second
qqBotIntents = (1 << 30) | (1 << 12) | (1 << 25) | (1 << 26)
)
type qqBotAccount struct {
AccountID string
AppID string
ClientSecret string
BaseURL string
TokenURL string
}
type qqBotChannel struct {
account qqBotAccount
client *http.Client
dialer *websocket.Dialer
mu sync.Mutex
writeMu sync.Mutex
handler core.MessageHandler
cancel context.CancelFunc
done chan struct{}
sessionID string
sequence *int64
}
type qqBotGatewayPayload struct {
Op int `json:"op"`
Data json.RawMessage `json:"d"`
Sequence *int64 `json:"s"`
Type string `json:"t"`
}
type qqBotHello struct {
HeartbeatInterval int `json:"heartbeat_interval"`
}
func newQQBotChannel(account qqBotAccount) *qqBotChannel {
if strings.TrimSpace(account.BaseURL) == "" {
account.BaseURL = defaultQQBotBaseURL
}
if strings.TrimSpace(account.TokenURL) == "" {
account.TokenURL = defaultQQBotTokenURL
}
return &qqBotChannel{
account: account,
client: &http.Client{Timeout: qqBotRequestTimeout},
dialer: websocket.DefaultDialer,
}
}
func newQQBotChannelFromConfig(accountID string, cfg map[string]any) (*qqBotChannel, error) {
appID := firstString(cfg, "app_id")
clientSecret := firstString(cfg, "client_secret")
if appID == "" || clientSecret == "" {
return nil, fmt.Errorf("qqbot account %q is missing app_id or client_secret", accountID)
}
return newQQBotChannel(qqBotAccount{
AccountID: accountID,
AppID: appID,
ClientSecret: clientSecret,
BaseURL: firstString(cfg, "base_url"),
}), nil
}
func (c *qqBotChannel) ChannelID() string {
return "qqbot"
}
func (c *qqBotChannel) AccountID() string {
return c.account.AccountID
}
func (c *qqBotChannel) SetMessageHandler(handler core.MessageHandler) {
c.mu.Lock()
defer c.mu.Unlock()
c.handler = handler
}
func (c *qqBotChannel) Start(ctx context.Context) error {
c.mu.Lock()
if c.cancel != nil {
c.mu.Unlock()
return nil
}
runCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
c.cancel = cancel
c.done = done
c.mu.Unlock()
go func() {
c.run(runCtx)
c.mu.Lock()
if c.done == done {
c.cancel = nil
c.done = nil
}
c.mu.Unlock()
close(done)
}()
log.Printf("[qqbot:%s] starting gateway client", c.account.AccountID)
return nil
}
func (c *qqBotChannel) Stop(ctx context.Context) error {
c.mu.Lock()
cancel := c.cancel
done := c.done
c.mu.Unlock()
if cancel == nil {
return nil
}
cancel()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (c *qqBotChannel) Send(ctx context.Context, msg core.OutgoingMessage) error {
chatType, peerID := normalizeQQBotTarget(msg.ChatID)
if peerID == "" {
return errors.New("chat_id is required")
}
token, err := c.getAccessToken(ctx)
if err != nil {
return err
}
var path string
switch chatType {
case "c2c":
path = "/v2/users/" + url.PathEscape(peerID) + "/messages"
case "group":
path = "/v2/groups/" + url.PathEscape(peerID) + "/messages"
case "dm":
path = "/dms/" + url.PathEscape(peerID) + "/messages"
case "channel":
path = "/channels/" + url.PathEscape(peerID) + "/messages"
default:
return fmt.Errorf("unsupported outbound chat type %q", chatType)
}
body := map[string]any{
"content": msg.Text,
"msg_type": 0,
"msg_seq": qqBotMessageSequence(),
}
if msg.ReplyToMessageID != "" {
body["msg_id"] = msg.ReplyToMessageID
}
return c.requestJSON(ctx, token, http.MethodPost, path, body, true, false, nil)
}
func (c *qqBotChannel) run(ctx context.Context) {
for ctx.Err() == nil {
token, err := c.getAccessToken(ctx)
if err == nil {
var gatewayURL string
gatewayURL, err = c.getGatewayURL(ctx, token)
if err == nil {
err = c.runGatewaySession(ctx, gatewayURL, token)
}
}
if err != nil && ctx.Err() == nil {
log.Printf("[qqbot:%s] gateway loop error: %v", c.account.AccountID, err)
}
if !waitForContext(ctx, qqBotReconnectDelay) {
return
}
}
}
func (c *qqBotChannel) runGatewaySession(ctx context.Context, gatewayURL string, token string) error {
conn, _, err := c.dialer.DialContext(ctx, gatewayURL, nil)
if err != nil {
return err
}
defer conn.Close()
log.Printf("[qqbot:%s] connected to gateway", c.account.AccountID)
sessionCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
<-sessionCtx.Done()
_ = conn.Close()
}()
var heartbeatCancel context.CancelFunc
defer func() {
if heartbeatCancel != nil {
heartbeatCancel()
}
}()
for ctx.Err() == nil {
_, data, err := conn.ReadMessage()
if err != nil {
return err
}
var payload qqBotGatewayPayload
if err := json.Unmarshal(data, &payload); err != nil {
log.Printf("[qqbot:%s] invalid gateway payload: %.200s", c.account.AccountID, data)
continue
}
if payload.Sequence != nil {
c.setSequence(*payload.Sequence)
}
switch payload.Op {
case 10:
var hello qqBotHello
if err := json.Unmarshal(payload.Data, &hello); err != nil {
return fmt.Errorf("decode gateway hello: %w", err)
}
if err := c.sendIdentifyOrResume(conn, token); err != nil {
return err
}
if heartbeatCancel != nil {
heartbeatCancel()
}
heartbeatCtx, stopHeartbeat := context.WithCancel(sessionCtx)
heartbeatCancel = stopHeartbeat
go c.runHeartbeat(heartbeatCtx, conn, hello.HeartbeatInterval)
case 11:
continue
case 7:
return errors.New("gateway requested reconnect")
case 9:
var canResume bool
_ = json.Unmarshal(payload.Data, &canResume)
if !canResume {
c.clearSession()
if !waitForContext(ctx, qqBotReconnectDelay) {
return ctx.Err()
}
}
return fmt.Errorf("invalid gateway session (can_resume=%t)", canResume)
case 0:
if err := c.handleDispatch(ctx, payload.Type, payload.Data); err != nil {
log.Printf("[qqbot:%s] ignored invalid %s event: %v", c.account.AccountID, payload.Type, err)
}
}
}
return ctx.Err()
}
func (c *qqBotChannel) runHeartbeat(ctx context.Context, conn *websocket.Conn, intervalMS int) {
delay := time.Duration(intervalMS) * time.Millisecond
if delay < time.Second {
delay = time.Second
}
ticker := time.NewTicker(delay)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
payload := map[string]any{"op": 1, "d": c.currentSequence()}
if err := c.writeJSON(conn, payload); err != nil {
log.Printf("[qqbot:%s] heartbeat failed: %v", c.account.AccountID, err)
_ = conn.Close()
return
}
}
}
}
func (c *qqBotChannel) sendIdentifyOrResume(conn *websocket.Conn, token string) error {
c.mu.Lock()
sessionID := c.sessionID
var sequence *int64
if c.sequence != nil {
value := *c.sequence
sequence = &value
}
c.mu.Unlock()
if sessionID != "" && sequence != nil {
return c.writeJSON(conn, map[string]any{
"op": 6,
"d": map[string]any{
"token": "QQBot " + token,
"session_id": sessionID,
"seq": *sequence,
},
})
}
return c.writeJSON(conn, map[string]any{
"op": 2,
"d": map[string]any{
"token": "QQBot " + token,
"intents": qqBotIntents,
"shard": []int{0, 1},
},
})
}
func (c *qqBotChannel) handleDispatch(ctx context.Context, eventType string, data json.RawMessage) error {
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
if eventType == "READY" {
c.mu.Lock()
c.sessionID = anyToString(raw["session_id"])
c.mu.Unlock()
return nil
}
if eventType == "RESUMED" {
return nil
}
incoming := c.normalizeIncomingEvent(eventType, raw)
if incoming == nil {
return nil
}
c.mu.Lock()
handler := c.handler
c.mu.Unlock()
if handler != nil {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[qqbot:%s] handler panic: %v\n%s", c.account.AccountID, r, debug.Stack())
}
}()
if err := handler(ctx, *incoming); err != nil {
log.Printf("[qqbot:%s] handler error: %v", c.account.AccountID, err)
}
}()
}
return nil
}
func (c *qqBotChannel) normalizeIncomingEvent(eventType string, data map[string]any) *core.IncomingMessage {
author, _ := data["author"].(map[string]any)
message := &core.IncomingMessage{
Channel: c.ChannelID(),
AccountID: c.AccountID(),
MessageID: anyToString(data["id"]),
Text: anyToString(data["content"]),
Raw: data,
}
switch eventType {
case "C2C_MESSAGE_CREATE":
message.SenderID = anyToString(author["user_openid"])
if message.SenderID == "" {
return nil
}
message.ChatID = incomingQQBotChatID("c2c", message.SenderID)
message.ChatType = "p2p"
case "GROUP_AT_MESSAGE_CREATE", "GROUP_MESSAGE_CREATE":
groupID := anyToString(data["group_openid"])
message.SenderID = anyToString(author["member_openid"])
if groupID == "" || message.SenderID == "" {
return nil
}
message.ChatID = incomingQQBotChatID("group", groupID)
message.ChatType = "group"
case "DIRECT_MESSAGE_CREATE":
message.SenderID = anyToString(author["id"])
if message.SenderID == "" {
return nil
}
chatID := anyToString(data["guild_id"])
if chatID == "" {
chatID = message.SenderID
}
message.ChatID = incomingQQBotChatID("dm", chatID)
message.ChatType = "dm"
case "AT_MESSAGE_CREATE":
message.SenderID = anyToString(author["id"])
channelID := anyToString(data["channel_id"])
if message.SenderID == "" || channelID == "" {
return nil
}
message.ChatID = incomingQQBotChatID("channel", channelID)
message.ChatType = "channel"
default:
return nil
}
return message
}
func (c *qqBotChannel) getAccessToken(ctx context.Context) (string, error) {
var response map[string]any
err := c.requestJSON(ctx, "", http.MethodPost, c.account.TokenURL, map[string]any{
"appId": c.account.AppID,
"clientSecret": c.account.ClientSecret,
}, false, true, &response)
if err != nil {
return "", err
}
token := anyToString(response["access_token"])
if token == "" {
token = anyToString(response["accessToken"])
}
if token == "" {
return "", fmt.Errorf("qqbot token response missing access_token: %v", response)
}
return token, nil
}
func (c *qqBotChannel) getGatewayURL(ctx context.Context, token string) (string, error) {
var response map[string]any
if err := c.requestJSON(ctx, token, http.MethodGet, "/gateway", nil, true, false, &response); err != nil {
return "", err
}
gatewayURL := anyToString(response["url"])
if gatewayURL == "" {
return "", fmt.Errorf("qqbot gateway response missing url: %v", response)
}
return gatewayURL, nil
}
func (c *qqBotChannel) requestJSON(
ctx context.Context,
accessToken string,
method string,
path string,
body map[string]any,
auth bool,
absoluteURL bool,
out any,
) error {
requestURL := path
if !absoluteURL {
requestURL = strings.TrimRight(c.account.BaseURL, "/") + path
}
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, requestURL, reader)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if auth && accessToken != "" {
req.Header.Set("Authorization", "QQBot "+accessToken)
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("status: %d, response: %s", resp.StatusCode, payload)
}
if len(bytes.TrimSpace(payload)) == 0 {
return nil
}
if out == nil {
out = &map[string]any{}
}
if err := json.Unmarshal(payload, out); err != nil {
return fmt.Errorf("invalid json response from %s: %.200s: %w", path, payload, err)
}
return nil
}
func (c *qqBotChannel) writeJSON(conn *websocket.Conn, value any) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
return conn.WriteJSON(value)
}
func (c *qqBotChannel) setSequence(sequence int64) {
c.mu.Lock()
defer c.mu.Unlock()
c.sequence = &sequence
}
func (c *qqBotChannel) currentSequence() any {
c.mu.Lock()
defer c.mu.Unlock()
if c.sequence == nil {
return nil
}
return *c.sequence
}
func (c *qqBotChannel) clearSession() {
c.mu.Lock()
defer c.mu.Unlock()
c.sessionID = ""
c.sequence = nil
}
func normalizeQQBotTarget(chatID string) (string, string) {
raw := strings.TrimSpace(chatID)
raw = strings.TrimPrefix(raw, "qqbot:")
for _, chatType := range []string{"group", "channel", "dm", "c2c"} {
prefix := chatType + ":"
if strings.HasPrefix(raw, prefix) {
return chatType, strings.TrimPrefix(raw, prefix)
}
}
return "c2c", raw
}
func incomingQQBotChatID(chatType string, peerID string) string {
return "qqbot:" + chatType + ":" + peerID
}
func qqBotMessageSequence() int {
sequence := int(time.Now().UnixMilli() % 65535)
if sequence != 0 {
return sequence
}
var value uint16
if err := binary.Read(rand.Reader, binary.BigEndian, &value); err != nil || value == 0 {
return 1
}
return int(value)
}
func anyToString(value any) string {
if value == nil {
return ""
}
return fmt.Sprint(value)
}
func waitForContext(ctx context.Context, delay time.Duration) bool {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}