@zhin.js/adapter-icqq 6.0.3 → 7.0.1

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,155 @@
1
+ /**
2
+ * icqq IPC request/notice 事件 → 统一收件箱行(unified_inbox_request / unified_inbox_notice)。
3
+ *
4
+ * daemon 经 IpcEvent 推送 icqq client.em 分发的全部事件(见 protocol.ts),
5
+ * 好友/群请求(request.friend.add、request.group.add、request.group.invite)
6
+ * 与通知(notice.friend.*、notice.group.*)随事件流实时到达,无需轮询;
7
+ * endpoint 启动时补一次 GET_SYSTEM_MSG 拉取离线期间积存的待处理请求。
8
+ */
9
+ import type { IpcSystemMessage } from './types.js';
10
+
11
+ /** 行级公共前缀:adapter 槽 localName + endpoint live 名(uin)。 */
12
+ export interface IcqqInboxBase {
13
+ readonly adapter: string;
14
+ readonly endpointId: string;
15
+ }
16
+
17
+ /** post_type=request / request.* 判定(OneBot 事件壳)。 */
18
+ export function isIcqqRequestPayload(payload: unknown): boolean {
19
+ const postType = (payload as { post_type?: unknown } | null)?.post_type;
20
+ return typeof postType === 'string'
21
+ && (postType === 'request' || postType.startsWith('request.'));
22
+ }
23
+
24
+ /** post_type=notice / notice.* 判定。 */
25
+ export function isIcqqNoticePayload(payload: unknown): boolean {
26
+ const postType = (payload as { post_type?: unknown } | null)?.post_type;
27
+ return typeof postType === 'string'
28
+ && (postType === 'notice' || postType.startsWith('notice.'));
29
+ }
30
+
31
+ /**
32
+ * request 事件载荷 → unified_inbox_request 行。
33
+ * platform_request_id 取 flag(审批回执句柄),缺省回退 seq;两者皆无则无法审批,丢弃。
34
+ */
35
+ export function buildIcqqInboxRequestRow(
36
+ payload: Record<string, unknown>,
37
+ base: IcqqInboxBase,
38
+ ): Record<string, unknown> | null {
39
+ const userId = payload.user_id ?? payload.actor_id;
40
+ if (userId == null) return null;
41
+ const flag = asNonEmptyString(payload.flag);
42
+ const platformRequestId = flag ?? (payload.seq != null ? String(payload.seq) : '');
43
+ if (!platformRequestId) return null;
44
+ const requestType = asNonEmptyString(payload.request_type) ?? asNonEmptyString(payload.type);
45
+ const groupId = payload.group_id;
46
+ const isGroup = requestType === 'group' || groupId != null;
47
+ return {
48
+ adapter: base.adapter,
49
+ endpoint_id: base.endpointId,
50
+ platform_request_id: platformRequestId,
51
+ type: isGroup ? 'group' : (requestType ?? 'friend'),
52
+ scene_type: isGroup ? 'group' : null,
53
+ scene_id: String(groupId ?? userId),
54
+ sub_type: asNonEmptyString(payload.sub_type),
55
+ actor_id: String(userId),
56
+ actor_name: asNonEmptyString(payload.nickname),
57
+ comment: asNonEmptyString(payload.comment),
58
+ created_at: toMillis(payload.time),
59
+ resolved: 0,
60
+ resolved_at: null,
61
+ consumed: 0,
62
+ consumed_at: null,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * notice 事件载荷 → unified_inbox_notice 行。
68
+ * 事件本身无稳定 id,platform_notice_id 由 time + actor + type 合成(配合去重)。
69
+ */
70
+ export function buildIcqqInboxNoticeRow(
71
+ payload: Record<string, unknown>,
72
+ base: IcqqInboxBase,
73
+ ): Record<string, unknown> | null {
74
+ const noticeType = asNonEmptyString(payload.notice_type) ?? asNonEmptyString(payload.type);
75
+ if (!noticeType) return null;
76
+ const subType = asNonEmptyString(payload.sub_type);
77
+ const userId = payload.user_id;
78
+ const operatorId = payload.operator_id;
79
+ const groupId = payload.group_id;
80
+ const createdAt = toMillis(payload.time);
81
+ const actorId = operatorId ?? userId;
82
+ const platformNoticeId = asNonEmptyString(payload.notice_id)
83
+ ?? (payload.seq != null ? String(payload.seq) : undefined)
84
+ ?? `${createdAt}_${String(actorId ?? '')}_${noticeType}${subType ? `.${subType}` : ''}`;
85
+ const targetId = userId != null && String(userId) !== String(actorId ?? '') ? userId : undefined;
86
+ return {
87
+ adapter: base.adapter,
88
+ endpoint_id: base.endpointId,
89
+ platform_notice_id: platformNoticeId,
90
+ type: noticeType,
91
+ scene_type: groupId != null ? 'group' : null,
92
+ scene_id: String(groupId ?? userId ?? operatorId ?? ''),
93
+ sub_type: subType,
94
+ actor_id: actorId != null ? String(actorId) : null,
95
+ actor_name: asNonEmptyString(payload.nickname),
96
+ target_id: targetId != null ? String(targetId) : null,
97
+ target_name: null,
98
+ payload: safeJson(payload),
99
+ created_at: createdAt,
100
+ consumed: 0,
101
+ consumed_at: null,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * GET_SYSTEM_MSG 返回项 → unified_inbox_request 行(启动时首次拉取)。
107
+ * friend 列表项 type 语义即好友请求;group 列表项 type 为 add/invite(记 sub_type)。
108
+ */
109
+ export function buildIcqqSystemRequestRow(
110
+ message: IpcSystemMessage,
111
+ kind: 'friend' | 'group',
112
+ base: IcqqInboxBase,
113
+ ): Record<string, unknown> | null {
114
+ if (message.user_id == null) return null;
115
+ const platformRequestId = asNonEmptyString(message.flag)
116
+ ?? (message.seq != null ? String(message.seq) : '');
117
+ if (!platformRequestId) return null;
118
+ return {
119
+ adapter: base.adapter,
120
+ endpoint_id: base.endpointId,
121
+ platform_request_id: platformRequestId,
122
+ type: kind,
123
+ scene_type: kind === 'group' ? 'group' : null,
124
+ scene_id: String(kind === 'group' ? (message.group_id ?? message.user_id) : message.user_id),
125
+ sub_type: kind === 'group' ? (asNonEmptyString(message.type) ?? null) : null,
126
+ actor_id: String(message.user_id),
127
+ actor_name: asNonEmptyString(message.nickname),
128
+ comment: asNonEmptyString(message.comment),
129
+ created_at: toMillis(message.time),
130
+ resolved: 0,
131
+ resolved_at: null,
132
+ consumed: 0,
133
+ consumed_at: null,
134
+ };
135
+ }
136
+
137
+ function asNonEmptyString(value: unknown): string | null {
138
+ if (typeof value !== 'string') return null;
139
+ const trimmed = value.trim();
140
+ return trimmed ? trimmed : null;
141
+ }
142
+
143
+ /** oicq 事件 time 为秒;缺失时回退当前时间。 */
144
+ function toMillis(time: unknown): number {
145
+ const seconds = Number(time);
146
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : Date.now();
147
+ }
148
+
149
+ function safeJson(value: unknown): string {
150
+ try {
151
+ return JSON.stringify(value ?? {});
152
+ } catch {
153
+ return '{}';
154
+ }
155
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * ICQQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
3
+ * 由 plugin.ts setup() provide,adapter create 与 `icqq endpoint` 命令共享(同一 owner generation)。
4
+ */
5
+ import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
6
+
7
+ export const icqqRuntimeStateToken = defineEndpointRuntimeStateToken('icqq');
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  IcqqIpcEndpoint,
21
21
  type CreateIcqqIpc,
22
22
  type IcqqEndpointOptions,
23
+ type IcqqInboxHooks,
23
24
  type IcqqIpcTransport,
24
25
  } from './endpoint.js';
25
26
 
package/src/ipc-client.ts CHANGED
@@ -266,7 +266,7 @@ export class IpcClient {
266
266
  try {
267
267
  token = (await readFile(getTokenPath(uin), "utf-8")).trim();
268
268
  } catch {
269
- throw new Error("无法读取认证 token,守护进程可能未运行");
269
+ throw new Error(`无法读取认证 token,守护进程可能未运行:请先执行 icqq login ${uin} 完成登录并启动守护进程`);
270
270
  }
271
271
 
272
272
  let rpcInfo: { host: string; port: number };
@@ -274,7 +274,7 @@ export class IpcClient {
274
274
  const raw = await readFile(getRpcPortPath(uin), "utf-8");
275
275
  rpcInfo = JSON.parse(raw);
276
276
  } catch {
277
- throw new Error("RPC 未启用或守护进程未运行");
277
+ throw new Error(`RPC 未启用或守护进程未运行:请先执行 icqq login ${uin},并确认守护进程已启用 RPC`);
278
278
  }
279
279
 
280
280
  return IpcClient.connectRpc({
@@ -47,8 +47,9 @@ export function resolveIcqqOutboundMediaMode(
47
47
  }
48
48
 
49
49
  /**
50
- * file 模式:将 segment.data.base64 物化为本机路径。
51
- * base64 模式:不改动(由 toCqString 生成 base64://,供异机守护进程读取)。
50
+ * file 模式:将 segment.data.base64(legacy)或 canonical MediaRef
51
+ * (`data.media`,kind=base64)物化为本机路径,并把 media 改写为 path 引用。
52
+ * base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64://,供异机守护进程读取)。
52
53
  */
53
54
  export function materializeOutboundBase64(
54
55
  content: SendContent,
@@ -61,10 +62,22 @@ export function materializeOutboundBase64(
61
62
  if (typeof seg === "string") return seg;
62
63
  const { type, data } = seg as MessageSegment;
63
64
  const d = data as Record<string, unknown>;
64
- const b64 = d.base64 ?? d.data;
65
- if (typeof b64 !== "string" || !b64) return seg;
65
+ const media = d.media as
66
+ | { kind?: unknown; value?: unknown; mime_type?: unknown }
67
+ | undefined;
68
+ const mediaBase64 =
69
+ media && media.kind === "base64" && typeof media.value === "string" && media.value
70
+ ? media.value.replace(/^base64:\/\//, "")
71
+ : undefined;
72
+ const legacyBase64 = d.base64 ?? d.data;
73
+ const b64 =
74
+ mediaBase64 ??
75
+ (typeof legacyBase64 === "string" && legacyBase64 ? legacyBase64 : undefined);
76
+ if (!b64) return seg;
66
77
 
67
- const mime = String(d.mime ?? d.mimeType ?? "");
78
+ const mime = String(
79
+ d.mime ?? d.mimeType ?? (typeof media?.mime_type === "string" ? media.mime_type : "") ?? "",
80
+ );
68
81
  if (type === "image" || type === "record" || type === "audio" || type === "video") {
69
82
  const filePath = spoolBase64ToFile(
70
83
  b64,
@@ -75,6 +88,11 @@ export function materializeOutboundBase64(
75
88
  type,
76
89
  data: {
77
90
  ...d,
91
+ media: {
92
+ kind: "path",
93
+ value: filePath,
94
+ ...(mime ? { mime_type: mime } : {}),
95
+ },
78
96
  file: filePath,
79
97
  url: filePath,
80
98
  },
package/src/protocol.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { pickCredential } from '@zhin.js/adapter';
16
+ import type { MessageSegment } from "zhin.js";
16
17
 
17
18
  /** CLI → Daemon 请求 */
18
19
  export type IpcRequest = {
@@ -313,6 +314,11 @@ export interface IcqqInboundMessage {
313
314
  readonly id: string;
314
315
  readonly target: string;
315
316
  readonly content: string;
317
+ /**
318
+ * 结构化段视图(canonical Segment,经 toCanonicalSegments 归一)。
319
+ * 与 content(CQ 原文纯文本视图)同源,随 gateway.receive 上送。
320
+ */
321
+ readonly segments?: readonly MessageSegment[];
316
322
  readonly sender: string;
317
323
  readonly channelType: 'private' | 'group' | 'channel';
318
324
  readonly metadata?: Record<string, unknown>;
@@ -422,16 +428,16 @@ export function formatOutboundBody(payload: unknown): string {
422
428
  case 'mention':
423
429
  return `[at:${item.data?.qq ?? item.data?.id ?? item.data?.target ?? ''}]`;
424
430
  case 'image':
425
- return `[image:${item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
431
+ return `[image:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
426
432
  case 'face':
427
433
  return `[face:${item.data?.id ?? ''}]`;
428
434
  case 'reply':
429
435
  return `[reply:${item.data?.message_id ?? item.data?.id ?? ''}]`;
430
436
  case 'record':
431
437
  case 'audio':
432
- return `[record:${item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
438
+ return `[record:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
433
439
  case 'video':
434
- return `[video:${item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
440
+ return `[video:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
435
441
  default:
436
442
  return String(item.data?.text ?? '');
437
443
  }
@@ -449,6 +455,22 @@ function base64Media(data: Record<string, unknown> | undefined): string | undefi
449
455
  return typeof b64 === 'string' && b64 ? `base64://${b64}` : undefined;
450
456
  }
451
457
 
458
+ /**
459
+ * canonical MediaRef(`data.media`,见 built/segment-contract)→ CQ 媒体值:
460
+ * base64 → `base64://` 前缀(守护进程解码);url/path 直出。
461
+ * file 模式下 materializeOutboundBase64 已把 base64 落盘并把 media 改写为 path 引用。
462
+ */
463
+ function mediaRefValue(data: Record<string, unknown> | undefined): string | undefined {
464
+ const media = data?.media;
465
+ if (!media || typeof media !== 'object' || Array.isArray(media)) return undefined;
466
+ const { kind, value } = media as { kind?: unknown; value?: unknown };
467
+ if (typeof value !== 'string' || !value) return undefined;
468
+ if (kind === 'base64') {
469
+ return value.startsWith('base64://') ? value : `base64://${value}`;
470
+ }
471
+ return value;
472
+ }
473
+
452
474
  /** 段对象判定:`{ type: string, data? }`(非数组 payload 的防御入口)。 */
453
475
  function isWireSegment(value: unknown): value is IcqqWireSegment {
454
476
  return typeof value === 'object'
package/src/types.ts CHANGED
@@ -76,3 +76,19 @@ export interface IpcMemberInfo {
76
76
  last_sent_time?: number;
77
77
  shutup_time?: number;
78
78
  }
79
+
80
+ /**
81
+ * GET_SYSTEM_MSG 归一后的待处理请求(@icqqjs/cli NormalizedSystemMessage)。
82
+ * friendRequests 与 groupRequests 均为该形状,按 flag 处理。
83
+ */
84
+ export interface IpcSystemMessage {
85
+ type: string;
86
+ user_id?: number;
87
+ nickname?: string;
88
+ group_id?: number;
89
+ group_name?: string;
90
+ comment?: string;
91
+ flag?: string;
92
+ seq?: number;
93
+ time?: number;
94
+ }