koishi-plugin-kkk 3.7.1 → 3.8.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/CHANGELOG.md +60 -0
- package/lib/build-metadata.json +5 -5
- package/lib/compat/imageMarkdown.js +280 -0
- package/lib/compat/node-karin.js +40 -9
- package/lib/karin/module/utils/amagiClient.js +40 -15
- package/lib/karin/platform/douyin/douyin.js +10 -0
- package/lib/ktr/template/bilibili/interactive/components/interactive.js +99 -5
- package/package.json +2 -2
- package/scripts/probe-chart-wrap.cjs +33 -0
- package/scripts/smoke-amagi-capability.cjs +97 -0
- package/scripts/smoke-comments.cjs +164 -0
- package/scripts/smoke-md-image.cjs +206 -0
- package/src/compat/imageMarkdown.ts +263 -0
- package/src/compat/node-karin.ts +43 -9
- package/src/karin/module/utils/amagiClient.ts +49 -27
- package/src/karin/platform/douyin/douyin.ts +10 -0
- package/src/ktr/template/bilibili/interactive/components/interactive.tsx +102 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 3.8.0
|
|
4
|
+
|
|
5
|
+
> 抖音解析修好了。抖音那边改了风控规则,**换 cookie 是没用的** —— 问题出在解析用的那个库太旧;
|
|
6
|
+
> 顺带修好快手、小红书的评论区渲染。
|
|
7
|
+
|
|
8
|
+
### 抖音:解析不了 / 一直提示 Cookie 失效
|
|
9
|
+
|
|
10
|
+
- **现象**:发抖音链接,机器人回「抖音没有返回这条作品的数据(错误码 500:抖音数据获取失败),
|
|
11
|
+
常见原因:Cookie 失效、被风控」,可同一个 cookie 换个插件却能正常解析。
|
|
12
|
+
- **原因**:抖音现在要求每个网页接口都带上设备标识(`uifid`)和一套新的签名头
|
|
13
|
+
(`x-secsdk-web-signature`,由抖音自己的风控 SDK 算出来)。
|
|
14
|
+
插件用的解析库 6.6.0 里根本没有这套东西,请求直接被抖音拦下
|
|
15
|
+
(服务器回的是「Blocked by ArgusSecurityPlugin Uifid Not Found」),
|
|
16
|
+
库再把它包成一句「抖音数据获取失败」—— 看起来就像 cookie 过期,怎么换都没用。
|
|
17
|
+
- **处理**:把解析库升到 7.x(目前是 7.0.0-beta.5;npm 上标着「最新」的 6.6.0 反而是坏的)。
|
|
18
|
+
升级后实测:作品详情、评论区、弹幕都正常。
|
|
19
|
+
- 更新完**重启一次机器人**即可;手动 `npm i` 更新插件时会自动装上对应版本的解析库。
|
|
20
|
+
|
|
21
|
+
### 日志更好排查
|
|
22
|
+
|
|
23
|
+
- 以后再遇到这类失败,日志里会直接写明「**多半是解析库版本过旧**」,
|
|
24
|
+
不用再照着「换 cookie、换网络」排查半天。
|
|
25
|
+
|
|
26
|
+
### 修复快手评论渲染
|
|
27
|
+
|
|
28
|
+
- 快手评论区接口以前未实现,现已实现
|
|
29
|
+
|
|
30
|
+
### 修复小红书评论渲染
|
|
31
|
+
|
|
32
|
+
- 小红书评论区接口变更导致失效,现已重新接好,评论能正常显示了
|
|
33
|
+
|
|
34
|
+
## 3.7.2
|
|
35
|
+
|
|
36
|
+
> 群里发的图片**统一改走 markdown** 了 —— 普通图片消息会被 QQ 压得发糊,markdown 图片不会;
|
|
37
|
+
> 顺手把互动视频那张**剧情图的字放大**了。
|
|
38
|
+
|
|
39
|
+
### 图片不再被压糊
|
|
40
|
+
|
|
41
|
+
- 卡片、评论图、图集、推送里的图……以前都是当「普通图片」发出去的,QQ 会二次压缩,
|
|
42
|
+
卡片上的小字糊成一团。现在**图片一律按 markdown 图片发**:先传到机器人自己的图床,
|
|
43
|
+
再用带尺寸的图片写法发出去,字是清楚的。
|
|
44
|
+
- markdown 图片**必须写死尺寸**(``)—— 不写尺寸的话手机端根本不显示这张图,
|
|
45
|
+
所以每一张都会先把真实的宽高读出来(JPEG / PNG / GIF / WebP 都认)。
|
|
46
|
+
- **平台不一样,处理不一样**(客户端能力决定的,不是选择):
|
|
47
|
+
- QQ 官方机器人:走 markdown 图片;
|
|
48
|
+
- OneBot 系(NapCat、Lagrange、go-cqhttp、Chronocat 等):客户端不认 markdown,
|
|
49
|
+
收到只会显示成一串网址、图片全丢,所以这条链路**照旧发普通图片**;
|
|
50
|
+
- QQ 频道(qqguild)同理,也不改写。
|
|
51
|
+
- 一条消息里**同时有视频 / 语音 / 文件**时不改写:那种消息会按媒体发出去,markdown 内容会被丢掉。
|
|
52
|
+
- 转发里的图、合并转发收集中的图也不改写,保持原样。
|
|
53
|
+
- 传图床失败(或者宿主没装 assets 服务)时**照旧发普通图片**,不会因为改写不了就把图弄丢。
|
|
54
|
+
- 同一张图反复发(推送封面、面板卡片)不会再反复上传。
|
|
55
|
+
|
|
56
|
+
### 互动视频:剧情图的字大了
|
|
57
|
+
|
|
58
|
+
- 剧情图里每个选项 / 结局的**字号调大**了(大约 1/3),手机上看得清多了。
|
|
59
|
+
- 长文字不再按「14 个字」硬切,改成**按这一列还能放多少像素**来截断,
|
|
60
|
+
字大了也不会压到下一列的圆圈上;最后一列右边本来空着,会多显示几个字。
|
|
61
|
+
- 「已选」「图例」「提示」这些说明文字也跟着放大了一点。
|
|
62
|
+
|
|
3
63
|
## 3.7.1
|
|
4
64
|
|
|
5
65
|
> 修一个 3.7.0 改过头的地方:**视频直接发到群里的那种互动视频,照旧要在群里发带按钮的选择面板**;
|
package/lib/build-metadata.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "3.
|
|
3
|
-
"buildTime": "2026-09-
|
|
4
|
-
"buildTimestamp":
|
|
2
|
+
"version": "3.8.0",
|
|
3
|
+
"buildTime": "2026-09-26T03:37:45.450Z",
|
|
4
|
+
"buildTimestamp": 1790393865451,
|
|
5
5
|
"name": "koishi-plugin-kkk",
|
|
6
6
|
"description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
|
|
7
7
|
"homepage": "https://github.com/maimai993/koishi-plugin-kkk#readme",
|
|
8
|
-
"commitHash": "
|
|
9
|
-
"shortCommitHash": "
|
|
8
|
+
"commitHash": "d5bd8abb16c68f3f03575a4f8de736512e55e94b",
|
|
9
|
+
"shortCommitHash": "d5bd8ab"
|
|
10
10
|
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.imagesToMarkdown = exports.clearMarkdownImageCache = exports.markdownImageOf = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* 图片段 → markdown 图片(``)。
|
|
9
|
+
*
|
|
10
|
+
* 用户反馈两条,这里一起解决:
|
|
11
|
+
* 1. **「所有的图片发送都要走 md,不然太模糊了」** —— QQ 的普通图片消息(image 段 → 适配器按
|
|
12
|
+
* MEDIA 上传)会被客户端二次压缩,卡片上的小字糊成一团;markdown 里的图片不会被压,字是清楚的。
|
|
13
|
+
* 2. **「![#px大小#px大小]() 必须要 #px,不然手机不会显示」** —— md 图片必须写死尺寸,
|
|
14
|
+
* 不写尺寸手机端干脆不显示这张图。
|
|
15
|
+
*
|
|
16
|
+
* 所以这里在**发送出口**统一改写:compat/node-karin 的 reply / sendMsg / sendMaster 四条路径都过一遍,
|
|
17
|
+
* 业务代码照旧 e.reply(segment.image(...)),不用一处处改。
|
|
18
|
+
*
|
|
19
|
+
* 只在「认 markdown 的平台」上生效(官方 QQ:`qq`):
|
|
20
|
+
* - OneBot 系(NapCat / Lagrange / go-cqhttp / Chronocat…)收到 markdown 只显示成一串文字、
|
|
21
|
+
* 图片全丢(用户实测),所以那条链路继续发图片段;
|
|
22
|
+
* - QQ 频道(`qqguild`)的适配器没有 markdown 分支,md 元素会被当纯文本渲染 → 也排除。
|
|
23
|
+
*
|
|
24
|
+
* 另外三种情况**不动**(动了只会更糟):
|
|
25
|
+
* - 消息里还有视频 / 语音 / 文件:适配器遇到附件会把整条消息转成 MEDIA,markdown 内容会被丢掉;
|
|
26
|
+
* - 合并转发收集中的消息:转发节点里塞 markdown 客户端不认;
|
|
27
|
+
* - 上传拿不到公网地址(没装 assets 服务等)、或者**读不出图片尺寸**:保持原来的图片段。
|
|
28
|
+
* 读不出尺寸是可以退回普通图片的,但**绝不能发一张不带尺寸的 markdown 图片** ——
|
|
29
|
+
* 用户实测「必须要 #px,不然手机不会显示」,那种图上手机就是一片空白。
|
|
30
|
+
*/
|
|
31
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
32
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
33
|
+
const node_url_1 = require("node:url");
|
|
34
|
+
const logger_1 = require("./logger");
|
|
35
|
+
const runtime_1 = require("./runtime");
|
|
36
|
+
const segment_1 = require("./segment");
|
|
37
|
+
/** OneBot 系:不渲染 markdown(见文件头) */
|
|
38
|
+
const ONEBOT_LIKE = /onebot|napcat|lagrange|go-?cqhttp|chronocat|mirai/i;
|
|
39
|
+
/**
|
|
40
|
+
* 会渲染 markdown 图片的平台。
|
|
41
|
+
*
|
|
42
|
+
* 只认 `qq` 这一个平台名 —— QQ 的 markdown 是**官方机器人**能力,
|
|
43
|
+
* `qqguild`(频道)走的是另一套编码器,md 元素在那里会被当成纯文本原样发出去。
|
|
44
|
+
*/
|
|
45
|
+
const canUseMarkdownImage = (platform) => {
|
|
46
|
+
const name = String(platform || '').toLowerCase();
|
|
47
|
+
if (!name)
|
|
48
|
+
return false;
|
|
49
|
+
if (ONEBOT_LIKE.test(name))
|
|
50
|
+
return false;
|
|
51
|
+
if (name.startsWith('qqguild'))
|
|
52
|
+
return false;
|
|
53
|
+
return name === 'qq' || name.startsWith('qq-') || name.startsWith('qqbot');
|
|
54
|
+
};
|
|
55
|
+
/** 媒体段:md 和附件不能塞进同一条消息(会被适配器吃掉),所以要跳过整条改写 */
|
|
56
|
+
const ATTACHMENT_TYPES = new Set(['video', 'audio', 'record', 'file']);
|
|
57
|
+
const isImageElement = (element) => !!element && typeof element === 'object' && (element.type === 'img' || element.type === 'image');
|
|
58
|
+
const srcOf = (element) => {
|
|
59
|
+
const attrs = element?.attrs ?? {};
|
|
60
|
+
const src = attrs.src ?? attrs.url ?? element?.data?.file ?? element?.data?.url;
|
|
61
|
+
return typeof src === 'string' ? src : '';
|
|
62
|
+
};
|
|
63
|
+
/** 按二进制头认 mime(发给 assets 用;标错会让适配器按错格式处理) */
|
|
64
|
+
const mimeOfBuffer = (buffer) => {
|
|
65
|
+
if (buffer.length >= 8 && buffer[0] === 0x89 && buffer.toString('latin1', 1, 4) === 'PNG')
|
|
66
|
+
return 'image/png';
|
|
67
|
+
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8)
|
|
68
|
+
return 'image/jpeg';
|
|
69
|
+
if (buffer.length >= 6 && buffer.toString('latin1', 0, 3) === 'GIF')
|
|
70
|
+
return 'image/gif';
|
|
71
|
+
if (buffer.length >= 12 && buffer.toString('latin1', 0, 4) === 'RIFF' && buffer.toString('latin1', 8, 12) === 'WEBP')
|
|
72
|
+
return 'image/webp';
|
|
73
|
+
return 'image/jpeg';
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* 读出图片的**真实像素尺寸** —— md 里的 `#宽px #高px` 就靠它。
|
|
77
|
+
*
|
|
78
|
+
* 不引第三方库:卡片是 JPEG、图集是 JPEG、二维码是 PNG、实况图封面是 WebP,四种头都自己认,
|
|
79
|
+
* 免得又出现「取不到尺寸 → 正方形拉伸」或「没写尺寸 → 手机不显示」。
|
|
80
|
+
*/
|
|
81
|
+
const readImageSize = (buffer) => {
|
|
82
|
+
// PNG:IHDR 就在固定偏移
|
|
83
|
+
if (buffer.length >= 24 && buffer[0] === 0x89 && buffer.toString('latin1', 1, 4) === 'PNG') {
|
|
84
|
+
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
85
|
+
}
|
|
86
|
+
// GIF:逻辑屏幕描述符,小端
|
|
87
|
+
if (buffer.length >= 10 && buffer.toString('latin1', 0, 3) === 'GIF') {
|
|
88
|
+
return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
|
|
89
|
+
}
|
|
90
|
+
// JPEG:扫到 SOF 段(SOF0..SOF15,跳过 DHT/DAC 这些非 SOF 标记)
|
|
91
|
+
if (buffer.length > 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
92
|
+
let offset = 2;
|
|
93
|
+
while (offset + 9 < buffer.length) {
|
|
94
|
+
if (buffer[offset] !== 0xff) {
|
|
95
|
+
offset++;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const marker = buffer[offset + 1];
|
|
99
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
100
|
+
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
|
|
101
|
+
}
|
|
102
|
+
const length = buffer.readUInt16BE(offset + 2);
|
|
103
|
+
if (length <= 0)
|
|
104
|
+
break;
|
|
105
|
+
offset += 2 + length;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// WebP:VP8X(扩展)/ VP8L(无损)/ VP8(有损)三种头的写法都不一样
|
|
109
|
+
if (buffer.length >= 30 && buffer.toString('latin1', 0, 4) === 'RIFF' && buffer.toString('latin1', 8, 12) === 'WEBP') {
|
|
110
|
+
const format = buffer.toString('latin1', 12, 16);
|
|
111
|
+
if (format === 'VP8X') {
|
|
112
|
+
return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) };
|
|
113
|
+
}
|
|
114
|
+
if (format === 'VP8L') {
|
|
115
|
+
const bits = buffer.readUInt32LE(21);
|
|
116
|
+
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
|
|
117
|
+
}
|
|
118
|
+
if (format === 'VP8 ') {
|
|
119
|
+
return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { width: 0, height: 0 };
|
|
123
|
+
};
|
|
124
|
+
/** 把各种写法读成 Buffer:data URI / base64:// / file:// / 本地路径 / http(s) */
|
|
125
|
+
const loadImageBuffer = async (src) => {
|
|
126
|
+
try {
|
|
127
|
+
if (src.startsWith('base64://'))
|
|
128
|
+
return Buffer.from(src.slice('base64://'.length), 'base64');
|
|
129
|
+
if (src.startsWith('data:')) {
|
|
130
|
+
const comma = src.indexOf(',');
|
|
131
|
+
if (comma < 0)
|
|
132
|
+
return null;
|
|
133
|
+
return Buffer.from(src.slice(comma + 1), 'base64');
|
|
134
|
+
}
|
|
135
|
+
const localPath = src.startsWith('file://') ? (0, node_url_1.fileURLToPath)(src) : src;
|
|
136
|
+
if (!/^https?:\/\//i.test(src)) {
|
|
137
|
+
if (node_fs_1.default.existsSync(localPath))
|
|
138
|
+
return node_fs_1.default.readFileSync(localPath);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const response = await fetch(src);
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
logger_1.logger.mark('[图片md] 下载失败 HTTP ' + response.status + ': ' + src.slice(0, 80));
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return Buffer.from(await response.arrayBuffer());
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
logger_1.logger.mark('[图片md] 读取图片失败: ' + String(error?.message ?? error).slice(0, 120));
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* 上传到宿主的 assets,拿到 QQ 能直接取的 https 地址。
|
|
155
|
+
*
|
|
156
|
+
* md 里的图片地址**必须是公网 http(s)**:适配器只对 data: 图片做特殊处理
|
|
157
|
+
* (见 qq-crack 的 renderMarkdownDataImages —— data 图片会被它转回 MEDIA 上传,等于白改),
|
|
158
|
+
* 相对路径/本地路径手机端也取不到。
|
|
159
|
+
*/
|
|
160
|
+
let warnedNoAssets = false;
|
|
161
|
+
const uploadImage = async (buffer) => {
|
|
162
|
+
const assets = (0, runtime_1.tryGetRuntime)()?.ctx?.assets;
|
|
163
|
+
if (!assets?.upload) {
|
|
164
|
+
/** 没装 assets 服务就整条链路都用不了,提示一次就够,别每张图刷一遍日志 */
|
|
165
|
+
if (!warnedNoAssets) {
|
|
166
|
+
warnedNoAssets = true;
|
|
167
|
+
logger_1.logger.mark('[图片md] 宿主没有 assets 服务(拿不到公网图片地址),图片继续按普通图片段发送');
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const mime = mimeOfBuffer(buffer);
|
|
172
|
+
const ext = mime === 'image/png' ? '.png' : mime === 'image/gif' ? '.gif' : mime === 'image/webp' ? '.webp' : '.jpg';
|
|
173
|
+
try {
|
|
174
|
+
const uploaded = await assets.upload('data:' + mime + ';base64,' + buffer.toString('base64'), 'kkk-md' + ext);
|
|
175
|
+
const url = typeof uploaded === 'string' ? uploaded : uploaded?.url;
|
|
176
|
+
return url && /^https?:\/\//i.test(String(url)) ? String(url) : null;
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
logger_1.logger.mark('[图片md] 上传失败: ' + String(error?.message ?? error).slice(0, 120));
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* 同一张图反复发(推送里的封面、面板卡片…)不用反复上传:
|
|
185
|
+
* 按图片内容摘要缓存结果,顺带省掉重复的下载与解码。
|
|
186
|
+
*/
|
|
187
|
+
const imageCache = new Map();
|
|
188
|
+
const IMAGE_CACHE_MAX = 200;
|
|
189
|
+
/** 图片段 → md 图片字符串;拿不到尺寸/地址就返回 null(调用方保持原样,别把图弄丢) */
|
|
190
|
+
const markdownImageOf = async (src) => {
|
|
191
|
+
if (!src)
|
|
192
|
+
return null;
|
|
193
|
+
const key = node_crypto_1.default.createHash('md5').update(src).digest('hex');
|
|
194
|
+
const cached = imageCache.get(key);
|
|
195
|
+
if (cached)
|
|
196
|
+
return cached;
|
|
197
|
+
const buffer = await loadImageBuffer(src);
|
|
198
|
+
let result = null;
|
|
199
|
+
if (buffer && buffer.length) {
|
|
200
|
+
const { width, height } = readImageSize(buffer);
|
|
201
|
+
/**
|
|
202
|
+
* **读不出尺寸就不换成 markdown**(退回普通图片段)。
|
|
203
|
+
*
|
|
204
|
+
* 用户实测:「必须要 #px,不然手机不会显示」—— 不带尺寸的 md 图片在手机端干脆是空白,
|
|
205
|
+
* 比糊更糟。所以这里宁可发一张(会被压糊但看得见的)普通图片,
|
|
206
|
+
* 也不发一张手机上什么都不显示的 markdown 图片。
|
|
207
|
+
*/
|
|
208
|
+
if (width > 0 && height > 0) {
|
|
209
|
+
const url = await uploadImage(buffer);
|
|
210
|
+
if (url)
|
|
211
|
+
result = '';
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* **只缓存成功的结果**:网络 / 图床抖动导致的失败要是也缓存下来,
|
|
216
|
+
* 这张图之后就一直按普通图片发(糊),得等重启才能恢复。
|
|
217
|
+
*/
|
|
218
|
+
if (result) {
|
|
219
|
+
if (imageCache.size >= IMAGE_CACHE_MAX)
|
|
220
|
+
imageCache.delete(imageCache.keys().next().value);
|
|
221
|
+
imageCache.set(key, result);
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
logger_1.logger.debug('[图片md] 这张图这次没转成 markdown,按普通图片发送: ' + src.slice(0, 60));
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
};
|
|
228
|
+
exports.markdownImageOf = markdownImageOf;
|
|
229
|
+
/** 清空缓存(测试用) */
|
|
230
|
+
const clearMarkdownImageCache = () => { imageCache.clear(); };
|
|
231
|
+
exports.clearMarkdownImageCache = clearMarkdownImageCache;
|
|
232
|
+
/**
|
|
233
|
+
* 把一条消息里的图片段改写成 markdown 图片(其它段原样保留、顺序不变)。
|
|
234
|
+
*
|
|
235
|
+
* 连续的图片合成**一个** markdown 元素(换行分隔)—— 和官方 QQ 的渲染习惯一致:
|
|
236
|
+
* 连续图片紧贴显示,视觉上仍是一整张卡片,而不是一张一张孤立的图。
|
|
237
|
+
*
|
|
238
|
+
* @param elements 已归一化的消息元素数组
|
|
239
|
+
* @param platform 适配器平台名(kompat 层从 bot.platform / session.platform 取)
|
|
240
|
+
* @returns 改写后的数组;不该改或改不动时**原样返回**,绝不让消息发不出去
|
|
241
|
+
*/
|
|
242
|
+
const imagesToMarkdown = async (elements, platform) => {
|
|
243
|
+
const list = Array.isArray(elements) ? elements : [];
|
|
244
|
+
if (!list.length)
|
|
245
|
+
return list;
|
|
246
|
+
if (!canUseMarkdownImage(platform))
|
|
247
|
+
return list;
|
|
248
|
+
if (!list.some(isImageElement))
|
|
249
|
+
return list;
|
|
250
|
+
if (list.some((element) => ATTACHMENT_TYPES.has(String(element?.type ?? ''))))
|
|
251
|
+
return list;
|
|
252
|
+
const out = [];
|
|
253
|
+
/** 攒着的连续图片(遇到非图片段就冲出去) */
|
|
254
|
+
let run = [];
|
|
255
|
+
const flushRun = () => {
|
|
256
|
+
// 有图转不动(上传失败等)时 run 里会是空串占位,这里只冲有内容的
|
|
257
|
+
if (run.length)
|
|
258
|
+
out.push(segment_1.segment.markdown(run.join(String.fromCharCode(10))));
|
|
259
|
+
run = [];
|
|
260
|
+
};
|
|
261
|
+
for (const element of list) {
|
|
262
|
+
if (!isImageElement(element)) {
|
|
263
|
+
flushRun();
|
|
264
|
+
out.push(element);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const markdown = await (0, exports.markdownImageOf)(srcOf(element));
|
|
268
|
+
if (markdown) {
|
|
269
|
+
run.push(markdown);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
// 转不了就把它自己按图片段发出去,别丢
|
|
273
|
+
flushRun();
|
|
274
|
+
out.push(element);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
flushRun();
|
|
278
|
+
return out;
|
|
279
|
+
};
|
|
280
|
+
exports.imagesToMarkdown = imagesToMarkdown;
|
package/lib/compat/node-karin.js
CHANGED
|
@@ -34,6 +34,7 @@ const adapter_info_1 = require("./adapter-info");
|
|
|
34
34
|
const logger_1 = require("./logger");
|
|
35
35
|
Object.defineProperty(exports, "logger", { enumerable: true, get: function () { return logger_1.logger; } });
|
|
36
36
|
const forward_collect_1 = require("./forward-collect");
|
|
37
|
+
const imageMarkdown_1 = require("./imageMarkdown");
|
|
37
38
|
const sendError_1 = require("./sendError");
|
|
38
39
|
const runtime_1 = require("./runtime");
|
|
39
40
|
const segment_1 = require("./segment");
|
|
@@ -240,14 +241,16 @@ class KkkBot {
|
|
|
240
241
|
logger_1.logger.debug('当前适配器(' + this.bot.platform + ')不支持合并转发,改为直接发送内容');
|
|
241
242
|
}
|
|
242
243
|
// 退化路径:整条发一次;失败再逐个元素发,尽量把内容送出去
|
|
244
|
+
/** 退化发送 = 直接发消息,所以图片同样要改走 markdown(见 compat/imageMarkdown) */
|
|
245
|
+
const fallback = await (0, imageMarkdown_1.imagesToMarkdown)(payload.elements, platformOfBot(this.bot));
|
|
243
246
|
try {
|
|
244
|
-
const ids = await this.bot.sendMessage(channelId,
|
|
247
|
+
const ids = await this.bot.sendMessage(channelId, fallback);
|
|
245
248
|
return { messageId: ids[ids.length - 1] ?? '' };
|
|
246
249
|
}
|
|
247
250
|
catch (error) {
|
|
248
251
|
logger_1.logger.warn('整条发送失败,改为逐个元素发送: ' + String(error?.message ?? error));
|
|
249
252
|
let lastId = '';
|
|
250
|
-
for (const item of
|
|
253
|
+
for (const item of fallback) {
|
|
251
254
|
const ids = await this.bot.sendMessage(channelId, [item]);
|
|
252
255
|
lastId = ids[ids.length - 1] ?? lastId;
|
|
253
256
|
}
|
|
@@ -346,11 +349,14 @@ class KkkBot {
|
|
|
346
349
|
}
|
|
347
350
|
/** 发送消息(karin 的 bot.sendMsg) */
|
|
348
351
|
async sendMsg(contact, content, _options) {
|
|
352
|
+
const elements = normalizeContent(content);
|
|
349
353
|
/** 解析结果合并转发:正在收集就攒起来(见 compat/forward-collect) */
|
|
350
|
-
if ((0, forward_collect_1.collectForward)(peerOf(contact),
|
|
354
|
+
if ((0, forward_collect_1.collectForward)(peerOf(contact), elements))
|
|
351
355
|
return { messageId: forward_collect_1.COLLECTED_MESSAGE_ID };
|
|
352
356
|
const channelId = typeof contact === 'string' ? contact : contact.peer;
|
|
353
|
-
|
|
357
|
+
/** 图片统一改走 markdown(见 compat/imageMarkdown) */
|
|
358
|
+
const outgoing = await (0, imageMarkdown_1.imagesToMarkdown)(elements, platformOfBot(this.bot));
|
|
359
|
+
const ids = await this.bot.sendMessage(channelId, outgoing);
|
|
354
360
|
// 同 reply/uploadFile:**没拿到消息 ID 就是没发出去**,别当成功返回
|
|
355
361
|
const id = ids?.[ids.length - 1] ?? '';
|
|
356
362
|
if (!id) {
|
|
@@ -471,13 +477,21 @@ class Message {
|
|
|
471
477
|
* 这时自动改用**主动消息**(不带引用,直接往频道里发),失败才把原错误抛出去。
|
|
472
478
|
*/
|
|
473
479
|
async reply(content, _options) {
|
|
474
|
-
const
|
|
480
|
+
const raw = normalizeContent(content);
|
|
475
481
|
/**
|
|
476
482
|
* 解析结果合并转发:正在收集就攒起来,等解析结束发一条转发(见 compat/forward-collect)。
|
|
477
483
|
* 回一个假的消息 ID:调用方普遍只看「有没有拿到 ID」(例如 uploadFile 判断发送成没成功)。
|
|
478
484
|
*/
|
|
479
|
-
if ((0, forward_collect_1.collectForward)(this.contact?.peer ?? '',
|
|
485
|
+
if ((0, forward_collect_1.collectForward)(this.contact?.peer ?? '', raw))
|
|
480
486
|
return { messageId: forward_collect_1.COLLECTED_MESSAGE_ID };
|
|
487
|
+
/**
|
|
488
|
+
* **图片统一改走 markdown**(上传拿公网地址 + 写死 `#宽px #高px`),见 compat/imageMarkdown。
|
|
489
|
+
*
|
|
490
|
+
* 放在这里是因为它是**所有回复的唯一出口** —— 业务代码一律 `e.reply(...)`,
|
|
491
|
+
* 不用一处处改业务代码。转换不成功会退回原来的图片段(绝不把图弄丢)。
|
|
492
|
+
* 上面的合并转发分支已经返回了,所以转发节点里仍然是普通图片段。
|
|
493
|
+
*/
|
|
494
|
+
const elements = await (0, imageMarkdown_1.imagesToMarkdown)(raw, platformOfMessage(this));
|
|
481
495
|
/**
|
|
482
496
|
* 没拿到消息 ID 就是**没发出去**(对齐 qq-chat 的判法)。
|
|
483
497
|
*
|
|
@@ -570,6 +584,19 @@ function normalizeContent(content) {
|
|
|
570
584
|
function peerOf(contact) {
|
|
571
585
|
return typeof contact === 'string' ? contact : String(contact?.peer ?? '');
|
|
572
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* 适配器平台名。
|
|
589
|
+
*
|
|
590
|
+
* 发送出口要用它判断「这个平台认不认 markdown 图片」(见 compat/imageMarkdown):
|
|
591
|
+
* KkkBot 包装上直接转发真实 Bot 的 platform,传 KkkBot 或裸 Bot 都能取到。
|
|
592
|
+
*/
|
|
593
|
+
function platformOfBot(bot) {
|
|
594
|
+
return String(bot?.platform ?? bot?.bot?.platform ?? '');
|
|
595
|
+
}
|
|
596
|
+
/** 同上,从消息事件取(会话上就带 platform,拿不到再问 bot) */
|
|
597
|
+
function platformOfMessage(message) {
|
|
598
|
+
return String(message.session?.platform ?? platformOfBot(message.bot));
|
|
599
|
+
}
|
|
573
600
|
/**
|
|
574
601
|
* 合并转发的载荷。
|
|
575
602
|
*
|
|
@@ -707,18 +734,22 @@ async function sendMsg(selfId, contact, content, _options) {
|
|
|
707
734
|
+ '),这条消息发不出去。等它重新上线后下次推送会正常发出;如果是长期不在线,检查一下适配器连接');
|
|
708
735
|
}
|
|
709
736
|
const channelId = peerOf(contact);
|
|
737
|
+
const elements = normalizeContent(content);
|
|
710
738
|
/** 解析结果合并转发:这条也走漏斗(karin.sendMsg 是业务代码另一条常用出口) */
|
|
711
|
-
if ((0, forward_collect_1.collectForward)(channelId,
|
|
739
|
+
if ((0, forward_collect_1.collectForward)(channelId, elements)) {
|
|
712
740
|
return { messageId: forward_collect_1.COLLECTED_MESSAGE_ID, rawData: [] };
|
|
713
741
|
}
|
|
714
|
-
|
|
742
|
+
/** 图片统一改走 markdown(见 compat/imageMarkdown) */
|
|
743
|
+
const outgoing = await (0, imageMarkdown_1.imagesToMarkdown)(elements, platformOfBot(bot.bot));
|
|
744
|
+
const ids = await bot.bot.sendMessage(channelId, outgoing);
|
|
715
745
|
return { messageId: ids[ids.length - 1] ?? '', rawData: ids };
|
|
716
746
|
}
|
|
717
747
|
async function sendMaster(botId, master, content, _options) {
|
|
718
748
|
const bot = resolveBot(botId);
|
|
719
749
|
if (!bot)
|
|
720
750
|
throw new Error('[kkk] 没有可用的机器人实例,无法发送消息');
|
|
721
|
-
|
|
751
|
+
// 图片统一改走 markdown(见 compat/imageMarkdown):私聊和群里一样,md 图片才不会被压糊
|
|
752
|
+
const elements = await (0, imageMarkdown_1.imagesToMarkdown)(normalizeContent(content), platformOfBot(bot.bot));
|
|
722
753
|
const target = bot.bot;
|
|
723
754
|
// Satori 的 Bot 有 sendPrivateMessage;个别适配器没有,退化成私聊频道 id
|
|
724
755
|
const ids = typeof target.sendPrivateMessage === 'function'
|
|
@@ -34,6 +34,19 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.reloadAmagiConfig = exports.getAmagiClient = exports.xiaohongshuFetcher = exports.kuaishouFetcher = exports.douyinFetcher = exports.bilibiliFetcher = exports.registerAmagiReloadListener = exports.isSoftFailure = exports.softFetch = exports.SOFT_ERROR_CODES = exports.AmagiBase = exports.AmagiError = void 0;
|
|
37
|
+
/**
|
|
38
|
+
* 解析库(`@ikenxuan/amagi`)的版本要求:**必须 ≥ 7**(当前钉在 7.0.0-beta.5)。
|
|
39
|
+
*
|
|
40
|
+
* 线上故障(用户反馈「抖音解析改炸了,同一个 cookie 上游代码能正常解析」):
|
|
41
|
+
* 抖音现在要求 web 接口带上 `uifid` 与 `x-secsdk-web-signature`(Argus 风控 SDK 的签名),
|
|
42
|
+
* 而 npm 上的 **latest 6.6.0 完全没有这套东西**,于是每个抖音接口都被拦成
|
|
43
|
+
* `403 Blocked by ArgusSecurityPlugin Uifid Not Found`。amagi 把这个响应包成
|
|
44
|
+
* `{code:500,message:"抖音数据获取失败"}`,插件再翻译成一句「Cookie 失效 / 被风控」——
|
|
45
|
+
* 用户怎么换 cookie 都没用。
|
|
46
|
+
*
|
|
47
|
+
* secsdk / uifid 的实现只存在于 7.x(上游 karin-plugin-kkk 也是把这份新 amagi 打进包里的),
|
|
48
|
+
* 所以这里**故意钉在 beta 上**:latest 反而是坏的。升级解析库时请一并确认抖音能解析。
|
|
49
|
+
*/
|
|
37
50
|
const amagi_1 = __importStar(require("@ikenxuan/amagi"));
|
|
38
51
|
const node_karin_1 = require("../../../compat/node-karin.js");
|
|
39
52
|
const Config_1 = require("./Config");
|
|
@@ -133,16 +146,28 @@ const isFailureEnvelope = (value) => {
|
|
|
133
146
|
};
|
|
134
147
|
/** 判断是不是 thenable,用来只包装异步方法 */
|
|
135
148
|
const isThenable = (value) => !!value && (typeof value === 'object' || typeof value === 'function') && typeof value.then === 'function';
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
const throwOnFailure = (target) => {
|
|
150
|
+
/**
|
|
151
|
+
* **摊平成普通对象,而不是再套一层 Proxy。**
|
|
152
|
+
*
|
|
153
|
+
* amagi 自己的 fetcher 就是「带 `ownKeys` / `getOwnPropertyDescriptor` trap、方法按需物化」
|
|
154
|
+
* 的 Proxy。我们再包一层 Proxy 的话,外面只要做一次 `Object.getOwnPropertyDescriptor`
|
|
155
|
+
* (打桩时最常见的 `client.douyin.fetcher.parseWork = fn` 就会走到)就会进到里层 trap,
|
|
156
|
+
* 触发 V8 的 Proxy 不变量检查并抛:
|
|
157
|
+
*
|
|
158
|
+
* TypeError: 'getOwnPropertyDescriptor' on proxy: trap returned descriptor for property
|
|
159
|
+
* 'parseWork' that is incompatible with the existing property in the proxy target
|
|
160
|
+
*
|
|
161
|
+
* 结果是所有「用打桩跑一遍链路」的冒烟集体炸掉(2026-09-26 升级 amagi 7 时踩到)。
|
|
162
|
+
* 摊平成普通对象既没有这个坑,也顺手把每个方法的包装**缓存**了一次,调用少一层代理。
|
|
163
|
+
*/
|
|
164
|
+
const wrapped = {};
|
|
165
|
+
for (const key of Reflect.ownKeys(target)) {
|
|
166
|
+
const value = target[key];
|
|
143
167
|
if (typeof value === 'function') {
|
|
144
|
-
|
|
145
|
-
|
|
168
|
+
const fn = value.bind(target);
|
|
169
|
+
wrapped[key] = (...args) => {
|
|
170
|
+
const returned = fn(...args);
|
|
146
171
|
// 同步方法原样放行:包成 async 会把返回值套一层 Promise,破坏语义
|
|
147
172
|
if (!isThenable(returned))
|
|
148
173
|
return returned;
|
|
@@ -152,14 +177,14 @@ const throwOnFailure = (target) => new Proxy(target, {
|
|
|
152
177
|
return result;
|
|
153
178
|
});
|
|
154
179
|
};
|
|
180
|
+
continue;
|
|
155
181
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
return value;
|
|
182
|
+
wrapped[key] = value && typeof value === 'object' && !Array.isArray(value)
|
|
183
|
+
? throwOnFailure(value)
|
|
184
|
+
: value;
|
|
160
185
|
}
|
|
161
|
-
|
|
162
|
-
|
|
186
|
+
return wrapped;
|
|
187
|
+
};
|
|
163
188
|
class AmagiBase {
|
|
164
189
|
/**
|
|
165
190
|
* 原始 v7 客户端。
|
|
@@ -106,6 +106,16 @@ class DouYin extends utils_1.Base {
|
|
|
106
106
|
const code = raw.code ?? raw.data?.code;
|
|
107
107
|
const message = raw.message ?? raw.msg ?? raw.data?.message ?? raw.data?.msg;
|
|
108
108
|
node_karin_2.logger.error('[抖音] 接口没有返回作品详情: ' + JSON.stringify({ code, message, 顶层键: Object.keys(raw) }).slice(0, 300));
|
|
109
|
+
/**
|
|
110
|
+
* amagi 自己的 500 信封 = 抖音那边没给数据。最常见的成因已经**不是** cookie,
|
|
111
|
+
* 而是解析库版本太旧(新版抖音要 uifid + secsdk 签名,旧库会被 Argus 拦成空响应)。
|
|
112
|
+
* 日志里点出来,免得下一次又照着「换个 cookie」排查半天。
|
|
113
|
+
*/
|
|
114
|
+
if (Number(code) === 500 && /抖音数据获取失败/.test(String(message ?? ''))) {
|
|
115
|
+
node_karin_2.logger.mark('[抖音] 这多半是解析库版本过旧:新版抖音要求 uifid + x-secsdk-web-signature,'
|
|
116
|
+
+ '旧版会被 Argus 风控拦成 403(Blocked by ArgusSecurityPlugin Uifid Not Found)。'
|
|
117
|
+
+ '本插件依赖 @ikenxuan/amagi 需要 7.x(见 package.json)');
|
|
118
|
+
}
|
|
109
119
|
throw new Error('抖音没有返回这条作品的数据'
|
|
110
120
|
+ (code !== undefined ? '(错误码 ' + String(code) + (message ? ':' + String(message) : '') + ')' : '')
|
|
111
121
|
+ ',常见原因:Cookie 失效、被风控,或者这条链接已经失效 / 作品已删除。');
|