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