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