@zhin.js/adapter-icqq 8.0.12 → 8.0.14

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.
@@ -0,0 +1,109 @@
1
+ import { formatCompact } from '@zhin.js/logger';
2
+ /**
3
+ * Drive ICQQ login challenges through LoginAssist (refresh-safe pending tasks).
4
+ * Mirrors the classic stdin flow: qrcode/auth → login(); slider → submitSlider;
5
+ * device → sendSmsCode then submitSmsCode.
6
+ */
7
+ export async function runIcqqLoginAssistStep(input) {
8
+ const { assist, client, adapter, endpointKey, owner, logger, step, event } = input;
9
+ assist.cancelOwned(owner, `replaced_by_${step}`);
10
+ const payload = buildPayload(step, event);
11
+ logger.info(formatCompact({
12
+ op: 'login_assist_pending',
13
+ endpoint: endpointKey,
14
+ type: step,
15
+ message: payload.message,
16
+ ...(typeof payload.url === 'string' ? { url: payload.url } : {}),
17
+ }));
18
+ try {
19
+ if (step === 'device') {
20
+ try {
21
+ await client.sendSmsCode();
22
+ }
23
+ catch (error) {
24
+ logger.warn(formatCompact({
25
+ op: 'login_assist_send_sms_failed',
26
+ endpoint: endpointKey,
27
+ error: error instanceof Error ? error.message : String(error),
28
+ }));
29
+ }
30
+ }
31
+ const value = await assist.waitForInput(adapter, endpointKey, step, payload, { owner });
32
+ const text = normalizeSubmitValue(value);
33
+ switch (step) {
34
+ case 'qrcode':
35
+ case 'auth':
36
+ await client.login();
37
+ break;
38
+ case 'slider':
39
+ await client.submitSlider(text);
40
+ break;
41
+ case 'device':
42
+ case 'sms':
43
+ await client.submitSmsCode(text);
44
+ break;
45
+ default:
46
+ await client.login();
47
+ break;
48
+ }
49
+ }
50
+ catch (error) {
51
+ logger.warn(formatCompact({
52
+ op: 'login_assist_failed',
53
+ endpoint: endpointKey,
54
+ type: step,
55
+ error: error instanceof Error ? error.message : String(error),
56
+ }));
57
+ }
58
+ }
59
+ function buildPayload(step, event) {
60
+ const raw = event && typeof event === 'object' ? event : {};
61
+ switch (step) {
62
+ case 'qrcode':
63
+ return {
64
+ message: '扫码登录:请在手机 QQ 扫描二维码,完成后在 Console / 终端确认继续',
65
+ image: bufferToDataUrl(raw.image),
66
+ };
67
+ case 'slider':
68
+ return {
69
+ message: '滑动验证:打开链接完成滑块,将返回的 ticket(可含 randstr)提交继续',
70
+ url: typeof raw.url === 'string' ? raw.url : undefined,
71
+ };
72
+ case 'device':
73
+ return {
74
+ message: '设备锁:已请求短信验证码,请提交短信验证码继续',
75
+ url: typeof raw.url === 'string' ? raw.url : undefined,
76
+ phone: raw.phone,
77
+ };
78
+ case 'auth':
79
+ return {
80
+ message: '身份验证:完成后在 Console / 终端确认继续',
81
+ url: typeof raw.url === 'string' ? raw.url : undefined,
82
+ device: raw.device,
83
+ };
84
+ default:
85
+ return { message: `登录辅助(${step})` };
86
+ }
87
+ }
88
+ function normalizeSubmitValue(value) {
89
+ if (typeof value === 'string')
90
+ return value.trim();
91
+ if (typeof value.ticket === 'string')
92
+ return value.ticket.trim();
93
+ if (typeof value.code === 'string')
94
+ return value.code.trim();
95
+ if (typeof value.value === 'string')
96
+ return value.value.trim();
97
+ return JSON.stringify(value);
98
+ }
99
+ function bufferToDataUrl(image) {
100
+ if (Buffer.isBuffer(image)) {
101
+ return `data:image/png;base64,${image.toString('base64')}`;
102
+ }
103
+ if (image instanceof Uint8Array) {
104
+ return `data:image/png;base64,${Buffer.from(image).toString('base64')}`;
105
+ }
106
+ if (typeof image === 'string' && image.trim())
107
+ return image.trim();
108
+ return undefined;
109
+ }
package/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Actions, formatInboundContent, formatOutboundBody, icqqInboundConversation, icqqOutboundTarget, type ActionResult, type IcqqAdapterConfig, type IcqqInboundMessage, type IcqqWireSegment, type ParsedIcqqSendTarget, type ResolvedIcqqConfig, } from './protocol.js';
2
- export { IcqqEndpoint, type IcqqEndpointOptions, type IcqqInboxHooks, } from './endpoint.js';
2
+ export { IcqqEndpoint, type IcqqEndpointOptions, } from './endpoint.js';
3
3
  export * from './types.js';
4
4
  export { getIcqqAgentDeps, registerIcqqAgentEndpoint, setIcqqAgentDeps, type IcqqAgentDeps, type IcqqAgentEndpoint, } from './icqq-agent-deps.js';
5
5
  export { resolveIcqqInboundMessageId, } from './icqq-inbound.js';
@@ -5,6 +5,10 @@ export declare function resolveIcqqOutboundMediaMode(config: Pick<IcqqEndpointCo
5
5
  /**
6
6
  * file 模式:将 canonical MediaRef(`data.media`,kind=base64)物化为本机路径,
7
7
  * 并把 media 改写为 path 引用(icqq 客户端直接读盘)。
8
- * base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64://)。
8
+ * base64 模式:不改动(由 native Sendable projection 生成 base64:// file)。
9
9
  */
10
- export declare function materializeOutboundBase64(content: SendContent, mode?: IcqqOutboundMediaMode): SendContent;
10
+ export interface PreparedIcqqOutboundMedia {
11
+ readonly content: SendContent;
12
+ dispose(): void;
13
+ }
14
+ export declare function prepareIcqqOutboundMedia(content: SendContent, mode?: IcqqOutboundMediaMode): PreparedIcqqOutboundMedia;
@@ -29,19 +29,18 @@ export function resolveIcqqOutboundMediaMode(config) {
29
29
  }
30
30
  return "file";
31
31
  }
32
- /**
33
- * file 模式:将 canonical MediaRef(`data.media`,kind=base64)物化为本机路径,
34
- * 并把 media 改写为 path 引用(icqq 客户端直接读盘)。
35
- * base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64://)。
36
- */
37
- export function materializeOutboundBase64(content, mode = "file") {
32
+ export function prepareIcqqOutboundMedia(content, mode = "file") {
38
33
  if (mode === "base64")
39
- return content;
34
+ return { content, dispose() { } };
35
+ const createdFiles = [];
36
+ const inputWasArray = Array.isArray(content);
40
37
  const segments = Array.isArray(content) ? content : [content];
41
- return segments.map((seg) => {
38
+ const materialized = segments.map((seg) => {
42
39
  if (typeof seg === "string")
43
40
  return seg;
44
41
  const { type, data } = seg;
42
+ if (!data || typeof data !== "object")
43
+ return seg;
45
44
  const d = data;
46
45
  const media = d.media;
47
46
  if (!isMediaRef(media) || media.kind !== "base64")
@@ -54,6 +53,7 @@ export function materializeOutboundBase64(content, mode = "file") {
54
53
  }
55
54
  const mime = media.mime_type ?? "";
56
55
  const filePath = spoolBase64ToFile(b64, mime || (type === "image" ? "image/jpeg" : type === "video" ? "video/mp4" : "audio/mpeg"), type === "record" || type === "audio" ? "audio" : type);
56
+ createdFiles.push(filePath);
57
57
  return {
58
58
  type,
59
59
  data: {
@@ -66,4 +66,11 @@ export function materializeOutboundBase64(content, mode = "file") {
66
66
  },
67
67
  };
68
68
  });
69
+ return {
70
+ content: inputWasArray ? materialized : materialized[0],
71
+ dispose() {
72
+ for (const filePath of createdFiles)
73
+ fs.rmSync(filePath, { force: true });
74
+ },
75
+ };
69
76
  }
package/lib/protocol.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Sendable } from '@icqqjs/icqq';
1
2
  import type { ConversationRef } from '@zhin.js/im-contract';
2
3
  import type { MessageSegment } from "zhin.js";
3
4
  export type ActionResult = {
@@ -231,6 +232,9 @@ export interface IcqqInboundMessage {
231
232
  };
232
233
  readonly channelType: 'private' | 'group' | 'channel';
233
234
  readonly metadata?: Record<string, unknown>;
235
+ readonly replyTo?: {
236
+ readonly id: string;
237
+ };
234
238
  }
235
239
  export type ParsedIcqqSendTarget = {
236
240
  readonly kind: 'private';
@@ -263,5 +267,10 @@ export declare function icqqInboundConversation(endpointKey: string, input: {
263
267
  * 群容器(parent kind 'group')内的 private 会话即 ICQQ 群临时会话(temp)。
264
268
  */
265
269
  export declare function icqqOutboundTarget(conversation: ConversationRef): ParsedIcqqSendTarget;
266
- export declare function formatOutboundBody(payload: unknown): string;
270
+ /**
271
+ * Project canonical Zhin segments directly into the native ICQQ Sendable
272
+ * algebra. ICQQ's direct client does not parse Zhin's historical `[image:x]`
273
+ * notation; passing that notation would send it as literal text.
274
+ */
275
+ export declare function formatOutboundBody(payload: unknown): Sendable;
267
276
  export declare function formatInboundContent(rawMessage: string): string;
package/lib/protocol.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { pickCredential } from 'zhin.js/adapter';
2
- import { resolveCqMediaArg } from "./cq-message.js";
2
+ import { resolveIcqqMediaFile } from "./cq-message.js";
3
3
  export { resolveIcqqInboundMessageId } from "./icqq-inbound.js";
4
4
  /**
5
5
  * 所有操作常量(@icqqjs/icqq)
@@ -257,6 +257,18 @@ export function icqqOutboundTarget(conversation) {
257
257
  }
258
258
  return { kind: 'channel', guildId, channelId: conversation.id };
259
259
  }
260
+ const ICQQ_STANDALONE_ELEMENTS = new Set([
261
+ 'record',
262
+ 'video',
263
+ 'file',
264
+ 'json',
265
+ 'long_msg',
266
+ ]);
267
+ /**
268
+ * Project canonical Zhin segments directly into the native ICQQ Sendable
269
+ * algebra. ICQQ's direct client does not parse Zhin's historical `[image:x]`
270
+ * notation; passing that notation would send it as literal text.
271
+ */
260
272
  export function formatOutboundBody(payload) {
261
273
  if (typeof payload === 'string') {
262
274
  const text = payload.trim();
@@ -276,7 +288,7 @@ export function formatOutboundBody(payload) {
276
288
  const text = String(payload).trim();
277
289
  return text || '\u200b';
278
290
  }
279
- const joined = payload.map((seg) => {
291
+ const elements = payload.flatMap((seg) => {
280
292
  if (typeof seg === 'string')
281
293
  return seg;
282
294
  const item = seg;
@@ -284,32 +296,82 @@ export function formatOutboundBody(payload) {
284
296
  case 'text':
285
297
  return String(item.data?.text ?? '');
286
298
  case 'at':
287
- case 'mention':
288
- return `[at:${item.data?.qq ?? item.data?.id ?? item.data?.target ?? ''}]`;
299
+ case 'mention': {
300
+ const target = item.data?.qq ?? item.data?.id ?? item.data?.target;
301
+ if (target === 'all')
302
+ return { type: 'at', qq: 'all' };
303
+ const qq = Number(target);
304
+ if (!Number.isSafeInteger(qq) || qq <= 0) {
305
+ throw new TypeError(`Invalid ICQQ mention target: ${String(target ?? '')}`);
306
+ }
307
+ return { type: 'at', qq };
308
+ }
289
309
  case 'image': {
290
- const arg = resolveCqMediaArg('image', item.data);
291
- return arg ? `[image:${arg}]` : '';
310
+ const arg = resolveIcqqMediaFile(item.data);
311
+ if (!arg)
312
+ throw new TypeError('ICQQ image segment requires canonical data.media');
313
+ return { type: 'image', file: arg };
314
+ }
315
+ case 'face': {
316
+ const id = Number(item.data?.id);
317
+ if (!Number.isSafeInteger(id) || id < 0) {
318
+ throw new TypeError(`Invalid ICQQ face id: ${String(item.data?.id ?? '')}`);
319
+ }
320
+ return { type: 'face', id };
321
+ }
322
+ case 'reply': {
323
+ const id = String(item.data?.message_id ?? item.data?.id ?? '').trim();
324
+ if (!id)
325
+ throw new TypeError('ICQQ reply segment requires message_id');
326
+ return { type: 'reply', id };
292
327
  }
293
- case 'face':
294
- return `[face:${item.data?.id ?? ''}]`;
295
- case 'reply':
296
- return `[reply:${item.data?.message_id ?? item.data?.id ?? ''}]`;
297
328
  case 'record':
298
329
  case 'audio': {
299
- const arg = resolveCqMediaArg('record', item.data);
300
- return arg ? `[record:${arg}]` : '';
330
+ const arg = resolveIcqqMediaFile(item.data);
331
+ if (!arg)
332
+ throw new TypeError('ICQQ audio segment requires canonical data.media');
333
+ return { type: 'record', file: arg };
301
334
  }
302
335
  case 'video': {
303
- const arg = resolveCqMediaArg('video', item.data);
304
- return arg ? `[video:${arg}]` : '';
336
+ const arg = resolveIcqqMediaFile(item.data);
337
+ if (!arg)
338
+ throw new TypeError('ICQQ video segment requires canonical data.media');
339
+ return { type: 'video', file: arg };
340
+ }
341
+ case 'file': {
342
+ const arg = resolveIcqqMediaFile(item.data);
343
+ if (!arg)
344
+ throw new TypeError('ICQQ file segment requires canonical data.media');
345
+ const name = String(item.data?.name ?? '').trim();
346
+ return { type: 'file', file: arg, ...(name ? { name } : {}) };
347
+ }
348
+ case 'dice':
349
+ return { type: 'dice' };
350
+ case 'rps':
351
+ return { type: 'rps' };
352
+ case 'forward': {
353
+ const resid = String(item.data?.forward_id ?? item.data?.resid ?? item.data?.id ?? '').trim();
354
+ if (!resid)
355
+ throw new TypeError('ICQQ forward segment requires forward_id');
356
+ return { type: 'long_msg', resid };
305
357
  }
306
358
  case 'share':
307
359
  return formatMusicShareJson(item.data);
308
- default:
309
- return String(item.data?.text ?? '');
360
+ default: {
361
+ const text = item.data?.text;
362
+ if (typeof text === 'string' && text)
363
+ return text;
364
+ throw new TypeError(`Unsupported ICQQ outbound segment: ${item.type}`);
365
+ }
310
366
  }
311
- }).join('').trim();
312
- return joined || '\u200b';
367
+ }).filter((element) => typeof element !== 'string' || element.length > 0);
368
+ if (elements.length === 0)
369
+ return '\u200b';
370
+ const standalone = elements.find((element) => typeof element !== 'string' && ICQQ_STANDALONE_ELEMENTS.has(element.type));
371
+ if (standalone && elements.length !== 1) {
372
+ throw new TypeError(`ICQQ ${standalone.type} element must be sent as a standalone message`);
373
+ }
374
+ return elements.length === 1 ? elements[0] : elements;
313
375
  }
314
376
  export function formatInboundContent(rawMessage) {
315
377
  return rawMessage;
@@ -322,7 +384,7 @@ function isWireSegment(value) {
322
384
  }
323
385
  function formatMusicShareJson(data) {
324
386
  if (!data)
325
- return '';
387
+ throw new TypeError('ICQQ share segment requires data');
326
388
  const config = data.config;
327
389
  const title = String(data.title ?? '');
328
390
  const desc = String(data.artist ?? '');
@@ -359,5 +421,5 @@ function formatMusicShareJson(data) {
359
421
  },
360
422
  },
361
423
  };
362
- return `[json:${JSON.stringify(json)}]`;
424
+ return { type: 'json', data: json };
363
425
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-icqq",
3
- "version": "8.0.12",
3
+ "version": "8.0.14",
4
4
  "description": "Zhin.js ICQQ adapter — direct connection to @icqqjs/icqq",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -34,21 +34,21 @@
34
34
  "directory": "plugins/adapters/icqq"
35
35
  },
36
36
  "dependencies": {
37
- "@zhin.js/adapter": "1.1.10",
38
- "@zhin.js/core": "1.5.10",
39
- "@zhin.js/im-contract": "1.0.3",
37
+ "@zhin.js/adapter": "1.1.11",
38
+ "@zhin.js/core": "1.5.12",
39
+ "@zhin.js/im-contract": "1.0.4",
40
40
  "@zhin.js/logger": "1.0.76",
41
- "@zhin.js/permission": "1.0.2",
42
- "@zhin.js/tool": "1.0.11"
41
+ "@zhin.js/permission": "1.0.3",
42
+ "@zhin.js/tool": "1.0.12"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@icqqjs/icqq": "^1.12.2",
46
46
  "zod": "^4.0.0",
47
- "@zhin.js/adapter": "1.1.10",
48
- "@zhin.js/agent": "1.1.12",
49
- "@zhin.js/command": "1.0.14",
50
- "@zhin.js/core": "1.5.10",
51
- "zhin.js": "6.0.10"
47
+ "@zhin.js/adapter": "1.1.11",
48
+ "@zhin.js/agent": "1.1.14",
49
+ "@zhin.js/command": "1.0.15",
50
+ "@zhin.js/core": "1.5.12",
51
+ "zhin.js": "6.0.12"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@icqqjs/icqq": {
@@ -73,8 +73,8 @@
73
73
  "typescript": "^6.0.3",
74
74
  "vitest": "^4.1.10",
75
75
  "zod": "^4.4.3",
76
- "@zhin.js/agent": "1.1.12",
77
- "zhin.js": "6.0.10"
76
+ "@zhin.js/agent": "1.1.14",
77
+ "zhin.js": "6.0.12"
78
78
  },
79
79
  "files": [
80
80
  "adapters",
package/src/cq-message.ts CHANGED
@@ -1,13 +1,6 @@
1
- /**
2
- * CQ 码与 MessageSegment 互转。
3
- * 出站:@icqqjs/cli 要求 message 为非空字符串;引用段编码为 [reply:id](需 cli parse-message 支持 reply,见 scripts/patch-icqq-cli-reply.mjs)。
4
- * 媒体段双向均为 canonical MediaRef(`data.media`),无 legacy url/file/base64 字段。
5
- */
1
+ /** ICQQ 历史文本视图 → canonical MessageSegment。出站只使用原生 Sendable。 */
6
2
  import { isMediaRef, type MediaRef } from "@zhin.js/core";
7
- import { formatCompact, getLogger } from "@zhin.js/logger";
8
- import { MessageSegment, segment, SendContent } from "zhin.js";
9
-
10
- const logger = getLogger("icqq");
3
+ import type { MessageSegment } from "zhin.js";
11
4
 
12
5
  const MAX_CQ_PARSE_LEN = 256_000;
13
6
 
@@ -29,23 +22,14 @@ export function icqqMediaRefFromString(value: string): MediaRef {
29
22
  }
30
23
 
31
24
  /**
32
- * canonical MediaRef(`data.media`,唯一媒体来源)→ CQ 媒体参数:
33
- * base64 → `base64://` 前缀(守护进程解码);url/path/file 原样直出。
34
- * 无 canonical 媒体引用时 warn 并返回 undefined(调用方丢弃该段)。
25
+ * canonical MediaRef(`data.media`,唯一媒体来源)→ ICQQ native
26
+ * element 的 file 参数。base64 使用 ICQQ 原生支持的 `base64://` 前缀。
35
27
  */
36
- export function resolveCqMediaArg(
37
- type: string,
28
+ export function resolveIcqqMediaFile(
38
29
  data: Record<string, unknown> | undefined,
39
30
  ): string | undefined {
40
31
  const media = data?.media;
41
- if (!isMediaRef(media)) {
42
- logger.warn(formatCompact({
43
- op: "icqq_outbound_media_dropped",
44
- type,
45
- reason: "missing_media_ref",
46
- }));
47
- return undefined;
48
- }
32
+ if (!isMediaRef(media)) return undefined;
49
33
  if (media.kind === "base64") {
50
34
  return media.value.startsWith("base64://") ? media.value : `base64://${media.value}`;
51
35
  }
@@ -75,7 +59,7 @@ function pushCqSegment(segments: MessageSegment[], type: string, arg: string): v
75
59
  break;
76
60
  case "record":
77
61
  case "audio":
78
- segments.push({ type: "record", data: { media: icqqMediaRefFromString(arg) } });
62
+ segments.push({ type: "audio", data: { media: icqqMediaRefFromString(arg) } });
79
63
  break;
80
64
  case "video":
81
65
  segments.push({ type: "video", data: { media: icqqMediaRefFromString(arg) } });
@@ -127,54 +111,3 @@ export function parseCqMessage(raw: string): MessageSegment[] {
127
111
 
128
112
  return segments.length ? segments : [{ type: "text", data: { text: raw } }];
129
113
  }
130
-
131
- /** 构建 icqq send_*_msg 的 message 字符串(非空) */
132
- export function buildIcqqMessage(content: SendContent): string {
133
- let message = toCqString(content).trim();
134
- if (!message) message = "\u200b";
135
- return message;
136
- }
137
-
138
- export function toCqString(content: SendContent): string {
139
- if (!Array.isArray(content)) content = [content];
140
- return content
141
- .map((seg) => {
142
- if (typeof seg === "string") return seg;
143
- const { type, data } = seg as MessageSegment;
144
- switch (type) {
145
- case "text":
146
- return data.text ?? "";
147
- case "face":
148
- return `[face:${data.id}]`;
149
- case "image": {
150
- const arg = resolveCqMediaArg("image", data);
151
- return arg ? `[image:${arg}]` : "";
152
- }
153
- case "at":
154
- case "mention": {
155
- const target = data.target ?? data.qq ?? data.id ?? data.user_id;
156
- return `[at:${target}]`;
157
- }
158
- case "dice":
159
- return "[dice]";
160
- case "rps":
161
- return "[rps]";
162
- case "record":
163
- case "audio": {
164
- const arg = resolveCqMediaArg("record", data);
165
- return arg ? `[record:${arg}]` : "";
166
- }
167
- case "video": {
168
- const arg = resolveCqMediaArg("video", data);
169
- return arg ? `[video:${arg}]` : "";
170
- }
171
- case "reply":
172
- return `[reply:${data.message_id ?? data.id}]`;
173
- case "json":
174
- return `[json:${data.text ?? ''}]`;
175
- default:
176
- return segment.toString(seg);
177
- }
178
- })
179
- .join("");
180
- }