@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
package/lib/endpoint.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
2
2
|
import { registerIcqqAgentEndpoint } from './icqq-agent-deps.js';
|
|
3
3
|
import { InboundMessageDeduper, findIcqqNestedMessageSource, isIcqqBotMentioned, isIcqqMessagePostType, normalizeIcqqInboundMessage, quotedPayloadFromIcqqSource, resolveIcqqQuoteIdFromEvent, shouldSkipSelfInboundMessage, unwrapIcqqIpcEventPayload, } from './icqq-inbound.js';
|
|
4
|
-
import {
|
|
4
|
+
import { buildIcqqInboxNoticeRow, buildIcqqInboxRequestRow, buildIcqqSystemRequestRow, isIcqqNoticePayload, isIcqqRequestPayload, } from './icqq-inbox.js';
|
|
5
|
+
import { IcqqGuildCatalog, isIcqqGuildIpcEvent, normalizeIcqqGuildInboundMessage, } from './icqq-guild.js';
|
|
5
6
|
import { IpcClient } from './ipc-client.js';
|
|
6
7
|
import { materializeOutboundBase64, resolveIcqqOutboundMediaMode, } from './outbound-media.js';
|
|
7
8
|
import { Actions, formatInboundContent, formatInboundTarget, formatOutboundBody, parseSendTarget, } from './protocol.js';
|
|
@@ -14,10 +15,28 @@ export class IcqqIpcEndpoint {
|
|
|
14
15
|
ipc;
|
|
15
16
|
friends = new Map();
|
|
16
17
|
groups = new Map();
|
|
18
|
+
#guildCatalog = new IcqqGuildCatalog();
|
|
19
|
+
management = Object.freeze({
|
|
20
|
+
listFriends: () => this.getFriendList(),
|
|
21
|
+
listGroups: () => this.getGroupList(),
|
|
22
|
+
listChannels: async () => {
|
|
23
|
+
await this.#guildCatalog.syncAll(this.ipc);
|
|
24
|
+
return this.#guildCatalog.getGuildChannelList();
|
|
25
|
+
},
|
|
26
|
+
listGroupMembers: (groupId) => this.getGroupMemberList(groupId),
|
|
27
|
+
approveRequest: (requestId, remark) => this.approveRequest(requestId, remark),
|
|
28
|
+
rejectRequest: (requestId, reason) => this.rejectRequest(requestId, reason),
|
|
29
|
+
kickGroupMember: (groupId, userId) => this.removeMember(groupId, userId),
|
|
30
|
+
muteGroupMember: (groupId, userId, duration) => this.muteMember(groupId, userId, duration),
|
|
31
|
+
setGroupAdmin: (groupId, userId, enabled) => this.setModerator(groupId, userId, enabled),
|
|
32
|
+
deleteFriend: (userId) => this.deleteFriend(userId),
|
|
33
|
+
});
|
|
17
34
|
#open = false;
|
|
18
35
|
#started = false;
|
|
19
36
|
#subscriptions = [];
|
|
20
37
|
#inboundDeduper = new InboundMessageDeduper();
|
|
38
|
+
/** request/notice 去重(推送事件与 GET_SYSTEM_MSG 首拉可能重复同一 flag)。 */
|
|
39
|
+
#inboxDeduper = new InboundMessageDeduper();
|
|
21
40
|
#unregisterAgent;
|
|
22
41
|
/** 用户主动 stop 时为 true,阻止自动重连 */
|
|
23
42
|
#intentionalDisconnect = false;
|
|
@@ -71,6 +90,9 @@ export class IcqqIpcEndpoint {
|
|
|
71
90
|
this.#handleEvent(event);
|
|
72
91
|
});
|
|
73
92
|
this.#subscriptions.push(sub);
|
|
93
|
+
// daemon 推送 client.em 全部事件,request/notice 实时到达;
|
|
94
|
+
// 启动时补一次 GET_SYSTEM_MSG,捞离线期间积存的待处理请求。
|
|
95
|
+
void this.#pullPendingSystemMessages();
|
|
74
96
|
}
|
|
75
97
|
/** IPC/RPC 意外断开时调度重连(指数退避) */
|
|
76
98
|
#scheduleReconnect() {
|
|
@@ -145,8 +167,10 @@ export class IcqqIpcEndpoint {
|
|
|
145
167
|
this.#unregisterAgent?.();
|
|
146
168
|
this.#unregisterAgent = undefined;
|
|
147
169
|
this.#inboundDeduper.clear();
|
|
170
|
+
this.#inboxDeduper.clear();
|
|
148
171
|
this.friends.clear();
|
|
149
172
|
this.groups.clear();
|
|
173
|
+
this.#guildCatalog.clear();
|
|
150
174
|
try {
|
|
151
175
|
this.ipc?.close();
|
|
152
176
|
}
|
|
@@ -342,16 +366,166 @@ export class IcqqIpcEndpoint {
|
|
|
342
366
|
}));
|
|
343
367
|
}
|
|
344
368
|
}
|
|
369
|
+
// ── 社交/群管(Console endpoint RPC 探测面)────────────────────────
|
|
370
|
+
// console-rpc-extended.ts 按方法名探测这些接口;全部薄封装走 IPC daemon。
|
|
371
|
+
/** EndpointManagement.listFriends —— 好友列表(LIST_FRIENDS,归一为 {user_id, nickname, remark})。 */
|
|
372
|
+
async getFriendList() {
|
|
373
|
+
const resp = await this.#mustRequest(Actions.LIST_FRIENDS, undefined, '获取好友列表');
|
|
374
|
+
const list = Array.isArray(resp.data) ? resp.data : [];
|
|
375
|
+
return list.map((f) => ({
|
|
376
|
+
user_id: f.user_id,
|
|
377
|
+
nickname: f.nickname,
|
|
378
|
+
remark: f.remark ?? '',
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
/** EndpointManagement.listGroups —— 群列表(LIST_GROUPS,归一为 {group_id, name})。 */
|
|
382
|
+
async getGroupList() {
|
|
383
|
+
const resp = await this.#mustRequest(Actions.LIST_GROUPS, undefined, '获取群列表');
|
|
384
|
+
const list = Array.isArray(resp.data) ? resp.data : [];
|
|
385
|
+
return list.map((g) => ({
|
|
386
|
+
group_id: g.group_id,
|
|
387
|
+
name: g.group_name,
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
/** EndpointManagement.listGroupMembers —— 群成员列表(字段对齐 daemon 返回)。 */
|
|
391
|
+
async getGroupMemberList(groupId) {
|
|
392
|
+
const resp = await this.#mustRequest(Actions.LIST_GROUP_MEMBERS, { group_id: toNumericId(groupId, 'group_id') }, '获取群成员列表');
|
|
393
|
+
return Array.isArray(resp.data) ? resp.data : [];
|
|
394
|
+
}
|
|
395
|
+
/** getGroupMemberList 别名(console 探测 listMembers / getMemberList)。 */
|
|
396
|
+
listMembers(groupId) {
|
|
397
|
+
return this.getGroupMemberList(groupId);
|
|
398
|
+
}
|
|
399
|
+
getMemberList(groupId) {
|
|
400
|
+
return this.getGroupMemberList(groupId);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* EndpointManagement.resolveRequest —— id 为 inbox 行的 platform_request_id。
|
|
404
|
+
* 先 GET_SYSTEM_MSG 按 flag(回退 seq)定位请求,再按类型路由 handle_friend/group_request。
|
|
405
|
+
*/
|
|
406
|
+
async approveRequest(id, remark) {
|
|
407
|
+
await this.#handleSystemRequest(id, true, { remark });
|
|
408
|
+
}
|
|
409
|
+
async rejectRequest(id, reason) {
|
|
410
|
+
await this.#handleSystemRequest(id, false, { reason });
|
|
411
|
+
}
|
|
412
|
+
/** EndpointManagement.deleteFriend —— FRIEND_DELETE。 */
|
|
413
|
+
async deleteFriend(userId) {
|
|
414
|
+
await this.#mustRequest(Actions.FRIEND_DELETE, { user_id: toNumericId(userId, 'user_id') }, '删除好友');
|
|
415
|
+
}
|
|
416
|
+
/** deleteFriend 别名(console 探测 delete_friend)。 */
|
|
417
|
+
delete_friend(userId) {
|
|
418
|
+
return this.deleteFriend(userId);
|
|
419
|
+
}
|
|
420
|
+
/** EndpointManagement.kickGroupMember —— GROUP_KICK。 */
|
|
421
|
+
async removeMember(groupId, userId) {
|
|
422
|
+
await this.#mustRequest(Actions.GROUP_KICK, { group_id: toNumericId(groupId, 'group_id'), user_id: toNumericId(userId, 'user_id') }, '踢出群成员');
|
|
423
|
+
}
|
|
424
|
+
kickMember(groupId, userId) {
|
|
425
|
+
return this.removeMember(groupId, userId);
|
|
426
|
+
}
|
|
427
|
+
setGroupKick(groupId, userId) {
|
|
428
|
+
return this.removeMember(groupId, userId);
|
|
429
|
+
}
|
|
430
|
+
/** EndpointManagement.muteGroupMember —— GROUP_MUTE(duration 秒,默认 600)。 */
|
|
431
|
+
async muteMember(groupId, userId, duration = 600) {
|
|
432
|
+
await this.#mustRequest(Actions.GROUP_MUTE, {
|
|
433
|
+
group_id: toNumericId(groupId, 'group_id'),
|
|
434
|
+
user_id: toNumericId(userId, 'user_id'),
|
|
435
|
+
duration,
|
|
436
|
+
}, '禁言群成员');
|
|
437
|
+
}
|
|
438
|
+
banMember(groupId, userId, duration = 600) {
|
|
439
|
+
return this.muteMember(groupId, userId, duration);
|
|
440
|
+
}
|
|
441
|
+
setGroupMute(groupId, userId, duration = 600) {
|
|
442
|
+
return this.muteMember(groupId, userId, duration);
|
|
443
|
+
}
|
|
444
|
+
/** EndpointManagement.setGroupAdmin —— SET_GROUP_ADMIN(enable 默认 true)。 */
|
|
445
|
+
async setModerator(groupId, userId, enable = true) {
|
|
446
|
+
await this.#mustRequest(Actions.SET_GROUP_ADMIN, {
|
|
447
|
+
group_id: toNumericId(groupId, 'group_id'),
|
|
448
|
+
user_id: toNumericId(userId, 'user_id'),
|
|
449
|
+
enable,
|
|
450
|
+
}, '设置群管理员');
|
|
451
|
+
}
|
|
452
|
+
setAdmin(groupId, userId, enable = true) {
|
|
453
|
+
return this.setModerator(groupId, userId, enable);
|
|
454
|
+
}
|
|
455
|
+
setGroupAdmin(groupId, userId, enable = true) {
|
|
456
|
+
return this.setModerator(groupId, userId, enable);
|
|
457
|
+
}
|
|
458
|
+
/** IPC 请求并统一错误上下文:daemon 返回 ok=false 时抛出带操作名的错误。 */
|
|
459
|
+
async #mustRequest(action, params, label) {
|
|
460
|
+
const resp = await this.request(action, params);
|
|
461
|
+
if (!resp.ok) {
|
|
462
|
+
throw new Error(`icqq ${label}失败: ${resp.error ?? 'daemon 未返回错误详情'}`);
|
|
463
|
+
}
|
|
464
|
+
return resp;
|
|
465
|
+
}
|
|
466
|
+
/** 按 flag/seq 在 GET_SYSTEM_MSG 中定位请求,路由到好友/群请求处理 action。 */
|
|
467
|
+
async #handleSystemRequest(id, approve, extra) {
|
|
468
|
+
const resp = await this.#mustRequest(Actions.GET_SYSTEM_MSG, undefined, '获取待处理请求');
|
|
469
|
+
const data = resp.data;
|
|
470
|
+
const friendRequests = Array.isArray(data?.friendRequests)
|
|
471
|
+
? data.friendRequests
|
|
472
|
+
: [];
|
|
473
|
+
const groupRequests = Array.isArray(data?.groupRequests)
|
|
474
|
+
? data.groupRequests
|
|
475
|
+
: [];
|
|
476
|
+
const matches = (m) => m.flag === id || (m.seq != null && String(m.seq) === id);
|
|
477
|
+
const friend = friendRequests.find(matches);
|
|
478
|
+
const group = friend ? undefined : groupRequests.find(matches);
|
|
479
|
+
const target = friend ?? group;
|
|
480
|
+
if (!target?.flag) {
|
|
481
|
+
throw new Error(`icqq 未找到待处理请求: ${id}(GET_SYSTEM_MSG 中无匹配 flag/seq)`);
|
|
482
|
+
}
|
|
483
|
+
const params = { flag: target.flag, approve };
|
|
484
|
+
if (friend) {
|
|
485
|
+
if (extra.remark)
|
|
486
|
+
params.remark = extra.remark;
|
|
487
|
+
await this.#mustRequest(Actions.HANDLE_FRIEND_REQUEST, params, '处理好友请求');
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
if (extra.reason)
|
|
491
|
+
params.reason = extra.reason;
|
|
492
|
+
await this.#mustRequest(Actions.HANDLE_GROUP_REQUEST, params, '处理群请求');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
345
495
|
/** Test / internal: admit when open. */
|
|
346
496
|
admit(msg) {
|
|
347
|
-
if (!this.#open)
|
|
497
|
+
if (!this.#open) {
|
|
498
|
+
logger.debug(formatCompact({
|
|
499
|
+
op: 'icqq_inbound_skip',
|
|
500
|
+
reason: 'endpoint_closed',
|
|
501
|
+
endpoint: this.name,
|
|
502
|
+
id: msg.id,
|
|
503
|
+
target: msg.target,
|
|
504
|
+
}));
|
|
348
505
|
return;
|
|
349
|
-
|
|
350
|
-
|
|
506
|
+
}
|
|
507
|
+
// 新 Runtime Message.content 为纯文本:@ 本机(uin = name)经结构化段 / metadata 传递
|
|
508
|
+
const mentioned = isIcqqBotMentioned({
|
|
509
|
+
uin: this.name,
|
|
510
|
+
content: msg.segments,
|
|
511
|
+
rawMessage: msg.content,
|
|
512
|
+
});
|
|
513
|
+
logger.debug(formatCompact({
|
|
514
|
+
op: 'icqq_inbound_admit',
|
|
515
|
+
endpoint: this.name,
|
|
516
|
+
id: msg.id,
|
|
517
|
+
target: msg.target,
|
|
518
|
+
sender: msg.sender,
|
|
519
|
+
channelType: msg.channelType,
|
|
520
|
+
mentioned: mentioned || undefined,
|
|
521
|
+
preview: msg.content.slice(0, 80),
|
|
522
|
+
segments: summarizeSegmentTypes(msg.segments),
|
|
523
|
+
}));
|
|
351
524
|
void this.#options.gateway.receive({
|
|
352
525
|
adapter: this.#options.id,
|
|
353
526
|
target: msg.target,
|
|
354
527
|
content: msg.content,
|
|
528
|
+
...(msg.segments ? { segments: msg.segments } : {}),
|
|
355
529
|
sender: msg.sender,
|
|
356
530
|
id: msg.id,
|
|
357
531
|
metadata: Object.freeze({
|
|
@@ -394,16 +568,47 @@ export class IcqqIpcEndpoint {
|
|
|
394
568
|
const payload = unwrapIcqqIpcEventPayload(event);
|
|
395
569
|
if (!payload || typeof payload !== 'object')
|
|
396
570
|
return;
|
|
571
|
+
if (isIcqqRequestPayload(payload)) {
|
|
572
|
+
this.#recordInboxRequest(payload);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (isIcqqNoticePayload(payload)) {
|
|
576
|
+
this.#recordInboxNotice(payload);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
397
579
|
if (!isIcqqMessagePostType(payload))
|
|
398
580
|
return;
|
|
399
581
|
const data = payload;
|
|
400
|
-
if (shouldSkipSelfInboundMessage(data))
|
|
582
|
+
if (shouldSkipSelfInboundMessage(data)) {
|
|
583
|
+
logger.debug(formatCompact({
|
|
584
|
+
op: 'icqq_inbound_skip',
|
|
585
|
+
reason: 'self',
|
|
586
|
+
endpoint: this.name,
|
|
587
|
+
user_id: data.user_id,
|
|
588
|
+
self_id: data.self_id,
|
|
589
|
+
}));
|
|
401
590
|
return;
|
|
591
|
+
}
|
|
402
592
|
const normalized = normalizeIcqqInboundMessage(data);
|
|
403
|
-
if (!normalized)
|
|
593
|
+
if (!normalized) {
|
|
594
|
+
logger.debug(formatCompact({
|
|
595
|
+
op: 'icqq_inbound_skip',
|
|
596
|
+
reason: 'normalize_failed',
|
|
597
|
+
endpoint: this.name,
|
|
598
|
+
message_id: data.message_id,
|
|
599
|
+
message_type: data.message_type ?? data.type,
|
|
600
|
+
}));
|
|
404
601
|
return;
|
|
405
|
-
|
|
602
|
+
}
|
|
603
|
+
if (!this.#inboundDeduper.shouldProcess(normalized.messageId)) {
|
|
604
|
+
logger.debug(formatCompact({
|
|
605
|
+
op: 'icqq_inbound_skip',
|
|
606
|
+
reason: 'dedupe',
|
|
607
|
+
endpoint: this.name,
|
|
608
|
+
id: normalized.messageId,
|
|
609
|
+
}));
|
|
406
610
|
return;
|
|
611
|
+
}
|
|
407
612
|
this.admit({
|
|
408
613
|
id: normalized.messageId,
|
|
409
614
|
target: formatInboundTarget({
|
|
@@ -412,6 +617,7 @@ export class IcqqIpcEndpoint {
|
|
|
412
617
|
channelParentGroupId: normalized.channelParentGroupId,
|
|
413
618
|
}),
|
|
414
619
|
content: formatInboundContent(normalized.rawMessage),
|
|
620
|
+
segments: normalized.content,
|
|
415
621
|
sender: normalized.userId,
|
|
416
622
|
channelType: normalized.channelType,
|
|
417
623
|
metadata: buildIcqqQuoteMetadata(data, {
|
|
@@ -425,10 +631,24 @@ export class IcqqIpcEndpoint {
|
|
|
425
631
|
if (!payload || typeof payload !== 'object')
|
|
426
632
|
return;
|
|
427
633
|
const normalized = normalizeIcqqGuildInboundMessage(payload);
|
|
428
|
-
if (!normalized)
|
|
634
|
+
if (!normalized) {
|
|
635
|
+
logger.debug(formatCompact({
|
|
636
|
+
op: 'icqq_inbound_skip',
|
|
637
|
+
reason: 'guild_normalize_failed',
|
|
638
|
+
endpoint: this.name,
|
|
639
|
+
}));
|
|
429
640
|
return;
|
|
430
|
-
|
|
641
|
+
}
|
|
642
|
+
this.#guildCatalog.upsertFromInbound(normalized.raw);
|
|
643
|
+
if (!this.#inboundDeduper.shouldProcess(`guild:${normalized.messageId}`)) {
|
|
644
|
+
logger.debug(formatCompact({
|
|
645
|
+
op: 'icqq_inbound_skip',
|
|
646
|
+
reason: 'dedupe',
|
|
647
|
+
endpoint: this.name,
|
|
648
|
+
id: `guild:${normalized.messageId}`,
|
|
649
|
+
}));
|
|
431
650
|
return;
|
|
651
|
+
}
|
|
432
652
|
this.admit({
|
|
433
653
|
id: normalized.messageId,
|
|
434
654
|
target: formatInboundTarget({
|
|
@@ -437,6 +657,7 @@ export class IcqqIpcEndpoint {
|
|
|
437
657
|
guildId: normalized.guildId,
|
|
438
658
|
}),
|
|
439
659
|
content: formatInboundContent(normalized.rawMessage),
|
|
660
|
+
segments: normalized.content,
|
|
440
661
|
sender: normalized.userId,
|
|
441
662
|
channelType: 'channel',
|
|
442
663
|
metadata: {
|
|
@@ -445,6 +666,71 @@ export class IcqqIpcEndpoint {
|
|
|
445
666
|
},
|
|
446
667
|
});
|
|
447
668
|
}
|
|
669
|
+
/** 收件箱行公共前缀:adapter 槽 localName + endpoint live 名(uin)。 */
|
|
670
|
+
#inboxBase() {
|
|
671
|
+
const id = String(this.#options.id);
|
|
672
|
+
return { adapter: id.split('\0').pop() ?? id, endpointId: this.name };
|
|
673
|
+
}
|
|
674
|
+
#recordInboxRequest(payload) {
|
|
675
|
+
const hooks = this.#options.inbox;
|
|
676
|
+
if (!hooks)
|
|
677
|
+
return;
|
|
678
|
+
const row = buildIcqqInboxRequestRow(payload, this.#inboxBase());
|
|
679
|
+
if (!row)
|
|
680
|
+
return;
|
|
681
|
+
if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`))
|
|
682
|
+
return;
|
|
683
|
+
void hooks.recordRequest(row);
|
|
684
|
+
hooks.publish?.('endpoint:request', row);
|
|
685
|
+
}
|
|
686
|
+
#recordInboxNotice(payload) {
|
|
687
|
+
const hooks = this.#options.inbox;
|
|
688
|
+
if (!hooks)
|
|
689
|
+
return;
|
|
690
|
+
const row = buildIcqqInboxNoticeRow(payload, this.#inboxBase());
|
|
691
|
+
if (!row)
|
|
692
|
+
return;
|
|
693
|
+
if (!this.#inboxDeduper.shouldProcess(`notice:${String(row.platform_notice_id)}`))
|
|
694
|
+
return;
|
|
695
|
+
void hooks.recordNotice(row);
|
|
696
|
+
hooks.publish?.('endpoint:notice', row);
|
|
697
|
+
}
|
|
698
|
+
/** 启动/重连后首拉 GET_SYSTEM_MSG:补录离线期间的好友/群待处理请求。 */
|
|
699
|
+
async #pullPendingSystemMessages() {
|
|
700
|
+
const hooks = this.#options.inbox;
|
|
701
|
+
if (!hooks || !this.ipc)
|
|
702
|
+
return;
|
|
703
|
+
try {
|
|
704
|
+
const resp = await this.ipc.request(Actions.GET_SYSTEM_MSG);
|
|
705
|
+
if (!resp.ok)
|
|
706
|
+
return;
|
|
707
|
+
const data = resp.data;
|
|
708
|
+
const base = this.#inboxBase();
|
|
709
|
+
const rows = [
|
|
710
|
+
...(Array.isArray(data?.friendRequests)
|
|
711
|
+
? data.friendRequests.map((m) => buildIcqqSystemRequestRow(m, 'friend', base))
|
|
712
|
+
: []),
|
|
713
|
+
...(Array.isArray(data?.groupRequests)
|
|
714
|
+
? data.groupRequests.map((m) => buildIcqqSystemRequestRow(m, 'group', base))
|
|
715
|
+
: []),
|
|
716
|
+
];
|
|
717
|
+
for (const row of rows) {
|
|
718
|
+
if (!row)
|
|
719
|
+
continue;
|
|
720
|
+
if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`))
|
|
721
|
+
continue;
|
|
722
|
+
void hooks.recordRequest(row);
|
|
723
|
+
hooks.publish?.('endpoint:request', row);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
catch (error) {
|
|
727
|
+
logger.debug(formatCompact({
|
|
728
|
+
op: 'inbox_pull_system_msg',
|
|
729
|
+
endpoint: this.name,
|
|
730
|
+
error: error instanceof Error ? error.message : String(error),
|
|
731
|
+
}));
|
|
732
|
+
}
|
|
733
|
+
}
|
|
448
734
|
}
|
|
449
735
|
/**
|
|
450
736
|
* 组装入站 metadata:在基础字段上补充 quote 链路信息。
|
|
@@ -474,6 +760,28 @@ function buildIcqqQuoteMetadata(data, base) {
|
|
|
474
760
|
metadata.quote_content = quoteText;
|
|
475
761
|
return metadata;
|
|
476
762
|
}
|
|
763
|
+
/** console RPC 传入的 gid/uid 可能是字符串,统一收敛为数字。 */
|
|
764
|
+
function toNumericId(value, label) {
|
|
765
|
+
const n = Number(value);
|
|
766
|
+
if (!Number.isFinite(n) || String(value).trim() === '') {
|
|
767
|
+
throw new TypeError(`icqq ${label} 必须是数字: ${String(value)}`);
|
|
768
|
+
}
|
|
769
|
+
return n;
|
|
770
|
+
}
|
|
771
|
+
function summarizeSegmentTypes(segments) {
|
|
772
|
+
if (!segments?.length)
|
|
773
|
+
return undefined;
|
|
774
|
+
return segments
|
|
775
|
+
.map((segment) => {
|
|
776
|
+
if (typeof segment.type === 'string')
|
|
777
|
+
return segment.type;
|
|
778
|
+
if (segment.type && typeof segment.type === 'object' && 'name' in segment.type) {
|
|
779
|
+
return String(segment.type.name);
|
|
780
|
+
}
|
|
781
|
+
return '?';
|
|
782
|
+
})
|
|
783
|
+
.join(',');
|
|
784
|
+
}
|
|
477
785
|
async function defaultCreateIpc(config) {
|
|
478
786
|
const uin = Number(config.name);
|
|
479
787
|
if (config.rpc) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const icqqEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `icqq endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
*
|
|
4
|
+
* ICQQ 的凭据由外部守护进程(@icqqjs/cli)按账号持有(~/.icqq/<uin>/),
|
|
5
|
+
* 其 IPC/RPC 协议是「按 uin 连接 + auth + 账号级 action」,没有 add-account /
|
|
6
|
+
* 触发 login 的 action——扫码登录只能经 `icqq login <uin>` CLI 完成。
|
|
7
|
+
* 因此 add 不走 kv 凭据录入,而是 bindFlow:校验 uin、把 { name: '<uin>' }
|
|
8
|
+
* 追加到 plugins.icqq.endpoints,并引导用户先完成守护进程登录。
|
|
9
|
+
*/
|
|
10
|
+
import { addEndpointToConfig, createEndpointCommands } from '@zhin.js/adapter';
|
|
11
|
+
import { defineCommand } from '@zhin.js/command';
|
|
12
|
+
import { icqqRuntimeStateToken } from './icqq-runtime-state.js';
|
|
13
|
+
export const icqqEndpointCommands = createEndpointCommands({
|
|
14
|
+
adapterKey: 'icqq',
|
|
15
|
+
adapterDisplayName: 'ICQQ',
|
|
16
|
+
running: (use) => use(icqqRuntimeStateToken).endpoints.values(),
|
|
17
|
+
describeEntry: (entry) => (entry.rpc ? 'rpc(远程守护进程)' : 'ipc(本地守护进程)'),
|
|
18
|
+
addDescription: '登记 ICQQ endpoint(uin 需先经 icqq login 完成扫码登录;重启 zhin 生效)',
|
|
19
|
+
bindFlow: ({ name }) => {
|
|
20
|
+
if (!name) {
|
|
21
|
+
return '用法:icqq endpoint add <uin>(uin 为纯数字 QQ 号,且需先执行 icqq login <uin> 完成扫码登录)';
|
|
22
|
+
}
|
|
23
|
+
if (!/^\d+$/.test(name)) {
|
|
24
|
+
return 'icqq endpoint 名必须是纯数字 QQ 号(uin)';
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const filePath = addEndpointToConfig('icqq', { name });
|
|
28
|
+
return (`✅ endpoint「${name}」已追加到 ${filePath} 的 plugins.icqq.endpoints。\n` +
|
|
29
|
+
`⚠️ 请确保已执行 icqq login ${name} 启动守护进程并完成扫码登录;重启 zhin 后生效。`);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return `添加失败:${error instanceof Error ? error.message : String(error)}`;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}, defineCommand);
|
package/lib/icqq-guild.d.ts
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { type MessageSegment } from "zhin.js";
|
|
5
5
|
import { type IpcGuildMessageEventData } from "./protocol.js";
|
|
6
|
-
|
|
6
|
+
/** Narrow request port shared by the real IPC client and endpoint test transports. */
|
|
7
|
+
export interface IcqqGuildRpc {
|
|
8
|
+
request(action: string, params?: Record<string, unknown>): Promise<{
|
|
9
|
+
ok: boolean;
|
|
10
|
+
data?: unknown;
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
7
13
|
export interface IcqqGuildChannelEntry {
|
|
8
14
|
channel_id: string;
|
|
9
15
|
channel_name: string;
|
|
@@ -27,7 +33,7 @@ export declare class IcqqGuildCatalog {
|
|
|
27
33
|
private readonly channels;
|
|
28
34
|
private readonly guilds;
|
|
29
35
|
clear(): void;
|
|
30
|
-
syncAll(ipc:
|
|
36
|
+
syncAll(ipc: IcqqGuildRpc): Promise<void>;
|
|
31
37
|
upsertFromInbound(data: IpcGuildMessageEventData): void;
|
|
32
38
|
getGuildChannelList(): Array<{
|
|
33
39
|
id: string;
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
/** 行级公共前缀:adapter 槽 localName + endpoint live 名(uin)。 */
|
|
11
|
+
export interface IcqqInboxBase {
|
|
12
|
+
readonly adapter: string;
|
|
13
|
+
readonly endpointId: string;
|
|
14
|
+
}
|
|
15
|
+
/** post_type=request / request.* 判定(OneBot 事件壳)。 */
|
|
16
|
+
export declare function isIcqqRequestPayload(payload: unknown): boolean;
|
|
17
|
+
/** post_type=notice / notice.* 判定。 */
|
|
18
|
+
export declare function isIcqqNoticePayload(payload: unknown): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* request 事件载荷 → unified_inbox_request 行。
|
|
21
|
+
* platform_request_id 取 flag(审批回执句柄),缺省回退 seq;两者皆无则无法审批,丢弃。
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildIcqqInboxRequestRow(payload: Record<string, unknown>, base: IcqqInboxBase): Record<string, unknown> | null;
|
|
24
|
+
/**
|
|
25
|
+
* notice 事件载荷 → unified_inbox_notice 行。
|
|
26
|
+
* 事件本身无稳定 id,platform_notice_id 由 time + actor + type 合成(配合去重)。
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildIcqqInboxNoticeRow(payload: Record<string, unknown>, base: IcqqInboxBase): Record<string, unknown> | null;
|
|
29
|
+
/**
|
|
30
|
+
* GET_SYSTEM_MSG 返回项 → unified_inbox_request 行(启动时首次拉取)。
|
|
31
|
+
* friend 列表项 type 语义即好友请求;group 列表项 type 为 add/invite(记 sub_type)。
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildIcqqSystemRequestRow(message: IpcSystemMessage, kind: 'friend' | 'group', base: IcqqInboxBase): Record<string, unknown> | null;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** post_type=request / request.* 判定(OneBot 事件壳)。 */
|
|
2
|
+
export function isIcqqRequestPayload(payload) {
|
|
3
|
+
const postType = payload?.post_type;
|
|
4
|
+
return typeof postType === 'string'
|
|
5
|
+
&& (postType === 'request' || postType.startsWith('request.'));
|
|
6
|
+
}
|
|
7
|
+
/** post_type=notice / notice.* 判定。 */
|
|
8
|
+
export function isIcqqNoticePayload(payload) {
|
|
9
|
+
const postType = payload?.post_type;
|
|
10
|
+
return typeof postType === 'string'
|
|
11
|
+
&& (postType === 'notice' || postType.startsWith('notice.'));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* request 事件载荷 → unified_inbox_request 行。
|
|
15
|
+
* platform_request_id 取 flag(审批回执句柄),缺省回退 seq;两者皆无则无法审批,丢弃。
|
|
16
|
+
*/
|
|
17
|
+
export function buildIcqqInboxRequestRow(payload, base) {
|
|
18
|
+
const userId = payload.user_id ?? payload.actor_id;
|
|
19
|
+
if (userId == null)
|
|
20
|
+
return null;
|
|
21
|
+
const flag = asNonEmptyString(payload.flag);
|
|
22
|
+
const platformRequestId = flag ?? (payload.seq != null ? String(payload.seq) : '');
|
|
23
|
+
if (!platformRequestId)
|
|
24
|
+
return null;
|
|
25
|
+
const requestType = asNonEmptyString(payload.request_type) ?? asNonEmptyString(payload.type);
|
|
26
|
+
const groupId = payload.group_id;
|
|
27
|
+
const isGroup = requestType === 'group' || groupId != null;
|
|
28
|
+
return {
|
|
29
|
+
adapter: base.adapter,
|
|
30
|
+
endpoint_id: base.endpointId,
|
|
31
|
+
platform_request_id: platformRequestId,
|
|
32
|
+
type: isGroup ? 'group' : (requestType ?? 'friend'),
|
|
33
|
+
scene_type: isGroup ? 'group' : null,
|
|
34
|
+
scene_id: String(groupId ?? userId),
|
|
35
|
+
sub_type: asNonEmptyString(payload.sub_type),
|
|
36
|
+
actor_id: String(userId),
|
|
37
|
+
actor_name: asNonEmptyString(payload.nickname),
|
|
38
|
+
comment: asNonEmptyString(payload.comment),
|
|
39
|
+
created_at: toMillis(payload.time),
|
|
40
|
+
resolved: 0,
|
|
41
|
+
resolved_at: null,
|
|
42
|
+
consumed: 0,
|
|
43
|
+
consumed_at: null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* notice 事件载荷 → unified_inbox_notice 行。
|
|
48
|
+
* 事件本身无稳定 id,platform_notice_id 由 time + actor + type 合成(配合去重)。
|
|
49
|
+
*/
|
|
50
|
+
export function buildIcqqInboxNoticeRow(payload, base) {
|
|
51
|
+
const noticeType = asNonEmptyString(payload.notice_type) ?? asNonEmptyString(payload.type);
|
|
52
|
+
if (!noticeType)
|
|
53
|
+
return null;
|
|
54
|
+
const subType = asNonEmptyString(payload.sub_type);
|
|
55
|
+
const userId = payload.user_id;
|
|
56
|
+
const operatorId = payload.operator_id;
|
|
57
|
+
const groupId = payload.group_id;
|
|
58
|
+
const createdAt = toMillis(payload.time);
|
|
59
|
+
const actorId = operatorId ?? userId;
|
|
60
|
+
const platformNoticeId = asNonEmptyString(payload.notice_id)
|
|
61
|
+
?? (payload.seq != null ? String(payload.seq) : undefined)
|
|
62
|
+
?? `${createdAt}_${String(actorId ?? '')}_${noticeType}${subType ? `.${subType}` : ''}`;
|
|
63
|
+
const targetId = userId != null && String(userId) !== String(actorId ?? '') ? userId : undefined;
|
|
64
|
+
return {
|
|
65
|
+
adapter: base.adapter,
|
|
66
|
+
endpoint_id: base.endpointId,
|
|
67
|
+
platform_notice_id: platformNoticeId,
|
|
68
|
+
type: noticeType,
|
|
69
|
+
scene_type: groupId != null ? 'group' : null,
|
|
70
|
+
scene_id: String(groupId ?? userId ?? operatorId ?? ''),
|
|
71
|
+
sub_type: subType,
|
|
72
|
+
actor_id: actorId != null ? String(actorId) : null,
|
|
73
|
+
actor_name: asNonEmptyString(payload.nickname),
|
|
74
|
+
target_id: targetId != null ? String(targetId) : null,
|
|
75
|
+
target_name: null,
|
|
76
|
+
payload: safeJson(payload),
|
|
77
|
+
created_at: createdAt,
|
|
78
|
+
consumed: 0,
|
|
79
|
+
consumed_at: null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* GET_SYSTEM_MSG 返回项 → unified_inbox_request 行(启动时首次拉取)。
|
|
84
|
+
* friend 列表项 type 语义即好友请求;group 列表项 type 为 add/invite(记 sub_type)。
|
|
85
|
+
*/
|
|
86
|
+
export function buildIcqqSystemRequestRow(message, kind, base) {
|
|
87
|
+
if (message.user_id == null)
|
|
88
|
+
return null;
|
|
89
|
+
const platformRequestId = asNonEmptyString(message.flag)
|
|
90
|
+
?? (message.seq != null ? String(message.seq) : '');
|
|
91
|
+
if (!platformRequestId)
|
|
92
|
+
return null;
|
|
93
|
+
return {
|
|
94
|
+
adapter: base.adapter,
|
|
95
|
+
endpoint_id: base.endpointId,
|
|
96
|
+
platform_request_id: platformRequestId,
|
|
97
|
+
type: kind,
|
|
98
|
+
scene_type: kind === 'group' ? 'group' : null,
|
|
99
|
+
scene_id: String(kind === 'group' ? (message.group_id ?? message.user_id) : message.user_id),
|
|
100
|
+
sub_type: kind === 'group' ? (asNonEmptyString(message.type) ?? null) : null,
|
|
101
|
+
actor_id: String(message.user_id),
|
|
102
|
+
actor_name: asNonEmptyString(message.nickname),
|
|
103
|
+
comment: asNonEmptyString(message.comment),
|
|
104
|
+
created_at: toMillis(message.time),
|
|
105
|
+
resolved: 0,
|
|
106
|
+
resolved_at: null,
|
|
107
|
+
consumed: 0,
|
|
108
|
+
consumed_at: null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function asNonEmptyString(value) {
|
|
112
|
+
if (typeof value !== 'string')
|
|
113
|
+
return null;
|
|
114
|
+
const trimmed = value.trim();
|
|
115
|
+
return trimmed ? trimmed : null;
|
|
116
|
+
}
|
|
117
|
+
/** oicq 事件 time 为秒;缺失时回退当前时间。 */
|
|
118
|
+
function toMillis(time) {
|
|
119
|
+
const seconds = Number(time);
|
|
120
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : Date.now();
|
|
121
|
+
}
|
|
122
|
+
function safeJson(value) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.stringify(value ?? {});
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return '{}';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const icqqRuntimeStateToken: import("zhin.js").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
|
|
@@ -0,0 +1,6 @@
|
|
|
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
|
+
export const icqqRuntimeStateToken = defineEndpointRuntimeStateToken('icqq');
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { Actions, formatInboundContent, formatInboundTarget, formatOutboundBody, parseSendTarget, resolveIcqqConfig, type IcqqAdapterConfig, type IcqqInboundMessage, type IcqqWireSegment, type IpcEvent, type IpcMessage, type IpcRequest, type IpcResponse, type ParsedIcqqSendTarget, type ResolvedIcqqConfig, } from './protocol.js';
|
|
2
|
-
export { IcqqIpcEndpoint, type CreateIcqqIpc, type IcqqEndpointOptions, type IcqqIpcTransport, } from './endpoint.js';
|
|
2
|
+
export { IcqqIpcEndpoint, type CreateIcqqIpc, type IcqqEndpointOptions, type IcqqInboxHooks, type IcqqIpcTransport, } 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 { IpcClient } from './ipc-client.js';
|