@zhin.js/adapter-qq 2.0.11 → 3.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 (51) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +117 -9
  3. package/lib/adapter.d.ts +4 -1
  4. package/lib/adapter.d.ts.map +1 -1
  5. package/lib/adapter.js +12 -1
  6. package/lib/adapter.js.map +1 -1
  7. package/lib/bot.d.ts +14 -1
  8. package/lib/bot.d.ts.map +1 -1
  9. package/lib/bot.js +145 -17
  10. package/lib/bot.js.map +1 -1
  11. package/lib/gateway-config.d.ts +14 -0
  12. package/lib/gateway-config.d.ts.map +1 -0
  13. package/lib/gateway-config.js +32 -0
  14. package/lib/gateway-config.js.map +1 -0
  15. package/lib/group-at-normalize.d.ts +8 -0
  16. package/lib/group-at-normalize.d.ts.map +1 -0
  17. package/lib/group-at-normalize.js +71 -0
  18. package/lib/group-at-normalize.js.map +1 -0
  19. package/lib/inbound-normalize.d.ts +11 -0
  20. package/lib/inbound-normalize.d.ts.map +1 -0
  21. package/lib/inbound-normalize.js +47 -0
  22. package/lib/inbound-normalize.js.map +1 -0
  23. package/lib/index.d.ts +2 -0
  24. package/lib/index.d.ts.map +1 -1
  25. package/lib/index.js +37 -17
  26. package/lib/index.js.map +1 -1
  27. package/lib/outbound-markdown.d.ts +12 -0
  28. package/lib/outbound-markdown.d.ts.map +1 -0
  29. package/lib/outbound-markdown.js +78 -0
  30. package/lib/outbound-markdown.js.map +1 -0
  31. package/lib/outbound-media.d.ts +9 -0
  32. package/lib/outbound-media.d.ts.map +1 -0
  33. package/lib/outbound-media.js +50 -0
  34. package/lib/outbound-media.js.map +1 -0
  35. package/lib/sdk-version.d.ts +8 -0
  36. package/lib/sdk-version.d.ts.map +1 -0
  37. package/lib/sdk-version.js +19 -0
  38. package/lib/sdk-version.js.map +1 -0
  39. package/lib/types.d.ts +22 -0
  40. package/lib/types.d.ts.map +1 -1
  41. package/package.json +9 -9
  42. package/src/adapter.ts +18 -1
  43. package/src/bot.ts +176 -18
  44. package/src/gateway-config.ts +48 -0
  45. package/src/group-at-normalize.ts +78 -0
  46. package/src/inbound-normalize.ts +57 -0
  47. package/src/index.ts +39 -17
  48. package/src/outbound-markdown.ts +93 -0
  49. package/src/outbound-media.ts +57 -0
  50. package/src/sdk-version.ts +21 -0
  51. package/src/types.ts +22 -0
package/src/adapter.ts CHANGED
@@ -5,12 +5,20 @@ import {
5
5
  Adapter,
6
6
  Plugin,
7
7
  } from "zhin.js";
8
+ import type { Router } from "@zhin.js/host-router";
8
9
  import { QQBot } from "./bot.js";
9
10
  import type { QQBotConfig, ReceiverMode } from "./types.js";
10
11
 
11
12
  export class QQAdapter extends Adapter<QQBot<ReceiverMode>> {
12
- constructor(plugin: Plugin) {
13
+ #router?: Router;
14
+
15
+ constructor(plugin: Plugin, router?: Router) {
13
16
  super(plugin, "qq", []);
17
+ this.#router = router;
18
+ }
19
+
20
+ getRouter(): Router | undefined {
21
+ return this.#router;
14
22
  }
15
23
 
16
24
  createBot(config: QQBotConfig<ReceiverMode>): QQBot<ReceiverMode> {
@@ -52,6 +60,15 @@ export class QQAdapter extends Adapter<QQBot<ReceiverMode>> {
52
60
  // ── 生命周期 ───────────────────────────────────────────────────────
53
61
 
54
62
  async start(): Promise<void> {
63
+ if (!this.#router) {
64
+ this.#router = (this.plugin.inject as (key: string) => Router | undefined)("router");
65
+ }
66
+ (this.plugin.useContext as (key: string, fn: (router: Router) => void) => void)(
67
+ "router",
68
+ (router) => {
69
+ this.#router = router;
70
+ },
71
+ );
55
72
  await super.start();
56
73
  }
57
74
 
package/src/bot.ts CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  GroupMessageEvent,
8
8
  } from "qq-official-bot";
9
9
  import path from "path";
10
+ import { formatCompact } from "@zhin.js/logger";
11
+ import { registerFetchRoute, type RouterContext } from "@zhin.js/host-router/router";
10
12
  import {
11
13
  Bot as ZhinBot,
12
14
  Message,
@@ -14,9 +16,38 @@ import {
14
16
  SendContent,
15
17
  segment,
16
18
  } from "zhin.js";
17
- import type { QQBotConfig, ReceiverMode, ApplicationPlatform } from "./types.js";
19
+ import type { MessageElement } from "zhin.js";
20
+ import { ReceiverMode, type QQBotConfig, type ApplicationPlatform } from "./types.js";
18
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 } from "./outbound-markdown.js";
27
+ import { normalizeOutboundMedia } from "./outbound-media.js";
19
28
 
29
+ /** 从 qq-official-bot SendResult / 审核回包中解析出站消息 ID */
30
+ export function resolveOutboundMessageId(result: unknown): string {
31
+ if (!result || typeof result !== "object") {
32
+ throw new Error("QQ 发送消息失败:响应为空");
33
+ }
34
+ const row = result as Record<string, unknown>;
35
+ const nested = row.data && typeof row.data === "object"
36
+ ? row.data as Record<string, unknown>
37
+ : undefined;
38
+ const audit = (row.message_audit ?? nested?.message_audit) as Record<string, unknown> | undefined;
39
+ const id = row.id ?? row.message_id ?? audit?.audit_id;
40
+ if (id == null || id === "") {
41
+ const code = row.code;
42
+ const msg = row.message;
43
+ throw new Error(
44
+ code != null
45
+ ? `QQ 发送消息失败(${String(code)})${msg ? `: ${String(msg)}` : ""}`
46
+ : "QQ 发送消息失败:响应缺少消息 ID",
47
+ );
48
+ }
49
+ return String(id);
50
+ }
20
51
 
21
52
  export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = ApplicationPlatform>
22
53
  extends Bot
@@ -37,21 +68,74 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
37
68
 
38
69
  constructor(public adapter: QQAdapter, config: QQBotConfig<T, M>) {
39
70
  if (!config.data_dir) config.data_dir = path.join(process.cwd(), "data");
71
+ if (config.mode === ReceiverMode.MIDDLEWARE) {
72
+ const mw = config as QQBotConfig<ReceiverMode.MIDDLEWARE, M> & {
73
+ platform?: ApplicationPlatform;
74
+ };
75
+ if (!mw.platform) {
76
+ mw.platform = (mw.application ?? "koa") as ApplicationPlatform;
77
+ }
78
+ }
40
79
  super(config);
41
80
  this.$config = config;
81
+ this.attachSdkVersionHeader();
82
+ applyCustomAuthEndpoints(this, config, this.pluginLogger);
83
+ }
84
+
85
+ /** 出站 QQ API 请求附带 SDK 版本,便于平台侧日志排查 */
86
+ private attachSdkVersionHeader(): void {
87
+ this.request.interceptors.request.use((reqConfig) => {
88
+ reqConfig.headers[SDK_VERSION_HEADER] = SDK_VERSION;
89
+ return reqConfig;
90
+ });
91
+ }
92
+
93
+ /** 归一化 QQ API v2 群聊字段后再交给 qq-official-bot 解析 */
94
+ dispatchEvent(event: string, wsRes: QqWsPacket): void {
95
+ this.pluginLogger.debug({
96
+ op: 'inbound_ws',
97
+ event,
98
+ group: String(wsRes.d?.group_openid ?? wsRes.d?.group_id ?? '?'),
99
+ });
100
+ if (event === 'GROUP_AT_MESSAGE_CREATE' || event === 'GROUP_MESSAGE_CREATE') {
101
+ this.pluginLogger.info(
102
+ `qq inbound ws: ${event} group=${String(wsRes.d?.group_openid ?? wsRes.d?.group_id ?? '?')}`,
103
+ );
104
+ }
105
+ normalizeQqInboundWsPayload(event, wsRes);
106
+ super.dispatchEvent(event, wsRes);
42
107
  }
43
108
 
44
109
  private handleQQMessage(msg: PrivateMessageEvent | GroupMessageEvent): void {
45
- const message = this.$formatMessage(msg);
46
- this.adapter.emit("message.receive", message);
47
- this.pluginLogger.debug(`${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`);
110
+ try {
111
+ const message = this.$formatMessage(msg);
112
+ this.adapter.emit("message.receive", message);
113
+ this.pluginLogger.debug(
114
+ `${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`,
115
+ );
116
+ } catch (err) {
117
+ this.pluginLogger.warn(
118
+ `qq format inbound failed (${msg.message_type}): ${err instanceof Error ? err.message : String(err)}`,
119
+ );
120
+ }
121
+ }
122
+
123
+ private handleGroupNotice(event: string, payload: { group_id?: string; operator_id?: string }): void {
124
+ this.pluginLogger.info(
125
+ `qq notice ${event}: group=${payload.group_id ?? '?'} operator=${payload.operator_id ?? '?'}`,
126
+ );
48
127
  }
49
128
 
50
129
  async $connect(): Promise<void> {
51
130
  this.on("message.group", this.handleQQMessage.bind(this));
52
131
  this.on("message.guild", this.handleQQMessage.bind(this));
53
132
  this.on("message.private", this.handleQQMessage.bind(this));
133
+ this.on("notice.group.increase", (e) => this.handleGroupNotice('group.add_robot', e));
134
+ this.on("notice.group.decrease", (e) => this.handleGroupNotice('group.del_robot', e));
135
+ this.on("notice.group.receive_open", (e) => this.handleGroupNotice('group.msg_receive_open', e));
136
+ this.on("notice.group.receive_close", (e) => this.handleGroupNotice('group.msg_receive_close', e));
54
137
  await this.start();
138
+ this.mountWebhookReceiver();
55
139
  this.$connected = true;
56
140
  try {
57
141
  const self = await this.getSelfInfo();
@@ -72,11 +156,81 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
72
156
  this.$connected = false;
73
157
  }
74
158
 
159
+ private resolveWebhookPath(): string {
160
+ const raw = this.$config.webhookPath ?? "/qq/webhook";
161
+ return raw.startsWith("/") ? raw : `/${raw}`;
162
+ }
163
+
164
+ /**
165
+ * Webhook 入站:middleware(挂 host-router)或独立 HTTP 端口(qq-official-bot 内置服务)。
166
+ */
167
+ private mountWebhookReceiver(): void {
168
+ const mode = this.$config.mode;
169
+ if (mode === ReceiverMode.MIDDLEWARE) {
170
+ const router = this.adapter.getRouter();
171
+ if (!router) {
172
+ throw new Error("QQ mode=middleware 需要启用 @zhin.js/host-router 插件");
173
+ }
174
+ const webhookPath = this.resolveWebhookPath();
175
+ const mw = this.middleware as (
176
+ ctx: RouterContext,
177
+ next: () => Promise<void>,
178
+ ) => Promise<unknown>;
179
+ router.post(webhookPath, async (ctx: RouterContext) => {
180
+ this.pluginLogger.debug(ctx.body);
181
+ await mw(ctx, async () => {});
182
+ });
183
+ this.pluginLogger.info(formatCompact({
184
+ op: "webhook",
185
+ mode: "middleware",
186
+ path: webhookPath,
187
+ url: `POST ${webhookPath}`,
188
+ note: "无 /api 前缀;Host API 才走 /api/*",
189
+ }));
190
+ return;
191
+ }
192
+ if (mode === ReceiverMode.WEBHOOK) {
193
+ const cfg = this.$config as QQBotConfig<ReceiverMode.WEBHOOK>;
194
+ this.pluginLogger.info(formatCompact({
195
+ op: "webhook",
196
+ mode: "standalone",
197
+ port: cfg.port,
198
+ path: cfg.path,
199
+ url: `http://127.0.0.1:${cfg.port}${cfg.path}`,
200
+ }));
201
+ }
202
+ }
203
+
75
204
  $formatMessage(msg: PrivateMessageEvent | GroupMessageEvent) {
205
+ const raw = msg as PrivateMessageEvent & GroupMessageEvent & {
206
+ group_openid?: string;
207
+ author?: { member_openid?: string; user_openid?: string; id?: string; username?: string };
208
+ __zhin_group_at?: boolean;
209
+ };
210
+
211
+ if (msg.message_type === "group") {
212
+ if (!raw.group_id && raw.group_openid) {
213
+ raw.group_id = raw.group_openid;
214
+ }
215
+ if (!msg.user_id && raw.author) {
216
+ const uid = raw.author.member_openid ?? raw.author.user_openid ?? raw.author.id;
217
+ if (uid) msg.user_id = String(uid);
218
+ }
219
+ }
220
+
76
221
  let target_id = msg.user_id;
77
222
  if (msg.message_type === "guild") target_id = msg.channel_id!;
78
- if (msg.message_type === "group") target_id = msg.group_id!;
223
+ if (msg.message_type === "group") target_id = raw.group_id ?? msg.group_id ?? "";
79
224
  if (msg.sub_type === "direct") target_id = `direct:${msg.guild_id}`;
225
+
226
+ let content = msg.message;
227
+ if (msg.message_type === "group" && Array.isArray(content)) {
228
+ const botAtIds = [this.$platformUserId, this.$config.appid]
229
+ .filter((id): id is string => Boolean(id))
230
+ .map(String);
231
+ content = normalizeGroupAtPrefix(content, botAtIds, raw.__zhin_group_at === true);
232
+ }
233
+
80
234
  const result = Message.from(msg, {
81
235
  $id: msg.message_id?.toString(),
82
236
  $adapter: "qq" as const,
@@ -89,7 +243,7 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
89
243
  id: target_id,
90
244
  type: msg.message_type === "guild" ? "channel" : msg.message_type,
91
245
  },
92
- $content: msg.message,
246
+ $content: content,
93
247
  $raw: msg.raw_message,
94
248
  $timestamp: Date.now(),
95
249
  $recall: async () => {
@@ -110,28 +264,32 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
110
264
  }
111
265
 
112
266
  async $sendMessage(options: SendOptions): Promise<string> {
267
+ const content = normalizeOutboundMarkdown(
268
+ normalizeOutboundMedia(options.content),
269
+ this.$config.outboundMarkdown,
270
+ );
113
271
  switch (options.type) {
114
272
  case "private": {
115
273
  if (options.id.startsWith("direct:")) {
116
274
  const id = options.id.replace("direct:", "");
117
- const result = await this.sendDirectMessage(id, options.content);
118
- this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(options.content)}`);
119
- return `direct-${options.id}:${result.id.toString()}`;
275
+ const result = await this.sendDirectMessage(id, content);
276
+ this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
277
+ return `direct-${options.id}:${resolveOutboundMessageId(result)}`;
120
278
  } else {
121
- const result = await this.sendPrivateMessage(options.id, options.content);
122
- this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(options.content)}`);
123
- return `private-${options.id}:${result.id.toString()}`;
279
+ const result = await this.sendPrivateMessage(options.id, content);
280
+ this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
281
+ return `private-${options.id}:${resolveOutboundMessageId(result)}`;
124
282
  }
125
283
  }
126
284
  case "group": {
127
- const result = await this.sendGroupMessage(options.id, options.content);
128
- this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(options.content)}`);
129
- return `group-${options.id}:${result.id.toString()}`;
285
+ const result = await this.sendGroupMessage(options.id, content);
286
+ this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
287
+ return `group-${options.id}:${resolveOutboundMessageId(result)}`;
130
288
  }
131
289
  case "channel": {
132
- const result = await this.sendGuildMessage(options.id, options.content);
133
- this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(options.content)}`);
134
- return `channel-${options.id}:${result.id.toString()}`;
290
+ const result = await this.sendGuildMessage(options.id, content);
291
+ this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
292
+ return `channel-${options.id}:${resolveOutboundMessageId(result)}`;
135
293
  }
136
294
  default:
137
295
  throw new Error(`unsupported channel type ${options.type}`);
@@ -0,0 +1,48 @@
1
+ import type { Logger } from "@zhin.js/logger";
2
+ import { formatCompact } from "@zhin.js/logger";
3
+ import type { Bot } from "qq-official-bot";
4
+ import { ReceiverMode } from "qq-official-bot";
5
+ import type { QQBotConfig } from "./types.js";
6
+
7
+ /** qq-official-bot 默认 token 接口 */
8
+ export const DEFAULT_ACCESS_TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
9
+ /** qq-official-bot 默认 gateway 路径(相对 API baseURL) */
10
+ export const DEFAULT_GATEWAY_URL = "/gateway/bot";
11
+
12
+ type AuthManagerSession = {
13
+ authManager: {
14
+ config: {
15
+ accessTokenUrl?: string;
16
+ gatewayUrl?: string;
17
+ };
18
+ };
19
+ };
20
+
21
+ /**
22
+ * 将自定义 gateway / token 地址注入 SDK Auth。
23
+ * SDK 仅在 websocket 模式自动读取配置;middleware / webhook 需在此补丁。
24
+ */
25
+ export function applyCustomAuthEndpoints(
26
+ bot: Bot,
27
+ config: Pick<QQBotConfig<ReceiverMode>, "mode" | "accessTokenUrl" | "gatewayUrl">,
28
+ logger: Logger,
29
+ ): void {
30
+ const { accessTokenUrl, gatewayUrl, mode } = config;
31
+ if (!accessTokenUrl && !gatewayUrl) return;
32
+
33
+ if (mode !== ReceiverMode.WEBSOCKET) {
34
+ const session = bot.sessionManager as unknown as AuthManagerSession;
35
+ if (accessTokenUrl) session.authManager.config.accessTokenUrl = accessTokenUrl;
36
+ if (gatewayUrl) session.authManager.config.gatewayUrl = gatewayUrl;
37
+ }
38
+
39
+ logger.info(formatCompact({
40
+ op: "qq_gateway",
41
+ mode,
42
+ accessTokenUrl: accessTokenUrl ?? DEFAULT_ACCESS_TOKEN_URL,
43
+ gatewayUrl: gatewayUrl ?? DEFAULT_GATEWAY_URL,
44
+ note: mode === ReceiverMode.WEBSOCKET
45
+ ? "websocket 入站使用 gatewayUrl"
46
+ : "middleware/webhook 仅 accessTokenUrl 生效",
47
+ }));
48
+ }
@@ -0,0 +1,78 @@
1
+ import type { MessageElement } from "zhin.js";
2
+
3
+ function segmentAtId(seg: MessageElement): string {
4
+ if (seg.type !== "at" && seg.type !== "mention") return "";
5
+ const data = seg.data as Record<string, unknown> | undefined;
6
+ const raw = data?.user_id ?? data?.qq ?? data?.id;
7
+ return raw == null ? "" : String(raw);
8
+ }
9
+
10
+ function textMentionsBot(text: string, botIds: string[]): boolean {
11
+ for (const id of botIds) {
12
+ const re = new RegExp(`@${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
13
+ if (re.test(text)) return true;
14
+ }
15
+ return false;
16
+ }
17
+
18
+ function stripInlineAtBot(text: string, botIds: string[]): string {
19
+ let result = text;
20
+ for (const id of botIds) {
21
+ if (!id) continue;
22
+ const re = new RegExp(`^@${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
23
+ result = result.replace(re, "");
24
+ }
25
+ return result.trimStart();
26
+ }
27
+
28
+ /**
29
+ * 统一 QQ 群 @ 机器人前缀:
30
+ * - 去掉正文前的 at 段与内联 @,使命令/文本从首段开始匹配
31
+ * - 若曾 @ 机器人,在末尾追加规范 at 段,供 AI @ 触发器识别
32
+ */
33
+ export function normalizeGroupAtPrefix(
34
+ content: MessageElement[],
35
+ botAtIds: string[],
36
+ forceAt: boolean,
37
+ ): MessageElement[] {
38
+ const ids = [...new Set(botAtIds.map(String).filter(Boolean))];
39
+ if (!ids.length || !Array.isArray(content)) return content;
40
+
41
+ let mentioned = forceAt;
42
+ const body: MessageElement[] = [];
43
+
44
+ for (const seg of content) {
45
+ if (seg.type === "at" || seg.type === "mention") {
46
+ const uid = segmentAtId(seg);
47
+ if (uid && ids.includes(uid)) {
48
+ mentioned = true;
49
+ continue;
50
+ }
51
+ body.push(seg);
52
+ continue;
53
+ }
54
+ if (seg.type === "text" && seg.data && typeof seg.data === "object") {
55
+ const raw = String((seg.data as { text?: string }).text ?? "");
56
+ if (textMentionsBot(raw, ids)) mentioned = true;
57
+ const stripped = stripInlineAtBot(raw, ids);
58
+ if (stripped) {
59
+ body.push({ ...seg, data: { ...(seg.data as object), text: stripped } });
60
+ }
61
+ continue;
62
+ }
63
+ body.push(seg);
64
+ }
65
+
66
+ while (body.length > 0 && body[0]?.type === "text") {
67
+ const t = String((body[0].data as { text?: string })?.text ?? "").trim();
68
+ if (t) break;
69
+ body.shift();
70
+ }
71
+
72
+ if (mentioned) {
73
+ const canonicalId = ids[0]!;
74
+ body.push({ type: "at", data: { qq: canonicalId, id: canonicalId } });
75
+ }
76
+
77
+ return body.length ? body : content;
78
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * QQ 官方 API v2 入站 WebSocket payload 归一化(group_openid → group_id 等)。
3
+ * qq-official-bot 解析层仍按旧字段名取值,未归一化时群 @ 可能解析失败并被 SDK 静默丢弃。
4
+ */
5
+
6
+ export type QqWsPacket = {
7
+ d?: Record<string, unknown>;
8
+ id?: string;
9
+ t?: string;
10
+ };
11
+
12
+ const GROUP_MESSAGE_EVENTS = new Set([
13
+ 'GROUP_AT_MESSAGE_CREATE',
14
+ 'GROUP_MESSAGE_CREATE',
15
+ ]);
16
+
17
+ export function normalizeQqInboundWsPayload(event: string, packet: QqWsPacket): void {
18
+ const d = packet.d;
19
+ if (!d || typeof d !== 'object') return;
20
+
21
+ if (GROUP_MESSAGE_EVENTS.has(event)) {
22
+ if (!d.group_id && typeof d.group_openid === 'string') {
23
+ d.group_id = d.group_openid;
24
+ }
25
+
26
+ const author = d.author;
27
+ if (author && typeof author === 'object') {
28
+ const a = author as Record<string, unknown>;
29
+ if (!a.id) {
30
+ a.id = a.member_openid ?? a.user_openid ?? a.id;
31
+ }
32
+ if (!a.username && !a.user_name) {
33
+ a.username = a.member_openid ?? a.user_openid ?? 'unknown';
34
+ }
35
+ }
36
+
37
+ if (!Array.isArray(d.mentions) && typeof d.content === 'string') {
38
+ const mentions: Array<{ id: string }> = [];
39
+ const re = /<@!?(\d+)>/g;
40
+ let m: RegExpExecArray | null;
41
+ while ((m = re.exec(d.content))) {
42
+ mentions.push({ id: m[1]! });
43
+ }
44
+ if (mentions.length) d.mentions = mentions;
45
+ }
46
+
47
+ if (Array.isArray(d.attachments)) {
48
+ for (const att of d.attachments) {
49
+ if (att && typeof att === 'object' && (att as Record<string, unknown>).url == null) {
50
+ (att as Record<string, unknown>).url = '';
51
+ }
52
+ }
53
+ }
54
+
55
+ d.__zhin_group_at = event === 'GROUP_AT_MESSAGE_CREATE';
56
+ }
57
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import path from "node:path";
5
5
  import { usePlugin, type Plugin, type IGroupManagement, createGroupManagementTools, type ToolFeature } from "zhin.js";
6
+ import type { Router } from "@zhin.js/host-router";
6
7
  import { QQAdapter } from "./adapter.js";
7
8
  import { PageManager } from "@zhin.js/host-api";
8
9
 
@@ -10,6 +11,7 @@ declare module "zhin.js" {
10
11
  namespace Plugin {
11
12
  interface Contexts {
12
13
  web: PageManager;
14
+ router: Router;
13
15
  }
14
16
  }
15
17
  }
@@ -27,17 +29,19 @@ export { QQAdapter } from "./adapter.js";
27
29
  const plugin = usePlugin();
28
30
  const { provide, useContext } = plugin;
29
31
 
30
- provide({
31
- name: "qq",
32
- description: "QQ Official Bot Adapter",
33
- mounted: async (p: Plugin) => {
34
- const adapter = new QQAdapter(p);
35
- await adapter.start();
36
- return adapter;
37
- },
38
- dispose: async (adapter: QQAdapter) => {
39
- await adapter.stop();
40
- },
32
+ useContext("router", (router: Router) => {
33
+ provide({
34
+ name: "qq",
35
+ description: "QQ Official Bot Adapter",
36
+ mounted: async (p: Plugin) => {
37
+ const adapter = new QQAdapter(p, router);
38
+ await adapter.start();
39
+ return adapter;
40
+ },
41
+ dispose: async (adapter: QQAdapter) => {
42
+ await adapter.stop();
43
+ },
44
+ });
41
45
  });
42
46
 
43
47
  useContext('tool', 'qq', (toolService: ToolFeature, qq: QQAdapter) => {
@@ -296,28 +300,46 @@ useContext("router", "qq", (router: any, qq: QQAdapter) => {
296
300
  }
297
301
  });
298
302
 
299
- // 频道列表
303
+ // 频道列表(QQ 官方 API 返回 guild_id/guild_name,归一化为控制台用的 id/name)
300
304
  router.get("/api/qq/bots/:name/guilds", async (ctx: any) => {
301
305
  try {
302
306
  const bot = qq.bots.get(ctx.params.name);
303
307
  if (!bot) { ctx.status = 404; ctx.body = { success: false, error: "Bot 不存在" }; return; }
304
308
  if (!bot.$connected) { ctx.status = 400; ctx.body = { success: false, error: "Bot 未连接" }; return; }
305
- const guilds = await bot.getGuilds();
306
- ctx.body = { success: true, data: guilds || [] };
309
+ const raw = await bot.getGuilds();
310
+ const guilds = (raw || []).map((g: any) => ({
311
+ id: g.guild_id ?? g.id,
312
+ name: g.guild_name ?? g.name,
313
+ icon: g.icon,
314
+ description: g.description,
315
+ memberCount: g.member_count,
316
+ ownerId: g.owner_id,
317
+ owner: g.owner,
318
+ joinTime: g.join_time,
319
+ }));
320
+ ctx.body = { success: true, data: guilds };
307
321
  } catch (e: any) {
308
322
  ctx.status = 500;
309
323
  ctx.body = { success: false, error: e?.message || "获取频道失败" };
310
324
  }
311
325
  });
312
326
 
313
- // 子频道列表
327
+ // 子频道列表(QQ 官方 API 返回 channel_id/channel_name,归一化为 id/name)
314
328
  router.get("/api/qq/bots/:name/guilds/:guildId/channels", async (ctx: any) => {
315
329
  try {
316
330
  const bot = qq.bots.get(ctx.params.name);
317
331
  if (!bot) { ctx.status = 404; ctx.body = { success: false, error: "Bot 不存在" }; return; }
318
332
  if (!bot.$connected) { ctx.status = 400; ctx.body = { success: false, error: "Bot 未连接" }; return; }
319
- const channels = await bot.getChannels(ctx.params.guildId);
320
- ctx.body = { success: true, data: channels || [] };
333
+ const raw = await bot.getChannels(ctx.params.guildId);
334
+ const channels = (raw || []).map((ch: any) => ({
335
+ id: ch.channel_id ?? ch.id,
336
+ name: ch.channel_name ?? ch.name,
337
+ type: ch.type,
338
+ subType: ch.sub_type,
339
+ position: ch.position,
340
+ parentId: ch.parent_id,
341
+ }));
342
+ ctx.body = { success: true, data: channels };
321
343
  } catch (e: any) {
322
344
  ctx.status = 500;
323
345
  ctx.body = { success: false, error: e?.message || "获取子频道失败" };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * 将 AI 出站纯文本段合并为 QQ Markdown 消息(msg_type=2)。
3
+ * 官方已开放全量 Markdown,避免 **bold** 等语法以纯文本展示。
4
+ */
5
+ import { segment, type MessageElement, type MessageSegment, type SendContent } from "zhin.js";
6
+
7
+ function isMessageSegment(seg: MessageElement): seg is MessageSegment {
8
+ return typeof seg.type === "string";
9
+ }
10
+
11
+ export type OutboundMarkdownMode = boolean | "auto";
12
+
13
+ /** 常见 AI Markdown 输出特征(auto 模式) */
14
+ const MARKDOWN_HINT =
15
+ /(?:\*\*|__|`|\[.+\]\(.+\)|^#{1,6}\s|^>\s|^\s*[-*+]\s|^```|\|.+\|)/m;
16
+
17
+ const BODY_RICH_TYPES = new Set([
18
+ "image",
19
+ "audio",
20
+ "video",
21
+ "file",
22
+ "markdown",
23
+ "ark",
24
+ "embed",
25
+ "keyboard",
26
+ "button",
27
+ "face",
28
+ ]);
29
+
30
+ function textSegment(text: string): MessageSegment {
31
+ return { type: "text", data: { text } };
32
+ }
33
+
34
+ function asSegments(content: SendContent): MessageSegment[] {
35
+ if (typeof content === "string") return [textSegment(content)];
36
+ if (!Array.isArray(content)) {
37
+ return isMessageSegment(content) ? [content] : [];
38
+ }
39
+ return content.flatMap((item) =>
40
+ typeof item === "string" ? [textSegment(item)] : isMessageSegment(item) ? [item] : [],
41
+ );
42
+ }
43
+
44
+ function textFromSegment(seg: MessageSegment): string {
45
+ if (seg.type !== "text") return "";
46
+ return seg.data.text ?? seg.data.content ?? "";
47
+ }
48
+
49
+ function shouldConvert(mode: OutboundMarkdownMode | undefined, bodyText: string): boolean {
50
+ if (mode === false) return false;
51
+ if (mode === true) return bodyText.trim().length > 0;
52
+ return MARKDOWN_HINT.test(bodyText);
53
+ }
54
+
55
+ /**
56
+ * 出站归一化:保留 leading `reply`,将连续 text 合并为单条 markdown 段。
57
+ * 含图片/文件等富媒体时原样返回,避免破坏分条发送。
58
+ */
59
+ export function normalizeOutboundMarkdown(
60
+ content: SendContent,
61
+ mode: OutboundMarkdownMode | undefined = "auto",
62
+ ): SendContent {
63
+ const segments = asSegments(content);
64
+ if (segments.length === 0) return content;
65
+
66
+ const prefix: MessageSegment[] = [];
67
+ let i = 0;
68
+ while (i < segments.length && segments[i].type === "reply") {
69
+ prefix.push(segments[i]);
70
+ i++;
71
+ }
72
+
73
+ const body = segments.slice(i);
74
+ if (body.length === 0) return content;
75
+
76
+ if (body.some((seg) => BODY_RICH_TYPES.has(seg.type))) {
77
+ return content;
78
+ }
79
+
80
+ if (body.some((seg) => seg.type !== "text" && seg.type !== "at")) {
81
+ return content;
82
+ }
83
+
84
+ const merged = body
85
+ .map((seg) => (seg.type === "at"
86
+ ? `<@${String(seg.data.user_id ?? "")}>`
87
+ : textFromSegment(seg)))
88
+ .join("");
89
+
90
+ if (!shouldConvert(mode, merged)) return content;
91
+
92
+ return [...prefix, segment("markdown", { content: merged })];
93
+ }