@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,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表 +
|
|
3
|
+
* 扫码绑定 / 待选 botKind 流程。由 plugin.ts setup() provide,
|
|
4
|
+
* adapter create 与 commands / middleware 通过 use(qqRuntimeStateToken) 共享。
|
|
5
|
+
*/
|
|
6
|
+
import { createToken } from 'zhin.js';
|
|
7
|
+
|
|
8
|
+
export interface QqRunningEndpoint {
|
|
9
|
+
id: string;
|
|
10
|
+
mode: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface QqBindFlowHandle {
|
|
14
|
+
/** 绑定期望的 endpoint id(未指定时为 undefined,成功后取 appId) */
|
|
15
|
+
id?: string;
|
|
16
|
+
stop: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 扫码成功后暂存在内存:确认公域/私域后再一次性写 .env + yaml。
|
|
21
|
+
* (提前写 .env 会触发 HMR,冲掉 pending。)
|
|
22
|
+
*/
|
|
23
|
+
export interface QqPendingBotKind {
|
|
24
|
+
readonly endpointId: string;
|
|
25
|
+
readonly appId: string;
|
|
26
|
+
readonly appSecret: string;
|
|
27
|
+
/** 发起 add 的会话键;仅同会话回复可完成选择 */
|
|
28
|
+
readonly sessionKey: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface QqRuntimeState {
|
|
32
|
+
/** 当前 generation 已成功创建的 endpoint(id → 描述) */
|
|
33
|
+
readonly endpoints: Map<string, QqRunningEndpoint>;
|
|
34
|
+
/** 全局单例:同一时间只允许一个扫码绑定流程 */
|
|
35
|
+
bindFlow: QqBindFlowHandle | null;
|
|
36
|
+
/** 扫码完成后等待 botKind 选择(凭据仅在内存) */
|
|
37
|
+
pendingBotKind: QqPendingBotKind | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createQqRuntimeState(): QqRuntimeState {
|
|
41
|
+
return {
|
|
42
|
+
endpoints: new Map(),
|
|
43
|
+
bindFlow: null,
|
|
44
|
+
pendingBotKind: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const qqRuntimeStateToken = createToken<QqRuntimeState>(
|
|
49
|
+
'zhin.qq.runtime-state',
|
|
50
|
+
'QQ adapter runtime state (running endpoints + bind flow singleton)',
|
|
51
|
+
);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { receiveOneBotLikeSideEvent, type OneBotLikeRawEvent } from '@zhin.js/core';
|
|
2
|
+
import type { EndpointEventEmitter } from 'zhin.js/adapter';
|
|
3
|
+
import { formatCompact, type getAdapterLogger } from '@zhin.js/logger';
|
|
4
|
+
import type { ApproveJoinRequestOptions } from 'qq-official-bot';
|
|
5
|
+
|
|
6
|
+
export interface QqSideEventCaller {
|
|
7
|
+
approveGroupJoinRequest?(
|
|
8
|
+
groupId: string,
|
|
9
|
+
userId: string,
|
|
10
|
+
options: ApproveJoinRequestOptions,
|
|
11
|
+
): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function bindQqBotSideEvents(
|
|
15
|
+
bot: { on(event: string, listener: (...args: unknown[]) => void): void },
|
|
16
|
+
dispatch: (eventName: string, raw: unknown) => void,
|
|
17
|
+
): void {
|
|
18
|
+
// qq-official-bot emits every dotted prefix. Listening at `notice` once avoids
|
|
19
|
+
// dispatching e.g. notice.group.member.increase three times.
|
|
20
|
+
bot.on('notice', (raw) => dispatch(resolveQqSideEventName(raw), raw));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveQqSideEventName(raw: unknown): string {
|
|
24
|
+
if (!raw || typeof raw !== 'object') return 'notice';
|
|
25
|
+
const record = raw as Record<string, unknown>;
|
|
26
|
+
return ['notice', record.notice_type, record.sub_type]
|
|
27
|
+
.filter((part) => part != null && String(part) !== '')
|
|
28
|
+
.map(String)
|
|
29
|
+
.join('.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toOneBotLikeRaw(eventName: string, raw: unknown): OneBotLikeRawEvent | null {
|
|
33
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
34
|
+
const record = raw as Record<string, unknown>;
|
|
35
|
+
const asId = (value: unknown): string | number | undefined => {
|
|
36
|
+
if (typeof value === 'string' || typeof value === 'number') return value;
|
|
37
|
+
return undefined;
|
|
38
|
+
};
|
|
39
|
+
const asTime = (value: unknown): number => {
|
|
40
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
41
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
42
|
+
const date = typeof value === 'string' ? Date.parse(value) : Number.NaN;
|
|
43
|
+
return Number.isFinite(date) ? date : Date.now();
|
|
44
|
+
};
|
|
45
|
+
if (eventName === 'notice.group.join_request') {
|
|
46
|
+
return {
|
|
47
|
+
...record,
|
|
48
|
+
post_type: 'request',
|
|
49
|
+
request_type: 'group',
|
|
50
|
+
flag: record.join_request_id != null ? String(record.join_request_id) : undefined,
|
|
51
|
+
user_id: asId(record.user_id),
|
|
52
|
+
group_id: asId(record.group_id),
|
|
53
|
+
comment: formatJoinRequestComment(record.verify_info),
|
|
54
|
+
time: asTime(record.time ?? record.timestamp ?? record.apply_at),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (eventName.startsWith('notice.')) {
|
|
58
|
+
const memberChange = eventName.match(/^notice\.group(?:\.member)?\.(increase|decrease)$/);
|
|
59
|
+
const memberUserId = asId(record.user_id)
|
|
60
|
+
?? asId((record.bot as { self_id?: unknown } | undefined)?.self_id);
|
|
61
|
+
return {
|
|
62
|
+
...record,
|
|
63
|
+
post_type: 'notice',
|
|
64
|
+
notice_type: memberChange ? `group_${memberChange[1]}` : eventName.replace(/^notice\./, ''),
|
|
65
|
+
...(memberChange && memberUserId != null ? { user_id: memberUserId } : {}),
|
|
66
|
+
time: asTime(record.time ?? record.timestamp),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatJoinRequestComment(value: unknown): string | undefined {
|
|
73
|
+
if (typeof value === 'string') return value;
|
|
74
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
75
|
+
const verify = value as {
|
|
76
|
+
verify_message?: unknown;
|
|
77
|
+
review_qa_list?: Array<{ question?: unknown; answer?: unknown }>;
|
|
78
|
+
};
|
|
79
|
+
if (typeof verify.verify_message === 'string') return verify.verify_message;
|
|
80
|
+
if (!Array.isArray(verify.review_qa_list)) return undefined;
|
|
81
|
+
const lines = verify.review_qa_list.flatMap((item) => {
|
|
82
|
+
const question = typeof item?.question === 'string' ? item.question : '';
|
|
83
|
+
const answer = typeof item?.answer === 'string' ? item.answer : '';
|
|
84
|
+
return question || answer ? [`${question}${question && answer ? ': ' : ''}${answer}`] : [];
|
|
85
|
+
});
|
|
86
|
+
return lines.length > 0 ? lines.join('\n') : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function receiveQqSideEvent(
|
|
90
|
+
emit: EndpointEventEmitter,
|
|
91
|
+
endpointKey: string,
|
|
92
|
+
caller: QqSideEventCaller,
|
|
93
|
+
eventName: string,
|
|
94
|
+
raw: unknown,
|
|
95
|
+
logger: ReturnType<typeof getAdapterLogger>,
|
|
96
|
+
): void {
|
|
97
|
+
if (!emit) return;
|
|
98
|
+
const mapped = toOneBotLikeRaw(eventName, raw);
|
|
99
|
+
if (!mapped) return;
|
|
100
|
+
const isJoinRequest = eventName === 'notice.group.join_request';
|
|
101
|
+
const joinRaw = isJoinRequest && raw && typeof raw === 'object'
|
|
102
|
+
? raw as { group_id?: string | number; user_id?: string | number; join_request_id?: string | number }
|
|
103
|
+
: undefined;
|
|
104
|
+
void receiveOneBotLikeSideEvent(emit, {
|
|
105
|
+
adapter: 'qq',
|
|
106
|
+
endpointKey,
|
|
107
|
+
platform: 'qq',
|
|
108
|
+
raw: mapped,
|
|
109
|
+
...(isJoinRequest && caller.approveGroupJoinRequest && joinRaw ? {
|
|
110
|
+
approve: async (flag, remark) => {
|
|
111
|
+
await caller.approveGroupJoinRequest!(
|
|
112
|
+
String(joinRaw.group_id ?? ''),
|
|
113
|
+
String(joinRaw.user_id ?? ''),
|
|
114
|
+
{
|
|
115
|
+
op: 'approve',
|
|
116
|
+
join_request_id: String(joinRaw.join_request_id ?? flag),
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
},
|
|
120
|
+
reject: async (flag, reason) => {
|
|
121
|
+
await caller.approveGroupJoinRequest!(
|
|
122
|
+
String(joinRaw.group_id ?? ''),
|
|
123
|
+
String(joinRaw.user_id ?? ''),
|
|
124
|
+
{
|
|
125
|
+
op: 'decline',
|
|
126
|
+
join_request_id: String(joinRaw.join_request_id ?? flag),
|
|
127
|
+
...(reason ? { reject_reason: reason } : {}),
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
} : {}),
|
|
132
|
+
}).catch((err) => {
|
|
133
|
+
logger.warn(formatCompact({
|
|
134
|
+
op: 'qq_side_event_failed',
|
|
135
|
+
endpoint: endpointKey,
|
|
136
|
+
event: eventName,
|
|
137
|
+
error: err instanceof Error ? err.message : String(err),
|
|
138
|
+
}));
|
|
139
|
+
});
|
|
140
|
+
}
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ HTTP webhook / middleware inbound via httpHostToken POST.
|
|
3
|
+
*/
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
|
+
import { Bot, ReceiverMode, type Sendable } from 'qq-official-bot';
|
|
7
|
+
import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
|
|
8
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
9
|
+
import type { ResolvedQqHttpConfig } from './protocol.js';
|
|
10
|
+
import type { QqBotTransport } from './ws.js';
|
|
11
|
+
|
|
12
|
+
const logger = getLogger('qq');
|
|
13
|
+
|
|
14
|
+
export type QqHttpBotTransport = QqBotTransport & {
|
|
15
|
+
middleware: (ctx: unknown, next: () => Promise<void>) => Promise<unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CreateQqHttpBot = (config: ResolvedQqHttpConfig) => QqHttpBotTransport;
|
|
19
|
+
|
|
20
|
+
export interface QqWebhookHandler {
|
|
21
|
+
readonly config: ResolvedQqHttpConfig;
|
|
22
|
+
getBot(): (QqHttpBotTransport & { middleware?: QqHttpBotTransport['middleware'] }) | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerQqWebhookRoutes(
|
|
26
|
+
http: HttpHost,
|
|
27
|
+
handler: QqWebhookHandler,
|
|
28
|
+
): HttpRouteRegistration[] {
|
|
29
|
+
const webhookPath = handler.config.webhookPath;
|
|
30
|
+
return [
|
|
31
|
+
http.route('POST', webhookPath, async (request, response) => {
|
|
32
|
+
await handleQqWebhookRequest(request, response, handler);
|
|
33
|
+
}, { summary: 'QQ webhook callback', tags: ['qq'] }),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function handleQqWebhookRequest(
|
|
38
|
+
request: IncomingMessage,
|
|
39
|
+
response: ServerResponse,
|
|
40
|
+
handler: QqWebhookHandler,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const bot = handler.getBot();
|
|
43
|
+
if (!bot?.middleware) {
|
|
44
|
+
response.writeHead(503, { 'Content-Type': 'text/plain' });
|
|
45
|
+
response.end('QQ receiver not ready');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
// 不预消费请求流:库的验签必须基于原始字节(JSON.parse → JSON.stringify
|
|
50
|
+
// 会改变空白/转义/键序导致 401),body 留 undefined 让库自己读流;
|
|
51
|
+
// 预消费还会让非 JSON body 时库监听已结束的 end 事件而挂死。
|
|
52
|
+
await bot.middleware({
|
|
53
|
+
req: request,
|
|
54
|
+
res: response,
|
|
55
|
+
request: { body: undefined },
|
|
56
|
+
}, async () => undefined);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
logger.error('QQ webhook error:', error);
|
|
59
|
+
if (!response.headersSent) {
|
|
60
|
+
response.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
61
|
+
response.end('Internal Server Error');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function readRequestBodyText(request: IncomingMessage): Promise<string> {
|
|
67
|
+
const chunks: Buffer[] = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
for await (const chunk of request) {
|
|
70
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
71
|
+
size += buffer.length;
|
|
72
|
+
if (size > 1_048_576) {
|
|
73
|
+
request.destroy();
|
|
74
|
+
throw new Error('Request body exceeds 1MB');
|
|
75
|
+
}
|
|
76
|
+
chunks.push(buffer);
|
|
77
|
+
}
|
|
78
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function defaultCreateHttpBot(config: ResolvedQqHttpConfig): QqHttpBotTransport {
|
|
82
|
+
const bot = new Bot({
|
|
83
|
+
appid: config.appid,
|
|
84
|
+
secret: config.secret,
|
|
85
|
+
mode: ReceiverMode.MIDDLEWARE,
|
|
86
|
+
platform: 'koa',
|
|
87
|
+
sandbox: config.sandbox,
|
|
88
|
+
dataDir: path.join(process.cwd(), 'data', 'qq'),
|
|
89
|
+
// 关闭内置 log4js,与 WebSocket 模式一致走 zhin adapter logger
|
|
90
|
+
logLevel: 'off',
|
|
91
|
+
} as never) as Bot<ReceiverMode.MIDDLEWARE, 'koa'> & QqBotTransport;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
api: bot,
|
|
95
|
+
start: () => bot.start().then(() => undefined),
|
|
96
|
+
stop: () => bot.stop().then(() => undefined),
|
|
97
|
+
on: (event, listener) => {
|
|
98
|
+
bot.on(event as never, listener as never);
|
|
99
|
+
},
|
|
100
|
+
removeAllListeners: () => {
|
|
101
|
+
bot.removeAllListeners(undefined as never);
|
|
102
|
+
},
|
|
103
|
+
sendPrivateMessage: (userId, message) => bot.sendPrivateMessage(userId, message as Sendable),
|
|
104
|
+
sendGroupMessage: (groupId, message) => bot.sendGroupMessage(groupId, message as Sendable),
|
|
105
|
+
sendGuildMessage: (channelId, message) => bot.sendGuildMessage(channelId, message as Sendable),
|
|
106
|
+
sendDirectMessage: (guildId, message) => bot.sendDirectMessage(guildId, message as Sendable),
|
|
107
|
+
recallPrivateMessage: (userId, messageId) => bot.recallPrivateMessage(userId, messageId),
|
|
108
|
+
recallGroupMessage: (groupId, messageId) => bot.recallGroupMessage(groupId, messageId),
|
|
109
|
+
recallGuildMessage: (channelId, messageId) => bot.recallGuildMessage(channelId, messageId),
|
|
110
|
+
recallDirectMessage: (guildId, messageId) => bot.recallDirectMessage(guildId, messageId),
|
|
111
|
+
getGuilds: () => bot.guildService.getList(),
|
|
112
|
+
getChannels: (guildId) => bot.channelService.getList(guildId),
|
|
113
|
+
getChannelInfo: (channelId) => bot.channelService.getInfo(channelId),
|
|
114
|
+
getGuildMember: (guildId, userId) => bot.memberService.getGuildMemberInfo(guildId, userId),
|
|
115
|
+
getGuildRoles: (guildId) => bot.guildService.getRoles(guildId),
|
|
116
|
+
createGuildRole: (guildId, name, color) =>
|
|
117
|
+
bot.guildService.createRole(guildId, { name, color: color || 0, hoist: 0 }),
|
|
118
|
+
addMemberRole: async (guildId, channelId, userId, roleId) => {
|
|
119
|
+
await bot.memberService.addMemberRole(guildId, channelId, userId, roleId);
|
|
120
|
+
return true;
|
|
121
|
+
},
|
|
122
|
+
removeMemberRole: async (guildId, channelId, userId, roleId) => {
|
|
123
|
+
await bot.memberService.removeMemberRole(guildId, channelId, userId, roleId);
|
|
124
|
+
return true;
|
|
125
|
+
},
|
|
126
|
+
approveGroupJoinRequest: (groupId, userId, options) =>
|
|
127
|
+
(bot as { approveGroupJoinRequest?: QqBotTransport['approveGroupJoinRequest'] })
|
|
128
|
+
.approveGroupJoinRequest?.(groupId, userId, options)
|
|
129
|
+
?? Promise.reject(new Error('approveGroupJoinRequest is not available')),
|
|
130
|
+
middleware: (ctx, next) => bot.middleware(ctx as never, next as never),
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/ws.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ WebSocket transport: bot factory and inbound message normalization.
|
|
3
|
+
*/
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
Bot,
|
|
7
|
+
ReceiverMode,
|
|
8
|
+
type ApproveJoinRequestOptions,
|
|
9
|
+
type Sendable,
|
|
10
|
+
} from 'qq-official-bot';
|
|
11
|
+
import type { MediaRef, Segment } from '@zhin.js/im-contract';
|
|
12
|
+
import type { QqOutboundMessage } from './outbound.js';
|
|
13
|
+
import type { QqChannelKind, QqInboundMessage, ResolvedQqWebsocketConfig } from './protocol.js';
|
|
14
|
+
|
|
15
|
+
/** SDK business surface exposed to plugins without bypassing Endpoint lifecycle ownership. */
|
|
16
|
+
export type QqOfficialApi = Pick<Bot,
|
|
17
|
+
| 'messageService'
|
|
18
|
+
| 'fileProcessor'
|
|
19
|
+
| 'guildService'
|
|
20
|
+
| 'channelService'
|
|
21
|
+
| 'memberService'
|
|
22
|
+
| 'permissionService'
|
|
23
|
+
| 'reactionService'
|
|
24
|
+
| 'scheduleService'
|
|
25
|
+
| 'threadService'
|
|
26
|
+
| 'audioService'
|
|
27
|
+
| 'botService'
|
|
28
|
+
| 'groupService'
|
|
29
|
+
| 'menuPanelService'
|
|
30
|
+
| 'group'
|
|
31
|
+
| 'user'
|
|
32
|
+
| 'channel'
|
|
33
|
+
| 'direct'
|
|
34
|
+
| 'guild'
|
|
35
|
+
>;
|
|
36
|
+
|
|
37
|
+
/** Minimal bot surface used by the endpoint (real qq-official-bot or test mock). */
|
|
38
|
+
export interface QqBotTransport {
|
|
39
|
+
/** Full qq-official-bot business API; lifecycle remains owned by the Endpoint. */
|
|
40
|
+
readonly api: QqOfficialApi;
|
|
41
|
+
start(): Promise<void>;
|
|
42
|
+
stop(): Promise<void>;
|
|
43
|
+
on(event: string, listener: (...args: unknown[]) => void): void;
|
|
44
|
+
removeAllListeners(): void;
|
|
45
|
+
sendPrivateMessage(userId: string, message: QqOutboundMessage): Promise<unknown>;
|
|
46
|
+
sendGroupMessage(groupId: string, message: QqOutboundMessage): Promise<unknown>;
|
|
47
|
+
sendGuildMessage(channelId: string, message: QqOutboundMessage): Promise<unknown>;
|
|
48
|
+
sendDirectMessage?(guildId: string, message: QqOutboundMessage): Promise<unknown>;
|
|
49
|
+
recallPrivateMessage?(userId: string, messageId: string): Promise<unknown>;
|
|
50
|
+
recallGroupMessage?(groupId: string, messageId: string): Promise<unknown>;
|
|
51
|
+
recallGuildMessage?(channelId: string, messageId: string): Promise<unknown>;
|
|
52
|
+
recallDirectMessage?(guildId: string, messageId: string): Promise<unknown>;
|
|
53
|
+
getGuilds(): Promise<unknown[]>;
|
|
54
|
+
getChannels(guildId: string): Promise<unknown[]>;
|
|
55
|
+
getChannelInfo(channelId: string): Promise<unknown>;
|
|
56
|
+
getGuildMember(guildId: string, userId: string): Promise<unknown>;
|
|
57
|
+
getGuildRoles(guildId: string): Promise<unknown[]>;
|
|
58
|
+
createGuildRole(guildId: string, name: string, color?: number): Promise<unknown>;
|
|
59
|
+
addMemberRole(
|
|
60
|
+
guildId: string,
|
|
61
|
+
channelId: string,
|
|
62
|
+
userId: string,
|
|
63
|
+
roleId: string,
|
|
64
|
+
): Promise<boolean>;
|
|
65
|
+
removeMemberRole(
|
|
66
|
+
guildId: string,
|
|
67
|
+
channelId: string,
|
|
68
|
+
userId: string,
|
|
69
|
+
roleId: string,
|
|
70
|
+
): Promise<boolean>;
|
|
71
|
+
approveGroupJoinRequest?(
|
|
72
|
+
groupId: string,
|
|
73
|
+
userId: string,
|
|
74
|
+
options: ApproveJoinRequestOptions,
|
|
75
|
+
): Promise<unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type CreateQqBot = (config: ResolvedQqWebsocketConfig) => QqBotTransport;
|
|
79
|
+
|
|
80
|
+
interface QqOfficialBotLifecycle {
|
|
81
|
+
stop(): Promise<unknown>;
|
|
82
|
+
readonly sessionManager: {
|
|
83
|
+
userClose: boolean;
|
|
84
|
+
readonly connectionManager?: { destroy(): void };
|
|
85
|
+
readonly authManager?: { destroy(): void };
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* qq-official-bot 1.2 used a separate connection manager; 1.3 moved WebSocket
|
|
91
|
+
* ownership into the receiver, but Session.stop() still leaves the auth refresh
|
|
92
|
+
* manager alive. Mark the session closed, clean up legacy connections when
|
|
93
|
+
* present, stop the receiver, then destroy auth refresh at the SDK boundary.
|
|
94
|
+
*/
|
|
95
|
+
export async function stopQqOfficialBot(bot: QqOfficialBotLifecycle): Promise<void> {
|
|
96
|
+
const session = bot.sessionManager;
|
|
97
|
+
session.userClose = true;
|
|
98
|
+
session.connectionManager?.destroy();
|
|
99
|
+
try {
|
|
100
|
+
await bot.stop();
|
|
101
|
+
} finally {
|
|
102
|
+
session.authManager?.destroy();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function bindQqBotInboundEvents(
|
|
107
|
+
bot: QqBotTransport,
|
|
108
|
+
admitRaw: (raw: unknown) => void,
|
|
109
|
+
): void {
|
|
110
|
+
bot.on('message.group', admitRaw);
|
|
111
|
+
bot.on('message.guild', admitRaw);
|
|
112
|
+
bot.on('message.private', admitRaw);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function normalizeQqMessage(raw: unknown): QqInboundMessage | null {
|
|
116
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
117
|
+
const msg = raw as {
|
|
118
|
+
message_id?: string | number;
|
|
119
|
+
message_type?: string;
|
|
120
|
+
sub_type?: string;
|
|
121
|
+
user_id?: string | number;
|
|
122
|
+
group_id?: string | number;
|
|
123
|
+
group_openid?: string;
|
|
124
|
+
channel_id?: string | number;
|
|
125
|
+
guild_id?: string | number;
|
|
126
|
+
raw_message?: string;
|
|
127
|
+
message?: unknown;
|
|
128
|
+
sender?: { user_id?: string | number; user_name?: string; roles?: (string | number)[] };
|
|
129
|
+
member?: { roles?: (string | number)[] };
|
|
130
|
+
author?: { member_openid?: string; user_openid?: string; id?: string; username?: string };
|
|
131
|
+
/** AT 事件载荷:被 @ 的用户列表;is_you 标识当前机器人,bot 标识机器人账号 */
|
|
132
|
+
mentions?: Array<{ id?: string | number; bot?: boolean; is_you?: boolean; member_openid?: string; member_role?: string }>;
|
|
133
|
+
};
|
|
134
|
+
if (msg.message_id == null) return null;
|
|
135
|
+
|
|
136
|
+
let channelKind: QqChannelKind = 'private';
|
|
137
|
+
let channelId = '';
|
|
138
|
+
|
|
139
|
+
const isGroup = msg.message_type === 'group'
|
|
140
|
+
|| (msg.message_type == null && (msg.group_id != null || msg.group_openid != null));
|
|
141
|
+
const isGuild = msg.message_type === 'guild'
|
|
142
|
+
|| (msg.message_type == null && !isGroup && msg.guild_id != null && msg.channel_id != null);
|
|
143
|
+
|
|
144
|
+
if (isGuild) {
|
|
145
|
+
channelKind = msg.sub_type === 'direct' ? 'direct' : 'channel';
|
|
146
|
+
channelId = channelKind === 'direct'
|
|
147
|
+
? String(msg.guild_id ?? '')
|
|
148
|
+
: String(msg.channel_id ?? '');
|
|
149
|
+
} else if (isGroup) {
|
|
150
|
+
channelKind = 'group';
|
|
151
|
+
channelId = String(msg.group_id ?? msg.group_openid ?? '');
|
|
152
|
+
} else {
|
|
153
|
+
channelKind = 'private';
|
|
154
|
+
channelId = String(msg.user_id ?? msg.author?.user_openid ?? msg.author?.id ?? '');
|
|
155
|
+
}
|
|
156
|
+
if (!channelId) return null;
|
|
157
|
+
|
|
158
|
+
const authorId = String(
|
|
159
|
+
msg.sender?.user_id
|
|
160
|
+
?? msg.user_id
|
|
161
|
+
?? msg.author?.member_openid
|
|
162
|
+
?? msg.author?.user_openid
|
|
163
|
+
?? msg.author?.id
|
|
164
|
+
?? '',
|
|
165
|
+
);
|
|
166
|
+
const content = extractTextContent(msg.message) || msg.raw_message || '';
|
|
167
|
+
const segments = extractCanonicalSegments(msg.message);
|
|
168
|
+
const rawRoles = msg.member?.roles ?? msg.sender?.roles;
|
|
169
|
+
const authorRoles = Array.isArray(rawRoles) ? rawRoles.map(String) : undefined;
|
|
170
|
+
// 统一按 mentions 判定 @:is_you 精确标识当前机器人(群载荷实测字段)。
|
|
171
|
+
// 群里 @ 另一个机器人时 mentions 带 bot:true 但无 is_you,不能误判为 @ 当前机器人,
|
|
172
|
+
// 故群场景只认 is_you;bot 回退仅限频道 AT_MESSAGE_CREATE(该事件仅 @ 当前
|
|
173
|
+
// 机器人时下发,其 mentions 无 is_you)。
|
|
174
|
+
const mentioned = Array.isArray(msg.mentions)
|
|
175
|
+
&& msg.mentions.some((m) => m?.is_you === true || (m?.bot === true && channelKind !== 'group'));
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
id: String(msg.message_id),
|
|
179
|
+
content,
|
|
180
|
+
channelKind,
|
|
181
|
+
channelId,
|
|
182
|
+
authorId,
|
|
183
|
+
authorName: msg.sender?.user_name || msg.author?.username || authorId,
|
|
184
|
+
authorRoles,
|
|
185
|
+
timestamp: Date.now(),
|
|
186
|
+
guildId: msg.guild_id != null ? String(msg.guild_id) : undefined,
|
|
187
|
+
rawMessage: msg.raw_message,
|
|
188
|
+
...(segments.length > 0 ? { segments } : {}),
|
|
189
|
+
...(mentioned ? { mentioned: true } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* SDK 解析后的消息段(text/at/face/image/video/audio/file/reply)→ canonical Segment[]。
|
|
195
|
+
* 附件段的 url 已由 SDK 归一(补 https:// 前缀);mime 无法从载荷得知时按类型给默认。
|
|
196
|
+
*/
|
|
197
|
+
function extractCanonicalSegments(message: unknown): Segment[] {
|
|
198
|
+
if (!Array.isArray(message)) return [];
|
|
199
|
+
const out: Segment[] = [];
|
|
200
|
+
for (const seg of message) {
|
|
201
|
+
if (!seg || typeof seg !== 'object') continue;
|
|
202
|
+
const item = seg as { type?: string; data?: Record<string, unknown> };
|
|
203
|
+
const type = item.type ?? '';
|
|
204
|
+
const data = item.data ?? {};
|
|
205
|
+
switch (type) {
|
|
206
|
+
case 'text': {
|
|
207
|
+
const text = String(data.text ?? '');
|
|
208
|
+
if (text) out.push({ type: 'text', data: { text } });
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case 'at': {
|
|
212
|
+
const target = String(data.user_id ?? data.id ?? '');
|
|
213
|
+
if (!target) break;
|
|
214
|
+
const name = typeof data.username === 'string' && data.username
|
|
215
|
+
? data.username
|
|
216
|
+
: undefined;
|
|
217
|
+
out.push({ type: 'mention', data: name ? { target, name } : { target } });
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case 'face': {
|
|
221
|
+
const id = data.id;
|
|
222
|
+
if (id == null) break;
|
|
223
|
+
out.push({ type: 'face', data: { id: id as string | number } });
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case 'image':
|
|
227
|
+
case 'video':
|
|
228
|
+
case 'audio':
|
|
229
|
+
case 'file': {
|
|
230
|
+
const url = typeof data.url === 'string' ? data.url : '';
|
|
231
|
+
if (!url) break;
|
|
232
|
+
const media: MediaRef = {
|
|
233
|
+
kind: 'url',
|
|
234
|
+
value: url,
|
|
235
|
+
mime_type: defaultMimeForSegment(type),
|
|
236
|
+
...(typeof data.name === 'string' && data.name ? { file_name: data.name } : {}),
|
|
237
|
+
...(typeof data.size === 'number' ? { size: data.size } : {}),
|
|
238
|
+
};
|
|
239
|
+
if (type === 'image') out.push({ type: 'image', data: { media } });
|
|
240
|
+
else if (type === 'video') out.push({ type: 'video', data: { media } });
|
|
241
|
+
else if (type === 'audio') out.push({ type: 'audio', data: { media } });
|
|
242
|
+
else out.push({ type: 'file', data: { media, ...(media.file_name ? { name: media.file_name } : {}) } });
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case 'reply': {
|
|
246
|
+
const messageId = String(data.id ?? data.message_id ?? '');
|
|
247
|
+
if (messageId) out.push({ type: 'reply', data: { message_id: messageId } });
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
default:
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function defaultMimeForSegment(type: string): string | undefined {
|
|
258
|
+
switch (type) {
|
|
259
|
+
case 'image': return 'image/jpeg';
|
|
260
|
+
case 'video': return 'video/mp4';
|
|
261
|
+
case 'audio': return 'audio/mpeg';
|
|
262
|
+
default: return undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function extractTextContent(message: unknown): string {
|
|
267
|
+
if (typeof message === 'string') return message;
|
|
268
|
+
if (!Array.isArray(message)) return '';
|
|
269
|
+
return message
|
|
270
|
+
.map((seg) => {
|
|
271
|
+
if (!seg || typeof seg !== 'object') return '';
|
|
272
|
+
const item = seg as { type?: string; data?: { text?: string }; text?: string };
|
|
273
|
+
if (item.type === 'text') return String(item.data?.text ?? item.text ?? '');
|
|
274
|
+
return '';
|
|
275
|
+
})
|
|
276
|
+
.join('');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function defaultCreateBot(config: ResolvedQqWebsocketConfig): QqBotTransport {
|
|
280
|
+
const bot = new Bot({
|
|
281
|
+
appid: config.appid,
|
|
282
|
+
secret: config.secret,
|
|
283
|
+
mode: ReceiverMode.WEBSOCKET,
|
|
284
|
+
sandbox: config.sandbox,
|
|
285
|
+
dataDir: path.join(process.cwd(), 'data', 'qq'),
|
|
286
|
+
// 关闭 qq-official-bot 内置 log4js,入/出站由 getAdapterLogger('qq', …) 统一格式
|
|
287
|
+
logLevel: 'off',
|
|
288
|
+
...(config.intents ? { intents: config.intents as never } : {}),
|
|
289
|
+
...(config.accessTokenUrl ? { accessTokenUrl: config.accessTokenUrl } : {}),
|
|
290
|
+
...(config.gatewayUrl ? { gatewayUrl: config.gatewayUrl } : {}),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
api: bot,
|
|
295
|
+
start: () => bot.start().then(() => undefined),
|
|
296
|
+
stop: () => stopQqOfficialBot(bot as unknown as QqOfficialBotLifecycle),
|
|
297
|
+
on: (event, listener) => {
|
|
298
|
+
bot.on(event as never, listener as never);
|
|
299
|
+
},
|
|
300
|
+
removeAllListeners: () => {
|
|
301
|
+
bot.removeAllListeners(undefined as never);
|
|
302
|
+
},
|
|
303
|
+
sendPrivateMessage: (userId, message) => bot.sendPrivateMessage(userId, message as Sendable),
|
|
304
|
+
sendGroupMessage: (groupId, message) => bot.sendGroupMessage(groupId, message as Sendable),
|
|
305
|
+
sendGuildMessage: (channelId, message) => bot.sendGuildMessage(channelId, message as Sendable),
|
|
306
|
+
sendDirectMessage: (guildId, message) => bot.sendDirectMessage(guildId, message as Sendable),
|
|
307
|
+
recallPrivateMessage: (userId, messageId) => bot.recallPrivateMessage(userId, messageId),
|
|
308
|
+
recallGroupMessage: (groupId, messageId) => bot.recallGroupMessage(groupId, messageId),
|
|
309
|
+
recallGuildMessage: (channelId, messageId) => bot.recallGuildMessage(channelId, messageId),
|
|
310
|
+
recallDirectMessage: (guildId, messageId) => bot.recallDirectMessage(guildId, messageId),
|
|
311
|
+
getGuilds: () => bot.guildService.getList(),
|
|
312
|
+
getChannels: (guildId) => bot.channelService.getList(guildId),
|
|
313
|
+
getChannelInfo: (channelId) => bot.channelService.getInfo(channelId),
|
|
314
|
+
getGuildMember: (guildId, userId) => bot.memberService.getGuildMemberInfo(guildId, userId),
|
|
315
|
+
getGuildRoles: (guildId) => bot.guildService.getRoles(guildId),
|
|
316
|
+
createGuildRole: (guildId, name, color) =>
|
|
317
|
+
bot.guildService.createRole(guildId, { name, color: color || 0, hoist: 0 }),
|
|
318
|
+
addMemberRole: async (guildId, channelId, userId, roleId) => {
|
|
319
|
+
await bot.memberService.addMemberRole(guildId, channelId, userId, roleId);
|
|
320
|
+
return true;
|
|
321
|
+
},
|
|
322
|
+
removeMemberRole: async (guildId, channelId, userId, roleId) => {
|
|
323
|
+
await bot.memberService.removeMemberRole(guildId, channelId, userId, roleId);
|
|
324
|
+
return true;
|
|
325
|
+
},
|
|
326
|
+
approveGroupJoinRequest: (groupId, userId, options) =>
|
|
327
|
+
(bot as { approveGroupJoinRequest?: QqBotTransport['approveGroupJoinRequest'] })
|
|
328
|
+
.approveGroupJoinRequest?.(groupId, userId, options)
|
|
329
|
+
?? Promise.reject(new Error('approveGroupJoinRequest is not available')),
|
|
330
|
+
};
|
|
331
|
+
}
|