@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/src/endpoint.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* IcqqIpcEndpoint — lifecycle, outbound, IPC subscribe/admit for ICQQ daemon.
|
|
3
3
|
*/
|
|
4
|
-
import type { EndpointInstance } from '@zhin.js/adapter';
|
|
4
|
+
import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
5
5
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
6
6
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
7
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
@@ -19,6 +19,14 @@ import {
|
|
|
19
19
|
type IcqqIpcMessageEvent,
|
|
20
20
|
} from './icqq-inbound.js';
|
|
21
21
|
import {
|
|
22
|
+
buildIcqqInboxNoticeRow,
|
|
23
|
+
buildIcqqInboxRequestRow,
|
|
24
|
+
buildIcqqSystemRequestRow,
|
|
25
|
+
isIcqqNoticePayload,
|
|
26
|
+
isIcqqRequestPayload,
|
|
27
|
+
} from './icqq-inbox.js';
|
|
28
|
+
import {
|
|
29
|
+
IcqqGuildCatalog,
|
|
22
30
|
isIcqqGuildIpcEvent,
|
|
23
31
|
normalizeIcqqGuildInboundMessage,
|
|
24
32
|
} from './icqq-guild.js';
|
|
@@ -38,7 +46,7 @@ import {
|
|
|
38
46
|
type IpcResponse,
|
|
39
47
|
type ResolvedIcqqConfig,
|
|
40
48
|
} from './protocol.js';
|
|
41
|
-
import type { IpcFriendInfo, IpcGroupInfo } from './types.js';
|
|
49
|
+
import type { IpcFriendInfo, IpcGroupInfo, IpcMemberInfo, IpcSystemMessage } from './types.js';
|
|
42
50
|
|
|
43
51
|
const logger = getLogger('icqq');
|
|
44
52
|
|
|
@@ -56,11 +64,23 @@ export interface IcqqIpcTransport {
|
|
|
56
64
|
|
|
57
65
|
export type CreateIcqqIpc = (config: ResolvedIcqqConfig) => Promise<IcqqIpcTransport>;
|
|
58
66
|
|
|
67
|
+
/**
|
|
68
|
+
* 收件箱写/推钩子(由装配层注入,见 adapters/icqq.ts)。
|
|
69
|
+
* record* 写 unified_inbox_request/notice;publish 向 console hub 推送实时事件。
|
|
70
|
+
*/
|
|
71
|
+
export interface IcqqInboxHooks {
|
|
72
|
+
recordRequest(row: Record<string, unknown>): void | Promise<void>;
|
|
73
|
+
recordNotice(row: Record<string, unknown>): void | Promise<void>;
|
|
74
|
+
publish?(type: string, data: unknown): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
export interface IcqqEndpointOptions {
|
|
60
78
|
readonly id: CapabilityId;
|
|
61
79
|
readonly gateway: MessageGateway;
|
|
62
80
|
readonly config: ResolvedIcqqConfig;
|
|
63
81
|
readonly createIpc?: CreateIcqqIpc;
|
|
82
|
+
/** 未注入时 request/notice 事件仅按非消息载荷忽略(不报错)。 */
|
|
83
|
+
readonly inbox?: IcqqInboxHooks;
|
|
64
84
|
}
|
|
65
85
|
|
|
66
86
|
export class IcqqIpcEndpoint implements EndpointInstance {
|
|
@@ -71,10 +91,28 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
71
91
|
ipc!: IcqqIpcTransport;
|
|
72
92
|
readonly friends = new Map<number, IpcFriendInfo>();
|
|
73
93
|
readonly groups = new Map<number, IpcGroupInfo>();
|
|
94
|
+
readonly #guildCatalog = new IcqqGuildCatalog();
|
|
95
|
+
readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
|
|
96
|
+
listFriends: () => this.getFriendList(),
|
|
97
|
+
listGroups: () => this.getGroupList(),
|
|
98
|
+
listChannels: async () => {
|
|
99
|
+
await this.#guildCatalog.syncAll(this.ipc);
|
|
100
|
+
return this.#guildCatalog.getGuildChannelList();
|
|
101
|
+
},
|
|
102
|
+
listGroupMembers: (groupId) => this.getGroupMemberList(groupId),
|
|
103
|
+
approveRequest: (requestId, remark) => this.approveRequest(requestId, remark),
|
|
104
|
+
rejectRequest: (requestId, reason) => this.rejectRequest(requestId, reason),
|
|
105
|
+
kickGroupMember: (groupId, userId) => this.removeMember(groupId, userId),
|
|
106
|
+
muteGroupMember: (groupId, userId, duration) => this.muteMember(groupId, userId, duration),
|
|
107
|
+
setGroupAdmin: (groupId, userId, enabled) => this.setModerator(groupId, userId, enabled),
|
|
108
|
+
deleteFriend: (userId) => this.deleteFriend(userId),
|
|
109
|
+
});
|
|
74
110
|
#open = false;
|
|
75
111
|
#started = false;
|
|
76
112
|
#subscriptions: Array<{ unsubscribe: () => Promise<void> }> = [];
|
|
77
113
|
#inboundDeduper = new InboundMessageDeduper();
|
|
114
|
+
/** request/notice 去重(推送事件与 GET_SYSTEM_MSG 首拉可能重复同一 flag)。 */
|
|
115
|
+
#inboxDeduper = new InboundMessageDeduper();
|
|
78
116
|
#unregisterAgent?: () => void;
|
|
79
117
|
/** 用户主动 stop 时为 true,阻止自动重连 */
|
|
80
118
|
#intentionalDisconnect = false;
|
|
@@ -130,6 +168,9 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
130
168
|
this.#handleEvent(event);
|
|
131
169
|
});
|
|
132
170
|
this.#subscriptions.push(sub);
|
|
171
|
+
// daemon 推送 client.em 全部事件,request/notice 实时到达;
|
|
172
|
+
// 启动时补一次 GET_SYSTEM_MSG,捞离线期间积存的待处理请求。
|
|
173
|
+
void this.#pullPendingSystemMessages();
|
|
133
174
|
}
|
|
134
175
|
|
|
135
176
|
/** IPC/RPC 意外断开时调度重连(指数退避) */
|
|
@@ -203,8 +244,10 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
203
244
|
this.#unregisterAgent?.();
|
|
204
245
|
this.#unregisterAgent = undefined;
|
|
205
246
|
this.#inboundDeduper.clear();
|
|
247
|
+
this.#inboxDeduper.clear();
|
|
206
248
|
this.friends.clear();
|
|
207
249
|
this.groups.clear();
|
|
250
|
+
this.#guildCatalog.clear();
|
|
208
251
|
try {
|
|
209
252
|
this.ipc?.close();
|
|
210
253
|
} catch {
|
|
@@ -404,15 +447,215 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
404
447
|
}
|
|
405
448
|
}
|
|
406
449
|
|
|
450
|
+
// ── 社交/群管(Console endpoint RPC 探测面)────────────────────────
|
|
451
|
+
// console-rpc-extended.ts 按方法名探测这些接口;全部薄封装走 IPC daemon。
|
|
452
|
+
|
|
453
|
+
/** EndpointManagement.listFriends —— 好友列表(LIST_FRIENDS,归一为 {user_id, nickname, remark})。 */
|
|
454
|
+
async getFriendList(): Promise<Array<{ user_id: number; nickname: string; remark: string }>> {
|
|
455
|
+
const resp = await this.#mustRequest(Actions.LIST_FRIENDS, undefined, '获取好友列表');
|
|
456
|
+
const list = Array.isArray(resp.data) ? (resp.data as IpcFriendInfo[]) : [];
|
|
457
|
+
return list.map((f) => ({
|
|
458
|
+
user_id: f.user_id,
|
|
459
|
+
nickname: f.nickname,
|
|
460
|
+
remark: f.remark ?? '',
|
|
461
|
+
}));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** EndpointManagement.listGroups —— 群列表(LIST_GROUPS,归一为 {group_id, name})。 */
|
|
465
|
+
async getGroupList(): Promise<Array<{ group_id: number; name: string }>> {
|
|
466
|
+
const resp = await this.#mustRequest(Actions.LIST_GROUPS, undefined, '获取群列表');
|
|
467
|
+
const list = Array.isArray(resp.data) ? (resp.data as IpcGroupInfo[]) : [];
|
|
468
|
+
return list.map((g) => ({
|
|
469
|
+
group_id: g.group_id,
|
|
470
|
+
name: g.group_name,
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** EndpointManagement.listGroupMembers —— 群成员列表(字段对齐 daemon 返回)。 */
|
|
475
|
+
async getGroupMemberList(groupId: number | string): Promise<IpcMemberInfo[]> {
|
|
476
|
+
const resp = await this.#mustRequest(
|
|
477
|
+
Actions.LIST_GROUP_MEMBERS,
|
|
478
|
+
{ group_id: toNumericId(groupId, 'group_id') },
|
|
479
|
+
'获取群成员列表',
|
|
480
|
+
);
|
|
481
|
+
return Array.isArray(resp.data) ? (resp.data as IpcMemberInfo[]) : [];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** getGroupMemberList 别名(console 探测 listMembers / getMemberList)。 */
|
|
485
|
+
listMembers(groupId: number | string): Promise<IpcMemberInfo[]> {
|
|
486
|
+
return this.getGroupMemberList(groupId);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
getMemberList(groupId: number | string): Promise<IpcMemberInfo[]> {
|
|
490
|
+
return this.getGroupMemberList(groupId);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* EndpointManagement.resolveRequest —— id 为 inbox 行的 platform_request_id。
|
|
495
|
+
* 先 GET_SYSTEM_MSG 按 flag(回退 seq)定位请求,再按类型路由 handle_friend/group_request。
|
|
496
|
+
*/
|
|
497
|
+
async approveRequest(id: string, remark?: string): Promise<void> {
|
|
498
|
+
await this.#handleSystemRequest(id, true, { remark });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async rejectRequest(id: string, reason?: string): Promise<void> {
|
|
502
|
+
await this.#handleSystemRequest(id, false, { reason });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** EndpointManagement.deleteFriend —— FRIEND_DELETE。 */
|
|
506
|
+
async deleteFriend(userId: number | string): Promise<void> {
|
|
507
|
+
await this.#mustRequest(
|
|
508
|
+
Actions.FRIEND_DELETE,
|
|
509
|
+
{ user_id: toNumericId(userId, 'user_id') },
|
|
510
|
+
'删除好友',
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** deleteFriend 别名(console 探测 delete_friend)。 */
|
|
515
|
+
delete_friend(userId: number | string): Promise<void> {
|
|
516
|
+
return this.deleteFriend(userId);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** EndpointManagement.kickGroupMember —— GROUP_KICK。 */
|
|
520
|
+
async removeMember(groupId: number | string, userId: number | string): Promise<void> {
|
|
521
|
+
await this.#mustRequest(
|
|
522
|
+
Actions.GROUP_KICK,
|
|
523
|
+
{ group_id: toNumericId(groupId, 'group_id'), user_id: toNumericId(userId, 'user_id') },
|
|
524
|
+
'踢出群成员',
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
kickMember(groupId: number | string, userId: number | string): Promise<void> {
|
|
529
|
+
return this.removeMember(groupId, userId);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
setGroupKick(groupId: number | string, userId: number | string): Promise<void> {
|
|
533
|
+
return this.removeMember(groupId, userId);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** EndpointManagement.muteGroupMember —— GROUP_MUTE(duration 秒,默认 600)。 */
|
|
537
|
+
async muteMember(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
|
|
538
|
+
await this.#mustRequest(
|
|
539
|
+
Actions.GROUP_MUTE,
|
|
540
|
+
{
|
|
541
|
+
group_id: toNumericId(groupId, 'group_id'),
|
|
542
|
+
user_id: toNumericId(userId, 'user_id'),
|
|
543
|
+
duration,
|
|
544
|
+
},
|
|
545
|
+
'禁言群成员',
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
banMember(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
|
|
550
|
+
return this.muteMember(groupId, userId, duration);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
setGroupMute(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
|
|
554
|
+
return this.muteMember(groupId, userId, duration);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** EndpointManagement.setGroupAdmin —— SET_GROUP_ADMIN(enable 默认 true)。 */
|
|
558
|
+
async setModerator(groupId: number | string, userId: number | string, enable = true): Promise<void> {
|
|
559
|
+
await this.#mustRequest(
|
|
560
|
+
Actions.SET_GROUP_ADMIN,
|
|
561
|
+
{
|
|
562
|
+
group_id: toNumericId(groupId, 'group_id'),
|
|
563
|
+
user_id: toNumericId(userId, 'user_id'),
|
|
564
|
+
enable,
|
|
565
|
+
},
|
|
566
|
+
'设置群管理员',
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
setAdmin(groupId: number | string, userId: number | string, enable = true): Promise<void> {
|
|
571
|
+
return this.setModerator(groupId, userId, enable);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
setGroupAdmin(groupId: number | string, userId: number | string, enable = true): Promise<void> {
|
|
575
|
+
return this.setModerator(groupId, userId, enable);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** IPC 请求并统一错误上下文:daemon 返回 ok=false 时抛出带操作名的错误。 */
|
|
579
|
+
async #mustRequest(
|
|
580
|
+
action: string,
|
|
581
|
+
params: Record<string, unknown> | undefined,
|
|
582
|
+
label: string,
|
|
583
|
+
): Promise<IpcResponse> {
|
|
584
|
+
const resp = await this.request(action, params);
|
|
585
|
+
if (!resp.ok) {
|
|
586
|
+
throw new Error(`icqq ${label}失败: ${resp.error ?? 'daemon 未返回错误详情'}`);
|
|
587
|
+
}
|
|
588
|
+
return resp;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** 按 flag/seq 在 GET_SYSTEM_MSG 中定位请求,路由到好友/群请求处理 action。 */
|
|
592
|
+
async #handleSystemRequest(
|
|
593
|
+
id: string,
|
|
594
|
+
approve: boolean,
|
|
595
|
+
extra: { remark?: string; reason?: string },
|
|
596
|
+
): Promise<void> {
|
|
597
|
+
const resp = await this.#mustRequest(Actions.GET_SYSTEM_MSG, undefined, '获取待处理请求');
|
|
598
|
+
const data = resp.data as
|
|
599
|
+
| { friendRequests?: unknown; groupRequests?: unknown }
|
|
600
|
+
| undefined;
|
|
601
|
+
const friendRequests = Array.isArray(data?.friendRequests)
|
|
602
|
+
? (data.friendRequests as IpcSystemMessage[])
|
|
603
|
+
: [];
|
|
604
|
+
const groupRequests = Array.isArray(data?.groupRequests)
|
|
605
|
+
? (data.groupRequests as IpcSystemMessage[])
|
|
606
|
+
: [];
|
|
607
|
+
const matches = (m: IpcSystemMessage): boolean =>
|
|
608
|
+
m.flag === id || (m.seq != null && String(m.seq) === id);
|
|
609
|
+
const friend = friendRequests.find(matches);
|
|
610
|
+
const group = friend ? undefined : groupRequests.find(matches);
|
|
611
|
+
const target = friend ?? group;
|
|
612
|
+
if (!target?.flag) {
|
|
613
|
+
throw new Error(`icqq 未找到待处理请求: ${id}(GET_SYSTEM_MSG 中无匹配 flag/seq)`);
|
|
614
|
+
}
|
|
615
|
+
const params: Record<string, unknown> = { flag: target.flag, approve };
|
|
616
|
+
if (friend) {
|
|
617
|
+
if (extra.remark) params.remark = extra.remark;
|
|
618
|
+
await this.#mustRequest(Actions.HANDLE_FRIEND_REQUEST, params, '处理好友请求');
|
|
619
|
+
} else {
|
|
620
|
+
if (extra.reason) params.reason = extra.reason;
|
|
621
|
+
await this.#mustRequest(Actions.HANDLE_GROUP_REQUEST, params, '处理群请求');
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
407
625
|
/** Test / internal: admit when open. */
|
|
408
626
|
admit(msg: IcqqInboundMessage): void {
|
|
409
|
-
if (!this.#open)
|
|
410
|
-
|
|
411
|
-
|
|
627
|
+
if (!this.#open) {
|
|
628
|
+
logger.debug(formatCompact({
|
|
629
|
+
op: 'icqq_inbound_skip',
|
|
630
|
+
reason: 'endpoint_closed',
|
|
631
|
+
endpoint: this.name,
|
|
632
|
+
id: msg.id,
|
|
633
|
+
target: msg.target,
|
|
634
|
+
}));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
// 新 Runtime Message.content 为纯文本:@ 本机(uin = name)经结构化段 / metadata 传递
|
|
638
|
+
const mentioned = isIcqqBotMentioned({
|
|
639
|
+
uin: this.name,
|
|
640
|
+
content: msg.segments,
|
|
641
|
+
rawMessage: msg.content,
|
|
642
|
+
});
|
|
643
|
+
logger.debug(formatCompact({
|
|
644
|
+
op: 'icqq_inbound_admit',
|
|
645
|
+
endpoint: this.name,
|
|
646
|
+
id: msg.id,
|
|
647
|
+
target: msg.target,
|
|
648
|
+
sender: msg.sender,
|
|
649
|
+
channelType: msg.channelType,
|
|
650
|
+
mentioned: mentioned || undefined,
|
|
651
|
+
preview: msg.content.slice(0, 80),
|
|
652
|
+
segments: summarizeSegmentTypes(msg.segments),
|
|
653
|
+
}));
|
|
412
654
|
void this.#options.gateway.receive({
|
|
413
655
|
adapter: this.#options.id,
|
|
414
656
|
target: msg.target,
|
|
415
657
|
content: msg.content,
|
|
658
|
+
...(msg.segments ? { segments: msg.segments } : {}),
|
|
416
659
|
sender: msg.sender,
|
|
417
660
|
id: msg.id,
|
|
418
661
|
metadata: Object.freeze({
|
|
@@ -456,12 +699,46 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
456
699
|
}
|
|
457
700
|
const payload = unwrapIcqqIpcEventPayload(event);
|
|
458
701
|
if (!payload || typeof payload !== 'object') return;
|
|
702
|
+
if (isIcqqRequestPayload(payload)) {
|
|
703
|
+
this.#recordInboxRequest(payload as Record<string, unknown>);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (isIcqqNoticePayload(payload)) {
|
|
707
|
+
this.#recordInboxNotice(payload as Record<string, unknown>);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
459
710
|
if (!isIcqqMessagePostType(payload)) return;
|
|
460
711
|
const data = payload as IcqqIpcMessageEvent;
|
|
461
|
-
if (shouldSkipSelfInboundMessage(data))
|
|
712
|
+
if (shouldSkipSelfInboundMessage(data)) {
|
|
713
|
+
logger.debug(formatCompact({
|
|
714
|
+
op: 'icqq_inbound_skip',
|
|
715
|
+
reason: 'self',
|
|
716
|
+
endpoint: this.name,
|
|
717
|
+
user_id: data.user_id,
|
|
718
|
+
self_id: data.self_id,
|
|
719
|
+
}));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
462
722
|
const normalized = normalizeIcqqInboundMessage(data);
|
|
463
|
-
if (!normalized)
|
|
464
|
-
|
|
723
|
+
if (!normalized) {
|
|
724
|
+
logger.debug(formatCompact({
|
|
725
|
+
op: 'icqq_inbound_skip',
|
|
726
|
+
reason: 'normalize_failed',
|
|
727
|
+
endpoint: this.name,
|
|
728
|
+
message_id: data.message_id,
|
|
729
|
+
message_type: data.message_type ?? data.type,
|
|
730
|
+
}));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (!this.#inboundDeduper.shouldProcess(normalized.messageId)) {
|
|
734
|
+
logger.debug(formatCompact({
|
|
735
|
+
op: 'icqq_inbound_skip',
|
|
736
|
+
reason: 'dedupe',
|
|
737
|
+
endpoint: this.name,
|
|
738
|
+
id: normalized.messageId,
|
|
739
|
+
}));
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
465
742
|
this.admit({
|
|
466
743
|
id: normalized.messageId,
|
|
467
744
|
target: formatInboundTarget({
|
|
@@ -470,6 +747,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
470
747
|
channelParentGroupId: normalized.channelParentGroupId,
|
|
471
748
|
}),
|
|
472
749
|
content: formatInboundContent(normalized.rawMessage),
|
|
750
|
+
segments: normalized.content,
|
|
473
751
|
sender: normalized.userId,
|
|
474
752
|
channelType: normalized.channelType,
|
|
475
753
|
metadata: buildIcqqQuoteMetadata(data, {
|
|
@@ -485,8 +763,24 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
485
763
|
const normalized = normalizeIcqqGuildInboundMessage(
|
|
486
764
|
payload as Parameters<typeof normalizeIcqqGuildInboundMessage>[0],
|
|
487
765
|
);
|
|
488
|
-
if (!normalized)
|
|
489
|
-
|
|
766
|
+
if (!normalized) {
|
|
767
|
+
logger.debug(formatCompact({
|
|
768
|
+
op: 'icqq_inbound_skip',
|
|
769
|
+
reason: 'guild_normalize_failed',
|
|
770
|
+
endpoint: this.name,
|
|
771
|
+
}));
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
this.#guildCatalog.upsertFromInbound(normalized.raw);
|
|
775
|
+
if (!this.#inboundDeduper.shouldProcess(`guild:${normalized.messageId}`)) {
|
|
776
|
+
logger.debug(formatCompact({
|
|
777
|
+
op: 'icqq_inbound_skip',
|
|
778
|
+
reason: 'dedupe',
|
|
779
|
+
endpoint: this.name,
|
|
780
|
+
id: `guild:${normalized.messageId}`,
|
|
781
|
+
}));
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
490
784
|
this.admit({
|
|
491
785
|
id: normalized.messageId,
|
|
492
786
|
target: formatInboundTarget({
|
|
@@ -495,6 +789,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
495
789
|
guildId: normalized.guildId,
|
|
496
790
|
}),
|
|
497
791
|
content: formatInboundContent(normalized.rawMessage),
|
|
792
|
+
segments: normalized.content,
|
|
498
793
|
sender: normalized.userId,
|
|
499
794
|
channelType: 'channel',
|
|
500
795
|
metadata: {
|
|
@@ -503,6 +798,68 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
503
798
|
},
|
|
504
799
|
});
|
|
505
800
|
}
|
|
801
|
+
|
|
802
|
+
/** 收件箱行公共前缀:adapter 槽 localName + endpoint live 名(uin)。 */
|
|
803
|
+
#inboxBase(): { adapter: string; endpointId: string } {
|
|
804
|
+
const id = String(this.#options.id);
|
|
805
|
+
return { adapter: id.split('\0').pop() ?? id, endpointId: this.name };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
#recordInboxRequest(payload: Record<string, unknown>): void {
|
|
809
|
+
const hooks = this.#options.inbox;
|
|
810
|
+
if (!hooks) return;
|
|
811
|
+
const row = buildIcqqInboxRequestRow(payload, this.#inboxBase());
|
|
812
|
+
if (!row) return;
|
|
813
|
+
if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`)) return;
|
|
814
|
+
void hooks.recordRequest(row);
|
|
815
|
+
hooks.publish?.('endpoint:request', row);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
#recordInboxNotice(payload: Record<string, unknown>): void {
|
|
819
|
+
const hooks = this.#options.inbox;
|
|
820
|
+
if (!hooks) return;
|
|
821
|
+
const row = buildIcqqInboxNoticeRow(payload, this.#inboxBase());
|
|
822
|
+
if (!row) return;
|
|
823
|
+
if (!this.#inboxDeduper.shouldProcess(`notice:${String(row.platform_notice_id)}`)) return;
|
|
824
|
+
void hooks.recordNotice(row);
|
|
825
|
+
hooks.publish?.('endpoint:notice', row);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** 启动/重连后首拉 GET_SYSTEM_MSG:补录离线期间的好友/群待处理请求。 */
|
|
829
|
+
async #pullPendingSystemMessages(): Promise<void> {
|
|
830
|
+
const hooks = this.#options.inbox;
|
|
831
|
+
if (!hooks || !this.ipc) return;
|
|
832
|
+
try {
|
|
833
|
+
const resp = await this.ipc.request(Actions.GET_SYSTEM_MSG);
|
|
834
|
+
if (!resp.ok) return;
|
|
835
|
+
const data = resp.data as
|
|
836
|
+
| { friendRequests?: unknown; groupRequests?: unknown }
|
|
837
|
+
| undefined;
|
|
838
|
+
const base = this.#inboxBase();
|
|
839
|
+
const rows = [
|
|
840
|
+
...(Array.isArray(data?.friendRequests)
|
|
841
|
+
? (data.friendRequests as IpcSystemMessage[]).map((m) =>
|
|
842
|
+
buildIcqqSystemRequestRow(m, 'friend', base))
|
|
843
|
+
: []),
|
|
844
|
+
...(Array.isArray(data?.groupRequests)
|
|
845
|
+
? (data.groupRequests as IpcSystemMessage[]).map((m) =>
|
|
846
|
+
buildIcqqSystemRequestRow(m, 'group', base))
|
|
847
|
+
: []),
|
|
848
|
+
];
|
|
849
|
+
for (const row of rows) {
|
|
850
|
+
if (!row) continue;
|
|
851
|
+
if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`)) continue;
|
|
852
|
+
void hooks.recordRequest(row);
|
|
853
|
+
hooks.publish?.('endpoint:request', row);
|
|
854
|
+
}
|
|
855
|
+
} catch (error) {
|
|
856
|
+
logger.debug(formatCompact({
|
|
857
|
+
op: 'inbox_pull_system_msg',
|
|
858
|
+
endpoint: this.name,
|
|
859
|
+
error: error instanceof Error ? error.message : String(error),
|
|
860
|
+
}));
|
|
861
|
+
}
|
|
862
|
+
}
|
|
506
863
|
}
|
|
507
864
|
|
|
508
865
|
/**
|
|
@@ -536,6 +893,30 @@ function buildIcqqQuoteMetadata(
|
|
|
536
893
|
return metadata;
|
|
537
894
|
}
|
|
538
895
|
|
|
896
|
+
/** console RPC 传入的 gid/uid 可能是字符串,统一收敛为数字。 */
|
|
897
|
+
function toNumericId(value: number | string, label: string): number {
|
|
898
|
+
const n = Number(value);
|
|
899
|
+
if (!Number.isFinite(n) || String(value).trim() === '') {
|
|
900
|
+
throw new TypeError(`icqq ${label} 必须是数字: ${String(value)}`);
|
|
901
|
+
}
|
|
902
|
+
return n;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function summarizeSegmentTypes(
|
|
906
|
+
segments: readonly { readonly type?: unknown }[] | undefined,
|
|
907
|
+
): string | undefined {
|
|
908
|
+
if (!segments?.length) return undefined;
|
|
909
|
+
return segments
|
|
910
|
+
.map((segment) => {
|
|
911
|
+
if (typeof segment.type === 'string') return segment.type;
|
|
912
|
+
if (segment.type && typeof segment.type === 'object' && 'name' in segment.type) {
|
|
913
|
+
return String((segment.type as { name: unknown }).name);
|
|
914
|
+
}
|
|
915
|
+
return '?';
|
|
916
|
+
})
|
|
917
|
+
.join(',');
|
|
918
|
+
}
|
|
919
|
+
|
|
539
920
|
async function defaultCreateIpc(config: ResolvedIcqqConfig): Promise<IcqqIpcTransport> {
|
|
540
921
|
const uin = Number(config.name);
|
|
541
922
|
if (config.rpc) {
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
|
|
14
|
+
export const icqqEndpointCommands = createEndpointCommands({
|
|
15
|
+
adapterKey: 'icqq',
|
|
16
|
+
adapterDisplayName: 'ICQQ',
|
|
17
|
+
running: (use) => use(icqqRuntimeStateToken).endpoints.values(),
|
|
18
|
+
describeEntry: (entry) => (entry.rpc ? 'rpc(远程守护进程)' : 'ipc(本地守护进程)'),
|
|
19
|
+
addDescription: '登记 ICQQ endpoint(uin 需先经 icqq login 完成扫码登录;重启 zhin 生效)',
|
|
20
|
+
bindFlow: ({ name }) => {
|
|
21
|
+
if (!name) {
|
|
22
|
+
return '用法:icqq endpoint add <uin>(uin 为纯数字 QQ 号,且需先执行 icqq login <uin> 完成扫码登录)';
|
|
23
|
+
}
|
|
24
|
+
if (!/^\d+$/.test(name)) {
|
|
25
|
+
return 'icqq endpoint 名必须是纯数字 QQ 号(uin)';
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const filePath = addEndpointToConfig('icqq', { name });
|
|
29
|
+
return (
|
|
30
|
+
`✅ endpoint「${name}」已追加到 ${filePath} 的 plugins.icqq.endpoints。\n` +
|
|
31
|
+
`⚠️ 请确保已执行 icqq login ${name} 启动守护进程并完成扫码登录;重启 zhin 后生效。`
|
|
32
|
+
);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return `添加失败:${error instanceof Error ? error.message : String(error)}`;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
}, defineCommand);
|
package/src/icqq-guild.ts
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { toCanonicalSegments, type MessageSegment } from "zhin.js";
|
|
5
5
|
import { Actions, type IpcGuildMessageEventData } from "./protocol.js";
|
|
6
|
-
import type { IpcClient } from "./ipc-client.js";
|
|
7
6
|
import { parseCqMessage } from "./cq-message.js";
|
|
8
7
|
|
|
8
|
+
/** Narrow request port shared by the real IPC client and endpoint test transports. */
|
|
9
|
+
export interface IcqqGuildRpc {
|
|
10
|
+
request(
|
|
11
|
+
action: string,
|
|
12
|
+
params?: Record<string, unknown>,
|
|
13
|
+
): Promise<{ ok: boolean; data?: unknown }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
export interface IcqqGuildChannelEntry {
|
|
10
17
|
channel_id: string;
|
|
11
18
|
channel_name: string;
|
|
@@ -36,7 +43,7 @@ export class IcqqGuildCatalog {
|
|
|
36
43
|
this.guilds.clear();
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
async syncAll(ipc:
|
|
46
|
+
async syncAll(ipc: IcqqGuildRpc): Promise<void> {
|
|
40
47
|
const listResp = await ipc.request(Actions.GUILD_LIST);
|
|
41
48
|
if (!listResp.ok) return;
|
|
42
49
|
|