@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +940 -17
  2. package/PERMITS.md +25 -0
  3. package/README.md +135 -233
  4. package/adapters/qq/index.js +50 -0
  5. package/adapters/qq/index.ts +71 -0
  6. package/agents/qq/agent.json +20 -0
  7. package/agents/qq/boundaries.md +3 -0
  8. package/agents/qq/conventions.md +3 -0
  9. package/agents/qq/skills/qq/SKILL.md +22 -0
  10. package/agents/qq/skills/qq/tools/add_role/index.js +21 -0
  11. package/agents/qq/skills/qq/tools/add_role/index.ts +21 -0
  12. package/agents/qq/skills/qq/tools/channel_info/index.js +17 -0
  13. package/agents/qq/skills/qq/tools/channel_info/index.ts +17 -0
  14. package/agents/qq/skills/qq/tools/create_role/index.js +20 -0
  15. package/agents/qq/skills/qq/tools/create_role/index.ts +20 -0
  16. package/agents/qq/skills/qq/tools/list_channels/index.js +18 -0
  17. package/agents/qq/skills/qq/tools/list_channels/index.ts +18 -0
  18. package/agents/qq/skills/qq/tools/list_guilds/index.js +14 -0
  19. package/agents/qq/skills/qq/tools/list_guilds/index.ts +14 -0
  20. package/agents/qq/skills/qq/tools/list_roles/index.js +18 -0
  21. package/agents/qq/skills/qq/tools/list_roles/index.ts +18 -0
  22. package/agents/qq/skills/qq/tools/member_detail/index.js +18 -0
  23. package/agents/qq/skills/qq/tools/member_detail/index.ts +18 -0
  24. package/agents/qq/skills/qq/tools/remove_role/index.js +21 -0
  25. package/agents/qq/skills/qq/tools/remove_role/index.ts +21 -0
  26. package/agents/qq/system.md +3 -0
  27. package/commands/qq/endpoint/add/[[id]]/index.js +3 -0
  28. package/commands/qq/endpoint/add/[[id]]/index.ts +3 -0
  29. package/commands/qq/endpoint/cancel/index.js +11 -0
  30. package/commands/qq/endpoint/cancel/index.ts +15 -0
  31. package/commands/qq/endpoint/definition.js +188 -0
  32. package/commands/qq/endpoint/definition.ts +263 -0
  33. package/commands/qq/endpoint/list/index.js +3 -0
  34. package/commands/qq/endpoint/list/index.ts +3 -0
  35. package/commands/qq/endpoint/remove/[id]/index.js +3 -0
  36. package/commands/qq/endpoint/remove/[id]/index.ts +3 -0
  37. package/lib/client.d.ts +12 -0
  38. package/lib/client.js +2 -0
  39. package/lib/endpoint.d.ts +56 -0
  40. package/lib/endpoint.js +456 -0
  41. package/lib/index.d.ts +8 -18
  42. package/lib/index.js +8 -338
  43. package/lib/outbound.d.ts +17 -0
  44. package/lib/outbound.js +234 -0
  45. package/lib/platform-permit.d.ts +15 -0
  46. package/lib/platform-permit.js +41 -0
  47. package/lib/protocol.d.ts +90 -0
  48. package/lib/protocol.js +161 -0
  49. package/lib/qq-bind-api.d.ts +36 -0
  50. package/lib/qq-bind-api.js +89 -0
  51. package/lib/qq-bind-flow.d.ts +19 -0
  52. package/lib/qq-bind-flow.js +85 -0
  53. package/lib/qq-bot-kind-prompt.d.ts +9 -0
  54. package/lib/qq-bot-kind-prompt.js +37 -0
  55. package/lib/qq-intents.d.ts +27 -0
  56. package/lib/qq-intents.js +51 -0
  57. package/lib/qq-runtime-state.d.ts +30 -0
  58. package/lib/qq-runtime-state.js +14 -0
  59. package/lib/side-event-dispatch.d.ts +10 -0
  60. package/lib/side-event-dispatch.js +114 -0
  61. package/lib/webhook.d.ts +18 -0
  62. package/lib/webhook.js +103 -0
  63. package/lib/ws.d.ts +55 -0
  64. package/lib/ws.js +234 -0
  65. package/middlewares/bot-kind-reply/index.js +47 -0
  66. package/middlewares/bot-kind-reply/index.ts +65 -0
  67. package/package.json +82 -29
  68. package/plugin.js +18 -0
  69. package/schema.json +158 -0
  70. package/src/client.ts +16 -0
  71. package/src/endpoint.ts +534 -0
  72. package/src/index.ts +66 -326
  73. package/src/outbound.ts +255 -0
  74. package/src/platform-permit.ts +56 -0
  75. package/src/protocol.ts +249 -0
  76. package/src/qq-bind-api.ts +145 -0
  77. package/src/qq-bind-flow.ts +127 -0
  78. package/src/qq-bot-kind-prompt.ts +47 -0
  79. package/src/qq-intents.ts +77 -0
  80. package/src/qq-runtime-state.ts +51 -0
  81. package/src/side-event-dispatch.ts +140 -0
  82. package/src/webhook.ts +132 -0
  83. package/src/ws.ts +331 -0
  84. package/client/Dashboard.tsx +0 -230
  85. package/client/index.tsx +0 -11
  86. package/client/tsconfig.json +0 -7
  87. package/client/utils/api.ts +0 -17
  88. package/dist/index.js +0 -30
  89. package/lib/adapter.d.ts +0 -17
  90. package/lib/adapter.d.ts.map +0 -1
  91. package/lib/adapter.js +0 -49
  92. package/lib/adapter.js.map +0 -1
  93. package/lib/bot.d.ts +0 -102
  94. package/lib/bot.d.ts.map +0 -1
  95. package/lib/bot.js +0 -337
  96. package/lib/bot.js.map +0 -1
  97. package/lib/index.d.ts.map +0 -1
  98. package/lib/index.js.map +0 -1
  99. package/lib/types.d.ts +0 -16
  100. package/lib/types.d.ts.map +0 -1
  101. package/lib/types.js +0 -2
  102. package/lib/types.js.map +0 -1
  103. package/plugin.yml +0 -3
  104. package/skills/qq/SKILL.md +0 -97
  105. package/src/adapter.ts +0 -58
  106. package/src/bot.ts +0 -347
  107. package/src/types.ts +0 -24
@@ -0,0 +1,90 @@
1
+ /** QQ Official Bot protocol helpers. Canonicalization is owned by Core. */
2
+ import type { ConversationRef } from '@zhin.js/im-contract';
3
+ /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
4
+ export interface QqEndpointConfig {
5
+ readonly id: string;
6
+ readonly appid: string;
7
+ readonly secret: string;
8
+ /** Default `websocket`. `webhook` / `middleware` use httpHostToken POST. */
9
+ readonly mode?: 'websocket' | 'webhook' | 'middleware';
10
+ readonly sandbox?: boolean;
11
+ /** Explicit intents; when omitted, derived from `botKind` (default public). */
12
+ readonly intents?: readonly string[];
13
+ /** public | private — drives default intents when `intents` omitted. */
14
+ readonly botKind?: string;
15
+ readonly accessTokenUrl?: string;
16
+ readonly gatewayUrl?: string;
17
+ readonly webhookPath?: string;
18
+ }
19
+ export interface ResolvedQqWebsocketConfig {
20
+ readonly context: 'qq';
21
+ readonly mode: 'websocket';
22
+ readonly id: string;
23
+ readonly appid: string;
24
+ readonly secret: string;
25
+ readonly sandbox: boolean;
26
+ readonly intents?: readonly string[];
27
+ readonly accessTokenUrl?: string;
28
+ readonly gatewayUrl?: string;
29
+ }
30
+ export interface ResolvedQqHttpConfig {
31
+ readonly context: 'qq';
32
+ readonly mode: 'webhook' | 'middleware';
33
+ readonly id: string;
34
+ readonly appid: string;
35
+ readonly secret: string;
36
+ readonly webhookPath: string;
37
+ readonly sandbox: boolean;
38
+ }
39
+ export type ResolvedQqConfig = ResolvedQqWebsocketConfig | ResolvedQqHttpConfig;
40
+ export type QqChannelKind = 'private' | 'group' | 'channel' | 'direct';
41
+ export interface QqInboundMessage {
42
+ readonly id: string;
43
+ readonly content: string;
44
+ readonly channelKind: QqChannelKind;
45
+ readonly channelId: string;
46
+ readonly authorId: string;
47
+ readonly authorName: string;
48
+ readonly authorRoles?: string[];
49
+ readonly timestamp: number;
50
+ readonly guildId?: string;
51
+ readonly rawMessage?: string;
52
+ /** canonical 入站段(text/mention/face/image/audio/video/file/reply),与 content 同源 */
53
+ readonly segments?: readonly import('@zhin.js/core').Segment[];
54
+ /**
55
+ * 入站是否 @ 当前机器人:统一按 mentions 判定。群场景只认 is_you(@ 另一个
56
+ * 机器人时 bot:true 但无 is_you,不误判);bot 回退仅限频道 AT 事件。
57
+ * 群 GROUP_MESSAGE_CREATE 的非 @ 消息不置位。新 Runtime 纯文本 content 需经
58
+ * metadata 传递。
59
+ */
60
+ readonly mentioned?: boolean;
61
+ }
62
+ export interface QqWireSegment {
63
+ readonly type: string;
64
+ readonly data?: Record<string, unknown>;
65
+ }
66
+ export declare function resolveQqConfig(config: QqEndpointConfig): ResolvedQqConfig;
67
+ /**
68
+ * 入站归一化 → ConversationRef:`direct`(频道私信)映射为 guild 容器内的
69
+ * private 会话;频道消息的所属 guild 进 `parent`。
70
+ */
71
+ export declare function qqInboundConversation(endpointKey: string, msg: QqInboundMessage): ConversationRef;
72
+ /** 出站:guild 容器内的 private 会话即 QQ `direct`(频道私信)。 */
73
+ export declare function qqOutboundKind(conversation: ConversationRef): QqChannelKind;
74
+ export declare function senderDisplayName(msg: QqInboundMessage): string;
75
+ export declare function formatInboundContent(msg: QqInboundMessage): string;
76
+ /**
77
+ * Wire-encode an already-rendered outbound payload into a plain text / QQ Sendable string.
78
+ * Segment canonicalization is intentionally not done here.
79
+ */
80
+ export declare function formatOutboundText(payload: unknown): string;
81
+ /**
82
+ * Parse a compound messageId produced by `send()`: `kind-channelId:qqMsgId`.
83
+ */
84
+ export declare function parseCompoundMessageId(messageId: string): {
85
+ kind: QqChannelKind;
86
+ channelId: string;
87
+ qqMsgId: string;
88
+ };
89
+ /** 从 QQ API SendResult / 审核回包中解析出站消息 ID */
90
+ export declare function resolveOutboundMessageId(result: unknown): string;
@@ -0,0 +1,161 @@
1
+ /** QQ Official Bot protocol helpers. Canonicalization is owned by Core. */
2
+ import { formatCompact, getLogger } from '@zhin.js/logger';
3
+ import { resolveQqIntents } from './qq-intents.js';
4
+ const logger = getLogger('qq');
5
+ export function resolveQqConfig(config) {
6
+ const id = requiredEndpointField(config.id, 'id');
7
+ const appid = requiredEndpointField(config.appid, 'appid');
8
+ const secret = requiredEndpointField(config.secret, 'secret');
9
+ const mode = config.mode ?? 'websocket';
10
+ if (mode === 'webhook' || mode === 'middleware') {
11
+ const webhookPathRaw = config.webhookPath ?? '/qq/webhook';
12
+ const webhookPath = webhookPathRaw.startsWith('/') ? webhookPathRaw : `/${webhookPathRaw}`;
13
+ return {
14
+ context: 'qq',
15
+ mode,
16
+ id,
17
+ appid,
18
+ secret,
19
+ webhookPath,
20
+ sandbox: config.sandbox === true,
21
+ };
22
+ }
23
+ return {
24
+ context: 'qq',
25
+ mode: 'websocket',
26
+ id,
27
+ appid,
28
+ secret,
29
+ sandbox: config.sandbox === true,
30
+ intents: resolveQqIntents({
31
+ intents: config.intents,
32
+ botKind: config.botKind,
33
+ }),
34
+ accessTokenUrl: config.accessTokenUrl,
35
+ gatewayUrl: config.gatewayUrl,
36
+ };
37
+ }
38
+ function requiredEndpointField(value, field) {
39
+ if (typeof value !== 'string' || !value.trim()) {
40
+ throw new TypeError(`QQ endpoint requires a non-empty ${field}`);
41
+ }
42
+ return value.trim();
43
+ }
44
+ /**
45
+ * 入站归一化 → ConversationRef:`direct`(频道私信)映射为 guild 容器内的
46
+ * private 会话;频道消息的所属 guild 进 `parent`。
47
+ */
48
+ export function qqInboundConversation(endpointKey, msg) {
49
+ const kind = msg.channelKind === 'direct' ? 'private' : msg.channelKind;
50
+ return {
51
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
52
+ kind,
53
+ id: msg.channelId,
54
+ ...(msg.guildId && (msg.channelKind === 'channel' || msg.channelKind === 'direct')
55
+ ? { parent: { kind: 'channel', id: msg.guildId } }
56
+ : {}),
57
+ };
58
+ }
59
+ /** 出站:guild 容器内的 private 会话即 QQ `direct`(频道私信)。 */
60
+ export function qqOutboundKind(conversation) {
61
+ if (conversation.kind === 'private' && conversation.parent?.kind === 'channel')
62
+ return 'direct';
63
+ return conversation.kind;
64
+ }
65
+ export function senderDisplayName(msg) {
66
+ return msg.authorName || msg.authorId;
67
+ }
68
+ export function formatInboundContent(msg) {
69
+ const text = (msg.content || msg.rawMessage || '').trim();
70
+ return text || '(Empty message)';
71
+ }
72
+ /**
73
+ * Wire-encode an already-rendered outbound payload into a plain text / QQ Sendable string.
74
+ * Segment canonicalization is intentionally not done here.
75
+ */
76
+ export function formatOutboundText(payload) {
77
+ if (typeof payload === 'string')
78
+ return payload;
79
+ const segments = Array.isArray(payload)
80
+ ? payload
81
+ : payload && typeof payload === 'object' && 'type' in payload
82
+ ? [payload]
83
+ : [];
84
+ if (segments.length === 0) {
85
+ return payload == null
86
+ ? ''
87
+ : typeof payload === 'object'
88
+ ? JSON.stringify(payload)
89
+ : String(payload);
90
+ }
91
+ return segments
92
+ .map((item) => {
93
+ if (typeof item === 'string')
94
+ return item;
95
+ const data = item.data ?? {};
96
+ switch (item.type) {
97
+ case 'text':
98
+ return String(data.text ?? data.content ?? '');
99
+ case 'at': {
100
+ const id = String(data.user_id ?? data.qq ?? data.id ?? '');
101
+ return id ? `<@!${id}>` : '';
102
+ }
103
+ case 'image': {
104
+ const src = String(data.url || data.file || '');
105
+ // base64/本地路径由 formatOutbound 走媒体上传,文本视图静默省略;
106
+ // 仅当彻底缺少来源时留痕,避免出站静默降级排障无头绪。
107
+ if (!src) {
108
+ logger.warn(formatCompact({
109
+ op: 'qq_outbound_image_dropped',
110
+ reason: 'missing_source',
111
+ }));
112
+ return '';
113
+ }
114
+ return /^https?:\/\//.test(src) ? src : '';
115
+ }
116
+ case 'reply':
117
+ return '';
118
+ default:
119
+ return data.text != null ? String(data.text) : '';
120
+ }
121
+ })
122
+ .filter(Boolean)
123
+ .join('');
124
+ }
125
+ /**
126
+ * Parse a compound messageId produced by `send()`: `kind-channelId:qqMsgId`.
127
+ */
128
+ export function parseCompoundMessageId(messageId) {
129
+ const hyphen = messageId.indexOf('-');
130
+ if (hyphen <= 0)
131
+ throw new Error(`invalid QQ messageId format: ${messageId}`);
132
+ const kind = messageId.slice(0, hyphen);
133
+ if (kind !== 'private' && kind !== 'group' && kind !== 'channel' && kind !== 'direct') {
134
+ throw new Error(`invalid QQ messageId kind: ${kind}`);
135
+ }
136
+ const rest = messageId.slice(hyphen + 1);
137
+ const colon = rest.indexOf(':');
138
+ if (colon <= 0)
139
+ throw new Error(`invalid QQ messageId format: ${messageId}`);
140
+ return { kind, channelId: rest.slice(0, colon), qqMsgId: rest.slice(colon + 1) };
141
+ }
142
+ /** 从 QQ API SendResult / 审核回包中解析出站消息 ID */
143
+ export function resolveOutboundMessageId(result) {
144
+ if (!result || typeof result !== 'object') {
145
+ throw new Error('QQ 发送消息失败:响应为空');
146
+ }
147
+ const row = result;
148
+ const nested = row.data && typeof row.data === 'object'
149
+ ? row.data
150
+ : undefined;
151
+ const audit = (row.message_audit ?? nested?.message_audit);
152
+ const id = row.id ?? row.message_id ?? audit?.audit_id;
153
+ if (id == null || id === '') {
154
+ const code = row.code;
155
+ const msg = row.message;
156
+ throw new Error(code != null
157
+ ? `QQ 发送消息失败(${String(code)})${msg ? `: ${String(msg)}` : ''}`
158
+ : 'QQ 发送消息失败:响应缺少消息 ID');
159
+ }
160
+ return String(id);
161
+ }
@@ -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,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,27 @@
1
+ /**
2
+ * QQ 官方机器人 intents 与公域/私域映射。
3
+ *
4
+ * 群聊无公/私域之分,两类都订阅 `GROUP_AND_C2C_EVENT`。
5
+ * 频道消息 intent 不同:
6
+ * - public(公域):`PUBLIC_GUILD_MESSAGES`(仅 @ 机器人)
7
+ * - private(私域):`GUILD_MESSAGES`(频道全量消息;公域订阅会 Identify 失败)
8
+ */
9
+ export type QqBotKind = 'public' | 'private';
10
+ export type QqIntent = 'GUILDS' | 'GUILD_MEMBERS' | 'GUILD_MESSAGES' | 'GUILD_MESSAGE_REACTIONS' | 'DIRECT_MESSAGE' | 'GROUP_AND_C2C_EVENT' | 'INTERACTION' | 'MESSAGE_AUDIT' | 'FORUMS_EVENT' | 'AUDIO_ACTION' | 'PUBLIC_GUILD_MESSAGES' | 'GROUP_MEMBER';
11
+ /** 扫码绑定 / 未声明 botKind 时的默认(公域更常见)。 */
12
+ export declare const DEFAULT_QQ_BOT_KIND: QqBotKind;
13
+ export declare const QQ_INTENTS_BY_KIND: Readonly<Record<QqBotKind, readonly QqIntent[]>>;
14
+ /** 解析 botKind:仅接受 `public` / `private`。 */
15
+ export declare function parseQqBotKind(value: unknown): QqBotKind | undefined;
16
+ /**
17
+ * 解析最终 intents:显式 `intents` 优先;否则按 `botKind`(默认 public)展开。
18
+ */
19
+ export declare function resolveQqIntents(input: {
20
+ readonly intents?: readonly string[] | undefined;
21
+ readonly botKind?: unknown;
22
+ }): readonly QqIntent[];
23
+ /** 添加 endpoint 时写入配置的默认字段(显式展开,便于用户改)。 */
24
+ export declare function defaultQqEndpointIntentFields(botKind?: QqBotKind): {
25
+ readonly botKind: QqBotKind;
26
+ readonly intents: readonly QqIntent[];
27
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * QQ 官方机器人 intents 与公域/私域映射。
3
+ *
4
+ * 群聊无公/私域之分,两类都订阅 `GROUP_AND_C2C_EVENT`。
5
+ * 频道消息 intent 不同:
6
+ * - public(公域):`PUBLIC_GUILD_MESSAGES`(仅 @ 机器人)
7
+ * - private(私域):`GUILD_MESSAGES`(频道全量消息;公域订阅会 Identify 失败)
8
+ */
9
+ /** 扫码绑定 / 未声明 botKind 时的默认(公域更常见)。 */
10
+ export const DEFAULT_QQ_BOT_KIND = 'public';
11
+ /** 公/私域共用:群/C2C、群成员变更、频道基础事件、频道私信。 */
12
+ const QQ_SHARED_INTENTS = Object.freeze([
13
+ 'GROUP_AND_C2C_EVENT',
14
+ 'GROUP_MEMBER',
15
+ 'GUILDS',
16
+ 'GUILD_MEMBERS',
17
+ 'DIRECT_MESSAGE',
18
+ ]);
19
+ export const QQ_INTENTS_BY_KIND = Object.freeze({
20
+ public: Object.freeze([
21
+ ...QQ_SHARED_INTENTS,
22
+ 'PUBLIC_GUILD_MESSAGES',
23
+ ]),
24
+ private: Object.freeze([
25
+ ...QQ_SHARED_INTENTS,
26
+ 'GUILD_MESSAGES',
27
+ ]),
28
+ });
29
+ /** 解析 botKind:仅接受 `public` / `private`。 */
30
+ export function parseQqBotKind(value) {
31
+ if (value === 'public' || value === 'private')
32
+ return value;
33
+ return undefined;
34
+ }
35
+ /**
36
+ * 解析最终 intents:显式 `intents` 优先;否则按 `botKind`(默认 public)展开。
37
+ */
38
+ export function resolveQqIntents(input) {
39
+ if (Array.isArray(input.intents) && input.intents.length > 0) {
40
+ return Object.freeze(input.intents.map(String));
41
+ }
42
+ const kind = parseQqBotKind(input.botKind) ?? DEFAULT_QQ_BOT_KIND;
43
+ return QQ_INTENTS_BY_KIND[kind];
44
+ }
45
+ /** 添加 endpoint 时写入配置的默认字段(显式展开,便于用户改)。 */
46
+ export function defaultQqEndpointIntentFields(botKind = DEFAULT_QQ_BOT_KIND) {
47
+ return Object.freeze({
48
+ botKind,
49
+ intents: QQ_INTENTS_BY_KIND[botKind],
50
+ });
51
+ }
@@ -0,0 +1,30 @@
1
+ export interface QqRunningEndpoint {
2
+ id: string;
3
+ mode: string;
4
+ }
5
+ export interface QqBindFlowHandle {
6
+ /** 绑定期望的 endpoint id(未指定时为 undefined,成功后取 appId) */
7
+ id?: string;
8
+ stop: () => void;
9
+ }
10
+ /**
11
+ * 扫码成功后暂存在内存:确认公域/私域后再一次性写 .env + yaml。
12
+ * (提前写 .env 会触发 HMR,冲掉 pending。)
13
+ */
14
+ export interface QqPendingBotKind {
15
+ readonly endpointId: string;
16
+ readonly appId: string;
17
+ readonly appSecret: string;
18
+ /** 发起 add 的会话键;仅同会话回复可完成选择 */
19
+ readonly sessionKey: string;
20
+ }
21
+ export interface QqRuntimeState {
22
+ /** 当前 generation 已成功创建的 endpoint(id → 描述) */
23
+ readonly endpoints: Map<string, QqRunningEndpoint>;
24
+ /** 全局单例:同一时间只允许一个扫码绑定流程 */
25
+ bindFlow: QqBindFlowHandle | null;
26
+ /** 扫码完成后等待 botKind 选择(凭据仅在内存) */
27
+ pendingBotKind: QqPendingBotKind | null;
28
+ }
29
+ export declare function createQqRuntimeState(): QqRuntimeState;
30
+ export declare const qqRuntimeStateToken: import("zhin.js").Token<QqRuntimeState>;
@@ -0,0 +1,14 @@
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
+ export function createQqRuntimeState() {
8
+ return {
9
+ endpoints: new Map(),
10
+ bindFlow: null,
11
+ pendingBotKind: null,
12
+ };
13
+ }
14
+ export const qqRuntimeStateToken = createToken('zhin.qq.runtime-state', 'QQ adapter runtime state (running endpoints + bind flow singleton)');
@@ -0,0 +1,10 @@
1
+ import type { EndpointEventEmitter } from 'zhin.js/adapter';
2
+ import { type getAdapterLogger } from '@zhin.js/logger';
3
+ import type { ApproveJoinRequestOptions } from 'qq-official-bot';
4
+ export interface QqSideEventCaller {
5
+ approveGroupJoinRequest?(groupId: string, userId: string, options: ApproveJoinRequestOptions): Promise<unknown>;
6
+ }
7
+ export declare function bindQqBotSideEvents(bot: {
8
+ on(event: string, listener: (...args: unknown[]) => void): void;
9
+ }, dispatch: (eventName: string, raw: unknown) => void): void;
10
+ export declare function receiveQqSideEvent(emit: EndpointEventEmitter, endpointKey: string, caller: QqSideEventCaller, eventName: string, raw: unknown, logger: ReturnType<typeof getAdapterLogger>): void;