@zhin.js/adapter-qq 1.0.83 → 1.1.0
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 +853 -16
- package/README.md +136 -233
- package/adapters/qq.js +50 -0
- package/adapters/qq.ts +71 -0
- package/agent/PERMITS.md +25 -0
- package/{skills/qq/SKILL.md → agent/skills/qq.md} +1 -1
- package/agent/tools/add_role.ts +21 -0
- package/agent/tools/channel_info.ts +17 -0
- package/agent/tools/create_role.ts +20 -0
- package/agent/tools/list_channels.ts +18 -0
- package/agent/tools/list_guilds.ts +14 -0
- package/agent/tools/list_roles.ts +18 -0
- package/agent/tools/member_detail.ts +18 -0
- package/agent/tools/remove_role.ts +21 -0
- package/commands/endpoint/add/[[id]].js +3 -0
- package/commands/endpoint/add/[[id]].ts +3 -0
- package/commands/endpoint/cancel.js +12 -0
- package/commands/endpoint/cancel.ts +15 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[id].js +3 -0
- package/commands/endpoint/remove/[id].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 +111 -0
- package/lib/protocol.js +166 -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-bind-persist.d.ts +12 -0
- package/lib/qq-bind-persist.js +22 -0
- package/lib/qq-bot-kind-prompt.d.ts +9 -0
- package/lib/qq-bot-kind-prompt.js +37 -0
- package/lib/qq-endpoint-commands.d.ts +45 -0
- package/lib/qq-endpoint-commands.js +180 -0
- package/lib/qq-endpoint-config.d.ts +17 -0
- package/lib/qq-endpoint-config.js +22 -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.js +46 -0
- package/middlewares/bot-kind-reply.ts +60 -0
- package/package.json +69 -28
- package/plugin.js +18 -0
- package/schema.json +142 -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 +273 -0
- package/src/qq-bind-api.ts +145 -0
- package/src/qq-bind-flow.ts +127 -0
- package/src/qq-bind-persist.ts +44 -0
- package/src/qq-bot-kind-prompt.ts +47 -0
- package/src/qq-endpoint-commands.ts +251 -0
- package/src/qq-endpoint-config.ts +44 -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/src/adapter.ts +0 -58
- package/src/bot.ts +0 -347
- package/src/types.ts +0 -24
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Official Bot protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
3
|
+
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
|
+
*/
|
|
5
|
+
import type { ConversationRef } from '@zhin.js/im-contract';
|
|
6
|
+
/** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
|
|
7
|
+
export interface QqAdapterConfig {
|
|
8
|
+
readonly id?: string;
|
|
9
|
+
readonly appid?: string;
|
|
10
|
+
readonly secret?: string;
|
|
11
|
+
/** Default `websocket`. `webhook` / `middleware` use httpHostToken POST. */
|
|
12
|
+
readonly mode?: 'websocket' | 'webhook' | 'middleware';
|
|
13
|
+
readonly sandbox?: boolean;
|
|
14
|
+
/** Explicit intents; when omitted, derived from `botKind` (default public). */
|
|
15
|
+
readonly intents?: readonly string[];
|
|
16
|
+
/** public | private — drives default intents when `intents` omitted. */
|
|
17
|
+
readonly botKind?: string;
|
|
18
|
+
readonly accessTokenUrl?: string;
|
|
19
|
+
readonly gatewayUrl?: string;
|
|
20
|
+
readonly webhookPath?: string;
|
|
21
|
+
readonly port?: number;
|
|
22
|
+
readonly path?: string;
|
|
23
|
+
/** Transitional: legacy root `endpoints[]` with `context: qq`. */
|
|
24
|
+
readonly endpoints?: ReadonlyArray<{
|
|
25
|
+
readonly context?: string;
|
|
26
|
+
readonly id?: string;
|
|
27
|
+
readonly appid?: string;
|
|
28
|
+
readonly secret?: string;
|
|
29
|
+
readonly mode?: 'websocket' | 'webhook' | 'middleware';
|
|
30
|
+
readonly sandbox?: boolean;
|
|
31
|
+
readonly intents?: readonly string[];
|
|
32
|
+
readonly botKind?: string;
|
|
33
|
+
readonly accessTokenUrl?: string;
|
|
34
|
+
readonly gatewayUrl?: string;
|
|
35
|
+
readonly webhookPath?: string;
|
|
36
|
+
readonly port?: number;
|
|
37
|
+
readonly path?: string;
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
export interface ResolvedQqWebsocketConfig {
|
|
41
|
+
readonly context: 'qq';
|
|
42
|
+
readonly mode: 'websocket';
|
|
43
|
+
readonly id: string;
|
|
44
|
+
readonly appid: string;
|
|
45
|
+
readonly secret: string;
|
|
46
|
+
readonly sandbox: boolean;
|
|
47
|
+
readonly intents?: readonly string[];
|
|
48
|
+
readonly accessTokenUrl?: string;
|
|
49
|
+
readonly gatewayUrl?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface ResolvedQqHttpConfig {
|
|
52
|
+
readonly context: 'qq';
|
|
53
|
+
readonly mode: 'webhook' | 'middleware';
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly appid: string;
|
|
56
|
+
readonly secret: string;
|
|
57
|
+
readonly webhookPath: string;
|
|
58
|
+
readonly sandbox: boolean;
|
|
59
|
+
}
|
|
60
|
+
export type ResolvedQqConfig = ResolvedQqWebsocketConfig | ResolvedQqHttpConfig;
|
|
61
|
+
export type QqChannelKind = 'private' | 'group' | 'channel' | 'direct';
|
|
62
|
+
export interface QqInboundMessage {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
readonly content: string;
|
|
65
|
+
readonly channelKind: QqChannelKind;
|
|
66
|
+
readonly channelId: string;
|
|
67
|
+
readonly authorId: string;
|
|
68
|
+
readonly authorName: string;
|
|
69
|
+
readonly authorRoles?: string[];
|
|
70
|
+
readonly timestamp: number;
|
|
71
|
+
readonly guildId?: string;
|
|
72
|
+
readonly rawMessage?: string;
|
|
73
|
+
/** canonical 入站段(text/mention/face/image/audio/video/file/reply),与 content 同源 */
|
|
74
|
+
readonly segments?: readonly import('@zhin.js/core').Segment[];
|
|
75
|
+
/**
|
|
76
|
+
* 入站是否 @ 当前机器人:统一按 mentions 判定。群场景只认 is_you(@ 另一个
|
|
77
|
+
* 机器人时 bot:true 但无 is_you,不误判);bot 回退仅限频道 AT 事件。
|
|
78
|
+
* 群 GROUP_MESSAGE_CREATE 的非 @ 消息不置位。新 Runtime 纯文本 content 需经
|
|
79
|
+
* metadata 传递。
|
|
80
|
+
*/
|
|
81
|
+
readonly mentioned?: boolean;
|
|
82
|
+
}
|
|
83
|
+
export interface QqWireSegment {
|
|
84
|
+
readonly type: string;
|
|
85
|
+
readonly data?: Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
export declare function resolveQqConfig(config?: QqAdapterConfig): ResolvedQqConfig;
|
|
88
|
+
/**
|
|
89
|
+
* 入站归一化 → ConversationRef:`direct`(频道私信)映射为 guild 容器内的
|
|
90
|
+
* private 会话;频道消息的所属 guild 进 `parent`。
|
|
91
|
+
*/
|
|
92
|
+
export declare function qqInboundConversation(endpointKey: string, msg: QqInboundMessage): ConversationRef;
|
|
93
|
+
/** 出站:guild 容器内的 private 会话即 QQ `direct`(频道私信)。 */
|
|
94
|
+
export declare function qqOutboundKind(conversation: ConversationRef): QqChannelKind;
|
|
95
|
+
export declare function senderDisplayName(msg: QqInboundMessage): string;
|
|
96
|
+
export declare function formatInboundContent(msg: QqInboundMessage): string;
|
|
97
|
+
/**
|
|
98
|
+
* Wire-encode an already-rendered outbound payload into a plain text / QQ Sendable string.
|
|
99
|
+
* Segment canonicalization is intentionally not done here.
|
|
100
|
+
*/
|
|
101
|
+
export declare function formatOutboundText(payload: unknown): string;
|
|
102
|
+
/**
|
|
103
|
+
* Parse a compound messageId produced by `send()`: `kind-channelId:qqMsgId`.
|
|
104
|
+
*/
|
|
105
|
+
export declare function parseCompoundMessageId(messageId: string): {
|
|
106
|
+
kind: QqChannelKind;
|
|
107
|
+
channelId: string;
|
|
108
|
+
qqMsgId: string;
|
|
109
|
+
};
|
|
110
|
+
/** 从 QQ API SendResult / 审核回包中解析出站消息 ID */
|
|
111
|
+
export declare function resolveOutboundMessageId(result: unknown): string;
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Official Bot protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
|
|
3
|
+
* Canonicalization is owned by gateway/core before endpoint.send.
|
|
4
|
+
*/
|
|
5
|
+
import { pickCredential } from 'zhin.js/adapter';
|
|
6
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
|
+
import { resolveQqIntents } from './qq-intents.js';
|
|
8
|
+
const logger = getLogger('qq');
|
|
9
|
+
export function resolveQqConfig(config = {}) {
|
|
10
|
+
const entry = config.endpoints?.find((item) => item.context === 'qq' || !item.context);
|
|
11
|
+
const appid = pickCredential(config.appid, entry?.appid, process.env.QQ_APPID, process.env.QQ_BOT_APPID);
|
|
12
|
+
const secret = pickCredential(config.secret, entry?.secret, process.env.QQ_SECRET, process.env.QQ_BOT_SECRET);
|
|
13
|
+
if (!appid || !secret) {
|
|
14
|
+
throw new TypeError('QQ adapter requires appid + secret (plugins.<key>.appid/secret or endpoints with context: qq)');
|
|
15
|
+
}
|
|
16
|
+
const id = (typeof config.id === 'string' && config.id)
|
|
17
|
+
|| (typeof entry?.id === 'string' && entry.id)
|
|
18
|
+
|| process.env.QQ_BOT_NAME
|
|
19
|
+
|| 'qq-bot';
|
|
20
|
+
const mode = config.mode ?? entry?.mode ?? 'websocket';
|
|
21
|
+
if (mode === 'webhook' || mode === 'middleware') {
|
|
22
|
+
const webhookPathRaw = config.webhookPath ?? entry?.webhookPath ?? '/qq/webhook';
|
|
23
|
+
const webhookPath = webhookPathRaw.startsWith('/') ? webhookPathRaw : `/${webhookPathRaw}`;
|
|
24
|
+
return {
|
|
25
|
+
context: 'qq',
|
|
26
|
+
mode,
|
|
27
|
+
id,
|
|
28
|
+
appid,
|
|
29
|
+
secret,
|
|
30
|
+
webhookPath,
|
|
31
|
+
sandbox: config.sandbox === true || entry?.sandbox === true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
context: 'qq',
|
|
36
|
+
mode: 'websocket',
|
|
37
|
+
id,
|
|
38
|
+
appid,
|
|
39
|
+
secret,
|
|
40
|
+
sandbox: config.sandbox === true || entry?.sandbox === true,
|
|
41
|
+
intents: resolveQqIntents({
|
|
42
|
+
intents: config.intents ?? entry?.intents,
|
|
43
|
+
botKind: config.botKind ?? entry?.botKind,
|
|
44
|
+
}),
|
|
45
|
+
accessTokenUrl: config.accessTokenUrl ?? entry?.accessTokenUrl,
|
|
46
|
+
gatewayUrl: config.gatewayUrl ?? entry?.gatewayUrl,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 入站归一化 → ConversationRef:`direct`(频道私信)映射为 guild 容器内的
|
|
51
|
+
* private 会话;频道消息的所属 guild 进 `parent`。
|
|
52
|
+
*/
|
|
53
|
+
export function qqInboundConversation(endpointKey, msg) {
|
|
54
|
+
const kind = msg.channelKind === 'direct' ? 'private' : msg.channelKind;
|
|
55
|
+
return {
|
|
56
|
+
endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
|
|
57
|
+
kind,
|
|
58
|
+
id: msg.channelId,
|
|
59
|
+
...(msg.guildId && (msg.channelKind === 'channel' || msg.channelKind === 'direct')
|
|
60
|
+
? { parent: { kind: 'channel', id: msg.guildId } }
|
|
61
|
+
: {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** 出站:guild 容器内的 private 会话即 QQ `direct`(频道私信)。 */
|
|
65
|
+
export function qqOutboundKind(conversation) {
|
|
66
|
+
if (conversation.kind === 'private' && conversation.parent?.kind === 'channel')
|
|
67
|
+
return 'direct';
|
|
68
|
+
return conversation.kind;
|
|
69
|
+
}
|
|
70
|
+
export function senderDisplayName(msg) {
|
|
71
|
+
return msg.authorName || msg.authorId;
|
|
72
|
+
}
|
|
73
|
+
export function formatInboundContent(msg) {
|
|
74
|
+
const text = (msg.content || msg.rawMessage || '').trim();
|
|
75
|
+
return text || '(Empty message)';
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Wire-encode an already-rendered outbound payload into a plain text / QQ Sendable string.
|
|
79
|
+
* Segment canonicalization is intentionally not done here.
|
|
80
|
+
*/
|
|
81
|
+
export function formatOutboundText(payload) {
|
|
82
|
+
if (typeof payload === 'string')
|
|
83
|
+
return payload;
|
|
84
|
+
const segments = Array.isArray(payload)
|
|
85
|
+
? payload
|
|
86
|
+
: payload && typeof payload === 'object' && 'type' in payload
|
|
87
|
+
? [payload]
|
|
88
|
+
: [];
|
|
89
|
+
if (segments.length === 0) {
|
|
90
|
+
return payload == null
|
|
91
|
+
? ''
|
|
92
|
+
: typeof payload === 'object'
|
|
93
|
+
? JSON.stringify(payload)
|
|
94
|
+
: String(payload);
|
|
95
|
+
}
|
|
96
|
+
return segments
|
|
97
|
+
.map((item) => {
|
|
98
|
+
if (typeof item === 'string')
|
|
99
|
+
return item;
|
|
100
|
+
const data = item.data ?? {};
|
|
101
|
+
switch (item.type) {
|
|
102
|
+
case 'text':
|
|
103
|
+
return String(data.text ?? data.content ?? '');
|
|
104
|
+
case 'at': {
|
|
105
|
+
const id = String(data.user_id ?? data.qq ?? data.id ?? '');
|
|
106
|
+
return id ? `<@!${id}>` : '';
|
|
107
|
+
}
|
|
108
|
+
case 'image': {
|
|
109
|
+
const src = String(data.url || data.file || '');
|
|
110
|
+
// base64/本地路径由 formatOutbound 走媒体上传,文本视图静默省略;
|
|
111
|
+
// 仅当彻底缺少来源时留痕,避免出站静默降级排障无头绪。
|
|
112
|
+
if (!src) {
|
|
113
|
+
logger.warn(formatCompact({
|
|
114
|
+
op: 'qq_outbound_image_dropped',
|
|
115
|
+
reason: 'missing_source',
|
|
116
|
+
}));
|
|
117
|
+
return '';
|
|
118
|
+
}
|
|
119
|
+
return /^https?:\/\//.test(src) ? src : '';
|
|
120
|
+
}
|
|
121
|
+
case 'reply':
|
|
122
|
+
return '';
|
|
123
|
+
default:
|
|
124
|
+
return data.text != null ? String(data.text) : '';
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.join('');
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Parse a compound messageId produced by `send()`: `kind-channelId:qqMsgId`.
|
|
132
|
+
*/
|
|
133
|
+
export function parseCompoundMessageId(messageId) {
|
|
134
|
+
const hyphen = messageId.indexOf('-');
|
|
135
|
+
if (hyphen <= 0)
|
|
136
|
+
throw new Error(`invalid QQ messageId format: ${messageId}`);
|
|
137
|
+
const kind = messageId.slice(0, hyphen);
|
|
138
|
+
if (kind !== 'private' && kind !== 'group' && kind !== 'channel' && kind !== 'direct') {
|
|
139
|
+
throw new Error(`invalid QQ messageId kind: ${kind}`);
|
|
140
|
+
}
|
|
141
|
+
const rest = messageId.slice(hyphen + 1);
|
|
142
|
+
const colon = rest.indexOf(':');
|
|
143
|
+
if (colon <= 0)
|
|
144
|
+
throw new Error(`invalid QQ messageId format: ${messageId}`);
|
|
145
|
+
return { kind, channelId: rest.slice(0, colon), qqMsgId: rest.slice(colon + 1) };
|
|
146
|
+
}
|
|
147
|
+
/** 从 QQ API SendResult / 审核回包中解析出站消息 ID */
|
|
148
|
+
export function resolveOutboundMessageId(result) {
|
|
149
|
+
if (!result || typeof result !== 'object') {
|
|
150
|
+
throw new Error('QQ 发送消息失败:响应为空');
|
|
151
|
+
}
|
|
152
|
+
const row = result;
|
|
153
|
+
const nested = row.data && typeof row.data === 'object'
|
|
154
|
+
? row.data
|
|
155
|
+
: undefined;
|
|
156
|
+
const audit = (row.message_audit ?? nested?.message_audit);
|
|
157
|
+
const id = row.id ?? row.message_id ?? audit?.audit_id;
|
|
158
|
+
if (id == null || id === '') {
|
|
159
|
+
const code = row.code;
|
|
160
|
+
const msg = row.message;
|
|
161
|
+
throw new Error(code != null
|
|
162
|
+
? `QQ 发送消息失败(${String(code)})${msg ? `: ${String(msg)}` : ''}`
|
|
163
|
+
: 'QQ 发送消息失败:响应缺少消息 ID');
|
|
164
|
+
}
|
|
165
|
+
return String(id);
|
|
166
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type QQBotEnv = 'production' | 'test';
|
|
2
|
+
export declare enum BindStatus {
|
|
3
|
+
NONE = 0,
|
|
4
|
+
PENDING = 1,
|
|
5
|
+
COMPLETED = 2,
|
|
6
|
+
EXPIRED = 3
|
|
7
|
+
}
|
|
8
|
+
export declare function getQQBotHost(env?: QQBotEnv): string;
|
|
9
|
+
export declare function generateBindKey(): string;
|
|
10
|
+
export declare function buildConnectUrl(taskId: string, source?: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* AES-256-GCM 解密 base64 密文。
|
|
13
|
+
* 密文格式: IV(12 bytes) + ciphertext + AuthTag(16 bytes)
|
|
14
|
+
*/
|
|
15
|
+
export declare function decryptSecret(encryptedBase64: string, keyBase64: string): string;
|
|
16
|
+
export interface BindTaskResult {
|
|
17
|
+
taskId: string;
|
|
18
|
+
key: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function createBindTask(env?: QQBotEnv, timeoutMs?: number): Promise<BindTaskResult>;
|
|
21
|
+
export interface PollBindResultOk {
|
|
22
|
+
status: BindStatus;
|
|
23
|
+
botAppId: string;
|
|
24
|
+
botEncryptSecret: string;
|
|
25
|
+
/** 扫码绑定开发者 QQ 的 OpenID(poll_bind_result 返回) */
|
|
26
|
+
userOpenId: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function parsePollBindResultData(data: {
|
|
29
|
+
status?: number;
|
|
30
|
+
bot_appid?: string | number;
|
|
31
|
+
bot_encrypt_secret?: string;
|
|
32
|
+
user_openid?: string;
|
|
33
|
+
userOpenid?: string;
|
|
34
|
+
openid?: string;
|
|
35
|
+
} | undefined): PollBindResultOk;
|
|
36
|
+
export declare function pollBindResult(taskId: string, env?: QQBotEnv, timeoutMs?: number): Promise<PollBindResultOk>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 扫码绑定协议(内联)
|
|
3
|
+
* Protocol aligned with @tencent-connect/qqbot-connector@1.1.0 qqbot-session — inlined to avoid runtime dependency.
|
|
4
|
+
*/
|
|
5
|
+
import { createDecipheriv, randomBytes } from 'node:crypto';
|
|
6
|
+
const HOSTS = {
|
|
7
|
+
production: 'q.qq.com',
|
|
8
|
+
test: 'test.q.qq.com',
|
|
9
|
+
};
|
|
10
|
+
export var BindStatus;
|
|
11
|
+
(function (BindStatus) {
|
|
12
|
+
BindStatus[BindStatus["NONE"] = 0] = "NONE";
|
|
13
|
+
BindStatus[BindStatus["PENDING"] = 1] = "PENDING";
|
|
14
|
+
BindStatus[BindStatus["COMPLETED"] = 2] = "COMPLETED";
|
|
15
|
+
BindStatus[BindStatus["EXPIRED"] = 3] = "EXPIRED";
|
|
16
|
+
})(BindStatus || (BindStatus = {}));
|
|
17
|
+
export function getQQBotHost(env = 'production') {
|
|
18
|
+
return HOSTS[env];
|
|
19
|
+
}
|
|
20
|
+
export function generateBindKey() {
|
|
21
|
+
return randomBytes(32).toString('base64');
|
|
22
|
+
}
|
|
23
|
+
export function buildConnectUrl(taskId, source = 'zhin') {
|
|
24
|
+
return `https://${getQQBotHost('production')}/qqbot/openclaw/connect.html?task_id=${encodeURIComponent(taskId)}&source=${encodeURIComponent(source)}&_wv=2`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* AES-256-GCM 解密 base64 密文。
|
|
28
|
+
* 密文格式: IV(12 bytes) + ciphertext + AuthTag(16 bytes)
|
|
29
|
+
*/
|
|
30
|
+
export function decryptSecret(encryptedBase64, keyBase64) {
|
|
31
|
+
const key = Buffer.from(keyBase64, 'base64');
|
|
32
|
+
const payload = Buffer.from(encryptedBase64, 'base64');
|
|
33
|
+
const iv = payload.subarray(0, 12);
|
|
34
|
+
const authTag = payload.subarray(payload.length - 16);
|
|
35
|
+
const ciphertext = payload.subarray(12, payload.length - 16);
|
|
36
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
37
|
+
decipher.setAuthTag(authTag);
|
|
38
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
39
|
+
}
|
|
40
|
+
async function postJson(url, body, timeoutMs) {
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(url, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
Accept: 'application/json',
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
throw new Error(`HTTP ${res.status} from ${url}`);
|
|
55
|
+
}
|
|
56
|
+
return (await res.json());
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function createBindTask(env = 'production', timeoutMs = 10_000) {
|
|
63
|
+
const url = `https://${getQQBotHost(env)}/lite/create_bind_task`;
|
|
64
|
+
const key = generateBindKey();
|
|
65
|
+
const res = await postJson(url, { key }, timeoutMs);
|
|
66
|
+
if (res.retcode !== 0) {
|
|
67
|
+
throw new Error(res.msg ?? 'create_bind_task failed');
|
|
68
|
+
}
|
|
69
|
+
if (!res.data?.task_id) {
|
|
70
|
+
throw new Error('create_bind_task: missing task_id');
|
|
71
|
+
}
|
|
72
|
+
return { taskId: res.data.task_id, key };
|
|
73
|
+
}
|
|
74
|
+
export function parsePollBindResultData(data) {
|
|
75
|
+
return {
|
|
76
|
+
status: (data?.status ?? BindStatus.NONE),
|
|
77
|
+
botAppId: String(data?.bot_appid ?? ''),
|
|
78
|
+
botEncryptSecret: data?.bot_encrypt_secret ?? '',
|
|
79
|
+
userOpenId: String(data?.user_openid ?? data?.userOpenid ?? data?.openid ?? ''),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export async function pollBindResult(taskId, env = 'production', timeoutMs = 10_000) {
|
|
83
|
+
const url = `https://${getQQBotHost(env)}/lite/poll_bind_result`;
|
|
84
|
+
const res = await postJson(url, { task_id: taskId }, timeoutMs);
|
|
85
|
+
if (res.retcode !== 0) {
|
|
86
|
+
throw new Error(res.msg ?? 'poll_bind_result failed');
|
|
87
|
+
}
|
|
88
|
+
return parsePollBindResultData(res.data);
|
|
89
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface QqBindCredentials {
|
|
2
|
+
appId: string;
|
|
3
|
+
appSecret: string;
|
|
4
|
+
userOpenId?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface QqBindCallbacks {
|
|
7
|
+
onSuccess: (credentials: QqBindCredentials[]) => void;
|
|
8
|
+
onFailure: (error: Error) => void;
|
|
9
|
+
onQrDisplayed?: (url: string) => void | Promise<void>;
|
|
10
|
+
onQrExpired?: () => void | Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface QqBindFlowOptions {
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
source?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 轮询等待扫码结果(回调风格)。返回 stop 函数可中止流程。
|
|
18
|
+
*/
|
|
19
|
+
export declare function startQqBindFlow(callbacks: QqBindCallbacks, options?: QqBindFlowOptions): () => void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 扫码绑定流程(内联,等价 qqbot-connector startQrConnect)
|
|
3
|
+
*/
|
|
4
|
+
import { BindStatus, buildConnectUrl, createBindTask, decryptSecret, pollBindResult, } from './qq-bind-api.js';
|
|
5
|
+
const POLL_INTERVAL_MS = 2000;
|
|
6
|
+
function sleep(ms, signal) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
if (signal?.aborted) {
|
|
9
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const timer = setTimeout(resolve, ms);
|
|
13
|
+
signal?.addEventListener('abort', () => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
16
|
+
}, { once: true });
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async function pollUntilResult(taskId, key, signal) {
|
|
20
|
+
while (!signal?.aborted) {
|
|
21
|
+
let result;
|
|
22
|
+
try {
|
|
23
|
+
result = await pollBindResult(taskId);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (result.status === BindStatus.COMPLETED) {
|
|
30
|
+
const appSecret = decryptSecret(result.botEncryptSecret, key);
|
|
31
|
+
return {
|
|
32
|
+
outcome: 'scanned',
|
|
33
|
+
appId: result.botAppId,
|
|
34
|
+
appSecret,
|
|
35
|
+
userOpenId: result.userOpenId,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (result.status === BindStatus.EXPIRED) {
|
|
39
|
+
return { outcome: 'expired' };
|
|
40
|
+
}
|
|
41
|
+
await sleep(POLL_INTERVAL_MS, signal);
|
|
42
|
+
}
|
|
43
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 轮询等待扫码结果(回调风格)。返回 stop 函数可中止流程。
|
|
47
|
+
*/
|
|
48
|
+
export function startQqBindFlow(callbacks, options = {}) {
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const signal = options.signal
|
|
51
|
+
? AbortSignal.any([controller.signal, options.signal])
|
|
52
|
+
: controller.signal;
|
|
53
|
+
void (async () => {
|
|
54
|
+
for (;;) {
|
|
55
|
+
if (signal.aborted) {
|
|
56
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
57
|
+
}
|
|
58
|
+
let task;
|
|
59
|
+
try {
|
|
60
|
+
task = await createBindTask();
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
throw new Error(`获取绑定任务失败: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
64
|
+
}
|
|
65
|
+
const connectUrl = buildConnectUrl(task.taskId, options.source ?? 'zhin');
|
|
66
|
+
await callbacks.onQrDisplayed?.(connectUrl);
|
|
67
|
+
const pollResult = await pollUntilResult(task.taskId, task.key, signal);
|
|
68
|
+
if (pollResult.outcome === 'scanned') {
|
|
69
|
+
callbacks.onSuccess([{
|
|
70
|
+
appId: pollResult.appId,
|
|
71
|
+
appSecret: pollResult.appSecret,
|
|
72
|
+
userOpenId: pollResult.userOpenId || undefined,
|
|
73
|
+
}]);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await callbacks.onQrExpired?.();
|
|
77
|
+
}
|
|
78
|
+
})().catch((err) => {
|
|
79
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
callbacks.onFailure(err instanceof Error ? err : new Error(String(err)));
|
|
83
|
+
});
|
|
84
|
+
return () => controller.abort();
|
|
85
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { resolveProjectRoot } from 'zhin.js/adapter';
|
|
2
|
+
export { resolveProjectRoot };
|
|
3
|
+
export interface QqCredentialEnvKeys {
|
|
4
|
+
appidKey: string;
|
|
5
|
+
secretKey: string;
|
|
6
|
+
appidRef: string;
|
|
7
|
+
secretRef: string;
|
|
8
|
+
}
|
|
9
|
+
/** 按 endpoint 名称生成唯一 env 键(如 `QQ_MY_BOT_APPID`) */
|
|
10
|
+
export declare function buildQqCredentialEnvKeys(endpointId: string): QqCredentialEnvKeys;
|
|
11
|
+
/** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
|
|
12
|
+
export declare function persistQqCredentialsToEnv(endpointId: string, appId: string, appSecret: string, projectRoot?: string): QqCredentialEnvKeys;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { buildEndpointEnvKey, persistEndpointEnvValues, resolveProjectRoot, } from 'zhin.js/adapter';
|
|
2
|
+
export { resolveProjectRoot };
|
|
3
|
+
/** 按 endpoint 名称生成唯一 env 键(如 `QQ_MY_BOT_APPID`) */
|
|
4
|
+
export function buildQqCredentialEnvKeys(endpointId) {
|
|
5
|
+
const appidKey = buildEndpointEnvKey('qq', endpointId, 'appid');
|
|
6
|
+
const secretKey = buildEndpointEnvKey('qq', endpointId, 'secret');
|
|
7
|
+
return {
|
|
8
|
+
appidKey,
|
|
9
|
+
secretKey,
|
|
10
|
+
appidRef: `\${${appidKey}}`,
|
|
11
|
+
secretRef: `\${${secretKey}}`,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
|
|
15
|
+
export function persistQqCredentialsToEnv(endpointId, appId, appSecret, projectRoot) {
|
|
16
|
+
const keys = buildQqCredentialEnvKeys(endpointId);
|
|
17
|
+
persistEndpointEnvValues({
|
|
18
|
+
[keys.appidKey]: appId,
|
|
19
|
+
[keys.secretKey]: appSecret,
|
|
20
|
+
}, projectRoot);
|
|
21
|
+
return keys;
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type QqBotKind } from './qq-intents.js';
|
|
2
|
+
/** 从命令/中间件入站消息提取会话键;无法识别时返回 undefined。 */
|
|
3
|
+
export declare function qqCommandSessionKey(input: unknown): string | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* 解析用户对公域/私域的回复。
|
|
6
|
+
* 接受:public / private / 公域 / 私域(大小写不敏感,可带前后空白)。
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseQqBotKindAnswer(raw: unknown): QqBotKind | undefined;
|
|
9
|
+
export declare const QQ_BOT_KIND_PROMPT: string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { parseQqBotKind } from './qq-intents.js';
|
|
2
|
+
/** 从命令/中间件入站消息提取会话键;无法识别时返回 undefined。 */
|
|
3
|
+
export function qqCommandSessionKey(input) {
|
|
4
|
+
const message = input;
|
|
5
|
+
const conversation = message?.conversation;
|
|
6
|
+
const adapter = conversation?.endpoint?.adapter;
|
|
7
|
+
const endpointKey = conversation?.endpoint?.id;
|
|
8
|
+
const kind = conversation?.kind;
|
|
9
|
+
const id = conversation?.id;
|
|
10
|
+
const sender = message?.sender;
|
|
11
|
+
if (adapter == null
|
|
12
|
+
|| endpointKey == null
|
|
13
|
+
|| (kind !== 'private' && kind !== 'group' && kind !== 'channel')
|
|
14
|
+
|| typeof id !== 'string'
|
|
15
|
+
|| !id
|
|
16
|
+
|| sender == null
|
|
17
|
+
|| String(sender).trim() === '') {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return `${String(adapter)}\0${String(endpointKey)}\0${kind}\0${id}\0${String(sender).trim()}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 解析用户对公域/私域的回复。
|
|
24
|
+
* 接受:public / private / 公域 / 私域(大小写不敏感,可带前后空白)。
|
|
25
|
+
*/
|
|
26
|
+
export function parseQqBotKindAnswer(raw) {
|
|
27
|
+
const text = String(raw ?? '').trim().toLowerCase();
|
|
28
|
+
if (!text)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (text === '公域' || text === '公开' || text === '1')
|
|
31
|
+
return 'public';
|
|
32
|
+
if (text === '私域' || text === '2')
|
|
33
|
+
return 'private';
|
|
34
|
+
return parseQqBotKind(text);
|
|
35
|
+
}
|
|
36
|
+
export const QQ_BOT_KIND_PROMPT = '请回复 public(公域)或 private(私域),确认后一次性写入 .env 与配置。\n'
|
|
37
|
+
+ '也可发 qq.endpoint cancel 取消。';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `qq.endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
|
|
3
|
+
* 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
|
|
4
|
+
* @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
|
|
5
|
+
* 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
|
|
6
|
+
* qqEndpointCommands 供 commands/endpoint/ 下的命令文件默认导出。
|
|
7
|
+
*/
|
|
8
|
+
import { type EndpointCommandReply } from 'zhin.js/adapter';
|
|
9
|
+
import { type QqBotKind } from './qq-intents.js';
|
|
10
|
+
import { type QqPendingBotKind, type QqRuntimeState } from './qq-runtime-state.js';
|
|
11
|
+
export type QqCommandReply = EndpointCommandReply;
|
|
12
|
+
/**
|
|
13
|
+
* endpoint 管理命令的操作者校验:实例配置声明了 master(顶层或任一端点项)时
|
|
14
|
+
* 仅 master 可执行 add/cancel/remove;未配置则放行(首个扫码绑定者即为 owner,
|
|
15
|
+
* legacy applyBindOwnership 会把 operator 写为新端点的 master)。
|
|
16
|
+
*/
|
|
17
|
+
export declare function isQqEndpointOperator(config: unknown, input: unknown): boolean;
|
|
18
|
+
export declare const QQ_ENDPOINT_FORBIDDEN: string;
|
|
19
|
+
/**
|
|
20
|
+
* 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractQqCommandReply(input: unknown): QqCommandReply;
|
|
23
|
+
/** `qq.endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
|
|
24
|
+
export declare function runQqEndpointList(state: QqRuntimeState, projectRoot?: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* 完成 pending 的公域/私域选择:一次性写 .env + yaml。
|
|
27
|
+
* @returns 成功文案;调用方负责清 pending。
|
|
28
|
+
*/
|
|
29
|
+
export declare function completeQqPendingBotKind(pending: QqPendingBotKind, botKind: QqBotKind, projectRoot?: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* `qq.endpoint add [id]`:启动扫码绑定流程。
|
|
32
|
+
* 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
|
|
33
|
+
* 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
|
|
34
|
+
* 扫码成功后凭据暂存内存并询问公域/私域;确认后一次性写 .env + yaml。
|
|
35
|
+
*/
|
|
36
|
+
export declare function runQqEndpointAdd(state: QqRuntimeState, id: string | undefined, reply: QqCommandReply, projectRoot?: string, input?: unknown): Promise<string>;
|
|
37
|
+
/** `qq.endpoint cancel`:中止进行中的绑定或待选 botKind */
|
|
38
|
+
export declare function runQqEndpointCancel(state: QqRuntimeState): string;
|
|
39
|
+
/** `qq.endpoint remove <id>`:从 zhin.config.yml 移除对应 endpoints 项 */
|
|
40
|
+
export declare function runQqEndpointRemove(_state: QqRuntimeState, id: string, projectRoot?: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* 通用套件生成的 QQ endpoint 命令(add 经 bindFlow 钩子走扫码绑定)。
|
|
43
|
+
* commands/endpoint/ 下的 list / add / remove 直接默认导出这三项;cancel 为 QQ 特化,单独定义。
|
|
44
|
+
*/
|
|
45
|
+
export declare const qqEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage, string | undefined>>>;
|