@zhin.js/adapter-qq 2.0.10 → 2.0.12

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 +16 -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 +12 -1
  8. package/lib/bot.d.ts.map +1 -1
  9. package/lib/bot.js +121 -13
  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 +13 -11
  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 +6 -6
  42. package/src/adapter.ts +18 -1
  43. package/src/bot.ts +150 -14
  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 +15 -11
  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/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,8 +16,15 @@ 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
 
20
29
 
21
30
  export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = ApplicationPlatform>
@@ -37,21 +46,74 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
37
46
 
38
47
  constructor(public adapter: QQAdapter, config: QQBotConfig<T, M>) {
39
48
  if (!config.data_dir) config.data_dir = path.join(process.cwd(), "data");
49
+ if (config.mode === ReceiverMode.MIDDLEWARE) {
50
+ const mw = config as QQBotConfig<ReceiverMode.MIDDLEWARE, M> & {
51
+ platform?: ApplicationPlatform;
52
+ };
53
+ if (!mw.platform) {
54
+ mw.platform = (mw.application ?? "koa") as ApplicationPlatform;
55
+ }
56
+ }
40
57
  super(config);
41
58
  this.$config = config;
59
+ this.attachSdkVersionHeader();
60
+ applyCustomAuthEndpoints(this, config, this.pluginLogger);
61
+ }
62
+
63
+ /** 出站 QQ API 请求附带 SDK 版本,便于平台侧日志排查 */
64
+ private attachSdkVersionHeader(): void {
65
+ this.request.interceptors.request.use((reqConfig) => {
66
+ reqConfig.headers[SDK_VERSION_HEADER] = SDK_VERSION;
67
+ return reqConfig;
68
+ });
69
+ }
70
+
71
+ /** 归一化 QQ API v2 群聊字段后再交给 qq-official-bot 解析 */
72
+ dispatchEvent(event: string, wsRes: QqWsPacket): void {
73
+ this.pluginLogger.debug({
74
+ op: 'inbound_ws',
75
+ event,
76
+ group: String(wsRes.d?.group_openid ?? wsRes.d?.group_id ?? '?'),
77
+ });
78
+ if (event === 'GROUP_AT_MESSAGE_CREATE' || event === 'GROUP_MESSAGE_CREATE') {
79
+ this.pluginLogger.info(
80
+ `qq inbound ws: ${event} group=${String(wsRes.d?.group_openid ?? wsRes.d?.group_id ?? '?')}`,
81
+ );
82
+ }
83
+ normalizeQqInboundWsPayload(event, wsRes);
84
+ super.dispatchEvent(event, wsRes);
42
85
  }
43
86
 
44
87
  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)}`);
88
+ try {
89
+ const message = this.$formatMessage(msg);
90
+ this.adapter.emit("message.receive", message);
91
+ this.pluginLogger.debug(
92
+ `${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`,
93
+ );
94
+ } catch (err) {
95
+ this.pluginLogger.warn(
96
+ `qq format inbound failed (${msg.message_type}): ${err instanceof Error ? err.message : String(err)}`,
97
+ );
98
+ }
99
+ }
100
+
101
+ private handleGroupNotice(event: string, payload: { group_id?: string; operator_id?: string }): void {
102
+ this.pluginLogger.info(
103
+ `qq notice ${event}: group=${payload.group_id ?? '?'} operator=${payload.operator_id ?? '?'}`,
104
+ );
48
105
  }
49
106
 
50
107
  async $connect(): Promise<void> {
51
108
  this.on("message.group", this.handleQQMessage.bind(this));
52
109
  this.on("message.guild", this.handleQQMessage.bind(this));
53
110
  this.on("message.private", this.handleQQMessage.bind(this));
111
+ this.on("notice.group.increase", (e) => this.handleGroupNotice('group.add_robot', e));
112
+ this.on("notice.group.decrease", (e) => this.handleGroupNotice('group.del_robot', e));
113
+ this.on("notice.group.receive_open", (e) => this.handleGroupNotice('group.msg_receive_open', e));
114
+ this.on("notice.group.receive_close", (e) => this.handleGroupNotice('group.msg_receive_close', e));
54
115
  await this.start();
116
+ this.mountWebhookReceiver();
55
117
  this.$connected = true;
56
118
  try {
57
119
  const self = await this.getSelfInfo();
@@ -72,11 +134,81 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
72
134
  this.$connected = false;
73
135
  }
74
136
 
137
+ private resolveWebhookPath(): string {
138
+ const raw = this.$config.webhookPath ?? "/qq/webhook";
139
+ return raw.startsWith("/") ? raw : `/${raw}`;
140
+ }
141
+
142
+ /**
143
+ * Webhook 入站:middleware(挂 host-router)或独立 HTTP 端口(qq-official-bot 内置服务)。
144
+ */
145
+ private mountWebhookReceiver(): void {
146
+ const mode = this.$config.mode;
147
+ if (mode === ReceiverMode.MIDDLEWARE) {
148
+ const router = this.adapter.getRouter();
149
+ if (!router) {
150
+ throw new Error("QQ mode=middleware 需要启用 @zhin.js/host-router 插件");
151
+ }
152
+ const webhookPath = this.resolveWebhookPath();
153
+ const mw = this.middleware as (
154
+ ctx: RouterContext,
155
+ next: () => Promise<void>,
156
+ ) => Promise<unknown>;
157
+ router.post(webhookPath, async (ctx: RouterContext) => {
158
+ this.pluginLogger.debug(ctx.body);
159
+ await mw(ctx, async () => {});
160
+ });
161
+ this.pluginLogger.info(formatCompact({
162
+ op: "webhook",
163
+ mode: "middleware",
164
+ path: webhookPath,
165
+ url: `POST ${webhookPath}`,
166
+ note: "无 /api 前缀;Host API 才走 /api/*",
167
+ }));
168
+ return;
169
+ }
170
+ if (mode === ReceiverMode.WEBHOOK) {
171
+ const cfg = this.$config as QQBotConfig<ReceiverMode.WEBHOOK>;
172
+ this.pluginLogger.info(formatCompact({
173
+ op: "webhook",
174
+ mode: "standalone",
175
+ port: cfg.port,
176
+ path: cfg.path,
177
+ url: `http://127.0.0.1:${cfg.port}${cfg.path}`,
178
+ }));
179
+ }
180
+ }
181
+
75
182
  $formatMessage(msg: PrivateMessageEvent | GroupMessageEvent) {
183
+ const raw = msg as PrivateMessageEvent & GroupMessageEvent & {
184
+ group_openid?: string;
185
+ author?: { member_openid?: string; user_openid?: string; id?: string; username?: string };
186
+ __zhin_group_at?: boolean;
187
+ };
188
+
189
+ if (msg.message_type === "group") {
190
+ if (!raw.group_id && raw.group_openid) {
191
+ raw.group_id = raw.group_openid;
192
+ }
193
+ if (!msg.user_id && raw.author) {
194
+ const uid = raw.author.member_openid ?? raw.author.user_openid ?? raw.author.id;
195
+ if (uid) msg.user_id = String(uid);
196
+ }
197
+ }
198
+
76
199
  let target_id = msg.user_id;
77
200
  if (msg.message_type === "guild") target_id = msg.channel_id!;
78
- if (msg.message_type === "group") target_id = msg.group_id!;
201
+ if (msg.message_type === "group") target_id = raw.group_id ?? msg.group_id ?? "";
79
202
  if (msg.sub_type === "direct") target_id = `direct:${msg.guild_id}`;
203
+
204
+ let content = msg.message;
205
+ if (msg.message_type === "group" && Array.isArray(content)) {
206
+ const botAtIds = [this.$platformUserId, this.$config.appid]
207
+ .filter((id): id is string => Boolean(id))
208
+ .map(String);
209
+ content = normalizeGroupAtPrefix(content, botAtIds, raw.__zhin_group_at === true);
210
+ }
211
+
80
212
  const result = Message.from(msg, {
81
213
  $id: msg.message_id?.toString(),
82
214
  $adapter: "qq" as const,
@@ -89,7 +221,7 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
89
221
  id: target_id,
90
222
  type: msg.message_type === "guild" ? "channel" : msg.message_type,
91
223
  },
92
- $content: msg.message,
224
+ $content: content,
93
225
  $raw: msg.raw_message,
94
226
  $timestamp: Date.now(),
95
227
  $recall: async () => {
@@ -110,27 +242,31 @@ export class QQBot<T extends ReceiverMode, M extends ApplicationPlatform = Appli
110
242
  }
111
243
 
112
244
  async $sendMessage(options: SendOptions): Promise<string> {
245
+ const content = normalizeOutboundMarkdown(
246
+ normalizeOutboundMedia(options.content),
247
+ this.$config.outboundMarkdown,
248
+ );
113
249
  switch (options.type) {
114
250
  case "private": {
115
251
  if (options.id.startsWith("direct:")) {
116
252
  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)}`);
253
+ const result = await this.sendDirectMessage(id, content);
254
+ this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
119
255
  return `direct-${options.id}:${result.id.toString()}`;
120
256
  } 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)}`);
257
+ const result = await this.sendPrivateMessage(options.id, content);
258
+ this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
123
259
  return `private-${options.id}:${result.id.toString()}`;
124
260
  }
125
261
  }
126
262
  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)}`);
263
+ const result = await this.sendGroupMessage(options.id, content);
264
+ this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
129
265
  return `group-${options.id}:${result.id.toString()}`;
130
266
  }
131
267
  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)}`);
268
+ const result = await this.sendGuildMessage(options.id, content);
269
+ this.pluginLogger.debug(`${this.$config.name} send ${options.type}(${options.id}):${segment.raw(content)}`);
134
270
  return `channel-${options.id}:${result.id.toString()}`;
135
271
  }
136
272
  default:
@@ -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) => {
@@ -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
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * QQ 官方出站媒体归一化。
3
+ * qq-official-bot 的 formatMediaData 对 data.url 直接当远程 URL 上传;
4
+ * base64:// / data:...;base64 须走 data.file 才会解析为 file_data。
5
+ */
6
+ import type { MessageElement, MessageSegment, SendContent } from "zhin.js";
7
+
8
+ const MEDIA_TYPES = new Set(["image", "audio", "video", "file"]);
9
+
10
+ function isMessageSegment(seg: MessageElement): seg is MessageSegment {
11
+ return typeof seg.type === "string";
12
+ }
13
+
14
+ function isRemoteUrl(value: string): boolean {
15
+ return /^https?:\/\//i.test(value);
16
+ }
17
+
18
+ function isInlineBase64(value: string): boolean {
19
+ return value.startsWith("base64://") || /^data:[^/]+\/[^;]+;base64,/i.test(value);
20
+ }
21
+
22
+ function resolveMediaFile(data: Record<string, unknown>): string | undefined {
23
+ const url = typeof data.url === "string" ? data.url : undefined;
24
+ const file = typeof data.file === "string" ? data.file : undefined;
25
+ const base64 = typeof data.base64 === "string" ? data.base64 : undefined;
26
+
27
+ if (file && !isRemoteUrl(file)) return file;
28
+ if (url && isInlineBase64(url)) return url;
29
+ if (url && !isRemoteUrl(url) && (url.startsWith("file://") || url.startsWith("/"))) return url;
30
+ if (base64) return base64.startsWith("base64://") ? base64 : `base64://${base64}`;
31
+ return undefined;
32
+ }
33
+
34
+ function normalizeMediaSegment(seg: MessageSegment): MessageSegment {
35
+ if (!MEDIA_TYPES.has(seg.type)) return seg;
36
+
37
+ const data = { ...seg.data } as Record<string, unknown>;
38
+ const file = resolveMediaFile(data);
39
+ if (!file) return seg;
40
+
41
+ delete data.url;
42
+ delete data.base64;
43
+ data.file = file;
44
+ return { type: seg.type, data } as MessageSegment;
45
+ }
46
+
47
+ /** 将 Zhin 出站 image/base64 段转为 qq-official-bot 可识别的 file 字段 */
48
+ export function normalizeOutboundMedia(content: SendContent): SendContent {
49
+ if (typeof content === "string") return content;
50
+ if (!Array.isArray(content)) {
51
+ return isMessageSegment(content) ? normalizeMediaSegment(content) : content;
52
+ }
53
+ return content.map((item) => {
54
+ if (typeof item === "string") return item;
55
+ return isMessageSegment(item) ? normalizeMediaSegment(item) : item;
56
+ });
57
+ }
@@ -0,0 +1,21 @@
1
+ import { createRequire } from "node:module";
2
+ import adapterPkg from "../package.json" with { type: "json" };
3
+
4
+ const require = createRequire(import.meta.url);
5
+
6
+ function readDepVersion(dep: string): string {
7
+ try {
8
+ return (require(`${dep}/package.json`) as { version: string }).version;
9
+ } catch {
10
+ return "unknown";
11
+ }
12
+ }
13
+
14
+ /** HTTP header name for outbound QQ API requests. */
15
+ export const SDK_VERSION_HEADER = "x-sdk-version";
16
+
17
+ /**
18
+ * Composite SDK identity: Zhin adapter + underlying qq-official-bot.
19
+ * Example: `zhin-adapter-qq/v2.0.11+qq-official-bot/v1.2.1`
20
+ */
21
+ export const SDK_VERSION = `zhin-adapter-qq/v${adapterPkg.version}+qq-official-bot/v${readDepVersion("qq-official-bot")}`;
package/src/types.ts CHANGED
@@ -14,6 +14,28 @@ export type QQBotConfig<
14
14
  context: "qq";
15
15
  name: string;
16
16
  data_dir?: string;
17
+ /**
18
+ * middleware 模式:挂在 host-router 的回调路径(默认 `/qq/webhook`,完整 URL 为 `{host}:8086/qq/webhook`,无 `/api` 前缀)。
19
+ * webhook 独立端口模式:使用 qq-official-bot 的 `path` 字段。
20
+ */
21
+ webhookPath?: string;
22
+ /**
23
+ * 自定义 access token 接口(完整 URL)。
24
+ * 默认 `https://bots.qq.com/app/getAppAccessToken`;代理或私有部署时可覆盖。
25
+ */
26
+ accessTokenUrl?: string;
27
+ /**
28
+ * 自定义 gateway 接口(完整 URL 或相对路径,如 `/gateway/bot`)。
29
+ * 响应中的 `url` 为 WebSocket 地址;**仅 `mode: websocket` 入站时使用**。
30
+ */
31
+ gatewayUrl?: string;
32
+ /**
33
+ * AI 出站是否转为 QQ Markdown(`msg_type=2`)。
34
+ * - `auto`(默认):正文含 Markdown 语法时转换
35
+ * - `true`:纯文本也走 Markdown
36
+ * - `false`:保持纯文本
37
+ */
38
+ outboundMarkdown?: boolean | "auto";
17
39
  };
18
40
 
19
41
  export interface QQBot<