@zhin.js/adapter-qq 5.0.0 → 5.0.2

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 (136) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +50 -1
  3. package/{skills/qq → agent}/PERMITS.md +1 -1
  4. package/agent/tools/add_role.ts +23 -0
  5. package/agent/tools/channel_info.ts +19 -0
  6. package/agent/tools/create_role.ts +22 -0
  7. package/agent/tools/list_channels.ts +20 -0
  8. package/agent/tools/list_guilds.ts +17 -0
  9. package/agent/tools/list_roles.ts +20 -0
  10. package/agent/tools/member_detail.ts +20 -0
  11. package/agent/tools/remove_role.ts +23 -0
  12. package/dist/index.js +2 -2
  13. package/lib/agent/tools/add_role.js +23 -0
  14. package/lib/agent/tools/add_role.js.map +1 -0
  15. package/lib/agent/tools/channel_info.js +19 -0
  16. package/lib/agent/tools/channel_info.js.map +1 -0
  17. package/lib/agent/tools/create_role.js +22 -0
  18. package/lib/agent/tools/create_role.js.map +1 -0
  19. package/lib/agent/tools/list_channels.js +20 -0
  20. package/lib/agent/tools/list_channels.js.map +1 -0
  21. package/lib/agent/tools/list_guilds.js +17 -0
  22. package/lib/agent/tools/list_guilds.js.map +1 -0
  23. package/lib/agent/tools/list_roles.js +20 -0
  24. package/lib/agent/tools/list_roles.js.map +1 -0
  25. package/lib/agent/tools/member_detail.js +20 -0
  26. package/lib/agent/tools/member_detail.js.map +1 -0
  27. package/lib/agent/tools/remove_role.js +23 -0
  28. package/lib/agent/tools/remove_role.js.map +1 -0
  29. package/lib/package.json +105 -0
  30. package/lib/{adapter.js → src/adapter.js} +12 -5
  31. package/lib/src/adapter.js.map +1 -0
  32. package/lib/{endpoint.js → src/endpoint.js} +80 -3
  33. package/lib/src/endpoint.js.map +1 -0
  34. package/lib/{gateway-config.js → src/gateway-config.js} +3 -3
  35. package/lib/src/gateway-config.js.map +1 -0
  36. package/lib/{group-at-normalize.js → src/group-at-normalize.js} +2 -5
  37. package/lib/src/group-at-normalize.js.map +1 -0
  38. package/lib/src/inbound-action.js +57 -0
  39. package/lib/src/inbound-action.js.map +1 -0
  40. package/lib/src/inbound-normalize.js.map +1 -0
  41. package/lib/src/index.js +194 -0
  42. package/lib/src/index.js.map +1 -0
  43. package/lib/src/mention-enrich.js +52 -0
  44. package/lib/src/mention-enrich.js.map +1 -0
  45. package/lib/src/outbound-keyboard.js +100 -0
  46. package/lib/src/outbound-keyboard.js.map +1 -0
  47. package/lib/src/outbound-markdown.js +189 -0
  48. package/lib/src/outbound-markdown.js.map +1 -0
  49. package/lib/{outbound-media.js → src/outbound-media.js} +2 -1
  50. package/lib/src/outbound-media.js.map +1 -0
  51. package/lib/src/outbound-mixed-media.js +78 -0
  52. package/lib/src/outbound-mixed-media.js.map +1 -0
  53. package/lib/{platform-permit.js → src/platform-permit.js} +5 -2
  54. package/lib/src/platform-permit.js.map +1 -0
  55. package/lib/src/qq-agent-deps.js +10 -0
  56. package/lib/src/qq-agent-deps.js.map +1 -0
  57. package/lib/{qq-bind-api.js → src/qq-bind-api.js} +9 -5
  58. package/lib/src/qq-bind-api.js.map +1 -0
  59. package/lib/{qq-bind-flow.js → src/qq-bind-flow.js} +11 -2
  60. package/lib/src/qq-bind-flow.js.map +1 -0
  61. package/lib/src/qq-bind-persist.js +52 -0
  62. package/lib/src/qq-bind-persist.js.map +1 -0
  63. package/lib/{qq-endpoint-manager.js → src/qq-endpoint-manager.js} +43 -8
  64. package/lib/src/qq-endpoint-manager.js.map +1 -0
  65. package/lib/src/sdk-version.js.map +1 -0
  66. package/lib/src/segment-mapper.js +2 -0
  67. package/lib/src/segment-mapper.js.map +1 -0
  68. package/lib/src/types.js.map +1 -0
  69. package/package.json +27 -17
  70. package/src/adapter.ts +14 -10
  71. package/src/endpoint.ts +119 -12
  72. package/src/gateway-config.ts +5 -5
  73. package/src/group-at-normalize.ts +2 -5
  74. package/src/inbound-action.ts +87 -0
  75. package/src/index.ts +13 -196
  76. package/src/mention-enrich.ts +71 -0
  77. package/src/outbound-keyboard.ts +117 -0
  78. package/src/outbound-markdown.ts +141 -22
  79. package/src/outbound-media.ts +2 -1
  80. package/src/outbound-mixed-media.ts +101 -0
  81. package/src/platform-permit.ts +3 -4
  82. package/src/qq-agent-deps.ts +23 -0
  83. package/src/qq-bind-api.ts +24 -5
  84. package/src/qq-bind-flow.ts +13 -3
  85. package/src/qq-bind-persist.ts +71 -0
  86. package/src/qq-endpoint-manager.ts +64 -11
  87. package/src/segment-mapper.ts +1 -0
  88. package/src/types.ts +8 -2
  89. package/lib/adapter.d.ts +0 -25
  90. package/lib/adapter.d.ts.map +0 -1
  91. package/lib/adapter.js.map +0 -1
  92. package/lib/endpoint.d.ts +0 -120
  93. package/lib/endpoint.d.ts.map +0 -1
  94. package/lib/endpoint.js.map +0 -1
  95. package/lib/gateway-config.d.ts +0 -14
  96. package/lib/gateway-config.d.ts.map +0 -1
  97. package/lib/gateway-config.js.map +0 -1
  98. package/lib/group-at-normalize.d.ts +0 -8
  99. package/lib/group-at-normalize.d.ts.map +0 -1
  100. package/lib/group-at-normalize.js.map +0 -1
  101. package/lib/inbound-normalize.d.ts +0 -11
  102. package/lib/inbound-normalize.d.ts.map +0 -1
  103. package/lib/inbound-normalize.js.map +0 -1
  104. package/lib/index.d.ts +0 -23
  105. package/lib/index.d.ts.map +0 -1
  106. package/lib/index.js +0 -375
  107. package/lib/index.js.map +0 -1
  108. package/lib/outbound-markdown.d.ts +0 -12
  109. package/lib/outbound-markdown.d.ts.map +0 -1
  110. package/lib/outbound-markdown.js +0 -78
  111. package/lib/outbound-markdown.js.map +0 -1
  112. package/lib/outbound-media.d.ts +0 -9
  113. package/lib/outbound-media.d.ts.map +0 -1
  114. package/lib/outbound-media.js.map +0 -1
  115. package/lib/platform-permit.d.ts +0 -17
  116. package/lib/platform-permit.d.ts.map +0 -1
  117. package/lib/platform-permit.js.map +0 -1
  118. package/lib/qq-bind-api.d.ts +0 -27
  119. package/lib/qq-bind-api.d.ts.map +0 -1
  120. package/lib/qq-bind-api.js.map +0 -1
  121. package/lib/qq-bind-flow.d.ts +0 -19
  122. package/lib/qq-bind-flow.d.ts.map +0 -1
  123. package/lib/qq-bind-flow.js.map +0 -1
  124. package/lib/qq-endpoint-manager.d.ts +0 -16
  125. package/lib/qq-endpoint-manager.d.ts.map +0 -1
  126. package/lib/qq-endpoint-manager.js.map +0 -1
  127. package/lib/sdk-version.d.ts +0 -8
  128. package/lib/sdk-version.d.ts.map +0 -1
  129. package/lib/sdk-version.js.map +0 -1
  130. package/lib/types.d.ts +0 -38
  131. package/lib/types.d.ts.map +0 -1
  132. package/lib/types.js.map +0 -1
  133. /package/{skills/qq/SKILL.md → agent/skills/qq.md} +0 -0
  134. /package/lib/{inbound-normalize.js → src/inbound-normalize.js} +0 -0
  135. /package/lib/{sdk-version.js → src/sdk-version.js} +0 -0
  136. /package/lib/{types.js → src/types.js} +0 -0
package/src/endpoint.ts CHANGED
@@ -9,22 +9,27 @@ import {
9
9
  import path from "path";
10
10
  import { formatCompact } from "@zhin.js/logger";
11
11
  import { registerFetchRoute, type RouterContext } from "@zhin.js/host-router/router";
12
- import {
13
- Endpoint,
14
- Message,
15
- SendOptions,
16
- SendContent,
17
- segment,} from 'zhin.js';
18
- import type { MessageElement } from "zhin.js";
12
+ import { Endpoint, Message, SendOptions, SendContent, segment, type MessageElement } from 'zhin.js';
13
+
19
14
  import { ReceiverMode, type QQEndpointConfig, type ApplicationPlatform } from "./types.js";
20
15
  import type { QQAdapter } from "./adapter.js";
21
16
  import { normalizeQqInboundWsPayload, type QqWsPacket } from "./inbound-normalize.js";
22
17
  import { normalizeGroupAtPrefix } from "./group-at-normalize.js";
23
18
  import { SDK_VERSION, SDK_VERSION_HEADER } from "./sdk-version.js";
24
19
  import { applyCustomAuthEndpoints } from "./gateway-config.js";
25
- import { normalizeOutboundMarkdown } from "./outbound-markdown.js";
20
+ import { normalizeOutboundMarkdown, asOutboundSegments, splitReplyPrefix } from "./outbound-markdown.js";
26
21
  import { normalizeOutboundMedia } from "./outbound-media.js";
22
+ import { fromCanonicalSegments, toCanonicalSegments } from "./segment-mapper.js";
23
+ import { enrichCanonicalMentionNames } from "./mention-enrich.js";
24
+ import {
25
+ buildMixedMediaMessagePayload,
26
+ buildQqImageUploadPayload,
27
+ firstImageSegment,
28
+ shouldSendTextImageMixedMedia,
29
+ } from "./outbound-mixed-media.js";
27
30
  import { normalizeQqGuildSenderForPermit } from "./platform-permit.js";
31
+ import { expandKeyboardSegmentsForQq } from "./outbound-keyboard.js";
32
+ import { formatQqActionMessage, type QqActionNoticeEvent } from "./inbound-action.js";
28
33
 
29
34
  /** 从 qq-official-bot SendResult / 审核回包中解析出站消息 ID */
30
35
  export function resolveOutboundMessageId(result: unknown): string {
@@ -171,6 +176,34 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
171
176
  }
172
177
  }
173
178
 
179
+ private async handleQQAction(event: QqActionNoticeEvent): Promise<void> {
180
+ try {
181
+ await event.reply(0);
182
+ const channel = event.notice_type === 'group'
183
+ ? { id: String(event.group_id), type: 'group' as const }
184
+ : event.notice_type === 'guild'
185
+ ? { id: String(event.channel_id), type: 'channel' as const }
186
+ : { id: String(event.operator_id), type: 'private' as const };
187
+ const message = formatQqActionMessage(event, {
188
+ endpointName: this.$config.name,
189
+ send: async (content) => this.adapter.sendMessage({
190
+ ...channel,
191
+ context: 'qq',
192
+ endpoint: this.$config.name,
193
+ content,
194
+ }),
195
+ });
196
+ await this.adapter.receiveMessageAwait(message);
197
+ this.pluginLogger.debug(
198
+ `qq action ${message.$channel.type}(${message.$channel.id}): ${message.$raw}`,
199
+ );
200
+ } catch (err) {
201
+ this.pluginLogger.warn(
202
+ `qq format action failed: ${err instanceof Error ? err.message : String(err)}`,
203
+ );
204
+ }
205
+ }
206
+
174
207
  private handleGroupNotice(event: string, payload: { group_id?: string; operator_id?: string }): void {
175
208
  this.pluginLogger.info(
176
209
  `qq notice ${event}: group=${payload.group_id ?? '?'} operator=${payload.operator_id ?? '?'}`,
@@ -181,6 +214,9 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
181
214
  this.on("message.group", this.handleQQMessage.bind(this));
182
215
  this.on("message.guild", this.handleQQMessage.bind(this));
183
216
  this.on("message.private", this.handleQQMessage.bind(this));
217
+ this.on("notice.friend.action", this.handleQQAction.bind(this));
218
+ this.on("notice.group.action", this.handleQQAction.bind(this));
219
+ this.on("notice.guild.action", this.handleQQAction.bind(this));
184
220
  this.on("notice.group.increase", (e) => this.handleGroupNotice('group.add_robot', e));
185
221
  this.on("notice.group.decrease", (e) => this.handleGroupNotice('group.del_robot', e));
186
222
  this.on("notice.group.receive_open", (e) => this.handleGroupNotice('group.msg_receive_open', e));
@@ -252,10 +288,18 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
252
288
  }
253
289
  }
254
290
 
291
+ private toWireSendContent(content: SendContent): MessageElement[] {
292
+ const items = (Array.isArray(content) ? content : [content]).map((item) =>
293
+ typeof item === "string" ? { type: "text", data: { text: item } } : item as MessageElement,
294
+ );
295
+ return fromCanonicalSegments(toCanonicalSegments(items as never));
296
+ }
297
+
255
298
  $formatMessage(msg: PrivateMessageEvent | GroupMessageEvent) {
256
299
  const raw = msg as PrivateMessageEvent & GroupMessageEvent & {
257
300
  group_openid?: string;
258
301
  author?: { member_openid?: string; user_openid?: string; id?: string; username?: string };
302
+ mentions?: Array<{ id?: string; member_openid?: string; username?: string; nickname?: string }>;
259
303
  __zhin_group_at?: boolean;
260
304
  };
261
305
 
@@ -282,6 +326,14 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
282
326
  content = normalizeGroupAtPrefix(content, endpointAtIds, raw.__zhin_group_at === true);
283
327
  }
284
328
 
329
+ if (Array.isArray(content)) {
330
+ content = enrichCanonicalMentionNames(
331
+ toCanonicalSegments(content),
332
+ this.adapter,
333
+ raw.mentions,
334
+ );
335
+ }
336
+
285
337
  const result = Message.from(msg, {
286
338
  $id: msg.message_id?.toString(),
287
339
  $adapter: "qq" as const,
@@ -315,10 +367,20 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
315
367
  }
316
368
 
317
369
  async $sendMessage(options: SendOptions): Promise<string> {
318
- const content = normalizeOutboundMarkdown(
319
- normalizeOutboundMedia(options.content),
320
- this.$config.outboundMarkdown,
321
- );
370
+ const expanded = expandKeyboardSegmentsForQq(options.content);
371
+ const canonicalized = this.toWireSendContent(expanded);
372
+ const mediaNorm = normalizeOutboundMedia(canonicalized);
373
+ const markdownMode = this.$config.outboundMarkdown;
374
+
375
+ if (
376
+ (options.type === "group" || options.type === "private")
377
+ && !options.id.startsWith("direct:")
378
+ && shouldSendTextImageMixedMedia(mediaNorm, markdownMode)
379
+ ) {
380
+ return await this.$sendTextImageMixedMedia(options, mediaNorm);
381
+ }
382
+
383
+ const content = normalizeOutboundMarkdown(mediaNorm, markdownMode);
322
384
  switch (options.type) {
323
385
  case "private": {
324
386
  if (options.id.startsWith("direct:")) {
@@ -347,6 +409,51 @@ export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform =
347
409
  }
348
410
  }
349
411
 
412
+ /** 内联图 + 文本:上传 file_info 后以 msg_type=7 图文混排发送(二维码等场景) */
413
+ private async $sendTextImageMixedMedia(
414
+ options: SendOptions,
415
+ content: SendContent,
416
+ ): Promise<string> {
417
+ const endpointPath = this.resolveV2EndpointPath(options);
418
+ const { body } = splitReplyPrefix(asOutboundSegments(content));
419
+ const image = firstImageSegment(body);
420
+ if (!image) {
421
+ throw new Error("QQ 图文混排:未找到图片段");
422
+ }
423
+
424
+ const uploadPayload = buildQqImageUploadPayload(image);
425
+ const { data: uploadResult } = await this.request.post(
426
+ `${endpointPath}/files`,
427
+ { ...uploadPayload, srv_send_msg: false },
428
+ ) as { data: { file_info?: string } };
429
+
430
+ if (!uploadResult?.file_info) {
431
+ throw new Error("QQ 图文混排:图片上传未返回 file_info");
432
+ }
433
+
434
+ const messagePayload = buildMixedMediaMessagePayload(content, uploadResult.file_info);
435
+ const { data: result } = await this.request.post(
436
+ `${endpointPath}/messages`,
437
+ messagePayload,
438
+ );
439
+ this.pluginLogger.debug(
440
+ `${this.$config.name} send mixed-media ${options.type}(${options.id}):${segment.raw(content)}`,
441
+ );
442
+ const idPrefix = options.type === "group" ? "group" : "private";
443
+ return `${idPrefix}-${options.id}:${resolveOutboundMessageId(result)}`;
444
+ }
445
+
446
+ private resolveV2EndpointPath(options: SendOptions): string {
447
+ switch (options.type) {
448
+ case "group":
449
+ return `/v2/groups/${options.id}`;
450
+ case "private":
451
+ return `/v2/users/${options.id}`;
452
+ default:
453
+ throw new Error(`QQ 图文混排不支持 ${options.type}`);
454
+ }
455
+ }
456
+
350
457
  async $recallMessage(id: string): Promise<void> {
351
458
  if (!/^(private|group|channel|direct)-([^\:]+):(.+)$/.test(id)) throw new Error(`invalid message id ${id}`);
352
459
  const match = id.match(/^(private|group|channel|direct)-([^\:]+):(.+)$/);
@@ -1,7 +1,7 @@
1
- import type { Logger } from "@zhin.js/logger";
2
- import { formatCompact } from "@zhin.js/logger";
3
- import type { Bot as QQOfficialBot } from "qq-official-bot";
4
- import { ReceiverMode } from "qq-official-bot";
1
+ import { formatCompact, type Logger } from '@zhin.js/logger';
2
+
3
+ import { ReceiverMode, type Bot as QQOfficialBot } from 'qq-official-bot';
4
+
5
5
  import type { QQEndpointConfig } from "./types.js";
6
6
 
7
7
  /** qq-official-bot 默认 token 接口 */
@@ -36,7 +36,7 @@ export function applyCustomAuthEndpoints(
36
36
  if (gatewayUrl) session.authManager.config.gatewayUrl = gatewayUrl;
37
37
  }
38
38
 
39
- logger.info(formatCompact({
39
+ logger.debug(formatCompact({
40
40
  op: "qq_gateway",
41
41
  mode,
42
42
  accessTokenUrl: accessTokenUrl ?? DEFAULT_ACCESS_TOKEN_URL,
@@ -1,10 +1,7 @@
1
- import type { MessageElement } from "zhin.js";
1
+ import { readMentionSegmentTarget, type MessageElement } from 'zhin.js';
2
2
 
3
3
  function segmentAtId(seg: MessageElement): string {
4
- if (seg.type !== "at" && seg.type !== "mention") return "";
5
- const data = seg.data as Record<string, unknown> | undefined;
6
- const raw = data?.user_id ?? data?.qq ?? data?.id;
7
- return raw == null ? "" : String(raw);
4
+ return readMentionSegmentTarget(seg);
8
5
  }
9
6
 
10
7
  function textMentionsEndpoint(text: string, endpointIds: string[]): boolean {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * QQ 官方互动事件 → 标准 action 段
3
+ * @see https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/trans/msg-btn.html
4
+ */
5
+ import type {
6
+ FriendActionNoticeEvent,
7
+ GroupActionNoticeEvent,
8
+ GuildActionNoticeEvent,
9
+ } from 'qq-official-bot';
10
+ import { Message, type MessageSegment, type SendContent } from 'zhin.js';
11
+
12
+ export type QqActionNoticeEvent =
13
+ | FriendActionNoticeEvent
14
+ | GroupActionNoticeEvent
15
+ | GuildActionNoticeEvent;
16
+
17
+ export interface FormatQqActionMessageOptions {
18
+ endpointName: string;
19
+ send: (content: SendContent) => Promise<string>;
20
+ }
21
+
22
+ /** `group-{openid}:{msgId}` → 平台 msg_id */
23
+ export function stripQqOutboundMessageId(storedId: string): string {
24
+ const match = /^(?:private|group|channel|direct)-[^:]+:(.+)$/.exec(storedId);
25
+ return match ? match[1]! : storedId;
26
+ }
27
+
28
+ function prependReply(content: SendContent, msgId: string): MessageSegment[] {
29
+ const items = Array.isArray(content) ? [...content] : [content];
30
+ items.unshift({ type: 'reply', data: { id: msgId } });
31
+ return items as MessageSegment[];
32
+ }
33
+
34
+ function createQqActionReplyHandler(
35
+ send: (content: SendContent) => Promise<string>,
36
+ ) {
37
+ return async (content: SendContent, quote: boolean | string = false): Promise<string> => {
38
+ const outbound = typeof quote === 'string'
39
+ ? prependReply(content, stripQqOutboundMessageId(quote))
40
+ : content;
41
+ return await send(outbound);
42
+ };
43
+ }
44
+
45
+ export function formatQqActionMessage(
46
+ event: QqActionNoticeEvent,
47
+ options: FormatQqActionMessageOptions,
48
+ ): ReturnType<typeof Message.from<QqActionNoticeEvent>> {
49
+ const resolved = event.data?.resolved;
50
+ const buttonId = String(resolved?.button_id ?? '');
51
+ const buttonData = resolved?.button_data != null ? String(resolved.button_data) : '';
52
+ const payload = buttonData || buttonId;
53
+ const sourceMessageId = resolved?.message_id != null ? String(resolved.message_id) : undefined;
54
+
55
+ let channelType: 'private' | 'group' | 'channel' = 'private';
56
+ let channelId = String(event.operator_id ?? '');
57
+
58
+ if (event.notice_type === 'group') {
59
+ channelType = 'group';
60
+ channelId = String(event.group_id);
61
+ } else if (event.notice_type === 'guild') {
62
+ channelType = 'channel';
63
+ channelId = String(event.channel_id);
64
+ }
65
+
66
+ const senderId = String(event.operator_id ?? '');
67
+
68
+ return Message.from(event, {
69
+ $id: event.notice_id ?? event.event_id ?? `action:${Date.now()}`,
70
+ $adapter: 'qq',
71
+ $endpoint: options.endpointName,
72
+ $sender: { id: senderId, name: senderId },
73
+ $channel: { id: channelId, type: channelType },
74
+ $content: [{
75
+ type: 'action',
76
+ data: {
77
+ id: buttonId,
78
+ payload,
79
+ sourceMessageId,
80
+ },
81
+ }],
82
+ $raw: payload,
83
+ $timestamp: Date.now(),
84
+ $recall: async () => {},
85
+ $reply: createQqActionReplyHandler(options.send),
86
+ });
87
+ }
package/src/index.ts CHANGED
@@ -2,16 +2,16 @@
2
2
  * QQ 官方适配器入口:类型扩展、导出、注册
3
3
  */
4
4
  import path from "node:path";
5
- import { usePlugin, type Plugin, type IGroupManagement, createGroupManagementTools, type ToolFeature } from "zhin.js";
5
+ import { usePlugin, type Plugin, type ISceneManagement, createSceneManagementTools, type ToolFeature } from "zhin.js";
6
6
  import type { Router } from "@zhin.js/host-router";
7
7
  import { QQAdapter } from "./adapter.js";
8
8
  import { PageManager } from "@zhin.js/host-api";
9
9
  import {
10
- platformPermit,
11
10
  qqGuildPermitResolver,
12
11
  registerQqPlatformPermitChecker,
13
12
  } from "./platform-permit.js";
14
13
  import { disposeQqEndpointProvision } from "./qq-endpoint-manager.js";
14
+ import { setQqAgentDeps } from "./qq-agent-deps.js";
15
15
 
16
16
  declare module "zhin.js" {
17
17
  namespace Plugin {
@@ -56,203 +56,20 @@ useContext("router", (router: Router) => {
56
56
  useContext('tool', 'qq', (toolService: ToolFeature, qq: QQAdapter) => {
57
57
  const disposers: (() => void)[] = [];
58
58
  disposers.push(registerQqPlatformPermitChecker());
59
- const groupTools = createGroupManagementTools(
60
- qq as unknown as IGroupManagement,
59
+ setQqAgentDeps({
60
+ getEndpoint: (endpointId) => {
61
+ const endpoint = qq.endpoints.get(endpointId);
62
+ if (!endpoint) throw new Error(`Endpoint ${endpointId} 不存在`);
63
+ return endpoint;
64
+ },
65
+ getAdapter: () => qq,
66
+ });
67
+ const sceneTools = createSceneManagementTools(
68
+ qq as unknown as ISceneManagement,
61
69
  'qq',
62
70
  { permitResolver: qqGuildPermitResolver, registerChecker: false },
63
71
  );
64
- disposers.push(...groupTools.map(t => toolService.addTool(t, plugin.name)));
65
-
66
- // 获取频道列表
67
- disposers.push(toolService.addTool({
68
- name: 'qq_list_guilds',
69
- description: '获取 QQ 频道列表',
70
- parameters: {
71
- type: 'object',
72
- properties: {
73
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
74
- },
75
- required: ['endpoint_id'],
76
- },
77
- platforms: ['qq'],
78
- tags: ['qq'],
79
- execute: async (args: Record<string, any>) => {
80
- const endpoint = qq.endpoints.get(args.endpoint_id);
81
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
82
- const guilds = await endpoint.getGuilds();
83
- return { guilds, count: guilds.length };
84
- },
85
- }, plugin.name));
86
-
87
- // 获取子频道列表
88
- disposers.push(toolService.addTool({
89
- name: 'qq_list_channels',
90
- description: '获取 QQ 频道下的子频道列表',
91
- parameters: {
92
- type: 'object',
93
- properties: {
94
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
95
- guild_id: { type: 'string', description: '频道 ID' },
96
- },
97
- required: ['endpoint_id', 'guild_id'],
98
- },
99
- platforms: ['qq'],
100
- tags: ['qq'],
101
- permissions: [platformPermit('manage_channels')],
102
- execute: async (args: Record<string, any>) => {
103
- const endpoint = qq.endpoints.get(args.endpoint_id);
104
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
105
- const channels = await endpoint.getChannels(args.guild_id);
106
- return { channels, count: channels.length };
107
- },
108
- }, plugin.name));
109
-
110
- // 获取角色列表
111
- disposers.push(toolService.addTool({
112
- name: 'qq_list_roles',
113
- description: '获取 QQ 频道角色列表',
114
- parameters: {
115
- type: 'object',
116
- properties: {
117
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
118
- guild_id: { type: 'string', description: '频道 ID' },
119
- },
120
- required: ['endpoint_id', 'guild_id'],
121
- },
122
- platforms: ['qq'],
123
- tags: ['qq'],
124
- permissions: [platformPermit('manage_roles')],
125
- execute: async (args: Record<string, any>) => {
126
- const endpoint = qq.endpoints.get(args.endpoint_id);
127
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
128
- const roles = await endpoint.getGuildRoles(args.guild_id);
129
- return { roles, count: roles.length };
130
- },
131
- }, plugin.name));
132
-
133
- // 创建角色
134
- disposers.push(toolService.addTool({
135
- name: 'qq_create_role',
136
- description: '创建 QQ 频道角色',
137
- parameters: {
138
- type: 'object',
139
- properties: {
140
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
141
- guild_id: { type: 'string', description: '频道 ID' },
142
- name: { type: 'string', description: '角色名称' },
143
- color: { type: 'number', description: '颜色(RGB 十进制数值)' },
144
- },
145
- required: ['endpoint_id', 'guild_id', 'name'],
146
- },
147
- platforms: ['qq'],
148
- tags: ['qq'],
149
- permissions: [platformPermit('guild_owner')],
150
- execute: async (args: Record<string, any>) => {
151
- const endpoint = qq.endpoints.get(args.endpoint_id);
152
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
153
- const role = await endpoint.createGuildRole(args.guild_id, args.name, args.color);
154
- return { success: !!role, role, message: role ? `角色 "${args.name}" 创建成功` : '创建失败' };
155
- },
156
- }, plugin.name));
157
-
158
- // 添加角色
159
- disposers.push(toolService.addTool({
160
- name: 'qq_add_role',
161
- description: '给成员添加 QQ 频道角色',
162
- parameters: {
163
- type: 'object',
164
- properties: {
165
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
166
- guild_id: { type: 'string', description: '频道 ID' },
167
- channel_id: { type: 'string', description: '子频道 ID' },
168
- user_id: { type: 'string', description: '用户 ID' },
169
- role_id: { type: 'string', description: '角色 ID' },
170
- },
171
- required: ['endpoint_id', 'guild_id', 'channel_id', 'user_id', 'role_id'],
172
- },
173
- platforms: ['qq'],
174
- tags: ['qq'],
175
- permissions: [platformPermit('manage_roles')],
176
- execute: async (args: Record<string, any>) => {
177
- const endpoint = qq.endpoints.get(args.endpoint_id);
178
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
179
- const success = await endpoint.addMemberRole(args.guild_id, args.channel_id, args.user_id, args.role_id);
180
- return { success, message: success ? '已给成员添加角色' : '操作失败' };
181
- },
182
- }, plugin.name));
183
-
184
- // 移除角色
185
- disposers.push(toolService.addTool({
186
- name: 'qq_remove_role',
187
- description: '移除成员的 QQ 频道角色',
188
- parameters: {
189
- type: 'object',
190
- properties: {
191
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
192
- guild_id: { type: 'string', description: '频道 ID' },
193
- channel_id: { type: 'string', description: '子频道 ID' },
194
- user_id: { type: 'string', description: '用户 ID' },
195
- role_id: { type: 'string', description: '角色 ID' },
196
- },
197
- required: ['endpoint_id', 'guild_id', 'channel_id', 'user_id', 'role_id'],
198
- },
199
- platforms: ['qq'],
200
- tags: ['qq'],
201
- permissions: [platformPermit('manage_roles')],
202
- execute: async (args: Record<string, any>) => {
203
- const endpoint = qq.endpoints.get(args.endpoint_id);
204
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
205
- const success = await endpoint.removeMemberRole(args.guild_id, args.channel_id, args.user_id, args.role_id);
206
- return { success, message: success ? '已移除成员的角色' : '操作失败' };
207
- },
208
- }, plugin.name));
209
-
210
- // 子频道详情
211
- disposers.push(toolService.addTool({
212
- name: 'qq_channel_info',
213
- description: '获取 QQ 频道中指定子频道的详细信息',
214
- parameters: {
215
- type: 'object',
216
- properties: {
217
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
218
- channel_id: { type: 'string', description: '子频道 ID' },
219
- },
220
- required: ['endpoint_id', 'channel_id'],
221
- },
222
- platforms: ['qq'],
223
- tags: ['qq'],
224
- permissions: [platformPermit('guild_admin')],
225
- execute: async (args: Record<string, any>) => {
226
- const endpoint = qq.endpoints.get(args.endpoint_id);
227
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
228
- const info = await endpoint.getChannelInfo(args.channel_id);
229
- return info;
230
- },
231
- }, plugin.name));
232
-
233
- // 单成员详情
234
- disposers.push(toolService.addTool({
235
- name: 'qq_member_detail',
236
- description: '获取 QQ 频道中指定成员的详细信息',
237
- parameters: {
238
- type: 'object',
239
- properties: {
240
- endpoint_id: { type: 'string', description: 'Endpoint 名称', contextKey: 'endpointId' },
241
- guild_id: { type: 'string', description: '频道 ID' },
242
- user_id: { type: 'string', description: '用户 ID' },
243
- },
244
- required: ['endpoint_id', 'guild_id', 'user_id'],
245
- },
246
- platforms: ['qq'],
247
- tags: ['qq'],
248
- permissions: [platformPermit('guild_admin')],
249
- execute: async (args: Record<string, any>) => {
250
- const endpoint = qq.endpoints.get(args.endpoint_id);
251
- if (!endpoint) throw new Error(`Endpoint ${args.endpoint_id} 不存在`);
252
- const member = await endpoint.getGuildMember(args.guild_id, args.user_id);
253
- return member;
254
- },
255
- }, plugin.name));
72
+ disposers.push(...sceneTools.map(t => toolService.addTool(t, plugin.name)));
256
73
 
257
74
  return () => disposers.forEach(d => d());
258
75
  });
@@ -0,0 +1,71 @@
1
+ import type { Segment } from 'zhin.js';
2
+ import type { QQAdapter } from './adapter.js';
3
+
4
+ type MentionWire = {
5
+ id?: string;
6
+ member_openid?: string;
7
+ username?: string;
8
+ nickname?: string;
9
+ };
10
+
11
+ type QQEndpointLike = {
12
+ $id: string;
13
+ $platformUserId?: string;
14
+ $config: { name?: string; appid?: string; nickname?: string };
15
+ };
16
+
17
+ function mentionWireId(row: MentionWire): string {
18
+ return String(row.id ?? row.member_openid ?? '').trim();
19
+ }
20
+
21
+ function mentionWireName(row: MentionWire): string | undefined {
22
+ const name = row.username ?? row.nickname;
23
+ return typeof name === 'string' && name.trim() ? name.trim() : undefined;
24
+ }
25
+
26
+ function endpointLookupIds(endpoint: QQEndpointLike): string[] {
27
+ const cfg = endpoint.$config;
28
+ return [
29
+ endpoint.$platformUserId,
30
+ cfg.appid,
31
+ cfg.name,
32
+ endpoint.$id,
33
+ ].filter((id): id is string => Boolean(id)).map(String);
34
+ }
35
+
36
+ function resolveEndpointDisplayName(endpoint: QQEndpointLike): string | undefined {
37
+ const cfg = endpoint.$config;
38
+ return cfg.nickname?.trim() || cfg.name?.trim() || endpoint.$id;
39
+ }
40
+
41
+ /** 为 canonical mention 段补齐展示名(平台 payload / endpoint 配置)。 */
42
+ export function enrichCanonicalMentionNames(
43
+ segments: Segment[],
44
+ adapter: QQAdapter,
45
+ wireMentions?: MentionWire[],
46
+ ): Segment[] {
47
+ const nameById = new Map<string, string>();
48
+ for (const row of wireMentions ?? []) {
49
+ const id = mentionWireId(row);
50
+ const name = mentionWireName(row);
51
+ if (id && name) nameById.set(id, name);
52
+ }
53
+
54
+ for (const endpoint of adapter.endpoints.values()) {
55
+ const display = resolveEndpointDisplayName(endpoint);
56
+ if (!display) continue;
57
+ for (const id of endpointLookupIds(endpoint)) {
58
+ if (!nameById.has(id)) nameById.set(id, display);
59
+ }
60
+ }
61
+
62
+ return segments.map((seg) => {
63
+ if (seg.type !== 'mention') return seg;
64
+ const data = seg.data as { target?: string; name?: string };
65
+ const target = String(data.target ?? '').trim();
66
+ if (!target || data.name) return seg;
67
+ const name = nameById.get(target);
68
+ if (!name) return seg;
69
+ return { ...seg, data: { target, name } };
70
+ });
71
+ }