@@ -604,6 +633,7 @@ import telegramLogo from '@/assets/img/im/telegram.svg';
import dingtalkLogo from '@/assets/img/im/dingtalk.svg';
import mattermostLogo from '@/assets/img/im/mattermost.svg';
import wechatLogo from '@/assets/img/im/wechat.svg';
+import qqbotLogo from '@/assets/img/im/qqbot.svg';
type IMPlatform = IMChannel['platform'];
@@ -615,6 +645,7 @@ const PLATFORM_LOGO: Record = {
dingtalk: dingtalkLogo,
mattermost: mattermostLogo,
wechat: wechatLogo,
+ qqbot: qqbotLogo,
};
const platformLogo = (platform: string): string => (platform ? PLATFORM_LOGO[platform] || '' : '');
@@ -664,6 +695,7 @@ const platformOptions = computed(() => ([
{ value: 'dingtalk' as IMPlatform, label: t('agentEditor.im.dingtalk'), logo: dingtalkLogo },
{ value: 'mattermost' as IMPlatform, label: t('agentEditor.im.mattermost'), logo: mattermostLogo },
{ value: 'wechat' as IMPlatform, label: t('agentEditor.im.wechat'), logo: wechatLogo },
+ { value: 'qqbot' as IMPlatform, label: t('agentEditor.im.qqbot'), logo: qqbotLogo },
]));
const drawerTitle = computed(() => {
@@ -710,7 +742,7 @@ const defaultCredentials = (): Record => ({});
const formData = ref({
target_agent_id: '',
- platform: 'wecom' as 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost' | 'wechat',
+ platform: 'wecom' as IMPlatform,
name: '',
mode: 'websocket' as 'webhook' | 'websocket' | 'longpoll',
output_mode: 'stream' as 'stream' | 'full',
diff --git a/frontend/src/components/menu.vue b/frontend/src/components/menu.vue
index 74c59b09..a5123d90 100644
--- a/frontend/src/components/menu.vue
+++ b/frontend/src/components/menu.vue
@@ -259,6 +259,7 @@ import telegramLogo from '@/assets/img/im/telegram.svg';
import dingtalkLogo from '@/assets/img/im/dingtalk.svg';
import mattermostLogo from '@/assets/img/im/mattermost.svg';
import wechatLogo from '@/assets/img/im/wechat.svg';
+import qqbotLogo from '@/assets/img/im/qqbot.svg';
const PLATFORM_LOGO: Record = {
wecom: wecomLogo,
@@ -268,6 +269,7 @@ const PLATFORM_LOGO: Record = {
dingtalk: dingtalkLogo,
mattermost: mattermostLogo,
wechat: wechatLogo,
+ qqbot: qqbotLogo,
};
const platformLogo = (p: string): string => (p ? PLATFORM_LOGO[p] || '' : '');
diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts
index 68067f5b..7a1a1905 100755
--- a/frontend/src/i18n/locales/en-US.ts
+++ b/frontend/src/i18n/locales/en-US.ts
@@ -5303,7 +5303,7 @@ export default {
},
im: {
title: 'IM Integration',
- description: 'Connect agent to instant messaging platforms like WeCom, Feishu, Slack, Telegram, DingTalk, Mattermost and WeChat',
+ description: 'Connect agent to instant messaging platforms like WeCom, Feishu, Slack, Telegram, DingTalk, Mattermost, WeChat and QQBot',
feishu: 'Feishu',
slack: 'Slack',
telegram: 'Telegram',
@@ -5311,6 +5311,7 @@ export default {
mattermost: 'Mattermost',
wecom: 'WeCom',
wechat: 'WeChat',
+ qqbot: 'QQBot',
addChannel: 'Add Channel',
channelsTitle: 'IM Channels',
disabled: 'Disabled',
@@ -5336,6 +5337,9 @@ export default {
dingtalkCardTemplateId: 'Card Template ID (optional)',
dingtalkCardTemplateIdHint: 'Create an AI Card template at open-dev.dingtalk.com/fe/card to enable streaming output with typewriter effect',
mattermostConsole: 'Mattermost integrations',
+ qqbotConsole: 'QQ Open Platform',
+ qqbotAPIBaseURLHint: 'Optional. Leave empty to use the default production API endpoint.',
+ qqbotGatewayURLHint: 'Optional. Leave empty to fetch the common WebSocket gateway automatically.',
mattermostModeHint: 'Mattermost only supports Webhook mode (outgoing webhook + bot token).',
mattermostPostToMain: 'Post replies in channel timeline',
mattermostPostToMainHint:
diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts
index 2815d662..137c42ce 100755
--- a/frontend/src/i18n/locales/ko-KR.ts
+++ b/frontend/src/i18n/locales/ko-KR.ts
@@ -5244,7 +5244,7 @@ export default {
},
im: {
title: "IM 통합",
- description: "에이전트를 Feishu, Slack, Telegram, DingTalk, Mattermost 등 인스턴트 메시징 플랫폼에 연결",
+ description: "에이전트를 Feishu, Slack, Telegram, DingTalk, Mattermost, QQBot 등 인스턴트 메시징 플랫폼에 연결",
wecom: "WeCom",
feishu: "Feishu",
slack: "Slack",
@@ -5252,6 +5252,7 @@ export default {
dingtalk: "DingTalk",
mattermost: "Mattermost",
wechat: "WeChat",
+ qqbot: "QQBot",
addChannel: "채널 추가",
channelsTitle: "IM 채널",
disabled: "비활성",
@@ -5277,6 +5278,9 @@ export default {
dingtalkCardTemplateId: "카드 템플릿 ID (선택 사항)",
dingtalkCardTemplateIdHint: "open-dev.dingtalk.com/fe/card에서 AI 카드 템플릿을 만들면 타자기 효과 스트리밍 출력이 활성화됩니다",
mattermostConsole: "Mattermost 통합",
+ qqbotConsole: "QQ Open Platform",
+ qqbotAPIBaseURLHint: "선택 사항. 비워두면 기본 프로덕션 API 엔드포인트를 사용합니다.",
+ qqbotGatewayURLHint: "선택 사항. 비워두면 공용 WebSocket gateway를 자동으로 가져옵니다.",
mattermostModeHint: "Mattermost는 Webhook(아웃고잉 웹훅 + 봇 토큰)만 지원합니다.",
mattermostPostToMain: "답변을 채널 메인 타임라인에 표시",
mattermostPostToMainHint:
diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts
index d7c67875..66eef651 100755
--- a/frontend/src/i18n/locales/ru-RU.ts
+++ b/frontend/src/i18n/locales/ru-RU.ts
@@ -4814,7 +4814,7 @@ export default {
},
im: {
title: 'Интеграция IM',
- description: 'Подключите агента к платформам мгновенных сообщений, таким как Feishu, Slack, Telegram, DingTalk и Mattermost',
+ description: 'Подключите агента к платформам мгновенных сообщений, таким как Feishu, Slack, Telegram, DingTalk, Mattermost и QQBot',
wecom: 'WeCom',
feishu: 'Feishu',
slack: 'Slack',
@@ -4822,6 +4822,7 @@ export default {
dingtalk: 'DingTalk',
mattermost: 'Mattermost',
wechat: 'WeChat',
+ qqbot: 'QQBot',
addChannel: 'Добавить канал',
channelsTitle: 'IM-каналы',
disabled: 'Отключено',
@@ -4847,6 +4848,9 @@ export default {
dingtalkCardTemplateId: 'ID шаблона карточки (необязательно)',
dingtalkCardTemplateIdHint: 'Создайте шаблон AI-карточки на open-dev.dingtalk.com/fe/card для потоковой передачи с эффектом печатной машинки',
mattermostConsole: 'Интеграции Mattermost',
+ qqbotConsole: 'QQ Open Platform',
+ qqbotAPIBaseURLHint: 'Необязательно. Оставьте пустым, чтобы использовать стандартный production API endpoint.',
+ qqbotGatewayURLHint: 'Необязательно. Оставьте пустым, чтобы автоматически получить общий WebSocket gateway.',
mattermostModeHint: 'Mattermost поддерживает только режим Webhook (исходящий вебхук + токен бота).',
mattermostPostToMain: 'Ответы в основной ленте канала',
mattermostPostToMainHint:
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index 511b6320..ea948d32 100755
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -5251,7 +5251,7 @@ export default {
},
im: {
title: "IM 集成",
- description: "将智能体接入即时通讯平台,支持企业微信、飞书、Slack、Telegram、钉钉、Mattermost和微信",
+ description: "将智能体接入即时通讯平台,支持企业微信、飞书、Slack、Telegram、钉钉、Mattermost、微信和 QQBot",
wecom: "企业微信",
feishu: "飞书",
slack: "Slack",
@@ -5259,6 +5259,7 @@ export default {
dingtalk: "钉钉",
mattermost: "Mattermost",
wechat: "微信",
+ qqbot: "QQBot",
addChannel: "添加渠道",
channelsTitle: "IM 渠道",
disabled: "已停用",
@@ -5284,6 +5285,9 @@ export default {
dingtalkCardTemplateId: "卡片模板 ID(可选)",
dingtalkCardTemplateIdHint: "在 open-dev.dingtalk.com/fe/card 创建 AI 卡片模板,启用后支持打字机效果的流式输出",
mattermostConsole: "Mattermost 集成说明",
+ qqbotConsole: "QQ 开放平台",
+ qqbotAPIBaseURLHint: "可选,留空则使用默认正式环境 API 地址。",
+ qqbotGatewayURLHint: "可选,留空则自动获取通用 WebSocket 接入点。",
mattermostModeHint: "Mattermost 仅支持 Webhook(出站 Webhook + Bot Token)。",
mattermostPostToMain: "回复显示在频道主时间线",
mattermostPostToMainHint:
diff --git a/internal/container/container.go b/internal/container/container.go
index 6f78e35b..758039ff 100644
--- a/internal/container/container.go
+++ b/internal/container/container.go
@@ -66,6 +66,7 @@ import (
"github.com/Tencent/WeKnora/internal/im/dingtalk"
"github.com/Tencent/WeKnora/internal/im/feishu"
"github.com/Tencent/WeKnora/internal/im/mattermost"
+ "github.com/Tencent/WeKnora/internal/im/qqbot"
"github.com/Tencent/WeKnora/internal/im/slack"
"github.com/Tencent/WeKnora/internal/im/telegram"
"github.com/Tencent/WeKnora/internal/im/wechat"
@@ -1343,6 +1344,7 @@ func registerIMAdapterFactories(imService *imPkg.Service) {
imService.RegisterAdapterFactory("dingtalk", dingtalk.NewFactory())
imService.RegisterAdapterFactory("mattermost", mattermost.NewFactory())
imService.RegisterAdapterFactory("wechat", wechat.NewFactory())
+ imService.RegisterAdapterFactory("qqbot", qqbot.NewFactory())
// Load and start all enabled channels from database
if err := imService.LoadAndStartChannels(); err != nil {
diff --git a/internal/handler/im.go b/internal/handler/im.go
index f85e1ece..37ce3f36 100644
--- a/internal/handler/im.go
+++ b/internal/handler/im.go
@@ -14,7 +14,7 @@ import (
// validIMPlatforms is the set of supported IM platforms.
var validIMPlatforms = map[string]bool{
"wecom": true, "feishu": true, "slack": true, "telegram": true, "dingtalk": true, "mattermost": true,
- "wechat": true,
+ "wechat": true, "qqbot": true,
}
// IMHandler handles IM platform callback requests and channel CRUD.
@@ -60,7 +60,7 @@ func (h *IMHandler) CreateIMChannel(c *gin.Context) {
}
if !validIMPlatforms[req.Platform] {
- c.JSON(http.StatusBadRequest, gin.H{"error": "platform must be 'wecom', 'feishu', 'slack', 'telegram', 'dingtalk', 'mattermost' or 'wechat'"})
+ c.JSON(http.StatusBadRequest, gin.H{"error": "platform must be 'wecom', 'feishu', 'slack', 'telegram', 'dingtalk', 'mattermost', 'wechat' or 'qqbot'"})
return
}
diff --git a/internal/im/adapter.go b/internal/im/adapter.go
index 86c96fdd..41ec2234 100644
--- a/internal/im/adapter.go
+++ b/internal/im/adapter.go
@@ -18,6 +18,7 @@ const (
PlatformDingtalk Platform = "dingtalk"
PlatformMattermost Platform = "mattermost"
PlatformWeChat Platform = "wechat"
+ PlatformQQBot Platform = "qqbot"
)
// SessionMode determines how IM sessions are resolved.
diff --git a/internal/im/qqbot/adapter.go b/internal/im/qqbot/adapter.go
new file mode 100644
index 00000000..c36b78aa
--- /dev/null
+++ b/internal/im/qqbot/adapter.go
@@ -0,0 +1,127 @@
+package qqbot
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/Tencent/WeKnora/internal/im"
+)
+
+var _ im.Adapter = (*Adapter)(nil)
+
+type Adapter struct {
+ client *Client
+}
+
+func NewAdapter(client *Client) *Adapter {
+ return &Adapter{client: client}
+}
+
+func (a *Adapter) Platform() im.Platform {
+ return im.PlatformQQBot
+}
+
+func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
+ return false
+}
+
+func (a *Adapter) VerifyCallback(c *gin.Context) error {
+ return nil
+}
+
+func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
+ bodyBytes, err := io.ReadAll(c.Request.Body)
+ if err != nil {
+ return nil, fmt.Errorf("read body: %w", err)
+ }
+ c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
+
+ var payload gatewayPayload
+ if err := json.Unmarshal(bodyBytes, &payload); err != nil {
+ return nil, fmt.Errorf("parse payload: %w", err)
+ }
+ return parseGatewayPayload(&payload)
+}
+
+func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
+ content := strings.TrimSpace(im.FormatIMDisplayContent(reply.Content, im.StreamDisplayFinal))
+ if content == "" {
+ return nil
+ }
+ msgID := ""
+ if incoming.Extra != nil {
+ msgID = incoming.Extra[extraKeyMessageID]
+ }
+ if incoming.ChatType == im.ChatTypeGroup {
+ return a.client.SendGroupMessage(ctx, incoming.ChatID, content, msgID)
+ }
+ return a.client.SendC2CMessage(ctx, incoming.UserID, content, msgID)
+}
+
+func parseGatewayPayload(payload *gatewayPayload) (*im.IncomingMessage, error) {
+ if payload == nil || payload.Op != opDispatch {
+ return nil, nil
+ }
+ var event messageEvent
+ if err := json.Unmarshal(payload.D, &event); err != nil {
+ return nil, err
+ }
+ switch payload.T {
+ case eventC2CMessageCreate:
+ return parseC2CMessage(&event), nil
+ case eventGroupAtMessageCreate:
+ return parseGroupMessage(&event), nil
+ default:
+ return nil, nil
+ }
+}
+
+func parseC2CMessage(event *messageEvent) *im.IncomingMessage {
+ userID := firstNonEmpty(event.Author.UserOpenID, event.Author.ID)
+ return &im.IncomingMessage{
+ Platform: im.PlatformQQBot,
+ MessageType: im.MessageTypeText,
+ UserID: userID,
+ UserName: event.Author.Username,
+ ChatID: "",
+ ChatType: im.ChatTypeDirect,
+ Content: strings.TrimSpace(event.Content),
+ MessageID: event.ID,
+ Extra: map[string]string{
+ extraKeyMessageID: event.ID,
+ extraKeyChatKind: "c2c",
+ },
+ }
+}
+
+func parseGroupMessage(event *messageEvent) *im.IncomingMessage {
+ return &im.IncomingMessage{
+ Platform: im.PlatformQQBot,
+ MessageType: im.MessageTypeText,
+ UserID: firstNonEmpty(event.Author.MemberOpenID, event.Author.UserOpenID, event.Author.ID),
+ UserName: event.Author.Username,
+ ChatID: event.GroupOpenID,
+ ChatType: im.ChatTypeGroup,
+ Content: strings.TrimSpace(event.Content),
+ MessageID: event.ID,
+ Extra: map[string]string{
+ extraKeyMessageID: event.ID,
+ extraKeyChatKind: "group",
+ },
+ }
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
diff --git a/internal/im/qqbot/adapter_test.go b/internal/im/qqbot/adapter_test.go
new file mode 100644
index 00000000..28897329
--- /dev/null
+++ b/internal/im/qqbot/adapter_test.go
@@ -0,0 +1,78 @@
+package qqbot
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/Tencent/WeKnora/internal/im"
+)
+
+func TestParseGatewayPayloadC2CMessage(t *testing.T) {
+ event := messageEvent{
+ ID: "msg-1",
+ Content: " hello ",
+ Author: qqbotAuthor{UserOpenID: "user-openid", Username: "tester"},
+ }
+ raw, err := json.Marshal(event)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ msg, err := parseGatewayPayload(&gatewayPayload{Op: opDispatch, T: eventC2CMessageCreate, D: raw})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if msg == nil {
+ t.Fatal("expected message")
+ }
+ if msg.Platform != im.PlatformQQBot || msg.ChatType != im.ChatTypeDirect || msg.UserID != "user-openid" || msg.Content != "hello" {
+ t.Fatalf("unexpected message: %#v", msg)
+ }
+ if msg.Extra[extraKeyMessageID] != "msg-1" || msg.Extra[extraKeyChatKind] != "c2c" {
+ t.Fatalf("unexpected extra: %#v", msg.Extra)
+ }
+}
+
+func TestParseGatewayPayloadGroupMessage(t *testing.T) {
+ event := messageEvent{
+ ID: "msg-2",
+ Content: " group hello ",
+ GroupOpenID: "group-openid",
+ Author: qqbotAuthor{MemberOpenID: "member-openid"},
+ }
+ raw, err := json.Marshal(event)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ msg, err := parseGatewayPayload(&gatewayPayload{Op: opDispatch, T: eventGroupAtMessageCreate, D: raw})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if msg == nil {
+ t.Fatal("expected message")
+ }
+ if msg.ChatType != im.ChatTypeGroup || msg.ChatID != "group-openid" || msg.UserID != "member-openid" || msg.Content != "group hello" {
+ t.Fatalf("unexpected message: %#v", msg)
+ }
+}
+
+func TestParseExpiresIn(t *testing.T) {
+ cases := []struct {
+ name string
+ raw string
+ want int
+ }{
+ {name: "number", raw: `3600`, want: 3600},
+ {name: "string", raw: `"1800"`, want: 1800},
+ {name: "empty", raw: ``, want: 7200},
+ {name: "invalid", raw: `"bad"`, want: 7200},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := parseExpiresIn(json.RawMessage(tc.raw)); got != tc.want {
+ t.Fatalf("parseExpiresIn() = %d, want %d", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/im/qqbot/client.go b/internal/im/qqbot/client.go
new file mode 100644
index 00000000..49ebc3ed
--- /dev/null
+++ b/internal/im/qqbot/client.go
@@ -0,0 +1,169 @@
+package qqbot
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+type Client struct {
+ appID string
+ clientSecret string
+ apiBaseURL string
+ gatewayURL string
+ httpClient *http.Client
+
+ mu sync.Mutex
+ accessToken string
+ expiresAt time.Time
+}
+
+func NewClient(appID, clientSecret, apiBaseURL, gatewayURL string) (*Client, error) {
+ appID = strings.TrimSpace(appID)
+ clientSecret = strings.TrimSpace(clientSecret)
+ if appID == "" {
+ return nil, fmt.Errorf("qqbot app_id is required")
+ }
+ if clientSecret == "" {
+ return nil, fmt.Errorf("qqbot client_secret is required")
+ }
+ if apiBaseURL == "" {
+ apiBaseURL = defaultAPIBaseURL
+ }
+ return &Client{
+ appID: appID,
+ clientSecret: clientSecret,
+ apiBaseURL: strings.TrimRight(apiBaseURL, "/"),
+ gatewayURL: strings.TrimSpace(gatewayURL),
+ httpClient: &http.Client{Timeout: 15 * time.Second},
+ }, nil
+}
+
+func (c *Client) GatewayURL(ctx context.Context) (string, error) {
+ if c.gatewayURL != "" {
+ return c.gatewayURL, nil
+ }
+ var result gatewayResponse
+ if err := c.doJSON(ctx, http.MethodGet, defaultGatewayURL, nil, &result); err != nil {
+ return "", err
+ }
+ if result.URL == "" {
+ return "", fmt.Errorf("empty qqbot gateway url")
+ }
+ return result.URL, nil
+}
+
+func (c *Client) SendC2CMessage(ctx context.Context, openID, content, msgID string) error {
+ path := fmt.Sprintf("/v2/users/%s/messages", openID)
+ return c.sendText(ctx, path, content, msgID)
+}
+
+func (c *Client) SendGroupMessage(ctx context.Context, groupOpenID, content, msgID string) error {
+ path := fmt.Sprintf("/v2/groups/%s/messages", groupOpenID)
+ return c.sendText(ctx, path, content, msgID)
+}
+
+func (c *Client) sendText(ctx context.Context, path, content, msgID string) error {
+ body := sendMessageRequest{
+ Content: content,
+ MsgType: 0,
+ MsgID: msgID,
+ MsgSeq: 1,
+ }
+ return c.doJSON(ctx, http.MethodPost, c.apiBaseURL+path, body, nil)
+}
+
+func (c *Client) doJSON(ctx context.Context, method, url string, body any, out any) error {
+ var reader *bytes.Reader
+ if body != nil {
+ payload, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+ reader = bytes.NewReader(payload)
+ } else {
+ reader = bytes.NewReader(nil)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, method, url, reader)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if !strings.Contains(url, "getAppAccessToken") {
+ token, err := c.AccessToken(ctx)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "QQBot "+token)
+ }
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("qqbot api %s %s failed: %s", method, url, resp.Status)
+ }
+ if out == nil {
+ return nil
+ }
+ if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
+ return fmt.Errorf("decode qqbot response: %w", err)
+ }
+ return nil
+}
+
+func (c *Client) AccessToken(ctx context.Context) (string, error) {
+ c.mu.Lock()
+ if c.accessToken != "" && time.Until(c.expiresAt) > time.Minute {
+ token := c.accessToken
+ c.mu.Unlock()
+ return token, nil
+ }
+ c.mu.Unlock()
+
+ body := map[string]string{
+ "appId": c.appID,
+ "clientSecret": c.clientSecret,
+ }
+ var result tokenResponse
+ if err := c.doJSON(ctx, http.MethodPost, appTokenURL, body, &result); err != nil {
+ return "", err
+ }
+ if result.AccessToken == "" {
+ return "", fmt.Errorf("empty qqbot access token: code=%d message=%s", result.Code, result.Message)
+ }
+ expiresIn := parseExpiresIn(result.ExpiresIn)
+
+ c.mu.Lock()
+ c.accessToken = result.AccessToken
+ c.expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
+ c.mu.Unlock()
+ return result.AccessToken, nil
+}
+
+func parseExpiresIn(raw json.RawMessage) int {
+ if len(raw) == 0 {
+ return 7200
+ }
+ var number int
+ if err := json.Unmarshal(raw, &number); err == nil && number > 0 {
+ return number
+ }
+ var text string
+ if err := json.Unmarshal(raw, &text); err == nil {
+ var parsed int
+ if _, err := fmt.Sscanf(text, "%d", &parsed); err == nil && parsed > 0 {
+ return parsed
+ }
+ }
+ return 7200
+}
diff --git a/internal/im/qqbot/factory.go b/internal/im/qqbot/factory.go
new file mode 100644
index 00000000..fa2f9052
--- /dev/null
+++ b/internal/im/qqbot/factory.go
@@ -0,0 +1,47 @@
+package qqbot
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/Tencent/WeKnora/internal/im"
+ "github.com/Tencent/WeKnora/internal/logger"
+)
+
+func NewFactory() im.AdapterFactory {
+ return func(factoryCtx context.Context, channel *im.IMChannel, msgHandler func(context.Context, *im.IncomingMessage) error) (im.Adapter, context.CancelFunc, error) {
+ creds, err := im.ParseCredentials(channel.Credentials)
+ if err != nil {
+ return nil, nil, fmt.Errorf("parse qqbot credentials: %w", err)
+ }
+
+ mode := im.ResolveMode(channel, "websocket")
+ if mode != "websocket" {
+ return nil, nil, fmt.Errorf("unsupported qqbot mode: %s (only websocket is supported)", mode)
+ }
+
+ client, err := NewClient(
+ im.GetString(creds, "app_id"),
+ im.GetString(creds, "client_secret"),
+ im.GetString(creds, "api_base_url"),
+ im.GetString(creds, "gateway_url"),
+ )
+ if err != nil {
+ return nil, nil, err
+ }
+
+ longConn := NewLongConnClient(client, msgHandler)
+ wsCtx, wsCancel := context.WithCancel(context.Background())
+ go func() {
+ if err := longConn.Start(wsCtx); err != nil && wsCtx.Err() == nil {
+ logger.Errorf(context.Background(), "[IM] QQBot long connection stopped for channel %s: %v", channel.ID, err)
+ }
+ }()
+
+ adapter := NewAdapter(client)
+ return adapter, func() {
+ wsCancel()
+ longConn.Stop()
+ }, nil
+ }
+}
diff --git a/internal/im/qqbot/longconn.go b/internal/im/qqbot/longconn.go
new file mode 100644
index 00000000..ca79501e
--- /dev/null
+++ b/internal/im/qqbot/longconn.go
@@ -0,0 +1,196 @@
+package qqbot
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/Tencent/WeKnora/internal/im"
+ "github.com/Tencent/WeKnora/internal/logger"
+ ws "github.com/gorilla/websocket"
+)
+
+type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error
+
+type LongConnClient struct {
+ client *Client
+ handler MessageHandler
+
+ mu sync.Mutex
+ conn *ws.Conn
+ seq *int64
+ closed bool
+}
+
+func NewLongConnClient(client *Client, handler MessageHandler) *LongConnClient {
+ return &LongConnClient{client: client, handler: handler}
+}
+
+func (c *LongConnClient) Start(ctx context.Context) error {
+ logger.Infof(ctx, "[IM] QQBot WebSocket connecting...")
+ attempt := 0
+ for {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ if err := c.connectAndRun(ctx); err != nil {
+ if ctx.Err() != nil || c.isClosed() {
+ return ctx.Err()
+ }
+ attempt++
+ delay := reconnectDelay(attempt)
+ logger.Warnf(ctx, "[QQBot] connection lost: %v, reconnecting in %v", err, delay)
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(delay):
+ }
+ continue
+ }
+ attempt = 0
+ }
+}
+
+func (c *LongConnClient) Stop() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.closed = true
+ if c.conn != nil {
+ _ = c.conn.Close()
+ c.conn = nil
+ }
+}
+
+func (c *LongConnClient) connectAndRun(ctx context.Context) error {
+ gatewayURL, err := c.client.GatewayURL(ctx)
+ if err != nil {
+ return err
+ }
+ conn, _, err := ws.DefaultDialer.DialContext(ctx, gatewayURL, nil)
+ if err != nil {
+ return err
+ }
+ c.mu.Lock()
+ c.conn = conn
+ c.mu.Unlock()
+ defer func() {
+ c.mu.Lock()
+ if c.conn == conn {
+ c.conn = nil
+ }
+ c.mu.Unlock()
+ _ = conn.Close()
+ }()
+
+ for {
+ _, data, err := conn.ReadMessage()
+ if err != nil {
+ return err
+ }
+ var payload gatewayPayload
+ if err := json.Unmarshal(data, &payload); err != nil {
+ logger.Warnf(ctx, "[QQBot] invalid payload: %v", err)
+ continue
+ }
+ if payload.S != nil {
+ c.seq = payload.S
+ }
+ switch payload.Op {
+ case opHello:
+ if err := c.handleHello(ctx, conn, payload.D); err != nil {
+ return err
+ }
+ case opDispatch:
+ msg, err := parseGatewayPayload(&payload)
+ if err != nil {
+ logger.Warnf(ctx, "[QQBot] parse event failed: %v", err)
+ continue
+ }
+ if msg != nil {
+ if err := c.handler(ctx, msg); err != nil {
+ logger.Errorf(ctx, "[QQBot] handle message failed: %v", err)
+ }
+ }
+ case opReconnect, opInvalidSession:
+ return fmt.Errorf("gateway requested reconnect op=%d", payload.Op)
+ case opHeartbeatACK:
+ }
+ }
+}
+
+func (c *LongConnClient) handleHello(ctx context.Context, conn *ws.Conn, raw json.RawMessage) error {
+ var hello helloData
+ if err := json.Unmarshal(raw, &hello); err != nil {
+ return err
+ }
+ if hello.HeartbeatInterval <= 0 {
+ hello.HeartbeatInterval = 45000
+ }
+ token, err := c.client.AccessToken(ctx)
+ if err != nil {
+ return err
+ }
+ identify := identifyData{
+ Token: "QQBot " + token,
+ Intents: intentGroupAndC2C,
+ Shard: []int{0, 1},
+ }
+ payloadBytes, err := json.Marshal(identify)
+ if err != nil {
+ return err
+ }
+ if err := conn.WriteJSON(gatewayPayload{Op: opIdentify, D: payloadBytes}); err != nil {
+ return err
+ }
+ go c.heartbeatLoop(ctx, conn, time.Duration(hello.HeartbeatInterval)*time.Millisecond)
+ return nil
+}
+
+func (c *LongConnClient) heartbeatLoop(ctx context.Context, conn *ws.Conn, interval time.Duration) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ heartbeat, err := c.heartbeatPayload()
+ if err == nil {
+ err = conn.WriteJSON(heartbeat)
+ }
+ if err != nil {
+ return
+ }
+ }
+ }
+}
+
+func (c *LongConnClient) heartbeatPayload() (gatewayPayload, error) {
+ c.mu.Lock()
+ seq := c.seq
+ c.mu.Unlock()
+ data, err := json.Marshal(seq)
+ if err != nil {
+ return gatewayPayload{}, err
+ }
+ return gatewayPayload{Op: opHeartbeat, D: data}, nil
+}
+
+func (c *LongConnClient) isClosed() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.closed
+}
+
+func reconnectDelay(attempt int) time.Duration {
+ if attempt <= 0 {
+ return time.Second
+ }
+ delay := time.Duration(attempt) * time.Second
+ if delay > 30*time.Second {
+ return 30 * time.Second
+ }
+ return delay
+}
diff --git a/internal/im/qqbot/types.go b/internal/im/qqbot/types.go
new file mode 100644
index 00000000..0697ecfc
--- /dev/null
+++ b/internal/im/qqbot/types.go
@@ -0,0 +1,83 @@
+package qqbot
+
+import "encoding/json"
+
+const (
+ defaultAPIBaseURL = "https://api.sgroup.qq.com"
+ appTokenURL = "https://bots.qq.com/app/getAppAccessToken"
+ defaultGatewayURL = "https://api.sgroup.qq.com/gateway"
+
+ opDispatch = 0
+ opHeartbeat = 1
+ opIdentify = 2
+ opReconnect = 7
+ opInvalidSession = 9
+ opHello = 10
+ opHeartbeatACK = 11
+ intentGroupAndC2C = 1 << 25
+
+ eventC2CMessageCreate = "C2C_MESSAGE_CREATE"
+ eventGroupAtMessageCreate = "GROUP_AT_MESSAGE_CREATE"
+
+ extraKeyMessageID = "message_id"
+ extraKeyChatKind = "chat_kind"
+)
+
+type gatewayPayload struct {
+ ID string `json:"id,omitempty"`
+ Op int `json:"op"`
+ D json.RawMessage `json:"d,omitempty"`
+ S *int64 `json:"s,omitempty"`
+ T string `json:"t,omitempty"`
+}
+
+type helloData struct {
+ HeartbeatInterval int `json:"heartbeat_interval"`
+}
+
+type identifyData struct {
+ Token string `json:"token"`
+ Intents int `json:"intents"`
+ Shard []int `json:"shard,omitempty"`
+}
+
+type tokenResponse struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn json.RawMessage `json:"expires_in"`
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+type gatewayResponse struct {
+ URL string `json:"url"`
+}
+
+type messageEvent struct {
+ ID string `json:"id"`
+ Content string `json:"content"`
+ GroupOpenID string `json:"group_openid"`
+ Author qqbotAuthor `json:"author"`
+ Attachments []qqAttachment `json:"attachments"`
+}
+
+type qqbotAuthor struct {
+ UserOpenID string `json:"user_openid"`
+ MemberOpenID string `json:"member_openid"`
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Bot bool `json:"bot"`
+}
+
+type qqAttachment struct {
+ ContentType string `json:"content_type"`
+ Filename string `json:"filename"`
+ Size int64 `json:"size"`
+ URL string `json:"url"`
+}
+
+type sendMessageRequest struct {
+ Content string `json:"content"`
+ MsgType int `json:"msg_type"`
+ MsgID string `json:"msg_id,omitempty"`
+ MsgSeq int `json:"msg_seq,omitempty"`
+}
diff --git a/internal/im/types.go b/internal/im/types.go
index 092a38d9..236772dd 100644
--- a/internal/im/types.go
+++ b/internal/im/types.go
@@ -140,6 +140,10 @@ func (ch *IMChannel) computeBotIdentity() string {
if botID := str("ilink_bot_id"); botID != "" {
return "wechat:" + botID
}
+ case "qqbot":
+ if appID := str("app_id"); appID != "" {
+ return "qqbot:" + appID
+ }
}
return ""
}