@zhin.js/adapter-qq 1.1.18 → 1.1.20

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 (102) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +21 -18
  3. package/adapters/qq/index.js +4 -38
  4. package/adapters/qq/index.ts +3 -46
  5. package/agents/qq/conversation.js +1 -1
  6. package/agents/qq/conversation.ts +1 -1
  7. package/agents/qq/skills/qq/tools/add_role/index.js +2 -1
  8. package/agents/qq/skills/qq/tools/add_role/index.ts +2 -1
  9. package/agents/qq/skills/qq/tools/channel_info/index.js +1 -1
  10. package/agents/qq/skills/qq/tools/channel_info/index.ts +1 -1
  11. package/agents/qq/skills/qq/tools/create_role/index.js +1 -1
  12. package/agents/qq/skills/qq/tools/create_role/index.ts +1 -1
  13. package/agents/qq/skills/qq/tools/guild_kick_member/index.js +1 -1
  14. package/agents/qq/skills/qq/tools/guild_kick_member/index.ts +1 -1
  15. package/agents/qq/skills/qq/tools/guild_mute_member/index.js +1 -1
  16. package/agents/qq/skills/qq/tools/guild_mute_member/index.ts +1 -1
  17. package/agents/qq/skills/qq/tools/list_channels/index.js +1 -1
  18. package/agents/qq/skills/qq/tools/list_channels/index.ts +1 -1
  19. package/agents/qq/skills/qq/tools/list_roles/index.js +1 -1
  20. package/agents/qq/skills/qq/tools/list_roles/index.ts +1 -1
  21. package/agents/qq/skills/qq/tools/member_detail/index.js +1 -1
  22. package/agents/qq/skills/qq/tools/member_detail/index.ts +1 -1
  23. package/agents/qq/skills/qq/tools/remove_role/index.js +2 -1
  24. package/agents/qq/skills/qq/tools/remove_role/index.ts +2 -1
  25. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.js +1 -1
  26. package/agents/qq/skills/qq-conversation/tools/conversation_info/index.ts +1 -1
  27. package/agents/qq/skills/qq-directory/tools/list_guilds/index.js +1 -1
  28. package/agents/qq/skills/qq-directory/tools/list_guilds/index.ts +1 -1
  29. package/agents/qq/skills/qq-group-admin/tools/group_kick/index.js +2 -2
  30. package/agents/qq/skills/qq-group-admin/tools/group_kick/index.ts +2 -2
  31. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.js +1 -1
  32. package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.ts +1 -1
  33. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.js +2 -2
  34. package/agents/qq/skills/qq-group-admin/tools/set_mute/index.ts +2 -2
  35. package/agents/qq/skills/qq-group-directory/tools/group_info/index.js +1 -1
  36. package/agents/qq/skills/qq-group-directory/tools/group_info/index.ts +1 -1
  37. package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.js +1 -1
  38. package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.ts +1 -1
  39. package/agents/qq/skills/qq-group-directory/tools/group_members/index.js +1 -1
  40. package/agents/qq/skills/qq-group-directory/tools/group_members/index.ts +1 -1
  41. package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.js +1 -1
  42. package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.ts +1 -1
  43. package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.js +1 -1
  44. package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.ts +1 -1
  45. package/agents/qq/skills/qq-group-state/tools/list_muted/index.js +1 -1
  46. package/agents/qq/skills/qq-group-state/tools/list_muted/index.ts +1 -1
  47. package/commands/qq/endpoint/definition.js +41 -36
  48. package/commands/qq/endpoint/definition.ts +41 -67
  49. package/lib/adapter.d.ts +13 -0
  50. package/lib/adapter.js +27 -0
  51. package/lib/client.d.ts +7 -9
  52. package/lib/client.js +1 -2
  53. package/lib/console.d.ts +1 -6
  54. package/lib/console.js +29 -20
  55. package/lib/endpoint.d.ts +8 -27
  56. package/lib/endpoint.js +218 -165
  57. package/lib/group-admin.d.ts +14 -9
  58. package/lib/group-admin.js +21 -20
  59. package/lib/i18n.js +4 -4
  60. package/lib/image-size.d.ts +6 -1
  61. package/lib/image-size.js +2 -3
  62. package/lib/index.d.ts +5 -4
  63. package/lib/index.js +3 -3
  64. package/lib/interaction.js +1 -1
  65. package/lib/outbound.d.ts +4 -1
  66. package/lib/outbound.js +3 -2
  67. package/lib/protocol.d.ts +2 -0
  68. package/lib/qq-console-spec.js +12 -1
  69. package/lib/qq-console.d.ts +3 -4
  70. package/lib/qq-console.js +16 -6
  71. package/lib/qq-endpoint-write.d.ts +6 -10
  72. package/lib/qq-endpoint-write.js +5 -32
  73. package/lib/qq-runtime-state.d.ts +11 -10
  74. package/lib/qq-runtime-state.js +4 -5
  75. package/lib/qq-send.d.ts +7 -14
  76. package/lib/qq-send.js +20 -6
  77. package/lib/webhook.d.ts +3 -6
  78. package/lib/webhook.js +6 -49
  79. package/lib/ws.d.ts +10 -33
  80. package/lib/ws.js +21 -70
  81. package/middlewares/bot-kind-reply/index.js +1 -2
  82. package/middlewares/bot-kind-reply/index.ts +0 -2
  83. package/package.json +19 -19
  84. package/plugin.js +5 -5
  85. package/src/adapter.ts +23 -0
  86. package/src/client.ts +4 -12
  87. package/src/console.ts +27 -20
  88. package/src/endpoint.ts +218 -160
  89. package/src/group-admin.ts +28 -26
  90. package/src/i18n.ts +4 -4
  91. package/src/image-size.ts +7 -3
  92. package/src/index.ts +5 -5
  93. package/src/interaction.ts +1 -1
  94. package/src/outbound.ts +3 -2
  95. package/src/protocol.ts +2 -0
  96. package/src/qq-console-spec.ts +12 -1
  97. package/src/qq-console.ts +14 -8
  98. package/src/qq-endpoint-write.ts +10 -38
  99. package/src/qq-runtime-state.ts +11 -16
  100. package/src/qq-send.ts +28 -12
  101. package/src/webhook.ts +7 -57
  102. package/src/ws.ts +27 -105
@@ -11,11 +11,13 @@ import type {
11
11
  GroupMemberInfo,
12
12
  GroupMemberRole,
13
13
  } from 'qq-official-bot';
14
- import type {
15
- GroupMemberProfile,
16
- GroupModerationAction,
17
- GroupModerationMember,
18
- GroupSceneFacts,
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,
19
21
  } from 'zhin.js/adapter';
20
22
  import type { QqOfficialApi } from './ws.js';
21
23
 
@@ -54,46 +56,46 @@ export function parseQqTime(value: unknown, { allowFuture = false } = {}): numbe
54
56
  }
55
57
 
56
58
  /** 只读、实时:机器人在群里的状态(角色、是否允许主动消息、接收设置)。 */
57
- export async function qqGroupBotState(api: Partial<QqOfficialApi> | undefined, groupId: string): Promise<GroupBotState> {
58
- return qqGroupService(api).getBotState(requireId(groupId, 'group id'));
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')));
59
61
  }
60
62
 
61
- export async function qqGroupMember(api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string): Promise<GroupMemberInfo> {
62
- return qqGroupService(api).getMemberInfo(requireId(groupId, 'group id'), requireId(userId, 'member id'));
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')));
63
65
  }
64
66
 
65
67
  /** 禁言 / 解除禁言一个普通成员;0 = 解除。 */
66
68
  export async function muteQqGroupMember(
67
- api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string, durationSeconds: number,
69
+ api: Partial<QqOfficialApi> | undefined, { groupId, userId, durationSeconds, signal }: EndpointMuteRequest,
68
70
  ): Promise<void> {
69
71
  if (!Number.isSafeInteger(durationSeconds) || durationSeconds < 0 || durationSeconds > QQ_GROUP_MUTE_MAX_SECONDS) {
70
72
  throw new RangeError(`QQ group mute duration must be 0..${QQ_GROUP_MUTE_MAX_SECONDS} seconds`);
71
73
  }
72
74
  const member = requireId(userId, 'member id');
73
- await qqGroupService(api).setMemberMute(requireId(groupId, 'group id'), [durationSeconds === 0
75
+ await createEndpointOperation({ signal }).run(() => qqGroupService(api).setMemberMute(requireId(groupId, 'group id'), [durationSeconds === 0
74
76
  ? { op: 'del', member_openid: member, mute_expire_at: '' }
75
- : { op: 'add', member_openid: member, mute_expire_at: new Date(Date.now() + durationSeconds * 1000).toISOString() }]);
77
+ : { op: 'add', member_openid: member, mute_expire_at: new Date(Date.now() + durationSeconds * 1000).toISOString() }]));
76
78
  }
77
79
 
78
80
  /** 移出一个成员;可选同时加入群黑名单。平台未确认 success 时抛错,不把未确认当成功。 */
79
81
  export async function kickQqGroupMember(
80
- api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string, addToBlacklist = false,
82
+ api: Partial<QqOfficialApi> | undefined, { groupId, userId, addToBlacklist = false, signal }: EndpointMemberRequest & { readonly addToBlacklist?: boolean },
81
83
  ): Promise<{ blacklistFailed: boolean }> {
82
84
  const member = requireId(userId, 'member id');
83
- const result = await qqGroupService(api).removeMembers(requireId(groupId, 'group id'), {
85
+ const result = await createEndpointOperation({ signal }).run(() => qqGroupService(api).removeMembers(requireId(groupId, 'group id'), {
84
86
  member_openids: [member],
85
87
  ...(addToBlacklist ? { add_to_member_blacklist: true } : {}),
86
- });
88
+ }));
87
89
  if (result?.remove_members_result !== 'success') throw new Error('QQ did not confirm the member removal');
88
90
  return { blacklistFailed: addToBlacklist && (result.add_to_member_blacklist_fail_openids ?? []).includes(member) };
89
91
  }
90
92
 
91
93
  /** 自动审核用:机器人与目标成员的实时角色;平台只允许管理员处置普通成员(且非机器人)。 */
92
94
  export async function inspectQqGroupMember(
93
- api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string, signal: AbortSignal,
95
+ api: Partial<QqOfficialApi> | undefined, { groupId, userId, signal }: EndpointMemberInspectionRequest,
94
96
  ): Promise<GroupModerationMember> {
95
97
  signal.throwIfAborted();
96
- const [self, member] = await Promise.all([qqGroupBotState(api, groupId), qqGroupMember(api, groupId, userId)]);
98
+ const [self, member] = await Promise.all([qqGroupBotState(api, { groupId, signal }), qqGroupMember(api, { groupId, userId, signal })]);
97
99
  signal.throwIfAborted();
98
100
  const role = member.member_openid === userId ? platformRole(member.member_role) : undefined;
99
101
  const joinedAt = parseQqTime(member.joined_at);
@@ -106,10 +108,10 @@ export async function inspectQqGroupMember(
106
108
  }
107
109
 
108
110
  export async function moderateQqGroupMember(
109
- api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string,
110
- action: GroupModerationAction, signal: AbortSignal, isActive: () => boolean,
111
+ { api, isActive }: { readonly api: Partial<QqOfficialApi> | undefined; readonly isActive: () => boolean },
112
+ { groupId, userId, action, signal }: EndpointModerationRequest,
111
113
  ): Promise<void> {
112
- const member = await inspectQqGroupMember(api, groupId, userId, signal);
114
+ const member = await inspectQqGroupMember(api, { groupId: groupId, userId: userId, signal: signal });
113
115
  signal.throwIfAborted();
114
116
  if (!isActive() || !member.canModerate) throw new Error('Group moderation permission is unavailable');
115
117
  if (action.type === 'kick') {
@@ -118,21 +120,21 @@ export async function moderateQqGroupMember(
118
120
  || Date.now() - member.joinedAt > action.maxMembershipAgeMs) {
119
121
  throw new Error('Group membership no longer matches the approved newcomer window');
120
122
  }
121
- await kickQqGroupMember(api, groupId, userId);
123
+ await kickQqGroupMember(api, { groupId, userId, signal });
122
124
  return;
123
125
  }
124
126
  if (!Number.isSafeInteger(action.durationSeconds) || action.durationSeconds <= 0) {
125
127
  throw new Error('Invalid group mute duration');
126
128
  }
127
- await muteQqGroupMember(api, groupId, userId, Math.min(action.durationSeconds, QQ_GROUP_MUTE_MAX_SECONDS));
129
+ await muteQqGroupMember(api, { groupId: groupId, userId: userId, durationSeconds: Math.min(action.durationSeconds, QQ_GROUP_MUTE_MAX_SECONDS), signal });
128
130
  }
129
131
 
130
132
  /** ADR 0041 场景事实:一次群信息读取;官方群信息不含群主/管理员/全员禁言,留作 unknown。 */
131
133
  export async function inspectQqGroupScene(
132
- api: Partial<QqOfficialApi> | undefined, groupId: string, signal: AbortSignal,
134
+ api: Partial<QqOfficialApi> | undefined, { groupId, signal }: EndpointGroupInspectionRequest,
133
135
  ): Promise<GroupSceneFacts> {
134
136
  signal.throwIfAborted();
135
- const info = await qqGroupService(api).getInfo(requireId(groupId, 'group id'));
137
+ const info = await createEndpointOperation({ signal }).run(() => qqGroupService(api).getInfo(requireId(groupId, 'group id')));
136
138
  signal.throwIfAborted();
137
139
  if (info?.group_openid && info.group_openid !== groupId) return {};
138
140
  const name = typeof info?.group_name === 'string' && info.group_name.trim() ? info.group_name.trim() : undefined;
@@ -142,10 +144,10 @@ export async function inspectQqGroupScene(
142
144
 
143
145
  /** ADR 0041 成员事实:一次成员读取(群名片/头衔/禁言到期官方不提供)。 */
144
146
  export async function inspectQqGroupMemberProfile(
145
- api: Partial<QqOfficialApi> | undefined, groupId: string, userId: string, signal: AbortSignal,
147
+ api: Partial<QqOfficialApi> | undefined, { groupId, userId, signal }: EndpointMemberInspectionRequest,
146
148
  ): Promise<GroupMemberProfile> {
147
149
  signal.throwIfAborted();
148
- const member = await qqGroupMember(api, groupId, userId);
150
+ const member = await qqGroupMember(api, { groupId, userId, signal });
149
151
  signal.throwIfAborted();
150
152
  if (member.member_openid !== userId) return {};
151
153
  const role = platformRole(member.member_role);
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
@@ -57,6 +57,8 @@ export type ResolvedQqConfig = ResolvedQqWebsocketConfig | ResolvedQqHttpConfig;
57
57
  export type QqChannelKind = 'private' | 'group' | 'channel' | 'direct';
58
58
 
59
59
  export interface QqInboundMessage {
60
+ readonly event?: unknown;
61
+ readonly platformSentAt?: number;
60
62
  readonly id: string;
61
63
  readonly content: string;
62
64
  readonly channelKind: QqChannelKind;
@@ -9,7 +9,18 @@ import type { AdapterConsoleField } from 'zhin.js/adapter';
9
9
  export const qqConsoleSpec = {
10
10
  adapterKey: 'qq',
11
11
  adapterDisplayName: 'QQ',
12
- fields: [] as readonly AdapterConsoleField[],
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[],
13
24
  canAdd: false,
14
25
  canRemove: true,
15
26
  docsUrl: 'https://zhin.js.org/adapters/qq',
package/src/qq-console.ts CHANGED
@@ -3,15 +3,15 @@
3
3
  * 搬到 Console 页面。
4
4
  *
5
5
  * - `start` 返回扫码链接;后续状态写入 `state.consoleBind`,页面轮询 status。
6
- * - 扫码成功后凭据只留在内存,等页面选择公域 / 私域再一次性写 `.env` + 配置。
6
+ * - 扫码成功后凭据只留在内存,等页面选择公域 / 私域再通过捕获的所属 Adapter CAS 保存。
7
7
  * - 与聊天命令共享 `state.bindFlow` 单例,同一时间只允许一个绑定流程。
8
8
  */
9
9
  import { PluginConsoleRpcError } from 'zhin.js';
10
+ import { AdapterManagementError } from 'zhin.js/adapter';
10
11
  import { startQqBindFlow } from './qq-bind-flow.js';
11
- import { writeQqEndpointWithBotKind, type QqEndpointWriteResult } from './qq-endpoint-write.js';
12
+ import { writeQqEndpointWithBotKind, type QqEndpointWriteResult, type QqEndpointSavePort } from './qq-endpoint-write.js';
12
13
  import { parseQqBotKind } from './qq-intents.js';
13
14
  import type { QqRuntimeState } from './qq-runtime-state.js';
14
- import type { EndpointConfigurationStore } from 'zhin.js/adapter';
15
15
 
16
16
  /** 启动 Console 扫码;resolve 时二维码链接已就绪。 */
17
17
  export function startQqConsoleBind(
@@ -86,10 +86,10 @@ export function startQqConsoleBind(
86
86
  });
87
87
  }
88
88
 
89
- /** 扫码成功后选择公域 / 私域并写回配置。 */
89
+ /** 扫码成功后选择公域 / 私域并保存未启动账号。 */
90
90
  export async function completeQqConsoleBind(
91
91
  state: QqRuntimeState,
92
- store: EndpointConfigurationStore,
92
+ save: QqEndpointSavePort,
93
93
  kindValue: unknown,
94
94
  ): Promise<QqEndpointWriteResult> {
95
95
  const pending = state.consoleBind?.pending;
@@ -101,11 +101,16 @@ export async function completeQqConsoleBind(
101
101
  throw new PluginConsoleRpcError('invalid_input', '请选择公域(public)或私域(private)');
102
102
  }
103
103
  try {
104
- const result = await writeQqEndpointWithBotKind({ ...pending, botKind }, store);
104
+ const result = await writeQqEndpointWithBotKind({ ...pending, botKind }, save);
105
105
  state.consoleBind = null;
106
+ state.consoleAdmission = null;
106
107
  return result;
107
- } catch {
108
- throw new PluginConsoleRpcError('unavailable', '写入配置失败,请检查配置文件和 .env 的权限');
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', '账号配置未能保存,请刷新状态');
109
114
  }
110
115
  }
111
116
 
@@ -120,5 +125,6 @@ export function cancelQqConsoleBind(state: QqRuntimeState): boolean {
120
125
  state.bindFlow = null;
121
126
  state.pendingBotKind = null;
122
127
  state.consoleBind = null;
128
+ state.consoleAdmission = null;
123
129
  return active;
124
130
  }
@@ -1,11 +1,7 @@
1
- /**
2
- * QQ endpoint 配置写回:扫码成功后把 appId / appSecret 写进 `.env`,
3
- * 配置里只保留 `${ENV}` 引用,并按公域 / 私域展开 intents。
4
- *
5
- * 命令(`qq endpoint add`)与 Console 扫码绑定共用这一处实现。
6
- */
7
- import { buildEndpointEnvKey, type EndpointConfigurationStore } from 'zhin.js/adapter';
1
+ /** A validated QR result is saved through its owning Adapter's single CAS admission. */
2
+ import type { AdapterMutationResult } from 'zhin.js/adapter';
8
3
  import { defaultQqEndpointIntentFields, type QqBotKind } from './qq-intents.js';
4
+ import type { QqEndpointConfig } from './protocol.js';
9
5
 
10
6
  export interface QqEndpointWriteInput {
11
7
  readonly endpointId: string;
@@ -13,40 +9,16 @@ export interface QqEndpointWriteInput {
13
9
  readonly appSecret: string;
14
10
  readonly botKind: QqBotKind;
15
11
  }
16
-
12
+ export type QqEndpointSavePort = (configuration: QqEndpointConfig) => Promise<AdapterMutationResult>;
17
13
  export interface QqEndpointWriteResult {
18
- readonly filePath: string;
19
14
  readonly endpointId: string;
20
15
  readonly botKind: QqBotKind;
21
16
  readonly intents: readonly string[];
17
+ readonly mutation: AdapterMutationResult;
22
18
  }
23
-
24
- /** 写回 `.env` + `zhin.config.yml`;失败时抛原错误,由调用方决定文案。 */
25
- export async function writeQqEndpointWithBotKind(
26
- input: QqEndpointWriteInput,
27
- store: EndpointConfigurationStore,
28
- ): Promise<QqEndpointWriteResult> {
29
- const appidKey = buildEndpointEnvKey('qq', input.endpointId, 'appid');
30
- const secretKey = buildEndpointEnvKey('qq', input.endpointId, 'secret');
31
- const intentFields = defaultQqEndpointIntentFields(input.botKind);
32
- const { filePath } = await store.add({
33
- adapterKey: 'qq',
34
- entry: {
35
- id: input.endpointId,
36
- appid: `\${${appidKey}}`,
37
- secret: `\${${secretKey}}`,
38
- botKind: intentFields.botKind,
39
- intents: [...intentFields.intents],
40
- },
41
- environment: {
42
- [appidKey]: input.appId,
43
- [secretKey]: input.appSecret,
44
- },
45
- });
46
- return {
47
- filePath,
48
- endpointId: input.endpointId,
49
- botKind: intentFields.botKind,
50
- intents: intentFields.intents,
51
- };
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 };
52
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;
@@ -53,19 +48,19 @@ export interface QqConsoleBindState {
53
48
  }
54
49
 
55
50
  export interface QqRuntimeState {
56
- /** 当前 generation 已成功创建的 endpoint(id → 描述) */
57
- readonly endpoints: Map<string, QqRunningEndpoint>;
58
- /** 全局单例:同一时间只允许一个扫码绑定流程 */
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 内只允许一个扫码绑定流程 */
59
54
  bindFlow: QqBindFlowHandle | null;
60
55
  /** 扫码完成后等待 botKind 选择(凭据仅在内存) */
61
56
  pendingBotKind: QqPendingBotKind | null;
62
- /** Console 页面的扫码绑定进度(与 bindFlow 共享单例)。 */
57
+ /** Console 页面的扫码绑定进度(与 bindFlow 共享同一插件瞬态流程)。 */
63
58
  consoleBind: QqConsoleBindState | null;
64
59
  }
65
60
 
66
61
  export function createQqRuntimeState(): QqRuntimeState {
67
62
  return {
68
- endpoints: new Map(),
63
+ consoleAdmission: null,
69
64
  bindFlow: null,
70
65
  pendingBotKind: null,
71
66
  consoleBind: null,
@@ -74,5 +69,5 @@ export function createQqRuntimeState(): QqRuntimeState {
74
69
 
75
70
  export const qqRuntimeStateToken = createToken<QqRuntimeState>(
76
71
  'zhin.qq.runtime-state',
77
- 'QQ adapter runtime state (running endpoints + bind flow singleton)',
72
+ 'QQ transient QR binding state',
78
73
  );
package/src/qq-send.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  * 4. Active message refused (no permission, quota, user switched it off): remember the conversation
11
11
  * for a while and fail fast instead of calling the platform again.
12
12
  */
13
- import type { ConversationRef } from '@zhin.js/im-contract';
14
- import { EndpointDeliveryError } from '@zhin.js/im-contract';
13
+ import { createEndpointOperation, type EndpointOperation } from 'zhin.js/adapter';
14
+ import { EndpointDeliveryError, type ConversationRef } from '@zhin.js/im-contract';
15
+ import type { Sendable } from 'qq-official-bot';
15
16
  import type { QqOutboundElem, QqOutboundMessage } from './outbound.js';
16
17
  import {
17
18
  QQ_ACTIVE_UNAVAILABLE_CODES,
@@ -34,6 +35,20 @@ export interface QqSendContext {
34
35
  }
35
36
 
36
37
  type Kind = ReturnType<typeof qqOutboundKind>;
38
+ interface QqSendExecution extends QqSendContext { readonly operation: EndpointOperation; }
39
+ interface QqMessageDispatch {
40
+ readonly kind: Kind;
41
+ readonly id: string;
42
+ readonly message: QqOutboundMessage;
43
+ readonly source?: QqReplySource;
44
+ readonly options?: QqSendOptions;
45
+ }
46
+ export interface QqMessagesRequest {
47
+ readonly conversation: ConversationRef;
48
+ readonly messages: readonly QqOutboundMessage[];
49
+ readonly signal?: AbortSignal;
50
+ }
51
+
37
52
 
38
53
  function elements(message: QqOutboundMessage): Array<string | QqOutboundElem> {
39
54
  return Array.isArray(message) ? [...message] : [message as string | QqOutboundElem];
@@ -69,14 +84,14 @@ export function markdownAsText(message: QqOutboundMessage): QqOutboundMessage |
69
84
  return text ? [...kept, text] : undefined;
70
85
  }
71
86
 
72
- function dispatch(bot: QqBotTransport, kind: Kind, id: string, message: QqOutboundMessage, source?: QqReplySource, options?: QqSendOptions): Promise<unknown> {
87
+ function dispatch(bot: QqBotTransport, { kind, id, message, source, options }: QqMessageDispatch): Promise<unknown> {
73
88
  switch (kind) {
74
- case 'private': return bot.sendPrivateMessage(id, message, source, options);
75
- case 'group': return bot.sendGroupMessage(id, message, source, options);
76
- case 'channel': return bot.sendGuildMessage(id, message, source, options);
89
+ case 'private': return bot.sendPrivateMessage(id, message as Sendable, source, options);
90
+ case 'group': return bot.sendGroupMessage(id, message as Sendable, source, options);
91
+ case 'channel': return bot.sendGuildMessage(id, message as Sendable, source, options);
77
92
  case 'direct':
78
93
  if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
79
- return bot.sendDirectMessage(id, message, source, options);
94
+ return bot.sendDirectMessage(id, message as Sendable, source, options);
80
95
  }
81
96
  }
82
97
 
@@ -85,15 +100,15 @@ function platformCode(error: unknown): number | undefined {
85
100
  }
86
101
 
87
102
  /** Sends one message, passive if possible; returns the platform result. */
88
- async function sendOne(context: QqSendContext, kind: Kind, id: string, message: QqOutboundMessage): Promise<unknown> {
103
+ async function sendOne(context: QqSendExecution, { kind, id, message }: QqMessageDispatch): Promise<unknown> {
89
104
  const passive = (body: QqOutboundMessage, grant: PassiveReplyGrant) =>
90
- dispatch(context.bot, kind, id, body, grant.source, { msgSeq: grant.msgSeq });
105
+ context.operation.run(() => dispatch(context.bot, { kind, id, message: body, source: grant.source, options: { msgSeq: grant.msgSeq } }));
91
106
  const active = async (body: QqOutboundMessage): Promise<unknown> => {
92
107
  if (context.activeGate.blocked(kind, id)) {
93
108
  throw new EndpointDeliveryError('platform_rejected', 'QQ active messages are unavailable for this conversation', 'not_sent');
94
109
  }
95
110
  try {
96
- return await dispatch(context.bot, kind, id, body);
111
+ return await context.operation.run(() => dispatch(context.bot, { kind, id, message: body }));
97
112
  } catch (error) {
98
113
  const code = platformCode(error);
99
114
  if (code !== undefined && QQ_ACTIVE_UNAVAILABLE_CODES.has(code) && context.activeGate.block(kind, id)) {
@@ -130,9 +145,10 @@ async function sendOne(context: QqSendContext, kind: Kind, id: string, message:
130
145
  }
131
146
 
132
147
  /** Sends every message of one delivery in order; returns the last platform result. */
133
- export async function sendQqMessages(context: QqSendContext, conversation: ConversationRef, messages: readonly QqOutboundMessage[]): Promise<unknown> {
148
+ export async function sendQqMessages(context: QqSendContext, { conversation, messages, signal }: QqMessagesRequest): Promise<unknown> {
134
149
  const kind = qqOutboundKind(conversation);
150
+ const execution = { ...context, operation: createEndpointOperation({ signal }) };
135
151
  let result: unknown;
136
- for (const message of messages) result = await sendOne(context, kind, conversation.id, message);
152
+ for (const message of messages) result = await sendOne(execution, { kind, id: conversation.id, message });
137
153
  return result;
138
154
  }
package/src/webhook.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import path from 'node:path';
5
5
  import type { IncomingMessage, ServerResponse } from 'node:http';
6
- import { Bot, ReceiverMode, type Sendable } from 'qq-official-bot';
6
+ import { Bot, ReceiverMode } from 'qq-official-bot';
7
7
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
8
  import { formatCompact, getLogger } from '@zhin.js/logger';
9
9
  import type { ResolvedQqHttpConfig } from './protocol.js';
@@ -11,11 +11,7 @@ import type { QqBotTransport } from './ws.js';
11
11
 
12
12
  const logger = getLogger('qq');
13
13
 
14
- export type QqHttpBotTransport = QqBotTransport & {
15
- middleware: (ctx: unknown, next: () => Promise<void>) => Promise<unknown>;
16
- };
17
-
18
- export type CreateQqHttpBot = (config: ResolvedQqHttpConfig) => QqHttpBotTransport;
14
+ export type QqHttpBotTransport = Bot<ReceiverMode.MIDDLEWARE, 'koa'>;
19
15
 
20
16
  export interface QqWebhookHandler {
21
17
  readonly config: ResolvedQqHttpConfig;
@@ -53,6 +49,7 @@ export async function handleQqWebhookRequest(
53
49
  req: request,
54
50
  res: response,
55
51
  request: { body: undefined },
52
+ response: {},
56
53
  }, async () => undefined);
57
54
  } catch (error) {
58
55
  logger.error('QQ webhook error:', error);
@@ -78,55 +75,8 @@ export async function readRequestBodyText(request: IncomingMessage): Promise<str
78
75
  return Buffer.concat(chunks).toString('utf8');
79
76
  }
80
77
 
81
- export function defaultCreateHttpBot(config: ResolvedQqHttpConfig): QqHttpBotTransport {
82
- const bot = new Bot({
83
- appid: config.appid,
84
- secret: config.secret,
85
- mode: ReceiverMode.MIDDLEWARE,
86
- platform: 'koa',
87
- sandbox: config.sandbox,
88
- dataDir: path.join(process.cwd(), 'data', 'qq'),
89
- // 关闭内置 log4js,与 WebSocket 模式一致走 zhin adapter logger
90
- logLevel: 'off',
91
- } as never) as Bot<ReceiverMode.MIDDLEWARE, 'koa'> & QqBotTransport;
92
-
93
- return {
94
- api: bot,
95
- start: () => bot.start().then(() => undefined),
96
- stop: () => bot.stop().then(() => undefined),
97
- on: (event, listener) => {
98
- bot.on(event as never, listener as never);
99
- },
100
- removeAllListeners: () => {
101
- bot.removeAllListeners(undefined as never);
102
- },
103
- sendPrivateMessage: (userId, message, source, options) => bot.sendPrivateMessage(userId, message as Sendable, source, options),
104
- sendGroupMessage: (groupId, message, source, options) => bot.sendGroupMessage(groupId, message as Sendable, source, options),
105
- sendGuildMessage: (channelId, message, source, options) => bot.sendGuildMessage(channelId, message as Sendable, source, options),
106
- sendDirectMessage: (guildId, message, source, options) => bot.sendDirectMessage(guildId, message as Sendable, source, options),
107
- recallPrivateMessage: (userId, messageId) => bot.recallPrivateMessage(userId, messageId),
108
- recallGroupMessage: (groupId, messageId) => bot.recallGroupMessage(groupId, messageId),
109
- recallGuildMessage: (channelId, messageId) => bot.recallGuildMessage(channelId, messageId),
110
- recallDirectMessage: (guildId, messageId) => bot.recallDirectMessage(guildId, messageId),
111
- getGuilds: () => bot.guildService.getList(),
112
- getChannels: (guildId) => bot.channelService.getList(guildId),
113
- getChannelInfo: (channelId) => bot.channelService.getInfo(channelId),
114
- getGuildMember: (guildId, userId) => bot.memberService.getGuildMemberInfo(guildId, userId),
115
- getGuildRoles: (guildId) => bot.guildService.getRoles(guildId),
116
- createGuildRole: (guildId, name, color) =>
117
- bot.guildService.createRole(guildId, { name, color: color || 0, hoist: 0 }),
118
- addMemberRole: async (guildId, channelId, userId, roleId) => {
119
- await bot.memberService.addMemberRole(guildId, channelId, userId, roleId);
120
- return true;
121
- },
122
- removeMemberRole: async (guildId, channelId, userId, roleId) => {
123
- await bot.memberService.removeMemberRole(guildId, channelId, userId, roleId);
124
- return true;
125
- },
126
- approveGroupJoinRequest: (groupId, userId, options) =>
127
- (bot as { approveGroupJoinRequest?: QqBotTransport['approveGroupJoinRequest'] })
128
- .approveGroupJoinRequest?.(groupId, userId, options)
129
- ?? Promise.reject(new Error('approveGroupJoinRequest is not available')),
130
- middleware: (ctx, next) => bot.middleware(ctx as never, next as never),
131
- };
78
+ export function qqHttpBotOptions(config: ResolvedQqHttpConfig): ConstructorParameters<typeof Bot<ReceiverMode.MIDDLEWARE, 'koa'>>[0] {
79
+ return { handleProcessErrors: false, appid: config.appid, secret: config.secret,
80
+ mode: ReceiverMode.MIDDLEWARE, platform: 'koa', sandbox: config.sandbox,
81
+ dataDir: path.join(process.cwd(), 'data', 'qq'), logLevel: 'off' };
132
82
  }