@zhin.js/adapter-icqq 2.0.6 → 2.0.7

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/adapter.d.ts +7 -2
  3. package/lib/adapter.d.ts.map +1 -1
  4. package/lib/adapter.js +78 -33
  5. package/lib/adapter.js.map +1 -1
  6. package/lib/bot.d.ts +29 -29
  7. package/lib/bot.d.ts.map +1 -1
  8. package/lib/bot.js +226 -405
  9. package/lib/bot.js.map +1 -1
  10. package/lib/commands/index.d.ts +7 -0
  11. package/lib/commands/index.d.ts.map +1 -0
  12. package/lib/commands/index.js +30 -0
  13. package/lib/commands/index.js.map +1 -0
  14. package/lib/index.d.ts +1 -1
  15. package/lib/index.d.ts.map +1 -1
  16. package/lib/index.js +13 -297
  17. package/lib/index.js.map +1 -1
  18. package/lib/ipc-client.d.ts +60 -0
  19. package/lib/ipc-client.d.ts.map +1 -0
  20. package/lib/ipc-client.js +272 -0
  21. package/lib/ipc-client.js.map +1 -0
  22. package/lib/protocol.d.ts +174 -0
  23. package/lib/protocol.d.ts.map +1 -0
  24. package/lib/protocol.js +162 -0
  25. package/lib/protocol.js.map +1 -0
  26. package/lib/routes.d.ts +8 -0
  27. package/lib/routes.d.ts.map +1 -0
  28. package/lib/routes.js +67 -0
  29. package/lib/routes.js.map +1 -0
  30. package/lib/tools/index.d.ts +10 -0
  31. package/lib/tools/index.d.ts.map +1 -0
  32. package/lib/tools/index.js +336 -0
  33. package/lib/tools/index.js.map +1 -0
  34. package/lib/types.d.ts +45 -7
  35. package/lib/types.d.ts.map +1 -1
  36. package/lib/types.js +5 -0
  37. package/lib/types.js.map +1 -1
  38. package/package.json +3 -5
  39. package/plugin.yml +1 -1
  40. package/skills/icqq/SKILL.md +31 -64
  41. package/skills/icqq/references/friends.md +54 -0
  42. package/skills/icqq/references/general.md +145 -0
  43. package/skills/icqq/references/gfs.md +49 -0
  44. package/skills/icqq/references/groups.md +71 -0
  45. package/skills/icqq/references/messaging.md +66 -0
  46. package/skills/icqq/references/requests.md +27 -0
  47. package/skills/icqq/references/settings.md +38 -0
  48. package/src/adapter.ts +73 -35
  49. package/src/bot.ts +272 -443
  50. package/src/commands/index.ts +32 -0
  51. package/src/index.ts +14 -305
  52. package/src/ipc-client.ts +326 -0
  53. package/src/protocol.ts +242 -0
  54. package/src/routes.ts +83 -0
  55. package/src/tools/index.ts +407 -0
  56. package/src/types.ts +47 -7
package/src/bot.ts CHANGED
@@ -1,28 +1,9 @@
1
1
  /**
2
- * ICQQ Bot:继承 icqq Client,实现 zhin Bot 接口
2
+ * ICQQ Bot:通过 @icqqjs/cli 守护进程 IPC 通信,实现 zhin Bot 接口
3
+ *
4
+ * 不再直接依赖 @icqqjs/icqq 协议库。
5
+ * 登录由 `icqq login` 完成,本 Bot 只负责连接守护进程并收发消息。
3
6
  */
4
- import {
5
- Client,
6
- PrivateMessageEvent,
7
- GroupMessageEvent,
8
- Sendable,
9
- MessageElem,
10
- MemberInfo,
11
- MemberIncreaseEvent,
12
- MemberDecreaseEvent,
13
- GroupRecallEvent,
14
- GroupAdminEvent,
15
- GroupMuteEvent,
16
- GroupTransferEvent,
17
- GroupPokeEvent,
18
- FriendRecallEvent,
19
- FriendPokeEvent,
20
- FriendIncreaseEvent,
21
- FriendRequestEvent,
22
- GroupRequestEvent,
23
- GroupInviteEvent,
24
- } from "@icqqjs/icqq";
25
- import path from "path";
26
7
  import {
27
8
  Bot,
28
9
  Message,
@@ -30,181 +11,184 @@ import {
30
11
  MessageSegment,
31
12
  SendContent,
32
13
  segment,
33
- Notice,
34
- Request,
35
14
  } from "zhin.js";
36
- import type { IcqqBotConfig, IcqqSenderInfo } from "./types.js";
15
+ import type {
16
+ IcqqBotConfig,
17
+ IcqqSenderInfo,
18
+ IpcFriendInfo,
19
+ IpcGroupInfo,
20
+ IpcMemberInfo,
21
+ } from "./types.js";
37
22
  import type { IcqqAdapter } from "./adapter.js";
23
+ import { IpcClient } from "./ipc-client.js";
24
+ import {
25
+ Actions,
26
+ type IpcMessageEventData,
27
+ type IpcEvent,
28
+ } from "./protocol.js";
38
29
 
39
- export class IcqqBot
40
- extends Client
41
- implements Bot<IcqqBotConfig, PrivateMessageEvent | GroupMessageEvent>
42
- {
43
- $connected: boolean = false;
44
- $config!: IcqqBotConfig;
30
+ export class IcqqBot implements Bot<IcqqBotConfig, IpcMessageEventData> {
31
+ $connected = false;
32
+ $config: IcqqBotConfig;
33
+ ipc!: IpcClient;
45
34
 
46
- get pluginLogger() {
47
- return this.adapter.plugin.logger;
48
- }
35
+ /** 缓存的好友列表 */
36
+ friends = new Map<number, IpcFriendInfo>();
37
+ /** 缓存的群列表 */
38
+ groups = new Map<number, IpcGroupInfo>();
39
+
40
+ private subscriptions: Array<{ unsubscribe: () => Promise<void> }> = [];
49
41
 
50
42
  get $id() {
51
43
  return this.$config.name;
52
44
  }
53
45
 
54
- constructor(public adapter: IcqqAdapter, config: IcqqBotConfig) {
55
- if (!config.scope) config.scope = "icqqjs";
56
- if (!config.data_dir) config.data_dir = path.join(process.cwd(), "data");
57
- if (config.scope.startsWith("@")) config.scope = config.scope.slice(1);
58
- super(config);
46
+ get logger() {
47
+ return this.adapter.plugin.logger;
48
+ }
49
+
50
+ constructor(
51
+ public adapter: IcqqAdapter,
52
+ config: IcqqBotConfig,
53
+ ) {
59
54
  this.$config = config;
60
55
  }
61
56
 
62
- private handleIcqqMessage(
63
- msg: PrivateMessageEvent | GroupMessageEvent,
64
- ): void {
65
- const message = this.$formatMessage(msg);
66
- this.adapter.emit("message.receive", message);
67
- this.pluginLogger.debug(
68
- `${this.$config.name} recv ${message.$channel.type}(${
69
- message.$channel.id
70
- }):${segment.raw(message.$content)}`,
57
+ // ── 连接 ───────────────────────────────────────────────────────────
58
+
59
+ async $connect(): Promise<void> {
60
+ const uin = Number(this.$config.name);
61
+ const rpc = this.$config.rpc;
62
+
63
+ if (rpc) {
64
+ this.logger.info(`[${this.$id}] 正在通过 RPC 连接 icqq 守护进程 (${rpc.host}:${rpc.port})...`);
65
+ this.ipc = await IpcClient.connectRpc(rpc);
66
+ } else {
67
+ this.logger.info(`[${this.$id}] 正在连接 icqq 守护进程...`);
68
+ this.ipc = await IpcClient.connect(uin);
69
+ }
70
+ this.logger.info(`[${this.$id}] 已连接守护进程${rpc ? " (RPC)" : ""}`);
71
+
72
+ // 拉取好友/群列表并缓存
73
+ await this.refreshLists();
74
+
75
+ // 订阅所有好友消息
76
+ for (const [uid] of this.friends) {
77
+ const sub = this.ipc.subscribe(
78
+ Actions.SUBSCRIBE,
79
+ { type: "private", id: uid },
80
+ (event) => this.handleEvent(event),
81
+ );
82
+ this.subscriptions.push(sub);
83
+ }
84
+
85
+ // 订阅所有群消息
86
+ for (const [gid] of this.groups) {
87
+ const sub = this.ipc.subscribe(
88
+ Actions.SUBSCRIBE,
89
+ { type: "group", id: gid },
90
+ (event) => this.handleEvent(event),
91
+ );
92
+ this.subscriptions.push(sub);
93
+ }
94
+
95
+ this.$connected = true;
96
+ this.logger.info(
97
+ `[${this.$id}] 登录成功,好友 ${this.friends.size},群 ${this.groups.size}`,
71
98
  );
72
99
  }
73
100
 
74
- async $connect(): Promise<void> {
75
- this.on("message", this.handleIcqqMessage.bind(this));
76
- this.on("notice.group.increase", (e: MemberIncreaseEvent) => this.handleGroupNotice(e, 'group_member_increase', 'increase'));
77
- this.on("notice.group.decrease", (e: MemberDecreaseEvent) => this.handleGroupNotice(e, 'group_member_decrease', 'decrease'));
78
- this.on("notice.group.recall", (e: GroupRecallEvent) => this.handleGroupNotice(e, 'group_recall', 'recall'));
79
- this.on("notice.group.admin", (e: GroupAdminEvent) => this.handleGroupNotice(e, 'group_admin_change', 'admin'));
80
- this.on("notice.group.ban", (e: GroupMuteEvent) => this.handleGroupNotice(e, 'group_ban', 'ban'));
81
- this.on("notice.group.transfer", (e: GroupTransferEvent) => this.handleGroupNotice(e, 'group_transfer', 'transfer'));
82
- this.on("notice.group.poke", (e: GroupPokeEvent) => this.handleGroupNotice(e, 'group_poke', 'poke'));
83
- this.on("notice.friend.increase", (e: FriendIncreaseEvent) => this.handleFriendNotice(e, 'friend_add', 'increase'));
84
- this.on("notice.friend.recall", (e: FriendRecallEvent) => this.handleFriendNotice(e, 'friend_recall', 'recall'));
85
- this.on("notice.friend.poke", (e: FriendPokeEvent) => this.handleFriendNotice(e, 'friend_poke', 'poke'));
86
- this.on("request.friend.add", (e: FriendRequestEvent) => this.handleFriendRequest(e));
87
- this.on("request.group.add", (e: GroupRequestEvent) => this.handleGroupRequest(e, 'group_add'));
88
- this.on("request.group.invite", (e: GroupInviteEvent) => this.handleGroupRequest(e, 'group_invite'));
89
-
90
- this.on("system.login.device", async (e) => {
91
- this.pluginLogger.info(`[${this.$config.name}] 触发设备验证,正在发送短信验证码...`);
92
- await this.sendSmsCode();
93
- this.pluginLogger.info(`[${this.$config.name}] 短信验证码已发送,请查收手机短信`);
94
- this.pluginLogger.info(`[${this.$config.name}] 请在终端输入短信验证码:`);
95
- process.stdin.once("data", (data: Buffer) => {
96
- this.pluginLogger.info(`[${this.$config.name}] 正在提交短信验证码...`);
97
- this.submitSmsCode(data.toString().trim());
98
- });
99
- });
100
- this.on("system.login.qrcode", async (e: any) => {
101
- this.pluginLogger.info(`[${this.$config.name}] 触发扫码登录,请使用手机 QQ 扫描二维码`);
102
- this.pluginLogger.info(`[${this.$config.name}] 扫码完成后按回车继续`);
103
- process.stdin.once("data", () => {
104
- this.pluginLogger.info(`[${this.$config.name}] 扫码已确认,正在登录...`);
105
- this.login();
106
- });
107
- });
108
- this.on("system.login.slider", async (e: { url: string }) => {
109
- this.pluginLogger.info(`[${this.$config.name}] 触发滑块验证`);
110
- this.pluginLogger.info(`[${this.$config.name}] 验证地址: ${e.url}`);
111
- this.pluginLogger.info(`[${this.$config.name}] 请在浏览器中完成滑块验证后,在终端输入 ticket:`);
112
- process.stdin.once("data", (data: Buffer) => {
113
- this.pluginLogger.info(`[${this.$config.name}] 正在提交滑块 ticket...`);
114
- this.submitSlider(data.toString().trim());
115
- });
116
- });
101
+ /** 刷新好友/群列表缓存 */
102
+ async refreshLists(): Promise<void> {
103
+ const [flResp, glResp] = await Promise.all([
104
+ this.ipc.request(Actions.LIST_FRIENDS),
105
+ this.ipc.request(Actions.LIST_GROUPS),
106
+ ]);
107
+
108
+ this.friends.clear();
109
+ if (flResp.ok && Array.isArray(flResp.data)) {
110
+ for (const f of flResp.data as IpcFriendInfo[]) {
111
+ this.friends.set(f.user_id, f);
112
+ }
113
+ }
117
114
 
118
- const LOGIN_TIMEOUT = 120_000; // 2 分钟登录超时
119
- const loginMode = this.$config.password ? '密码' : '扫码';
120
- this.pluginLogger.info(`[${this.$config.name}] 正在尝试${loginMode}登录...`);
121
-
122
- return new Promise<void>((resolve, reject) => {
123
- const timer = setTimeout(() => {
124
- cleanup();
125
- const msg = `[${this.$config.name}] 登录超时(${LOGIN_TIMEOUT / 1000}s),请检查网络或验证流程是否完成`;
126
- this.pluginLogger.error(msg);
127
- reject(new Error(msg));
128
- }, LOGIN_TIMEOUT);
129
-
130
- const cleanup = () => {
131
- clearTimeout(timer);
132
- // once 注册的监听器在触发后会自动移除,这里只清理未触发的
133
- this.off("system.online" as any);
134
- this.off("system.login.error" as any);
135
- this.off("system.offline" as any);
136
- };
137
-
138
- const onOnline = () => {
139
- cleanup();
140
- this.$connected = true;
141
- this.pluginLogger.info(`[${this.$config.name}] 登录成功,已上线`);
142
- resolve();
143
- };
144
-
145
- const onError = (e: { code: number; message: string }) => {
146
- cleanup();
147
- const msg = `[${this.$config.name}] 登录失败 (code=${e.code}): ${e.message}`;
148
- this.pluginLogger.error(msg);
149
- reject(new Error(msg));
150
- };
151
-
152
- const onOffline = (e?: { message: string }) => {
153
- cleanup();
154
- const msg = `[${this.$config.name}] 登录过程中掉线: ${e?.message ?? '未知原因'}`;
155
- this.pluginLogger.error(msg);
156
- reject(new Error(msg));
157
- };
158
-
159
- this.once("system.online", onOnline);
160
- this.once("system.login.error", onError as any);
161
- this.once("system.offline", onOffline);
162
-
163
- this.login(Number(this.$config.name), this.$config.password);
164
- });
115
+ this.groups.clear();
116
+ if (glResp.ok && Array.isArray(glResp.data)) {
117
+ for (const g of glResp.data as IpcGroupInfo[]) {
118
+ this.groups.set(g.group_id, g);
119
+ }
120
+ }
165
121
  }
166
122
 
123
+ // ── 断开 ───────────────────────────────────────────────────────────
124
+
167
125
  async $disconnect(): Promise<void> {
168
- // Client 继承自 EventEmitter,清除 $connect() 注册的所有监听器
169
- (this as unknown as import('node:events').EventEmitter).removeAllListeners();
170
- await this.logout();
126
+ for (const sub of this.subscriptions) {
127
+ await sub.unsubscribe().catch(() => {});
128
+ }
129
+ this.subscriptions = [];
130
+ this.ipc?.close();
171
131
  this.$connected = false;
132
+ this.logger.info(`[${this.$id}] 已断开连接`);
133
+ }
134
+
135
+ // ── 消息处理 ───────────────────────────────────────────────────────
136
+
137
+ private handleEvent(event: IpcEvent): void {
138
+ const data = event.data as IpcMessageEventData;
139
+ if (!data || !data.raw_message) return;
140
+
141
+ const message = this.$formatMessage(data);
142
+ this.adapter.emit("message.receive", message);
143
+ this.logger.debug(
144
+ `${this.$id} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`,
145
+ );
172
146
  }
173
147
 
174
- $formatMessage(msg: PrivateMessageEvent | GroupMessageEvent) {
175
- const senderInfo = this.getSenderInfo(msg);
176
- const result = Message.from(msg, {
177
- $id: msg.message_id.toString(),
148
+ $formatMessage(raw: IpcMessageEventData) {
149
+ const channelId =
150
+ raw.type === "group"
151
+ ? String(raw.group_id ?? raw.from_id)
152
+ : String(raw.from_id);
153
+ const channelType = raw.type === "group" ? "group" : "private";
154
+ // 守护进程推送无 message_id,合成一个
155
+ const syntheticId = `${raw.time}_${raw.user_id}_${channelId}`;
156
+
157
+ const senderInfo: IcqqSenderInfo = {
158
+ id: String(raw.user_id),
159
+ name: raw.nickname,
160
+ };
161
+
162
+ const result = Message.from(raw, {
163
+ $id: syntheticId,
178
164
  $adapter: "icqq" as const,
179
- $bot: `${this.$config.name}`,
165
+ $bot: this.$config.name,
180
166
  $sender: senderInfo,
181
- $channel: {
182
- id:
183
- msg.message_type === "group"
184
- ? msg.group_id.toString()
185
- : msg.from_id.toString(),
186
- type: msg.message_type,
187
- },
188
- $content: IcqqBot.toSegments(msg.message),
189
- $raw: msg.raw_message,
190
- $timestamp: msg.time*1000,
167
+ $channel: { id: channelId, type: channelType },
168
+ $content: IcqqBot.parseCqMessage(raw.raw_message),
169
+ $raw: raw.raw_message,
170
+ $timestamp: raw.time * 1000,
191
171
  $recall: async () => {
192
- await this.$recallMessage(result.$id);
172
+ // 合成 id 无法撤回
173
+ this.logger.warn(
174
+ `[${this.$id}] 收到的消息无法撤回(守护进程推送不含 message_id)`,
175
+ );
193
176
  },
194
177
  $reply: async (
195
178
  content: SendContent,
196
179
  quote?: boolean | string,
197
180
  ): Promise<string> => {
198
181
  if (!Array.isArray(content)) content = [content];
199
- if (quote)
182
+ if (quote) {
200
183
  content.unshift({
201
184
  type: "reply",
202
185
  data: { id: typeof quote === "boolean" ? result.$id : quote },
203
186
  });
187
+ }
204
188
  return await this.adapter.sendMessage({
205
189
  ...result.$channel,
206
190
  context: "icqq",
207
- bot: `${this.uin}`,
191
+ bot: this.$config.name,
208
192
  content,
209
193
  });
210
194
  },
@@ -212,312 +196,157 @@ export class IcqqBot
212
196
  return result;
213
197
  }
214
198
 
215
- private getSenderInfo(
216
- msg: PrivateMessageEvent | GroupMessageEvent,
217
- ): IcqqSenderInfo {
218
- const senderInfo: IcqqSenderInfo = {
219
- id: msg.sender.user_id.toString(),
220
- name: msg.sender.nickname.toString(),
221
- };
222
- if (msg.message_type === "group") {
223
- const groupMsg = msg as GroupMessageEvent;
224
- const sender = groupMsg.sender as any;
225
- if (sender.role) {
226
- senderInfo.role = sender.role;
227
- senderInfo.isOwner = sender.role === "owner";
228
- senderInfo.isAdmin = sender.role === "admin" || sender.role === "owner";
229
- const perms: string[] = [];
230
- if (sender.role === "owner") perms.push("owner", "admin");
231
- else if (sender.role === "admin") perms.push("admin");
232
- senderInfo.permissions = perms;
233
- }
234
- if (sender.card) senderInfo.card = sender.card;
235
- if (sender.title) senderInfo.title = sender.title;
236
- }
237
- return senderInfo;
238
- }
239
-
240
- private handleGroupNotice(event: any, type: string, subType: string): void {
241
- const notice = Notice.from(event, {
242
- $id: `${event.time || Date.now()}_${type}_${event.group_id}`,
243
- $adapter: 'icqq',
244
- $bot: this.$config.name,
245
- $type: type,
246
- $subType: subType,
247
- $channel: { id: event.group_id?.toString() || '', type: 'group' },
248
- $operator: event.operator_id ? { id: event.operator_id.toString(), name: event.operator_id.toString() } : undefined,
249
- $target: event.user_id ? { id: event.user_id.toString(), name: event.user_id.toString() } : (event.target_id ? { id: event.target_id.toString(), name: event.target_id.toString() } : undefined),
250
- $timestamp: event.time*1000 || Date.now(),
251
- });
252
- this.adapter.emit('notice.receive', notice);
253
- }
199
+ // ── 撤回 ───────────────────────────────────────────────────────────
254
200
 
255
- private handleFriendNotice(event: any, type: string, subType: string): void {
256
- const notice = Notice.from(event, {
257
- $id: `${event.time || Date.now()}_${type}_${event.user_id}`,
258
- $adapter: 'icqq',
259
- $bot: this.$config.name,
260
- $type: type,
261
- $subType: subType,
262
- $channel: { id: event.user_id?.toString() || '', type: 'private' },
263
- $operator: event.operator_id ? { id: event.operator_id.toString(), name: event.operator_id.toString() } : undefined,
264
- $target: event.user_id ? { id: event.user_id.toString(), name: event.user_id.toString() } : undefined,
265
- $timestamp: event.time*1000 || Date.now(),
266
- });
267
- this.adapter.emit('notice.receive', notice);
268
- }
269
-
270
- private handleFriendRequest(event: FriendRequestEvent): void {
271
- const request = Request.from(event, {
272
- $id: event.flag || `${event.time}_friend_add_${event.user_id}`,
273
- $adapter: 'icqq',
274
- $bot: this.$config.name,
275
- $type: 'friend_add',
276
- $subType: event.sub_type,
277
- $channel: { id: event.user_id.toString(), type: 'private' },
278
- $sender: { id: event.user_id.toString(), name: event.nickname || event.user_id.toString() },
279
- $comment: event.comment,
280
- $timestamp: event.time*1000 || Date.now(),
281
- $approve: async () => { await event.approve(true); },
282
- $reject: async () => { await event.approve(false); },
283
- });
284
- this.adapter.emit('request.receive', request);
285
- }
286
-
287
- private handleGroupRequest(event: GroupRequestEvent | GroupInviteEvent, type: string): void {
288
- const request = Request.from(event, {
289
- $id: event.flag || `${event.time}_${type}_${event.user_id}`,
290
- $adapter: 'icqq',
291
- $bot: this.$config.name,
292
- $type: type,
293
- $subType: event.sub_type,
294
- $channel: { id: event.group_id.toString(), type: 'group' },
295
- $sender: { id: event.user_id.toString(), name: event.nickname || event.user_id.toString() },
296
- $comment: 'comment' in event ? event.comment : undefined,
297
- $timestamp: event.time*1000 || Date.now(),
298
- $approve: async () => { await event.approve(true); },
299
- $reject: async () => { await event.approve(false); },
201
+ async $recallMessage(id: string): Promise<void> {
202
+ const resp = await this.ipc.request(Actions.RECALL_MSG, {
203
+ message_id: id,
300
204
  });
301
- this.adapter.emit('request.receive', request);
302
- }
303
-
304
- async kickMember(groupId: number, userId: number, block?: boolean): Promise<boolean> {
305
- try {
306
- const group = this.pickGroup(groupId);
307
- const result = await group.kickMember(userId, undefined, block);
308
- this.pluginLogger.info(
309
- `ICQQ Bot ${this.$id} 踢出成员 ${userId} 从群 ${groupId}${block ? "(已拉黑)" : ""}`,
310
- );
311
- return result;
312
- } catch (error) {
313
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 踢出成员失败:`, error);
314
- throw error;
205
+ if (!resp.ok) {
206
+ this.logger.warn(`[${this.$id}] 撤回消息失败: ${resp.error}`);
315
207
  }
316
208
  }
317
209
 
318
- async muteMember(groupId: number, userId: number, duration: number = 600): Promise<boolean> {
319
- try {
320
- const group = this.pickGroup(groupId);
321
- const result = await group.muteMember(userId, duration);
322
- this.pluginLogger.info(
323
- `ICQQ Bot ${this.$id} ${duration > 0 ? `禁言成员 ${userId} ${duration}秒` : `解除成员 ${userId} 禁言`}(群 ${groupId})`,
324
- );
325
- return result;
326
- } catch (error) {
327
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 禁言操作失败:`, error);
328
- throw error;
329
- }
330
- }
210
+ // ── 发送消息 ───────────────────────────────────────────────────────
331
211
 
332
- async muteAll(groupId: number, enable: boolean = true): Promise<boolean> {
333
- try {
334
- const group = this.pickGroup(groupId);
335
- const result = await group.muteAll(enable);
336
- this.pluginLogger.info(`ICQQ Bot ${this.$id} ${enable ? "开启" : "关闭"}全员禁言(群 ${groupId})`);
337
- return result;
338
- } catch (error) {
339
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 全员禁言操作失败:`, error);
340
- throw error;
341
- }
342
- }
343
-
344
- async setAdmin(groupId: number, userId: number, enable: boolean = true): Promise<boolean> {
345
- try {
346
- const group = this.pickGroup(groupId);
347
- const result = await group.setAdmin(userId, enable);
348
- this.pluginLogger.info(`ICQQ Bot ${this.$id} ${enable ? "设置" : "取消"}管理员 ${userId}(群 ${groupId})`);
349
- return result;
350
- } catch (error) {
351
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 设置管理员失败:`, error);
352
- throw error;
353
- }
354
- }
355
-
356
- async setCard(groupId: number, userId: number, card: string): Promise<boolean> {
357
- try {
358
- const group = this.pickGroup(groupId);
359
- const result = await group.setCard(userId, card);
360
- this.pluginLogger.info(`ICQQ Bot ${this.$id} 设置成员 ${userId} 群名片为 "${card}"(群 ${groupId})`);
361
- return result;
362
- } catch (error) {
363
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 设置群名片失败:`, error);
364
- throw error;
365
- }
366
- }
212
+ async $sendMessage(options: SendOptions): Promise<string> {
213
+ const content = IcqqBot.toCqString(options.content);
367
214
 
368
- async setTitle(groupId: number, userId: number, title: string, duration: number = -1): Promise<boolean> {
369
- try {
370
- const group = this.pickGroup(groupId);
371
- const result = await group.setTitle(userId, title, duration);
372
- this.pluginLogger.info(`ICQQ Bot ${this.$id} 设置成员 ${userId} 头衔为 "${title}"(群 ${groupId})`);
373
- return result;
374
- } catch (error) {
375
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 设置头衔失败:`, error);
376
- throw error;
377
- }
378
- }
215
+ let action: string;
216
+ let params: Record<string, unknown>;
379
217
 
380
- async setGroupName(groupId: number, name: string): Promise<boolean> {
381
- try {
382
- const group = this.pickGroup(groupId);
383
- const result = await group.setName(name);
384
- this.pluginLogger.info(`ICQQ Bot ${this.$id} 设置群名为 "${name}"(群 ${groupId})`);
385
- return result;
386
- } catch (error) {
387
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 设置群名失败:`, error);
388
- throw error;
218
+ switch (options.type) {
219
+ case "private":
220
+ action = Actions.SEND_PRIVATE_MSG;
221
+ params = { user_id: Number(options.id), message: content };
222
+ break;
223
+ case "group":
224
+ action = Actions.SEND_GROUP_MSG;
225
+ params = { group_id: Number(options.id), message: content };
226
+ break;
227
+ default:
228
+ throw new Error(`不支持的频道类型: ${options.type}`);
389
229
  }
390
- }
391
230
 
392
- async sendAnnounce(groupId: number, content: string): Promise<boolean> {
393
- try {
394
- const group = this.pickGroup(groupId);
395
- const result = await group.announce(content);
396
- this.pluginLogger.info(`ICQQ Bot ${this.$id} 发送群公告(群 ${groupId})`);
397
- return result;
398
- } catch (error) {
399
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 发送群公告失败:`, error);
400
- throw error;
231
+ const resp = await this.ipc.request(action, params);
232
+ if (!resp.ok) {
233
+ throw new Error(`发送消息失败: ${resp.error}`);
401
234
  }
402
- }
403
235
 
404
- async pokeMember(groupId: number, userId: number): Promise<boolean> {
405
- try {
406
- const group = this.pickGroup(groupId);
407
- const result = await group.pokeMember(userId);
408
- this.pluginLogger.info(`ICQQ Bot ${this.$id} 戳了戳 ${userId}(群 ${groupId})`);
409
- return result;
410
- } catch (error) {
411
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 戳一戳失败:`, error);
412
- throw error;
413
- }
236
+ const messageId = String(
237
+ (resp.data as any)?.message_id ?? `sent_${Date.now()}`,
238
+ );
239
+ this.logger.debug(
240
+ `${this.$id} send ${options.type}(${options.id}):${segment.raw(options.content)}`,
241
+ );
242
+ return messageId;
414
243
  }
244
+ }
415
245
 
416
- async getMemberList(groupId: number): Promise<Map<number, MemberInfo>> {
417
- try {
418
- const group = this.pickGroup(groupId);
419
- return await group.getMemberMap();
420
- } catch (error) {
421
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 获取群成员列表失败:`, error);
422
- throw error;
423
- }
424
- }
246
+ // ── CQ 码解析工具 ──────────────────────────────────────────────────
425
247
 
426
- async getMutedMembers(groupId: number): Promise<any[]> {
427
- try {
428
- const group = this.pickGroup(groupId);
429
- return await group.getMuteMemberList();
430
- } catch (error) {
431
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 获取禁言列表失败:`, error);
432
- throw error;
433
- }
434
- }
248
+ export namespace IcqqBot {
249
+ /**
250
+ * CQ 码原始消息字符串解析为 MessageSegment 数组。
251
+ * 格式: `[type:value]` 或纯文本
252
+ */
253
+ export function parseCqMessage(raw: string): MessageSegment[] {
254
+ const segments: MessageSegment[] = [];
255
+ // 匹配 [type:arg] 或 [type:arg1,arg2=val] 等 CQ 码
256
+ const cqRegex = /\[([a-z_]+)(?::([^\]]*))?\]/g;
257
+ let lastIndex = 0;
258
+
259
+ for (const match of raw.matchAll(cqRegex)) {
260
+ // 前面的纯文本
261
+ if (match.index! > lastIndex) {
262
+ const text = raw.slice(lastIndex, match.index!);
263
+ if (text) segments.push({ type: "text", data: { text } });
264
+ }
435
265
 
436
- async setAnonymous(groupId: number, enable: boolean = true): Promise<boolean> {
437
- try {
438
- const group = this.pickGroup(groupId);
439
- const result = await group.allowAnony(enable);
440
- this.pluginLogger.info(`ICQQ Bot ${this.$id} ${enable ? "开启" : "关闭"}匿名(群 ${groupId})`);
441
- return result;
442
- } catch (error) {
443
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 设置匿名失败:`, error);
444
- throw error;
445
- }
446
- }
266
+ const type = match[1];
267
+ const arg = match[2] ?? "";
268
+
269
+ switch (type) {
270
+ case "face":
271
+ segments.push({ type: "face", data: { id: Number(arg) } });
272
+ break;
273
+ case "image":
274
+ segments.push({ type: "image", data: { url: arg, file: arg } });
275
+ break;
276
+ case "at":
277
+ if (arg === "all") {
278
+ segments.push({ type: "at", data: { qq: "all" } });
279
+ } else {
280
+ segments.push({ type: "at", data: { qq: arg } });
281
+ }
282
+ break;
283
+ case "dice":
284
+ segments.push({ type: "dice", data: {} });
285
+ break;
286
+ case "rps":
287
+ segments.push({ type: "rps", data: {} });
288
+ break;
289
+ case "record":
290
+ case "audio":
291
+ segments.push({ type: "record", data: { file: arg } });
292
+ break;
293
+ case "video":
294
+ segments.push({ type: "video", data: { file: arg } });
295
+ break;
296
+ case "reply":
297
+ segments.push({ type: "reply", data: { id: arg } });
298
+ break;
299
+ default:
300
+ segments.push({ type, data: { text: `[${type}:${arg}]` } });
301
+ break;
302
+ }
447
303
 
448
- async getGroupFiles(groupId: number): Promise<any> {
449
- try {
450
- const group = this.pickGroup(groupId);
451
- return await group.fs.ls();
452
- } catch (error) {
453
- this.pluginLogger.error(`ICQQ Bot ${this.$id} 获取群文件列表失败:`, error);
454
- throw error;
304
+ lastIndex = match.index! + match[0].length;
455
305
  }
456
- }
457
-
458
- async $recallMessage(id: string): Promise<void> {
459
- await this.deleteMsg(id);
460
- }
461
306
 
462
- async $sendMessage(options: SendOptions): Promise<string> {
463
- switch (options.type) {
464
- case "private": {
465
- const result = await this.sendPrivateMsg(
466
- Number(options.id),
467
- IcqqBot.toSendable(options.content),
468
- );
469
- this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(options.content)}`);
470
- return result.message_id.toString();
471
- }
472
- case "group": {
473
- const result = await this.sendGroupMsg(
474
- Number(options.id),
475
- IcqqBot.toSendable(options.content),
476
- );
477
- this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(options.content)}`);
478
- return result.message_id.toString();
479
- }
480
- default:
481
- throw new Error(`unsupported channel type ${options.type}`);
307
+ // 尾部文本
308
+ if (lastIndex < raw.length) {
309
+ const text = raw.slice(lastIndex);
310
+ if (text) segments.push({ type: "text", data: { text } });
482
311
  }
483
- }
484
- }
485
312
 
486
- export namespace IcqqBot {
487
- const allowTypes = [
488
- "text", "face", "image", "record", "audio", "dice", "rps", "video",
489
- "file", "location", "share", "json", "at", "reply", "long_msg",
490
- "button", "markdown", "xml",
491
- ];
492
-
493
- export function toSegments(message: Sendable): MessageSegment[] {
494
- if (!Array.isArray(message)) message = [message];
495
- return message
496
- .filter((item, index) => typeof item === "string" || (item as any).type !== "long_msg" || index !== 0)
497
- .map((item): MessageSegment => {
498
- if (typeof item === "string") return { type: "text", data: { text: item } };
499
- const { type, ...data } = item as any;
500
- return { type, data };
501
- });
313
+ return segments.length ? segments : [{ type: "text", data: { text: raw } }];
502
314
  }
503
- }
504
315
 
505
- export namespace IcqqBot {
506
- const allowTypes = [
507
- "text", "face", "image", "record", "audio", "dice", "rps", "video",
508
- "file", "location", "share", "json", "at", "reply", "long_msg",
509
- "button", "markdown", "xml",
510
- ];
511
-
512
- export function toSendable(content: SendContent): Sendable {
316
+ /**
317
+ * SendContent(MessageSegment[] 或字符串)转为 CQ 码字符串。
318
+ * 守护进程使用 CQ 码字符串格式收发消息。
319
+ */
320
+ export function toCqString(content: SendContent): string {
513
321
  if (!Array.isArray(content)) content = [content];
514
- return content.map((seg): MessageElem => {
515
- if (typeof seg === "string") return { type: "text", text: seg };
516
- let { type, data } = seg as any;
517
- if (typeof type === "function") type = type.name;
518
- if(['image','video','audio'].includes(type)) data.file=data.file||data.url||data.src
519
- if (!allowTypes.includes(type)) return { type: "text", text: segment.toString(seg) };
520
- return { type, ...data } as MessageElem;
521
- });
322
+ return content
323
+ .map((seg) => {
324
+ if (typeof seg === "string") return seg;
325
+ const { type, data } = seg as MessageSegment;
326
+ switch (type) {
327
+ case "text":
328
+ return data.text ?? "";
329
+ case "face":
330
+ return `[face:${data.id}]`;
331
+ case "image":
332
+ return `[image:${data.file || data.url || data.src}]`;
333
+ case "at":
334
+ return `[at:${data.qq ?? data.id}]`;
335
+ case "dice":
336
+ return "[dice]";
337
+ case "rps":
338
+ return "[rps]";
339
+ case "record":
340
+ case "audio":
341
+ return `[record:${data.file || data.url}]`;
342
+ case "video":
343
+ return `[video:${data.file || data.url}]`;
344
+ case "reply":
345
+ return `[reply:${data.id}]`;
346
+ default:
347
+ return segment.toString(seg);
348
+ }
349
+ })
350
+ .join("");
522
351
  }
523
352
  }