@zhin.js/adapter-qq 5.0.2 → 6.0.0

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 (130) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +75 -409
  3. package/adapters/qq.ts +51 -0
  4. package/agent/tools/add_role.ts +2 -2
  5. package/agent/tools/channel_info.ts +2 -2
  6. package/agent/tools/create_role.ts +2 -2
  7. package/agent/tools/list_channels.ts +2 -2
  8. package/agent/tools/list_guilds.ts +2 -2
  9. package/agent/tools/list_roles.ts +2 -2
  10. package/agent/tools/member_detail.ts +2 -2
  11. package/agent/tools/remove_role.ts +2 -2
  12. package/commands/endpoint/add/[name:string=].ts +21 -0
  13. package/commands/endpoint/cancel.ts +15 -0
  14. package/commands/endpoint/list.ts +10 -0
  15. package/commands/endpoint/remove/[name:string].ts +15 -0
  16. package/lib/endpoint.d.ts +75 -0
  17. package/lib/endpoint.js +358 -0
  18. package/lib/index.d.ts +6 -0
  19. package/lib/index.js +6 -0
  20. package/lib/platform-permit.d.ts +16 -0
  21. package/lib/{src/platform-permit.js → platform-permit.js} +1 -2
  22. package/lib/protocol.d.ts +96 -0
  23. package/lib/protocol.js +129 -0
  24. package/lib/qq-agent-deps.d.ts +21 -0
  25. package/lib/qq-agent-deps.js +30 -0
  26. package/lib/qq-bind-api.d.ts +36 -0
  27. package/lib/{src/qq-bind-api.js → qq-bind-api.js} +0 -1
  28. package/lib/qq-bind-flow.d.ts +19 -0
  29. package/lib/{src/qq-bind-flow.js → qq-bind-flow.js} +0 -1
  30. package/lib/qq-bind-persist.d.ts +12 -0
  31. package/lib/{src/qq-bind-persist.js → qq-bind-persist.js} +4 -5
  32. package/lib/qq-endpoint-commands.d.ts +25 -0
  33. package/lib/qq-endpoint-commands.js +147 -0
  34. package/lib/qq-endpoint-config.d.ts +17 -0
  35. package/lib/qq-endpoint-config.js +110 -0
  36. package/lib/qq-runtime-state.d.ts +17 -0
  37. package/lib/qq-runtime-state.js +13 -0
  38. package/lib/webhook.d.ts +18 -0
  39. package/lib/webhook.js +100 -0
  40. package/lib/ws.d.ts +24 -0
  41. package/lib/ws.js +118 -0
  42. package/package.json +51 -40
  43. package/plugin.ts +16 -0
  44. package/schema.json +85 -0
  45. package/src/endpoint.ts +362 -596
  46. package/src/index.ts +56 -186
  47. package/src/platform-permit.ts +1 -1
  48. package/src/protocol.ts +229 -0
  49. package/src/qq-agent-deps.ts +49 -12
  50. package/src/qq-bind-persist.ts +4 -4
  51. package/src/qq-endpoint-commands.ts +174 -0
  52. package/src/qq-endpoint-config.ts +135 -0
  53. package/src/qq-runtime-state.ts +36 -0
  54. package/src/webhook.ts +127 -0
  55. package/src/ws.ts +172 -0
  56. package/client/Dashboard.tsx +0 -230
  57. package/client/index.tsx +0 -11
  58. package/client/tsconfig.json +0 -7
  59. package/client/utils/api.ts +0 -30
  60. package/dist/index.js +0 -30
  61. package/lib/agent/tools/add_role.js +0 -23
  62. package/lib/agent/tools/add_role.js.map +0 -1
  63. package/lib/agent/tools/channel_info.js +0 -19
  64. package/lib/agent/tools/channel_info.js.map +0 -1
  65. package/lib/agent/tools/create_role.js +0 -22
  66. package/lib/agent/tools/create_role.js.map +0 -1
  67. package/lib/agent/tools/list_channels.js +0 -20
  68. package/lib/agent/tools/list_channels.js.map +0 -1
  69. package/lib/agent/tools/list_guilds.js +0 -17
  70. package/lib/agent/tools/list_guilds.js.map +0 -1
  71. package/lib/agent/tools/list_roles.js +0 -20
  72. package/lib/agent/tools/list_roles.js.map +0 -1
  73. package/lib/agent/tools/member_detail.js +0 -20
  74. package/lib/agent/tools/member_detail.js.map +0 -1
  75. package/lib/agent/tools/remove_role.js +0 -23
  76. package/lib/agent/tools/remove_role.js.map +0 -1
  77. package/lib/package.json +0 -105
  78. package/lib/src/adapter.js +0 -81
  79. package/lib/src/adapter.js.map +0 -1
  80. package/lib/src/endpoint.js +0 -600
  81. package/lib/src/endpoint.js.map +0 -1
  82. package/lib/src/gateway-config.js +0 -32
  83. package/lib/src/gateway-config.js.map +0 -1
  84. package/lib/src/group-at-normalize.js +0 -68
  85. package/lib/src/group-at-normalize.js.map +0 -1
  86. package/lib/src/inbound-action.js +0 -57
  87. package/lib/src/inbound-action.js.map +0 -1
  88. package/lib/src/inbound-normalize.js +0 -47
  89. package/lib/src/inbound-normalize.js.map +0 -1
  90. package/lib/src/index.js +0 -194
  91. package/lib/src/index.js.map +0 -1
  92. package/lib/src/mention-enrich.js +0 -52
  93. package/lib/src/mention-enrich.js.map +0 -1
  94. package/lib/src/outbound-keyboard.js +0 -100
  95. package/lib/src/outbound-keyboard.js.map +0 -1
  96. package/lib/src/outbound-markdown.js +0 -189
  97. package/lib/src/outbound-markdown.js.map +0 -1
  98. package/lib/src/outbound-media.js +0 -51
  99. package/lib/src/outbound-media.js.map +0 -1
  100. package/lib/src/outbound-mixed-media.js +0 -78
  101. package/lib/src/outbound-mixed-media.js.map +0 -1
  102. package/lib/src/platform-permit.js.map +0 -1
  103. package/lib/src/qq-agent-deps.js +0 -10
  104. package/lib/src/qq-agent-deps.js.map +0 -1
  105. package/lib/src/qq-bind-api.js.map +0 -1
  106. package/lib/src/qq-bind-flow.js.map +0 -1
  107. package/lib/src/qq-bind-persist.js.map +0 -1
  108. package/lib/src/qq-endpoint-manager.js +0 -159
  109. package/lib/src/qq-endpoint-manager.js.map +0 -1
  110. package/lib/src/sdk-version.js +0 -19
  111. package/lib/src/sdk-version.js.map +0 -1
  112. package/lib/src/segment-mapper.js +0 -2
  113. package/lib/src/segment-mapper.js.map +0 -1
  114. package/lib/src/types.js +0 -2
  115. package/lib/src/types.js.map +0 -1
  116. package/plugin.yml +0 -3
  117. package/src/adapter.ts +0 -97
  118. package/src/gateway-config.ts +0 -48
  119. package/src/group-at-normalize.ts +0 -75
  120. package/src/inbound-action.ts +0 -87
  121. package/src/inbound-normalize.ts +0 -57
  122. package/src/mention-enrich.ts +0 -71
  123. package/src/outbound-keyboard.ts +0 -117
  124. package/src/outbound-markdown.ts +0 -212
  125. package/src/outbound-media.ts +0 -58
  126. package/src/outbound-mixed-media.ts +0 -101
  127. package/src/qq-endpoint-manager.ts +0 -199
  128. package/src/sdk-version.ts +0 -21
  129. package/src/segment-mapper.ts +0 -1
  130. package/src/types.ts +0 -52
package/src/endpoint.ts CHANGED
@@ -1,675 +1,441 @@
1
1
  /**
2
- * QQ 官方 Endpoint 实现
2
+ * QQ endpoints — lifecycle, outbound, admit, agent tool surface.
3
3
  */
4
+ import type {
5
+ EndpointChannel,
6
+ EndpointInstance,
7
+ EndpointManagement,
8
+ } from '@zhin.js/adapter';
9
+ import type { MessageGateway } from '@zhin.js/core/runtime';
10
+ import type { HttpHost } from '@zhin.js/host-http';
11
+ import { formatCompact, getLogger } from '@zhin.js/logger';
12
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
13
+ import { registerQqAgentEndpoint } from './qq-agent-deps.js';
4
14
  import {
5
- Bot as QQOfficialClient,
6
- PrivateMessageEvent,
7
- GroupMessageEvent,
8
- } from "qq-official-bot";
9
- import path from "path";
10
- import { formatCompact } from "@zhin.js/logger";
11
- import { registerFetchRoute, type RouterContext } from "@zhin.js/host-router/router";
12
- import { Endpoint, Message, SendOptions, SendContent, segment, type MessageElement } from 'zhin.js';
13
-
14
- import { ReceiverMode, type QQEndpointConfig, type ApplicationPlatform } from "./types.js";
15
- import type { QQAdapter } from "./adapter.js";
16
- import { normalizeQqInboundWsPayload, type QqWsPacket } from "./inbound-normalize.js";
17
- import { normalizeGroupAtPrefix } from "./group-at-normalize.js";
18
- import { SDK_VERSION, SDK_VERSION_HEADER } from "./sdk-version.js";
19
- import { applyCustomAuthEndpoints } from "./gateway-config.js";
20
- import { normalizeOutboundMarkdown, asOutboundSegments, splitReplyPrefix } from "./outbound-markdown.js";
21
- import { normalizeOutboundMedia } from "./outbound-media.js";
22
- import { fromCanonicalSegments, toCanonicalSegments } from "./segment-mapper.js";
23
- import { enrichCanonicalMentionNames } from "./mention-enrich.js";
15
+ formatInboundContent,
16
+ formatInboundTarget,
17
+ formatOutboundText,
18
+ parseSendTarget,
19
+ resolveOutboundMessageId,
20
+ senderDisplayName,
21
+ type QqInboundMessage,
22
+ type ResolvedQqHttpConfig,
23
+ type ResolvedQqWebsocketConfig,
24
+ } from './protocol.js';
24
25
  import {
25
- buildMixedMediaMessagePayload,
26
- buildQqImageUploadPayload,
27
- firstImageSegment,
28
- shouldSendTextImageMixedMedia,
29
- } from "./outbound-mixed-media.js";
30
- import { normalizeQqGuildSenderForPermit } from "./platform-permit.js";
31
- import { expandKeyboardSegmentsForQq } from "./outbound-keyboard.js";
32
- import { formatQqActionMessage, type QqActionNoticeEvent } from "./inbound-action.js";
33
-
34
- /** 从 qq-official-bot SendResult / 审核回包中解析出站消息 ID */
35
- export function resolveOutboundMessageId(result: unknown): string {
36
- if (!result || typeof result !== "object") {
37
- throw new Error("QQ 发送消息失败:响应为空");
38
- }
39
- const row = result as Record<string, unknown>;
40
- const nested = row.data && typeof row.data === "object"
41
- ? row.data as Record<string, unknown>
42
- : undefined;
43
- const audit = (row.message_audit ?? nested?.message_audit) as Record<string, unknown> | undefined;
44
- const id = row.id ?? row.message_id ?? audit?.audit_id;
45
- if (id == null || id === "") {
46
- const code = row.code;
47
- const msg = row.message;
48
- throw new Error(
49
- code != null
50
- ? `QQ 发送消息失败(${String(code)})${msg ? `: ${String(msg)}` : ""}`
51
- : "QQ 发送消息失败:响应缺少消息 ID",
52
- );
53
- }
54
- return String(id);
26
+ defaultCreateHttpBot,
27
+ registerQqWebhookRoutes,
28
+ type CreateQqHttpBot,
29
+ type QqHttpBotTransport,
30
+ } from './webhook.js';
31
+ import {
32
+ bindQqBotInboundEvents,
33
+ defaultCreateBot,
34
+ normalizeQqMessage,
35
+ type CreateQqBot,
36
+ type QqBotTransport,
37
+ } from './ws.js';
38
+
39
+ const logger = getLogger('qq');
40
+
41
+ export type { CreateQqBot, QqBotTransport } from './ws.js';
42
+ export type { CreateQqHttpBot, QqHttpBotTransport } from './webhook.js';
43
+
44
+ export interface QqEndpointOptions {
45
+ readonly id: CapabilityId;
46
+ readonly gateway: MessageGateway;
47
+ readonly config: ResolvedQqWebsocketConfig;
48
+ readonly createBot?: CreateQqBot;
55
49
  }
56
50
 
57
- export class QQEndpoint<T extends ReceiverMode, M extends ApplicationPlatform = ApplicationPlatform>
58
- extends QQOfficialClient
59
- implements Endpoint<QQEndpointConfig<T, M>, PrivateMessageEvent | GroupMessageEvent>
60
- {
61
- $connected: boolean = false;
62
- declare $config: QQEndpointConfig<T, M>;
63
- /** 平台侧机器人 user_id,用于 @ 触发匹配 */
64
- $platformUserId?: string;
51
+ export class QqWebsocketEndpoint implements EndpointInstance {
52
+ readonly #options: QqEndpointOptions;
53
+ readonly #createBot: CreateQqBot;
54
+ #bot: QqBotTransport | null = null;
55
+ #open = false;
56
+ #started = false;
57
+ #unregisterAgent?: () => void;
58
+ readonly management: EndpointManagement = createQqEndpointManagement(this);
65
59
 
66
- get pluginLogger() {
67
- return this.adapter.plugin.logger;
60
+ constructor(options: QqEndpointOptions) {
61
+ this.#options = options;
62
+ this.#createBot = options.createBot ?? defaultCreateBot;
68
63
  }
69
64
 
70
- get $id() {
71
- return this.$config.name;
65
+ /** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
66
+ get name(): string {
67
+ return this.#options.config.name;
72
68
  }
73
69
 
74
- constructor(public adapter: QQAdapter, config: QQEndpointConfig<T, M>) {
75
- if (!config.data_dir) config.data_dir = path.join(process.cwd(), "data");
76
- if (config.mode === ReceiverMode.MIDDLEWARE) {
77
- const mw = config as QQEndpointConfig<ReceiverMode.MIDDLEWARE, M> & {
78
- platform?: ApplicationPlatform;
79
- };
80
- if (!mw.platform) {
81
- mw.platform = (mw.application ?? "koa") as ApplicationPlatform;
82
- }
70
+ async start(): Promise<void> {
71
+ if (this.#started) return;
72
+ this.#started = true;
73
+ try {
74
+ this.#unregisterAgent = registerQqAgentEndpoint(this.#options.config.name, this);
75
+ this.#bot = this.#createBot(this.#options.config);
76
+ this.#bindBot(this.#bot);
77
+ await this.#bot.start();
78
+ logger.info(formatCompact({
79
+ op: 'connect',
80
+ endpoint: this.#options.config.name,
81
+ mode: 'websocket',
82
+ appid: this.#options.config.appid,
83
+ }));
84
+ } catch (error) {
85
+ await this.stop();
86
+ logger.error('Failed to connect QQ websocket:', error);
87
+ throw error;
83
88
  }
84
- super(config);
85
- this.$config = config;
86
- this.attachSdkVersionHeader();
87
- applyCustomAuthEndpoints(this, config, this.pluginLogger);
88
89
  }
89
90
 
90
- /** 出站 QQ API 请求附带 SDK 版本,便于平台侧日志排查 */
91
- private attachSdkVersionHeader(): void {
92
- this.request.interceptors.request.use((reqConfig) => {
93
- reqConfig.headers[SDK_VERSION_HEADER] = SDK_VERSION;
94
- return reqConfig;
95
- });
91
+ open(): void {
92
+ this.#open = true;
96
93
  }
97
94
 
98
- /** 归一化 QQ API v2 群聊字段后再交给 qq-official-bot 解析 */
99
- dispatchEvent(event: string, wsRes: QqWsPacket): void {
100
- this.pluginLogger.debug({
101
- op: 'inbound_ws',
102
- event,
103
- group: String(wsRes.d?.group_openid ?? wsRes.d?.group_id ?? '?'),
104
- });
105
- if (event === 'GROUP_AT_MESSAGE_CREATE' || event === 'GROUP_MESSAGE_CREATE') {
106
- this.pluginLogger.info(
107
- `qq inbound ws: ${event} group=${String(wsRes.d?.group_openid ?? wsRes.d?.group_id ?? '?')}`,
108
- );
109
- }
110
- normalizeQqInboundWsPayload(event, wsRes);
111
- super.dispatchEvent(event, wsRes);
112
- }
113
-
114
- private guildMemberCache = new Map<string, { at: number; role?: string; permissions: string[] }>();
115
- private guildOwnerCache = new Map<string, { at: number; ownerId?: string }>();
116
-
117
- private async enrichGuildSender(
118
- message: ReturnType<QQEndpoint<ReceiverMode>["$formatMessage"]>,
119
- guildId: string,
120
- userId: string,
121
- ): Promise<void> {
122
- const key = `${guildId}:${userId}`;
123
- const now = Date.now();
124
- const cached = this.guildMemberCache.get(key);
125
- if (cached && now - cached.at < 60_000) {
126
- message.$sender.role = cached.role;
127
- message.$sender.permissions = cached.permissions;
128
- return;
129
- }
130
- try {
131
- let ownerId: string | undefined;
132
- const ownerCached = this.guildOwnerCache.get(guildId);
133
- if (ownerCached && now - ownerCached.at < 300_000) {
134
- ownerId = ownerCached.ownerId;
135
- } else {
136
- const guild = await this.getGuildInfo(guildId);
137
- ownerId = guild?.owner_id?.toString?.() ?? guild?.owner?.id?.toString?.();
138
- this.guildOwnerCache.set(guildId, { at: now, ownerId });
139
- }
140
- const member = await this.getGuildMember(guildId, userId);
141
- const roleIds = Array.isArray(member?.roles)
142
- ? member.roles.map((r: unknown) =>
143
- typeof r === "string" ? r : (r as { id?: string })?.id ?? String(r),
144
- )
145
- : [];
146
- const isOwner = ownerId === userId;
147
- const isAdmin = !isOwner && roleIds.length > 0;
148
- const normalized = normalizeQqGuildSenderForPermit({ roles: roleIds, isOwner, isAdmin });
149
- const entry = {
150
- at: now,
151
- role: normalized.role,
152
- permissions: normalized.permissions ?? [],
153
- };
154
- this.guildMemberCache.set(key, entry);
155
- message.$sender.role = entry.role;
156
- message.$sender.permissions = entry.permissions;
157
- } catch {
158
- // 保守拒绝
159
- }
95
+ close(): void {
96
+ this.#open = false;
160
97
  }
161
98
 
162
- private async handleQQMessage(msg: PrivateMessageEvent | GroupMessageEvent): Promise<void> {
163
- try {
164
- const message = this.$formatMessage(msg);
165
- if (msg.message_type === "guild" && msg.guild_id && message.$sender.id) {
166
- await this.enrichGuildSender(message, String(msg.guild_id), message.$sender.id);
99
+ async stop(): Promise<void> {
100
+ this.#open = false;
101
+ this.#unregisterAgent?.();
102
+ this.#unregisterAgent = undefined;
103
+ if (this.#bot) {
104
+ try {
105
+ this.#bot.removeAllListeners();
106
+ await this.#bot.stop();
107
+ } catch {
108
+ /* ignore */
167
109
  }
168
- this.adapter.emit("message.receive", message);
169
- this.pluginLogger.debug(
170
- `${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`,
171
- );
172
- } catch (err) {
173
- this.pluginLogger.warn(
174
- `qq format inbound failed (${msg.message_type}): ${err instanceof Error ? err.message : String(err)}`,
175
- );
110
+ this.#bot = null;
176
111
  }
177
- }
178
-
179
- private async handleQQAction(event: QqActionNoticeEvent): Promise<void> {
180
- try {
181
- await event.reply(0);
182
- const channel = event.notice_type === 'group'
183
- ? { id: String(event.group_id), type: 'group' as const }
184
- : event.notice_type === 'guild'
185
- ? { id: String(event.channel_id), type: 'channel' as const }
186
- : { id: String(event.operator_id), type: 'private' as const };
187
- const message = formatQqActionMessage(event, {
188
- endpointName: this.$config.name,
189
- send: async (content) => this.adapter.sendMessage({
190
- ...channel,
191
- context: 'qq',
192
- endpoint: this.$config.name,
193
- content,
194
- }),
195
- });
196
- await this.adapter.receiveMessageAwait(message);
197
- this.pluginLogger.debug(
198
- `qq action ${message.$channel.type}(${message.$channel.id}): ${message.$raw}`,
199
- );
200
- } catch (err) {
201
- this.pluginLogger.warn(
202
- `qq format action failed: ${err instanceof Error ? err.message : String(err)}`,
203
- );
112
+ this.#started = false;
113
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
114
+ }
115
+
116
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
117
+ const body = formatOutboundText(payload);
118
+ const parsed = parseSendTarget(target);
119
+ const bot = this.#requireBot();
120
+ let result: unknown;
121
+ switch (parsed.kind) {
122
+ case 'private':
123
+ result = await bot.sendPrivateMessage(parsed.id, body);
124
+ break;
125
+ case 'group':
126
+ result = await bot.sendGroupMessage(parsed.id, body);
127
+ break;
128
+ case 'channel':
129
+ result = await bot.sendGuildMessage(parsed.id, body);
130
+ break;
131
+ case 'direct':
132
+ if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
133
+ result = await bot.sendDirectMessage(parsed.id, body);
134
+ break;
135
+ default:
136
+ throw new Error(`unsupported QQ target kind: ${String((parsed as ParsedSendTarget).kind)}`);
204
137
  }
138
+ const messageId = `${parsed.kind}-${parsed.id}:${resolveOutboundMessageId(result)}`;
139
+ logger.debug(formatCompact({
140
+ op: 'qq_send',
141
+ endpoint: this.#options.config.name,
142
+ target,
143
+ messageId,
144
+ }));
145
+ return messageId;
146
+ }
147
+
148
+ /** Test / internal: admit a message when open. */
149
+ admit(msg: QqInboundMessage): void {
150
+ if (!this.#open) return;
151
+ const target = formatInboundTarget(msg);
152
+ void this.#options.gateway.receive({
153
+ adapter: this.#options.id,
154
+ target,
155
+ content: formatInboundContent(msg),
156
+ sender: senderDisplayName(msg),
157
+ id: msg.id,
158
+ metadata: Object.freeze({
159
+ endpoint: this.#options.config.name,
160
+ channelKind: msg.channelKind,
161
+ userId: msg.authorId,
162
+ guildId: msg.guildId,
163
+ roles: msg.authorRoles,
164
+ // AT 事件本身即 @ 机器人;新 Runtime 纯文本 content 需经 metadata 传递
165
+ ...(msg.mentioned ? { mentioned: true } : {}),
166
+ }),
167
+ }).catch((err) => {
168
+ logger.warn(formatCompact({
169
+ op: 'qq_gateway_receive_failed',
170
+ target,
171
+ error: err instanceof Error ? err.message : String(err),
172
+ }));
173
+ });
205
174
  }
206
175
 
207
- private handleGroupNotice(event: string, payload: { group_id?: string; operator_id?: string }): void {
208
- this.pluginLogger.info(
209
- `qq notice ${event}: group=${payload.group_id ?? '?'} operator=${payload.operator_id ?? '?'}`,
210
- );
211
- }
212
-
213
- async $connect(): Promise<void> {
214
- this.on("message.group", this.handleQQMessage.bind(this));
215
- this.on("message.guild", this.handleQQMessage.bind(this));
216
- this.on("message.private", this.handleQQMessage.bind(this));
217
- this.on("notice.friend.action", this.handleQQAction.bind(this));
218
- this.on("notice.group.action", this.handleQQAction.bind(this));
219
- this.on("notice.guild.action", this.handleQQAction.bind(this));
220
- this.on("notice.group.increase", (e) => this.handleGroupNotice('group.add_robot', e));
221
- this.on("notice.group.decrease", (e) => this.handleGroupNotice('group.del_robot', e));
222
- this.on("notice.group.receive_open", (e) => this.handleGroupNotice('group.msg_receive_open', e));
223
- this.on("notice.group.receive_close", (e) => this.handleGroupNotice('group.msg_receive_close', e));
224
- await this.start();
225
- this.mountWebhookReceiver();
226
- this.$connected = true;
227
- try {
228
- const self = await this.getSelfInfo();
229
- const uid = (self as { id?: string; user_id?: string; username?: string })?.id
230
- ?? (self as { user_id?: string })?.user_id;
231
- if (uid) this.$platformUserId = String(uid);
232
- } catch (err) {
233
- this.pluginLogger.debug(
234
- `${this.$config.name} getSelfInfo failed: ${err instanceof Error ? err.message : String(err)}`,
235
- );
236
- }
176
+ getGuilds() {
177
+ return this.#requireBot().getGuilds();
237
178
  }
238
179
 
239
- async $disconnect(): Promise<void> {
240
- // Endpoint 继承自 EventEmitter,清除 $connect() 注册的所有监听器
241
- (this as unknown as import('node:events').EventEmitter).removeAllListeners();
242
- await this.stop();
243
- this.$connected = false;
180
+ getChannels(guildId: string) {
181
+ return this.#requireBot().getChannels(guildId);
244
182
  }
245
183
 
246
- private resolveWebhookPath(): string {
247
- const raw = this.$config.webhookPath ?? "/qq/webhook";
248
- return raw.startsWith("/") ? raw : `/${raw}`;
184
+ getChannelInfo(channelId: string) {
185
+ return this.#requireBot().getChannelInfo(channelId);
249
186
  }
250
187
 
251
- /**
252
- * Webhook 入站:middleware(挂 host-router)或独立 HTTP 端口(qq-official-bot 内置服务)。
253
- */
254
- private mountWebhookReceiver(): void {
255
- const mode = this.$config.mode;
256
- if (mode === ReceiverMode.MIDDLEWARE) {
257
- const router = this.adapter.getRouter();
258
- if (!router) {
259
- throw new Error("QQ mode=middleware 需要启用 @zhin.js/host-router 插件");
260
- }
261
- const webhookPath = this.resolveWebhookPath();
262
- const mw = this.middleware as (
263
- ctx: RouterContext,
264
- next: () => Promise<void>,
265
- ) => Promise<unknown>;
266
- router.post(webhookPath, async (ctx: RouterContext) => {
267
- this.pluginLogger.debug(ctx.body);
268
- await mw(ctx, async () => {});
269
- });
270
- this.pluginLogger.info(formatCompact({
271
- op: "webhook",
272
- mode: "middleware",
273
- path: webhookPath,
274
- url: `POST ${webhookPath}`,
275
- note: "无 /api 前缀;Host API 才走 /api/*",
276
- }));
277
- return;
278
- }
279
- if (mode === ReceiverMode.WEBHOOK) {
280
- const cfg = this.$config as QQEndpointConfig<ReceiverMode.WEBHOOK>;
281
- this.pluginLogger.info(formatCompact({
282
- op: "webhook",
283
- mode: "standalone",
284
- port: cfg.port,
285
- path: cfg.path,
286
- url: `http://127.0.0.1:${cfg.port}${cfg.path}`,
287
- }));
288
- }
188
+ getGuildMember(guildId: string, userId: string) {
189
+ return this.#requireBot().getGuildMember(guildId, userId);
289
190
  }
290
191
 
291
- private toWireSendContent(content: SendContent): MessageElement[] {
292
- const items = (Array.isArray(content) ? content : [content]).map((item) =>
293
- typeof item === "string" ? { type: "text", data: { text: item } } : item as MessageElement,
294
- );
295
- return fromCanonicalSegments(toCanonicalSegments(items as never));
192
+ getGuildRoles(guildId: string) {
193
+ return this.#requireBot().getGuildRoles(guildId);
296
194
  }
297
195
 
298
- $formatMessage(msg: PrivateMessageEvent | GroupMessageEvent) {
299
- const raw = msg as PrivateMessageEvent & GroupMessageEvent & {
300
- group_openid?: string;
301
- author?: { member_openid?: string; user_openid?: string; id?: string; username?: string };
302
- mentions?: Array<{ id?: string; member_openid?: string; username?: string; nickname?: string }>;
303
- __zhin_group_at?: boolean;
304
- };
305
-
306
- if (msg.message_type === "group") {
307
- if (!raw.group_id && raw.group_openid) {
308
- raw.group_id = raw.group_openid;
309
- }
310
- if (!msg.user_id && raw.author) {
311
- const uid = raw.author.member_openid ?? raw.author.user_openid ?? raw.author.id;
312
- if (uid) msg.user_id = String(uid);
313
- }
314
- }
196
+ createGuildRole(guildId: string, name: string, color?: number) {
197
+ return this.#requireBot().createGuildRole(guildId, name, color);
198
+ }
315
199
 
316
- let target_id = msg.user_id;
317
- if (msg.message_type === "guild") target_id = msg.channel_id!;
318
- if (msg.message_type === "group") target_id = raw.group_id ?? msg.group_id ?? "";
319
- if (msg.sub_type === "direct") target_id = `direct:${msg.guild_id}`;
320
-
321
- let content = msg.message;
322
- if (msg.message_type === "group" && Array.isArray(content)) {
323
- const endpointAtIds = [this.$platformUserId, this.$config.appid]
324
- .filter((id): id is string => Boolean(id))
325
- .map(String);
326
- content = normalizeGroupAtPrefix(content, endpointAtIds, raw.__zhin_group_at === true);
327
- }
200
+ addMemberRole(guildId: string, channelId: string, userId: string, roleId: string) {
201
+ return this.#requireBot().addMemberRole(guildId, channelId, userId, roleId);
202
+ }
328
203
 
329
- if (Array.isArray(content)) {
330
- content = enrichCanonicalMentionNames(
331
- toCanonicalSegments(content),
332
- this.adapter,
333
- raw.mentions,
334
- );
335
- }
204
+ removeMemberRole(guildId: string, channelId: string, userId: string, roleId: string) {
205
+ return this.#requireBot().removeMemberRole(guildId, channelId, userId, roleId);
206
+ }
336
207
 
337
- const result = Message.from(msg, {
338
- $id: msg.message_id?.toString(),
339
- $adapter: "qq" as const,
340
- $endpoint: this.$config.name,
341
- $sender: {
342
- id: msg.sender.user_id?.toString(),
343
- name: msg.sender.user_name?.toString(),
344
- },
345
- $channel: {
346
- id: target_id,
347
- type: msg.message_type === "guild" ? "channel" : msg.message_type,
348
- },
349
- $content: content,
350
- $raw: msg.raw_message,
351
- $timestamp: Date.now(),
352
- $recall: async () => {
353
- await this.$recallMessage(result.$id);
354
- },
355
- $reply: async (content: SendContent, quote: boolean | string = true): Promise<string> => {
356
- if (!Array.isArray(content)) content = [content];
357
- if (quote) content.unshift({ type: "reply", data: { id: typeof quote === "boolean" ? result.$id : quote } });
358
- return await this.adapter.sendMessage({
359
- ...result.$channel,
360
- context: "qq",
361
- endpoint: this.$config.name,
362
- content,
363
- });
364
- },
208
+ #bindBot(bot: QqBotTransport): void {
209
+ bindQqBotInboundEvents(bot, (raw) => {
210
+ const msg = normalizeQqMessage(raw);
211
+ if (msg) this.admit(msg);
365
212
  });
366
- return result;
367
213
  }
368
214
 
369
- async $sendMessage(options: SendOptions): Promise<string> {
370
- const expanded = expandKeyboardSegmentsForQq(options.content);
371
- const canonicalized = this.toWireSendContent(expanded);
372
- const mediaNorm = normalizeOutboundMedia(canonicalized);
373
- const markdownMode = this.$config.outboundMarkdown;
374
-
375
- if (
376
- (options.type === "group" || options.type === "private")
377
- && !options.id.startsWith("direct:")
378
- && shouldSendTextImageMixedMedia(mediaNorm, markdownMode)
379
- ) {
380
- return await this.$sendTextImageMixedMedia(options, mediaNorm);
381
- }
382
-
383
- const content = normalizeOutboundMarkdown(mediaNorm, markdownMode);
384
- switch (options.type) {
385
- case "private": {
386
- if (options.id.startsWith("direct:")) {
387
- const id = options.id.replace("direct:", "");
388
- const result = await this.sendDirectMessage(id, content);
389
- this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
390
- return `direct-${options.id}:${resolveOutboundMessageId(result)}`;
391
- } else {
392
- const result = await this.sendPrivateMessage(options.id, content);
393
- this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
394
- return `private-${options.id}:${resolveOutboundMessageId(result)}`;
395
- }
396
- }
397
- case "group": {
398
- const result = await this.sendGroupMessage(options.id, content);
399
- this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
400
- return `group-${options.id}:${resolveOutboundMessageId(result)}`;
401
- }
402
- case "channel": {
403
- const result = await this.sendGuildMessage(options.id, content);
404
- this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
405
- return `channel-${options.id}:${resolveOutboundMessageId(result)}`;
406
- }
407
- default:
408
- throw new Error(`unsupported channel type ${options.type}`);
409
- }
215
+ #requireBot(): QqBotTransport {
216
+ if (!this.#bot) throw new Error('QQ bot not connected');
217
+ return this.#bot;
410
218
  }
219
+ }
411
220
 
412
- /** 内联图 + 文本:上传 file_info 后以 msg_type=7 图文混排发送(二维码等场景) */
413
- private async $sendTextImageMixedMedia(
414
- options: SendOptions,
415
- content: SendContent,
416
- ): Promise<string> {
417
- const endpointPath = this.resolveV2EndpointPath(options);
418
- const { body } = splitReplyPrefix(asOutboundSegments(content));
419
- const image = firstImageSegment(body);
420
- if (!image) {
421
- throw new Error("QQ 图文混排:未找到图片段");
422
- }
221
+ type ParsedSendTarget = ReturnType<typeof parseSendTarget>;
423
222
 
424
- const uploadPayload = buildQqImageUploadPayload(image);
425
- const { data: uploadResult } = await this.request.post(
426
- `${endpointPath}/files`,
427
- { ...uploadPayload, srv_send_msg: false },
428
- ) as { data: { file_info?: string } };
223
+ export interface QqHttpEndpointOptions {
224
+ readonly id: CapabilityId;
225
+ readonly gateway: MessageGateway;
226
+ readonly http: HttpHost;
227
+ readonly config: ResolvedQqHttpConfig;
228
+ readonly createBot?: CreateQqHttpBot;
229
+ }
429
230
 
430
- if (!uploadResult?.file_info) {
431
- throw new Error("QQ 图文混排:图片上传未返回 file_info");
432
- }
231
+ /** Webhook / middleware inbound via httpHostToken POST (qq-official-bot Middleware receiver). */
232
+ export class QqHttpEndpoint implements EndpointInstance {
233
+ readonly #options: QqHttpEndpointOptions;
234
+ readonly #createBot: CreateQqHttpBot;
235
+ #bot: QqHttpBotTransport | null = null;
236
+ #routeReleases: ReturnType<typeof registerQqWebhookRoutes> = [];
237
+ #open = false;
238
+ #started = false;
239
+ #unregisterAgent?: () => void;
240
+ readonly management: EndpointManagement = createQqEndpointManagement(this);
433
241
 
434
- const messagePayload = buildMixedMediaMessagePayload(content, uploadResult.file_info);
435
- const { data: result } = await this.request.post(
436
- `${endpointPath}/messages`,
437
- messagePayload,
438
- );
439
- this.pluginLogger.debug(
440
- `${this.$config.name} send mixed-media ${options.type}(${options.id}):${segment.raw(content)}`,
441
- );
442
- const idPrefix = options.type === "group" ? "group" : "private";
443
- return `${idPrefix}-${options.id}:${resolveOutboundMessageId(result)}`;
444
- }
445
-
446
- private resolveV2EndpointPath(options: SendOptions): string {
447
- switch (options.type) {
448
- case "group":
449
- return `/v2/groups/${options.id}`;
450
- case "private":
451
- return `/v2/users/${options.id}`;
452
- default:
453
- throw new Error(`QQ 图文混排不支持 ${options.type}`);
454
- }
242
+ constructor(options: QqHttpEndpointOptions) {
243
+ this.#options = options;
244
+ this.#createBot = options.createBot ?? defaultCreateHttpBot;
455
245
  }
456
246
 
457
- async $recallMessage(id: string): Promise<void> {
458
- if (!/^(private|group|channel|direct)-([^\:]+):(.+)$/.test(id)) throw new Error(`invalid message id ${id}`);
459
- const match = id.match(/^(private|group|channel|direct)-([^\:]+):(.+)$/);
460
- if (!match) return;
461
- const [, target_type, target_id, message_id] = match;
462
- if (target_type === "private") await this.recallPrivateMessage(target_id, message_id);
463
- if (target_type === "group") await this.recallGroupMessage(target_id, message_id);
464
- if (target_type === "channel") await this.recallGuildMessage(target_id, message_id);
465
- if (target_type === "direct") await this.recallDirectMessage(target_id, message_id);
247
+ /** Live endpoint 名(Console/AdapterIndex 展示用)。 */
248
+ get name(): string {
249
+ return this.#options.config.name;
466
250
  }
467
251
 
468
- // ==================== 频道管理 API ====================
469
-
470
- /**
471
- * 获取频道列表
472
- */
473
- async getGuilds(): Promise<any[]> {
252
+ async start(): Promise<void> {
253
+ if (this.#started) return;
254
+ this.#started = true;
474
255
  try {
475
- return await this.guildService.getList();
256
+ this.#unregisterAgent = registerQqAgentEndpoint(
257
+ this.#options.config.name,
258
+ this as unknown as QqWebsocketEndpoint,
259
+ );
260
+ this.#setupRoutes();
261
+ this.#bot = this.#createBot(this.#options.config);
262
+ this.#bindBot(this.#bot);
263
+ await this.#bot.start();
264
+ logger.info(formatCompact({
265
+ op: 'connect',
266
+ endpoint: this.#options.config.name,
267
+ mode: this.#options.config.mode,
268
+ path: this.#options.config.webhookPath,
269
+ }));
476
270
  } catch (error) {
477
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 获取频道列表失败:`, error);
271
+ await this.stop();
272
+ logger.error('Failed to connect QQ HTTP receiver:', error);
478
273
  throw error;
479
274
  }
480
275
  }
481
276
 
482
- /**
483
- * 获取频道详情
484
- * @param guildId 频道 ID
485
- */
486
- async getGuildInfo(guildId: string): Promise<any> {
487
- try {
488
- return await this.guildService.getInfo(guildId);
489
- } catch (error) {
490
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 获取频道详情失败:`, error);
491
- throw error;
492
- }
277
+ open(): void {
278
+ this.#open = true;
493
279
  }
494
280
 
495
- /**
496
- * 获取子频道列表
497
- * @param guildId 频道 ID
498
- */
499
- async getChannels(guildId: string): Promise<any[]> {
500
- try {
501
- return await this.channelService.getList(guildId);
502
- } catch (error) {
503
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 获取子频道列表失败:`, error);
504
- throw error;
505
- }
281
+ close(): void {
282
+ this.#open = false;
506
283
  }
507
284
 
508
- /**
509
- * 获取子频道详情
510
- * @param channelId 子频道 ID
511
- */
512
- async getChannelInfo(channelId: string): Promise<any> {
513
- try {
514
- return await this.channelService.getInfo(channelId);
515
- } catch (error) {
516
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 获取子频道详情失败:`, error);
517
- throw error;
285
+ async stop(): Promise<void> {
286
+ this.#open = false;
287
+ for (const release of this.#routeReleases.splice(0)) release();
288
+ this.#unregisterAgent?.();
289
+ this.#unregisterAgent = undefined;
290
+ if (this.#bot) {
291
+ try {
292
+ this.#bot.removeAllListeners();
293
+ await this.#bot.stop();
294
+ } catch {
295
+ /* ignore */
296
+ }
297
+ this.#bot = null;
298
+ }
299
+ this.#started = false;
300
+ logger.debug(formatCompact({ op: 'disconnect', endpoint: this.#options.config.name }));
301
+ }
302
+
303
+ async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
304
+ const body = formatOutboundText(payload);
305
+ const parsed = parseSendTarget(target);
306
+ const bot = this.#requireBot();
307
+ let result: unknown;
308
+ switch (parsed.kind) {
309
+ case 'private':
310
+ result = await bot.sendPrivateMessage(parsed.id, body);
311
+ break;
312
+ case 'group':
313
+ result = await bot.sendGroupMessage(parsed.id, body);
314
+ break;
315
+ case 'channel':
316
+ result = await bot.sendGuildMessage(parsed.id, body);
317
+ break;
318
+ case 'direct':
319
+ if (!bot.sendDirectMessage) throw new Error('QQ direct message not supported by transport');
320
+ result = await bot.sendDirectMessage(parsed.id, body);
321
+ break;
322
+ default:
323
+ throw new Error(`unsupported QQ target kind: ${String((parsed as ParsedSendTarget).kind)}`);
518
324
  }
325
+ return `${parsed.kind}-${parsed.id}:${resolveOutboundMessageId(result)}`;
326
+ }
327
+
328
+ admit(msg: QqInboundMessage): void {
329
+ if (!this.#open) return;
330
+ const target = formatInboundTarget(msg);
331
+ void this.#options.gateway.receive({
332
+ adapter: this.#options.id,
333
+ target,
334
+ content: formatInboundContent(msg),
335
+ sender: senderDisplayName(msg),
336
+ id: msg.id,
337
+ metadata: Object.freeze({
338
+ endpoint: this.#options.config.name,
339
+ channelKind: msg.channelKind,
340
+ userId: msg.authorId,
341
+ guildId: msg.guildId,
342
+ roles: msg.authorRoles,
343
+ // AT 事件本身即 @ 机器人;新 Runtime 纯文本 content 需经 metadata 传递
344
+ ...(msg.mentioned ? { mentioned: true } : {}),
345
+ }),
346
+ }).catch((err) => {
347
+ logger.warn(formatCompact({
348
+ op: 'qq_gateway_receive_failed',
349
+ target,
350
+ error: err instanceof Error ? err.message : String(err),
351
+ }));
352
+ });
519
353
  }
520
354
 
521
- /**
522
- * 获取频道成员列表
523
- * @param guildId 频道 ID
524
- */
525
- async getGuildMembers(guildId: string): Promise<any[]> {
526
- try {
527
- return await this.memberService.getGuildMemberList(guildId);
528
- } catch (error) {
529
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 获取频道成员列表失败:`, error);
530
- throw error;
531
- }
355
+ getGuilds() {
356
+ return this.#requireBot().getGuilds();
532
357
  }
533
358
 
534
- /**
535
- * 获取频道成员详情
536
- * @param guildId 频道 ID
537
- * @param userId 用户 ID
538
- */
539
- async getGuildMember(guildId: string, userId: string): Promise<any> {
540
- try {
541
- return await this.memberService.getGuildMemberInfo(guildId, userId);
542
- } catch (error) {
543
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 获取成员详情失败:`, error);
544
- throw error;
545
- }
359
+ getChannels(guildId: string) {
360
+ return this.#requireBot().getChannels(guildId);
546
361
  }
547
362
 
548
- /**
549
- * 删除频道成员(踢出)
550
- * @param guildId 频道 ID
551
- * @param userId 用户 ID
552
- * @param addBlacklist 是否加入黑名单
553
- * @param deleteHistoryMsg 删除历史消息天数 (-1不删除,0全部删除,3/7/15/30)
554
- */
555
- async removeGuildMember(guildId: string, userId: string, addBlacklist?: boolean, deleteHistoryMsg?: -1 | 0 | 3 | 7 | 15 | 30): Promise<boolean> {
556
- try {
557
- await this.memberService.kickMember(guildId, userId, deleteHistoryMsg, addBlacklist);
558
- this.pluginLogger.info(`QQ Endpoint ${this.$id} 踢出成员 ${userId}(频道 ${guildId})`);
559
- return true;
560
- } catch (error) {
561
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 踢出成员失败:`, error);
562
- throw error;
563
- }
363
+ getChannelInfo(channelId: string) {
364
+ return this.#requireBot().getChannelInfo(channelId);
564
365
  }
565
366
 
566
- /**
567
- * 获取频道角色列表
568
- * @param guildId 频道 ID
569
- */
570
- async getGuildRoles(guildId: string): Promise<any[]> {
571
- try {
572
- return await this.guildService.getRoles(guildId);
573
- } catch (error) {
574
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 获取角色列表失败:`, error);
575
- throw error;
576
- }
367
+ getGuildMember(guildId: string, userId: string) {
368
+ return this.#requireBot().getGuildMember(guildId, userId);
577
369
  }
578
370
 
579
- /**
580
- * 创建频道角色
581
- * @param guildId 频道 ID
582
- * @param name 角色名
583
- * @param color 颜色
584
- * @param hoist 是否在成员列表中单独展示 (0 或 1)
585
- */
586
- async createGuildRole(guildId: string, name: string, color?: number, hoist?: 0 | 1): Promise<any> {
587
- try {
588
- const result = await this.guildService.createRole(guildId, { name, color: color || 0, hoist: hoist ?? 0 });
589
- this.pluginLogger.info(`QQ Endpoint ${this.$id} 创建角色 "${name}"(频道 ${guildId})`);
590
- return result;
591
- } catch (error) {
592
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 创建角色失败:`, error);
593
- throw error;
594
- }
371
+ getGuildRoles(guildId: string) {
372
+ return this.#requireBot().getGuildRoles(guildId);
595
373
  }
596
374
 
597
- /**
598
- * 给成员添加角色
599
- * @param guildId 频道 ID
600
- * @param channelId 子频道 ID
601
- * @param userId 用户 ID
602
- * @param roleId 角色 ID
603
- */
604
- async addMemberRole(guildId: string, channelId: string, userId: string, roleId: string): Promise<boolean> {
605
- try {
606
- await this.memberService.addMemberRole(guildId, channelId, userId, roleId);
607
- this.pluginLogger.info(`QQ Endpoint ${this.$id} 给成员 ${userId} 添加角色 ${roleId}(频道 ${guildId})`);
608
- return true;
609
- } catch (error) {
610
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 添加角色失败:`, error);
611
- throw error;
612
- }
375
+ createGuildRole(guildId: string, name: string, color?: number) {
376
+ return this.#requireBot().createGuildRole(guildId, name, color);
613
377
  }
614
378
 
615
- /**
616
- * 移除成员角色
617
- * @param guildId 频道 ID
618
- * @param channelId 子频道 ID
619
- * @param userId 用户 ID
620
- * @param roleId 角色 ID
621
- */
622
- async removeMemberRole(guildId: string, channelId: string, userId: string, roleId: string): Promise<boolean> {
623
- try {
624
- await this.memberService.removeMemberRole(guildId, channelId, userId, roleId);
625
- this.pluginLogger.info(`QQ Endpoint ${this.$id} 移除成员 ${userId} 的角色 ${roleId}(频道 ${guildId})`);
626
- return true;
627
- } catch (error) {
628
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 移除角色失败:`, error);
629
- throw error;
630
- }
379
+ addMemberRole(guildId: string, channelId: string, userId: string, roleId: string) {
380
+ return this.#requireBot().addMemberRole(guildId, channelId, userId, roleId);
631
381
  }
632
382
 
633
- /**
634
- * 禁言成员(批量)
635
- * @param guildId 频道 ID
636
- * @param userIds 用户 ID 列表
637
- * @param muteSeconds 禁言时长(秒),0 表示解除禁言
638
- */
639
- async muteMembers(guildId: string, userIds: string[], muteSeconds: number): Promise<boolean> {
640
- try {
641
- if (muteSeconds > 0) {
642
- await this.memberService.muteMembers(guildId, userIds, muteSeconds);
643
- this.pluginLogger.info(`QQ Endpoint ${this.$id} 禁言成员 ${userIds.join(',')} ${muteSeconds}秒(频道 ${guildId})`);
644
- } else {
645
- await this.memberService.unmuteMembers(guildId, userIds);
646
- this.pluginLogger.info(`QQ Endpoint ${this.$id} 解除成员 ${userIds.join(',')} 禁言(频道 ${guildId})`);
647
- }
648
- return true;
649
- } catch (error) {
650
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 禁言操作失败:`, error);
651
- throw error;
652
- }
383
+ removeMemberRole(guildId: string, channelId: string, userId: string, roleId: string) {
384
+ return this.#requireBot().removeMemberRole(guildId, channelId, userId, roleId);
653
385
  }
654
386
 
655
- /**
656
- * 全员禁言
657
- * @param guildId 频道 ID
658
- * @param muteSeconds 禁言时长(秒),0 表示解除禁言
659
- */
660
- async muteAll(guildId: string, muteSeconds: number): Promise<boolean> {
661
- try {
662
- if (muteSeconds > 0) {
663
- await this.guildService.mute(guildId, muteSeconds);
664
- this.pluginLogger.info(`QQ Endpoint ${this.$id} 开启全员禁言(频道 ${guildId})`);
665
- } else {
666
- await this.guildService.unmute(guildId);
667
- this.pluginLogger.info(`QQ Endpoint ${this.$id} 关闭全员禁言(频道 ${guildId})`);
668
- }
669
- return true;
670
- } catch (error) {
671
- this.pluginLogger.error(`QQ Endpoint ${this.$id} 全员禁言操作失败:`, error);
672
- throw error;
673
- }
387
+ #setupRoutes(): void {
388
+ this.#routeReleases.push(...registerQqWebhookRoutes(this.#options.http, {
389
+ config: this.#options.config,
390
+ getBot: () => this.#bot,
391
+ }));
674
392
  }
393
+
394
+ #bindBot(bot: QqBotTransport): void {
395
+ bindQqBotInboundEvents(bot, (raw) => {
396
+ const msg = normalizeQqMessage(raw);
397
+ if (msg) this.admit(msg);
398
+ });
399
+ }
400
+
401
+ #requireBot(): QqBotTransport {
402
+ if (!this.#bot) throw new Error('QQ bot not connected');
403
+ return this.#bot;
404
+ }
405
+ }
406
+
407
+ function createQqEndpointManagement(endpoint: {
408
+ getGuilds(): Promise<unknown[]>;
409
+ getChannels(guildId: string): Promise<unknown[]>;
410
+ }): EndpointManagement {
411
+ return Object.freeze<EndpointManagement>({
412
+ async listChannels(): Promise<readonly EndpointChannel[]> {
413
+ const channels: EndpointChannel[] = [];
414
+ const guilds = await endpoint.getGuilds();
415
+ for (const guildValue of Array.isArray(guilds) ? guilds : []) {
416
+ const guild = asRecord(guildValue);
417
+ const guildId = String(guild.id ?? guild.guild_id ?? guildValue ?? '');
418
+ if (!guildId) continue;
419
+ const guildName = String(guild.name ?? guild.guild_name ?? guildId);
420
+ const rows = await endpoint.getChannels(guildId);
421
+ for (const channelValue of Array.isArray(rows) ? rows : []) {
422
+ const channel = asRecord(channelValue);
423
+ const id = String(channel.id ?? channel.channel_id ?? channelValue ?? '');
424
+ if (!id) continue;
425
+ channels.push({
426
+ id,
427
+ name: String(channel.name ?? channel.channel_name ?? id),
428
+ parent: { type: 'guild', id: guildId, name: guildName },
429
+ });
430
+ }
431
+ }
432
+ return channels;
433
+ },
434
+ });
435
+ }
436
+
437
+ function asRecord(value: unknown): Record<string, unknown> {
438
+ return value !== null && typeof value === 'object'
439
+ ? value as Record<string, unknown>
440
+ : {};
675
441
  }