@zhin.js/adapter-icqq 8.0.8 → 8.0.10
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 +34 -0
- package/README.md +36 -28
- package/adapters/icqq.js +4 -9
- package/adapters/icqq.ts +5 -14
- package/agent/PERMITS.md +2 -0
- package/lib/agent-prompt.js +17 -22
- package/lib/cq-message.d.ts +2 -2
- package/lib/cq-message.js +7 -2
- package/lib/endpoint.d.ts +4 -74
- package/lib/endpoint.js +348 -507
- package/lib/forward-msg.d.ts +7 -8
- package/lib/forward-msg.js +11 -24
- package/lib/icqq-agent-deps.d.ts +3 -15
- package/lib/icqq-agent-deps.js +0 -5
- package/lib/icqq-endpoint-commands.js +4 -13
- package/lib/icqq-guild.d.ts +15 -11
- package/lib/icqq-guild.js +9 -17
- package/lib/icqq-inbound.d.ts +20 -20
- package/lib/icqq-inbound.js +9 -9
- package/lib/icqq-inbox.d.ts +2 -10
- package/lib/index.d.ts +2 -3
- package/lib/index.js +2 -3
- package/lib/outbound-media.d.ts +3 -8
- package/lib/outbound-media.js +3 -8
- package/lib/protocol.d.ts +38 -57
- package/lib/protocol.js +67 -21
- package/lib/types.d.ts +4 -40
- package/lib/types.js +0 -5
- package/package.json +26 -14
- package/plugin.js +6 -1
- package/schema.json +119 -61
- package/src/agent-prompt.ts +17 -22
- package/src/cq-message.ts +7 -2
- package/src/endpoint.ts +359 -600
- package/src/forward-msg.ts +12 -26
- package/src/icqq-agent-deps.ts +3 -17
- package/src/icqq-endpoint-commands.ts +4 -13
- package/src/icqq-guild.ts +16 -25
- package/src/icqq-inbound.ts +26 -26
- package/src/icqq-inbox.ts +2 -10
- package/src/icqqjs-icqq.d.ts +88 -0
- package/src/index.ts +2 -9
- package/src/outbound-media.ts +4 -9
- package/src/protocol.ts +108 -66
- package/src/types.ts +4 -41
- package/tools/announce.js +19 -0
- package/tools/announce.ts +18 -0
- package/tools/essence.js +24 -0
- package/{agent/tools → tools}/essence.ts +8 -8
- package/tools/friend_list.js +20 -0
- package/{agent/tools → tools}/friend_list.ts +7 -7
- package/tools/group_files.js +25 -0
- package/tools/group_files.ts +23 -0
- package/tools/group_invite.js +25 -0
- package/tools/group_invite.ts +22 -0
- package/tools/group_kick.js +28 -0
- package/tools/group_kick.ts +24 -0
- package/tools/group_list.js +21 -0
- package/{agent/tools → tools}/group_list.ts +7 -7
- package/tools/group_sign.js +21 -0
- package/tools/group_sign.ts +19 -0
- package/tools/list_muted.js +20 -0
- package/tools/list_muted.ts +19 -0
- package/tools/poke.js +19 -0
- package/tools/poke.ts +18 -0
- package/tools/send_user_like.js +28 -0
- package/tools/send_user_like.ts +25 -0
- package/tools/set_anonymous.js +23 -0
- package/tools/set_anonymous.ts +21 -0
- package/tools/set_mute.js +26 -0
- package/tools/set_mute.ts +23 -0
- package/tools/set_title.js +27 -0
- package/tools/set_title.ts +24 -0
- package/tools/user_info.js +17 -0
- package/tools/user_info.ts +16 -0
- package/agent/tools/announce.ts +0 -21
- package/agent/tools/group_files.ts +0 -27
- package/agent/tools/group_invite.ts +0 -20
- package/agent/tools/group_sign.ts +0 -19
- package/agent/tools/list_muted.ts +0 -22
- package/agent/tools/poke.ts +0 -20
- package/agent/tools/send_user_like.ts +0 -22
- package/agent/tools/set_anonymous.ts +0 -22
- package/agent/tools/set_title.ts +0 -25
- package/lib/ipc-client.d.ts +0 -73
- package/lib/ipc-client.js +0 -358
- package/src/ipc-client.ts +0 -417
package/src/endpoint.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
Client,
|
|
3
|
+
parseGroupMessageId,
|
|
4
|
+
type Config as NativeIcqqClientConfig,
|
|
5
|
+
type Sendable,
|
|
6
|
+
} from '@icqqjs/icqq';
|
|
7
|
+
import { inspect } from 'node:util';
|
|
4
8
|
import type {
|
|
5
9
|
EndpointControl,
|
|
6
10
|
EndpointInstance,
|
|
@@ -8,6 +12,7 @@ import type {
|
|
|
8
12
|
EndpointSendRequest,
|
|
9
13
|
} from '@zhin.js/adapter';
|
|
10
14
|
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
15
|
+
import type { MessageRef } from '@zhin.js/im-contract';
|
|
11
16
|
import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
|
|
12
17
|
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
13
18
|
import { registerIcqqAgentEndpoint } from './icqq-agent-deps.js';
|
|
@@ -20,8 +25,7 @@ import {
|
|
|
20
25
|
quotedPayloadFromIcqqSource,
|
|
21
26
|
resolveIcqqQuoteIdFromEvent,
|
|
22
27
|
shouldSkipSelfInboundMessage,
|
|
23
|
-
|
|
24
|
-
type IcqqIpcMessageEvent,
|
|
28
|
+
type IcqqMessageEvent,
|
|
25
29
|
} from './icqq-inbound.js';
|
|
26
30
|
import {
|
|
27
31
|
buildIcqqInboxNoticeRow,
|
|
@@ -32,45 +36,22 @@ import {
|
|
|
32
36
|
} from './icqq-inbox.js';
|
|
33
37
|
import {
|
|
34
38
|
IcqqGuildCatalog,
|
|
35
|
-
isIcqqGuildIpcEvent,
|
|
36
39
|
normalizeIcqqGuildInboundMessage,
|
|
37
40
|
} from './icqq-guild.js';
|
|
38
|
-
import { IpcClient } from './ipc-client.js';
|
|
39
41
|
import {
|
|
40
42
|
materializeOutboundBase64,
|
|
41
43
|
resolveIcqqOutboundMediaMode,
|
|
42
44
|
} from './outbound-media.js';
|
|
43
45
|
import {
|
|
44
|
-
Actions,
|
|
45
46
|
formatInboundContent,
|
|
46
47
|
formatOutboundBody,
|
|
47
48
|
icqqInboundConversation,
|
|
48
49
|
icqqOutboundTarget,
|
|
49
50
|
type IcqqInboundMessage,
|
|
50
|
-
type IpcEvent,
|
|
51
|
-
type IpcResponse,
|
|
52
51
|
type ResolvedIcqqConfig,
|
|
53
52
|
} from './protocol.js';
|
|
54
|
-
import type {
|
|
55
|
-
|
|
56
|
-
/** Minimal IPC surface used by the endpoint (real IpcClient or test mock). */
|
|
57
|
-
export interface IcqqIpcTransport {
|
|
58
|
-
request(action: string, params?: Record<string, unknown>): Promise<IpcResponse>;
|
|
59
|
-
subscribe(
|
|
60
|
-
action: string,
|
|
61
|
-
params: Record<string, unknown>,
|
|
62
|
-
handler: (event: IpcEvent) => void,
|
|
63
|
-
): { unsubscribe: () => Promise<void> };
|
|
64
|
-
setOnRemoteDisconnect(handler: (() => void) | null): void;
|
|
65
|
-
close(): void;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export type CreateIcqqIpc = (config: ResolvedIcqqConfig) => Promise<IcqqIpcTransport>;
|
|
53
|
+
import type { SystemMessage as IcqqSystemMessage } from './types.js';
|
|
69
54
|
|
|
70
|
-
/**
|
|
71
|
-
* 收件箱写/推钩子(由装配层注入,见 adapters/icqq.ts)。
|
|
72
|
-
* record* 写 unified_inbox_request/notice;publish 向 console hub 推送实时事件。
|
|
73
|
-
*/
|
|
74
55
|
export interface IcqqInboxHooks {
|
|
75
56
|
recordRequest(row: Record<string, unknown>): void | Promise<void>;
|
|
76
57
|
recordNotice(row: Record<string, unknown>): void | Promise<void>;
|
|
@@ -81,156 +62,156 @@ export interface IcqqEndpointOptions {
|
|
|
81
62
|
readonly id: CapabilityId;
|
|
82
63
|
readonly gateway: MessageGateway;
|
|
83
64
|
readonly config: ResolvedIcqqConfig;
|
|
84
|
-
readonly createIpc?: CreateIcqqIpc;
|
|
85
|
-
/** 未注入时 request/notice 事件仅按非消息载荷忽略(不报错)。 */
|
|
86
65
|
readonly inbox?: IcqqInboxHooks;
|
|
87
66
|
}
|
|
88
67
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
68
|
+
const BOUND_EVENTS = [
|
|
69
|
+
'message.private',
|
|
70
|
+
'message.group',
|
|
71
|
+
'message.guild',
|
|
72
|
+
'request.friend',
|
|
73
|
+
'request.group',
|
|
74
|
+
'notice.friend',
|
|
75
|
+
'notice.group',
|
|
76
|
+
'system.online',
|
|
77
|
+
'system.offline',
|
|
78
|
+
'system.offline.network',
|
|
79
|
+
'system.offline.kickoff',
|
|
80
|
+
'system.login.qrcode',
|
|
81
|
+
'system.login.device',
|
|
82
|
+
'system.login.slider',
|
|
83
|
+
'system.login.error',
|
|
84
|
+
'system.login.auth',
|
|
85
|
+
] as const;
|
|
86
|
+
|
|
87
|
+
export class IcqqEndpoint extends Client implements EndpointInstance {
|
|
88
|
+
readonly #logger: ReturnType<typeof getAdapterLogger>;
|
|
92
89
|
readonly #options: IcqqEndpointOptions;
|
|
93
|
-
readonly
|
|
94
|
-
readonly name: string;
|
|
95
|
-
/** Populated after start(); agent tools read this. */
|
|
96
|
-
ipc!: IcqqIpcTransport;
|
|
97
|
-
readonly friends = new Map<number, IpcFriendInfo>();
|
|
98
|
-
readonly groups = new Map<number, IpcGroupInfo>();
|
|
90
|
+
readonly endpointName: string;
|
|
99
91
|
readonly #guildCatalog = new IcqqGuildCatalog();
|
|
92
|
+
readonly #inboundDeduper = new InboundMessageDeduper();
|
|
93
|
+
readonly #inboxDeduper = new InboundMessageDeduper();
|
|
94
|
+
#open = false;
|
|
95
|
+
#started = false;
|
|
96
|
+
#heldInbound: IcqqInboundMessage[] = [];
|
|
97
|
+
#unregisterAgent?: () => void;
|
|
98
|
+
|
|
100
99
|
readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
|
|
101
|
-
listFriends: () =>
|
|
102
|
-
|
|
100
|
+
listFriends: async () =>
|
|
101
|
+
Array.from(this.fl.values()).map((f) => ({
|
|
102
|
+
user_id: f.user_id,
|
|
103
|
+
nickname: f.nickname,
|
|
104
|
+
remark: f.remark ?? '',
|
|
105
|
+
})),
|
|
106
|
+
listGroups: async () =>
|
|
107
|
+
Array.from(this.gl.values()).map((g) => ({
|
|
108
|
+
group_id: g.group_id,
|
|
109
|
+
name: g.group_name,
|
|
110
|
+
})),
|
|
103
111
|
listChannels: async () => {
|
|
104
|
-
await this.#guildCatalog.syncAll(this
|
|
112
|
+
await this.#guildCatalog.syncAll(this);
|
|
105
113
|
return this.#guildCatalog.getGuildChannelList();
|
|
106
114
|
},
|
|
107
|
-
listGroupMembers: (groupId) =>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
115
|
+
listGroupMembers: async (groupId) =>
|
|
116
|
+
Array.from((await this.getGroupMemberList(Number(groupId))).values()),
|
|
117
|
+
approveRequest: (requestId, remark) => this.#approveRequest(requestId, remark),
|
|
118
|
+
rejectRequest: (requestId, reason) => this.#rejectRequest(requestId, reason),
|
|
119
|
+
kickGroupMember: async (groupId, userId) => {
|
|
120
|
+
await this.setGroupKick(Number(groupId), Number(userId));
|
|
121
|
+
},
|
|
122
|
+
muteGroupMember: async (groupId, userId, duration) => {
|
|
123
|
+
await this.setGroupBan(Number(groupId), Number(userId), duration);
|
|
124
|
+
},
|
|
125
|
+
setGroupAdmin: async (groupId, userId, enabled) => {
|
|
126
|
+
await this.setGroupAdmin(Number(groupId), Number(userId), enabled);
|
|
127
|
+
},
|
|
128
|
+
deleteFriend: async (userId) => {
|
|
129
|
+
await this.deleteFriend(Number(userId));
|
|
130
|
+
},
|
|
114
131
|
});
|
|
132
|
+
|
|
115
133
|
readonly control: EndpointControl = Object.freeze({
|
|
116
|
-
recall: (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
134
|
+
recall: async (message: MessageRef) => {
|
|
135
|
+
if (!message.id || message.id.startsWith('outbound:')) return;
|
|
136
|
+
await this.deleteMsg(message.id);
|
|
137
|
+
},
|
|
138
|
+
addReaction: async (message: MessageRef, emoji: string) => {
|
|
139
|
+
const target = resolveIcqqGroupReactionTarget(message);
|
|
140
|
+
if (!target) return null;
|
|
141
|
+
try {
|
|
142
|
+
// Do not await protocol ACK — packet timeout must not stall the AI reply.
|
|
143
|
+
this.#ignoreReactionAck(
|
|
144
|
+
'add',
|
|
145
|
+
this.pickGroup(target.groupId).setReaction(target.seq, emoji),
|
|
146
|
+
);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
this.#logger.debug(formatCompact({
|
|
149
|
+
op: 'reaction_add_failed',
|
|
150
|
+
error: error instanceof Error ? error.message : String(error),
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
return emoji;
|
|
154
|
+
},
|
|
155
|
+
removeReaction: async (message: MessageRef, reactionId: string) => {
|
|
156
|
+
const target = resolveIcqqGroupReactionTarget(message);
|
|
157
|
+
if (!target) return;
|
|
158
|
+
try {
|
|
159
|
+
this.#ignoreReactionAck(
|
|
160
|
+
'remove',
|
|
161
|
+
this.pickGroup(target.groupId).delReaction(target.seq, reactionId),
|
|
162
|
+
);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
this.#logger.debug(formatCompact({
|
|
165
|
+
op: 'reaction_remove_failed',
|
|
166
|
+
error: error instanceof Error ? error.message : String(error),
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
},
|
|
120
170
|
});
|
|
121
|
-
#open = false;
|
|
122
|
-
#started = false;
|
|
123
|
-
#subscriptions: Array<{ unsubscribe: () => Promise<void> }> = [];
|
|
124
|
-
#inboundDeduper = new InboundMessageDeduper();
|
|
125
|
-
/** request/notice 去重(推送事件与 GET_SYSTEM_MSG 首拉可能重复同一 flag)。 */
|
|
126
|
-
#inboxDeduper = new InboundMessageDeduper();
|
|
127
|
-
#unregisterAgent?: () => void;
|
|
128
|
-
/** 用户主动 stop 时为 true,阻止自动重连 */
|
|
129
|
-
#intentionalDisconnect = false;
|
|
130
|
-
/** 是否已有重连循环在跑(避免多次 schedule 叠套) */
|
|
131
|
-
#reconnectRunning = false;
|
|
132
171
|
|
|
133
172
|
constructor(options: IcqqEndpointOptions) {
|
|
173
|
+
const nativeConfig = resolveNativeClientConfig(options.config);
|
|
174
|
+
super(Number(options.config.id), nativeConfig);
|
|
134
175
|
this.#logger = getAdapterLogger('icqq', options.config.id);
|
|
135
176
|
this.#options = options;
|
|
136
|
-
this.
|
|
137
|
-
this.#createIpc = options.createIpc ?? defaultCreateIpc;
|
|
177
|
+
this.endpointName = options.config.id;
|
|
138
178
|
}
|
|
139
179
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
180
|
+
#ignoreReactionAck(action: 'add' | 'remove', task: Promise<unknown> | unknown): void {
|
|
181
|
+
void Promise.resolve(task).catch((error) => {
|
|
182
|
+
this.#logger.debug(formatCompact({
|
|
183
|
+
op: `reaction_${action}_failed`,
|
|
184
|
+
error: error instanceof Error ? error.message : String(error),
|
|
185
|
+
}));
|
|
186
|
+
});
|
|
145
187
|
}
|
|
146
188
|
|
|
147
189
|
async start(): Promise<void> {
|
|
148
190
|
if (this.#started) return;
|
|
149
191
|
this.#started = true;
|
|
150
|
-
this.#intentionalDisconnect = false;
|
|
151
192
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
193
|
+
const onlineReady = new Promise<void>((resolve) => {
|
|
194
|
+
this.on('system.online', () => resolve());
|
|
195
|
+
});
|
|
196
|
+
this.#bindClientEvents();
|
|
197
|
+
await this.login(this.#options.config.password);
|
|
198
|
+
await onlineReady;
|
|
199
|
+
await this.#pullPendingSystemMessages();
|
|
200
|
+
this.#unregisterAgent = registerIcqqAgentEndpoint(this.endpointName, this);
|
|
154
201
|
this.#logger.info(
|
|
155
|
-
`connected (
|
|
202
|
+
`connected (direct) | friends: ${this.fl.size} | groups: ${this.gl.size}`,
|
|
156
203
|
);
|
|
157
204
|
} catch (error) {
|
|
158
205
|
await this.stop();
|
|
159
|
-
|
|
160
|
-
// keep this at debug to avoid a duplicate error-level log.
|
|
161
|
-
this.#logger.debug(`Failed to connect ICQQ IPC: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
206
|
+
this.#logger.debug(`Failed to connect ICQQ client: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
162
207
|
throw error;
|
|
163
208
|
}
|
|
164
209
|
}
|
|
165
210
|
|
|
166
|
-
/** 建立或恢复与守护进程的 IPC/RPC 会话(订阅、缓存列表) */
|
|
167
|
-
async #bindIpcSession(): Promise<void> {
|
|
168
|
-
// 旧连接上的订阅句柄已失效,不再对死 socket 发 unsubscribe
|
|
169
|
-
this.#subscriptions = [];
|
|
170
|
-
this.ipc = await this.#createIpc(this.#options.config);
|
|
171
|
-
if (this.#options.config.autoReconnect !== false) {
|
|
172
|
-
this.ipc.setOnRemoteDisconnect(() => this.#scheduleReconnect());
|
|
173
|
-
}
|
|
174
|
-
await this.#refreshLists();
|
|
175
|
-
const sub = this.ipc.subscribe(Actions.SUBSCRIBE, {}, (event) => {
|
|
176
|
-
this.#handleEvent(event);
|
|
177
|
-
});
|
|
178
|
-
this.#subscriptions.push(sub);
|
|
179
|
-
// daemon 推送 client.em 全部事件,request/notice 实时到达;
|
|
180
|
-
// 启动时补一次 GET_SYSTEM_MSG,捞离线期间积存的待处理请求。
|
|
181
|
-
void this.#pullPendingSystemMessages();
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** IPC/RPC 意外断开时调度重连(指数退避) */
|
|
185
|
-
#scheduleReconnect(): void {
|
|
186
|
-
if (this.#options.config.autoReconnect === false) return;
|
|
187
|
-
if (this.#intentionalDisconnect) return;
|
|
188
|
-
if (this.#reconnectRunning) return;
|
|
189
|
-
this.#reconnectRunning = true;
|
|
190
|
-
void this.#runReconnectLoop();
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
async #runReconnectLoop(): Promise<void> {
|
|
194
|
-
try {
|
|
195
|
-
for (let attempt = 0; !this.#intentionalDisconnect; attempt++) {
|
|
196
|
-
const base = Math.min(30_000, 500 * 2 ** Math.min(attempt, 6));
|
|
197
|
-
const jitter = Math.floor(Math.random() * 400);
|
|
198
|
-
const delayMs = base + jitter;
|
|
199
|
-
// 首次断开 WARN,后续重试静默为 DEBUG,避免刷屏
|
|
200
|
-
const disconnectLog = attempt === 0 ? this.#logger.warn.bind(this.#logger) : this.#logger.debug.bind(this.#logger);
|
|
201
|
-
disconnectLog(formatCompact({
|
|
202
|
-
op: 'disconnect',
|
|
203
|
-
ok: false,
|
|
204
|
-
delay_ms: delayMs,
|
|
205
|
-
attempt: attempt + 1,
|
|
206
|
-
}));
|
|
207
|
-
await new Promise<void>((r) => setTimeout(r, delayMs));
|
|
208
|
-
if (this.#intentionalDisconnect) break;
|
|
209
|
-
try {
|
|
210
|
-
await this.#bindIpcSession();
|
|
211
|
-
this.#logger.info(
|
|
212
|
-
`reconnected after ${attempt + 1} attempt(s)`
|
|
213
|
-
+ ` | friends: ${this.friends.size} | groups: ${this.groups.size}`,
|
|
214
|
-
);
|
|
215
|
-
break;
|
|
216
|
-
} catch (error) {
|
|
217
|
-
const retryLog = attempt === 0 ? this.#logger.warn.bind(this.#logger) : this.#logger.debug.bind(this.#logger);
|
|
218
|
-
retryLog(formatCompact({
|
|
219
|
-
op: 'reconnect',
|
|
220
|
-
endpoint: this.name,
|
|
221
|
-
ok: false,
|
|
222
|
-
attempt: attempt + 1,
|
|
223
|
-
error: error instanceof Error ? error.message : String(error),
|
|
224
|
-
}));
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
} finally {
|
|
228
|
-
this.#reconnectRunning = false;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
211
|
open(): void {
|
|
233
212
|
this.#open = true;
|
|
213
|
+
const held = this.#heldInbound.splice(0);
|
|
214
|
+
for (const msg of held) this.admit(msg);
|
|
234
215
|
}
|
|
235
216
|
|
|
236
217
|
close(): void {
|
|
@@ -238,409 +219,89 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
238
219
|
}
|
|
239
220
|
|
|
240
221
|
async stop(): Promise<void> {
|
|
241
|
-
this.#intentionalDisconnect = true;
|
|
242
222
|
this.#open = false;
|
|
243
|
-
this.
|
|
244
|
-
for (const sub of this.#subscriptions.splice(0)) {
|
|
245
|
-
await sub.unsubscribe().catch(() => { /* ignore */ });
|
|
246
|
-
}
|
|
223
|
+
this.#heldInbound.length = 0;
|
|
247
224
|
this.#unregisterAgent?.();
|
|
248
225
|
this.#unregisterAgent = undefined;
|
|
226
|
+
for (const event of BOUND_EVENTS) this.off(event);
|
|
227
|
+
try {
|
|
228
|
+
await this.logout?.();
|
|
229
|
+
} catch { /* ignore */ }
|
|
230
|
+
try {
|
|
231
|
+
this.terminate();
|
|
232
|
+
} catch { /* ignore */ }
|
|
249
233
|
this.#inboundDeduper.clear();
|
|
250
234
|
this.#inboxDeduper.clear();
|
|
251
|
-
this.friends.clear();
|
|
252
|
-
this.groups.clear();
|
|
253
235
|
this.#guildCatalog.clear();
|
|
254
|
-
try {
|
|
255
|
-
this.ipc?.close();
|
|
256
|
-
} catch {
|
|
257
|
-
/* ignore */
|
|
258
|
-
}
|
|
259
|
-
// 置空 ipc:start 前 / stop 后的 request() 走防御性报错而非 TypeError
|
|
260
|
-
this.ipc = undefined as unknown as IcqqIpcTransport;
|
|
261
236
|
this.#started = false;
|
|
262
237
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
263
238
|
}
|
|
264
239
|
|
|
265
240
|
async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
|
|
266
|
-
if (!this
|
|
267
|
-
throw new Error(`icqq endpoint ${this.name} 未连接(IPC 未初始化或已停止),无法发送消息`);
|
|
268
|
-
}
|
|
241
|
+
if (!this.#started) throw new Error('icqq endpoint 未连接');
|
|
269
242
|
const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
|
|
270
243
|
const content = Array.isArray(payload)
|
|
271
244
|
? materializeOutboundBase64(payload, mediaMode)
|
|
272
245
|
: payload;
|
|
273
246
|
const message = formatOutboundBody(content);
|
|
274
247
|
const parsed = icqqOutboundTarget(conversation);
|
|
275
|
-
let
|
|
276
|
-
let params: Record<string, unknown>;
|
|
277
|
-
switch (parsed.kind) {
|
|
278
|
-
case 'private':
|
|
279
|
-
action = Actions.SEND_PRIVATE_MSG;
|
|
280
|
-
params = { user_id: parsed.userId, message };
|
|
281
|
-
break;
|
|
282
|
-
case 'group':
|
|
283
|
-
action = Actions.SEND_GROUP_MSG;
|
|
284
|
-
params = { group_id: parsed.groupId, message };
|
|
285
|
-
break;
|
|
286
|
-
case 'temp':
|
|
287
|
-
action = Actions.SEND_TEMP_MSG;
|
|
288
|
-
params = {
|
|
289
|
-
group_id: parsed.groupId,
|
|
290
|
-
user_id: parsed.userId,
|
|
291
|
-
message,
|
|
292
|
-
};
|
|
293
|
-
break;
|
|
294
|
-
case 'channel':
|
|
295
|
-
action = Actions.GUILD_SEND_MSG;
|
|
296
|
-
params = {
|
|
297
|
-
guild_id: parsed.guildId,
|
|
298
|
-
channel_id: parsed.channelId,
|
|
299
|
-
message,
|
|
300
|
-
};
|
|
301
|
-
break;
|
|
302
|
-
}
|
|
303
|
-
const resp = await this.ipc.request(action, params);
|
|
304
|
-
if (!resp.ok) {
|
|
305
|
-
this.#logger.warn(
|
|
306
|
-
`send failed ${conversation.kind}:${conversation.id} | ${resp.error} | ${truncatePreview(typeof message === 'string' ? message : String(message), 80)}`,
|
|
307
|
-
);
|
|
308
|
-
throw new Error(`发送消息失败: ${resp.error}`);
|
|
309
|
-
}
|
|
310
|
-
const messageId = String(
|
|
311
|
-
(resp.data as { message_id?: unknown } | undefined)?.message_id ?? `sent_${Date.now()}`,
|
|
312
|
-
);
|
|
313
|
-
this.#logger.info(
|
|
314
|
-
`send ${conversation.kind}:${conversation.id} | id: ${messageId} | ${truncatePreview(typeof message === 'string' ? message : String(message), 80)}`,
|
|
315
|
-
);
|
|
316
|
-
return messageId;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// ── 消息回应(activity-feedback / typing reaction)────────────────────
|
|
320
|
-
|
|
321
|
-
private static readonly EMOJI_MAP: Record<string, string> = {
|
|
322
|
-
'⏳': '1468368274',
|
|
323
|
-
'👍': '128077',
|
|
324
|
-
'❤️': '10084',
|
|
325
|
-
'😊': '128522',
|
|
326
|
-
'🎉': '127881',
|
|
327
|
-
'🔥': '128293',
|
|
328
|
-
'✅': '9989',
|
|
329
|
-
'❌': '10060',
|
|
330
|
-
'⭐': '11088',
|
|
331
|
-
'💯': '128175',
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
private getEmojiId(emoji: string): string {
|
|
335
|
-
if (/^\d+$/u.test(emoji)) return emoji;
|
|
336
|
-
return IcqqIpcEndpoint.EMOJI_MAP[emoji] ?? String(emoji.codePointAt(0) || 0);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/** Activity-feedback / OutboundHost reaction surface. */
|
|
340
|
-
async addReaction(messageId: string, emoji: string): Promise<string | null> {
|
|
341
|
-
try {
|
|
342
|
-
const emojiId = this.getEmojiId(emoji);
|
|
343
|
-
const resp = await this.request(Actions.GROUP_SET_REACTION, {
|
|
344
|
-
message_id: messageId,
|
|
345
|
-
id: emojiId,
|
|
346
|
-
});
|
|
347
|
-
if (!resp.ok) {
|
|
348
|
-
this.#logger.warn(formatCompact({
|
|
349
|
-
op: 'add_reaction',
|
|
350
|
-
endpoint: this.name,
|
|
351
|
-
message_id: messageId,
|
|
352
|
-
emoji,
|
|
353
|
-
id: emojiId,
|
|
354
|
-
ok: false,
|
|
355
|
-
error: resp.error,
|
|
356
|
-
}));
|
|
357
|
-
return null;
|
|
358
|
-
}
|
|
359
|
-
this.#logger.debug(formatCompact({
|
|
360
|
-
op: 'add_reaction',
|
|
361
|
-
endpoint: this.name,
|
|
362
|
-
message_id: messageId,
|
|
363
|
-
emoji,
|
|
364
|
-
id: emojiId,
|
|
365
|
-
ok: true,
|
|
366
|
-
}));
|
|
367
|
-
return emojiId;
|
|
368
|
-
} catch (error) {
|
|
369
|
-
this.#logger.warn(formatCompact({
|
|
370
|
-
op: 'add_reaction',
|
|
371
|
-
endpoint: this.name,
|
|
372
|
-
message_id: messageId,
|
|
373
|
-
emoji,
|
|
374
|
-
ok: false,
|
|
375
|
-
error: error instanceof Error ? error.message : String(error),
|
|
376
|
-
}));
|
|
377
|
-
return null;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
async removeReaction(messageId: string, reactionId: string): Promise<void> {
|
|
382
|
-
try {
|
|
383
|
-
const parts = reactionId.split('_');
|
|
384
|
-
const emojiId = parts.length >= 2 ? parts[1]! : reactionId;
|
|
385
|
-
const resp = await this.request(Actions.GROUP_DEL_REACTION, {
|
|
386
|
-
message_id: messageId,
|
|
387
|
-
id: emojiId,
|
|
388
|
-
});
|
|
389
|
-
if (!resp.ok) {
|
|
390
|
-
this.#logger.warn(formatCompact({
|
|
391
|
-
op: 'remove_reaction',
|
|
392
|
-
endpoint: this.name,
|
|
393
|
-
message_id: messageId,
|
|
394
|
-
reaction_id: reactionId,
|
|
395
|
-
id: emojiId,
|
|
396
|
-
ok: false,
|
|
397
|
-
error: resp.error,
|
|
398
|
-
}));
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
this.#logger.debug(formatCompact({
|
|
402
|
-
op: 'remove_reaction',
|
|
403
|
-
endpoint: this.name,
|
|
404
|
-
message_id: messageId,
|
|
405
|
-
reaction_id: reactionId,
|
|
406
|
-
id: emojiId,
|
|
407
|
-
ok: true,
|
|
408
|
-
}));
|
|
409
|
-
} catch (error) {
|
|
410
|
-
this.#logger.warn(formatCompact({
|
|
411
|
-
op: 'remove_reaction',
|
|
412
|
-
endpoint: this.name,
|
|
413
|
-
message_id: messageId,
|
|
414
|
-
reaction_id: reactionId,
|
|
415
|
-
ok: false,
|
|
416
|
-
error: error instanceof Error ? error.message : String(error),
|
|
417
|
-
}));
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/** Activity-feedback autoRemove / Console recall. */
|
|
422
|
-
async recallMessage(messageId: string): Promise<void> {
|
|
423
|
-
if (!messageId || messageId.startsWith('outbound:')) return;
|
|
248
|
+
let result: { message_id?: unknown };
|
|
424
249
|
try {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
250
|
+
switch (parsed.kind) {
|
|
251
|
+
case 'private':
|
|
252
|
+
result = await this.sendPrivateMsg(parsed.userId, message as Sendable);
|
|
253
|
+
break;
|
|
254
|
+
case 'group':
|
|
255
|
+
result = await this.sendGroupMsg(parsed.groupId, message as Sendable);
|
|
256
|
+
break;
|
|
257
|
+
case 'temp':
|
|
258
|
+
result = await this.sendTempMsg(parsed.groupId, parsed.userId, message as Sendable);
|
|
259
|
+
break;
|
|
260
|
+
case 'channel':
|
|
261
|
+
result = await this.sendGuildMsg(parsed.guildId, parsed.channelId, message as Sendable) as unknown as { message_id?: unknown };
|
|
262
|
+
break;
|
|
435
263
|
}
|
|
436
|
-
this.#logger.debug(formatCompact({
|
|
437
|
-
op: 'recall_message',
|
|
438
|
-
endpoint: this.name,
|
|
439
|
-
message_id: messageId,
|
|
440
|
-
ok: true,
|
|
441
|
-
}));
|
|
442
264
|
} catch (error) {
|
|
443
265
|
this.#logger.warn(formatCompact({
|
|
444
|
-
op: '
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
error: error instanceof Error ? error.message : String(error),
|
|
266
|
+
op: 'icqq_send_failed',
|
|
267
|
+
target: `${conversation.kind}:${conversation.id}`,
|
|
268
|
+
error: describeUnknownError(error),
|
|
269
|
+
preview: truncatePreview(typeof message === 'string' ? message : String(message), 120),
|
|
449
270
|
}));
|
|
271
|
+
throw error;
|
|
450
272
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
// console-rpc-extended.ts 按方法名探测这些接口;全部薄封装走 IPC daemon。
|
|
455
|
-
|
|
456
|
-
/** EndpointManagement.listFriends —— 好友列表(LIST_FRIENDS,归一为 {user_id, nickname, remark})。 */
|
|
457
|
-
async getFriendList(): Promise<Array<{ user_id: number; nickname: string; remark: string }>> {
|
|
458
|
-
const resp = await this.#mustRequest(Actions.LIST_FRIENDS, undefined, '获取好友列表');
|
|
459
|
-
const list = Array.isArray(resp.data) ? (resp.data as IpcFriendInfo[]) : [];
|
|
460
|
-
return list.map((f) => ({
|
|
461
|
-
user_id: f.user_id,
|
|
462
|
-
nickname: f.nickname,
|
|
463
|
-
remark: f.remark ?? '',
|
|
464
|
-
}));
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/** EndpointManagement.listGroups —— 群列表(LIST_GROUPS,归一为 {group_id, name})。 */
|
|
468
|
-
async getGroupList(): Promise<Array<{ group_id: number; name: string }>> {
|
|
469
|
-
const resp = await this.#mustRequest(Actions.LIST_GROUPS, undefined, '获取群列表');
|
|
470
|
-
const list = Array.isArray(resp.data) ? (resp.data as IpcGroupInfo[]) : [];
|
|
471
|
-
return list.map((g) => ({
|
|
472
|
-
group_id: g.group_id,
|
|
473
|
-
name: g.group_name,
|
|
474
|
-
}));
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
/** EndpointManagement.listGroupMembers —— 群成员列表(字段对齐 daemon 返回)。 */
|
|
478
|
-
async getGroupMemberList(groupId: number | string): Promise<IpcMemberInfo[]> {
|
|
479
|
-
const resp = await this.#mustRequest(
|
|
480
|
-
Actions.LIST_GROUP_MEMBERS,
|
|
481
|
-
{ group_id: toNumericId(groupId, 'group_id') },
|
|
482
|
-
'获取群成员列表',
|
|
483
|
-
);
|
|
484
|
-
return Array.isArray(resp.data) ? (resp.data as IpcMemberInfo[]) : [];
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/** getGroupMemberList 别名(console 探测 listMembers / getMemberList)。 */
|
|
488
|
-
listMembers(groupId: number | string): Promise<IpcMemberInfo[]> {
|
|
489
|
-
return this.getGroupMemberList(groupId);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
getMemberList(groupId: number | string): Promise<IpcMemberInfo[]> {
|
|
493
|
-
return this.getGroupMemberList(groupId);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* EndpointManagement.resolveRequest —— id 为 inbox 行的 platform_request_id。
|
|
498
|
-
* 先 GET_SYSTEM_MSG 按 flag(回退 seq)定位请求,再按类型路由 handle_friend/group_request。
|
|
499
|
-
*/
|
|
500
|
-
async approveRequest(id: string, remark?: string): Promise<void> {
|
|
501
|
-
await this.#handleSystemRequest(id, true, { remark });
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
async rejectRequest(id: string, reason?: string): Promise<void> {
|
|
505
|
-
await this.#handleSystemRequest(id, false, { reason });
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/** EndpointManagement.deleteFriend —— FRIEND_DELETE。 */
|
|
509
|
-
async deleteFriend(userId: number | string): Promise<void> {
|
|
510
|
-
await this.#mustRequest(
|
|
511
|
-
Actions.FRIEND_DELETE,
|
|
512
|
-
{ user_id: toNumericId(userId, 'user_id') },
|
|
513
|
-
'删除好友',
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/** deleteFriend 别名(console 探测 delete_friend)。 */
|
|
518
|
-
delete_friend(userId: number | string): Promise<void> {
|
|
519
|
-
return this.deleteFriend(userId);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/** EndpointManagement.kickGroupMember —— GROUP_KICK。 */
|
|
523
|
-
async removeMember(groupId: number | string, userId: number | string): Promise<void> {
|
|
524
|
-
await this.#mustRequest(
|
|
525
|
-
Actions.GROUP_KICK,
|
|
526
|
-
{ group_id: toNumericId(groupId, 'group_id'), user_id: toNumericId(userId, 'user_id') },
|
|
527
|
-
'踢出群成员',
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
kickMember(groupId: number | string, userId: number | string): Promise<void> {
|
|
532
|
-
return this.removeMember(groupId, userId);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
setGroupKick(groupId: number | string, userId: number | string): Promise<void> {
|
|
536
|
-
return this.removeMember(groupId, userId);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/** EndpointManagement.muteGroupMember —— GROUP_MUTE(duration 秒,默认 600)。 */
|
|
540
|
-
async muteMember(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
|
|
541
|
-
await this.#mustRequest(
|
|
542
|
-
Actions.GROUP_MUTE,
|
|
543
|
-
{
|
|
544
|
-
group_id: toNumericId(groupId, 'group_id'),
|
|
545
|
-
user_id: toNumericId(userId, 'user_id'),
|
|
546
|
-
duration,
|
|
547
|
-
},
|
|
548
|
-
'禁言群成员',
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
banMember(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
|
|
553
|
-
return this.muteMember(groupId, userId, duration);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
setGroupMute(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
|
|
557
|
-
return this.muteMember(groupId, userId, duration);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
/** EndpointManagement.setGroupAdmin —— SET_GROUP_ADMIN(enable 默认 true)。 */
|
|
561
|
-
async setModerator(groupId: number | string, userId: number | string, enable = true): Promise<void> {
|
|
562
|
-
await this.#mustRequest(
|
|
563
|
-
Actions.SET_GROUP_ADMIN,
|
|
564
|
-
{
|
|
565
|
-
group_id: toNumericId(groupId, 'group_id'),
|
|
566
|
-
user_id: toNumericId(userId, 'user_id'),
|
|
567
|
-
enable,
|
|
568
|
-
},
|
|
569
|
-
'设置群管理员',
|
|
273
|
+
const messageId = String(result?.message_id ?? `sent_${Date.now()}`);
|
|
274
|
+
this.#logger.info(
|
|
275
|
+
`send ${conversation.kind}:${conversation.id} | id: ${messageId} | ${truncatePreview(typeof message === 'string' ? message : String(message), 80)}`,
|
|
570
276
|
);
|
|
277
|
+
return messageId;
|
|
571
278
|
}
|
|
572
279
|
|
|
573
|
-
setAdmin(groupId: number | string, userId: number | string, enable = true): Promise<void> {
|
|
574
|
-
return this.setModerator(groupId, userId, enable);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
setGroupAdmin(groupId: number | string, userId: number | string, enable = true): Promise<void> {
|
|
578
|
-
return this.setModerator(groupId, userId, enable);
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/** IPC 请求并统一错误上下文:daemon 返回 ok=false 时抛出带操作名的错误。 */
|
|
582
|
-
async #mustRequest(
|
|
583
|
-
action: string,
|
|
584
|
-
params: Record<string, unknown> | undefined,
|
|
585
|
-
label: string,
|
|
586
|
-
): Promise<IpcResponse> {
|
|
587
|
-
const resp = await this.request(action, params);
|
|
588
|
-
if (!resp.ok) {
|
|
589
|
-
throw new Error(`icqq ${label}失败: ${resp.error ?? 'daemon 未返回错误详情'}`);
|
|
590
|
-
}
|
|
591
|
-
return resp;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/** 按 flag/seq 在 GET_SYSTEM_MSG 中定位请求,路由到好友/群请求处理 action。 */
|
|
595
|
-
async #handleSystemRequest(
|
|
596
|
-
id: string,
|
|
597
|
-
approve: boolean,
|
|
598
|
-
extra: { remark?: string; reason?: string },
|
|
599
|
-
): Promise<void> {
|
|
600
|
-
const resp = await this.#mustRequest(Actions.GET_SYSTEM_MSG, undefined, '获取待处理请求');
|
|
601
|
-
const data = resp.data as
|
|
602
|
-
| { friendRequests?: unknown; groupRequests?: unknown }
|
|
603
|
-
| undefined;
|
|
604
|
-
const friendRequests = Array.isArray(data?.friendRequests)
|
|
605
|
-
? (data.friendRequests as IpcSystemMessage[])
|
|
606
|
-
: [];
|
|
607
|
-
const groupRequests = Array.isArray(data?.groupRequests)
|
|
608
|
-
? (data.groupRequests as IpcSystemMessage[])
|
|
609
|
-
: [];
|
|
610
|
-
const matches = (m: IpcSystemMessage): boolean =>
|
|
611
|
-
m.flag === id || (m.seq != null && String(m.seq) === id);
|
|
612
|
-
const friend = friendRequests.find(matches);
|
|
613
|
-
const group = friend ? undefined : groupRequests.find(matches);
|
|
614
|
-
const target = friend ?? group;
|
|
615
|
-
if (!target?.flag) {
|
|
616
|
-
throw new Error(`icqq 未找到待处理请求: ${id}(GET_SYSTEM_MSG 中无匹配 flag/seq)`);
|
|
617
|
-
}
|
|
618
|
-
const params: Record<string, unknown> = { flag: target.flag, approve };
|
|
619
|
-
if (friend) {
|
|
620
|
-
if (extra.remark) params.remark = extra.remark;
|
|
621
|
-
await this.#mustRequest(Actions.HANDLE_FRIEND_REQUEST, params, '处理好友请求');
|
|
622
|
-
} else {
|
|
623
|
-
if (extra.reason) params.reason = extra.reason;
|
|
624
|
-
await this.#mustRequest(Actions.HANDLE_GROUP_REQUEST, params, '处理群请求');
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/** Test / internal: admit when open. */
|
|
629
280
|
admit(msg: IcqqInboundMessage): void {
|
|
630
281
|
const conversation = msg.conversation;
|
|
631
282
|
if (!this.#open) {
|
|
632
|
-
this.#
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
283
|
+
if (this.#heldInbound.length >= INBOUND_HOLD_LIMIT) {
|
|
284
|
+
const dropped = this.#heldInbound.shift();
|
|
285
|
+
this.#logger.warn(formatCompact({
|
|
286
|
+
op: 'icqq_inbound_hold_drop',
|
|
287
|
+
endpoint: this.endpointName,
|
|
288
|
+
id: dropped?.id,
|
|
289
|
+
target: dropped
|
|
290
|
+
? `${dropped.conversation.kind}:${dropped.conversation.id}`
|
|
291
|
+
: undefined,
|
|
292
|
+
limit: INBOUND_HOLD_LIMIT,
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
this.#heldInbound.push(msg);
|
|
296
|
+
this.#logger.info(
|
|
297
|
+
`hold ${conversation.kind}:${conversation.id}`
|
|
298
|
+
+ (msg.sender ? ` from ${msg.sender.name ?? msg.sender.id}` : '')
|
|
299
|
+
+ ` until open | ${truncatePreview(msg.content, 80)}`,
|
|
300
|
+
);
|
|
639
301
|
return;
|
|
640
302
|
}
|
|
641
|
-
// 新 Runtime Message.content 为纯文本:@ 本机(uin = name)经结构化段 / metadata 传递
|
|
642
303
|
const mentioned = isIcqqBotMentioned({
|
|
643
|
-
uin: this.
|
|
304
|
+
uin: this.endpointName,
|
|
644
305
|
content: msg.segments,
|
|
645
306
|
rawMessage: msg.content,
|
|
646
307
|
});
|
|
@@ -658,7 +319,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
658
319
|
content: msg.content,
|
|
659
320
|
...(msg.segments ? { segments: msg.segments } : {}),
|
|
660
321
|
sender: msg.sender,
|
|
661
|
-
endpointId: this.
|
|
322
|
+
endpointId: this.endpointName,
|
|
662
323
|
...(mentioned ? { mentioned: true } : {}),
|
|
663
324
|
...(quoteId ? { replyTo: { id: String(quoteId) } } : {}),
|
|
664
325
|
metadata: Object.freeze({
|
|
@@ -668,7 +329,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
668
329
|
}).catch((err) => {
|
|
669
330
|
this.#logger.warn(formatCompact({
|
|
670
331
|
op: 'recv',
|
|
671
|
-
endpoint: this.
|
|
332
|
+
endpoint: this.endpointName,
|
|
672
333
|
target: `${conversation.kind}:${conversation.id}`,
|
|
673
334
|
ok: false,
|
|
674
335
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -676,47 +337,64 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
676
337
|
});
|
|
677
338
|
}
|
|
678
339
|
|
|
679
|
-
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
this.groups.clear();
|
|
691
|
-
if (glResp.ok && Array.isArray(glResp.data)) {
|
|
692
|
-
for (const g of glResp.data as IpcGroupInfo[]) {
|
|
693
|
-
this.groups.set(g.group_id, g);
|
|
340
|
+
#bindClientEvents(): void {
|
|
341
|
+
const safe = (label: string, fn: () => void): void => {
|
|
342
|
+
try {
|
|
343
|
+
fn();
|
|
344
|
+
} catch (error) {
|
|
345
|
+
this.#logger.warn(formatCompact({
|
|
346
|
+
op: 'icqq_event_handler',
|
|
347
|
+
endpoint: this.endpointName,
|
|
348
|
+
event: label,
|
|
349
|
+
error: error instanceof Error ? error.message : String(error),
|
|
350
|
+
}));
|
|
694
351
|
}
|
|
695
|
-
}
|
|
352
|
+
};
|
|
353
|
+
this.on('message.private', (event) => safe('message.private', () => this.#handleMessageEvent('message.private', event)));
|
|
354
|
+
this.on('message.group', (event) => safe('message.group', () => this.#handleMessageEvent('message.group', event)));
|
|
355
|
+
this.on('message.guild', (event) => safe('message.guild', () => this.#handleGuildEvent(event)));
|
|
356
|
+
this.on('request.friend', (event) => safe('request.friend', () => this.#recordInboxRequest(serializeIcqqEvent(event))));
|
|
357
|
+
this.on('request.group', (event) => safe('request.group', () => this.#recordInboxRequest(serializeIcqqEvent(event))));
|
|
358
|
+
this.on('notice.friend', (event) => safe('notice.friend', () => this.#recordInboxNotice(serializeIcqqEvent(event))));
|
|
359
|
+
this.on('notice.group', (event) => safe('notice.group', () => this.#recordInboxNotice(serializeIcqqEvent(event))));
|
|
360
|
+
this.on('system.offline', (event) => this.#logSystemEvent('offline', event));
|
|
361
|
+
this.on('system.offline.network', (event) => this.#logSystemEvent('offline.network', event));
|
|
362
|
+
this.on('system.offline.kickoff', (event) => this.#logSystemEvent('offline.kickoff', event));
|
|
363
|
+
this.on('system.login.qrcode', () => {
|
|
364
|
+
this.#logger.info('icqq 收到二维码登录事件,请按 icqq 默认流程扫码后继续登录');
|
|
365
|
+
});
|
|
366
|
+
this.on('system.login.device', (event) => this.#logSystemEvent('login.device', event));
|
|
367
|
+
this.on('system.login.slider', (event) => this.#logSystemEvent('login.slider', event));
|
|
368
|
+
this.on('system.login.error', (event) => this.#logSystemEvent('login.error', event));
|
|
369
|
+
this.on('system.login.auth', (event) => this.#logSystemEvent('login.auth', event));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#logSystemEvent(type: string, event: unknown): void {
|
|
373
|
+
const payload = serializeIcqqEvent(event);
|
|
374
|
+
this.#logger.warn(formatCompact({
|
|
375
|
+
op: `system.${type}`,
|
|
376
|
+
endpoint: this.endpointName,
|
|
377
|
+
...(payload ? { event: JSON.stringify(payload) } : {}),
|
|
378
|
+
}));
|
|
696
379
|
}
|
|
697
380
|
|
|
698
|
-
#
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
if (isIcqqNoticePayload(payload)) {
|
|
710
|
-
this.#recordInboxNotice(payload as Record<string, unknown>);
|
|
381
|
+
#handleMessageEvent(eventName: 'message.private' | 'message.group', event: unknown): void {
|
|
382
|
+
const payload = serializeIcqqEvent(event);
|
|
383
|
+
if (!payload || !isIcqqMessagePostType(payload)) {
|
|
384
|
+
this.#logger.debug(formatCompact({
|
|
385
|
+
op: 'icqq_inbound_skip',
|
|
386
|
+
reason: payload ? 'not_message_post' : 'serialize_failed',
|
|
387
|
+
endpoint: this.endpointName,
|
|
388
|
+
event: eventName,
|
|
389
|
+
}));
|
|
711
390
|
return;
|
|
712
391
|
}
|
|
713
|
-
|
|
714
|
-
const data = payload as IcqqIpcMessageEvent;
|
|
392
|
+
const data = payload as IcqqMessageEvent;
|
|
715
393
|
if (shouldSkipSelfInboundMessage(data)) {
|
|
716
394
|
this.#logger.debug(formatCompact({
|
|
717
395
|
op: 'icqq_inbound_skip',
|
|
718
396
|
reason: 'self',
|
|
719
|
-
endpoint: this.
|
|
397
|
+
endpoint: this.endpointName,
|
|
720
398
|
user_id: data.user_id,
|
|
721
399
|
self_id: data.self_id,
|
|
722
400
|
}));
|
|
@@ -727,9 +405,9 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
727
405
|
this.#logger.debug(formatCompact({
|
|
728
406
|
op: 'icqq_inbound_skip',
|
|
729
407
|
reason: 'normalize_failed',
|
|
730
|
-
endpoint: this.
|
|
408
|
+
endpoint: this.endpointName,
|
|
409
|
+
event: eventName,
|
|
731
410
|
message_id: data.message_id,
|
|
732
|
-
message_type: data.message_type ?? data.type,
|
|
733
411
|
}));
|
|
734
412
|
return;
|
|
735
413
|
}
|
|
@@ -737,7 +415,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
737
415
|
this.#logger.debug(formatCompact({
|
|
738
416
|
op: 'icqq_inbound_skip',
|
|
739
417
|
reason: 'dedupe',
|
|
740
|
-
endpoint: this.
|
|
418
|
+
endpoint: this.endpointName,
|
|
741
419
|
id: normalized.messageId,
|
|
742
420
|
}));
|
|
743
421
|
return;
|
|
@@ -764,8 +442,8 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
764
442
|
});
|
|
765
443
|
}
|
|
766
444
|
|
|
767
|
-
#handleGuildEvent(event:
|
|
768
|
-
const payload = event
|
|
445
|
+
#handleGuildEvent(event: unknown): void {
|
|
446
|
+
const payload = serializeIcqqEvent(event);
|
|
769
447
|
if (!payload || typeof payload !== 'object') return;
|
|
770
448
|
const normalized = normalizeIcqqGuildInboundMessage(
|
|
771
449
|
payload as Parameters<typeof normalizeIcqqGuildInboundMessage>[0],
|
|
@@ -774,7 +452,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
774
452
|
this.#logger.debug(formatCompact({
|
|
775
453
|
op: 'icqq_inbound_skip',
|
|
776
454
|
reason: 'guild_normalize_failed',
|
|
777
|
-
endpoint: this.
|
|
455
|
+
endpoint: this.endpointName,
|
|
778
456
|
}));
|
|
779
457
|
return;
|
|
780
458
|
}
|
|
@@ -783,7 +461,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
783
461
|
this.#logger.debug(formatCompact({
|
|
784
462
|
op: 'icqq_inbound_skip',
|
|
785
463
|
reason: 'dedupe',
|
|
786
|
-
endpoint: this.
|
|
464
|
+
endpoint: this.endpointName,
|
|
787
465
|
id: `guild:${normalized.messageId}`,
|
|
788
466
|
}));
|
|
789
467
|
return;
|
|
@@ -806,13 +484,8 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
806
484
|
});
|
|
807
485
|
}
|
|
808
486
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const id = String(this.#options.id);
|
|
812
|
-
return { adapter: id.split('\0').pop() ?? id, endpointKey: this.name };
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
#recordInboxRequest(payload: Record<string, unknown>): void {
|
|
487
|
+
#recordInboxRequest(payload: Record<string, unknown> | null): void {
|
|
488
|
+
if (!payload || !isIcqqRequestPayload(payload)) return;
|
|
816
489
|
const hooks = this.#options.inbox;
|
|
817
490
|
if (!hooks) return;
|
|
818
491
|
const row = buildIcqqInboxRequestRow(payload, this.#inboxBase());
|
|
@@ -822,7 +495,8 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
822
495
|
hooks.publish?.('endpoint:request', row);
|
|
823
496
|
}
|
|
824
497
|
|
|
825
|
-
#recordInboxNotice(payload: Record<string, unknown>): void {
|
|
498
|
+
#recordInboxNotice(payload: Record<string, unknown> | null): void {
|
|
499
|
+
if (!payload || !isIcqqNoticePayload(payload)) return;
|
|
826
500
|
const hooks = this.#options.inbox;
|
|
827
501
|
if (!hooks) return;
|
|
828
502
|
const row = buildIcqqInboxNoticeRow(payload, this.#inboxBase());
|
|
@@ -832,26 +506,29 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
832
506
|
hooks.publish?.('endpoint:notice', row);
|
|
833
507
|
}
|
|
834
508
|
|
|
835
|
-
|
|
509
|
+
#inboxBase(): { adapter: string; endpointKey: string } {
|
|
510
|
+
const id = String(this.#options.id);
|
|
511
|
+
return { adapter: id.split('\0').pop() ?? id, endpointKey: this.endpointName };
|
|
512
|
+
}
|
|
513
|
+
|
|
836
514
|
async #pullPendingSystemMessages(): Promise<void> {
|
|
837
515
|
const hooks = this.#options.inbox;
|
|
838
|
-
if (!hooks
|
|
516
|
+
if (!hooks) return;
|
|
839
517
|
try {
|
|
840
|
-
const
|
|
841
|
-
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
518
|
+
const messages = await this.getSystemMsg();
|
|
519
|
+
const friendRequests: IcqqSystemMessage[] = [];
|
|
520
|
+
const groupRequests: IcqqSystemMessage[] = [];
|
|
521
|
+
for (const msg of messages) {
|
|
522
|
+
if ((msg as { request_type?: string }).request_type === 'friend') {
|
|
523
|
+
friendRequests.push(msg as unknown as IcqqSystemMessage);
|
|
524
|
+
} else {
|
|
525
|
+
groupRequests.push(msg as unknown as IcqqSystemMessage);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
845
528
|
const base = this.#inboxBase();
|
|
846
529
|
const rows = [
|
|
847
|
-
...
|
|
848
|
-
|
|
849
|
-
buildIcqqSystemRequestRow(m, 'friend', base))
|
|
850
|
-
: []),
|
|
851
|
-
...(Array.isArray(data?.groupRequests)
|
|
852
|
-
? (data.groupRequests as IpcSystemMessage[]).map((m) =>
|
|
853
|
-
buildIcqqSystemRequestRow(m, 'group', base))
|
|
854
|
-
: []),
|
|
530
|
+
...friendRequests.map((m) => buildIcqqSystemRequestRow(m, 'friend', base)),
|
|
531
|
+
...groupRequests.map((m) => buildIcqqSystemRequestRow(m, 'group', base)),
|
|
855
532
|
];
|
|
856
533
|
for (const row of rows) {
|
|
857
534
|
if (!row) continue;
|
|
@@ -862,20 +539,94 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
862
539
|
} catch (error) {
|
|
863
540
|
this.#logger.debug(formatCompact({
|
|
864
541
|
op: 'inbox_pull_system_msg',
|
|
865
|
-
endpoint: this.
|
|
542
|
+
endpoint: this.endpointName,
|
|
866
543
|
error: error instanceof Error ? error.message : String(error),
|
|
867
544
|
}));
|
|
868
545
|
}
|
|
869
546
|
}
|
|
547
|
+
|
|
548
|
+
async #approveRequest(id: string, remark?: string): Promise<void> {
|
|
549
|
+
const messages = await this.getSystemMsg();
|
|
550
|
+
const target = messages.find((m) => {
|
|
551
|
+
const msg = m as unknown as IcqqSystemMessage;
|
|
552
|
+
return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
|
|
553
|
+
}) as unknown as IcqqSystemMessage | undefined;
|
|
554
|
+
if (!target?.flag) {
|
|
555
|
+
throw new Error(`icqq 未找到待处理请求: ${id}`);
|
|
556
|
+
}
|
|
557
|
+
if ((target as { request_type?: string }).request_type === 'friend') {
|
|
558
|
+
await this.setFriendAddRequest(target.flag, true, remark);
|
|
559
|
+
} else {
|
|
560
|
+
await this.setGroupAddRequest(target.flag, true);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async #rejectRequest(id: string, reason?: string): Promise<void> {
|
|
565
|
+
const messages = await this.getSystemMsg();
|
|
566
|
+
const target = messages.find((m) => {
|
|
567
|
+
const msg = m as unknown as IcqqSystemMessage;
|
|
568
|
+
return msg.flag === id || (msg.seq != null && String(msg.seq) === id);
|
|
569
|
+
}) as unknown as IcqqSystemMessage | undefined;
|
|
570
|
+
if (!target?.flag) {
|
|
571
|
+
throw new Error(`icqq 未找到待处理请求: ${id}`);
|
|
572
|
+
}
|
|
573
|
+
if ((target as { request_type?: string }).request_type === 'friend') {
|
|
574
|
+
await this.setFriendAddRequest(target.flag, false);
|
|
575
|
+
} else {
|
|
576
|
+
await this.setGroupAddRequest(target.flag, false, reason);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function describeUnknownError(error: unknown): string {
|
|
582
|
+
if (error instanceof Error) return error.stack ?? error.message;
|
|
583
|
+
if (error && typeof error === 'object') {
|
|
584
|
+
try {
|
|
585
|
+
return JSON.stringify(error);
|
|
586
|
+
} catch {
|
|
587
|
+
return inspect(error, { depth: 4, breakLength: 120 });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return String(error);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function resolveIcqqGroupReactionTarget(
|
|
594
|
+
message: MessageRef,
|
|
595
|
+
): { groupId: number; seq: number } | null {
|
|
596
|
+
if (message.conversation.kind !== 'group') return null;
|
|
597
|
+
const groupId = Number(message.conversation.id);
|
|
598
|
+
if (!Number.isFinite(groupId) || groupId <= 0) return null;
|
|
599
|
+
const raw = message.id?.trim();
|
|
600
|
+
if (!raw || raw.startsWith('outbound:')) return null;
|
|
601
|
+
if (/^[1-9]\d*$/.test(raw)) return { groupId, seq: Number(raw) };
|
|
602
|
+
try {
|
|
603
|
+
const parsed = parseGroupMessageId(raw);
|
|
604
|
+
if (parsed.seq > 0) return { groupId: parsed.group_id || groupId, seq: parsed.seq };
|
|
605
|
+
} catch {
|
|
606
|
+
// not a cqhttp group message id
|
|
607
|
+
}
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function resolveNativeClientConfig(config: ResolvedIcqqConfig): NativeIcqqClientConfig {
|
|
612
|
+
return {
|
|
613
|
+
log_level: 'info',
|
|
614
|
+
platform: config.platform,
|
|
615
|
+
...(config.ver ? { ver: config.ver } : {}),
|
|
616
|
+
...(config.dataDir ? { data_dir: config.dataDir } : {}),
|
|
617
|
+
...(config.signApiAddr ? { sign_api_addr: config.signApiAddr } : {}),
|
|
618
|
+
...(config.ignoreSelf != null ? { ignore_self: config.ignoreSelf } : {}),
|
|
619
|
+
...(config.resend != null ? { resend: config.resend } : {}),
|
|
620
|
+
...(config.cacheGroupMember != null ? { cache_group_member: config.cacheGroupMember } : {}),
|
|
621
|
+
...(config.autoServer != null ? { auto_server: config.autoServer } : {}),
|
|
622
|
+
...(config.qqnt != null ? { QQNT: config.qqnt } : {}),
|
|
623
|
+
...(config.ntLogin != null ? { NTLogin: config.ntLogin } : {}),
|
|
624
|
+
reconn_interval: config.autoReconnect ? 5 : 0,
|
|
625
|
+
};
|
|
870
626
|
}
|
|
871
627
|
|
|
872
|
-
/**
|
|
873
|
-
* 组装入站 metadata:在基础字段上补充 quote 链路信息。
|
|
874
|
-
* - quote_id:被引用消息 id(resolveIcqqQuoteIdFromEvent,无则不写)
|
|
875
|
-
* - quote_sender_id / quote_sender_name / quote_content:source 可用时的平铺摘要
|
|
876
|
-
*/
|
|
877
628
|
function buildIcqqQuoteMetadata(
|
|
878
|
-
data:
|
|
629
|
+
data: IcqqMessageEvent,
|
|
879
630
|
base: Record<string, unknown>,
|
|
880
631
|
): Record<string, unknown> {
|
|
881
632
|
const metadata: Record<string, unknown> = { ...base };
|
|
@@ -900,19 +651,27 @@ function buildIcqqQuoteMetadata(
|
|
|
900
651
|
return metadata;
|
|
901
652
|
}
|
|
902
653
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
if (!Number.isFinite(n) || String(value).trim() === '') {
|
|
907
|
-
throw new TypeError(`icqq ${label} 必须是数字: ${String(value)}`);
|
|
908
|
-
}
|
|
909
|
-
return n;
|
|
910
|
-
}
|
|
654
|
+
const INBOUND_HOLD_LIMIT = 32;
|
|
655
|
+
|
|
656
|
+
const ICQQ_SERIALIZE_SKIP = ['group', 'member', 'friend', 'discuss', 'client'] as const;
|
|
911
657
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
658
|
+
function serializeIcqqEvent(event: unknown): Record<string, unknown> | null {
|
|
659
|
+
if (!event || typeof event !== 'object') return null;
|
|
660
|
+
const nativeToJSON = (event as { toJSON?: (keys: string[]) => unknown }).toJSON;
|
|
661
|
+
if (typeof nativeToJSON === 'function') {
|
|
662
|
+
try {
|
|
663
|
+
const json = nativeToJSON.call(event, [...ICQQ_SERIALIZE_SKIP]);
|
|
664
|
+
if (json && typeof json === 'object' && !Array.isArray(json)) {
|
|
665
|
+
return json as Record<string, unknown>;
|
|
666
|
+
}
|
|
667
|
+
} catch {
|
|
668
|
+
/* JSON.stringify 也会无参调 toJSON,不能当回退 */
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const out: Record<string, unknown> = {};
|
|
672
|
+
for (const [key, value] of Object.entries(event as Record<string, unknown>)) {
|
|
673
|
+
if (typeof value === 'function' || (ICQQ_SERIALIZE_SKIP as readonly string[]).includes(key)) continue;
|
|
674
|
+
out[key] = value;
|
|
916
675
|
}
|
|
917
|
-
return
|
|
676
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
918
677
|
}
|