mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4df7e1258 |
@@ -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",
|
||||
|
||||
@@ -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": "频道已移除",
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
dts: true,
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
sourcemap: true,
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user