@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.
- package/CHANGELOG.md +57 -0
- package/README.md +23 -5
- package/adapters/icqq.ts +79 -3
- package/commands/endpoint/add/[name:string=].ts +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[name:string].ts +3 -0
- package/lib/endpoint.d.ts +52 -2
- package/lib/endpoint.js +317 -9
- package/lib/icqq-endpoint-commands.d.ts +1 -0
- package/lib/icqq-endpoint-commands.js +35 -0
- package/lib/icqq-guild.d.ts +8 -2
- package/lib/icqq-inbox.d.ts +33 -0
- package/lib/icqq-inbox.js +129 -0
- package/lib/icqq-runtime-state.d.ts +1 -0
- package/lib/icqq-runtime-state.js +6 -0
- package/lib/index.d.ts +1 -1
- package/lib/ipc-client.js +2 -2
- package/lib/outbound-media.d.ts +3 -2
- package/lib/outbound-media.js +17 -5
- package/lib/protocol.d.ts +6 -0
- package/lib/protocol.js +20 -3
- package/lib/types.d.ts +15 -0
- package/package.json +17 -11
- package/plugin.ts +5 -1
- package/schema.json +86 -17
- package/src/endpoint.ts +391 -10
- package/src/icqq-endpoint-commands.ts +37 -0
- package/src/icqq-guild.ts +9 -2
- package/src/icqq-inbox.ts +155 -0
- package/src/icqq-runtime-state.ts +7 -0
- package/src/index.ts +1 -0
- package/src/ipc-client.ts +2 -2
- package/src/outbound-media.ts +23 -5
- package/src/protocol.ts +25 -3
- package/src/types.ts +16 -0
|
@@ -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
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(
|
|
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(
|
|
277
|
+
throw new Error(`RPC 未启用或守护进程未运行:请先执行 icqq login ${uin},并确认守护进程已启用 RPC`);
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
return IpcClient.connectRpc({
|
package/src/outbound-media.ts
CHANGED
|
@@ -47,8 +47,9 @@ export function resolveIcqqOutboundMediaMode(
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* file 模式:将 segment.data.base64
|
|
51
|
-
* 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
|
|
65
|
-
|
|
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(
|
|
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
|
+
}
|