@zhin.js/adapter-qq 1.1.17 → 1.1.19

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 (122) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +75 -21
  3. package/adapters/qq/index.js +4 -33
  4. package/adapters/qq/index.ts +3 -41
  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/add_role/index.js +2 -1
  12. package/agents/qq/skills/qq/tools/add_role/index.ts +2 -1
  13. package/agents/qq/skills/qq/tools/channel_info/index.js +1 -1
  14. package/agents/qq/skills/qq/tools/channel_info/index.ts +1 -1
  15. package/agents/qq/skills/qq/tools/create_role/index.js +1 -1
  16. package/agents/qq/skills/qq/tools/create_role/index.ts +1 -1
  17. package/agents/qq/skills/qq/tools/guild_kick_member/index.js +30 -0
  18. package/agents/qq/skills/qq/tools/guild_kick_member/index.ts +29 -0
  19. package/agents/qq/skills/qq/tools/guild_mute_member/index.js +31 -0
  20. package/agents/qq/skills/qq/tools/guild_mute_member/index.ts +30 -0
  21. package/agents/qq/skills/qq/tools/list_channels/index.js +1 -1
  22. package/agents/qq/skills/qq/tools/list_channels/index.ts +1 -1
  23. package/agents/qq/skills/qq/tools/list_roles/index.js +1 -1
  24. package/agents/qq/skills/qq/tools/list_roles/index.ts +1 -1
  25. package/agents/qq/skills/qq/tools/member_detail/index.js +1 -1
  26. package/agents/qq/skills/qq/tools/member_detail/index.ts +1 -1
  27. package/agents/qq/skills/qq/tools/remove_role/index.js +2 -1
  28. package/agents/qq/skills/qq/tools/remove_role/index.ts +2 -1
  29. package/agents/qq/skills/qq-conversation/SKILL.md +24 -0
  30. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.js +78 -0
  31. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.ts +76 -0
  32. package/agents/qq/skills/qq-directory/tools/list_guilds/index.js +1 -1
  33. package/agents/qq/skills/qq-directory/tools/list_guilds/index.ts +1 -1
  34. package/agents/qq/skills/qq-group-admin/SKILL.md +34 -0
  35. package/agents/qq/skills/qq-group-admin/tools/group_kick/index.js +39 -0
  36. package/agents/qq/skills/qq-group-admin/tools/group_kick/index.ts +38 -0
  37. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.js +33 -0
  38. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.ts +32 -0
  39. package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.js +25 -0
  40. package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.ts +25 -0
  41. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.js +37 -0
  42. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.ts +36 -0
  43. package/agents/qq/skills/qq-group-directory/SKILL.md +24 -0
  44. package/agents/qq/skills/qq-group-directory/tools/group_info/index.js +16 -0
  45. package/agents/qq/skills/qq-group-directory/tools/group_info/index.ts +16 -0
  46. package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.js +19 -0
  47. package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.ts +19 -0
  48. package/agents/qq/skills/qq-group-directory/tools/group_members/index.js +19 -0
  49. package/agents/qq/skills/qq-group-directory/tools/group_members/index.ts +19 -0
  50. package/agents/qq/skills/qq-group-state/SKILL.md +21 -0
  51. package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.js +19 -0
  52. package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.ts +19 -0
  53. package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.js +19 -0
  54. package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.ts +19 -0
  55. package/agents/qq/skills/qq-group-state/tools/list_muted/index.js +21 -0
  56. package/agents/qq/skills/qq-group-state/tools/list_muted/index.ts +21 -0
  57. package/agents/qq/skills/qq-messages/SKILL.md +27 -0
  58. package/agents/qq/skills/qq-messages/tools/quote_reply/index.js +21 -0
  59. package/agents/qq/skills/qq-messages/tools/quote_reply/index.ts +21 -0
  60. package/agents/qq/skills/qq-messages/tools/recall_own_message/index.js +18 -0
  61. package/agents/qq/skills/qq-messages/tools/recall_own_message/index.ts +17 -0
  62. package/commands/qq/endpoint/definition.js +73 -55
  63. package/commands/qq/endpoint/definition.ts +75 -86
  64. package/lib/adapter.d.ts +13 -0
  65. package/lib/adapter.js +27 -0
  66. package/lib/client.d.ts +7 -9
  67. package/lib/client.js +1 -2
  68. package/lib/console.d.ts +3 -0
  69. package/lib/console.js +93 -0
  70. package/lib/endpoint.d.ts +17 -27
  71. package/lib/endpoint.js +325 -146
  72. package/lib/group-admin.d.ts +41 -0
  73. package/lib/group-admin.js +114 -0
  74. package/lib/i18n.js +4 -4
  75. package/lib/image-size.d.ts +6 -1
  76. package/lib/image-size.js +2 -3
  77. package/lib/index.d.ts +5 -4
  78. package/lib/index.js +3 -3
  79. package/lib/interaction.js +1 -1
  80. package/lib/outbound.d.ts +4 -1
  81. package/lib/outbound.js +3 -2
  82. package/lib/protocol.d.ts +7 -0
  83. package/lib/protocol.js +4 -0
  84. package/lib/qq-console-spec.d.ts +17 -0
  85. package/lib/qq-console-spec.js +21 -0
  86. package/lib/qq-console.d.ts +10 -0
  87. package/lib/qq-console.js +126 -0
  88. package/lib/qq-endpoint-write.d.ts +18 -0
  89. package/lib/qq-endpoint-write.js +7 -0
  90. package/lib/qq-runtime-state.d.ts +29 -9
  91. package/lib/qq-runtime-state.js +5 -5
  92. package/lib/qq-send.d.ts +7 -14
  93. package/lib/qq-send.js +20 -6
  94. package/lib/webhook.d.ts +3 -6
  95. package/lib/webhook.js +6 -49
  96. package/lib/ws.d.ts +10 -33
  97. package/lib/ws.js +21 -70
  98. package/middlewares/bot-kind-reply/index.js +1 -2
  99. package/middlewares/bot-kind-reply/index.ts +0 -2
  100. package/package.json +26 -18
  101. package/pages/index/index.js +15 -0
  102. package/pages/index/index.ts +16 -0
  103. package/plugin.js +9 -6
  104. package/schema.json +174 -0
  105. package/src/adapter.ts +23 -0
  106. package/src/client.ts +4 -12
  107. package/src/console.ts +103 -0
  108. package/src/endpoint.ts +328 -141
  109. package/src/group-admin.ts +155 -0
  110. package/src/i18n.ts +4 -4
  111. package/src/image-size.ts +7 -3
  112. package/src/index.ts +5 -5
  113. package/src/interaction.ts +1 -1
  114. package/src/outbound.ts +3 -2
  115. package/src/protocol.ts +10 -0
  116. package/src/qq-console-spec.ts +29 -0
  117. package/src/qq-console.ts +130 -0
  118. package/src/qq-endpoint-write.ts +24 -0
  119. package/src/qq-runtime-state.ts +31 -15
  120. package/src/qq-send.ts +28 -12
  121. package/src/webhook.ts +7 -57
  122. package/src/ws.ts +27 -105
@@ -0,0 +1,155 @@
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 {
10
+ GroupBotState,
11
+ GroupMemberInfo,
12
+ GroupMemberRole,
13
+ } from 'qq-official-bot';
14
+ import {
15
+ createEndpointOperation,
16
+ type EndpointGroupRequest, type EndpointMemberRequest, type EndpointMuteRequest,
17
+ type EndpointGroupInspectionRequest, type EndpointMemberInspectionRequest, type EndpointModerationRequest,
18
+ type GroupMemberProfile,
19
+ type GroupModerationMember,
20
+ type GroupSceneFacts,
21
+ } from 'zhin.js/adapter';
22
+ import type { QqOfficialApi } from './ws.js';
23
+
24
+ /** 官方 restrict_chat_setting 上限:30 天。 */
25
+ export const QQ_GROUP_MUTE_MAX_SECONDS = 30 * 24 * 60 * 60;
26
+
27
+ export type QqGroupService = QqOfficialApi['groupService'];
28
+
29
+ export function qqGroupService(api: Partial<QqOfficialApi> | undefined): QqGroupService {
30
+ const service = api?.groupService;
31
+ if (!service) throw new Error('QQ group management API is unavailable on this transport');
32
+ return service;
33
+ }
34
+
35
+ export function isQqGroupManagerRole(role: unknown): role is 'owner' | 'admin' {
36
+ return role === 'owner' || role === 'admin';
37
+ }
38
+
39
+ function platformRole(role: unknown): GroupMemberRole | undefined {
40
+ return role === 'owner' || role === 'admin' || role === 'member' ? role : undefined;
41
+ }
42
+
43
+ function requireId(value: string, what: string): string {
44
+ const id = typeof value === 'string' ? value.trim() : '';
45
+ if (!id || /[\s/?#]/.test(id)) throw new Error(`Invalid QQ ${what}`);
46
+ return id;
47
+ }
48
+
49
+ /** RFC3339 → epoch ms;非法或未来时间返回 undefined(从不猜测)。 */
50
+ export function parseQqTime(value: unknown, { allowFuture = false } = {}): number | undefined {
51
+ if (typeof value !== 'string' || !value) return undefined;
52
+ const at = Date.parse(value);
53
+ if (!Number.isFinite(at) || at <= 0) return undefined;
54
+ if (!allowFuture && at > Date.now()) return undefined;
55
+ return at;
56
+ }
57
+
58
+ /** 只读、实时:机器人在群里的状态(角色、是否允许主动消息、接收设置)。 */
59
+ export async function qqGroupBotState(api: Partial<QqOfficialApi> | undefined, { groupId, signal }: EndpointGroupRequest): Promise<GroupBotState> {
60
+ return createEndpointOperation({ signal }).run(() => qqGroupService(api).getBotState(requireId(groupId, 'group id')));
61
+ }
62
+
63
+ export async function qqGroupMember(api: Partial<QqOfficialApi> | undefined, { groupId, userId, signal }: EndpointMemberRequest): Promise<GroupMemberInfo> {
64
+ return createEndpointOperation({ signal }).run(() => qqGroupService(api).getMemberInfo(requireId(groupId, 'group id'), requireId(userId, 'member id')));
65
+ }
66
+
67
+ /** 禁言 / 解除禁言一个普通成员;0 = 解除。 */
68
+ export async function muteQqGroupMember(
69
+ api: Partial<QqOfficialApi> | undefined, { groupId, userId, durationSeconds, signal }: EndpointMuteRequest,
70
+ ): Promise<void> {
71
+ if (!Number.isSafeInteger(durationSeconds) || durationSeconds < 0 || durationSeconds > QQ_GROUP_MUTE_MAX_SECONDS) {
72
+ throw new RangeError(`QQ group mute duration must be 0..${QQ_GROUP_MUTE_MAX_SECONDS} seconds`);
73
+ }
74
+ const member = requireId(userId, 'member id');
75
+ await createEndpointOperation({ signal }).run(() => qqGroupService(api).setMemberMute(requireId(groupId, 'group id'), [durationSeconds === 0
76
+ ? { op: 'del', member_openid: member, mute_expire_at: '' }
77
+ : { op: 'add', member_openid: member, mute_expire_at: new Date(Date.now() + durationSeconds * 1000).toISOString() }]));
78
+ }
79
+
80
+ /** 移出一个成员;可选同时加入群黑名单。平台未确认 success 时抛错,不把未确认当成功。 */
81
+ export async function kickQqGroupMember(
82
+ api: Partial<QqOfficialApi> | undefined, { groupId, userId, addToBlacklist = false, signal }: EndpointMemberRequest & { readonly addToBlacklist?: boolean },
83
+ ): Promise<{ blacklistFailed: boolean }> {
84
+ const member = requireId(userId, 'member id');
85
+ const result = await createEndpointOperation({ signal }).run(() => qqGroupService(api).removeMembers(requireId(groupId, 'group id'), {
86
+ member_openids: [member],
87
+ ...(addToBlacklist ? { add_to_member_blacklist: true } : {}),
88
+ }));
89
+ if (result?.remove_members_result !== 'success') throw new Error('QQ did not confirm the member removal');
90
+ return { blacklistFailed: addToBlacklist && (result.add_to_member_blacklist_fail_openids ?? []).includes(member) };
91
+ }
92
+
93
+ /** 自动审核用:机器人与目标成员的实时角色;平台只允许管理员处置普通成员(且非机器人)。 */
94
+ export async function inspectQqGroupMember(
95
+ api: Partial<QqOfficialApi> | undefined, { groupId, userId, signal }: EndpointMemberInspectionRequest,
96
+ ): Promise<GroupModerationMember> {
97
+ signal.throwIfAborted();
98
+ const [self, member] = await Promise.all([qqGroupBotState(api, { groupId, signal }), qqGroupMember(api, { groupId, userId, signal })]);
99
+ signal.throwIfAborted();
100
+ const role = member.member_openid === userId ? platformRole(member.member_role) : undefined;
101
+ const joinedAt = parseQqTime(member.joined_at);
102
+ return {
103
+ canModerate: isQqGroupManagerRole(self.member_role) && self.member_openid !== userId
104
+ && member.member_openid === userId && role === 'member' && member.bot !== true,
105
+ ...(role ? { platformRole: role } : {}),
106
+ ...(joinedAt ? { joinedAt } : {}),
107
+ };
108
+ }
109
+
110
+ export async function moderateQqGroupMember(
111
+ { api, isActive }: { readonly api: Partial<QqOfficialApi> | undefined; readonly isActive: () => boolean },
112
+ { groupId, userId, action, signal }: EndpointModerationRequest,
113
+ ): Promise<void> {
114
+ const member = await inspectQqGroupMember(api, { groupId: groupId, userId: userId, signal: signal });
115
+ signal.throwIfAborted();
116
+ if (!isActive() || !member.canModerate) throw new Error('Group moderation permission is unavailable');
117
+ if (action.type === 'kick') {
118
+ if (!Number.isSafeInteger(action.maxMembershipAgeMs) || action.maxMembershipAgeMs <= 0
119
+ || !member.joinedAt || member.joinedAt !== action.joinedAt
120
+ || Date.now() - member.joinedAt > action.maxMembershipAgeMs) {
121
+ throw new Error('Group membership no longer matches the approved newcomer window');
122
+ }
123
+ await kickQqGroupMember(api, { groupId, userId, signal });
124
+ return;
125
+ }
126
+ if (!Number.isSafeInteger(action.durationSeconds) || action.durationSeconds <= 0) {
127
+ throw new Error('Invalid group mute duration');
128
+ }
129
+ await muteQqGroupMember(api, { groupId: groupId, userId: userId, durationSeconds: Math.min(action.durationSeconds, QQ_GROUP_MUTE_MAX_SECONDS), signal });
130
+ }
131
+
132
+ /** ADR 0041 场景事实:一次群信息读取;官方群信息不含群主/管理员/全员禁言,留作 unknown。 */
133
+ export async function inspectQqGroupScene(
134
+ api: Partial<QqOfficialApi> | undefined, { groupId, signal }: EndpointGroupInspectionRequest,
135
+ ): Promise<GroupSceneFacts> {
136
+ signal.throwIfAborted();
137
+ const info = await createEndpointOperation({ signal }).run(() => qqGroupService(api).getInfo(requireId(groupId, 'group id')));
138
+ signal.throwIfAborted();
139
+ if (info?.group_openid && info.group_openid !== groupId) return {};
140
+ const name = typeof info?.group_name === 'string' && info.group_name.trim() ? info.group_name.trim() : undefined;
141
+ const memberCount = Number.isSafeInteger(info?.group_member_num) && info.group_member_num > 0 ? info.group_member_num : undefined;
142
+ return { ...(name ? { name } : {}), ...(memberCount ? { memberCount } : {}) };
143
+ }
144
+
145
+ /** ADR 0041 成员事实:一次成员读取(群名片/头衔/禁言到期官方不提供)。 */
146
+ export async function inspectQqGroupMemberProfile(
147
+ api: Partial<QqOfficialApi> | undefined, { groupId, userId, signal }: EndpointMemberInspectionRequest,
148
+ ): Promise<GroupMemberProfile> {
149
+ signal.throwIfAborted();
150
+ const member = await qqGroupMember(api, { groupId, userId, signal });
151
+ signal.throwIfAborted();
152
+ if (member.member_openid !== userId) return {};
153
+ const role = platformRole(member.member_role);
154
+ return role ? { platformRole: role } : {};
155
+ }
package/src/i18n.ts CHANGED
@@ -3,8 +3,8 @@ import { createMessages } from 'zhin.js/i18n';
3
3
  export const messages = createMessages({
4
4
  bindTaskFailed: { 'zh-CN': '获取绑定任务失败: {error}', en: 'Could not create the binding task: {error}' },
5
5
  "kindPrompt": {
6
- "zh-CN": "请回复 public(公域)或 private(私域),确认后一次性写入 .env 与配置。",
7
- "en": "Reply public or private to save .env and configuration together."
6
+ "zh-CN": "请回复 public(公域)或 private(私域),确认后保存账号;需手动启动。",
7
+ "en": "Reply public or private to save the account, then start it explicitly."
8
8
  },
9
9
  "cancelHint": {
10
10
  "zh-CN": "也可发 {command} 取消。",
@@ -19,8 +19,8 @@ export const messages = createMessages({
19
19
  "en": "⚠️ Endpoint “{id}” is waiting for public/private selection"
20
20
  },
21
21
  "endpointSaved": {
22
- "zh-CN": "✅ 已按 botKind={kind} 将 endpoint「{id}」写入 .env 与 {file}。\nintents: {intents}。\n⚠️ 需重启 zhin 后新 endpoint 才会生效。",
23
- "en": "✅ Saved endpoint “{id}” with botKind={kind} to .env and {file}.\nintents: {intents}.\n⚠️ Restart zhin to activate the new endpoint."
22
+ "zh-CN": "✅ 已按 botKind={kind} 保存账号「{id}」。\nintents: {intents}。\n尚未启动,请显式启动此账号。",
23
+ "en": "✅ Saved account “{id}” with botKind={kind}.\nintents: {intents}.\nThe account is inactive; start it explicitly."
24
24
  },
25
25
  "existingFlow": {
26
26
  "zh-CN": "已有进行中的 QQ 机器人绑定或待确认的公域/私域选择,请先取消后再试",
package/src/image-size.ts CHANGED
@@ -92,15 +92,19 @@ async function readHead(response: Awaited<ReturnType<ImageProbeFetch>>): Promise
92
92
  }
93
93
 
94
94
  /** 读取公网图片文件头得到尺寸;网络错误、超时或格式不识别时返回 undefined。 */
95
+ export interface ImageProbeOptions {
96
+ readonly fetch?: ImageProbeFetch;
97
+ readonly timeoutMs?: number;
98
+ readonly signal?: AbortSignal;
99
+ }
95
100
  export async function probeImageSize(
96
101
  url: string,
97
- fetchImpl: ImageProbeFetch = fetch as unknown as ImageProbeFetch,
98
- timeoutMs = PROBE_TIMEOUT_MS,
102
+ { fetch: fetchImpl = fetch as unknown as ImageProbeFetch, timeoutMs = PROBE_TIMEOUT_MS, signal }: ImageProbeOptions = {},
99
103
  ): Promise<QqImageSize | undefined> {
100
104
  try {
101
105
  const response = await fetchImpl(url, {
102
106
  headers: { Range: `bytes=0-${PROBE_BYTES - 1}` },
103
- signal: AbortSignal.timeout(timeoutMs),
107
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
104
108
  });
105
109
  if (!response.ok) return undefined;
106
110
  return imageSizeFromBytes(await readHead(response));
package/src/index.ts CHANGED
@@ -38,8 +38,6 @@ export { imageSizeFromBytes, probeImageSize, type QqImageSize } from './image-si
38
38
  export {
39
39
  QqHttpEndpoint,
40
40
  QqWebsocketEndpoint,
41
- type CreateQqBot,
42
- type CreateQqHttpBot,
43
41
  type QqBotTransport,
44
42
  type QqOfficialApi,
45
43
  type QqEndpointOptions,
@@ -51,17 +49,17 @@ export {
51
49
  registerQqWebhookRoutes,
52
50
  handleQqWebhookRequest,
53
51
  readRequestBodyText,
54
- defaultCreateHttpBot,
52
+ qqHttpBotOptions,
55
53
  type QqWebhookHandler,
56
54
  } from './webhook.js';
57
55
 
58
56
  export {
59
57
  bindQqBotInboundEvents,
60
- defaultCreateBot,
58
+ qqWebsocketBotOptions,
61
59
  normalizeQqMessage,
62
60
  } from './ws.js';
63
61
 
64
- export { qqClient, type QqClientEventMap } from './client.js';
62
+ export { type QqClientEventMap } from './client.js';
65
63
 
66
64
  export {
67
65
  checkQqPlatformPermit,
@@ -69,3 +67,5 @@ export {
69
67
  platformPermit,
70
68
  qqGuildPermitResolver,
71
69
  } from './platform-permit.js';
70
+
71
+ export { QqAdapter } from './adapter.js';
@@ -11,5 +11,5 @@ export function normalizeQqInteraction(raw: unknown): { message: QqInboundMessag
11
11
  const id = kind === 'private' ? actor : kind === 'group' ? event.group_id : event.channel_id;
12
12
  if (!kind || !id || !actor || (kind === 'channel' && !event.guild_id)) return null;
13
13
  const sourceMessageId = typeof resolved.message_id === 'string' && resolved.message_id ? resolved.message_id : undefined;
14
- return { message: { id: event.notice_id, content: '', channelKind: kind, channelId: id, authorId: actor, authorName: '', timestamp: Date.now(), ...(event.guild_id ? { guildId: event.guild_id } : {}), segments: [{ type: 'action', data: { payload: resolved.button_data } }] }, ...(sourceMessageId ? { sourceMessageId } : {}), ack: () => event.reply!(0) };
14
+ return { message: { event: raw, id: event.notice_id, content: '', channelKind: kind, channelId: id, authorId: actor, authorName: '', timestamp: Date.now(), ...(event.guild_id ? { guildId: event.guild_id } : {}), segments: [{ type: 'action', data: { payload: resolved.button_data } }] }, ...(sourceMessageId ? { sourceMessageId } : {}), ack: () => event.reply!(0) };
15
15
  }
package/src/outbound.ts CHANGED
@@ -374,7 +374,7 @@ export function formatOutboundMessages(payload: unknown, options: FormatOutbound
374
374
  */
375
375
  export async function collectOutboundImageSizes(
376
376
  payload: unknown,
377
- probe: (url: string) => Promise<QqImageSize | undefined> = (url) => probeImageSize(url),
377
+ { probe, signal }: { readonly probe?: (url: string) => Promise<QqImageSize | undefined>; readonly signal?: AbortSignal } = {},
378
378
  ): Promise<Map<string, QqImageSize>> {
379
379
  const sizes = new Map<string, QqImageSize>();
380
380
  const segments = toSegmentList(payload).filter((item): item is QqWireSegment => typeof item !== 'string');
@@ -385,7 +385,8 @@ export async function collectOutboundImageSizes(
385
385
  if (isMediaRef(media) && media.kind === 'url' && /^https?:\/\//i.test(media.value)) urls.add(media.value);
386
386
  }
387
387
  await Promise.all([...urls].map(async (url) => {
388
- const size = await probe(url);
388
+ signal?.throwIfAborted();
389
+ const size = await (probe ? probe(url) : probeImageSize(url, { signal }));
389
390
  if (size) sizes.set(url, size);
390
391
  }));
391
392
  return sizes;
package/src/protocol.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { EndpointDeliveryError, type ConversationKind, type ConversationRef } from '@zhin.js/im-contract';
4
4
  import { formatCompact, getLogger } from '@zhin.js/logger';
5
5
  import { resolveQqIntents } from './qq-intents.js';
6
+ import { resolveAdapterNotifyTarget, type AdapterNotifyTarget, type ResolvedAdapterNotifyTarget } from 'zhin.js/adapter';
6
7
 
7
8
  const logger = getLogger('qq');
8
9
 
@@ -22,6 +23,8 @@ export interface QqEndpointConfig {
22
23
  readonly gatewayUrl?: string;
23
24
  readonly streamProxy?: import('./stream-proxy.js').QqStreamProxy;
24
25
  readonly webhookPath?: string;
26
+ /** Optional out-of-band outage notification target (usually an ICQQ instance). */
27
+ readonly notify?: AdapterNotifyTarget;
25
28
  }
26
29
 
27
30
  export interface ResolvedQqWebsocketConfig {
@@ -35,6 +38,7 @@ export interface ResolvedQqWebsocketConfig {
35
38
  readonly accessTokenUrl?: string;
36
39
  readonly gatewayUrl?: string;
37
40
  readonly streamProxy?: import('./stream-proxy.js').QqStreamProxy;
41
+ readonly notify?: ResolvedAdapterNotifyTarget;
38
42
  }
39
43
 
40
44
  export interface ResolvedQqHttpConfig {
@@ -45,6 +49,7 @@ export interface ResolvedQqHttpConfig {
45
49
  readonly secret: string;
46
50
  readonly webhookPath: string;
47
51
  readonly sandbox: boolean;
52
+ readonly notify?: ResolvedAdapterNotifyTarget;
48
53
  }
49
54
 
50
55
  export type ResolvedQqConfig = ResolvedQqWebsocketConfig | ResolvedQqHttpConfig;
@@ -52,6 +57,8 @@ export type ResolvedQqConfig = ResolvedQqWebsocketConfig | ResolvedQqHttpConfig;
52
57
  export type QqChannelKind = 'private' | 'group' | 'channel' | 'direct';
53
58
 
54
59
  export interface QqInboundMessage {
60
+ readonly event?: unknown;
61
+ readonly platformSentAt?: number;
55
62
  readonly id: string;
56
63
  readonly content: string;
57
64
  readonly channelKind: QqChannelKind;
@@ -83,6 +90,7 @@ export function resolveQqConfig(config: QqEndpointConfig): ResolvedQqConfig {
83
90
  const appid = requiredEndpointField(config.appid, 'appid');
84
91
  const secret = requiredEndpointField(config.secret, 'secret');
85
92
  const mode = config.mode ?? 'websocket';
93
+ const notify = resolveAdapterNotifyTarget(config.notify);
86
94
 
87
95
  if (mode === 'webhook' || mode === 'middleware') {
88
96
  const webhookPathRaw = config.webhookPath ?? '/qq/webhook';
@@ -95,6 +103,7 @@ export function resolveQqConfig(config: QqEndpointConfig): ResolvedQqConfig {
95
103
  secret,
96
104
  webhookPath,
97
105
  sandbox: config.sandbox === true,
106
+ ...(notify ? { notify } : {}),
98
107
  };
99
108
  }
100
109
 
@@ -114,6 +123,7 @@ export function resolveQqConfig(config: QqEndpointConfig): ResolvedQqConfig {
114
123
  accessTokenUrl: config.accessTokenUrl,
115
124
  gatewayUrl: config.gatewayUrl,
116
125
  ...(config.streamProxy ? { streamProxy: config.streamProxy } : {}),
126
+ ...(notify ? { notify } : {}),
117
127
  };
118
128
  }
119
129
 
@@ -0,0 +1,29 @@
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
+
9
+ export const qqConsoleSpec = {
10
+ adapterKey: 'qq',
11
+ adapterDisplayName: 'QQ',
12
+ fields: [
13
+ { key: 'appid', required: true, description: 'QQ appid' },
14
+ { key: 'secret', required: true, env: true, description: 'QQ app secret' },
15
+ { key: 'mode', description: 'websocket, webhook or middleware' },
16
+ { key: 'sandbox', type: 'boolean', description: 'QQ sandbox environment' },
17
+ { key: 'botKind', description: 'public or private; existing explicit intents remain authoritative' },
18
+ { key: 'accessTokenUrl', description: 'Configured access-token API URL' },
19
+ { key: 'gatewayUrl', description: 'Configured Gateway discovery URL' },
20
+ { key: 'webhookPath', description: 'Local webhook route' },
21
+ { key: 'streamProxy.port', type: 'number', description: 'Configured Gateway loopback proxy port' },
22
+ { key: 'streamProxy.serverName', description: 'Expected Gateway TLS hostname' },
23
+ ] as readonly AdapterConsoleField[],
24
+ canAdd: false,
25
+ canRemove: true,
26
+ docsUrl: 'https://zhin.js.org/adapters/qq',
27
+ readPermission: 'qq:read',
28
+ writePermission: 'qq:write',
29
+ } as const;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * QQ Console 扫码绑定:把原本只能在聊天里执行的 `qq endpoint add` 流程
3
+ * 搬到 Console 页面。
4
+ *
5
+ * - `start` 返回扫码链接;后续状态写入 `state.consoleBind`,页面轮询 status。
6
+ * - 扫码成功后凭据只留在内存,等页面选择公域 / 私域再通过捕获的所属 Adapter CAS 保存。
7
+ * - 与聊天命令共享 `state.bindFlow` 单例,同一时间只允许一个绑定流程。
8
+ */
9
+ import { PluginConsoleRpcError } from 'zhin.js';
10
+ import { AdapterManagementError } from 'zhin.js/adapter';
11
+ import { startQqBindFlow } from './qq-bind-flow.js';
12
+ import { writeQqEndpointWithBotKind, type QqEndpointWriteResult, type QqEndpointSavePort } from './qq-endpoint-write.js';
13
+ import { parseQqBotKind } from './qq-intents.js';
14
+ import type { QqRuntimeState } from './qq-runtime-state.js';
15
+
16
+ /** 启动 Console 扫码;resolve 时二维码链接已就绪。 */
17
+ export function startQqConsoleBind(
18
+ state: QqRuntimeState,
19
+ id?: string,
20
+ ): Promise<{ readonly url: string }> {
21
+ if (state.bindFlow || state.pendingBotKind) {
22
+ throw new PluginConsoleRpcError('conflict', '已有扫码绑定流程在进行,请先完成或取消');
23
+ }
24
+ if (state.consoleBind?.pending) {
25
+ throw new PluginConsoleRpcError('conflict', '已经扫码成功,请先选择公域或私域');
26
+ }
27
+ const endpointId = id?.trim() || undefined;
28
+ return new Promise((resolve, reject) => {
29
+ let settled = false;
30
+ const stop = startQqBindFlow({
31
+ onQrDisplayed: (url) => {
32
+ state.consoleBind = {
33
+ state: 'waiting',
34
+ url,
35
+ message: '请用手机 QQ 扫码',
36
+ startedAt: state.consoleBind?.startedAt ?? Date.now(),
37
+ };
38
+ if (settled) return;
39
+ settled = true;
40
+ resolve({ url });
41
+ },
42
+ onQrExpired: () => {
43
+ state.consoleBind = {
44
+ state: 'waiting',
45
+ ...(state.consoleBind?.url ? { url: state.consoleBind.url } : {}),
46
+ message: '二维码已过期,正在刷新…',
47
+ startedAt: state.consoleBind?.startedAt ?? Date.now(),
48
+ };
49
+ },
50
+ onSuccess: (credentials) => {
51
+ state.bindFlow = null;
52
+ const [first] = credentials;
53
+ if (!first) {
54
+ state.consoleBind = {
55
+ state: 'failed',
56
+ error: '扫码结果为空,请重新扫码',
57
+ startedAt: state.consoleBind?.startedAt ?? Date.now(),
58
+ };
59
+ return;
60
+ }
61
+ state.consoleBind = {
62
+ state: 'scanned',
63
+ ...(state.consoleBind?.url ? { url: state.consoleBind.url } : {}),
64
+ message: '已扫码,请选择公域或私域',
65
+ startedAt: state.consoleBind?.startedAt ?? Date.now(),
66
+ pending: {
67
+ endpointId: endpointId ?? first.appId,
68
+ appId: first.appId,
69
+ appSecret: first.appSecret,
70
+ },
71
+ };
72
+ },
73
+ onFailure: (error) => {
74
+ state.bindFlow = null;
75
+ state.consoleBind = {
76
+ state: 'failed',
77
+ error: error.message,
78
+ startedAt: state.consoleBind?.startedAt ?? Date.now(),
79
+ };
80
+ if (settled) return;
81
+ settled = true;
82
+ reject(new PluginConsoleRpcError('unavailable', error.message));
83
+ },
84
+ }, { source: 'zhin' });
85
+ state.bindFlow = { id: endpointId, stop, locale: 'zh-CN' };
86
+ });
87
+ }
88
+
89
+ /** 扫码成功后选择公域 / 私域并保存未启动账号。 */
90
+ export async function completeQqConsoleBind(
91
+ state: QqRuntimeState,
92
+ save: QqEndpointSavePort,
93
+ kindValue: unknown,
94
+ ): Promise<QqEndpointWriteResult> {
95
+ const pending = state.consoleBind?.pending;
96
+ if (!pending) {
97
+ throw new PluginConsoleRpcError('conflict', '还没有扫码成功,请先扫码绑定');
98
+ }
99
+ const botKind = parseQqBotKind(kindValue);
100
+ if (!botKind) {
101
+ throw new PluginConsoleRpcError('invalid_input', '请选择公域(public)或私域(private)');
102
+ }
103
+ try {
104
+ const result = await writeQqEndpointWithBotKind({ ...pending, botKind }, save);
105
+ state.consoleBind = null;
106
+ state.consoleAdmission = null;
107
+ return result;
108
+ } catch (error) {
109
+ if (error instanceof PluginConsoleRpcError) throw error;
110
+ const code = error instanceof AdapterManagementError ? error.code : 'unavailable';
111
+ if (code === 'commit_unknown') throw new PluginConsoleRpcError('commit_unknown', '提交结果未知,请刷新核实,勿重复提交');
112
+ if (code === 'conflict' || code === 'duplicate_entry' || code === 'closed') throw new PluginConsoleRpcError('conflict', '账号清单已变化,请刷新核实');
113
+ throw new PluginConsoleRpcError('unavailable', '账号配置未能保存,请刷新状态');
114
+ }
115
+ }
116
+
117
+ /** 取消 Console 绑定(也可用于收拾聊天命令发起的流程)。 */
118
+ export function cancelQqConsoleBind(state: QqRuntimeState): boolean {
119
+ const active = Boolean(state.bindFlow || state.pendingBotKind || state.consoleBind);
120
+ try {
121
+ state.bindFlow?.stop();
122
+ } catch {
123
+ // best effort
124
+ }
125
+ state.bindFlow = null;
126
+ state.pendingBotKind = null;
127
+ state.consoleBind = null;
128
+ state.consoleAdmission = null;
129
+ return active;
130
+ }
@@ -0,0 +1,24 @@
1
+ /** A validated QR result is saved through its owning Adapter's single CAS admission. */
2
+ import type { AdapterMutationResult } from 'zhin.js/adapter';
3
+ import { defaultQqEndpointIntentFields, type QqBotKind } from './qq-intents.js';
4
+ import type { QqEndpointConfig } from './protocol.js';
5
+
6
+ export interface QqEndpointWriteInput {
7
+ readonly endpointId: string;
8
+ readonly appId: string;
9
+ readonly appSecret: string;
10
+ readonly botKind: QqBotKind;
11
+ }
12
+ export type QqEndpointSavePort = (configuration: QqEndpointConfig) => Promise<AdapterMutationResult>;
13
+ export interface QqEndpointWriteResult {
14
+ readonly endpointId: string;
15
+ readonly botKind: QqBotKind;
16
+ readonly intents: readonly string[];
17
+ readonly mutation: AdapterMutationResult;
18
+ }
19
+ export async function writeQqEndpointWithBotKind(input: QqEndpointWriteInput, save: QqEndpointSavePort): Promise<QqEndpointWriteResult> {
20
+ const fields = defaultQqEndpointIntentFields(input.botKind);
21
+ const mutation = await save({ id: input.endpointId, appid: input.appId, secret: input.appSecret,
22
+ botKind: fields.botKind, intents: [...fields.intents] });
23
+ return { endpointId: input.endpointId, botKind: fields.botKind, intents: fields.intents, mutation };
24
+ }
@@ -1,14 +1,9 @@
1
1
  /**
2
- * QQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表 +
3
- * 扫码绑定 / 待选 botKind 流程。由 plugin.ts setup() provide,
4
- * adapter create 与 commands / middleware 通过 use(qqRuntimeStateToken) 共享。
2
+ * QQ 插件实例的瞬态扫码绑定 / 待选 botKind 流程。
3
+ * 账号目录与 SDK 生命周期只由所属 Adapter 持有;此处不保存 Endpoint Map。
5
4
  */
6
5
  import { createToken } from 'zhin.js';
7
-
8
- export interface QqRunningEndpoint {
9
- id: string;
10
- mode: string;
11
- }
6
+ import type { QqEndpointSavePort } from './qq-endpoint-write.js';
12
7
 
13
8
  export interface QqBindFlowHandle {
14
9
  /** 绑定期望的 endpoint id(未指定时为 undefined,成功后取 appId) */
@@ -20,10 +15,10 @@ export interface QqBindFlowHandle {
20
15
  }
21
16
 
22
17
  /**
23
- * 扫码成功后暂存在内存:确认公域/私域后再一次性写 .env + yaml。
24
- * (提前写 .env 会触发 HMR,冲掉 pending。)
18
+ * 扫码成功后暂存在内存:确认公域/私域后调用发起时捕获的所属 Adapter CAS。
25
19
  */
26
20
  export interface QqPendingBotKind {
21
+ readonly save: QqEndpointSavePort;
27
22
  readonly endpointId: string;
28
23
  readonly appId: string;
29
24
  readonly appSecret: string;
@@ -34,24 +29,45 @@ export interface QqPendingBotKind {
34
29
  readonly commandHeads?: readonly string[];
35
30
  }
36
31
 
32
+ /**
33
+ * Console 发起的扫码绑定进度。凭据(appSecret)只留在内存,
34
+ * status 只暴露状态、扫码链接与目标 endpoint id。
35
+ */
36
+ export interface QqConsoleBindState {
37
+ readonly state: 'waiting' | 'scanned' | 'failed';
38
+ readonly url?: string;
39
+ readonly message?: string;
40
+ readonly error?: string;
41
+ readonly startedAt: number;
42
+ /** 扫码成功后暂存,确认公域/私域后写配置。 */
43
+ readonly pending?: {
44
+ readonly endpointId: string;
45
+ readonly appId: string;
46
+ readonly appSecret: string;
47
+ };
48
+ }
49
+
37
50
  export interface QqRuntimeState {
38
- /** 当前 generation 已成功创建的 endpoint(id → 描述) */
39
- readonly endpoints: Map<string, QqRunningEndpoint>;
40
- /** 全局单例:同一时间只允许一个扫码绑定流程 */
51
+ /** Snapshot checked when the QR flow began, never a second account directory. */
52
+ consoleAdmission: Readonly<{ adapterId: string; generation: number; revision: number | null }> | null;
53
+ /** 同一插件 generation 内只允许一个扫码绑定流程 */
41
54
  bindFlow: QqBindFlowHandle | null;
42
55
  /** 扫码完成后等待 botKind 选择(凭据仅在内存) */
43
56
  pendingBotKind: QqPendingBotKind | null;
57
+ /** Console 页面的扫码绑定进度(与 bindFlow 共享同一插件瞬态流程)。 */
58
+ consoleBind: QqConsoleBindState | null;
44
59
  }
45
60
 
46
61
  export function createQqRuntimeState(): QqRuntimeState {
47
62
  return {
48
- endpoints: new Map(),
63
+ consoleAdmission: null,
49
64
  bindFlow: null,
50
65
  pendingBotKind: null,
66
+ consoleBind: null,
51
67
  };
52
68
  }
53
69
 
54
70
  export const qqRuntimeStateToken = createToken<QqRuntimeState>(
55
71
  'zhin.qq.runtime-state',
56
- 'QQ adapter runtime state (running endpoints + bind flow singleton)',
72
+ 'QQ transient QR binding state',
57
73
  );