feat(im): 新增QQBot即时通讯平台集成支持

完整实现QQBot的IM适配器功能,包括客户端封装、WebSocket长连接管理、消息收发与事件解析;更新后端合法平台校验列表与适配器注册配置;补充前端平台选型选项、logo资源、多语言文案以及渠道配置表单,同时添加对应的单元测试。
This commit is contained in:
YS-zdck
2026-06-26 15:35:13 +08:00
committed by lyingbug
parent 974ca359e5
commit b8ce6a699c
18 changed files with 766 additions and 8 deletions
+1 -1
View File
@@ -259,7 +259,7 @@ export interface IMChannel {
id: string;
tenant_id?: number;
agent_id: string;
platform: 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost' | 'wechat';
platform: 'wecom' | 'feishu' | 'slack' | 'telegram' | 'dingtalk' | 'mattermost' | 'wechat' | 'qqbot';
name: string;
enabled: boolean;
mode: 'webhook' | 'websocket' | 'longpoll';
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><circle cx="128" cy="128" r="120" fill="#12B7F5"/><ellipse cx="128" cy="124" fill="#fff" rx="73" ry="82"/><circle cx="101" cy="105" r="10" fill="#1F2937"/><circle cx="155" cy="105" r="10" fill="#1F2937"/><path fill="#F59E0B" d="M128 120c-14 0-30 8-30 20c0 8 12 15 30 15s30-7 30-15c0-12-16-20-30-20Z"/><path fill="#EF4444" d="M85 157c11 24 28 36 43 36s32-12 43-36c-13 10-27 15-43 15s-30-5-43-15Z"/><path fill="#1F2937" d="M71 121c-10 2-17 10-17 20c0 13 11 23 24 23c3 0 6-1 9-2c-7-12-12-26-16-41Zm114 0c-4 15-9 29-16 41c3 1 6 2 9 2c13 0 24-10 24-23c0-10-7-18-17-20Z" opacity=".12"/></svg>

After

Width:  |  Height:  |  Size: 674 B

+33 -1
View File
@@ -492,6 +492,35 @@
</div>
</template>
<!-- QQBot credentials -->
<template v-if="formData.platform === 'qqbot'">
<div class="platform-link-hint">
<a href="https://q.qq.com/" target="_blank" rel="noopener noreferrer" class="doc-link">
{{ $t('agentEditor.im.qqbotConsole') }}
<t-icon name="link" class="link-icon" />
</a>
<span class="hint-text">{{ $t('agentEditor.im.consoleTip') }}</span>
</div>
<div class="form-item">
<label class="form-label">App ID</label>
<t-input v-model="formData.credentials.app_id" placeholder="QQBot App ID" />
</div>
<div class="form-item">
<label class="form-label">App Secret</label>
<t-input v-model="formData.credentials.client_secret" type="password" placeholder="QQBot App Secret" />
</div>
<div class="form-item">
<label class="form-label">API Base URL</label>
<t-input v-model="formData.credentials.api_base_url" placeholder="https://api.sgroup.qq.com" />
<p class="form-desc">{{ $t('agentEditor.im.qqbotAPIBaseURLHint') }}</p>
</div>
<div class="form-item">
<label class="form-label">Gateway URL</label>
<t-input v-model="formData.credentials.gateway_url" placeholder="wss://api.sgroup.qq.com/websocket/" />
<p class="form-desc">{{ $t('agentEditor.im.qqbotGatewayURLHint') }}</p>
</div>
</template>
<!-- Mattermost credentials -->
<template v-if="formData.platform === 'mattermost'">
<div class="platform-link-hint">
@@ -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<string, string> = {
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<string, any> => ({});
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',
+2
View File
@@ -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<string, string> = {
wecom: wecomLogo,
@@ -268,6 +269,7 @@ const PLATFORM_LOGO: Record<string, string> = {
dingtalk: dingtalkLogo,
mattermost: mattermostLogo,
wechat: wechatLogo,
qqbot: qqbotLogo,
};
const platformLogo = (p: string): string => (p ? PLATFORM_LOGO[p] || '' : '');
+5 -1
View File
@@ -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:
+5 -1
View File
@@ -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:
+5 -1
View File
@@ -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:
+5 -1
View File
@@ -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:
+2
View File
@@ -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 {
+2 -2
View File
@@ -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
}
+1
View File
@@ -18,6 +18,7 @@ const (
PlatformDingtalk Platform = "dingtalk"
PlatformMattermost Platform = "mattermost"
PlatformWeChat Platform = "wechat"
PlatformQQBot Platform = "qqbot"
)
// SessionMode determines how IM sessions are resolved.
+127
View File
@@ -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 ""
}
+78
View File
@@ -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)
}
})
}
}
+169
View File
@@ -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
}
+47
View File
@@ -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
}
}
+196
View File
@@ -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
}
+83
View File
@@ -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"`
}
+4
View File
@@ -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 ""
}