@zhin.js/adapter-qq 1.0.84 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +940 -17
- package/PERMITS.md +25 -0
- package/README.md +135 -233
- package/adapters/qq/index.js +50 -0
- package/adapters/qq/index.ts +71 -0
- package/agents/qq/agent.json +20 -0
- package/agents/qq/boundaries.md +3 -0
- package/agents/qq/conventions.md +3 -0
- package/agents/qq/skills/qq/SKILL.md +22 -0
- package/agents/qq/skills/qq/tools/add_role/index.js +21 -0
- package/agents/qq/skills/qq/tools/add_role/index.ts +21 -0
- package/agents/qq/skills/qq/tools/channel_info/index.js +17 -0
- package/agents/qq/skills/qq/tools/channel_info/index.ts +17 -0
- package/agents/qq/skills/qq/tools/create_role/index.js +20 -0
- package/agents/qq/skills/qq/tools/create_role/index.ts +20 -0
- package/agents/qq/skills/qq/tools/list_channels/index.js +18 -0
- package/agents/qq/skills/qq/tools/list_channels/index.ts +18 -0
- package/agents/qq/skills/qq/tools/list_guilds/index.js +14 -0
- package/agents/qq/skills/qq/tools/list_guilds/index.ts +14 -0
- package/agents/qq/skills/qq/tools/list_roles/index.js +18 -0
- package/agents/qq/skills/qq/tools/list_roles/index.ts +18 -0
- package/agents/qq/skills/qq/tools/member_detail/index.js +18 -0
- package/agents/qq/skills/qq/tools/member_detail/index.ts +18 -0
- package/agents/qq/skills/qq/tools/remove_role/index.js +21 -0
- package/agents/qq/skills/qq/tools/remove_role/index.ts +21 -0
- package/agents/qq/system.md +3 -0
- package/commands/qq/endpoint/add/[[id]]/index.js +3 -0
- package/commands/qq/endpoint/add/[[id]]/index.ts +3 -0
- package/commands/qq/endpoint/cancel/index.js +11 -0
- package/commands/qq/endpoint/cancel/index.ts +15 -0
- package/commands/qq/endpoint/definition.js +188 -0
- package/commands/qq/endpoint/definition.ts +263 -0
- package/commands/qq/endpoint/list/index.js +3 -0
- package/commands/qq/endpoint/list/index.ts +3 -0
- package/commands/qq/endpoint/remove/[id]/index.js +3 -0
- package/commands/qq/endpoint/remove/[id]/index.ts +3 -0
- package/lib/client.d.ts +12 -0
- package/lib/client.js +2 -0
- package/lib/endpoint.d.ts +56 -0
- package/lib/endpoint.js +456 -0
- package/lib/index.d.ts +8 -18
- package/lib/index.js +8 -338
- package/lib/outbound.d.ts +17 -0
- package/lib/outbound.js +234 -0
- package/lib/platform-permit.d.ts +15 -0
- package/lib/platform-permit.js +41 -0
- package/lib/protocol.d.ts +90 -0
- package/lib/protocol.js +161 -0
- package/lib/qq-bind-api.d.ts +36 -0
- package/lib/qq-bind-api.js +89 -0
- package/lib/qq-bind-flow.d.ts +19 -0
- package/lib/qq-bind-flow.js +85 -0
- package/lib/qq-bot-kind-prompt.d.ts +9 -0
- package/lib/qq-bot-kind-prompt.js +37 -0
- package/lib/qq-intents.d.ts +27 -0
- package/lib/qq-intents.js +51 -0
- package/lib/qq-runtime-state.d.ts +30 -0
- package/lib/qq-runtime-state.js +14 -0
- package/lib/side-event-dispatch.d.ts +10 -0
- package/lib/side-event-dispatch.js +114 -0
- package/lib/webhook.d.ts +18 -0
- package/lib/webhook.js +103 -0
- package/lib/ws.d.ts +55 -0
- package/lib/ws.js +234 -0
- package/middlewares/bot-kind-reply/index.js +47 -0
- package/middlewares/bot-kind-reply/index.ts +65 -0
- package/package.json +82 -29
- package/plugin.js +18 -0
- package/schema.json +158 -0
- package/src/client.ts +16 -0
- package/src/endpoint.ts +534 -0
- package/src/index.ts +66 -326
- package/src/outbound.ts +255 -0
- package/src/platform-permit.ts +56 -0
- package/src/protocol.ts +249 -0
- package/src/qq-bind-api.ts +145 -0
- package/src/qq-bind-flow.ts +127 -0
- package/src/qq-bot-kind-prompt.ts +47 -0
- package/src/qq-intents.ts +77 -0
- package/src/qq-runtime-state.ts +51 -0
- package/src/side-event-dispatch.ts +140 -0
- package/src/webhook.ts +132 -0
- package/src/ws.ts +331 -0
- package/client/Dashboard.tsx +0 -230
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -7
- package/client/utils/api.ts +0 -17
- package/dist/index.js +0 -30
- package/lib/adapter.d.ts +0 -17
- package/lib/adapter.d.ts.map +0 -1
- package/lib/adapter.js +0 -49
- package/lib/adapter.js.map +0 -1
- package/lib/bot.d.ts +0 -102
- package/lib/bot.d.ts.map +0 -1
- package/lib/bot.js +0 -337
- package/lib/bot.js.map +0 -1
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types.d.ts +0 -16
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -2
- package/lib/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/skills/qq/SKILL.md +0 -97
- package/src/adapter.ts +0 -58
- package/src/bot.ts +0 -347
- package/src/types.ts +0 -24
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { receiveOneBotLikeSideEvent } from '@zhin.js/core';
|
|
2
|
+
import { formatCompact } from '@zhin.js/logger';
|
|
3
|
+
export function bindQqBotSideEvents(bot, dispatch) {
|
|
4
|
+
// qq-official-bot emits every dotted prefix. Listening at `notice` once avoids
|
|
5
|
+
// dispatching e.g. notice.group.member.increase three times.
|
|
6
|
+
bot.on('notice', (raw) => dispatch(resolveQqSideEventName(raw), raw));
|
|
7
|
+
}
|
|
8
|
+
function resolveQqSideEventName(raw) {
|
|
9
|
+
if (!raw || typeof raw !== 'object')
|
|
10
|
+
return 'notice';
|
|
11
|
+
const record = raw;
|
|
12
|
+
return ['notice', record.notice_type, record.sub_type]
|
|
13
|
+
.filter((part) => part != null && String(part) !== '')
|
|
14
|
+
.map(String)
|
|
15
|
+
.join('.');
|
|
16
|
+
}
|
|
17
|
+
function toOneBotLikeRaw(eventName, raw) {
|
|
18
|
+
if (!raw || typeof raw !== 'object')
|
|
19
|
+
return null;
|
|
20
|
+
const record = raw;
|
|
21
|
+
const asId = (value) => {
|
|
22
|
+
if (typeof value === 'string' || typeof value === 'number')
|
|
23
|
+
return value;
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
|
26
|
+
const asTime = (value) => {
|
|
27
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
28
|
+
if (Number.isFinite(n) && n > 0)
|
|
29
|
+
return n;
|
|
30
|
+
const date = typeof value === 'string' ? Date.parse(value) : Number.NaN;
|
|
31
|
+
return Number.isFinite(date) ? date : Date.now();
|
|
32
|
+
};
|
|
33
|
+
if (eventName === 'notice.group.join_request') {
|
|
34
|
+
return {
|
|
35
|
+
...record,
|
|
36
|
+
post_type: 'request',
|
|
37
|
+
request_type: 'group',
|
|
38
|
+
flag: record.join_request_id != null ? String(record.join_request_id) : undefined,
|
|
39
|
+
user_id: asId(record.user_id),
|
|
40
|
+
group_id: asId(record.group_id),
|
|
41
|
+
comment: formatJoinRequestComment(record.verify_info),
|
|
42
|
+
time: asTime(record.time ?? record.timestamp ?? record.apply_at),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (eventName.startsWith('notice.')) {
|
|
46
|
+
const memberChange = eventName.match(/^notice\.group(?:\.member)?\.(increase|decrease)$/);
|
|
47
|
+
const memberUserId = asId(record.user_id)
|
|
48
|
+
?? asId(record.bot?.self_id);
|
|
49
|
+
return {
|
|
50
|
+
...record,
|
|
51
|
+
post_type: 'notice',
|
|
52
|
+
notice_type: memberChange ? `group_${memberChange[1]}` : eventName.replace(/^notice\./, ''),
|
|
53
|
+
...(memberChange && memberUserId != null ? { user_id: memberUserId } : {}),
|
|
54
|
+
time: asTime(record.time ?? record.timestamp),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function formatJoinRequestComment(value) {
|
|
60
|
+
if (typeof value === 'string')
|
|
61
|
+
return value;
|
|
62
|
+
if (!value || typeof value !== 'object')
|
|
63
|
+
return undefined;
|
|
64
|
+
const verify = value;
|
|
65
|
+
if (typeof verify.verify_message === 'string')
|
|
66
|
+
return verify.verify_message;
|
|
67
|
+
if (!Array.isArray(verify.review_qa_list))
|
|
68
|
+
return undefined;
|
|
69
|
+
const lines = verify.review_qa_list.flatMap((item) => {
|
|
70
|
+
const question = typeof item?.question === 'string' ? item.question : '';
|
|
71
|
+
const answer = typeof item?.answer === 'string' ? item.answer : '';
|
|
72
|
+
return question || answer ? [`${question}${question && answer ? ': ' : ''}${answer}`] : [];
|
|
73
|
+
});
|
|
74
|
+
return lines.length > 0 ? lines.join('\n') : undefined;
|
|
75
|
+
}
|
|
76
|
+
export function receiveQqSideEvent(emit, endpointKey, caller, eventName, raw, logger) {
|
|
77
|
+
if (!emit)
|
|
78
|
+
return;
|
|
79
|
+
const mapped = toOneBotLikeRaw(eventName, raw);
|
|
80
|
+
if (!mapped)
|
|
81
|
+
return;
|
|
82
|
+
const isJoinRequest = eventName === 'notice.group.join_request';
|
|
83
|
+
const joinRaw = isJoinRequest && raw && typeof raw === 'object'
|
|
84
|
+
? raw
|
|
85
|
+
: undefined;
|
|
86
|
+
void receiveOneBotLikeSideEvent(emit, {
|
|
87
|
+
adapter: 'qq',
|
|
88
|
+
endpointKey,
|
|
89
|
+
platform: 'qq',
|
|
90
|
+
raw: mapped,
|
|
91
|
+
...(isJoinRequest && caller.approveGroupJoinRequest && joinRaw ? {
|
|
92
|
+
approve: async (flag, remark) => {
|
|
93
|
+
await caller.approveGroupJoinRequest(String(joinRaw.group_id ?? ''), String(joinRaw.user_id ?? ''), {
|
|
94
|
+
op: 'approve',
|
|
95
|
+
join_request_id: String(joinRaw.join_request_id ?? flag),
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
reject: async (flag, reason) => {
|
|
99
|
+
await caller.approveGroupJoinRequest(String(joinRaw.group_id ?? ''), String(joinRaw.user_id ?? ''), {
|
|
100
|
+
op: 'decline',
|
|
101
|
+
join_request_id: String(joinRaw.join_request_id ?? flag),
|
|
102
|
+
...(reason ? { reject_reason: reason } : {}),
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
} : {}),
|
|
106
|
+
}).catch((err) => {
|
|
107
|
+
logger.warn(formatCompact({
|
|
108
|
+
op: 'qq_side_event_failed',
|
|
109
|
+
endpoint: endpointKey,
|
|
110
|
+
event: eventName,
|
|
111
|
+
error: err instanceof Error ? err.message : String(err),
|
|
112
|
+
}));
|
|
113
|
+
});
|
|
114
|
+
}
|
package/lib/webhook.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
3
|
+
import type { ResolvedQqHttpConfig } from './protocol.js';
|
|
4
|
+
import type { QqBotTransport } from './ws.js';
|
|
5
|
+
export type QqHttpBotTransport = QqBotTransport & {
|
|
6
|
+
middleware: (ctx: unknown, next: () => Promise<void>) => Promise<unknown>;
|
|
7
|
+
};
|
|
8
|
+
export type CreateQqHttpBot = (config: ResolvedQqHttpConfig) => QqHttpBotTransport;
|
|
9
|
+
export interface QqWebhookHandler {
|
|
10
|
+
readonly config: ResolvedQqHttpConfig;
|
|
11
|
+
getBot(): (QqHttpBotTransport & {
|
|
12
|
+
middleware?: QqHttpBotTransport['middleware'];
|
|
13
|
+
}) | null;
|
|
14
|
+
}
|
|
15
|
+
export declare function registerQqWebhookRoutes(http: HttpHost, handler: QqWebhookHandler): HttpRouteRegistration[];
|
|
16
|
+
export declare function handleQqWebhookRequest(request: IncomingMessage, response: ServerResponse, handler: QqWebhookHandler): Promise<void>;
|
|
17
|
+
export declare function readRequestBodyText(request: IncomingMessage): Promise<string>;
|
|
18
|
+
export declare function defaultCreateHttpBot(config: ResolvedQqHttpConfig): QqHttpBotTransport;
|
package/lib/webhook.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ HTTP webhook / middleware inbound via httpHostToken POST.
|
|
3
|
+
*/
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { Bot, ReceiverMode } from 'qq-official-bot';
|
|
6
|
+
import { getLogger } from '@zhin.js/logger';
|
|
7
|
+
const logger = getLogger('qq');
|
|
8
|
+
export function registerQqWebhookRoutes(http, handler) {
|
|
9
|
+
const webhookPath = handler.config.webhookPath;
|
|
10
|
+
return [
|
|
11
|
+
http.route('POST', webhookPath, async (request, response) => {
|
|
12
|
+
await handleQqWebhookRequest(request, response, handler);
|
|
13
|
+
}, { summary: 'QQ webhook callback', tags: ['qq'] }),
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
export async function handleQqWebhookRequest(request, response, handler) {
|
|
17
|
+
const bot = handler.getBot();
|
|
18
|
+
if (!bot?.middleware) {
|
|
19
|
+
response.writeHead(503, { 'Content-Type': 'text/plain' });
|
|
20
|
+
response.end('QQ receiver not ready');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
// 不预消费请求流:库的验签必须基于原始字节(JSON.parse → JSON.stringify
|
|
25
|
+
// 会改变空白/转义/键序导致 401),body 留 undefined 让库自己读流;
|
|
26
|
+
// 预消费还会让非 JSON body 时库监听已结束的 end 事件而挂死。
|
|
27
|
+
await bot.middleware({
|
|
28
|
+
req: request,
|
|
29
|
+
res: response,
|
|
30
|
+
request: { body: undefined },
|
|
31
|
+
}, async () => undefined);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
logger.error('QQ webhook error:', error);
|
|
35
|
+
if (!response.headersSent) {
|
|
36
|
+
response.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
37
|
+
response.end('Internal Server Error');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function readRequestBodyText(request) {
|
|
42
|
+
const chunks = [];
|
|
43
|
+
let size = 0;
|
|
44
|
+
for await (const chunk of request) {
|
|
45
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
46
|
+
size += buffer.length;
|
|
47
|
+
if (size > 1_048_576) {
|
|
48
|
+
request.destroy();
|
|
49
|
+
throw new Error('Request body exceeds 1MB');
|
|
50
|
+
}
|
|
51
|
+
chunks.push(buffer);
|
|
52
|
+
}
|
|
53
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
54
|
+
}
|
|
55
|
+
export function defaultCreateHttpBot(config) {
|
|
56
|
+
const bot = new Bot({
|
|
57
|
+
appid: config.appid,
|
|
58
|
+
secret: config.secret,
|
|
59
|
+
mode: ReceiverMode.MIDDLEWARE,
|
|
60
|
+
platform: 'koa',
|
|
61
|
+
sandbox: config.sandbox,
|
|
62
|
+
dataDir: path.join(process.cwd(), 'data', 'qq'),
|
|
63
|
+
// 关闭内置 log4js,与 WebSocket 模式一致走 zhin adapter logger
|
|
64
|
+
logLevel: 'off',
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
api: bot,
|
|
68
|
+
start: () => bot.start().then(() => undefined),
|
|
69
|
+
stop: () => bot.stop().then(() => undefined),
|
|
70
|
+
on: (event, listener) => {
|
|
71
|
+
bot.on(event, listener);
|
|
72
|
+
},
|
|
73
|
+
removeAllListeners: () => {
|
|
74
|
+
bot.removeAllListeners(undefined);
|
|
75
|
+
},
|
|
76
|
+
sendPrivateMessage: (userId, message) => bot.sendPrivateMessage(userId, message),
|
|
77
|
+
sendGroupMessage: (groupId, message) => bot.sendGroupMessage(groupId, message),
|
|
78
|
+
sendGuildMessage: (channelId, message) => bot.sendGuildMessage(channelId, message),
|
|
79
|
+
sendDirectMessage: (guildId, message) => bot.sendDirectMessage(guildId, message),
|
|
80
|
+
recallPrivateMessage: (userId, messageId) => bot.recallPrivateMessage(userId, messageId),
|
|
81
|
+
recallGroupMessage: (groupId, messageId) => bot.recallGroupMessage(groupId, messageId),
|
|
82
|
+
recallGuildMessage: (channelId, messageId) => bot.recallGuildMessage(channelId, messageId),
|
|
83
|
+
recallDirectMessage: (guildId, messageId) => bot.recallDirectMessage(guildId, messageId),
|
|
84
|
+
getGuilds: () => bot.guildService.getList(),
|
|
85
|
+
getChannels: (guildId) => bot.channelService.getList(guildId),
|
|
86
|
+
getChannelInfo: (channelId) => bot.channelService.getInfo(channelId),
|
|
87
|
+
getGuildMember: (guildId, userId) => bot.memberService.getGuildMemberInfo(guildId, userId),
|
|
88
|
+
getGuildRoles: (guildId) => bot.guildService.getRoles(guildId),
|
|
89
|
+
createGuildRole: (guildId, name, color) => bot.guildService.createRole(guildId, { name, color: color || 0, hoist: 0 }),
|
|
90
|
+
addMemberRole: async (guildId, channelId, userId, roleId) => {
|
|
91
|
+
await bot.memberService.addMemberRole(guildId, channelId, userId, roleId);
|
|
92
|
+
return true;
|
|
93
|
+
},
|
|
94
|
+
removeMemberRole: async (guildId, channelId, userId, roleId) => {
|
|
95
|
+
await bot.memberService.removeMemberRole(guildId, channelId, userId, roleId);
|
|
96
|
+
return true;
|
|
97
|
+
},
|
|
98
|
+
approveGroupJoinRequest: (groupId, userId, options) => bot
|
|
99
|
+
.approveGroupJoinRequest?.(groupId, userId, options)
|
|
100
|
+
?? Promise.reject(new Error('approveGroupJoinRequest is not available')),
|
|
101
|
+
middleware: (ctx, next) => bot.middleware(ctx, next),
|
|
102
|
+
};
|
|
103
|
+
}
|
package/lib/ws.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Bot, type ApproveJoinRequestOptions } from 'qq-official-bot';
|
|
2
|
+
import type { QqOutboundMessage } from './outbound.js';
|
|
3
|
+
import type { QqInboundMessage, ResolvedQqWebsocketConfig } from './protocol.js';
|
|
4
|
+
/** SDK business surface exposed to plugins without bypassing Endpoint lifecycle ownership. */
|
|
5
|
+
export type QqOfficialApi = Pick<Bot, 'messageService' | 'fileProcessor' | 'guildService' | 'channelService' | 'memberService' | 'permissionService' | 'reactionService' | 'scheduleService' | 'threadService' | 'audioService' | 'botService' | 'groupService' | 'menuPanelService' | 'group' | 'user' | 'channel' | 'direct' | 'guild'>;
|
|
6
|
+
/** Minimal bot surface used by the endpoint (real qq-official-bot or test mock). */
|
|
7
|
+
export interface QqBotTransport {
|
|
8
|
+
/** Full qq-official-bot business API; lifecycle remains owned by the Endpoint. */
|
|
9
|
+
readonly api: QqOfficialApi;
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
stop(): Promise<void>;
|
|
12
|
+
on(event: string, listener: (...args: unknown[]) => void): void;
|
|
13
|
+
removeAllListeners(): void;
|
|
14
|
+
sendPrivateMessage(userId: string, message: QqOutboundMessage): Promise<unknown>;
|
|
15
|
+
sendGroupMessage(groupId: string, message: QqOutboundMessage): Promise<unknown>;
|
|
16
|
+
sendGuildMessage(channelId: string, message: QqOutboundMessage): Promise<unknown>;
|
|
17
|
+
sendDirectMessage?(guildId: string, message: QqOutboundMessage): Promise<unknown>;
|
|
18
|
+
recallPrivateMessage?(userId: string, messageId: string): Promise<unknown>;
|
|
19
|
+
recallGroupMessage?(groupId: string, messageId: string): Promise<unknown>;
|
|
20
|
+
recallGuildMessage?(channelId: string, messageId: string): Promise<unknown>;
|
|
21
|
+
recallDirectMessage?(guildId: string, messageId: string): Promise<unknown>;
|
|
22
|
+
getGuilds(): Promise<unknown[]>;
|
|
23
|
+
getChannels(guildId: string): Promise<unknown[]>;
|
|
24
|
+
getChannelInfo(channelId: string): Promise<unknown>;
|
|
25
|
+
getGuildMember(guildId: string, userId: string): Promise<unknown>;
|
|
26
|
+
getGuildRoles(guildId: string): Promise<unknown[]>;
|
|
27
|
+
createGuildRole(guildId: string, name: string, color?: number): Promise<unknown>;
|
|
28
|
+
addMemberRole(guildId: string, channelId: string, userId: string, roleId: string): Promise<boolean>;
|
|
29
|
+
removeMemberRole(guildId: string, channelId: string, userId: string, roleId: string): Promise<boolean>;
|
|
30
|
+
approveGroupJoinRequest?(groupId: string, userId: string, options: ApproveJoinRequestOptions): Promise<unknown>;
|
|
31
|
+
}
|
|
32
|
+
export type CreateQqBot = (config: ResolvedQqWebsocketConfig) => QqBotTransport;
|
|
33
|
+
interface QqOfficialBotLifecycle {
|
|
34
|
+
stop(): Promise<unknown>;
|
|
35
|
+
readonly sessionManager: {
|
|
36
|
+
userClose: boolean;
|
|
37
|
+
readonly connectionManager?: {
|
|
38
|
+
destroy(): void;
|
|
39
|
+
};
|
|
40
|
+
readonly authManager?: {
|
|
41
|
+
destroy(): void;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* qq-official-bot 1.2 used a separate connection manager; 1.3 moved WebSocket
|
|
47
|
+
* ownership into the receiver, but Session.stop() still leaves the auth refresh
|
|
48
|
+
* manager alive. Mark the session closed, clean up legacy connections when
|
|
49
|
+
* present, stop the receiver, then destroy auth refresh at the SDK boundary.
|
|
50
|
+
*/
|
|
51
|
+
export declare function stopQqOfficialBot(bot: QqOfficialBotLifecycle): Promise<void>;
|
|
52
|
+
export declare function bindQqBotInboundEvents(bot: QqBotTransport, admitRaw: (raw: unknown) => void): void;
|
|
53
|
+
export declare function normalizeQqMessage(raw: unknown): QqInboundMessage | null;
|
|
54
|
+
export declare function defaultCreateBot(config: ResolvedQqWebsocketConfig): QqBotTransport;
|
|
55
|
+
export {};
|
package/lib/ws.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ WebSocket transport: bot factory and inbound message normalization.
|
|
3
|
+
*/
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { Bot, ReceiverMode, } from 'qq-official-bot';
|
|
6
|
+
/**
|
|
7
|
+
* qq-official-bot 1.2 used a separate connection manager; 1.3 moved WebSocket
|
|
8
|
+
* ownership into the receiver, but Session.stop() still leaves the auth refresh
|
|
9
|
+
* manager alive. Mark the session closed, clean up legacy connections when
|
|
10
|
+
* present, stop the receiver, then destroy auth refresh at the SDK boundary.
|
|
11
|
+
*/
|
|
12
|
+
export async function stopQqOfficialBot(bot) {
|
|
13
|
+
const session = bot.sessionManager;
|
|
14
|
+
session.userClose = true;
|
|
15
|
+
session.connectionManager?.destroy();
|
|
16
|
+
try {
|
|
17
|
+
await bot.stop();
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
session.authManager?.destroy();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function bindQqBotInboundEvents(bot, admitRaw) {
|
|
24
|
+
bot.on('message.group', admitRaw);
|
|
25
|
+
bot.on('message.guild', admitRaw);
|
|
26
|
+
bot.on('message.private', admitRaw);
|
|
27
|
+
}
|
|
28
|
+
export function normalizeQqMessage(raw) {
|
|
29
|
+
if (!raw || typeof raw !== 'object')
|
|
30
|
+
return null;
|
|
31
|
+
const msg = raw;
|
|
32
|
+
if (msg.message_id == null)
|
|
33
|
+
return null;
|
|
34
|
+
let channelKind = 'private';
|
|
35
|
+
let channelId = '';
|
|
36
|
+
const isGroup = msg.message_type === 'group'
|
|
37
|
+
|| (msg.message_type == null && (msg.group_id != null || msg.group_openid != null));
|
|
38
|
+
const isGuild = msg.message_type === 'guild'
|
|
39
|
+
|| (msg.message_type == null && !isGroup && msg.guild_id != null && msg.channel_id != null);
|
|
40
|
+
if (isGuild) {
|
|
41
|
+
channelKind = msg.sub_type === 'direct' ? 'direct' : 'channel';
|
|
42
|
+
channelId = channelKind === 'direct'
|
|
43
|
+
? String(msg.guild_id ?? '')
|
|
44
|
+
: String(msg.channel_id ?? '');
|
|
45
|
+
}
|
|
46
|
+
else if (isGroup) {
|
|
47
|
+
channelKind = 'group';
|
|
48
|
+
channelId = String(msg.group_id ?? msg.group_openid ?? '');
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
channelKind = 'private';
|
|
52
|
+
channelId = String(msg.user_id ?? msg.author?.user_openid ?? msg.author?.id ?? '');
|
|
53
|
+
}
|
|
54
|
+
if (!channelId)
|
|
55
|
+
return null;
|
|
56
|
+
const authorId = String(msg.sender?.user_id
|
|
57
|
+
?? msg.user_id
|
|
58
|
+
?? msg.author?.member_openid
|
|
59
|
+
?? msg.author?.user_openid
|
|
60
|
+
?? msg.author?.id
|
|
61
|
+
?? '');
|
|
62
|
+
const content = extractTextContent(msg.message) || msg.raw_message || '';
|
|
63
|
+
const segments = extractCanonicalSegments(msg.message);
|
|
64
|
+
const rawRoles = msg.member?.roles ?? msg.sender?.roles;
|
|
65
|
+
const authorRoles = Array.isArray(rawRoles) ? rawRoles.map(String) : undefined;
|
|
66
|
+
// 统一按 mentions 判定 @:is_you 精确标识当前机器人(群载荷实测字段)。
|
|
67
|
+
// 群里 @ 另一个机器人时 mentions 带 bot:true 但无 is_you,不能误判为 @ 当前机器人,
|
|
68
|
+
// 故群场景只认 is_you;bot 回退仅限频道 AT_MESSAGE_CREATE(该事件仅 @ 当前
|
|
69
|
+
// 机器人时下发,其 mentions 无 is_you)。
|
|
70
|
+
const mentioned = Array.isArray(msg.mentions)
|
|
71
|
+
&& msg.mentions.some((m) => m?.is_you === true || (m?.bot === true && channelKind !== 'group'));
|
|
72
|
+
return {
|
|
73
|
+
id: String(msg.message_id),
|
|
74
|
+
content,
|
|
75
|
+
channelKind,
|
|
76
|
+
channelId,
|
|
77
|
+
authorId,
|
|
78
|
+
authorName: msg.sender?.user_name || msg.author?.username || authorId,
|
|
79
|
+
authorRoles,
|
|
80
|
+
timestamp: Date.now(),
|
|
81
|
+
guildId: msg.guild_id != null ? String(msg.guild_id) : undefined,
|
|
82
|
+
rawMessage: msg.raw_message,
|
|
83
|
+
...(segments.length > 0 ? { segments } : {}),
|
|
84
|
+
...(mentioned ? { mentioned: true } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* SDK 解析后的消息段(text/at/face/image/video/audio/file/reply)→ canonical Segment[]。
|
|
89
|
+
* 附件段的 url 已由 SDK 归一(补 https:// 前缀);mime 无法从载荷得知时按类型给默认。
|
|
90
|
+
*/
|
|
91
|
+
function extractCanonicalSegments(message) {
|
|
92
|
+
if (!Array.isArray(message))
|
|
93
|
+
return [];
|
|
94
|
+
const out = [];
|
|
95
|
+
for (const seg of message) {
|
|
96
|
+
if (!seg || typeof seg !== 'object')
|
|
97
|
+
continue;
|
|
98
|
+
const item = seg;
|
|
99
|
+
const type = item.type ?? '';
|
|
100
|
+
const data = item.data ?? {};
|
|
101
|
+
switch (type) {
|
|
102
|
+
case 'text': {
|
|
103
|
+
const text = String(data.text ?? '');
|
|
104
|
+
if (text)
|
|
105
|
+
out.push({ type: 'text', data: { text } });
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case 'at': {
|
|
109
|
+
const target = String(data.user_id ?? data.id ?? '');
|
|
110
|
+
if (!target)
|
|
111
|
+
break;
|
|
112
|
+
const name = typeof data.username === 'string' && data.username
|
|
113
|
+
? data.username
|
|
114
|
+
: undefined;
|
|
115
|
+
out.push({ type: 'mention', data: name ? { target, name } : { target } });
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case 'face': {
|
|
119
|
+
const id = data.id;
|
|
120
|
+
if (id == null)
|
|
121
|
+
break;
|
|
122
|
+
out.push({ type: 'face', data: { id: id } });
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case 'image':
|
|
126
|
+
case 'video':
|
|
127
|
+
case 'audio':
|
|
128
|
+
case 'file': {
|
|
129
|
+
const url = typeof data.url === 'string' ? data.url : '';
|
|
130
|
+
if (!url)
|
|
131
|
+
break;
|
|
132
|
+
const media = {
|
|
133
|
+
kind: 'url',
|
|
134
|
+
value: url,
|
|
135
|
+
mime_type: defaultMimeForSegment(type),
|
|
136
|
+
...(typeof data.name === 'string' && data.name ? { file_name: data.name } : {}),
|
|
137
|
+
...(typeof data.size === 'number' ? { size: data.size } : {}),
|
|
138
|
+
};
|
|
139
|
+
if (type === 'image')
|
|
140
|
+
out.push({ type: 'image', data: { media } });
|
|
141
|
+
else if (type === 'video')
|
|
142
|
+
out.push({ type: 'video', data: { media } });
|
|
143
|
+
else if (type === 'audio')
|
|
144
|
+
out.push({ type: 'audio', data: { media } });
|
|
145
|
+
else
|
|
146
|
+
out.push({ type: 'file', data: { media, ...(media.file_name ? { name: media.file_name } : {}) } });
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case 'reply': {
|
|
150
|
+
const messageId = String(data.id ?? data.message_id ?? '');
|
|
151
|
+
if (messageId)
|
|
152
|
+
out.push({ type: 'reply', data: { message_id: messageId } });
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
function defaultMimeForSegment(type) {
|
|
162
|
+
switch (type) {
|
|
163
|
+
case 'image': return 'image/jpeg';
|
|
164
|
+
case 'video': return 'video/mp4';
|
|
165
|
+
case 'audio': return 'audio/mpeg';
|
|
166
|
+
default: return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function extractTextContent(message) {
|
|
170
|
+
if (typeof message === 'string')
|
|
171
|
+
return message;
|
|
172
|
+
if (!Array.isArray(message))
|
|
173
|
+
return '';
|
|
174
|
+
return message
|
|
175
|
+
.map((seg) => {
|
|
176
|
+
if (!seg || typeof seg !== 'object')
|
|
177
|
+
return '';
|
|
178
|
+
const item = seg;
|
|
179
|
+
if (item.type === 'text')
|
|
180
|
+
return String(item.data?.text ?? item.text ?? '');
|
|
181
|
+
return '';
|
|
182
|
+
})
|
|
183
|
+
.join('');
|
|
184
|
+
}
|
|
185
|
+
export function defaultCreateBot(config) {
|
|
186
|
+
const bot = new Bot({
|
|
187
|
+
appid: config.appid,
|
|
188
|
+
secret: config.secret,
|
|
189
|
+
mode: ReceiverMode.WEBSOCKET,
|
|
190
|
+
sandbox: config.sandbox,
|
|
191
|
+
dataDir: path.join(process.cwd(), 'data', 'qq'),
|
|
192
|
+
// 关闭 qq-official-bot 内置 log4js,入/出站由 getAdapterLogger('qq', …) 统一格式
|
|
193
|
+
logLevel: 'off',
|
|
194
|
+
...(config.intents ? { intents: config.intents } : {}),
|
|
195
|
+
...(config.accessTokenUrl ? { accessTokenUrl: config.accessTokenUrl } : {}),
|
|
196
|
+
...(config.gatewayUrl ? { gatewayUrl: config.gatewayUrl } : {}),
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
api: bot,
|
|
200
|
+
start: () => bot.start().then(() => undefined),
|
|
201
|
+
stop: () => stopQqOfficialBot(bot),
|
|
202
|
+
on: (event, listener) => {
|
|
203
|
+
bot.on(event, listener);
|
|
204
|
+
},
|
|
205
|
+
removeAllListeners: () => {
|
|
206
|
+
bot.removeAllListeners(undefined);
|
|
207
|
+
},
|
|
208
|
+
sendPrivateMessage: (userId, message) => bot.sendPrivateMessage(userId, message),
|
|
209
|
+
sendGroupMessage: (groupId, message) => bot.sendGroupMessage(groupId, message),
|
|
210
|
+
sendGuildMessage: (channelId, message) => bot.sendGuildMessage(channelId, message),
|
|
211
|
+
sendDirectMessage: (guildId, message) => bot.sendDirectMessage(guildId, message),
|
|
212
|
+
recallPrivateMessage: (userId, messageId) => bot.recallPrivateMessage(userId, messageId),
|
|
213
|
+
recallGroupMessage: (groupId, messageId) => bot.recallGroupMessage(groupId, messageId),
|
|
214
|
+
recallGuildMessage: (channelId, messageId) => bot.recallGuildMessage(channelId, messageId),
|
|
215
|
+
recallDirectMessage: (guildId, messageId) => bot.recallDirectMessage(guildId, messageId),
|
|
216
|
+
getGuilds: () => bot.guildService.getList(),
|
|
217
|
+
getChannels: (guildId) => bot.channelService.getList(guildId),
|
|
218
|
+
getChannelInfo: (channelId) => bot.channelService.getInfo(channelId),
|
|
219
|
+
getGuildMember: (guildId, userId) => bot.memberService.getGuildMemberInfo(guildId, userId),
|
|
220
|
+
getGuildRoles: (guildId) => bot.guildService.getRoles(guildId),
|
|
221
|
+
createGuildRole: (guildId, name, color) => bot.guildService.createRole(guildId, { name, color: color || 0, hoist: 0 }),
|
|
222
|
+
addMemberRole: async (guildId, channelId, userId, roleId) => {
|
|
223
|
+
await bot.memberService.addMemberRole(guildId, channelId, userId, roleId);
|
|
224
|
+
return true;
|
|
225
|
+
},
|
|
226
|
+
removeMemberRole: async (guildId, channelId, userId, roleId) => {
|
|
227
|
+
await bot.memberService.removeMemberRole(guildId, channelId, userId, roleId);
|
|
228
|
+
return true;
|
|
229
|
+
},
|
|
230
|
+
approveGroupJoinRequest: (groupId, userId, options) => bot
|
|
231
|
+
.approveGroupJoinRequest?.(groupId, userId, options)
|
|
232
|
+
?? Promise.reject(new Error('approveGroupJoinRequest is not available')),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineMiddleware } from 'zhin.js/middleware';
|
|
3
|
+
import { endpointConfigurationStoreToken } from 'zhin.js/adapter';
|
|
4
|
+
import { completeQqPendingBotKind, } from '../../commands/qq/endpoint/definition.js';
|
|
5
|
+
import { parseQqBotKindAnswer, qqCommandSessionKey, QQ_BOT_KIND_PROMPT, } from "../../lib/qq-bot-kind-prompt.js";
|
|
6
|
+
import { qqRuntimeStateToken } from "../../lib/qq-runtime-state.js";
|
|
7
|
+
/**
|
|
8
|
+
* 扫码绑定成功后,在同一会话收取 public/private(或 公域/私域)回复并写入 intents。
|
|
9
|
+
* 非待确认会话、或内容无法识别为 botKind 时放行后续中间件/命令。
|
|
10
|
+
*/
|
|
11
|
+
export default defineMiddleware({
|
|
12
|
+
target: 'inbound',
|
|
13
|
+
phase: 'before-dispatch',
|
|
14
|
+
order: -50,
|
|
15
|
+
async handle(context, next) {
|
|
16
|
+
const state = context.use(qqRuntimeStateToken);
|
|
17
|
+
const pending = state.pendingBotKind;
|
|
18
|
+
if (!pending) {
|
|
19
|
+
await next();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const sessionKey = qqCommandSessionKey(context.input);
|
|
23
|
+
if (!sessionKey || sessionKey !== pending.sessionKey) {
|
|
24
|
+
await next();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const raw = String(context.input.content ?? '').trim();
|
|
28
|
+
// 让 qq endpoint cancel 等命令继续走命令链
|
|
29
|
+
if (raw.toLowerCase().startsWith('qq endpoint')) {
|
|
30
|
+
await next();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const botKind = parseQqBotKindAnswer(raw);
|
|
34
|
+
if (!botKind) {
|
|
35
|
+
await context.input.$reply?.(`未识别「${raw}」。${QQ_BOT_KIND_PROMPT}`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const text = await completeQqPendingBotKind(pending, botKind, context.use(endpointConfigurationStoreToken));
|
|
40
|
+
state.pendingBotKind = null;
|
|
41
|
+
await context.input.$reply?.(text);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
await context.input.$reply?.(`写入配置失败:${error instanceof Error ? error.message : String(error)}`);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { defineMiddleware } from 'zhin.js/middleware';
|
|
2
|
+
import type { CommandMessage } from 'zhin.js/command';
|
|
3
|
+
import { endpointConfigurationStoreToken } from 'zhin.js/adapter';
|
|
4
|
+
import {
|
|
5
|
+
completeQqPendingBotKind,
|
|
6
|
+
} from '../../commands/qq/endpoint/definition.js';
|
|
7
|
+
import {
|
|
8
|
+
parseQqBotKindAnswer,
|
|
9
|
+
qqCommandSessionKey,
|
|
10
|
+
QQ_BOT_KIND_PROMPT,
|
|
11
|
+
} from '../../src/qq-bot-kind-prompt.js';
|
|
12
|
+
import { qqRuntimeStateToken } from '../../src/qq-runtime-state.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 扫码绑定成功后,在同一会话收取 public/private(或 公域/私域)回复并写入 intents。
|
|
16
|
+
* 非待确认会话、或内容无法识别为 botKind 时放行后续中间件/命令。
|
|
17
|
+
*/
|
|
18
|
+
export default defineMiddleware<CommandMessage>({
|
|
19
|
+
target: 'inbound',
|
|
20
|
+
phase: 'before-dispatch',
|
|
21
|
+
order: -50,
|
|
22
|
+
async handle(context, next) {
|
|
23
|
+
const state = context.use(qqRuntimeStateToken);
|
|
24
|
+
const pending = state.pendingBotKind;
|
|
25
|
+
if (!pending) {
|
|
26
|
+
await next();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sessionKey = qqCommandSessionKey(context.input);
|
|
31
|
+
if (!sessionKey || sessionKey !== pending.sessionKey) {
|
|
32
|
+
await next();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const raw = String(context.input.content ?? '').trim();
|
|
37
|
+
// 让 qq endpoint cancel 等命令继续走命令链
|
|
38
|
+
if (raw.toLowerCase().startsWith('qq endpoint')) {
|
|
39
|
+
await next();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const botKind = parseQqBotKindAnswer(raw);
|
|
44
|
+
if (!botKind) {
|
|
45
|
+
await context.input.$reply?.(
|
|
46
|
+
`未识别「${raw}」。${QQ_BOT_KIND_PROMPT}`,
|
|
47
|
+
);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const text = await completeQqPendingBotKind(
|
|
53
|
+
pending,
|
|
54
|
+
botKind,
|
|
55
|
+
context.use(endpointConfigurationStoreToken),
|
|
56
|
+
);
|
|
57
|
+
state.pendingBotKind = null;
|
|
58
|
+
await context.input.$reply?.(text);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
await context.input.$reply?.(
|
|
61
|
+
`写入配置失败:${error instanceof Error ? error.message : String(error)}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
});
|