@zhin.js/adapter-icqq 5.0.2 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +32 -4
  3. package/client/LoginAssistPanel.tsx +38 -3
  4. package/dist/index.js +2 -2
  5. package/lib/adapter.d.ts +12 -4
  6. package/lib/adapter.d.ts.map +1 -1
  7. package/lib/adapter.js +14 -6
  8. package/lib/adapter.js.map +1 -1
  9. package/lib/cq-message.d.ts.map +1 -1
  10. package/lib/cq-message.js +12 -3
  11. package/lib/cq-message.js.map +1 -1
  12. package/lib/endpoint.d.ts +12 -8
  13. package/lib/endpoint.d.ts.map +1 -1
  14. package/lib/endpoint.js +135 -47
  15. package/lib/endpoint.js.map +1 -1
  16. package/lib/icqq-guild.d.ts +48 -0
  17. package/lib/icqq-guild.d.ts.map +1 -0
  18. package/lib/icqq-guild.js +115 -0
  19. package/lib/icqq-guild.js.map +1 -0
  20. package/lib/icqq-inbound.d.ts +7 -0
  21. package/lib/icqq-inbound.d.ts.map +1 -1
  22. package/lib/icqq-inbound.js +39 -6
  23. package/lib/icqq-inbound.js.map +1 -1
  24. package/lib/icqq-login-assist-bridge.d.ts +5 -0
  25. package/lib/icqq-login-assist-bridge.d.ts.map +1 -0
  26. package/lib/icqq-login-assist-bridge.js +148 -0
  27. package/lib/icqq-login-assist-bridge.js.map +1 -0
  28. package/lib/index.d.ts +0 -2
  29. package/lib/index.d.ts.map +1 -1
  30. package/lib/index.js +2 -1
  31. package/lib/index.js.map +1 -1
  32. package/lib/login-assist-routes.d.ts +7 -0
  33. package/lib/login-assist-routes.d.ts.map +1 -0
  34. package/lib/login-assist-routes.js +29 -0
  35. package/lib/login-assist-routes.js.map +1 -0
  36. package/lib/login-ipc-contract.d.ts +44 -0
  37. package/lib/login-ipc-contract.d.ts.map +1 -0
  38. package/lib/login-ipc-contract.js +35 -0
  39. package/lib/login-ipc-contract.js.map +1 -0
  40. package/lib/protocol.d.ts +32 -1
  41. package/lib/protocol.d.ts.map +1 -1
  42. package/lib/protocol.js +23 -0
  43. package/lib/protocol.js.map +1 -1
  44. package/lib/segment-mapper.d.ts +2 -0
  45. package/lib/segment-mapper.d.ts.map +1 -0
  46. package/lib/segment-mapper.js +2 -0
  47. package/lib/segment-mapper.js.map +1 -0
  48. package/lib/tools/index.d.ts +1 -1
  49. package/lib/tools/index.js +8 -8
  50. package/lib/types.d.ts +0 -44
  51. package/lib/types.d.ts.map +1 -1
  52. package/package.json +18 -18
  53. package/skills/icqq/PERMITS.md +6 -6
  54. package/src/adapter.ts +14 -6
  55. package/src/cq-message.ts +11 -3
  56. package/src/endpoint.ts +169 -52
  57. package/src/icqq-guild.ts +157 -0
  58. package/src/icqq-inbound.ts +52 -7
  59. package/src/icqq-login-assist-bridge.ts +196 -0
  60. package/src/index.ts +2 -8
  61. package/src/login-assist-routes.ts +39 -0
  62. package/src/login-ipc-contract.ts +52 -0
  63. package/src/protocol.ts +32 -1
  64. package/src/segment-mapper.ts +1 -0
  65. package/src/tools/index.ts +9 -9
  66. package/src/types.ts +0 -45
  67. package/lib/typing-indicator-example.d.ts +0 -108
  68. package/lib/typing-indicator-example.d.ts.map +0 -1
  69. package/lib/typing-indicator-example.js +0 -220
  70. package/lib/typing-indicator-example.js.map +0 -1
  71. package/lib/typing-indicator.d.ts +0 -87
  72. package/lib/typing-indicator.d.ts.map +0 -1
  73. package/lib/typing-indicator.js +0 -225
  74. package/lib/typing-indicator.js.map +0 -1
  75. package/src/typing-indicator-example.ts +0 -269
  76. package/src/typing-indicator.ts +0 -313
package/src/endpoint.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * 不再直接依赖 @icqqjs/icqq 协议库。
5
5
  * 登录由 `icqq login` 完成,本 Endpoint 只负责连接守护进程并收发消息。
6
6
  */
7
- import { formatCompact, Endpoint, Message, segment, SendContent, SendOptions, type QuotedMessagePayload } from 'zhin.js';
7
+ import { formatCompact, Endpoint, Message, segment, SendContent, SendOptions, expandInteractiveSegmentsInContent, type QuotedMessagePayload,} from 'zhin.js';
8
8
  import type {
9
9
  IcqqEndpointConfig,
10
10
  IcqqSenderInfo,
@@ -49,9 +49,19 @@ import {
49
49
  shouldRefreshListsOnMeta,
50
50
  type IcqqIpcRawEvent,
51
51
  } from "./icqq-side-events.js";
52
- import { enableTypingIndicator, type ICQQTypingIndicatorManager } from "./typing-indicator.js";
52
+ import {
53
+ disposeIcqqLoginAssistBridge,
54
+ handleIcqqLoginIpcEvent,
55
+ } from "./icqq-login-assist-bridge.js";
53
56
  import { parseIcqqGetMsgResponse } from "./get-msg.js";
54
57
  import { enrichQuotedPayloadWithForward, isForwardPlaceholderPayload } from "./forward-msg.js";
58
+ import {
59
+ IcqqGuildCatalog,
60
+ isIcqqGuildIpcEvent,
61
+ normalizeIcqqGuildInboundMessage,
62
+ type NormalizedIcqqGuildInbound,
63
+ } from "./icqq-guild.js";
64
+ import type { IpcGuildMessageEventData } from "./protocol.js";
55
65
 
56
66
  export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessageEvent> {
57
67
  $connected = false;
@@ -62,6 +72,8 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
62
72
  friends = new Map<number, IpcFriendInfo>();
63
73
  /** 缓存的群列表 */
64
74
  groups = new Map<number, IpcGroupInfo>();
75
+ /** QQ 频道(guild)子频道缓存 */
76
+ readonly guildCatalog = new IcqqGuildCatalog();
65
77
 
66
78
  private subscriptions: Array<{ unsubscribe: () => Promise<void> }> = [];
67
79
  /** 事件去重:覆盖多端回流/服务端重复推送等场景 */
@@ -73,9 +85,6 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
73
85
  /** 是否已有重连循环在跑(避免多次 schedule 叠套) */
74
86
  private reconnectRunning = false;
75
87
 
76
- /** Typing Indicator 管理器 */
77
- $typingIndicator?: ICQQTypingIndicatorManager;
78
-
79
88
  get $id() {
80
89
  return this.$config.name;
81
90
  }
@@ -106,50 +115,13 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
106
115
 
107
116
  await this.rebindIpcSession();
108
117
 
109
- // 根据配置自动启用 Typing Indicator
110
- this.initTypingIndicator();
111
-
112
118
  this.logger.info(formatCompact( {
113
119
  机器人: this.$id,
114
120
  好友数: this.friends.size,
115
121
  群组数: this.groups.size,
116
- 思考提示: this.$typingIndicator ? '已启用' : '未启用',
117
122
  }));
118
123
  }
119
124
 
120
- /**
121
- * 初始化 Typing Indicator
122
- * 根据配置自动启用或禁用
123
- */
124
- private initTypingIndicator(): void {
125
- const typingConfig = this.$config.typingIndicator;
126
-
127
- // 如果配置中明确设置为 false,则不启用
128
- if (typingConfig?.enabled === false) {
129
- this.logger.debug(formatCompact({
130
- endpoint: this.$id,
131
- typingIndicator: 'disabled_by_config',
132
- }));
133
- return;
134
- }
135
-
136
- // 启用 Typing Indicator
137
- try {
138
- this.$typingIndicator = enableTypingIndicator(this, typingConfig);
139
- this.logger.debug(formatCompact({
140
- endpoint: this.$id,
141
- typingIndicator: 'enabled',
142
- emoji: typingConfig?.defaultEmoji || '⏳',
143
- }));
144
- } catch (error) {
145
- this.logger.warn(formatCompact({
146
- endpoint: this.$id,
147
- typingIndicator: 'failed',
148
- error: error instanceof Error ? error.message : String(error),
149
- }));
150
- }
151
- }
152
-
153
125
  /** 建立或恢复与守护进程的 IPC/RPC 会话(订阅、缓存列表) */
154
126
  private async rebindIpcSession(): Promise<void> {
155
127
  const uin = Number(this.$config.name);
@@ -261,6 +233,27 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
261
233
  this.groups.set(g.group_id, g);
262
234
  }
263
235
  }
236
+
237
+ await this.guildCatalog.syncAll(this.ipc).catch((e: unknown) => {
238
+ this.logger.warn(formatCompact({
239
+ op: "sync_guilds",
240
+ endpoint: this.$id,
241
+ ok: false,
242
+ error: e instanceof Error ? e.message : String(e),
243
+ }));
244
+ });
245
+ }
246
+
247
+ /** Console:列出 guild 子频道(含 parent.guild) */
248
+ getGuildChannelList(): ReturnType<IcqqGuildCatalog["getGuildChannelList"]> {
249
+ return this.guildCatalog.getGuildChannelList();
250
+ }
251
+
252
+ resolveConsoleChannelNames(
253
+ channelId: string,
254
+ guildId?: string,
255
+ ): { channelName?: string; parentName?: string } {
256
+ return this.guildCatalog.resolveConsoleChannelNames(channelId, guildId);
264
257
  }
265
258
 
266
259
  // ── 断开 ───────────────────────────────────────────────────────────
@@ -268,18 +261,13 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
268
261
  async $disconnect(): Promise<void> {
269
262
  this.intentionalDisconnect = true;
270
263
 
271
- // 停止所有 Typing Indicator
272
- if (this.$typingIndicator) {
273
- await this.$typingIndicator.stopAll().catch(() => {});
274
- this.$typingIndicator = undefined;
275
- }
276
-
277
264
  this.ipc?.setOnRemoteDisconnect(null);
278
265
  for (const sub of this.subscriptions) {
279
266
  await sub.unsubscribe().catch(() => {});
280
267
  }
281
268
  this.subscriptions = [];
282
269
  this.inboundDeduper.clear();
270
+ disposeIcqqLoginAssistBridge(this.$id);
283
271
  this.ipc?.close();
284
272
  this.$connected = false;
285
273
  this.logger.info(formatCompact( { op: "disconnect", endpoint: this.$id }));
@@ -288,6 +276,28 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
288
276
  // ── 消息处理 ───────────────────────────────────────────────────────
289
277
 
290
278
  private handleEvent(event: IpcEvent): void {
279
+ if (event.event?.startsWith("system.login.")) {
280
+ handleIcqqLoginIpcEvent(this, event.event, event.data);
281
+ return;
282
+ }
283
+
284
+ if (event.event === Actions.RELOAD_GUILDS) {
285
+ void this.guildCatalog.syncAll(this.ipc).catch((e: unknown) => {
286
+ this.logger.warn(formatCompact({
287
+ op: "reload_guilds",
288
+ endpoint: this.$id,
289
+ ok: false,
290
+ error: e instanceof Error ? e.message : String(e),
291
+ }));
292
+ });
293
+ return;
294
+ }
295
+
296
+ if (isIcqqGuildIpcEvent(event.event)) {
297
+ this.handleGuildMessageEvent(event);
298
+ return;
299
+ }
300
+
291
301
  const payload = unwrapIcqqIpcEventPayload(event);
292
302
  if (!payload || typeof payload !== "object") {
293
303
  if (process.env.ICQQ_IPC_LOG_RAW === "1") {
@@ -380,6 +390,80 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
380
390
  );
381
391
  }
382
392
 
393
+ private handleGuildMessageEvent(event: IpcEvent): void {
394
+ const payload = event.data;
395
+ if (!payload || typeof payload !== "object") return;
396
+ const data = payload as IpcGuildMessageEventData & Record<string, unknown>;
397
+ const normalized = normalizeIcqqGuildInboundMessage(data);
398
+ if (!normalized) return;
399
+
400
+ this.guildCatalog.upsertFromInbound(data);
401
+
402
+ if (!this.inboundDeduper.shouldProcess(`guild:${normalized.messageId}`)) {
403
+ return;
404
+ }
405
+
406
+ void this.dispatchGuildInboundMessage(normalized);
407
+ }
408
+
409
+ private async dispatchGuildInboundMessage(
410
+ normalized: NormalizedIcqqGuildInbound,
411
+ ): Promise<void> {
412
+ const message = this.$formatGuildMessage(normalized);
413
+ this.adapter.emit("message.receive", message);
414
+ this.logger.debug(
415
+ `${this.$id} recv channel(${message.$channel.id}):${segment.raw(message.$content)}`,
416
+ );
417
+ }
418
+
419
+ private $formatGuildMessage(
420
+ normalized: NormalizedIcqqGuildInbound,
421
+ ): ReturnType<typeof Message.from<IpcGuildMessageEventData>> {
422
+ const senderInfo: IcqqSenderInfo = {
423
+ id: normalized.userId,
424
+ name: normalized.nickname,
425
+ };
426
+
427
+ const result = Message.from(normalized.raw, {
428
+ $id: normalized.messageId,
429
+ $adapter: "icqq" as const,
430
+ $endpoint: this.$config.name,
431
+ $sender: senderInfo,
432
+ $channel: {
433
+ id: normalized.channelId,
434
+ type: "channel",
435
+ parent: { type: "guild", id: normalized.guildId },
436
+ },
437
+ $content: normalized.content,
438
+ $raw: normalized.rawMessage,
439
+ $timestamp: normalized.timestampMs,
440
+ $recall: async () => {
441
+ await this.$recallMessage(result.$id);
442
+ },
443
+ $reply: async (
444
+ content: SendContent,
445
+ quote?: boolean | string,
446
+ ): Promise<string> => {
447
+ if (!Array.isArray(content)) content = [content];
448
+ if (quote) {
449
+ content.unshift({
450
+ type: "reply",
451
+ data: { id: typeof quote === "boolean" ? result.$id : quote },
452
+ });
453
+ }
454
+ return await this.adapter.sendMessage({
455
+ context: "icqq",
456
+ endpoint: this.$config.name,
457
+ id: normalized.channelId,
458
+ type: "channel",
459
+ parent: { type: "guild", id: normalized.guildId },
460
+ content,
461
+ });
462
+ },
463
+ });
464
+ return result;
465
+ }
466
+
383
467
  private handleNoticeEvent(event: IcqqIpcRawEvent): void {
384
468
  const dedupeKey = resolveSideEventDedupeKey(event, "notice");
385
469
  if (!this.inboundDeduper.shouldProcess(dedupeKey)) {
@@ -503,6 +587,9 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
503
587
  $channel: {
504
588
  id: normalized.channelId,
505
589
  type: normalized.channelType,
590
+ ...(normalized.channelParentGroupId
591
+ ? { parent: { type: 'group' as const, id: normalized.channelParentGroupId } }
592
+ : {}),
506
593
  },
507
594
  $content: normalized.content,
508
595
  $quote_id: quoteId,
@@ -641,21 +728,51 @@ export class IcqqEndpoint implements Endpoint<IcqqEndpointConfig, IcqqIpcMessage
641
728
 
642
729
  async $sendMessage(options: SendOptions): Promise<string> {
643
730
  const outboundMedia = resolveIcqqOutboundMediaMode(this.$config);
644
- const content = materializeOutboundBase64(options.content, outboundMedia);
731
+ const expanded = expandInteractiveSegmentsInContent(options.content);
732
+ const qrcodeContent = expanded;
733
+ const content = materializeOutboundBase64(qrcodeContent, outboundMedia);
645
734
  const message = buildIcqqIpcMessageImpl(content);
646
735
 
647
736
  let action: string;
648
737
  let params: Record<string, unknown>;
649
738
 
650
739
  switch (options.type) {
651
- case "private":
652
- action = Actions.SEND_PRIVATE_MSG;
653
- params = { user_id: Number(options.id), message };
740
+ case "private": {
741
+ const groupParent =
742
+ options.parent?.type === "group" ? options.parent.id : undefined;
743
+ if (groupParent) {
744
+ action = Actions.SEND_TEMP_MSG;
745
+ params = {
746
+ group_id: Number(groupParent),
747
+ user_id: Number(options.id),
748
+ message,
749
+ };
750
+ } else {
751
+ action = Actions.SEND_PRIVATE_MSG;
752
+ params = { user_id: Number(options.id), message };
753
+ }
654
754
  break;
755
+ }
655
756
  case "group":
656
757
  action = Actions.SEND_GROUP_MSG;
657
758
  params = { group_id: Number(options.id), message };
658
759
  break;
760
+ case "channel": {
761
+ const guildId =
762
+ options.parent?.type === "guild" ? options.parent.id : undefined;
763
+ if (!guildId) {
764
+ throw new Error(
765
+ 'channel 发送需要 parent: { type: "guild", id: guild_id }',
766
+ );
767
+ }
768
+ action = Actions.GUILD_SEND_MSG;
769
+ params = {
770
+ guild_id: guildId,
771
+ channel_id: options.id,
772
+ message,
773
+ };
774
+ break;
775
+ }
659
776
  default:
660
777
  throw new Error(`不支持的频道类型: ${options.type}`);
661
778
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * ICQQ guild (QQ 频道) catalog sync, inbound normalize, Console channel list.
3
+ */
4
+ import type { MessageSegment } from "zhin.js";
5
+ import { Actions, type IpcGuildMessageEventData } from "./protocol.js";
6
+ import type { IpcClient } from "./ipc-client.js";
7
+ import { parseCqMessage } from "./cq-message.js";
8
+ import { toCanonicalSegments } from "./segment-mapper.js";
9
+
10
+ export interface IcqqGuildChannelEntry {
11
+ channel_id: string;
12
+ channel_name: string;
13
+ guild_id: string;
14
+ guild_name: string;
15
+ }
16
+
17
+ export interface NormalizedIcqqGuildInbound {
18
+ messageId: string;
19
+ channelId: string;
20
+ guildId: string;
21
+ guildName: string;
22
+ channelName: string;
23
+ userId: string;
24
+ nickname: string;
25
+ content: MessageSegment[];
26
+ rawMessage: string;
27
+ timestampMs: number;
28
+ raw: IpcGuildMessageEventData;
29
+ }
30
+
31
+ export class IcqqGuildCatalog {
32
+ private readonly channels = new Map<string, IcqqGuildChannelEntry>();
33
+ private readonly guilds = new Map<string, { guild_id: string; guild_name: string }>();
34
+
35
+ clear(): void {
36
+ this.channels.clear();
37
+ this.guilds.clear();
38
+ }
39
+
40
+ async syncAll(ipc: IpcClient): Promise<void> {
41
+ const listResp = await ipc.request(Actions.GUILD_LIST);
42
+ if (!listResp.ok) return;
43
+
44
+ const guildRows = Array.isArray(listResp.data) ? listResp.data : [];
45
+ for (const row of guildRows) {
46
+ const rec = row as Record<string, unknown>;
47
+ const guild_id = String(rec.guild_id ?? rec.id ?? "");
48
+ if (!guild_id) continue;
49
+ const guild_name = String(rec.guild_name ?? rec.name ?? guild_id);
50
+ this.guilds.set(guild_id, { guild_id, guild_name });
51
+
52
+ const chResp = await ipc.request(Actions.GUILD_CHANNELS, { guild_id });
53
+ if (!chResp.ok || !Array.isArray(chResp.data)) continue;
54
+ for (const ch of chResp.data) {
55
+ const chRec = ch as Record<string, unknown>;
56
+ const channel_id = String(chRec.channel_id ?? chRec.id ?? "");
57
+ if (!channel_id) continue;
58
+ const channel_name = String(chRec.channel_name ?? chRec.name ?? channel_id);
59
+ this.channels.set(channel_id, {
60
+ channel_id,
61
+ channel_name,
62
+ guild_id,
63
+ guild_name,
64
+ });
65
+ }
66
+ }
67
+ }
68
+
69
+ upsertFromInbound(data: IpcGuildMessageEventData): void {
70
+ const guild_id = String(data.guild_id ?? "");
71
+ const channel_id = String(data.channel_id ?? "");
72
+ if (!guild_id || !channel_id) return;
73
+ const guild_name = String(data.guild_name ?? guild_id);
74
+ const channel_name = String(data.channel_name ?? channel_id);
75
+ this.guilds.set(guild_id, { guild_id, guild_name });
76
+ this.channels.set(channel_id, {
77
+ channel_id,
78
+ channel_name,
79
+ guild_id,
80
+ guild_name,
81
+ });
82
+ }
83
+
84
+ getGuildChannelList(): Array<{
85
+ id: string;
86
+ name: string;
87
+ parent: { type: "guild"; id: string; name: string };
88
+ }> {
89
+ return Array.from(this.channels.values()).map((entry) => ({
90
+ id: entry.channel_id,
91
+ name: entry.channel_name,
92
+ parent: {
93
+ type: "guild" as const,
94
+ id: entry.guild_id,
95
+ name: entry.guild_name,
96
+ },
97
+ }));
98
+ }
99
+
100
+ resolveConsoleChannelNames(
101
+ channelId: string,
102
+ guildId?: string,
103
+ ): { channelName?: string; parentName?: string } {
104
+ const entry = this.channels.get(channelId);
105
+ if (entry) {
106
+ return { channelName: entry.channel_name, parentName: entry.guild_name };
107
+ }
108
+ if (guildId) {
109
+ const guild = this.guilds.get(guildId);
110
+ return guild ? { parentName: guild.guild_name } : {};
111
+ }
112
+ return {};
113
+ }
114
+ }
115
+
116
+ export function isIcqqGuildIpcEvent(eventName: string | undefined): boolean {
117
+ return typeof eventName === "string" && eventName.startsWith("message.guild");
118
+ }
119
+
120
+ export function normalizeIcqqGuildInboundMessage(
121
+ data: IpcGuildMessageEventData & Record<string, unknown>,
122
+ ): NormalizedIcqqGuildInbound | null {
123
+ if (data.type !== "guild") return null;
124
+ const guildId = String(data.guild_id ?? "");
125
+ const channelId = String(data.channel_id ?? "");
126
+ if (!guildId || !channelId) return null;
127
+
128
+ const userId = String(data.tiny_id ?? data.user_id ?? "");
129
+ const nickname = String(data.nickname ?? userId);
130
+ const rawMessage = String(data.raw_message ?? "");
131
+ const timestampMs =
132
+ typeof data.time === "number" ? data.time * 1000 : Date.now();
133
+ const messageId =
134
+ data.message_id != null && String(data.message_id) !== ""
135
+ ? String(data.message_id)
136
+ : data.seq != null
137
+ ? String(data.seq)
138
+ : `${data.time ?? 0}_${userId}_${channelId}`;
139
+
140
+ const parsed = parseCqMessage(rawMessage);
141
+ const canonical = toCanonicalSegments(parsed);
142
+ const content: MessageSegment[] = canonical.length > 0 ? canonical : [{ type: "text", data: { text: rawMessage } }];
143
+
144
+ return {
145
+ messageId,
146
+ channelId,
147
+ guildId,
148
+ guildName: String(data.guild_name ?? guildId),
149
+ channelName: String(data.channel_name ?? channelId),
150
+ userId,
151
+ nickname,
152
+ content,
153
+ rawMessage,
154
+ timestampMs,
155
+ raw: data,
156
+ };
157
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { Message, type MessageSegment, type QuotedMessagePayload } from "zhin.js";
5
5
  import { parseCqMessage } from "./cq-message.js";
6
+ import { toCanonicalSegments } from "./segment-mapper.js";
6
7
  import { extractForwardResidFromJsonElement } from "./forward-msg.js";
7
8
  import type { GroupRole } from "./types.js";
8
9
 
@@ -30,6 +31,8 @@ export interface IcqqIpcMessageEvent extends IcqqIpcEventBase {
30
31
  user_uid?: string;
31
32
  nickname?: string;
32
33
  card?: string;
34
+ /** 群临时会话来源群号 */
35
+ group_id?: number;
33
36
  /** NT/IPC 群成员角色:owner | admin | member */
34
37
  role?: GroupRole | string;
35
38
  };
@@ -81,6 +84,8 @@ export interface NormalizedIcqqInbound {
81
84
  idSource: "message_id" | "msg_id" | "seq" | "synthetic";
82
85
  channelType: "group" | "private";
83
86
  channelId: string;
87
+ /** 群临时会话:来源群号 */
88
+ channelParentGroupId?: string;
84
89
  userId: string;
85
90
  nickname: string;
86
91
  /** 群聊时来自 IPC sender.role(私聊无) */
@@ -204,9 +209,38 @@ export function shouldSkipSelfInboundMessage(
204
209
  return Number(data.self_id) === Number(data.user_id);
205
210
  }
206
211
 
212
+ /** 群临时会话私聊:icqq message_type=private & sub_type=group */
213
+ export function isIcqqGroupTempPrivateMessage(data: IcqqIpcMessageEvent): boolean {
214
+ const messageType =
215
+ data.message_type ??
216
+ (data.type === "group" || data.type === "private" ? data.type : undefined);
217
+ if (messageType !== "private") return false;
218
+ if (data.sub_type === "group") return true;
219
+ const groupId = data.sender?.group_id;
220
+ return typeof groupId === "number" && groupId > 0;
221
+ }
222
+
207
223
  export function resolveChannelFromIcqqMessage(
208
224
  data: IcqqIpcMessageEvent,
209
- ): { channelType: "group" | "private"; channelId: string } {
225
+ ): {
226
+ channelType: "group" | "private";
227
+ channelId: string;
228
+ channelParentGroupId?: string;
229
+ } {
230
+ if (isIcqqGroupTempPrivateMessage(data)) {
231
+ const groupId = data.sender?.group_id ?? data.group_id;
232
+ const userId = resolveIcqqInboundUserId(
233
+ data as unknown as Record<string, unknown>,
234
+ );
235
+ if (groupId != null && userId != null) {
236
+ return {
237
+ channelType: "private",
238
+ channelId: String(userId),
239
+ channelParentGroupId: String(groupId),
240
+ };
241
+ }
242
+ }
243
+
210
244
  const messageType =
211
245
  data.message_type ??
212
246
  (data.type === "group" || data.type === "private" ? data.type : undefined);
@@ -259,12 +293,22 @@ export function icqqElementsToSegments(
259
293
  out.push({ type: "text", data: { text: String(el.text) } });
260
294
  }
261
295
  break;
262
- case "at":
296
+ case "at": {
297
+ const atId = String(el.qq ?? el.user_id ?? "");
298
+ const name =
299
+ typeof el.name === "string"
300
+ ? el.name
301
+ : typeof el.nickname === "string"
302
+ ? el.nickname
303
+ : undefined;
263
304
  out.push({
264
305
  type: "at",
265
- data: { qq: String(el.qq ?? el.user_id ?? "") },
306
+ data: name
307
+ ? { qq: atId, user_id: atId, id: atId, name }
308
+ : { qq: atId, user_id: atId, id: atId },
266
309
  });
267
310
  break;
311
+ }
268
312
  case "face":
269
313
  out.push({ type: "face", data: { id: Number(el.id) } });
270
314
  break;
@@ -551,7 +595,7 @@ function mergeReplyFromRawMessage(
551
595
  const quoteFromRaw = Message.quoteIdFromContent(fromRaw);
552
596
  if (!quoteFromRaw) return content;
553
597
  return [
554
- { type: "reply", data: { id: quoteFromRaw, message_id: quoteFromRaw } },
598
+ { type: "reply", data: { message_id: quoteFromRaw } },
555
599
  ...content.filter((s) => s.type !== "reply"),
556
600
  ];
557
601
  }
@@ -566,7 +610,7 @@ function mergeReplyFromSource(
566
610
  resolveQuoteIdFromIcqqSource(findIcqqNestedMessageSource(data));
567
611
  if (!quoteId) return content;
568
612
  return [
569
- { type: "reply", data: { id: quoteId, message_id: quoteId } },
613
+ { type: "reply", data: { message_id: quoteId } },
570
614
  ...content.filter((s) => s.type !== "reply"),
571
615
  ];
572
616
  }
@@ -584,7 +628,7 @@ export function resolveInboundContent(
584
628
  } else {
585
629
  content = [{ type: "text", data: { text: "" } }];
586
630
  }
587
- return mergeReplyFromSource(content, data);
631
+ return toCanonicalSegments(mergeReplyFromSource(content, data));
588
632
  }
589
633
 
590
634
  export function normalizeIcqqInboundMessage(
@@ -594,7 +638,7 @@ export function normalizeIcqqInboundMessage(
594
638
  const ext = data as IcqqIpcMessageEvent & Record<string, unknown>;
595
639
  const userIdNum = resolveIcqqInboundUserId(ext);
596
640
  if (userIdNum == null) return null;
597
- const { channelType, channelId } = resolveChannelFromIcqqMessage(data);
641
+ const { channelType, channelId, channelParentGroupId } = resolveChannelFromIcqqMessage(data);
598
642
  const resolved = resolveIcqqInboundMessageId(data, channelId);
599
643
  const nickname =
600
644
  data.sender?.nickname ?? data.nickname ?? String(userIdNum);
@@ -608,6 +652,7 @@ export function normalizeIcqqInboundMessage(
608
652
  idSource: resolved.source,
609
653
  channelType,
610
654
  channelId,
655
+ channelParentGroupId,
611
656
  userId: String(userIdNum),
612
657
  nickname,
613
658
  senderRole,