@karinjs/adapter-qqbot 3.1.0 → 3.2.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.
package/README.md CHANGED
@@ -119,9 +119,9 @@ export const menuKeyboard = karin.button(/^菜单(?:\s+\d+)?$/, (next, args) =>
119
119
  - 较大的图片或视频直接交给 QQ 发送时,QQ 客户端可能会把它显示成群文件,而不是图片或视频卡片;配置 `fileToUrl` 后通常可以避免这种显示问题。
120
120
  - 直接交给 QQ 只是兜底方案,生产环境仍然建议准备自己的文件服务。
121
121
 
122
- > **注意:Markdown 里的图片必须配置 `fileToUrl`。**
122
+ > **注意:Markdown 里的图片依赖 `fileToUrl`。**
123
123
  >
124
- > 只要你发送的内容包含 Markdown,并且 Markdown 里有图片、本地生成图、Base64 图等资源,就必须配置 `fileToUrl`。因为 Markdown 里的图片只能写成 QQ 能访问的链接,适配器不能把 Markdown 文本里的图片自动当成附件上传。
124
+ > Markdown 里的图片只能写成 QQ 能访问的链接,适配器不能把 Markdown 文本里的图片自动当成附件上传。QQ 单聊和群聊有下面提到的内置临时图床可以兜住,频道场景则必须自己配置 `fileToUrl`。
125
125
 
126
126
  你可以在自己的 Karin 插件中编写并注册 `fileToUrl` Handler:
127
127
 
@@ -154,6 +154,8 @@ export const uploadResource = karin.handler('fileToUrl', async (args) => {
154
154
 
155
155
  建议生产环境都配置 `fileToUrl`。这样图片、视频和文件都可以先上传到你自己的文件服务,再交给 QQ 发送,成功率和可控性都会更好。
156
156
 
157
+ 没有自己的图床也不至于完全用不了 Markdown 图片:适配器内置了一个基于 QQ 官方分片上传的临时图床,QQ 单聊和群聊里会自动生效,把本地图片、截图、Base64 图片换成临时直链后嵌进 Markdown。它排在你自己的 `fileToUrl` 之后,只在你没有配置、或者你的图床这次没接手时才出手,拿到的地址会过期,因此仅供当次发送使用。频道场景和文件类型用不了它,仍然需要自备 `fileToUrl`。
158
+
157
159
  ## 事件
158
160
 
159
161
  - 支持 QQ 单聊、群聊、频道、频道私信和按钮回调。
@@ -0,0 +1,4 @@
1
+ //#region src/apps/qqbot-file-to-url.d.ts
2
+ declare const qqbotFileToUrl: import("node-karin").Handler;
3
+ //#endregion
4
+ export { qqbotFileToUrl };
@@ -0,0 +1,94 @@
1
+ import { n as getImageSize } from "../common-OHGAoohl.js";
2
+ import karin, { getAllBot } from "node-karin";
3
+
4
+ //#region src/apps/qqbot-file-to-url.ts
5
+ /**
6
+ * 内置 `fileToUrl` 处理器:用 QQ 官方分片上传充当临时图床。
7
+ *
8
+ * 分片上传完成后,合并响应会带上资源的下载直链。有了这个地址,本地图片、截图、
9
+ * base64 图片就能以 markdown 图片语法进入消息正文,和文本、按钮合并成一条消息,
10
+ * 而不是退化成一条独立的富媒体消息。
11
+ *
12
+ * 无需任何配置:适配器发送消息时会把执行发送的 bot 和真实发送目标一并交给本
13
+ * 处理器,上传因此总是发生在同一个 bot、同一个会话下。QQ 的 openid 按 bot
14
+ * 隔离,这一点不能靠猜。上传只借用会话的上传通道,不会往会话里发任何消息。
15
+ *
16
+ * 只在以下条件全部满足时接手,其余情况一律放行给链路上的其他处理器:
17
+ * - 资源要被写进 markdown 正文(富媒体上传不需要先换成 URL);
18
+ * - 类型是图片 / 视频 / 语音(QQ 不为文件类型返回直链);
19
+ * - 能确定执行上传的 bot 与归属会话(频道场景没有可用 openid)。
20
+ */
21
+ /**
22
+ * 判断已注册的 bot 是否由本插件提供。
23
+ *
24
+ * @param bot Karin 注册表里的 bot 实例。
25
+ * @returns 是否为 QQBot 适配器。
26
+ */
27
+ const isQQBot = (bot) => {
28
+ const candidate = bot;
29
+ return candidate?.adapter?.protocol === "qqbot" && !!candidate.super?.media;
30
+ };
31
+ /**
32
+ * 按 appId 取出对应的 QQBot。
33
+ *
34
+ * 不做「随便挑一个」的兜底:openid 按 bot 隔离,用错 bot 上传要么失败,要么把
35
+ * 文件传到了另一个账号的会话下,只能定位到确切的那一个。
36
+ *
37
+ * @param selfId 目标 bot 的 appId。
38
+ * @returns 对应的 QQBot 适配器,未注册时返回 undefined。
39
+ */
40
+ const findBot = (selfId) => {
41
+ return getAllBot().filter(isQQBot).find((bot) => bot.selfId === selfId);
42
+ };
43
+ /**
44
+ * 把处理器收到的文件参数归一成 media 接口可用的来源字符串。
45
+ *
46
+ * @param file 处理器入参里的文件数据。
47
+ * @returns 路径 / URL 字符串,或 base64:// 形式的内容。
48
+ */
49
+ const toUploadSource = (file) => {
50
+ if (typeof file === "string") return file;
51
+ return `base64://${Buffer.from(file).toString("base64")}`;
52
+ };
53
+ const qqbotFileToUrl = karin.handler("fileToUrl", async (payload, next) => {
54
+ /** fileToUrl 把第四个参数原样放在 args 字段下。 */
55
+ const { file, type, filename, args: extra } = payload;
56
+ /**
57
+ * 富媒体上传接受本地文件与 base64,先换成 URL 再交给 QQ 反而多一次往返,
58
+ * 而且那次往返仍然是 QQ 自己下载转存,没有收益。
59
+ */
60
+ if (extra?.purpose !== "markdown") return next();
61
+ /** QQ 只对图片 / 视频 / 语音返回下载直链。 */
62
+ if (type === "file") return next();
63
+ /** 频道场景用 channel_id,没有上传接口要求的 openid。 */
64
+ const origin = extra.origin;
65
+ if (!extra.selfId || !origin) return next();
66
+ const bot = findBot(extra.selfId);
67
+ if (!bot) return next();
68
+ try {
69
+ const { url, response } = await bot.super.media.uploadForUrl(origin.scene, origin.peer, type, toUploadSource(file), filename);
70
+ bot.logger("debug", `[内置图床] 已取得直链,有效期 ${response.ttl}s: ${url}`);
71
+ if (type !== "image") return { url };
72
+ /** markdown 图片语法需要宽高;解析不出来时 getImageSize 会给出保守默认值。 */
73
+ const { width, height } = await getImageSize(file);
74
+ return {
75
+ url,
76
+ width,
77
+ height
78
+ };
79
+ } catch (err) {
80
+ const reason = err instanceof Error ? err.message : String(err);
81
+ bot.logger("debug", `[内置图床] 取直链失败,交给后续处理器: ${reason}`);
82
+ return next();
83
+ }
84
+ }, {
85
+ name: "QQBot 分片上传图床",
86
+ /**
87
+ * rank 升序执行,默认 10000。这里排在默认值之后:本处理器给出的是会过期的
88
+ * 临时地址,使用方自建的图床通常是长期地址,应当优先生效。
89
+ */
90
+ rank: 2e4
91
+ });
92
+
93
+ //#endregion
94
+ export { qqbotFileToUrl };
@@ -0,0 +1,42 @@
1
+ import { common, logger } from "node-karin";
2
+ import qrcode from "qrcode";
3
+ import size from "image-size";
4
+ import lodash from "node-karin/lodash";
5
+ import "@karinjs/geturls";
6
+
7
+ //#region src/core/constants/regex.ts
8
+ /** HTTP(S) 资源已经是可直接传给 QQ 平台的公网地址。 */
9
+ const HTTP_URL_RE = /^https?:\/\//i;
10
+ /** Karin 常见 base64 图片/媒体前缀。 */
11
+ const BASE64_PREFIX_RE = /^base64:\/\//i;
12
+ /** 标准 data URL 的 base64 前缀。 */
13
+ const DATA_URL_BASE64_RE = /^data:[^;,]+;base64,/i;
14
+
15
+ //#endregion
16
+ //#region src/utils/common.ts
17
+ /** 随机数 */
18
+ const random = (min, max) => lodash.random(min, max);
19
+ /**
20
+ * 伪事件日志(红字)
21
+ */
22
+ const fakeEvent = (log) => {
23
+ logger.fatal(`${logger.red("[QQ Official Bot][虚假事件]")} ${log.replace(/\n/g, "\\n")}`);
24
+ };
25
+ /**
26
+ * 读取图片宽高
27
+ *
28
+ * @param source 图片来源,支持 URL、本地路径、base64://、完整 data URL 与已在内存中的 Buffer。
29
+ * @returns 图片宽高;解析失败时回落到 100x100。
30
+ */
31
+ const getImageSize = async (source) => {
32
+ /** karin 的 common.buffer 不认识带 MIME 头的 data URL,先归一为 base64:// 形式 */
33
+ if (typeof source === "string" && DATA_URL_BASE64_RE.test(source)) source = source.replace(DATA_URL_BASE64_RE, "base64://");
34
+ const { width = 100, height = 100 } = size(await common.buffer(source));
35
+ return {
36
+ width,
37
+ height
38
+ };
39
+ };
40
+
41
+ //#endregion
42
+ export { DATA_URL_BASE64_RE as a, BASE64_PREFIX_RE as i, getImageSize as n, HTTP_URL_RE as o, random as r, fakeEvent as t };
package/dist/index.d.ts CHANGED
@@ -89,6 +89,18 @@ interface SendQQMediaMessageRequest extends SendQQMessageRequest, QQMessageID {
89
89
  /** 文件信息,用于发消息接口的 media 字段使用 */
90
90
  file_info: string;
91
91
  };
92
+ /**
93
+ * 按钮。
94
+ *
95
+ * `keyboard` 在官方发消息接口中是与 `content` / `markdown` / `media` 平级的独立字段,
96
+ * 文档未声明它与 `media` 互斥(互斥约束只写在 `content` / `markdown` / `ark` 之间),
97
+ * 接口说明也写明「支持文本/Markdown/富媒体等类型,可附带内嵌键盘」。
98
+ *
99
+ * 用于图片走 QQ 上传兜底(无法进入 markdown)时,让按钮随富媒体一起发出,
100
+ * 避免额外产生一条只有按钮的空消息。若平台拒绝(305007),发送层会去掉
101
+ * keyboard 重试一次,保证富媒体本身能送达。
102
+ */
103
+ keyboard?: Keyboard;
92
104
  }
93
105
  /** 发送QQ流式消息请求参数(POST /v2/users/{openid}/stream_messages,仅单聊) */
94
106
  interface SendQQStreamMessageRequest {
@@ -329,6 +341,16 @@ interface UploadMediaResponse {
329
341
  ttl: number;
330
342
  /** 发送消息的唯一ID,当srv_send_msg设置为true时返回 */
331
343
  id: string;
344
+ /**
345
+ * 文件下载链接(COS 预签名 GET URL),有效期与 `ttl` 一致。
346
+ *
347
+ * 仅分片上传合并(走 `upload_id` 路径)且 `file_type` 为图片/视频/语音时返回;
348
+ * URL 直传和文件类型(`file_type=4`)不返回该字段。
349
+ *
350
+ * 这是把 QQ 分片上传当图床用的关键:拿到该直链后,图片就能以 markdown
351
+ * 图片语法嵌入 msg_type=2,不必再走 msg_type=7 富媒体单独发送。
352
+ */
353
+ raw_url?: string;
332
354
  }
333
355
  /** 自定义菜单按钮类型 */
334
356
  type MenuItemType = 'switch' | 'send_message' | 'link' | 'menu';
@@ -1046,6 +1068,29 @@ declare class MediaApi extends Http {
1046
1068
  * @returns 上传结果,包含后续发送消息所需的 file_info
1047
1069
  */
1048
1070
  uploadFallback(scene: Scene, peer: string, type: MediaType, source: string, srvSendMsg?: boolean, fileName?: string): Promise<UploadMediaResponse>;
1071
+ /**
1072
+ * 上传资源并取回它的公网直链。
1073
+ *
1074
+ * 分片上传的合并响应里带 `raw_url`,等价于一个临时图床:拿到地址后图片就能以
1075
+ * markdown 图片语法嵌进 msg_type=2,和文本、按钮合成一条消息,不必退化成
1076
+ * 独立的 msg_type=7 富媒体消息。
1077
+ *
1078
+ * 与 `uploadFallback` 的差别只有一处:那边按体积决定是否分片,目标是拿
1079
+ * `file_info`;这里必须无条件分片,否则服务端不会给出 `raw_url`。
1080
+ *
1081
+ * 直链的存活时间等于响应里的 `ttl`,属于临时地址,只适合当次发送使用。
1082
+ *
1083
+ * @param scene user(单聊) / group(群聊)
1084
+ * @param peer openid 或 group_openid,决定上传归属的会话
1085
+ * @param type 文件类型;`file` 无直链可取
1086
+ * @param source 本地路径、http url、data URL、base64:// 或裸 base64
1087
+ * @param fileName 文件名,同时用于推断直链声明的 MIME
1088
+ * @returns 可直接嵌入消息的直链,以及原始上传响应
1089
+ */
1090
+ uploadForUrl(scene: Scene, peer: string, type: MediaType, source: string, fileName?: string): Promise<{
1091
+ url: string;
1092
+ response: UploadMediaResponse;
1093
+ }>;
1049
1094
  /**
1050
1095
  * 确认单个分片已完成上传。
1051
1096
  *
@@ -1449,7 +1494,7 @@ declare class QQBotApi {
1449
1494
  text(content: string): SendQQTextMessageRequest;
1450
1495
  markdown(markdown: Markdown, keyboard?: Keyboard): SendQQMarkdownMessageRequest;
1451
1496
  ark(ark: SendQQArkMessageRequest["ark"]): SendQQArkMessageRequest;
1452
- media(fileInfo: string): SendQQMediaMessageRequest;
1497
+ media(fileInfo: string, keyboard?: Keyboard): SendQQMediaMessageRequest;
1453
1498
  };
1454
1499
  readonly guild: {
1455
1500
  text(content: string, image?: string): SendGuildTextMessageRequest;