@zhin.js/adapter-qq 5.0.1 → 5.0.3

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