Compare commits

...

1 Commits

Author SHA1 Message Date
ONLY-yours a4df7e1258 feat: init the qq im into lobehub 2026-03-10 20:26:43 +08:00
19 changed files with 1035 additions and 2 deletions
+2
View File
@@ -28,6 +28,8 @@
"channel.platforms": "Platforms",
"channel.publicKey": "Public Key",
"channel.publicKeyPlaceholder": "Required for interaction verification",
"channel.qq.appIdHint": "Your QQ Bot App ID from QQ Open Platform",
"channel.qq.description": "Connect this assistant to QQ for group chats and direct messages.",
"channel.removeChannel": "Remove Channel",
"channel.removeFailed": "Failed to remove channel",
"channel.removed": "Channel removed",
+2
View File
@@ -30,6 +30,8 @@
"channel.publicKey": "公钥",
"channel.publicKeyHint": "可选。用于验证来自 Discord 的交互请求。",
"channel.publicKeyPlaceholder": "用于交互验证",
"channel.qq.appIdHint": "您在 QQ 开放平台获取的机器人 App ID",
"channel.qq.description": "将助手连接到 QQ,支持群聊和私聊。",
"channel.removeChannel": "移除频道",
"channel.removeFailed": "移除频道失败",
"channel.removed": "频道已移除",
+1
View File
@@ -188,6 +188,7 @@
"@khmyznikov/pwa-install": "0.3.9",
"@langchain/community": "^0.3.59",
"@lobechat/adapter-lark": "workspace:*",
"@lobechat/adapter-qq": "workspace:*",
"@lobechat/agent-runtime": "workspace:*",
"@lobechat/builtin-agents": "workspace:*",
"@lobechat/builtin-skills": "workspace:*",
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@lobechat/adapter-qq",
"version": "0.1.0",
"description": "QQ Bot adapter for chat SDK",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"chat": "^4.14.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.2"
}
}
+426
View File
@@ -0,0 +1,426 @@
import type {
Adapter,
AdapterPostableMessage,
Author,
ChatInstance,
EmojiValue,
FetchOptions,
FetchResult,
FormattedContent,
Logger,
RawMessage,
ThreadInfo,
WebhookOptions,
} from 'chat';
import { Message, parseMarkdown } from 'chat';
import { QQApiClient } from './api';
import { signWebhookResponse } from './crypto';
import { QQFormatConverter } from './format-converter';
import type {
QQAdapterConfig,
QQRawMessage,
QQThreadId,
QQWebhookEventData,
QQWebhookPayload,
} from './types';
import { QQ_EVENT_TYPES, QQ_OP_CODES } from './types';
export class QQAdapter implements Adapter<QQThreadId, QQRawMessage> {
readonly name = 'qq';
private readonly api: QQApiClient;
private readonly clientSecret: string;
private readonly formatConverter: QQFormatConverter;
private _userName: string;
private _botUserId?: string;
private chat!: ChatInstance;
private logger!: Logger;
get userName(): string {
return this._userName;
}
get botUserId(): string | undefined {
return this._botUserId;
}
constructor(config: QQAdapterConfig & { userName?: string }) {
this.api = new QQApiClient(config.appId, config.clientSecret);
this.clientSecret = config.clientSecret;
this.formatConverter = new QQFormatConverter();
this._userName = config.userName || 'qq-bot';
}
async initialize(chat: ChatInstance): Promise<void> {
this.chat = chat;
this.logger = chat.getLogger(this.name);
this._userName = chat.getUserName();
// Validate credentials by getting access token
await this.api.getAccessToken();
// Try to fetch bot info
try {
const botInfo = await this.api.getBotInfo();
if (botInfo) {
if (botInfo.username) this._userName = botInfo.username;
if (botInfo.id) this._botUserId = botInfo.id;
}
} catch {
// Bot info not critical
}
this.logger.info('Initialized QQ adapter (botUserId=%s)', this._botUserId);
}
// ------------------------------------------------------------------
// Webhook handling
// ------------------------------------------------------------------
async handleWebhook(request: Request, options?: WebhookOptions): Promise<Response> {
const bodyText = await request.text();
let payload: QQWebhookPayload;
try {
payload = JSON.parse(bodyText);
} catch {
return new Response('Invalid JSON', { status: 400 });
}
// Handle webhook verification (op: 13)
if (payload.op === QQ_OP_CODES.VERIFY) {
const verifyData = payload.d as { event_ts: string; plain_token: string };
if (verifyData.plain_token && verifyData.event_ts) {
const signature = signWebhookResponse(
verifyData.event_ts,
verifyData.plain_token,
this.clientSecret,
);
return Response.json({
plain_token: verifyData.plain_token,
signature,
});
}
return new Response('Missing verification data', { status: 400 });
}
// Handle dispatch events (op: 0)
if (payload.op !== QQ_OP_CODES.DISPATCH) {
return Response.json({ ok: true });
}
const eventType = payload.t;
const eventData = payload.d;
// Only handle message events
if (!this.isMessageEvent(eventType)) {
return Response.json({ ok: true });
}
// Extract message content
const content = eventData.content;
if (!content?.trim()) {
return Response.json({ ok: true });
}
// Build thread ID based on event type
const threadId = this.buildThreadId(eventType, eventData);
if (!threadId) {
return Response.json({ ok: true });
}
// Create message via factory
const messageFactory = () => this.parseRawEvent(eventData, threadId, eventType!);
// Delegate to Chat SDK pipeline
this.chat.processMessage(this, threadId, messageFactory, options);
return Response.json({ ok: true });
}
private isMessageEvent(eventType?: string): boolean {
if (!eventType) return false;
return (
eventType === QQ_EVENT_TYPES.GROUP_AT_MESSAGE_CREATE ||
eventType === QQ_EVENT_TYPES.C2C_MESSAGE_CREATE ||
eventType === QQ_EVENT_TYPES.AT_MESSAGE_CREATE ||
eventType === QQ_EVENT_TYPES.DIRECT_MESSAGE_CREATE
);
}
private buildThreadId(eventType: string | undefined, data: QQWebhookEventData): string | null {
if (!eventType) return null;
switch (eventType) {
case QQ_EVENT_TYPES.GROUP_AT_MESSAGE_CREATE: {
if (!data.group_openid) return null;
return this.encodeThreadId({ id: data.group_openid, type: 'group' });
}
case QQ_EVENT_TYPES.C2C_MESSAGE_CREATE: {
if (!data.author?.id) return null;
return this.encodeThreadId({ id: data.author.id, type: 'c2c' });
}
case QQ_EVENT_TYPES.AT_MESSAGE_CREATE: {
if (!data.channel_id) return null;
return this.encodeThreadId({
guildId: data.guild_id,
id: data.channel_id,
type: 'guild',
});
}
case QQ_EVENT_TYPES.DIRECT_MESSAGE_CREATE: {
if (!data.guild_id) return null;
return this.encodeThreadId({ id: data.guild_id, type: 'dms' });
}
default: {
return null;
}
}
}
// ------------------------------------------------------------------
// Message operations
// ------------------------------------------------------------------
async postMessage(
threadId: string,
message: AdapterPostableMessage,
): Promise<RawMessage<QQRawMessage>> {
const { type, id, guildId } = this.decodeThreadId(threadId);
const text = this.formatConverter.renderPostable(message);
let response;
switch (type) {
case 'group': {
response = await this.api.sendGroupMessage(id, text);
break;
}
case 'guild': {
response = await this.api.sendGuildMessage(id, text);
break;
}
case 'c2c': {
response = await this.api.sendC2CMessage(id, text);
break;
}
case 'dms': {
response = await this.api.sendDmsMessage(guildId || id, text);
break;
}
default: {
throw new Error(`Unknown thread type: ${type}`);
}
}
return {
id: response.id,
raw: {
author: { id: this._botUserId || '' },
content: text,
id: response.id,
timestamp: response.timestamp,
} as QQRawMessage,
threadId,
};
}
async editMessage(
_threadId: string,
_messageId: string,
_message: AdapterPostableMessage,
): Promise<RawMessage<QQRawMessage>> {
// QQ doesn't support editing messages
throw new Error('QQ does not support message editing');
}
async deleteMessage(_threadId: string, _messageId: string): Promise<void> {
// TODO: Implement message recall if QQ API supports it
this.logger.warn('Message deletion not implemented for QQ');
}
async fetchMessages(
_threadId: string,
_options?: FetchOptions,
): Promise<FetchResult<QQRawMessage>> {
// QQ doesn't provide message history API for bots
return {
messages: [],
nextCursor: undefined,
};
}
async fetchThread(threadId: string): Promise<ThreadInfo> {
const { type, id } = this.decodeThreadId(threadId);
return {
channelId: threadId,
id: threadId,
isDM: type === 'c2c' || type === 'dms',
metadata: { id, type },
};
}
// ------------------------------------------------------------------
// Message parsing
// ------------------------------------------------------------------
parseMessage(raw: QQRawMessage): Message<QQRawMessage> {
const cleanText = this.formatConverter.cleanMentions(raw.content || '');
const formatted = parseMarkdown(cleanText);
let threadId: string;
if (raw.group_openid) {
threadId = this.encodeThreadId({ id: raw.group_openid, type: 'group' });
} else if (raw.channel_id) {
threadId = this.encodeThreadId({
guildId: raw.guild_id,
id: raw.channel_id,
type: 'guild',
});
} else {
threadId = this.encodeThreadId({ id: raw.author.id, type: 'c2c' });
}
return new Message({
attachments: [],
author: {
fullName: 'Unknown',
isBot: false,
isMe: false,
userId: raw.author.id,
userName: 'unknown',
},
formatted,
id: raw.id,
metadata: {
dateSent: new Date(raw.timestamp),
edited: false,
},
raw,
text: cleanText,
threadId,
});
}
private async parseRawEvent(
data: QQWebhookEventData,
threadId: string,
_eventType: string,
): Promise<Message<QQRawMessage>> {
const content = data.content || '';
const cleanText = this.formatConverter.cleanMentions(content);
const formatted = parseMarkdown(cleanText);
const authorId = data.author?.id || 'unknown';
const isBot = false; // Webhook events are from users
const author: Author = {
fullName: authorId,
isBot,
isMe: isBot && authorId === this._botUserId,
userId: authorId,
userName: authorId,
};
const raw: QQRawMessage = {
author: data.author || { id: 'unknown' },
channel_id: data.channel_id,
content,
group_openid: data.group_openid,
guild_id: data.guild_id,
id: data.id || '',
timestamp: data.timestamp || new Date().toISOString(),
};
return new Message({
attachments: [],
author,
formatted,
id: data.id || '',
metadata: {
dateSent: new Date(data.timestamp || Date.now()),
edited: false,
},
raw,
text: cleanText,
threadId,
});
}
// ------------------------------------------------------------------
// Reactions (not supported by QQ Bot API)
// ------------------------------------------------------------------
async addReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {
// QQ Bot API doesn't support reactions
}
async removeReaction(
_threadId: string,
_messageId: string,
_emoji: EmojiValue | string,
): Promise<void> {
// QQ Bot API doesn't support reactions
}
// ------------------------------------------------------------------
// Typing (not supported by QQ Bot API)
// ------------------------------------------------------------------
async startTyping(_threadId: string): Promise<void> {
// QQ has no typing indicator API for bots
}
// ------------------------------------------------------------------
// Thread ID encoding
// ------------------------------------------------------------------
encodeThreadId(data: QQThreadId): string {
if (data.guildId) {
return `qq:${data.type}:${data.id}:${data.guildId}`;
}
return `qq:${data.type}:${data.id}`;
}
decodeThreadId(threadId: string): QQThreadId {
const parts = threadId.split(':');
if (parts.length < 3 || parts[0] !== 'qq') {
// Fallback for malformed thread IDs
return { id: threadId, type: 'group' };
}
const type = parts[1] as QQThreadId['type'];
const id = parts[2];
const guildId = parts[3];
return { guildId, id, type };
}
channelIdFromThreadId(threadId: string): string {
return threadId;
}
isDM(threadId: string): boolean {
const { type } = this.decodeThreadId(threadId);
return type === 'c2c' || type === 'dms';
}
// ------------------------------------------------------------------
// Format rendering
// ------------------------------------------------------------------
renderFormatted(content: FormattedContent): string {
return this.formatConverter.fromAst(content);
}
}
/**
* Factory function to create a QQAdapter.
*/
export function createQQAdapter(config: QQAdapterConfig & { userName?: string }): QQAdapter {
return new QQAdapter(config);
}
+198
View File
@@ -0,0 +1,198 @@
import type { QQAccessTokenResponse, QQSendMessageParams, QQSendMessageResponse } from './types';
import { QQ_MSG_TYPE } from './types';
const AUTH_URL = 'https://bots.qq.com/app/getAppAccessToken';
const API_BASE_URL = 'https://api.sgroup.qq.com';
const MAX_TEXT_LENGTH = 2000;
export class QQApiClient {
private readonly appId: string;
private readonly clientSecret: string;
private cachedToken?: string;
private tokenExpiresAt = 0;
constructor(appId: string, clientSecret: string) {
this.appId = appId;
this.clientSecret = clientSecret;
}
async getAccessToken(): Promise<string> {
if (this.cachedToken && Date.now() < this.tokenExpiresAt) {
return this.cachedToken;
}
const response = await fetch(AUTH_URL, {
body: JSON.stringify({
appId: this.appId,
clientSecret: this.clientSecret,
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
const text = await response.text();
throw new Error(`QQ auth failed: ${response.status} ${text}`);
}
const data = (await response.json()) as QQAccessTokenResponse;
this.cachedToken = data.access_token;
// Refresh 5 minutes before expiration
this.tokenExpiresAt = Date.now() + (data.expires_in - 300) * 1000;
return this.cachedToken;
}
private async call<T>(method: string, path: string, body?: Record<string, unknown>): Promise<T> {
const token = await this.getAccessToken();
const url = `${API_BASE_URL}${path}`;
const init: RequestInit = {
headers: {
'Authorization': `QQBot ${token}`,
'Content-Type': 'application/json',
},
method,
};
if (body && method !== 'GET' && method !== 'DELETE') {
init.body = JSON.stringify(body);
}
const response = await fetch(url, init);
if (!response.ok) {
const text = await response.text();
throw new Error(`QQ API ${method} ${path} failed: ${response.status} ${text}`);
}
// Some endpoints return empty response
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json() as Promise<T>;
}
return {} as T;
}
/**
* Send message to a QQ group
*/
async sendGroupMessage(
groupOpenId: string,
content: string,
options?: { eventId?: string; msgId?: string; msgSeq?: number },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
if (options?.msgSeq !== undefined) {
params.msg_seq = options.msgSeq;
}
return this.call<QQSendMessageResponse>('POST', `/v2/groups/${groupOpenId}/messages`, params);
}
/**
* Send message to a QQ guild channel
*/
async sendGuildMessage(
channelId: string,
content: string,
options?: { eventId?: string; msgId?: string },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
return this.call<QQSendMessageResponse>('POST', `/channels/${channelId}/messages`, params);
}
/**
* Send direct message to a user (C2C)
*/
async sendC2CMessage(
openId: string,
content: string,
options?: { eventId?: string; msgId?: string; msgSeq?: number },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
if (options?.msgSeq !== undefined) {
params.msg_seq = options.msgSeq;
}
return this.call<QQSendMessageResponse>('POST', `/v2/users/${openId}/messages`, params);
}
/**
* Send direct message in a guild (DMS)
*/
async sendDmsMessage(
guildId: string,
content: string,
options?: { eventId?: string; msgId?: string },
): Promise<QQSendMessageResponse> {
const params: QQSendMessageParams = {
content: this.truncateText(content),
msg_type: QQ_MSG_TYPE.TEXT,
};
if (options?.msgId) {
params.msg_id = options.msgId;
}
if (options?.eventId) {
params.event_id = options.eventId;
}
return this.call<QQSendMessageResponse>('POST', `/dms/${guildId}/messages`, params);
}
/**
* Get bot information
*/
async getBotInfo(): Promise<{ avatar: string; id: string; username: string } | null> {
try {
const data = await this.call<{ avatar: string; id: string; username: string }>(
'GET',
'/users/@me',
);
return data;
} catch {
return null;
}
}
private truncateText(text: string): string {
if (text.length > MAX_TEXT_LENGTH) {
return text.slice(0, MAX_TEXT_LENGTH - 3) + '...';
}
return text;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { createPrivateKey, sign } from 'node:crypto';
/**
* Sign the webhook verification response using Ed25519.
*
* QQ Bot webhook verification requires:
* 1. Pad the clientSecret to 32 bytes as the seed
* 2. Create an Ed25519 private key from the seed
* 3. Sign the concatenated message (eventTs + plainToken)
* 4. Return the signature as a hex string
*/
export function signWebhookResponse(
eventTs: string,
plainToken: string,
clientSecret: string,
): string {
// Pad clientSecret to 32 bytes (Ed25519 seed length)
const seed = Buffer.alloc(32);
Buffer.from(clientSecret).copy(seed);
// Create Ed25519 private key from seed
// Node.js crypto expects the key in a specific format for Ed25519
// We need to construct a proper PKCS8 DER format
const privateKey = createPrivateKey({
format: 'jwk',
key: {
crv: 'Ed25519',
d: seed.toString('base64url'),
kty: 'OKP',
x: '', // Will be derived from d
},
});
// Sign the message
const message = Buffer.from(eventTs + plainToken);
const signature = sign(null, message, privateKey);
return signature.toString('hex');
}
@@ -0,0 +1,38 @@
import type { Root } from 'chat';
import { BaseFormatConverter, parseMarkdown, stringifyMarkdown } from 'chat';
export class QQFormatConverter extends BaseFormatConverter {
/**
* Convert mdast AST to QQ-compatible text.
* QQ supports basic text messages, we convert markdown to plain text for now.
*/
fromAst(ast: Root): string {
return stringifyMarkdown(ast);
}
/**
* Convert QQ message text to mdast AST.
* Clean up QQ @mention markers before parsing.
*/
toAst(text: string): Root {
// Clean QQ @mention markers (e.g., <@!user_id>, <@user_id>)
const cleaned = text
.replaceAll(/<@!?\d+>/g, '')
.replaceAll('<@everyone>', '')
.replaceAll(/<#\d+>/g, '')
.trim();
return parseMarkdown(cleaned);
}
/**
* Clean @mention markers from text
*/
cleanMentions(text: string): string {
return text
.replaceAll(/<@!?\d+>/g, '')
.replaceAll('<@everyone>', '')
.replaceAll(/<#\d+>/g, '')
.trim();
}
}
+19
View File
@@ -0,0 +1,19 @@
export { createQQAdapter, QQAdapter } from './adapter';
export { QQApiClient } from './api';
export { signWebhookResponse } from './crypto';
export { QQFormatConverter } from './format-converter';
export type {
QQAccessTokenResponse,
QQAdapterConfig,
QQAttachment,
QQAuthor,
QQMessageType,
QQRawMessage,
QQSendMessageParams,
QQSendMessageResponse,
QQThreadId,
QQWebhookEventData,
QQWebhookPayload,
QQWebhookVerifyData,
} from './types';
export { QQ_EVENT_TYPES, QQ_MSG_TYPE, QQ_OP_CODES } from './types';
+123
View File
@@ -0,0 +1,123 @@
export interface QQAdapterConfig {
appId: string;
clientSecret: string;
}
export interface QQThreadId {
/** For guild channels, the guild_id is needed for some operations */
guildId?: string;
id: string;
type: 'group' | 'guild' | 'c2c' | 'dms';
}
export interface QQAuthor {
id: string;
member_openid?: string;
union_openid?: string;
}
export interface QQAttachment {
content_type: string;
filename: string;
height?: number;
size: number;
url: string;
width?: number;
}
export interface QQMessageReference {
message_id: string;
}
export interface QQRawMessage {
attachments?: QQAttachment[];
author: QQAuthor;
channel_id?: string;
content: string;
group_openid?: string;
guild_id?: string;
id: string;
member?: {
joined_at: string;
roles?: string[];
};
mentions?: QQAuthor[];
message_reference?: QQMessageReference;
seq?: number;
seq_in_channel?: string;
timestamp: string;
}
export interface QQWebhookPayload {
d: QQWebhookEventData;
id: string;
op: number;
s?: number;
t?: string;
}
export interface QQWebhookEventData {
author?: QQAuthor;
channel_id?: string;
content?: string;
event_ts?: string;
group_openid?: string;
guild_id?: string;
id?: string;
member?: {
joined_at: string;
roles?: string[];
};
plain_token?: string;
timestamp?: string;
}
export interface QQWebhookVerifyData {
event_ts: string;
plain_token: string;
}
export interface QQAccessTokenResponse {
access_token: string;
expires_in: number;
}
export interface QQSendMessageParams {
[key: string]: unknown;
content?: string;
event_id?: string;
markdown?: {
content: string;
};
msg_id?: string;
msg_seq?: number;
msg_type: number;
}
export interface QQSendMessageResponse {
id: string;
timestamp: string;
}
export type QQMessageType = 'group' | 'guild' | 'c2c' | 'dms';
export const QQ_MSG_TYPE = {
ARK: 3,
EMBED: 4,
MARKDOWN: 2,
MEDIA: 7,
TEXT: 0,
} as const;
export const QQ_EVENT_TYPES = {
AT_MESSAGE_CREATE: 'AT_MESSAGE_CREATE',
C2C_MESSAGE_CREATE: 'C2C_MESSAGE_CREATE',
DIRECT_MESSAGE_CREATE: 'DIRECT_MESSAGE_CREATE',
GROUP_AT_MESSAGE_CREATE: 'GROUP_AT_MESSAGE_CREATE',
} as const;
export const QQ_OP_CODES = {
DISPATCH: 0,
HTTP_CALLBACK_ACK: 12,
VERIFY: 13,
} as const;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["ES2022"]
},
"exclude": ["node_modules", "dist"],
"include": ["src/**/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'tsup';
export default defineConfig({
dts: true,
entry: ['src/index.ts'],
format: ['esm'],
sourcemap: true,
});
+16 -1
View File
@@ -2,7 +2,7 @@ import { SiDiscord, SiTelegram } from '@icons-pack/react-simple-icons';
import type { LucideIcon } from 'lucide-react';
import type { FC } from 'react';
import { LarkIcon } from './icons';
import { LarkIcon, QQIcon } from './icons';
export interface ChannelProvider {
/** Lark-style auth: appId + appSecret instead of botToken */
@@ -94,4 +94,19 @@ export const CHANNEL_PROVIDERS: ChannelProvider[] = [
id: 'lark',
name: 'Lark',
},
{
authMode: 'app-secret',
color: '#12B7F5',
description: 'channel.qq.description',
docsLink: 'https://bot.q.qq.com/wiki/',
fieldTags: {
appId: 'App ID',
appSecret: 'App Secret',
webhook: 'Callback URL',
},
icon: QQIcon,
id: 'qq',
name: 'QQ',
webhookMode: 'manual',
},
];
@@ -22,6 +22,7 @@ import type { ChannelFormValues, TestResult } from './index';
import { getDiscordFormItems } from './platforms/discord';
import { getFeishuFormItems } from './platforms/feishu';
import { getLarkFormItems } from './platforms/lark';
import { getQQFormItems } from './platforms/qq';
import { getTelegramFormItems } from './platforms/telegram';
const prefixCls = 'ant';
@@ -76,6 +77,7 @@ const platformFormItemsMap: Record<
> = {
discord: getDiscordFormItems,
feishu: getFeishuFormItems,
qq: getQQFormItems,
lark: getLarkFormItems,
telegram: getTelegramFormItems,
};
@@ -0,0 +1,36 @@
import type { FormItemProps } from '@lobehub/ui';
import type { TFunction } from 'i18next';
import { FormInput, FormPassword } from '@/components/FormInput';
import type { ChannelProvider } from '../../const';
export const getQQFormItems = (
t: TFunction<'agent'>,
hasConfig: boolean,
provider: ChannelProvider,
): FormItemProps[] => [
{
children: <FormInput placeholder={t('channel.applicationIdPlaceholder')} />,
desc: t('channel.qq.appIdHint'),
label: t('channel.applicationId'),
name: 'applicationId',
rules: [{ required: true }],
tag: provider.fieldTags.appId,
},
{
children: (
<FormPassword
autoComplete="new-password"
placeholder={
hasConfig ? t('channel.botTokenPlaceholderExisting') : t('channel.appSecretPlaceholder')
}
/>
),
desc: t('channel.botTokenEncryptedHint'),
label: t('channel.appSecret'),
name: 'appSecret',
rules: [{ required: true }],
tag: provider.fieldTags.appSecret,
},
];
+25
View File
@@ -5,6 +5,31 @@ interface IconProps extends SVGProps<SVGSVGElement> {
size?: number | string;
}
/**
* QQ brand icon.
* From Simple Icons (https://simpleicons.org/?q=qq)
*/
export const QQIcon = ({
ref,
color,
size = 24,
style,
...props
}: IconProps & { ref?: React.RefObject<SVGSVGElement | null> }) => (
<svg
fill={color || 'currentColor'}
height={size}
ref={ref}
style={{ flexShrink: 0, ...style }}
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M21.395 15.035a39.548 39.548 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.06.014-.093 0-.252-.063-.49-.1-.733.137-.142.27-.288.389-.451.311-.425.474-.903.474-1.382 0-.451-.141-.896-.406-1.285-.265-.389-.658-.7-1.135-.902a7.127 7.127 0 0 0-.241-.092c.015-.116.029-.231.029-.348 0-1.466-.979-2.654-2.186-2.654-.217 0-.429.038-.633.1a4.95 4.95 0 0 0-.748-.584c-.416-.268-.947-.476-1.578-.602a9.53 9.53 0 0 0-1.966-.201c-.672 0-1.333.069-1.966.201-.631.126-1.162.334-1.578.602-.259.166-.5.363-.748.584-.204-.062-.416-.1-.633-.1-1.207 0-2.186 1.188-2.186 2.654 0 .117.014.232.029.348a7.127 7.127 0 0 0-.241.092c-.477.202-.87.513-1.135.902-.265.389-.406.834-.406 1.285 0 .479.163.957.474 1.382.119.163.252.309.389.451-.037.243-.1.481-.1.733 0 .033.013.061.014.093l-1.079 2.695a39.548 39.548 0 0 0-.803 2.264c-.123.635-.172 1.104-.172 1.463 0 .263.032.489.088.686.123.433.413.706.871.706.555 0 1.327-.393 2.227-1.061.483-.357.964-.789 1.444-1.283.015-.015.03-.03.044-.046a7.84 7.84 0 0 0 .358-.389l.026-.03.069-.079a7.18 7.18 0 0 0 .227-.273l.074-.093a5.67 5.67 0 0 0 .135-.177l.076-.103c.032-.044.063-.09.093-.135l.073-.11a4.53 4.53 0 0 0 .217-.355l.042-.073a6.056 6.056 0 0 0 .258-.519l.035-.077.099-.235c.022-.054.043-.108.063-.163l.026-.073c.3.191.624.351.962.479.438.165.908.28 1.392.34.31.038.627.058.949.058s.639-.02.949-.058c.484-.06.954-.175 1.392-.34.338-.128.662-.288.962-.479l.026.073c.02.055.041.109.063.163l.099.235.035.077c.069.163.159.344.258.519l.042.073c.069.117.141.237.217.355l.073.11c.03.045.061.091.093.135l.076.103c.043.059.088.118.135.177l.074.093a7.18 7.18 0 0 0 .227.273l.069.079.026.03c.115.134.234.264.358.389.014.016.029.031.044.046.48.494.961.926 1.444 1.283.9.668 1.672 1.061 2.227 1.061.458 0 .748-.273.871-.706.056-.197.088-.423.088-.686 0-.359-.049-.828-.172-1.463zM7.703 14.763c-.635.148-1.19.023-1.24-.278-.05-.302.424-.676 1.059-.824.635-.148 1.19-.023 1.24.278.05.302-.424.676-1.059.824zm8.594 0c-.635-.148-1.109-.522-1.059-.824.05-.301.605-.426 1.24-.278.635.148 1.109.522 1.059.824-.05.301-.605.426-1.24.278z" />
</svg>
);
/**
* Lark / Feishu brand mark icon.
* Extracted from the official Lark word-mark SVG.
+13 -1
View File
@@ -2,6 +2,7 @@ import { createDiscordAdapter } from '@chat-adapter/discord';
import { createIoRedisState } from '@chat-adapter/state-ioredis';
import { createTelegramAdapter } from '@chat-adapter/telegram';
import { createLarkAdapter } from '@lobechat/adapter-lark';
import { createQQAdapter } from '@lobechat/adapter-qq';
import { Chat, ConsoleLogger } from 'chat';
import debug from 'debug';
@@ -64,6 +65,14 @@ function createAdapterForPlatform(
}),
};
}
case 'qq': {
return {
qq: createQQAdapter({
appId: credentials.appId,
clientSecret: credentials.appSecret,
}),
};
}
default: {
return null;
}
@@ -112,6 +121,9 @@ export class BotMessageRouter {
case 'feishu': {
return this.handleChatSdkWebhook(req, platform, appId);
}
case 'qq': {
return this.handleChatSdkWebhook(req, platform, appId);
}
default: {
return new Response('No bot configured for this platform', { status: 404 });
}
@@ -351,7 +363,7 @@ export class BotMessageRouter {
const gateKeeper = await KeyVaultsGateKeeper.initWithEnvKey();
// Load all supported platforms
for (const platform of ['discord', 'telegram', 'lark', 'feishu']) {
for (const platform of ['discord', 'telegram', 'lark', 'feishu', 'qq']) {
const providers = await AgentBotProviderModel.findEnabledByPlatform(
serverDB,
platform,
@@ -1,11 +1,13 @@
import type { PlatformBotClass } from '../types';
import { Discord } from './discord';
import { Lark } from './lark';
import { QQ } from './qq';
import { Telegram } from './telegram';
export const platformBotRegistry: Record<string, PlatformBotClass> = {
discord: Discord,
feishu: Lark,
lark: Lark,
qq: QQ,
telegram: Telegram,
};
+38
View File
@@ -0,0 +1,38 @@
import debug from 'debug';
import type { PlatformBot } from '../types';
const log = debug('lobe-server:bot:gateway:qq');
export interface QQBotConfig {
[key: string]: string | undefined;
appId: string;
appSecret: string;
}
export class QQ implements PlatformBot {
readonly platform = 'qq';
readonly applicationId: string;
private config: QQBotConfig;
constructor(config: QQBotConfig) {
this.config = config;
this.applicationId = config.appId;
}
async start(): Promise<void> {
log('Starting QQBot appId=%s', this.applicationId);
// QQ webhook is configured manually in the QQ Open Platform
// No need to set webhook programmatically
log(
'QQBot appId=%s started (webhook must be configured in QQ Open Platform)',
this.applicationId,
);
}
async stop(): Promise<void> {
log('Stopping QQBot appId=%s', this.applicationId);
// No cleanup needed for QQ
}
}