@zhin.js/adapter-icqq 6.0.2 → 7.0.0
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 +65 -0
- package/README.md +48 -435
- package/adapters/icqq.ts +92 -0
- package/agent/tools/announce.ts +2 -2
- package/agent/tools/essence.ts +2 -2
- package/agent/tools/friend_list.ts +2 -2
- package/agent/tools/group_files.ts +2 -2
- package/agent/tools/group_invite.ts +2 -2
- package/agent/tools/group_list.ts +2 -2
- package/agent/tools/group_sign.ts +2 -2
- package/agent/tools/list_muted.ts +2 -2
- package/agent/tools/poke.ts +2 -2
- package/agent/tools/send_user_like.ts +2 -2
- package/agent/tools/set_anonymous.ts +2 -2
- package/agent/tools/set_title.ts +2 -2
- package/lib/agent-prompt.d.ts +2 -0
- package/lib/{src/agent-prompt.js → agent-prompt.js} +0 -1
- package/lib/cq-message.d.ts +9 -0
- package/lib/{src/cq-message.js → cq-message.js} +0 -1
- package/lib/endpoint.d.ts +101 -0
- package/lib/endpoint.js +715 -0
- package/lib/forward-msg.d.ts +26 -0
- package/lib/{src/forward-msg.js → forward-msg.js} +0 -1
- package/lib/icqq-agent-deps.d.ts +25 -0
- package/lib/icqq-agent-deps.js +32 -0
- package/lib/icqq-guild.d.ts +53 -0
- package/lib/{src/icqq-guild.js → icqq-guild.js} +4 -2
- package/lib/icqq-inbound.d.ts +139 -0
- package/lib/{src/icqq-inbound.js → icqq-inbound.js} +27 -3
- package/lib/icqq-inbox.d.ts +33 -0
- package/lib/icqq-inbox.js +129 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +6 -0
- package/lib/ipc-client.d.ts +73 -0
- package/lib/{src/ipc-client.js → ipc-client.js} +0 -1
- package/lib/outbound-media.d.ts +13 -0
- package/lib/{src/outbound-media.js → outbound-media.js} +0 -1
- package/lib/protocol.d.ts +269 -0
- package/lib/{src/protocol.js → protocol.js} +124 -2
- package/lib/types.d.ts +87 -0
- package/lib/{src/types.js → types.js} +0 -1
- package/package.json +49 -45
- package/plugin.ts +32 -0
- package/schema.json +104 -0
- package/src/endpoint.ts +704 -795
- package/src/icqq-agent-deps.ts +48 -9
- package/src/icqq-guild.ts +10 -4
- package/src/icqq-inbound.ts +31 -2
- package/src/icqq-inbox.ts +155 -0
- package/src/index.ts +36 -69
- package/src/protocol.ts +191 -1
- package/src/types.ts +16 -0
- package/client/ICQQManagement.tsx +0 -147
- package/client/LoginAssistPanel.tsx +0 -300
- package/client/index.tsx +0 -11
- package/client/tsconfig.json +0 -7
- package/client/utils/api.ts +0 -30
- package/dist/index.js +0 -34
- package/lib/agent/tools/announce.js +0 -21
- package/lib/agent/tools/announce.js.map +0 -1
- package/lib/agent/tools/essence.js +0 -22
- package/lib/agent/tools/essence.js.map +0 -1
- package/lib/agent/tools/friend_list.js +0 -19
- package/lib/agent/tools/friend_list.js.map +0 -1
- package/lib/agent/tools/group_files.js +0 -29
- package/lib/agent/tools/group_files.js.map +0 -1
- package/lib/agent/tools/group_invite.js +0 -20
- package/lib/agent/tools/group_invite.js.map +0 -1
- package/lib/agent/tools/group_list.js +0 -20
- package/lib/agent/tools/group_list.js.map +0 -1
- package/lib/agent/tools/group_sign.js +0 -19
- package/lib/agent/tools/group_sign.js.map +0 -1
- package/lib/agent/tools/list_muted.js +0 -23
- package/lib/agent/tools/list_muted.js.map +0 -1
- package/lib/agent/tools/poke.js +0 -20
- package/lib/agent/tools/poke.js.map +0 -1
- package/lib/agent/tools/send_user_like.js +0 -22
- package/lib/agent/tools/send_user_like.js.map +0 -1
- package/lib/agent/tools/set_anonymous.js +0 -22
- package/lib/agent/tools/set_anonymous.js.map +0 -1
- package/lib/agent/tools/set_title.js +0 -25
- package/lib/agent/tools/set_title.js.map +0 -1
- package/lib/src/adapter.js +0 -128
- package/lib/src/adapter.js.map +0 -1
- package/lib/src/agent-prompt.js.map +0 -1
- package/lib/src/cq-message.js.map +0 -1
- package/lib/src/endpoint.js +0 -755
- package/lib/src/endpoint.js.map +0 -1
- package/lib/src/forward-msg.js.map +0 -1
- package/lib/src/get-msg.js +0 -46
- package/lib/src/get-msg.js.map +0 -1
- package/lib/src/icqq-agent-deps.js +0 -10
- package/lib/src/icqq-agent-deps.js.map +0 -1
- package/lib/src/icqq-guild.js.map +0 -1
- package/lib/src/icqq-inbound.js.map +0 -1
- package/lib/src/icqq-login-assist-bridge.js +0 -148
- package/lib/src/icqq-login-assist-bridge.js.map +0 -1
- package/lib/src/icqq-side-events.js +0 -130
- package/lib/src/icqq-side-events.js.map +0 -1
- package/lib/src/index.js +0 -50
- package/lib/src/index.js.map +0 -1
- package/lib/src/ipc-client.js.map +0 -1
- package/lib/src/login-assist-routes.js +0 -29
- package/lib/src/login-assist-routes.js.map +0 -1
- package/lib/src/login-ipc-contract.js +0 -35
- package/lib/src/login-ipc-contract.js.map +0 -1
- package/lib/src/outbound-media.js.map +0 -1
- package/lib/src/protocol.js.map +0 -1
- package/lib/src/routes.js +0 -67
- package/lib/src/routes.js.map +0 -1
- package/lib/src/segment-mapper.js +0 -2
- package/lib/src/segment-mapper.js.map +0 -1
- package/lib/src/tools/index.js +0 -23
- package/lib/src/tools/index.js.map +0 -1
- package/lib/src/types.js.map +0 -1
- package/plugin.yml +0 -3
- package/src/adapter.ts +0 -151
- package/src/get-msg.ts +0 -56
- package/src/icqq-login-assist-bridge.ts +0 -196
- package/src/icqq-side-events.ts +0 -143
- package/src/login-assist-routes.ts +0 -39
- package/src/login-ipc-contract.ts +0 -52
- package/src/routes.ts +0 -83
- package/src/segment-mapper.ts +0 -1
- package/src/tools/index.ts +0 -31
package/src/endpoint.ts
CHANGED
|
@@ -1,846 +1,350 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* 不再直接依赖 @icqqjs/icqq 协议库。
|
|
5
|
-
* 登录由 `icqq login` 完成,本 Endpoint 只负责连接守护进程并收发消息。
|
|
2
|
+
* IcqqIpcEndpoint — lifecycle, outbound, IPC subscribe/admit for ICQQ daemon.
|
|
6
3
|
*/
|
|
7
|
-
import {
|
|
8
|
-
import type {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
IpcGroupInfo,
|
|
13
|
-
IpcMemberInfo,
|
|
14
|
-
} from "./types.js";
|
|
15
|
-
import type { IcqqAdapter } from "./adapter.js";
|
|
16
|
-
import { IpcClient } from "./ipc-client.js";
|
|
17
|
-
import { Actions, type IpcEvent, type IpcGuildMessageEventData } from './protocol.js';
|
|
18
|
-
import { findIcqqNestedMessageSource, InboundMessageDeduper, isIcqqMessagePostType, normalizeIcqqInboundMessage, quotedPayloadFromIcqqSource, resolveIcqqQuoteIdFromEvent, resolveQuoteIdFromIcqqSource, shouldSkipSelfInboundMessage, unwrapIcqqIpcEventPayload, type IcqqIpcMessageEvent, type NormalizedIcqqInbound } from './icqq-inbound.js';
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
buildIcqqIpcMessage as buildIcqqIpcMessageImpl,
|
|
22
|
-
parseCqMessage as parseCqMessageImpl,
|
|
23
|
-
toCqString as toCqStringImpl,
|
|
24
|
-
} from "./cq-message.js";
|
|
4
|
+
import type { EndpointInstance, EndpointManagement } from '@zhin.js/adapter';
|
|
5
|
+
import type { MessageGateway } from '@zhin.js/core/runtime';
|
|
6
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
|
+
import type { CapabilityId } from '@zhin.js/plugin-runtime';
|
|
8
|
+
import { registerIcqqAgentEndpoint } from './icqq-agent-deps.js';
|
|
25
9
|
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
10
|
+
InboundMessageDeduper,
|
|
11
|
+
findIcqqNestedMessageSource,
|
|
12
|
+
isIcqqBotMentioned,
|
|
13
|
+
isIcqqMessagePostType,
|
|
14
|
+
normalizeIcqqInboundMessage,
|
|
15
|
+
quotedPayloadFromIcqqSource,
|
|
16
|
+
resolveIcqqQuoteIdFromEvent,
|
|
17
|
+
shouldSkipSelfInboundMessage,
|
|
18
|
+
unwrapIcqqIpcEventPayload,
|
|
19
|
+
type IcqqIpcMessageEvent,
|
|
20
|
+
} from './icqq-inbound.js';
|
|
29
21
|
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
isIcqqMetaPayload,
|
|
22
|
+
buildIcqqInboxNoticeRow,
|
|
23
|
+
buildIcqqInboxRequestRow,
|
|
24
|
+
buildIcqqSystemRequestRow,
|
|
34
25
|
isIcqqNoticePayload,
|
|
35
26
|
isIcqqRequestPayload,
|
|
36
|
-
|
|
37
|
-
shouldRefreshListsOnMeta,
|
|
38
|
-
type IcqqIpcRawEvent,
|
|
39
|
-
} from "./icqq-side-events.js";
|
|
40
|
-
import {
|
|
41
|
-
disposeIcqqLoginAssistBridge,
|
|
42
|
-
handleIcqqLoginIpcEvent,
|
|
43
|
-
} from "./icqq-login-assist-bridge.js";
|
|
44
|
-
import { parseIcqqGetMsgResponse } from "./get-msg.js";
|
|
45
|
-
import { enrichQuotedPayloadWithForward, isForwardPlaceholderPayload } from "./forward-msg.js";
|
|
27
|
+
} from './icqq-inbox.js';
|
|
46
28
|
import {
|
|
47
29
|
IcqqGuildCatalog,
|
|
48
30
|
isIcqqGuildIpcEvent,
|
|
49
31
|
normalizeIcqqGuildInboundMessage,
|
|
50
|
-
|
|
51
|
-
} from
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
constructor(
|
|
84
|
-
public adapter: IcqqAdapter,
|
|
85
|
-
config: IcqqEndpointConfig,
|
|
86
|
-
) {
|
|
87
|
-
this.$config = config;
|
|
88
|
-
}
|
|
32
|
+
} from './icqq-guild.js';
|
|
33
|
+
import { IpcClient } from './ipc-client.js';
|
|
34
|
+
import {
|
|
35
|
+
materializeOutboundBase64,
|
|
36
|
+
resolveIcqqOutboundMediaMode,
|
|
37
|
+
} from './outbound-media.js';
|
|
38
|
+
import {
|
|
39
|
+
Actions,
|
|
40
|
+
formatInboundContent,
|
|
41
|
+
formatInboundTarget,
|
|
42
|
+
formatOutboundBody,
|
|
43
|
+
parseSendTarget,
|
|
44
|
+
type IcqqInboundMessage,
|
|
45
|
+
type IpcEvent,
|
|
46
|
+
type IpcResponse,
|
|
47
|
+
type ResolvedIcqqConfig,
|
|
48
|
+
} from './protocol.js';
|
|
49
|
+
import type { IpcFriendInfo, IpcGroupInfo, IpcMemberInfo, IpcSystemMessage } from './types.js';
|
|
50
|
+
|
|
51
|
+
const logger = getLogger('icqq');
|
|
52
|
+
|
|
53
|
+
/** Minimal IPC surface used by the endpoint (real IpcClient or test mock). */
|
|
54
|
+
export interface IcqqIpcTransport {
|
|
55
|
+
request(action: string, params?: Record<string, unknown>): Promise<IpcResponse>;
|
|
56
|
+
subscribe(
|
|
57
|
+
action: string,
|
|
58
|
+
params: Record<string, unknown>,
|
|
59
|
+
handler: (event: IpcEvent) => void,
|
|
60
|
+
): { unsubscribe: () => Promise<void> };
|
|
61
|
+
setOnRemoteDisconnect(handler: (() => void) | null): void;
|
|
62
|
+
close(): void;
|
|
63
|
+
}
|
|
89
64
|
|
|
90
|
-
|
|
65
|
+
export type CreateIcqqIpc = (config: ResolvedIcqqConfig) => Promise<IcqqIpcTransport>;
|
|
91
66
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
67
|
+
/**
|
|
68
|
+
* 收件箱写/推钩子(由装配层注入,见 adapters/icqq.ts)。
|
|
69
|
+
* record* 写 unified_inbox_request/notice;publish 向 console hub 推送实时事件。
|
|
70
|
+
*/
|
|
71
|
+
export interface IcqqInboxHooks {
|
|
72
|
+
recordRequest(row: Record<string, unknown>): void | Promise<void>;
|
|
73
|
+
recordNotice(row: Record<string, unknown>): void | Promise<void>;
|
|
74
|
+
publish?(type: string, data: unknown): void;
|
|
75
|
+
}
|
|
95
76
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
77
|
+
export interface IcqqEndpointOptions {
|
|
78
|
+
readonly id: CapabilityId;
|
|
79
|
+
readonly gateway: MessageGateway;
|
|
80
|
+
readonly config: ResolvedIcqqConfig;
|
|
81
|
+
readonly createIpc?: CreateIcqqIpc;
|
|
82
|
+
/** 未注入时 request/notice 事件仅按非消息载荷忽略(不报错)。 */
|
|
83
|
+
readonly inbox?: IcqqInboxHooks;
|
|
84
|
+
}
|
|
102
85
|
|
|
103
|
-
|
|
86
|
+
export class IcqqIpcEndpoint implements EndpointInstance {
|
|
87
|
+
readonly #options: IcqqEndpointOptions;
|
|
88
|
+
readonly #createIpc: CreateIcqqIpc;
|
|
89
|
+
readonly name: string;
|
|
90
|
+
/** Populated after start(); agent tools read this. */
|
|
91
|
+
ipc!: IcqqIpcTransport;
|
|
92
|
+
readonly friends = new Map<number, IpcFriendInfo>();
|
|
93
|
+
readonly groups = new Map<number, IpcGroupInfo>();
|
|
94
|
+
readonly #guildCatalog = new IcqqGuildCatalog();
|
|
95
|
+
readonly management: EndpointManagement = Object.freeze<EndpointManagement>({
|
|
96
|
+
listFriends: () => this.getFriendList(),
|
|
97
|
+
listGroups: () => this.getGroupList(),
|
|
98
|
+
listChannels: async () => {
|
|
99
|
+
await this.#guildCatalog.syncAll(this.ipc);
|
|
100
|
+
return this.#guildCatalog.getGuildChannelList();
|
|
101
|
+
},
|
|
102
|
+
listGroupMembers: (groupId) => this.getGroupMemberList(groupId),
|
|
103
|
+
approveRequest: (requestId, remark) => this.approveRequest(requestId, remark),
|
|
104
|
+
rejectRequest: (requestId, reason) => this.rejectRequest(requestId, reason),
|
|
105
|
+
kickGroupMember: (groupId, userId) => this.removeMember(groupId, userId),
|
|
106
|
+
muteGroupMember: (groupId, userId, duration) => this.muteMember(groupId, userId, duration),
|
|
107
|
+
setGroupAdmin: (groupId, userId, enabled) => this.setModerator(groupId, userId, enabled),
|
|
108
|
+
deleteFriend: (userId) => this.deleteFriend(userId),
|
|
109
|
+
});
|
|
110
|
+
#open = false;
|
|
111
|
+
#started = false;
|
|
112
|
+
#subscriptions: Array<{ unsubscribe: () => Promise<void> }> = [];
|
|
113
|
+
#inboundDeduper = new InboundMessageDeduper();
|
|
114
|
+
/** request/notice 去重(推送事件与 GET_SYSTEM_MSG 首拉可能重复同一 flag)。 */
|
|
115
|
+
#inboxDeduper = new InboundMessageDeduper();
|
|
116
|
+
#unregisterAgent?: () => void;
|
|
117
|
+
/** 用户主动 stop 时为 true,阻止自动重连 */
|
|
118
|
+
#intentionalDisconnect = false;
|
|
119
|
+
/** 是否已有重连循环在跑(避免多次 schedule 叠套) */
|
|
120
|
+
#reconnectRunning = false;
|
|
104
121
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}));
|
|
122
|
+
constructor(options: IcqqEndpointOptions) {
|
|
123
|
+
this.#options = options;
|
|
124
|
+
this.name = options.config.name;
|
|
125
|
+
this.#createIpc = options.createIpc ?? defaultCreateIpc;
|
|
110
126
|
}
|
|
111
127
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const rpc = this.$config.rpc;
|
|
116
|
-
|
|
117
|
-
// 旧连接上的订阅句柄已失效,不再对死 socket 发 unsubscribe
|
|
118
|
-
this.subscriptions = [];
|
|
119
|
-
|
|
120
|
-
let client: IpcClient;
|
|
121
|
-
if (rpc) {
|
|
122
|
-
client = await IpcClient.connectRpc(rpc);
|
|
123
|
-
} else {
|
|
124
|
-
client = await IpcClient.connect(uin);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (this.intentionalDisconnect) {
|
|
128
|
-
client.close();
|
|
129
|
-
throw new Error("连接已取消");
|
|
128
|
+
async request(action: string, params?: Record<string, unknown>): Promise<IpcResponse> {
|
|
129
|
+
if (!this.ipc) {
|
|
130
|
+
throw new Error(`icqq endpoint ${this.name} 未连接(IPC 未初始化或已停止),无法发起请求: ${action}`);
|
|
130
131
|
}
|
|
132
|
+
return this.ipc.request(action, params);
|
|
133
|
+
}
|
|
131
134
|
|
|
132
|
-
|
|
133
|
-
if (this
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
async start(): Promise<void> {
|
|
136
|
+
if (this.#started) return;
|
|
137
|
+
this.#started = true;
|
|
138
|
+
this.#intentionalDisconnect = false;
|
|
139
|
+
try {
|
|
140
|
+
await this.#bindIpcSession();
|
|
141
|
+
this.#unregisterAgent = registerIcqqAgentEndpoint(this.name, this);
|
|
142
|
+
logger.info(formatCompact({
|
|
143
|
+
op: 'connect',
|
|
144
|
+
endpoint: this.name,
|
|
145
|
+
mode: this.#options.config.rpc ? 'rpc' : 'ipc',
|
|
146
|
+
friends: this.friends.size,
|
|
147
|
+
groups: this.groups.size,
|
|
148
|
+
}));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
await this.stop();
|
|
151
|
+
// Startup connect failures are surfaced once (with stack) by AdapterIndex;
|
|
152
|
+
// keep this at debug to avoid a duplicate error-level log.
|
|
153
|
+
logger.debug(`Failed to connect ICQQ IPC: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
154
|
+
throw error;
|
|
137
155
|
}
|
|
156
|
+
}
|
|
138
157
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
(
|
|
146
|
-
|
|
147
|
-
this
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
/** 建立或恢复与守护进程的 IPC/RPC 会话(订阅、缓存列表) */
|
|
159
|
+
async #bindIpcSession(): Promise<void> {
|
|
160
|
+
// 旧连接上的订阅句柄已失效,不再对死 socket 发 unsubscribe
|
|
161
|
+
this.#subscriptions = [];
|
|
162
|
+
this.ipc = await this.#createIpc(this.#options.config);
|
|
163
|
+
if (this.#options.config.autoReconnect !== false) {
|
|
164
|
+
this.ipc.setOnRemoteDisconnect(() => this.#scheduleReconnect());
|
|
165
|
+
}
|
|
166
|
+
await this.#refreshLists();
|
|
167
|
+
const sub = this.ipc.subscribe(Actions.SUBSCRIBE, {}, (event) => {
|
|
168
|
+
this.#handleEvent(event);
|
|
169
|
+
});
|
|
170
|
+
this.#subscriptions.push(sub);
|
|
171
|
+
// daemon 推送 client.em 全部事件,request/notice 实时到达;
|
|
172
|
+
// 启动时补一次 GET_SYSTEM_MSG,捞离线期间积存的待处理请求。
|
|
173
|
+
void this.#pullPendingSystemMessages();
|
|
150
174
|
}
|
|
151
175
|
|
|
152
176
|
/** IPC/RPC 意外断开时调度重连(指数退避) */
|
|
153
|
-
|
|
154
|
-
if (this
|
|
155
|
-
if (this
|
|
156
|
-
if (this
|
|
157
|
-
this
|
|
158
|
-
void this
|
|
177
|
+
#scheduleReconnect(): void {
|
|
178
|
+
if (this.#options.config.autoReconnect === false) return;
|
|
179
|
+
if (this.#intentionalDisconnect) return;
|
|
180
|
+
if (this.#reconnectRunning) return;
|
|
181
|
+
this.#reconnectRunning = true;
|
|
182
|
+
void this.#runReconnectLoop();
|
|
159
183
|
}
|
|
160
184
|
|
|
161
|
-
|
|
185
|
+
async #runReconnectLoop(): Promise<void> {
|
|
162
186
|
try {
|
|
163
|
-
for (let attempt = 0; !this
|
|
187
|
+
for (let attempt = 0; !this.#intentionalDisconnect; attempt++) {
|
|
164
188
|
const base = Math.min(30_000, 500 * 2 ** Math.min(attempt, 6));
|
|
165
189
|
const jitter = Math.floor(Math.random() * 400);
|
|
166
190
|
const delayMs = base + jitter;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
op:
|
|
171
|
-
endpoint: this
|
|
191
|
+
// 首次断开 WARN,后续重试静默为 DEBUG,避免刷屏
|
|
192
|
+
const disconnectLog = attempt === 0 ? logger.warn.bind(logger) : logger.debug.bind(logger);
|
|
193
|
+
disconnectLog(formatCompact({
|
|
194
|
+
op: 'disconnect',
|
|
195
|
+
endpoint: this.name,
|
|
172
196
|
ok: false,
|
|
173
197
|
delay_ms: delayMs,
|
|
174
198
|
attempt: attempt + 1,
|
|
175
199
|
}));
|
|
176
|
-
|
|
177
200
|
await new Promise<void>((r) => setTimeout(r, delayMs));
|
|
178
|
-
if (this
|
|
179
|
-
|
|
201
|
+
if (this.#intentionalDisconnect) break;
|
|
180
202
|
try {
|
|
181
|
-
await this
|
|
182
|
-
|
|
183
|
-
op:
|
|
184
|
-
endpoint: this
|
|
203
|
+
await this.#bindIpcSession();
|
|
204
|
+
logger.info(formatCompact({
|
|
205
|
+
op: 'reconnect',
|
|
206
|
+
endpoint: this.name,
|
|
207
|
+
ok: true,
|
|
208
|
+
attempts: attempt + 1,
|
|
185
209
|
friends: this.friends.size,
|
|
186
210
|
groups: this.groups.size,
|
|
187
211
|
}));
|
|
188
212
|
break;
|
|
189
|
-
} catch (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const retryLog = attempt === 0 ? logger.warn.bind(logger) : logger.debug.bind(logger);
|
|
215
|
+
retryLog(formatCompact({
|
|
216
|
+
op: 'reconnect',
|
|
217
|
+
endpoint: this.name,
|
|
193
218
|
ok: false,
|
|
194
|
-
|
|
219
|
+
attempt: attempt + 1,
|
|
220
|
+
error: error instanceof Error ? error.message : String(error),
|
|
195
221
|
}));
|
|
196
222
|
}
|
|
197
223
|
}
|
|
198
224
|
} finally {
|
|
199
|
-
this
|
|
225
|
+
this.#reconnectRunning = false;
|
|
200
226
|
}
|
|
201
227
|
}
|
|
202
228
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const [flResp, glResp] = await Promise.all([
|
|
206
|
-
this.ipc.request(Actions.LIST_FRIENDS),
|
|
207
|
-
this.ipc.request(Actions.LIST_GROUPS),
|
|
208
|
-
]);
|
|
209
|
-
|
|
210
|
-
this.friends.clear();
|
|
211
|
-
if (flResp.ok && Array.isArray(flResp.data)) {
|
|
212
|
-
for (const f of flResp.data as IpcFriendInfo[]) {
|
|
213
|
-
this.friends.set(f.user_id, f);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
this.groups.clear();
|
|
218
|
-
if (glResp.ok && Array.isArray(glResp.data)) {
|
|
219
|
-
for (const g of glResp.data as IpcGroupInfo[]) {
|
|
220
|
-
this.groups.set(g.group_id, g);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
await this.guildCatalog.syncAll(this.ipc).catch((e: unknown) => {
|
|
225
|
-
this.logger.warn(formatCompact({
|
|
226
|
-
op: "sync_guilds",
|
|
227
|
-
endpoint: this.$id,
|
|
228
|
-
ok: false,
|
|
229
|
-
error: e instanceof Error ? e.message : String(e),
|
|
230
|
-
}));
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Console:列出 guild 子频道(含 parent.guild) */
|
|
235
|
-
getGuildChannelList(): ReturnType<IcqqGuildCatalog["getGuildChannelList"]> {
|
|
236
|
-
return this.guildCatalog.getGuildChannelList();
|
|
229
|
+
open(): void {
|
|
230
|
+
this.#open = true;
|
|
237
231
|
}
|
|
238
232
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
guildId?: string,
|
|
242
|
-
): { channelName?: string; parentName?: string } {
|
|
243
|
-
return this.guildCatalog.resolveConsoleChannelNames(channelId, guildId);
|
|
233
|
+
close(): void {
|
|
234
|
+
this.#open = false;
|
|
244
235
|
}
|
|
245
236
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
this.intentionalDisconnect = true;
|
|
250
|
-
|
|
237
|
+
async stop(): Promise<void> {
|
|
238
|
+
this.#intentionalDisconnect = true;
|
|
239
|
+
this.#open = false;
|
|
251
240
|
this.ipc?.setOnRemoteDisconnect(null);
|
|
252
|
-
for (const sub of this.
|
|
253
|
-
await sub.unsubscribe().catch(() => {});
|
|
254
|
-
}
|
|
255
|
-
this.subscriptions = [];
|
|
256
|
-
this.inboundDeduper.clear();
|
|
257
|
-
disposeIcqqLoginAssistBridge(this.$id);
|
|
258
|
-
this.ipc?.close();
|
|
259
|
-
this.$connected = false;
|
|
260
|
-
this.logger.debug(formatCompact( { op: "disconnect", endpoint: this.$id }));
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ── 消息处理 ───────────────────────────────────────────────────────
|
|
264
|
-
|
|
265
|
-
private handleEvent(event: IpcEvent): void {
|
|
266
|
-
if (event.event?.startsWith("system.login.")) {
|
|
267
|
-
handleIcqqLoginIpcEvent(this, event.event, event.data);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (event.event === Actions.RELOAD_GUILDS) {
|
|
272
|
-
void this.guildCatalog.syncAll(this.ipc).catch((e: unknown) => {
|
|
273
|
-
this.logger.warn(formatCompact({
|
|
274
|
-
op: "reload_guilds",
|
|
275
|
-
endpoint: this.$id,
|
|
276
|
-
ok: false,
|
|
277
|
-
error: e instanceof Error ? e.message : String(e),
|
|
278
|
-
}));
|
|
279
|
-
});
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (isIcqqGuildIpcEvent(event.event)) {
|
|
284
|
-
this.handleGuildMessageEvent(event);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const payload = unwrapIcqqIpcEventPayload(event);
|
|
289
|
-
if (!payload || typeof payload !== "object") {
|
|
290
|
-
if (process.env.ICQQ_IPC_LOG_RAW === "1") {
|
|
291
|
-
this.logger.info(
|
|
292
|
-
formatCompact({
|
|
293
|
-
ipc_skip: "no_payload",
|
|
294
|
-
ipc_event: event.event,
|
|
295
|
-
}),
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
if (process.env.ICQQ_IPC_LOG_RAW === "1") {
|
|
302
|
-
this.logger.info(
|
|
303
|
-
formatCompact({
|
|
304
|
-
ipc_raw: JSON.stringify(payload).slice(0, 800),
|
|
305
|
-
}),
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (isIcqqNoticePayload(payload)) {
|
|
310
|
-
this.handleNoticeEvent(payload);
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
if (isIcqqRequestPayload(payload)) {
|
|
314
|
-
this.handleRequestEvent(payload);
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
|
-
if (isIcqqMetaPayload(payload)) {
|
|
318
|
-
this.handleMetaEvent(payload);
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
if (!isIcqqMessagePostType(payload)) {
|
|
323
|
-
const postType = resolveIcqqEventPostType(
|
|
324
|
-
payload as Record<string, unknown>,
|
|
325
|
-
);
|
|
326
|
-
if (process.env.ICQQ_IPC_LOG_RAW === "1") {
|
|
327
|
-
this.logger.info(
|
|
328
|
-
formatCompact({ ipc_skip: postType ?? "unknown_event" }),
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const data = payload;
|
|
335
|
-
|
|
336
|
-
if (shouldSkipSelfInboundMessage(data)) {
|
|
337
|
-
if (process.env.ICQQ_IPC_LOG_RAW === "1") {
|
|
338
|
-
this.logger.debug(formatCompact({ ipc_skip: "self_message" }));
|
|
339
|
-
}
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const normalized = normalizeIcqqInboundMessage(data);
|
|
344
|
-
if (!normalized) {
|
|
345
|
-
if (process.env.ICQQ_IPC_LOG_RAW === "1") {
|
|
346
|
-
this.logger.debug(formatCompact({ ipc_skip: "normalize_failed" }));
|
|
347
|
-
}
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (!this.inboundDeduper.shouldProcess(normalized.messageId)) {
|
|
352
|
-
if (process.env.ICQQ_IPC_LOG_RAW === "1") {
|
|
353
|
-
this.logger.info(
|
|
354
|
-
formatCompact({ ipc_dedupe: normalized.messageId }),
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
void this.dispatchInboundMessage(data, normalized);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
private async dispatchInboundMessage(
|
|
364
|
-
data: IcqqIpcMessageEvent,
|
|
365
|
-
normalized: NormalizedIcqqInbound,
|
|
366
|
-
): Promise<void> {
|
|
367
|
-
this.logIpcInboundPayload(data, normalized);
|
|
368
|
-
|
|
369
|
-
await this.primeQuotedSourceCache(
|
|
370
|
-
data.source ?? findIcqqNestedMessageSource(data),
|
|
371
|
-
);
|
|
372
|
-
|
|
373
|
-
const message = this.$formatMessage(normalized);
|
|
374
|
-
this.adapter.emit("message.receive", message);
|
|
375
|
-
this.logger.debug(
|
|
376
|
-
`${this.$id} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}${message.$quote_id ? ` quote_id=${message.$quote_id}` : ""}`,
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
private handleGuildMessageEvent(event: IpcEvent): void {
|
|
381
|
-
const payload = event.data;
|
|
382
|
-
if (!payload || typeof payload !== "object") return;
|
|
383
|
-
const data = payload as IpcGuildMessageEventData & Record<string, unknown>;
|
|
384
|
-
const normalized = normalizeIcqqGuildInboundMessage(data);
|
|
385
|
-
if (!normalized) return;
|
|
386
|
-
|
|
387
|
-
this.guildCatalog.upsertFromInbound(data);
|
|
388
|
-
|
|
389
|
-
if (!this.inboundDeduper.shouldProcess(`guild:${normalized.messageId}`)) {
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
void this.dispatchGuildInboundMessage(normalized);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
private async dispatchGuildInboundMessage(
|
|
397
|
-
normalized: NormalizedIcqqGuildInbound,
|
|
398
|
-
): Promise<void> {
|
|
399
|
-
const message = this.$formatGuildMessage(normalized);
|
|
400
|
-
this.adapter.emit("message.receive", message);
|
|
401
|
-
this.logger.debug(
|
|
402
|
-
`${this.$id} recv channel(${message.$channel.id}):${segment.raw(message.$content)}`,
|
|
403
|
-
);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
private $formatGuildMessage(
|
|
407
|
-
normalized: NormalizedIcqqGuildInbound,
|
|
408
|
-
): ReturnType<typeof Message.from<IpcGuildMessageEventData>> {
|
|
409
|
-
const senderInfo: IcqqSenderInfo = {
|
|
410
|
-
id: normalized.userId,
|
|
411
|
-
name: normalized.nickname,
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
const result = Message.from(normalized.raw, {
|
|
415
|
-
$id: normalized.messageId,
|
|
416
|
-
$adapter: "icqq" as const,
|
|
417
|
-
$endpoint: this.$config.name,
|
|
418
|
-
$sender: senderInfo,
|
|
419
|
-
$channel: {
|
|
420
|
-
id: normalized.channelId,
|
|
421
|
-
type: "channel",
|
|
422
|
-
parent: { type: "guild", id: normalized.guildId },
|
|
423
|
-
},
|
|
424
|
-
$content: normalized.content,
|
|
425
|
-
$raw: normalized.rawMessage,
|
|
426
|
-
$timestamp: normalized.timestampMs,
|
|
427
|
-
$recall: async () => {
|
|
428
|
-
await this.$recallMessage(result.$id);
|
|
429
|
-
},
|
|
430
|
-
$reply: async (
|
|
431
|
-
content: SendContent,
|
|
432
|
-
quote?: boolean | string,
|
|
433
|
-
): Promise<string> => {
|
|
434
|
-
if (!Array.isArray(content)) content = [content];
|
|
435
|
-
if (quote) {
|
|
436
|
-
content.unshift({
|
|
437
|
-
type: "reply",
|
|
438
|
-
data: { id: typeof quote === "boolean" ? result.$id : quote },
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
return await this.adapter.sendMessage({
|
|
442
|
-
context: "icqq",
|
|
443
|
-
endpoint: this.$config.name,
|
|
444
|
-
id: normalized.channelId,
|
|
445
|
-
type: "channel",
|
|
446
|
-
parent: { type: "guild", id: normalized.guildId },
|
|
447
|
-
content,
|
|
448
|
-
});
|
|
449
|
-
},
|
|
450
|
-
});
|
|
451
|
-
return result;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
private handleNoticeEvent(event: IcqqIpcRawEvent): void {
|
|
455
|
-
const dedupeKey = resolveSideEventDedupeKey(event, "notice");
|
|
456
|
-
if (!this.inboundDeduper.shouldProcess(dedupeKey)) {
|
|
457
|
-
if (process.env.ICQQ_IPC_LOG_RAW === "1") {
|
|
458
|
-
this.logger.debug(formatCompact({ ipc_dedupe: dedupeKey }));
|
|
459
|
-
}
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
const notice = formatIcqqNotice(event, this.$config.name);
|
|
463
|
-
this.adapter.emit("notice.receive", notice);
|
|
464
|
-
this.logger.debug(
|
|
465
|
-
formatCompact({
|
|
466
|
-
notice: `${notice.$type}.${notice.$scene_type}.${notice.$sub_type}`,
|
|
467
|
-
scene: `${notice.$scene_type}(${notice.$scene_id})`,
|
|
468
|
-
endpoint: this.$id,
|
|
469
|
-
}),
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
private handleRequestEvent(event: IcqqIpcRawEvent): void {
|
|
474
|
-
const dedupeKey = resolveSideEventDedupeKey(event, "request");
|
|
475
|
-
if (!this.inboundDeduper.shouldProcess(dedupeKey)) {
|
|
476
|
-
if (process.env.ICQQ_IPC_LOG_RAW === "1") {
|
|
477
|
-
this.logger.debug(formatCompact({ ipc_dedupe: dedupeKey }));
|
|
478
|
-
}
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
const request = formatIcqqRequest(event, this.$config.name, this.ipc);
|
|
482
|
-
this.adapter.emit("request.receive", request);
|
|
483
|
-
this.logger.debug(
|
|
484
|
-
formatCompact({
|
|
485
|
-
request: `${request.$type}.${request.$scene_type}.${request.$sub_type}`,
|
|
486
|
-
scene: `${request.$scene_type}(${request.$scene_id})`,
|
|
487
|
-
endpoint: this.$id,
|
|
488
|
-
from: request.$actor.id,
|
|
489
|
-
}),
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
private handleMetaEvent(event: IcqqIpcRawEvent): void {
|
|
494
|
-
const time = event.time ?? 0;
|
|
495
|
-
const kind = event.meta_event_type ?? event.post_type ?? 'meta';
|
|
496
|
-
const dedupeKey = `meta:${time}_${kind}_${event.sub_type ?? ''}`;
|
|
497
|
-
if (!this.inboundDeduper.shouldProcess(dedupeKey)) return;
|
|
498
|
-
|
|
499
|
-
this.logger.debug(formatIcqqMetaLog(event));
|
|
500
|
-
if (shouldRefreshListsOnMeta(event)) {
|
|
501
|
-
void this.refreshLists().catch((e: unknown) => {
|
|
502
|
-
this.logger.warn(
|
|
503
|
-
formatCompact({
|
|
504
|
-
op: "refresh_lists",
|
|
505
|
-
ok: false,
|
|
506
|
-
error: e instanceof Error ? e.message : String(e),
|
|
507
|
-
}),
|
|
508
|
-
);
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/** 调试 IPC 入站字段:默认 debug;设 ICQQ_IPC_LOG_RAW=1 则 info 打完整 payload */
|
|
514
|
-
private logIpcInboundPayload(
|
|
515
|
-
data: IcqqIpcMessageEvent,
|
|
516
|
-
normalized: NormalizedIcqqInbound,
|
|
517
|
-
): void {
|
|
518
|
-
const ext = data as IcqqIpcMessageEvent & Record<string, unknown>;
|
|
519
|
-
const keys = Object.keys(ext);
|
|
520
|
-
const idHints: Record<string, unknown> = {};
|
|
521
|
-
for (const key of keys) {
|
|
522
|
-
if (/message|msg|seq|id|source/i.test(key)) {
|
|
523
|
-
idHints[key] = ext[key];
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
const preview = JSON.stringify(data);
|
|
527
|
-
const compact = formatCompact({
|
|
528
|
-
post_type: data.post_type ?? "legacy",
|
|
529
|
-
message_type: data.message_type ?? data.type,
|
|
530
|
-
ipc_message_id: normalized.messageId,
|
|
531
|
-
id_source: normalized.idSource,
|
|
532
|
-
ipc_keys: keys.join(","),
|
|
533
|
-
...(Object.keys(idHints).length ? { ipc_id_hints: JSON.stringify(idHints) } : {}),
|
|
534
|
-
});
|
|
535
|
-
this.logger.debug(compact);
|
|
536
|
-
this.logger.debug(formatCompact({ ipc_payload: preview.slice(0, 1200) }));
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
$formatMessage(
|
|
540
|
-
input: NormalizedIcqqInbound | IcqqIpcMessageEvent,
|
|
541
|
-
): ReturnType<typeof Message.from<IcqqIpcMessageEvent>> {
|
|
542
|
-
const normalized =
|
|
543
|
-
"messageId" in input
|
|
544
|
-
? input
|
|
545
|
-
: normalizeIcqqInboundMessage(input);
|
|
546
|
-
if (!normalized) {
|
|
547
|
-
throw new Error("无法解析 icqq 入站消息");
|
|
548
|
-
}
|
|
549
|
-
const raw = normalized.raw;
|
|
550
|
-
const senderInfo: IcqqSenderInfo = {
|
|
551
|
-
id: normalized.userId,
|
|
552
|
-
name: normalized.nickname,
|
|
553
|
-
};
|
|
554
|
-
if (normalized.senderRole && normalized.senderRole !== "member") {
|
|
555
|
-
senderInfo.role = normalized.senderRole;
|
|
556
|
-
if (normalized.senderRole === "owner") {
|
|
557
|
-
senderInfo.isOwner = true;
|
|
558
|
-
senderInfo.permissions = ["owner"];
|
|
559
|
-
} else if (normalized.senderRole === "admin") {
|
|
560
|
-
senderInfo.isAdmin = true;
|
|
561
|
-
senderInfo.permissions = ["admin"];
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
const quoteId =
|
|
566
|
-
Message.quoteIdFromContent(normalized.content) ??
|
|
567
|
-
resolveIcqqQuoteIdFromEvent(normalized.raw);
|
|
568
|
-
Message.alignReplySegments(normalized.content, quoteId);
|
|
569
|
-
|
|
570
|
-
const result = Message.from(raw, {
|
|
571
|
-
$id: normalized.messageId,
|
|
572
|
-
$adapter: "icqq" as const,
|
|
573
|
-
$endpoint: this.$config.name,
|
|
574
|
-
$sender: senderInfo,
|
|
575
|
-
$channel: {
|
|
576
|
-
id: normalized.channelId,
|
|
577
|
-
type: normalized.channelType,
|
|
578
|
-
...(normalized.channelParentGroupId
|
|
579
|
-
? { parent: { type: 'group' as const, id: normalized.channelParentGroupId } }
|
|
580
|
-
: {}),
|
|
581
|
-
},
|
|
582
|
-
$content: normalized.content,
|
|
583
|
-
$quote_id: quoteId,
|
|
584
|
-
$raw: normalized.rawMessage,
|
|
585
|
-
$timestamp: normalized.timestampMs,
|
|
586
|
-
$recall: async () => {
|
|
587
|
-
if (normalized.idSource === "synthetic") {
|
|
588
|
-
this.logger.warn(formatCompact( {
|
|
589
|
-
op: "recall",
|
|
590
|
-
endpoint: this.$id,
|
|
591
|
-
ok: false,
|
|
592
|
-
error: "no message_id in push",
|
|
593
|
-
}));
|
|
594
|
-
return;
|
|
595
|
-
}
|
|
596
|
-
await this.$recallMessage(result.$id);
|
|
597
|
-
},
|
|
598
|
-
$reply: async (
|
|
599
|
-
content: SendContent,
|
|
600
|
-
quote?: boolean | string,
|
|
601
|
-
): Promise<string> => {
|
|
602
|
-
if (!Array.isArray(content)) content = [content];
|
|
603
|
-
if (quote) {
|
|
604
|
-
content.unshift({
|
|
605
|
-
type: "reply",
|
|
606
|
-
data: { id: typeof quote === "boolean" ? result.$id : quote },
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
return await this.adapter.sendMessage({
|
|
610
|
-
...result.$channel,
|
|
611
|
-
context: "icqq",
|
|
612
|
-
endpoint: this.$config.name,
|
|
613
|
-
content,
|
|
614
|
-
});
|
|
615
|
-
},
|
|
616
|
-
});
|
|
617
|
-
return result;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* 有 source.message_id 时用 get_msg 拉全量正文;否则仅用 source 内联摘要。
|
|
622
|
-
*/
|
|
623
|
-
private async primeQuotedSourceCache(source: unknown): Promise<void> {
|
|
624
|
-
if (!source) return;
|
|
625
|
-
const quoteId = resolveQuoteIdFromIcqqSource(source);
|
|
626
|
-
const s = source as { message_id?: unknown };
|
|
627
|
-
const hasCanonicalId =
|
|
628
|
-
quoteId &&
|
|
629
|
-
s.message_id != null &&
|
|
630
|
-
String(s.message_id).trim() === quoteId;
|
|
631
|
-
|
|
632
|
-
if (hasCanonicalId) {
|
|
633
|
-
try {
|
|
634
|
-
await this.fetchQuotedMessagePayload(quoteId);
|
|
635
|
-
return;
|
|
636
|
-
} catch (e: unknown) {
|
|
637
|
-
this.logger.debug(
|
|
638
|
-
formatCompact({
|
|
639
|
-
op: "quote_get_msg",
|
|
640
|
-
message_id: quoteId,
|
|
641
|
-
ok: false,
|
|
642
|
-
error: e instanceof Error ? e.message : String(e),
|
|
643
|
-
}),
|
|
644
|
-
);
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
const sourcePayload = quotedPayloadFromIcqqSource(source);
|
|
649
|
-
if (!sourcePayload) return;
|
|
650
|
-
const enriched = await enrichQuotedPayloadWithForward(
|
|
651
|
-
this.ipc,
|
|
652
|
-
sourcePayload,
|
|
653
|
-
);
|
|
654
|
-
this.quotedSourceCache.set(enriched.messageId, enriched);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
private async fetchQuotedMessagePayload(
|
|
658
|
-
messageId: string,
|
|
659
|
-
): Promise<QuotedMessagePayload> {
|
|
660
|
-
const resp = await this.ipc.request(Actions.GET_MSG, {
|
|
661
|
-
message_id: messageId,
|
|
662
|
-
});
|
|
663
|
-
if (!resp.ok) {
|
|
664
|
-
throw new Error(resp.error ?? "get_msg failed");
|
|
665
|
-
}
|
|
666
|
-
const payload = parseIcqqGetMsgResponse(messageId, resp.data);
|
|
667
|
-
const enriched = await enrichQuotedPayloadWithForward(
|
|
668
|
-
this.ipc,
|
|
669
|
-
payload,
|
|
670
|
-
resp.data,
|
|
671
|
-
);
|
|
672
|
-
if (
|
|
673
|
-
isForwardPlaceholderPayload(enriched) &&
|
|
674
|
-
!String(
|
|
675
|
-
Array.isArray(enriched.content)
|
|
676
|
-
? segment.raw(enriched.content)
|
|
677
|
-
: enriched.content ?? "",
|
|
678
|
-
).includes("[Merged chat history")
|
|
679
|
-
) {
|
|
680
|
-
this.logger.debug(
|
|
681
|
-
formatCompact({
|
|
682
|
-
op: "forward_unresolved",
|
|
683
|
-
message_id: messageId,
|
|
684
|
-
}),
|
|
685
|
-
);
|
|
241
|
+
for (const sub of this.#subscriptions.splice(0)) {
|
|
242
|
+
await sub.unsubscribe().catch(() => { /* ignore */ });
|
|
686
243
|
}
|
|
687
|
-
this
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
244
|
+
this.#unregisterAgent?.();
|
|
245
|
+
this.#unregisterAgent = undefined;
|
|
246
|
+
this.#inboundDeduper.clear();
|
|
247
|
+
this.#inboxDeduper.clear();
|
|
248
|
+
this.friends.clear();
|
|
249
|
+
this.groups.clear();
|
|
250
|
+
this.#guildCatalog.clear();
|
|
251
|
+
try {
|
|
252
|
+
this.ipc?.close();
|
|
253
|
+
} catch {
|
|
254
|
+
/* ignore */
|
|
255
|
+
}
|
|
256
|
+
// 置空 ipc:start 前 / stop 后的 request() 走防御性报错而非 TypeError
|
|
257
|
+
this.ipc = undefined as unknown as IcqqIpcTransport;
|
|
258
|
+
this.#started = false;
|
|
259
|
+
logger.debug(formatCompact({ op: 'disconnect', endpoint: this.name }));
|
|
697
260
|
}
|
|
698
261
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const resp = await this.ipc.request(Actions.RECALL_MSG, {
|
|
703
|
-
message_id: id,
|
|
704
|
-
});
|
|
705
|
-
if (!resp.ok) {
|
|
706
|
-
this.logger.warn(formatCompact( {
|
|
707
|
-
op: "recall",
|
|
708
|
-
endpoint: this.$id,
|
|
709
|
-
ok: false,
|
|
710
|
-
error: resp.error,
|
|
711
|
-
}));
|
|
262
|
+
async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
|
|
263
|
+
if (!this.ipc) {
|
|
264
|
+
throw new Error(`icqq endpoint ${this.name} 未连接(IPC 未初始化或已停止),无法发送消息`);
|
|
712
265
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
const
|
|
719
|
-
const expanded = expandInteractiveSegmentsInContent(options.content);
|
|
720
|
-
const qrcodeContent = expanded;
|
|
721
|
-
const content = materializeOutboundBase64(qrcodeContent, outboundMedia);
|
|
722
|
-
const message = buildIcqqIpcMessageImpl(content);
|
|
723
|
-
|
|
266
|
+
const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
|
|
267
|
+
const content = Array.isArray(payload)
|
|
268
|
+
? materializeOutboundBase64(payload, mediaMode)
|
|
269
|
+
: payload;
|
|
270
|
+
const message = formatOutboundBody(content);
|
|
271
|
+
const parsed = parseSendTarget(target);
|
|
724
272
|
let action: string;
|
|
725
273
|
let params: Record<string, unknown>;
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
options.parent?.type === "group" ? options.parent.id : undefined;
|
|
731
|
-
if (groupParent) {
|
|
732
|
-
action = Actions.SEND_TEMP_MSG;
|
|
733
|
-
params = {
|
|
734
|
-
group_id: Number(groupParent),
|
|
735
|
-
user_id: Number(options.id),
|
|
736
|
-
message,
|
|
737
|
-
};
|
|
738
|
-
} else {
|
|
739
|
-
action = Actions.SEND_PRIVATE_MSG;
|
|
740
|
-
params = { user_id: Number(options.id), message };
|
|
741
|
-
}
|
|
274
|
+
switch (parsed.kind) {
|
|
275
|
+
case 'private':
|
|
276
|
+
action = Actions.SEND_PRIVATE_MSG;
|
|
277
|
+
params = { user_id: parsed.userId, message };
|
|
742
278
|
break;
|
|
743
|
-
|
|
744
|
-
case "group":
|
|
279
|
+
case 'group':
|
|
745
280
|
action = Actions.SEND_GROUP_MSG;
|
|
746
|
-
params = { group_id:
|
|
281
|
+
params = { group_id: parsed.groupId, message };
|
|
747
282
|
break;
|
|
748
|
-
case
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
283
|
+
case 'temp':
|
|
284
|
+
action = Actions.SEND_TEMP_MSG;
|
|
285
|
+
params = {
|
|
286
|
+
group_id: parsed.groupId,
|
|
287
|
+
user_id: parsed.userId,
|
|
288
|
+
message,
|
|
289
|
+
};
|
|
290
|
+
break;
|
|
291
|
+
case 'channel':
|
|
756
292
|
action = Actions.GUILD_SEND_MSG;
|
|
757
293
|
params = {
|
|
758
|
-
guild_id: guildId,
|
|
759
|
-
channel_id:
|
|
294
|
+
guild_id: parsed.guildId,
|
|
295
|
+
channel_id: parsed.channelId,
|
|
760
296
|
message,
|
|
761
297
|
};
|
|
762
298
|
break;
|
|
763
|
-
}
|
|
764
|
-
default:
|
|
765
|
-
throw new Error(`不支持的频道类型: ${options.type}`);
|
|
766
299
|
}
|
|
767
|
-
|
|
768
300
|
const resp = await this.ipc.request(action, params);
|
|
769
301
|
if (!resp.ok) {
|
|
770
|
-
this.logger.debug(formatCompact({
|
|
771
|
-
op: action,
|
|
772
|
-
...params,
|
|
773
|
-
}));
|
|
774
302
|
throw new Error(`发送消息失败: ${resp.error}`);
|
|
775
303
|
}
|
|
776
|
-
|
|
777
304
|
const messageId = String(
|
|
778
|
-
(resp.data as
|
|
779
|
-
);
|
|
780
|
-
this.logger.debug(
|
|
781
|
-
`${this.$id} send ${options.type}(${options.id}):${segment.raw(options.content)}`,
|
|
305
|
+
(resp.data as { message_id?: unknown } | undefined)?.message_id ?? `sent_${Date.now()}`,
|
|
782
306
|
);
|
|
307
|
+
logger.debug(formatCompact({
|
|
308
|
+
op: 'icqq_send',
|
|
309
|
+
endpoint: this.name,
|
|
310
|
+
target,
|
|
311
|
+
messageId,
|
|
312
|
+
}));
|
|
783
313
|
return messageId;
|
|
784
314
|
}
|
|
785
315
|
|
|
786
|
-
// ──
|
|
316
|
+
// ── 消息回应(activity-feedback / typing reaction)────────────────────
|
|
787
317
|
|
|
788
|
-
/**
|
|
789
|
-
* 表情符号到 reaction id 的映射
|
|
790
|
-
* QQ 使用数字 ID 来标识表情,而不是 Unicode 字符
|
|
791
|
-
*/
|
|
792
318
|
private static readonly EMOJI_MAP: Record<string, string> = {
|
|
793
|
-
'⏳': '1468368274',
|
|
794
|
-
'👍': '128077',
|
|
795
|
-
'❤️': '10084',
|
|
796
|
-
'😊': '128522',
|
|
797
|
-
'🎉': '127881',
|
|
798
|
-
'🔥': '128293',
|
|
799
|
-
'✅': '9989',
|
|
800
|
-
'❌': '10060',
|
|
801
|
-
'⭐': '11088',
|
|
802
|
-
'💯': '128175',
|
|
319
|
+
'⏳': '1468368274',
|
|
320
|
+
'👍': '128077',
|
|
321
|
+
'❤️': '10084',
|
|
322
|
+
'😊': '128522',
|
|
323
|
+
'🎉': '127881',
|
|
324
|
+
'🔥': '128293',
|
|
325
|
+
'✅': '9989',
|
|
326
|
+
'❌': '10060',
|
|
327
|
+
'⭐': '11088',
|
|
328
|
+
'💯': '128175',
|
|
803
329
|
};
|
|
804
330
|
|
|
805
|
-
/**
|
|
806
|
-
* 将表情符号转换为 reaction id
|
|
807
|
-
*/
|
|
808
331
|
private getEmojiId(emoji: string): string {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
return emoji;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// 从映射中查找
|
|
815
|
-
const id = IcqqEndpoint.EMOJI_MAP[emoji];
|
|
816
|
-
if (id) {
|
|
817
|
-
return id;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
// 默认返回 Unicode 码点
|
|
821
|
-
return String(emoji.codePointAt(0) || 0);
|
|
332
|
+
if (/^\d+$/u.test(emoji)) return emoji;
|
|
333
|
+
return IcqqIpcEndpoint.EMOJI_MAP[emoji] ?? String(emoji.codePointAt(0) || 0);
|
|
822
334
|
}
|
|
823
335
|
|
|
824
|
-
/**
|
|
825
|
-
|
|
826
|
-
*
|
|
827
|
-
* @param messageId - 消息 ID
|
|
828
|
-
* @param emoji - 表情符号或 reaction id
|
|
829
|
-
* @returns 反应 ID,可用于后续移除
|
|
830
|
-
*/
|
|
831
|
-
async $addReaction(messageId: string, emoji: string): Promise<string | null> {
|
|
336
|
+
/** Activity-feedback / OutboundHost reaction surface. */
|
|
337
|
+
async addReaction(messageId: string, emoji: string): Promise<string | null> {
|
|
832
338
|
try {
|
|
833
339
|
const emojiId = this.getEmojiId(emoji);
|
|
834
|
-
|
|
835
|
-
const resp = await this.ipc.request(Actions.GROUP_SET_REACTION, {
|
|
340
|
+
const resp = await this.request(Actions.GROUP_SET_REACTION, {
|
|
836
341
|
message_id: messageId,
|
|
837
342
|
id: emojiId,
|
|
838
343
|
});
|
|
839
|
-
|
|
840
344
|
if (!resp.ok) {
|
|
841
|
-
|
|
842
|
-
op:
|
|
843
|
-
endpoint: this
|
|
345
|
+
logger.warn(formatCompact({
|
|
346
|
+
op: 'add_reaction',
|
|
347
|
+
endpoint: this.name,
|
|
844
348
|
message_id: messageId,
|
|
845
349
|
emoji,
|
|
846
350
|
id: emojiId,
|
|
@@ -849,22 +353,19 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
|
|
|
849
353
|
}));
|
|
850
354
|
return null;
|
|
851
355
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
endpoint: this.$id,
|
|
356
|
+
logger.debug(formatCompact({
|
|
357
|
+
op: 'add_reaction',
|
|
358
|
+
endpoint: this.name,
|
|
856
359
|
message_id: messageId,
|
|
857
360
|
emoji,
|
|
858
361
|
id: emojiId,
|
|
859
362
|
ok: true,
|
|
860
363
|
}));
|
|
861
|
-
|
|
862
|
-
// 返回 reaction id,供 $removeReaction 直接使用
|
|
863
364
|
return emojiId;
|
|
864
365
|
} catch (error) {
|
|
865
|
-
|
|
866
|
-
op:
|
|
867
|
-
endpoint: this
|
|
366
|
+
logger.warn(formatCompact({
|
|
367
|
+
op: 'add_reaction',
|
|
368
|
+
endpoint: this.name,
|
|
868
369
|
message_id: messageId,
|
|
869
370
|
emoji,
|
|
870
371
|
ok: false,
|
|
@@ -874,61 +375,469 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
|
|
|
874
375
|
}
|
|
875
376
|
}
|
|
876
377
|
|
|
877
|
-
|
|
878
|
-
* 移除消息回应
|
|
879
|
-
*
|
|
880
|
-
* @param messageId - 消息 ID
|
|
881
|
-
* @param reactionId - 反应 ID(由 $addReaction 返回)
|
|
882
|
-
*/
|
|
883
|
-
async $removeReaction(messageId: string, reactionId: string): Promise<void> {
|
|
378
|
+
async removeReaction(messageId: string, reactionId: string): Promise<void> {
|
|
884
379
|
try {
|
|
885
|
-
// 兼容两种格式:
|
|
886
|
-
// 1) 旧格式 reaction_{id}_{timestamp}
|
|
887
|
-
// 2) 新格式直接为 id
|
|
888
380
|
const parts = reactionId.split('_');
|
|
889
|
-
const emojiId = parts.length >= 2 ? parts[1] : reactionId;
|
|
890
|
-
|
|
891
|
-
const resp = await this.ipc.request(Actions.GROUP_DEL_REACTION, {
|
|
381
|
+
const emojiId = parts.length >= 2 ? parts[1]! : reactionId;
|
|
382
|
+
const resp = await this.request(Actions.GROUP_DEL_REACTION, {
|
|
892
383
|
message_id: messageId,
|
|
893
384
|
id: emojiId,
|
|
894
385
|
});
|
|
895
|
-
|
|
896
386
|
if (!resp.ok) {
|
|
897
|
-
|
|
898
|
-
op:
|
|
899
|
-
endpoint: this
|
|
387
|
+
logger.warn(formatCompact({
|
|
388
|
+
op: 'remove_reaction',
|
|
389
|
+
endpoint: this.name,
|
|
900
390
|
message_id: messageId,
|
|
901
391
|
reaction_id: reactionId,
|
|
902
392
|
id: emojiId,
|
|
903
393
|
ok: false,
|
|
904
394
|
error: resp.error,
|
|
905
395
|
}));
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
logger.debug(formatCompact({
|
|
399
|
+
op: 'remove_reaction',
|
|
400
|
+
endpoint: this.name,
|
|
401
|
+
message_id: messageId,
|
|
402
|
+
reaction_id: reactionId,
|
|
403
|
+
id: emojiId,
|
|
404
|
+
ok: true,
|
|
405
|
+
}));
|
|
406
|
+
} catch (error) {
|
|
407
|
+
logger.warn(formatCompact({
|
|
408
|
+
op: 'remove_reaction',
|
|
409
|
+
endpoint: this.name,
|
|
410
|
+
message_id: messageId,
|
|
411
|
+
reaction_id: reactionId,
|
|
412
|
+
ok: false,
|
|
413
|
+
error: error instanceof Error ? error.message : String(error),
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Activity-feedback autoRemove / Console recall. */
|
|
419
|
+
async recallMessage(messageId: string): Promise<void> {
|
|
420
|
+
if (!messageId || messageId.startsWith('outbound:')) return;
|
|
421
|
+
try {
|
|
422
|
+
const resp = await this.request(Actions.RECALL_MSG, { message_id: messageId });
|
|
423
|
+
if (!resp.ok) {
|
|
424
|
+
logger.warn(formatCompact({
|
|
425
|
+
op: 'recall_message',
|
|
426
|
+
endpoint: this.name,
|
|
910
427
|
message_id: messageId,
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
ok: true,
|
|
428
|
+
ok: false,
|
|
429
|
+
error: resp.error,
|
|
914
430
|
}));
|
|
431
|
+
return;
|
|
915
432
|
}
|
|
433
|
+
logger.debug(formatCompact({
|
|
434
|
+
op: 'recall_message',
|
|
435
|
+
endpoint: this.name,
|
|
436
|
+
message_id: messageId,
|
|
437
|
+
ok: true,
|
|
438
|
+
}));
|
|
916
439
|
} catch (error) {
|
|
917
|
-
|
|
918
|
-
op:
|
|
919
|
-
endpoint: this
|
|
440
|
+
logger.warn(formatCompact({
|
|
441
|
+
op: 'recall_message',
|
|
442
|
+
endpoint: this.name,
|
|
920
443
|
message_id: messageId,
|
|
921
|
-
reaction_id: reactionId,
|
|
922
444
|
ok: false,
|
|
923
445
|
error: error instanceof Error ? error.message : String(error),
|
|
924
446
|
}));
|
|
925
447
|
}
|
|
926
448
|
}
|
|
449
|
+
|
|
450
|
+
// ── 社交/群管(Console endpoint RPC 探测面)────────────────────────
|
|
451
|
+
// console-rpc-extended.ts 按方法名探测这些接口;全部薄封装走 IPC daemon。
|
|
452
|
+
|
|
453
|
+
/** EndpointManagement.listFriends —— 好友列表(LIST_FRIENDS,归一为 {user_id, nickname, remark})。 */
|
|
454
|
+
async getFriendList(): Promise<Array<{ user_id: number; nickname: string; remark: string }>> {
|
|
455
|
+
const resp = await this.#mustRequest(Actions.LIST_FRIENDS, undefined, '获取好友列表');
|
|
456
|
+
const list = Array.isArray(resp.data) ? (resp.data as IpcFriendInfo[]) : [];
|
|
457
|
+
return list.map((f) => ({
|
|
458
|
+
user_id: f.user_id,
|
|
459
|
+
nickname: f.nickname,
|
|
460
|
+
remark: f.remark ?? '',
|
|
461
|
+
}));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** EndpointManagement.listGroups —— 群列表(LIST_GROUPS,归一为 {group_id, name})。 */
|
|
465
|
+
async getGroupList(): Promise<Array<{ group_id: number; name: string }>> {
|
|
466
|
+
const resp = await this.#mustRequest(Actions.LIST_GROUPS, undefined, '获取群列表');
|
|
467
|
+
const list = Array.isArray(resp.data) ? (resp.data as IpcGroupInfo[]) : [];
|
|
468
|
+
return list.map((g) => ({
|
|
469
|
+
group_id: g.group_id,
|
|
470
|
+
name: g.group_name,
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** EndpointManagement.listGroupMembers —— 群成员列表(字段对齐 daemon 返回)。 */
|
|
475
|
+
async getGroupMemberList(groupId: number | string): Promise<IpcMemberInfo[]> {
|
|
476
|
+
const resp = await this.#mustRequest(
|
|
477
|
+
Actions.LIST_GROUP_MEMBERS,
|
|
478
|
+
{ group_id: toNumericId(groupId, 'group_id') },
|
|
479
|
+
'获取群成员列表',
|
|
480
|
+
);
|
|
481
|
+
return Array.isArray(resp.data) ? (resp.data as IpcMemberInfo[]) : [];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** getGroupMemberList 别名(console 探测 listMembers / getMemberList)。 */
|
|
485
|
+
listMembers(groupId: number | string): Promise<IpcMemberInfo[]> {
|
|
486
|
+
return this.getGroupMemberList(groupId);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
getMemberList(groupId: number | string): Promise<IpcMemberInfo[]> {
|
|
490
|
+
return this.getGroupMemberList(groupId);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* EndpointManagement.resolveRequest —— id 为 inbox 行的 platform_request_id。
|
|
495
|
+
* 先 GET_SYSTEM_MSG 按 flag(回退 seq)定位请求,再按类型路由 handle_friend/group_request。
|
|
496
|
+
*/
|
|
497
|
+
async approveRequest(id: string, remark?: string): Promise<void> {
|
|
498
|
+
await this.#handleSystemRequest(id, true, { remark });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async rejectRequest(id: string, reason?: string): Promise<void> {
|
|
502
|
+
await this.#handleSystemRequest(id, false, { reason });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** EndpointManagement.deleteFriend —— FRIEND_DELETE。 */
|
|
506
|
+
async deleteFriend(userId: number | string): Promise<void> {
|
|
507
|
+
await this.#mustRequest(
|
|
508
|
+
Actions.FRIEND_DELETE,
|
|
509
|
+
{ user_id: toNumericId(userId, 'user_id') },
|
|
510
|
+
'删除好友',
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** deleteFriend 别名(console 探测 delete_friend)。 */
|
|
515
|
+
delete_friend(userId: number | string): Promise<void> {
|
|
516
|
+
return this.deleteFriend(userId);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** EndpointManagement.kickGroupMember —— GROUP_KICK。 */
|
|
520
|
+
async removeMember(groupId: number | string, userId: number | string): Promise<void> {
|
|
521
|
+
await this.#mustRequest(
|
|
522
|
+
Actions.GROUP_KICK,
|
|
523
|
+
{ group_id: toNumericId(groupId, 'group_id'), user_id: toNumericId(userId, 'user_id') },
|
|
524
|
+
'踢出群成员',
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
kickMember(groupId: number | string, userId: number | string): Promise<void> {
|
|
529
|
+
return this.removeMember(groupId, userId);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
setGroupKick(groupId: number | string, userId: number | string): Promise<void> {
|
|
533
|
+
return this.removeMember(groupId, userId);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** EndpointManagement.muteGroupMember —— GROUP_MUTE(duration 秒,默认 600)。 */
|
|
537
|
+
async muteMember(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
|
|
538
|
+
await this.#mustRequest(
|
|
539
|
+
Actions.GROUP_MUTE,
|
|
540
|
+
{
|
|
541
|
+
group_id: toNumericId(groupId, 'group_id'),
|
|
542
|
+
user_id: toNumericId(userId, 'user_id'),
|
|
543
|
+
duration,
|
|
544
|
+
},
|
|
545
|
+
'禁言群成员',
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
banMember(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
|
|
550
|
+
return this.muteMember(groupId, userId, duration);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
setGroupMute(groupId: number | string, userId: number | string, duration = 600): Promise<void> {
|
|
554
|
+
return this.muteMember(groupId, userId, duration);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** EndpointManagement.setGroupAdmin —— SET_GROUP_ADMIN(enable 默认 true)。 */
|
|
558
|
+
async setModerator(groupId: number | string, userId: number | string, enable = true): Promise<void> {
|
|
559
|
+
await this.#mustRequest(
|
|
560
|
+
Actions.SET_GROUP_ADMIN,
|
|
561
|
+
{
|
|
562
|
+
group_id: toNumericId(groupId, 'group_id'),
|
|
563
|
+
user_id: toNumericId(userId, 'user_id'),
|
|
564
|
+
enable,
|
|
565
|
+
},
|
|
566
|
+
'设置群管理员',
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
setAdmin(groupId: number | string, userId: number | string, enable = true): Promise<void> {
|
|
571
|
+
return this.setModerator(groupId, userId, enable);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
setGroupAdmin(groupId: number | string, userId: number | string, enable = true): Promise<void> {
|
|
575
|
+
return this.setModerator(groupId, userId, enable);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** IPC 请求并统一错误上下文:daemon 返回 ok=false 时抛出带操作名的错误。 */
|
|
579
|
+
async #mustRequest(
|
|
580
|
+
action: string,
|
|
581
|
+
params: Record<string, unknown> | undefined,
|
|
582
|
+
label: string,
|
|
583
|
+
): Promise<IpcResponse> {
|
|
584
|
+
const resp = await this.request(action, params);
|
|
585
|
+
if (!resp.ok) {
|
|
586
|
+
throw new Error(`icqq ${label}失败: ${resp.error ?? 'daemon 未返回错误详情'}`);
|
|
587
|
+
}
|
|
588
|
+
return resp;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** 按 flag/seq 在 GET_SYSTEM_MSG 中定位请求,路由到好友/群请求处理 action。 */
|
|
592
|
+
async #handleSystemRequest(
|
|
593
|
+
id: string,
|
|
594
|
+
approve: boolean,
|
|
595
|
+
extra: { remark?: string; reason?: string },
|
|
596
|
+
): Promise<void> {
|
|
597
|
+
const resp = await this.#mustRequest(Actions.GET_SYSTEM_MSG, undefined, '获取待处理请求');
|
|
598
|
+
const data = resp.data as
|
|
599
|
+
| { friendRequests?: unknown; groupRequests?: unknown }
|
|
600
|
+
| undefined;
|
|
601
|
+
const friendRequests = Array.isArray(data?.friendRequests)
|
|
602
|
+
? (data.friendRequests as IpcSystemMessage[])
|
|
603
|
+
: [];
|
|
604
|
+
const groupRequests = Array.isArray(data?.groupRequests)
|
|
605
|
+
? (data.groupRequests as IpcSystemMessage[])
|
|
606
|
+
: [];
|
|
607
|
+
const matches = (m: IpcSystemMessage): boolean =>
|
|
608
|
+
m.flag === id || (m.seq != null && String(m.seq) === id);
|
|
609
|
+
const friend = friendRequests.find(matches);
|
|
610
|
+
const group = friend ? undefined : groupRequests.find(matches);
|
|
611
|
+
const target = friend ?? group;
|
|
612
|
+
if (!target?.flag) {
|
|
613
|
+
throw new Error(`icqq 未找到待处理请求: ${id}(GET_SYSTEM_MSG 中无匹配 flag/seq)`);
|
|
614
|
+
}
|
|
615
|
+
const params: Record<string, unknown> = { flag: target.flag, approve };
|
|
616
|
+
if (friend) {
|
|
617
|
+
if (extra.remark) params.remark = extra.remark;
|
|
618
|
+
await this.#mustRequest(Actions.HANDLE_FRIEND_REQUEST, params, '处理好友请求');
|
|
619
|
+
} else {
|
|
620
|
+
if (extra.reason) params.reason = extra.reason;
|
|
621
|
+
await this.#mustRequest(Actions.HANDLE_GROUP_REQUEST, params, '处理群请求');
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Test / internal: admit when open. */
|
|
626
|
+
admit(msg: IcqqInboundMessage): void {
|
|
627
|
+
if (!this.#open) return;
|
|
628
|
+
// 新 Runtime Message.content 为纯文本:@ 本机(uin = name)只能经 metadata 传递
|
|
629
|
+
const mentioned = isIcqqBotMentioned({ uin: this.name, rawMessage: msg.content });
|
|
630
|
+
void this.#options.gateway.receive({
|
|
631
|
+
adapter: this.#options.id,
|
|
632
|
+
target: msg.target,
|
|
633
|
+
content: msg.content,
|
|
634
|
+
sender: msg.sender,
|
|
635
|
+
id: msg.id,
|
|
636
|
+
metadata: Object.freeze({
|
|
637
|
+
endpoint: this.name,
|
|
638
|
+
channelType: msg.channelType,
|
|
639
|
+
...msg.metadata,
|
|
640
|
+
...(mentioned ? { mentioned: true } : {}),
|
|
641
|
+
}),
|
|
642
|
+
}).catch((err) => {
|
|
643
|
+
logger.warn(formatCompact({
|
|
644
|
+
op: 'icqq_gateway_receive_failed',
|
|
645
|
+
target: msg.target,
|
|
646
|
+
error: err instanceof Error ? err.message : String(err),
|
|
647
|
+
}));
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async #refreshLists(): Promise<void> {
|
|
652
|
+
const [flResp, glResp] = await Promise.all([
|
|
653
|
+
this.ipc.request(Actions.LIST_FRIENDS),
|
|
654
|
+
this.ipc.request(Actions.LIST_GROUPS),
|
|
655
|
+
]);
|
|
656
|
+
this.friends.clear();
|
|
657
|
+
if (flResp.ok && Array.isArray(flResp.data)) {
|
|
658
|
+
for (const f of flResp.data as IpcFriendInfo[]) {
|
|
659
|
+
this.friends.set(f.user_id, f);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
this.groups.clear();
|
|
663
|
+
if (glResp.ok && Array.isArray(glResp.data)) {
|
|
664
|
+
for (const g of glResp.data as IpcGroupInfo[]) {
|
|
665
|
+
this.groups.set(g.group_id, g);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#handleEvent(event: IpcEvent): void {
|
|
671
|
+
if (isIcqqGuildIpcEvent(event.event)) {
|
|
672
|
+
this.#handleGuildEvent(event);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
const payload = unwrapIcqqIpcEventPayload(event);
|
|
676
|
+
if (!payload || typeof payload !== 'object') return;
|
|
677
|
+
if (isIcqqRequestPayload(payload)) {
|
|
678
|
+
this.#recordInboxRequest(payload as Record<string, unknown>);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (isIcqqNoticePayload(payload)) {
|
|
682
|
+
this.#recordInboxNotice(payload as Record<string, unknown>);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (!isIcqqMessagePostType(payload)) return;
|
|
686
|
+
const data = payload as IcqqIpcMessageEvent;
|
|
687
|
+
if (shouldSkipSelfInboundMessage(data)) return;
|
|
688
|
+
const normalized = normalizeIcqqInboundMessage(data);
|
|
689
|
+
if (!normalized) return;
|
|
690
|
+
if (!this.#inboundDeduper.shouldProcess(normalized.messageId)) return;
|
|
691
|
+
this.admit({
|
|
692
|
+
id: normalized.messageId,
|
|
693
|
+
target: formatInboundTarget({
|
|
694
|
+
channelType: normalized.channelType,
|
|
695
|
+
channelId: normalized.channelId,
|
|
696
|
+
channelParentGroupId: normalized.channelParentGroupId,
|
|
697
|
+
}),
|
|
698
|
+
content: formatInboundContent(normalized.rawMessage),
|
|
699
|
+
sender: normalized.userId,
|
|
700
|
+
channelType: normalized.channelType,
|
|
701
|
+
metadata: buildIcqqQuoteMetadata(data, {
|
|
702
|
+
nickname: normalized.nickname,
|
|
703
|
+
senderRole: normalized.senderRole,
|
|
704
|
+
}),
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
#handleGuildEvent(event: IpcEvent): void {
|
|
709
|
+
const payload = event.data;
|
|
710
|
+
if (!payload || typeof payload !== 'object') return;
|
|
711
|
+
const normalized = normalizeIcqqGuildInboundMessage(
|
|
712
|
+
payload as Parameters<typeof normalizeIcqqGuildInboundMessage>[0],
|
|
713
|
+
);
|
|
714
|
+
if (!normalized) return;
|
|
715
|
+
this.#guildCatalog.upsertFromInbound(normalized.raw);
|
|
716
|
+
if (!this.#inboundDeduper.shouldProcess(`guild:${normalized.messageId}`)) return;
|
|
717
|
+
this.admit({
|
|
718
|
+
id: normalized.messageId,
|
|
719
|
+
target: formatInboundTarget({
|
|
720
|
+
channelType: 'channel',
|
|
721
|
+
channelId: normalized.channelId,
|
|
722
|
+
guildId: normalized.guildId,
|
|
723
|
+
}),
|
|
724
|
+
content: formatInboundContent(normalized.rawMessage),
|
|
725
|
+
sender: normalized.userId,
|
|
726
|
+
channelType: 'channel',
|
|
727
|
+
metadata: {
|
|
728
|
+
guildId: normalized.guildId,
|
|
729
|
+
nickname: normalized.nickname,
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** 收件箱行公共前缀:adapter 槽 localName + endpoint live 名(uin)。 */
|
|
735
|
+
#inboxBase(): { adapter: string; endpointId: string } {
|
|
736
|
+
const id = String(this.#options.id);
|
|
737
|
+
return { adapter: id.split('\0').pop() ?? id, endpointId: this.name };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
#recordInboxRequest(payload: Record<string, unknown>): void {
|
|
741
|
+
const hooks = this.#options.inbox;
|
|
742
|
+
if (!hooks) return;
|
|
743
|
+
const row = buildIcqqInboxRequestRow(payload, this.#inboxBase());
|
|
744
|
+
if (!row) return;
|
|
745
|
+
if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`)) return;
|
|
746
|
+
void hooks.recordRequest(row);
|
|
747
|
+
hooks.publish?.('endpoint:request', row);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
#recordInboxNotice(payload: Record<string, unknown>): void {
|
|
751
|
+
const hooks = this.#options.inbox;
|
|
752
|
+
if (!hooks) return;
|
|
753
|
+
const row = buildIcqqInboxNoticeRow(payload, this.#inboxBase());
|
|
754
|
+
if (!row) return;
|
|
755
|
+
if (!this.#inboxDeduper.shouldProcess(`notice:${String(row.platform_notice_id)}`)) return;
|
|
756
|
+
void hooks.recordNotice(row);
|
|
757
|
+
hooks.publish?.('endpoint:notice', row);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** 启动/重连后首拉 GET_SYSTEM_MSG:补录离线期间的好友/群待处理请求。 */
|
|
761
|
+
async #pullPendingSystemMessages(): Promise<void> {
|
|
762
|
+
const hooks = this.#options.inbox;
|
|
763
|
+
if (!hooks || !this.ipc) return;
|
|
764
|
+
try {
|
|
765
|
+
const resp = await this.ipc.request(Actions.GET_SYSTEM_MSG);
|
|
766
|
+
if (!resp.ok) return;
|
|
767
|
+
const data = resp.data as
|
|
768
|
+
| { friendRequests?: unknown; groupRequests?: unknown }
|
|
769
|
+
| undefined;
|
|
770
|
+
const base = this.#inboxBase();
|
|
771
|
+
const rows = [
|
|
772
|
+
...(Array.isArray(data?.friendRequests)
|
|
773
|
+
? (data.friendRequests as IpcSystemMessage[]).map((m) =>
|
|
774
|
+
buildIcqqSystemRequestRow(m, 'friend', base))
|
|
775
|
+
: []),
|
|
776
|
+
...(Array.isArray(data?.groupRequests)
|
|
777
|
+
? (data.groupRequests as IpcSystemMessage[]).map((m) =>
|
|
778
|
+
buildIcqqSystemRequestRow(m, 'group', base))
|
|
779
|
+
: []),
|
|
780
|
+
];
|
|
781
|
+
for (const row of rows) {
|
|
782
|
+
if (!row) continue;
|
|
783
|
+
if (!this.#inboxDeduper.shouldProcess(`request:${String(row.platform_request_id)}`)) continue;
|
|
784
|
+
void hooks.recordRequest(row);
|
|
785
|
+
hooks.publish?.('endpoint:request', row);
|
|
786
|
+
}
|
|
787
|
+
} catch (error) {
|
|
788
|
+
logger.debug(formatCompact({
|
|
789
|
+
op: 'inbox_pull_system_msg',
|
|
790
|
+
endpoint: this.name,
|
|
791
|
+
error: error instanceof Error ? error.message : String(error),
|
|
792
|
+
}));
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* 组装入站 metadata:在基础字段上补充 quote 链路信息。
|
|
799
|
+
* - quote_id:被引用消息 id(resolveIcqqQuoteIdFromEvent,无则不写)
|
|
800
|
+
* - quote_sender_id / quote_sender_name / quote_content:source 可用时的平铺摘要
|
|
801
|
+
*/
|
|
802
|
+
function buildIcqqQuoteMetadata(
|
|
803
|
+
data: IcqqIpcMessageEvent,
|
|
804
|
+
base: Record<string, unknown>,
|
|
805
|
+
): Record<string, unknown> {
|
|
806
|
+
const metadata: Record<string, unknown> = { ...base };
|
|
807
|
+
const quoteId = resolveIcqqQuoteIdFromEvent(data);
|
|
808
|
+
if (quoteId) metadata.quote_id = quoteId;
|
|
809
|
+
const quoted = quotedPayloadFromIcqqSource(findIcqqNestedMessageSource(data));
|
|
810
|
+
if (quoted?.sender?.id) metadata.quote_sender_id = quoted.sender.id;
|
|
811
|
+
if (quoted?.sender?.name) metadata.quote_sender_name = quoted.sender.name;
|
|
812
|
+
const quoteText = quoted
|
|
813
|
+
? (typeof quoted.content === 'string'
|
|
814
|
+
? quoted.content
|
|
815
|
+
: quoted.content
|
|
816
|
+
.map((seg) =>
|
|
817
|
+
seg && typeof seg === 'object' && seg.type === 'text'
|
|
818
|
+
? String((seg.data as { text?: unknown } | undefined)?.text ?? '')
|
|
819
|
+
: '',
|
|
820
|
+
)
|
|
821
|
+
.join('')
|
|
822
|
+
).trim() || quoted.raw || ''
|
|
823
|
+
: '';
|
|
824
|
+
if (quoteText) metadata.quote_content = quoteText;
|
|
825
|
+
return metadata;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** console RPC 传入的 gid/uid 可能是字符串,统一收敛为数字。 */
|
|
829
|
+
function toNumericId(value: number | string, label: string): number {
|
|
830
|
+
const n = Number(value);
|
|
831
|
+
if (!Number.isFinite(n) || String(value).trim() === '') {
|
|
832
|
+
throw new TypeError(`icqq ${label} 必须是数字: ${String(value)}`);
|
|
833
|
+
}
|
|
834
|
+
return n;
|
|
927
835
|
}
|
|
928
836
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
837
|
+
async function defaultCreateIpc(config: ResolvedIcqqConfig): Promise<IcqqIpcTransport> {
|
|
838
|
+
const uin = Number(config.name);
|
|
839
|
+
if (config.rpc) {
|
|
840
|
+
return IpcClient.connectRpc(config.rpc);
|
|
841
|
+
}
|
|
842
|
+
return IpcClient.connect(uin);
|
|
934
843
|
}
|