@zhin.js/adapter-qq 1.1.17 → 1.1.18

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 +43 -0
  2. package/README.md +56 -5
  3. package/adapters/qq/index.js +7 -2
  4. package/adapters/qq/index.ts +7 -2
  5. package/agents/qq/boundaries.md +2 -0
  6. package/agents/qq/conversation.js +92 -0
  7. package/agents/qq/conversation.ts +99 -0
  8. package/agents/qq/messages.js +60 -0
  9. package/agents/qq/messages.ts +60 -0
  10. package/agents/qq/skills/qq/SKILL.md +8 -2
  11. package/agents/qq/skills/qq/tools/guild_kick_member/index.js +30 -0
  12. package/agents/qq/skills/qq/tools/guild_kick_member/index.ts +29 -0
  13. package/agents/qq/skills/qq/tools/guild_mute_member/index.js +31 -0
  14. package/agents/qq/skills/qq/tools/guild_mute_member/index.ts +30 -0
  15. package/agents/qq/skills/qq-conversation/SKILL.md +24 -0
  16. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.js +78 -0
  17. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.ts +76 -0
  18. package/agents/qq/skills/qq-group-admin/SKILL.md +34 -0
  19. package/agents/qq/skills/qq-group-admin/tools/group_kick/index.js +39 -0
  20. package/agents/qq/skills/qq-group-admin/tools/group_kick/index.ts +38 -0
  21. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.js +33 -0
  22. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.ts +32 -0
  23. package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.js +25 -0
  24. package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.ts +25 -0
  25. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.js +37 -0
  26. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.ts +36 -0
  27. package/agents/qq/skills/qq-group-directory/SKILL.md +24 -0
  28. package/agents/qq/skills/qq-group-directory/tools/group_info/index.js +16 -0
  29. package/agents/qq/skills/qq-group-directory/tools/group_info/index.ts +16 -0
  30. package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.js +19 -0
  31. package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.ts +19 -0
  32. package/agents/qq/skills/qq-group-directory/tools/group_members/index.js +19 -0
  33. package/agents/qq/skills/qq-group-directory/tools/group_members/index.ts +19 -0
  34. package/agents/qq/skills/qq-group-state/SKILL.md +21 -0
  35. package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.js +19 -0
  36. package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.ts +19 -0
  37. package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.js +19 -0
  38. package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.ts +19 -0
  39. package/agents/qq/skills/qq-group-state/tools/list_muted/index.js +21 -0
  40. package/agents/qq/skills/qq-group-state/tools/list_muted/index.ts +21 -0
  41. package/agents/qq/skills/qq-messages/SKILL.md +27 -0
  42. package/agents/qq/skills/qq-messages/tools/quote_reply/index.js +21 -0
  43. package/agents/qq/skills/qq-messages/tools/quote_reply/index.ts +21 -0
  44. package/agents/qq/skills/qq-messages/tools/recall_own_message/index.js +18 -0
  45. package/agents/qq/skills/qq-messages/tools/recall_own_message/index.ts +17 -0
  46. package/commands/qq/endpoint/definition.js +35 -22
  47. package/commands/qq/endpoint/definition.ts +37 -22
  48. package/lib/console.d.ts +8 -0
  49. package/lib/console.js +84 -0
  50. package/lib/endpoint.d.ts +10 -1
  51. package/lib/endpoint.js +134 -8
  52. package/lib/group-admin.d.ts +36 -0
  53. package/lib/group-admin.js +113 -0
  54. package/lib/protocol.d.ts +5 -0
  55. package/lib/protocol.js +4 -0
  56. package/lib/qq-console-spec.d.ts +17 -0
  57. package/lib/qq-console-spec.js +10 -0
  58. package/lib/qq-console.d.ts +11 -0
  59. package/lib/qq-console.js +116 -0
  60. package/lib/qq-endpoint-write.d.ts +22 -0
  61. package/lib/qq-endpoint-write.js +34 -0
  62. package/lib/qq-runtime-state.d.ts +19 -0
  63. package/lib/qq-runtime-state.js +1 -0
  64. package/package.json +17 -9
  65. package/pages/index/index.js +15 -0
  66. package/pages/index/index.ts +16 -0
  67. package/plugin.js +5 -2
  68. package/schema.json +174 -0
  69. package/src/console.ts +96 -0
  70. package/src/endpoint.ts +137 -8
  71. package/src/group-admin.ts +153 -0
  72. package/src/protocol.ts +8 -0
  73. package/src/qq-console-spec.ts +18 -0
  74. package/src/qq-console.ts +124 -0
  75. package/src/qq-endpoint-write.ts +52 -0
  76. package/src/qq-runtime-state.ts +21 -0
package/lib/endpoint.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * QQ endpoints — lifecycle, outbound, admit, agent tool surface.
3
3
  */
4
- import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest, type EndpointTransportState } from 'zhin.js/adapter';
4
+ import { Endpoint, type EndpointControl, type EndpointManagement, type EndpointSendRequest, type EndpointTransportState, type AdapterOutageNotifier } from 'zhin.js/adapter';
5
5
  import type { HttpHost } from '@zhin.js/host-http';
6
6
  import type { CapabilityId } from 'zhin.js';
7
7
  import { type QqInboundMessage, type ResolvedQqHttpConfig, type ResolvedQqWebsocketConfig } from './protocol.js';
@@ -13,6 +13,8 @@ export interface QqEndpointOptions {
13
13
  readonly id: CapabilityId;
14
14
  readonly config: ResolvedQqWebsocketConfig;
15
15
  readonly createBot?: CreateQqBot;
16
+ /** Out-of-band outage notifier; absent in tests/hosts without outbound. */
17
+ readonly notifier?: AdapterOutageNotifier;
16
18
  }
17
19
  export declare class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
18
20
  #private;
@@ -39,6 +41,8 @@ export interface QqHttpEndpointOptions {
39
41
  readonly http: HttpHost;
40
42
  readonly config: ResolvedQqHttpConfig;
41
43
  readonly createBot?: CreateQqHttpBot;
44
+ /** Out-of-band outage notifier; absent in tests/hosts without outbound. */
45
+ readonly notifier?: AdapterOutageNotifier;
42
46
  }
43
47
  /** Webhook / middleware inbound via httpHostToken POST (qq-official-bot Middleware receiver). */
44
48
  export declare class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
@@ -60,3 +64,8 @@ export declare class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
60
64
  /** Native click only; C2C/group have no platform source message ID. */
61
65
  admitInteraction(raw: unknown): boolean;
62
66
  }
67
+ /** `type:id`(1 系统表情 / 2 emoji)或单个 Unicode emoji(→ type 2 + 码点)。 */
68
+ export declare function parseQqEmoji(value: string): {
69
+ type: 1 | 2;
70
+ id: `${number}`;
71
+ } | null;
package/lib/endpoint.js CHANGED
@@ -4,10 +4,11 @@ import { qqDeliveryFailure } from './delivery-error.js';
4
4
  import { normalizeQqInteraction } from './interaction.js';
5
5
  import { awaitQqStartup } from './start-deadline.js';
6
6
  import { EndpointDeliveryError } from '@zhin.js/im-contract';
7
+ import { inspectQqGroupMember, inspectQqGroupMemberProfile, inspectQqGroupScene, kickQqGroupMember, moderateQqGroupMember, muteQqGroupMember, qqGroupService, } from './group-admin.js';
7
8
  /**
8
9
  * QQ endpoints — lifecycle, outbound, admit, agent tool surface.
9
10
  */
10
- import { Endpoint, createEndpointLifecycle, createRecallEndpointControl, } from 'zhin.js/adapter';
11
+ import { Endpoint, createEndpointLifecycle, } from 'zhin.js/adapter';
11
12
  import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
12
13
  import { collectOutboundImageSizes, formatOutboundMessages } from './outbound.js';
13
14
  import { QqActiveMessageGate, QqPassiveReplyLedger } from './passive-reply.js';
@@ -22,14 +23,15 @@ export class QqWebsocketEndpoint extends Endpoint {
22
23
  #createBot;
23
24
  #bot = null;
24
25
  #open = false;
26
+ #offlineNoticeAt = 0;
25
27
  #seenInteractions = new Map();
26
28
  /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
27
29
  #passiveReplies = new QqPassiveReplyLedger();
28
30
  #activeGate = new QqActiveMessageGate();
29
31
  #lifecycle;
30
32
  #cleanup;
31
- management = createQqEndpointManagement(() => this.#requireBot());
32
- control = createRecallEndpointControl((id) => this.recallMessage(id));
33
+ management = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#bot !== null);
34
+ control = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
33
35
  constructor(options) {
34
36
  super();
35
37
  this.#logger = getAdapterLogger('qq', options.config.id);
@@ -78,8 +80,13 @@ export class QqWebsocketEndpoint extends Endpoint {
78
80
  this.#cleanup = cleanup;
79
81
  handle.onForceClose(() => { void cleanup(); });
80
82
  try {
81
- bot.on('transport.close', () => { if (cancelled || this.#bot !== bot)
82
- return; void cleanup(); handle.notifyClosed(); });
83
+ bot.on('transport.close', () => {
84
+ if (cancelled || this.#bot !== bot)
85
+ return;
86
+ this.#notifyOffline('transport_close');
87
+ void cleanup();
88
+ handle.notifyClosed();
89
+ });
83
90
  this.#bindBot(bot);
84
91
  await awaitQqStartup(bot.start().then(async () => {
85
92
  if (cancelled)
@@ -94,6 +101,7 @@ export class QqWebsocketEndpoint extends Endpoint {
94
101
  await stopping;
95
102
  return;
96
103
  }
104
+ this.#offlineNoticeAt = 0;
97
105
  this.#logger.info(`connected (websocket) | appid: ${this.#options.config.appid}`);
98
106
  }
99
107
  catch (error) {
@@ -101,6 +109,7 @@ export class QqWebsocketEndpoint extends Endpoint {
101
109
  await cleanup();
102
110
  if (wasCancelled)
103
111
  return;
112
+ this.#notifyOffline('start_failed');
104
113
  const raw = error instanceof Error ? error.message : String(error);
105
114
  throw new Error(`QQ WebSocket 连接失败:请检查 appid/secret 是否配对、网关地址(gatewayUrl/accessTokenUrl)是否可达(原始错误:${raw})`, { cause: error });
106
115
  }
@@ -230,6 +239,26 @@ export class QqWebsocketEndpoint extends Endpoint {
230
239
  }).catch(() => this.#logger.warn('qq_interaction_receive_failed'));
231
240
  return true;
232
241
  }
242
+ #notifyOffline(reason) {
243
+ const notifier = this.#options.notifier;
244
+ if (!notifier?.configured)
245
+ return;
246
+ const now = Date.now();
247
+ if (now - this.#offlineNoticeAt < 60_000)
248
+ return;
249
+ this.#offlineNoticeAt = now;
250
+ const text = `[Zhin] QQ 机器人 ${this.#options.config.id} 掉线(${reason})。正在自动重连。\n`
251
+ + '恢复:在 QQ 回复 `qq endpoint add` 重新扫码绑定,或打开 Console 的 QQ 页面。';
252
+ void notifier.send(text).then((sent) => {
253
+ if (sent)
254
+ return;
255
+ this.#logger.warn(formatCompact({
256
+ op: 'qq_notify_failed',
257
+ endpoint: this.#options.config.id,
258
+ reason,
259
+ }));
260
+ });
261
+ }
233
262
  #bindBot(bot) {
234
263
  bindQqBotInboundEvents(bot, (raw) => {
235
264
  if (this.#bot !== bot)
@@ -270,14 +299,15 @@ export class QqHttpEndpoint extends Endpoint {
270
299
  #createBot;
271
300
  #bot = null;
272
301
  #open = false;
302
+ #offlineNoticeAt = 0;
273
303
  #seenInteractions = new Map();
274
304
  /** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
275
305
  #passiveReplies = new QqPassiveReplyLedger();
276
306
  #activeGate = new QqActiveMessageGate();
277
307
  #lifecycle;
278
308
  #cleanup;
279
- management = createQqEndpointManagement(() => this.#requireBot());
280
- control = createRecallEndpointControl((id) => this.recallMessage(id));
309
+ management = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#bot !== null);
310
+ control = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
281
311
  constructor(options) {
282
312
  super();
283
313
  this.#logger = getAdapterLogger('qq', options.config.id);
@@ -345,6 +375,7 @@ export class QqHttpEndpoint extends Endpoint {
345
375
  await stopping;
346
376
  return;
347
377
  }
378
+ this.#offlineNoticeAt = 0;
348
379
  this.#logger.info(`connected (${this.#options.config.mode}) | path: ${this.#options.config.webhookPath}`);
349
380
  }
350
381
  catch (error) {
@@ -352,6 +383,7 @@ export class QqHttpEndpoint extends Endpoint {
352
383
  await cleanup();
353
384
  if (wasCancelled)
354
385
  return;
386
+ this.#notifyOffline('start_failed');
355
387
  throw error;
356
388
  }
357
389
  });
@@ -479,6 +511,26 @@ export class QqHttpEndpoint extends Endpoint {
479
511
  }).catch(() => this.#logger.warn('qq_interaction_receive_failed'));
480
512
  return true;
481
513
  }
514
+ #notifyOffline(reason) {
515
+ const notifier = this.#options.notifier;
516
+ if (!notifier?.configured)
517
+ return;
518
+ const now = Date.now();
519
+ if (now - this.#offlineNoticeAt < 60_000)
520
+ return;
521
+ this.#offlineNoticeAt = now;
522
+ const text = `[Zhin] QQ 机器人 ${this.#options.config.id} 掉线(${reason})。正在自动重连。\n`
523
+ + '恢复:在 QQ 回复 `qq endpoint add` 重新扫码绑定,或打开 Console 的 QQ 页面。';
524
+ void notifier.send(text).then((sent) => {
525
+ if (sent)
526
+ return;
527
+ this.#logger.warn(formatCompact({
528
+ op: 'qq_notify_failed',
529
+ endpoint: this.#options.config.id,
530
+ reason,
531
+ }));
532
+ });
533
+ }
482
534
  #bindBot(bot) {
483
535
  bindQqBotInboundEvents(bot, (raw) => {
484
536
  if (this.#bot !== bot)
@@ -512,8 +564,23 @@ export class QqHttpEndpoint extends Endpoint {
512
564
  return this.#bot;
513
565
  }
514
566
  }
515
- function createQqEndpointManagement(requireClient) {
567
+ function createQqEndpointManagement(requireClient, isActive) {
568
+ const api = () => requireClient().api;
516
569
  return Object.freeze({
570
+ // QQ 群(group_openid / member_openid):官方 v2 群管理接口,部分为白名单能力(11253)。
571
+ inspectGroupMember: (group, user, signal) => inspectQqGroupMember(api(), group, user, signal),
572
+ inspectGroupScene: (group, signal) => inspectQqGroupScene(api(), group, signal),
573
+ inspectGroupMemberProfile: (group, user, signal) => inspectQqGroupMemberProfile(api(), group, user, signal),
574
+ moderateGroupMember: (group, user, action, signal) => moderateQqGroupMember(api(), group, user, action, signal, isActive),
575
+ async listGroupMembers(groupId) {
576
+ return qqGroupService(api()).getMembers(groupId);
577
+ },
578
+ async kickGroupMember(groupId, userId) {
579
+ await kickQqGroupMember(api(), groupId, userId);
580
+ },
581
+ async muteGroupMember(groupId, userId, durationSeconds) {
582
+ await muteQqGroupMember(api(), groupId, userId, durationSeconds);
583
+ },
517
584
  async listChannels() {
518
585
  const channels = [];
519
586
  const client = requireClient();
@@ -546,6 +613,65 @@ function asRecord(value) {
546
613
  ? value
547
614
  : {};
548
615
  }
616
+ /**
617
+ * Recall + 频道表情回应。recall 同时接受 send() 的复合 ID 与入站原生消息 ID(目标取自 MessageRef.conversation),
618
+ * 因此群管理员机器人可撤回普通成员 2 分钟内的消息(官方限制由平台执行)。
619
+ * 表情回应只有频道子频道消息支持(官方无群 / 单聊表情回应接口)。
620
+ */
621
+ function createQqEndpointControl(requireBot, recallById) {
622
+ return Object.freeze({
623
+ recall: async (message) => {
624
+ if (isCompoundMessageId(message.id))
625
+ return recallById(message.id);
626
+ const kind = qqOutboundKind(message.conversation);
627
+ await recallQqMessage(requireBot(), kind, message.conversation.id, message.id);
628
+ },
629
+ addReaction: async (message, emoji) => {
630
+ const target = qqGuildReactionTarget(message, emoji);
631
+ if (!target)
632
+ return null;
633
+ await requireBot().api.reactionService.addGuildMessageReaction(target.channelId, target.messageId, target.type, target.id);
634
+ return `${target.type}:${target.id}`;
635
+ },
636
+ removeReaction: async (message, reactionId) => {
637
+ const target = qqGuildReactionTarget(message, reactionId);
638
+ if (!target)
639
+ return;
640
+ await requireBot().api.reactionService.deleteGuildMessageReaction(target.channelId, target.messageId, target.type, target.id);
641
+ },
642
+ });
643
+ }
644
+ function isCompoundMessageId(id) {
645
+ try {
646
+ parseCompoundMessageId(id);
647
+ return true;
648
+ }
649
+ catch {
650
+ return false;
651
+ }
652
+ }
653
+ /** `type:id`(1 系统表情 / 2 emoji)或单个 Unicode emoji(→ type 2 + 码点)。 */
654
+ export function parseQqEmoji(value) {
655
+ const explicit = /^([12]):(\d+)$/.exec(value.trim());
656
+ if (explicit)
657
+ return { type: Number(explicit[1]), id: explicit[2] };
658
+ const chars = [...value.trim().replace(/\uFE0F/g, '')];
659
+ if (chars.length !== 1)
660
+ return null;
661
+ const code = chars[0].codePointAt(0);
662
+ return code > 0x7f ? { type: 2, id: String(code) } : null;
663
+ }
664
+ function qqGuildReactionTarget(message, emoji) {
665
+ let kind = qqOutboundKind(message.conversation);
666
+ let channelId = message.conversation.id;
667
+ let messageId = message.id;
668
+ if (isCompoundMessageId(message.id))
669
+ ({ kind, channelId, qqMsgId: messageId } = parseCompoundMessageId(message.id));
670
+ if (kind !== 'channel' || !channelId || !messageId)
671
+ return null;
672
+ const parsed = parseQqEmoji(emoji);
673
+ return parsed ? { channelId, messageId, ...parsed } : null;
674
+ }
549
675
  async function recallQqMessage(bot, kind, channelId, messageId) {
550
676
  const recall = { private: bot.recallPrivateMessage, group: bot.recallGroupMessage,
551
677
  channel: bot.recallGuildMessage, direct: bot.recallDirectMessage }[kind];
@@ -0,0 +1,36 @@
1
+ /**
2
+ * QQ 群(group_openid / member_openid)管理:只走官方 v2 群管理接口。
3
+ *
4
+ * 官方限制(https://bot.q.qq.com/wiki/develop/api-v2/autogen/):
5
+ * - 成员列表 / 成员信息 / 批量移除 / 黑名单 / 入群申请多为白名单能力,无权限返回 11253;
6
+ * - 禁言与入群审批要求机器人是群管理员;禁言最长 30 天,只能禁言普通成员;
7
+ * - 没有「机器人所在群列表」「好友列表」「退群」「设管理员」「群公告」等接口。
8
+ */
9
+ import type { GroupBotState, GroupMemberInfo } from 'qq-official-bot';
10
+ import type { GroupMemberProfile, GroupModerationAction, GroupModerationMember, GroupSceneFacts } from 'zhin.js/adapter';
11
+ import type { QqOfficialApi } from './ws.js';
12
+ /** 官方 restrict_chat_setting 上限:30 天。 */
13
+ export declare const QQ_GROUP_MUTE_MAX_SECONDS: number;
14
+ export type QqGroupService = QqOfficialApi['groupService'];
15
+ export declare function qqGroupService(api: Partial<QqOfficialApi> | undefined): QqGroupService;
16
+ export declare function isQqGroupManagerRole(role: unknown): role is 'owner' | 'admin';
17
+ /** RFC3339 → epoch ms;非法或未来时间返回 undefined(从不猜测)。 */
18
+ export declare function parseQqTime(value: unknown, { allowFuture }?: {
19
+ allowFuture?: boolean | undefined;
20
+ }): number | undefined;
21
+ /** 只读、实时:机器人在群里的状态(角色、是否允许主动消息、接收设置)。 */
22
+ export declare function qqGroupBotState(api: Partial<QqOfficialApi> | undefined, groupId: string): Promise<GroupBotState>;
23
+ export declare function qqGroupMember(api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string): Promise<GroupMemberInfo>;
24
+ /** 禁言 / 解除禁言一个普通成员;0 = 解除。 */
25
+ export declare function muteQqGroupMember(api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string, durationSeconds: number): Promise<void>;
26
+ /** 移出一个成员;可选同时加入群黑名单。平台未确认 success 时抛错,不把未确认当成功。 */
27
+ export declare function kickQqGroupMember(api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string, addToBlacklist?: boolean): Promise<{
28
+ blacklistFailed: boolean;
29
+ }>;
30
+ /** 自动审核用:机器人与目标成员的实时角色;平台只允许管理员处置普通成员(且非机器人)。 */
31
+ export declare function inspectQqGroupMember(api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string, signal: AbortSignal): Promise<GroupModerationMember>;
32
+ export declare function moderateQqGroupMember(api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string, action: GroupModerationAction, signal: AbortSignal, isActive: () => boolean): Promise<void>;
33
+ /** ADR 0041 场景事实:一次群信息读取;官方群信息不含群主/管理员/全员禁言,留作 unknown。 */
34
+ export declare function inspectQqGroupScene(api: Partial<QqOfficialApi> | undefined, groupId: string, signal: AbortSignal): Promise<GroupSceneFacts>;
35
+ /** ADR 0041 成员事实:一次成员读取(群名片/头衔/禁言到期官方不提供)。 */
36
+ export declare function inspectQqGroupMemberProfile(api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string, signal: AbortSignal): Promise<GroupMemberProfile>;
@@ -0,0 +1,113 @@
1
+ /** 官方 restrict_chat_setting 上限:30 天。 */
2
+ export const QQ_GROUP_MUTE_MAX_SECONDS = 30 * 24 * 60 * 60;
3
+ export function qqGroupService(api) {
4
+ const service = api?.groupService;
5
+ if (!service)
6
+ throw new Error('QQ group management API is unavailable on this transport');
7
+ return service;
8
+ }
9
+ export function isQqGroupManagerRole(role) {
10
+ return role === 'owner' || role === 'admin';
11
+ }
12
+ function platformRole(role) {
13
+ return role === 'owner' || role === 'admin' || role === 'member' ? role : undefined;
14
+ }
15
+ function requireId(value, what) {
16
+ const id = typeof value === 'string' ? value.trim() : '';
17
+ if (!id || /[\s/?#]/.test(id))
18
+ throw new Error(`Invalid QQ ${what}`);
19
+ return id;
20
+ }
21
+ /** RFC3339 → epoch ms;非法或未来时间返回 undefined(从不猜测)。 */
22
+ export function parseQqTime(value, { allowFuture = false } = {}) {
23
+ if (typeof value !== 'string' || !value)
24
+ return undefined;
25
+ const at = Date.parse(value);
26
+ if (!Number.isFinite(at) || at <= 0)
27
+ return undefined;
28
+ if (!allowFuture && at > Date.now())
29
+ return undefined;
30
+ return at;
31
+ }
32
+ /** 只读、实时:机器人在群里的状态(角色、是否允许主动消息、接收设置)。 */
33
+ export async function qqGroupBotState(api, groupId) {
34
+ return qqGroupService(api).getBotState(requireId(groupId, 'group id'));
35
+ }
36
+ export async function qqGroupMember(api, groupId, userId) {
37
+ return qqGroupService(api).getMemberInfo(requireId(groupId, 'group id'), requireId(userId, 'member id'));
38
+ }
39
+ /** 禁言 / 解除禁言一个普通成员;0 = 解除。 */
40
+ export async function muteQqGroupMember(api, groupId, userId, durationSeconds) {
41
+ if (!Number.isSafeInteger(durationSeconds) || durationSeconds < 0 || durationSeconds > QQ_GROUP_MUTE_MAX_SECONDS) {
42
+ throw new RangeError(`QQ group mute duration must be 0..${QQ_GROUP_MUTE_MAX_SECONDS} seconds`);
43
+ }
44
+ const member = requireId(userId, 'member id');
45
+ await qqGroupService(api).setMemberMute(requireId(groupId, 'group id'), [durationSeconds === 0
46
+ ? { op: 'del', member_openid: member, mute_expire_at: '' }
47
+ : { op: 'add', member_openid: member, mute_expire_at: new Date(Date.now() + durationSeconds * 1000).toISOString() }]);
48
+ }
49
+ /** 移出一个成员;可选同时加入群黑名单。平台未确认 success 时抛错,不把未确认当成功。 */
50
+ export async function kickQqGroupMember(api, groupId, userId, addToBlacklist = false) {
51
+ const member = requireId(userId, 'member id');
52
+ const result = await qqGroupService(api).removeMembers(requireId(groupId, 'group id'), {
53
+ member_openids: [member],
54
+ ...(addToBlacklist ? { add_to_member_blacklist: true } : {}),
55
+ });
56
+ if (result?.remove_members_result !== 'success')
57
+ throw new Error('QQ did not confirm the member removal');
58
+ return { blacklistFailed: addToBlacklist && (result.add_to_member_blacklist_fail_openids ?? []).includes(member) };
59
+ }
60
+ /** 自动审核用:机器人与目标成员的实时角色;平台只允许管理员处置普通成员(且非机器人)。 */
61
+ export async function inspectQqGroupMember(api, groupId, userId, signal) {
62
+ signal.throwIfAborted();
63
+ const [self, member] = await Promise.all([qqGroupBotState(api, groupId), qqGroupMember(api, groupId, userId)]);
64
+ signal.throwIfAborted();
65
+ const role = member.member_openid === userId ? platformRole(member.member_role) : undefined;
66
+ const joinedAt = parseQqTime(member.joined_at);
67
+ return {
68
+ canModerate: isQqGroupManagerRole(self.member_role) && self.member_openid !== userId
69
+ && member.member_openid === userId && role === 'member' && member.bot !== true,
70
+ ...(role ? { platformRole: role } : {}),
71
+ ...(joinedAt ? { joinedAt } : {}),
72
+ };
73
+ }
74
+ export async function moderateQqGroupMember(api, groupId, userId, action, signal, isActive) {
75
+ const member = await inspectQqGroupMember(api, groupId, userId, signal);
76
+ signal.throwIfAborted();
77
+ if (!isActive() || !member.canModerate)
78
+ throw new Error('Group moderation permission is unavailable');
79
+ if (action.type === 'kick') {
80
+ if (!Number.isSafeInteger(action.maxMembershipAgeMs) || action.maxMembershipAgeMs <= 0
81
+ || !member.joinedAt || member.joinedAt !== action.joinedAt
82
+ || Date.now() - member.joinedAt > action.maxMembershipAgeMs) {
83
+ throw new Error('Group membership no longer matches the approved newcomer window');
84
+ }
85
+ await kickQqGroupMember(api, groupId, userId);
86
+ return;
87
+ }
88
+ if (!Number.isSafeInteger(action.durationSeconds) || action.durationSeconds <= 0) {
89
+ throw new Error('Invalid group mute duration');
90
+ }
91
+ await muteQqGroupMember(api, groupId, userId, Math.min(action.durationSeconds, QQ_GROUP_MUTE_MAX_SECONDS));
92
+ }
93
+ /** ADR 0041 场景事实:一次群信息读取;官方群信息不含群主/管理员/全员禁言,留作 unknown。 */
94
+ export async function inspectQqGroupScene(api, groupId, signal) {
95
+ signal.throwIfAborted();
96
+ const info = await qqGroupService(api).getInfo(requireId(groupId, 'group id'));
97
+ signal.throwIfAborted();
98
+ if (info?.group_openid && info.group_openid !== groupId)
99
+ return {};
100
+ const name = typeof info?.group_name === 'string' && info.group_name.trim() ? info.group_name.trim() : undefined;
101
+ const memberCount = Number.isSafeInteger(info?.group_member_num) && info.group_member_num > 0 ? info.group_member_num : undefined;
102
+ return { ...(name ? { name } : {}), ...(memberCount ? { memberCount } : {}) };
103
+ }
104
+ /** ADR 0041 成员事实:一次成员读取(群名片/头衔/禁言到期官方不提供)。 */
105
+ export async function inspectQqGroupMemberProfile(api, groupId, userId, signal) {
106
+ signal.throwIfAborted();
107
+ const member = await qqGroupMember(api, groupId, userId);
108
+ signal.throwIfAborted();
109
+ if (member.member_openid !== userId)
110
+ return {};
111
+ const role = platformRole(member.member_role);
112
+ return role ? { platformRole: role } : {};
113
+ }
package/lib/protocol.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** QQ Official Bot protocol helpers. Canonicalization is owned by Core. */
2
2
  import { type ConversationRef } from '@zhin.js/im-contract';
3
+ import { type AdapterNotifyTarget, type ResolvedAdapterNotifyTarget } from 'zhin.js/adapter';
3
4
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
4
5
  export interface QqEndpointConfig {
5
6
  readonly id: string;
@@ -16,6 +17,8 @@ export interface QqEndpointConfig {
16
17
  readonly gatewayUrl?: string;
17
18
  readonly streamProxy?: import('./stream-proxy.js').QqStreamProxy;
18
19
  readonly webhookPath?: string;
20
+ /** Optional out-of-band outage notification target (usually an ICQQ instance). */
21
+ readonly notify?: AdapterNotifyTarget;
19
22
  }
20
23
  export interface ResolvedQqWebsocketConfig {
21
24
  readonly context: 'qq';
@@ -28,6 +31,7 @@ export interface ResolvedQqWebsocketConfig {
28
31
  readonly accessTokenUrl?: string;
29
32
  readonly gatewayUrl?: string;
30
33
  readonly streamProxy?: import('./stream-proxy.js').QqStreamProxy;
34
+ readonly notify?: ResolvedAdapterNotifyTarget;
31
35
  }
32
36
  export interface ResolvedQqHttpConfig {
33
37
  readonly context: 'qq';
@@ -37,6 +41,7 @@ export interface ResolvedQqHttpConfig {
37
41
  readonly secret: string;
38
42
  readonly webhookPath: string;
39
43
  readonly sandbox: boolean;
44
+ readonly notify?: ResolvedAdapterNotifyTarget;
40
45
  }
41
46
  export type ResolvedQqConfig = ResolvedQqWebsocketConfig | ResolvedQqHttpConfig;
42
47
  export type QqChannelKind = 'private' | 'group' | 'channel' | 'direct';
package/lib/protocol.js CHANGED
@@ -2,12 +2,14 @@
2
2
  import { EndpointDeliveryError } from '@zhin.js/im-contract';
3
3
  import { formatCompact, getLogger } from '@zhin.js/logger';
4
4
  import { resolveQqIntents } from './qq-intents.js';
5
+ import { resolveAdapterNotifyTarget } from 'zhin.js/adapter';
5
6
  const logger = getLogger('qq');
6
7
  export function resolveQqConfig(config) {
7
8
  const id = requiredEndpointField(config.id, 'id');
8
9
  const appid = requiredEndpointField(config.appid, 'appid');
9
10
  const secret = requiredEndpointField(config.secret, 'secret');
10
11
  const mode = config.mode ?? 'websocket';
12
+ const notify = resolveAdapterNotifyTarget(config.notify);
11
13
  if (mode === 'webhook' || mode === 'middleware') {
12
14
  const webhookPathRaw = config.webhookPath ?? '/qq/webhook';
13
15
  const webhookPath = webhookPathRaw.startsWith('/') ? webhookPathRaw : `/${webhookPathRaw}`;
@@ -19,6 +21,7 @@ export function resolveQqConfig(config) {
19
21
  secret,
20
22
  webhookPath,
21
23
  sandbox: config.sandbox === true,
24
+ ...(notify ? { notify } : {}),
22
25
  };
23
26
  }
24
27
  if (config.streamProxy && (!Number.isInteger(config.streamProxy.port) || config.streamProxy.port < 1 || config.streamProxy.port > 65535
@@ -38,6 +41,7 @@ export function resolveQqConfig(config) {
38
41
  accessTokenUrl: config.accessTokenUrl,
39
42
  gatewayUrl: config.gatewayUrl,
40
43
  ...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
44
+ ...(notify ? { notify } : {}),
41
45
  };
42
46
  }
43
47
  function requiredEndpointField(value, field) {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * QQ Console 页面元数据(浏览器与服务端共享;不要在这里 import 服务端代码)。
3
+ *
4
+ * QQ 的 endpoint 不走通用「字段表单」:`qq endpoint add` 是扫码绑定流程,
5
+ * 因此 `canAdd: false`,改由 `qq-console.ts` 的 `qq-bind-*` 动作完成。
6
+ */
7
+ import type { AdapterConsoleField } from 'zhin.js/adapter';
8
+ export declare const qqConsoleSpec: {
9
+ readonly adapterKey: "qq";
10
+ readonly adapterDisplayName: "QQ";
11
+ readonly fields: readonly AdapterConsoleField[];
12
+ readonly canAdd: false;
13
+ readonly canRemove: true;
14
+ readonly docsUrl: "https://zhin.js.org/adapters/qq";
15
+ readonly readPermission: "qq:read";
16
+ readonly writePermission: "qq:write";
17
+ };
@@ -0,0 +1,10 @@
1
+ export const qqConsoleSpec = {
2
+ adapterKey: 'qq',
3
+ adapterDisplayName: 'QQ',
4
+ fields: [],
5
+ canAdd: false,
6
+ canRemove: true,
7
+ docsUrl: 'https://zhin.js.org/adapters/qq',
8
+ readPermission: 'qq:read',
9
+ writePermission: 'qq:write',
10
+ };
@@ -0,0 +1,11 @@
1
+ import { type QqEndpointWriteResult } from './qq-endpoint-write.js';
2
+ import type { QqRuntimeState } from './qq-runtime-state.js';
3
+ import type { EndpointConfigurationStore } from 'zhin.js/adapter';
4
+ /** 启动 Console 扫码;resolve 时二维码链接已就绪。 */
5
+ export declare function startQqConsoleBind(state: QqRuntimeState, id?: string): Promise<{
6
+ readonly url: string;
7
+ }>;
8
+ /** 扫码成功后选择公域 / 私域并写回配置。 */
9
+ export declare function completeQqConsoleBind(state: QqRuntimeState, store: EndpointConfigurationStore, kindValue: unknown): Promise<QqEndpointWriteResult>;
10
+ /** 取消 Console 绑定(也可用于收拾聊天命令发起的流程)。 */
11
+ export declare function cancelQqConsoleBind(state: QqRuntimeState): boolean;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * QQ Console 扫码绑定:把原本只能在聊天里执行的 `qq endpoint add` 流程
3
+ * 搬到 Console 页面。
4
+ *
5
+ * - `start` 返回扫码链接;后续状态写入 `state.consoleBind`,页面轮询 status。
6
+ * - 扫码成功后凭据只留在内存,等页面选择公域 / 私域再一次性写 `.env` + 配置。
7
+ * - 与聊天命令共享 `state.bindFlow` 单例,同一时间只允许一个绑定流程。
8
+ */
9
+ import { PluginConsoleRpcError } from 'zhin.js';
10
+ import { startQqBindFlow } from './qq-bind-flow.js';
11
+ import { writeQqEndpointWithBotKind } from './qq-endpoint-write.js';
12
+ import { parseQqBotKind } from './qq-intents.js';
13
+ /** 启动 Console 扫码;resolve 时二维码链接已就绪。 */
14
+ export function startQqConsoleBind(state, id) {
15
+ if (state.bindFlow || state.pendingBotKind) {
16
+ throw new PluginConsoleRpcError('conflict', '已有扫码绑定流程在进行,请先完成或取消');
17
+ }
18
+ if (state.consoleBind?.pending) {
19
+ throw new PluginConsoleRpcError('conflict', '已经扫码成功,请先选择公域或私域');
20
+ }
21
+ const endpointId = id?.trim() || undefined;
22
+ return new Promise((resolve, reject) => {
23
+ let settled = false;
24
+ const stop = startQqBindFlow({
25
+ onQrDisplayed: (url) => {
26
+ state.consoleBind = {
27
+ state: 'waiting',
28
+ url,
29
+ message: '请用手机 QQ 扫码',
30
+ startedAt: state.consoleBind?.startedAt ?? Date.now(),
31
+ };
32
+ if (settled)
33
+ return;
34
+ settled = true;
35
+ resolve({ url });
36
+ },
37
+ onQrExpired: () => {
38
+ state.consoleBind = {
39
+ state: 'waiting',
40
+ ...(state.consoleBind?.url ? { url: state.consoleBind.url } : {}),
41
+ message: '二维码已过期,正在刷新…',
42
+ startedAt: state.consoleBind?.startedAt ?? Date.now(),
43
+ };
44
+ },
45
+ onSuccess: (credentials) => {
46
+ state.bindFlow = null;
47
+ const [first] = credentials;
48
+ if (!first) {
49
+ state.consoleBind = {
50
+ state: 'failed',
51
+ error: '扫码结果为空,请重新扫码',
52
+ startedAt: state.consoleBind?.startedAt ?? Date.now(),
53
+ };
54
+ return;
55
+ }
56
+ state.consoleBind = {
57
+ state: 'scanned',
58
+ ...(state.consoleBind?.url ? { url: state.consoleBind.url } : {}),
59
+ message: '已扫码,请选择公域或私域',
60
+ startedAt: state.consoleBind?.startedAt ?? Date.now(),
61
+ pending: {
62
+ endpointId: endpointId ?? first.appId,
63
+ appId: first.appId,
64
+ appSecret: first.appSecret,
65
+ },
66
+ };
67
+ },
68
+ onFailure: (error) => {
69
+ state.bindFlow = null;
70
+ state.consoleBind = {
71
+ state: 'failed',
72
+ error: error.message,
73
+ startedAt: state.consoleBind?.startedAt ?? Date.now(),
74
+ };
75
+ if (settled)
76
+ return;
77
+ settled = true;
78
+ reject(new PluginConsoleRpcError('unavailable', error.message));
79
+ },
80
+ }, { source: 'zhin' });
81
+ state.bindFlow = { id: endpointId, stop, locale: 'zh-CN' };
82
+ });
83
+ }
84
+ /** 扫码成功后选择公域 / 私域并写回配置。 */
85
+ export async function completeQqConsoleBind(state, store, kindValue) {
86
+ const pending = state.consoleBind?.pending;
87
+ if (!pending) {
88
+ throw new PluginConsoleRpcError('conflict', '还没有扫码成功,请先扫码绑定');
89
+ }
90
+ const botKind = parseQqBotKind(kindValue);
91
+ if (!botKind) {
92
+ throw new PluginConsoleRpcError('invalid_input', '请选择公域(public)或私域(private)');
93
+ }
94
+ try {
95
+ const result = await writeQqEndpointWithBotKind({ ...pending, botKind }, store);
96
+ state.consoleBind = null;
97
+ return result;
98
+ }
99
+ catch {
100
+ throw new PluginConsoleRpcError('unavailable', '写入配置失败,请检查配置文件和 .env 的权限');
101
+ }
102
+ }
103
+ /** 取消 Console 绑定(也可用于收拾聊天命令发起的流程)。 */
104
+ export function cancelQqConsoleBind(state) {
105
+ const active = Boolean(state.bindFlow || state.pendingBotKind || state.consoleBind);
106
+ try {
107
+ state.bindFlow?.stop();
108
+ }
109
+ catch {
110
+ // best effort
111
+ }
112
+ state.bindFlow = null;
113
+ state.pendingBotKind = null;
114
+ state.consoleBind = null;
115
+ return active;
116
+ }