koishi-plugin-audiomeme 1.2.1 → 1.2.2
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 +6 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +63 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,12 @@ audiomeme random
|
|
|
20
20
|
- `audiomeme random` 随机播放一个音效。
|
|
21
21
|
- 兼容旧命令:`memeaudio`、`memeaudio.list`、`memeaudio.random`。
|
|
22
22
|
|
|
23
|
+
## Audio Sending
|
|
24
|
+
|
|
25
|
+
默认使用“远程链接”模式发送音效,适合 OneBot 等适配器,避免本地 `file://` 缓存路径在机器人端不可见导致 0 秒语音。
|
|
26
|
+
|
|
27
|
+
如需让 Koishi 先下载音频再发送本地文件,可以在配置中把“音效发送模式”改为“缓存文件”。缓存模式会校验已下载文件,发现空文件、HTML 错误页或无效音频时会自动重新下载。
|
|
28
|
+
|
|
23
29
|
## ChatLuna Tool Calls
|
|
24
30
|
|
|
25
31
|
本插件可以选择接入 `chatluna_character`。
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -21,9 +21,14 @@ exports.Config = koishi_1.Schema.object({
|
|
|
21
21
|
cacheMaxAge: koishi_1.Schema.number().default(60 * 60 * 1000).description('缓存文件最长保留时间,单位为毫秒,默认 1 小时。'),
|
|
22
22
|
pageSize: koishi_1.Schema.number().min(5).max(50).default(20).description('列表每页显示的音效数量。'),
|
|
23
23
|
downloadTimeout: koishi_1.Schema.number().min(1000).default(30 * 1000).description('音频下载超时时间,单位为毫秒。'),
|
|
24
|
+
sendMode: koishi_1.Schema.union([
|
|
25
|
+
koishi_1.Schema.const('remote').description('远程链接:直接把音频 URL 交给平台发送,推荐 OneBot 使用。'),
|
|
26
|
+
koishi_1.Schema.const('cache').description('缓存文件:下载到 Koishi 缓存目录后以本地文件发送。'),
|
|
27
|
+
]).role('radio').default('remote').description('音效发送模式。'),
|
|
24
28
|
enableAudioMemeXmlTool: koishi_1.Schema.boolean().default(false).description('是否启用 ChatLuna 回复中的 XML 音效工具调用。'),
|
|
25
29
|
injectAudioMemeXmlToolAsReplyTool: koishi_1.Schema.boolean().default(false).description('是否将 XML 音效工具注入实验性“工具调用回复”参数中。'),
|
|
26
30
|
});
|
|
31
|
+
const MIN_AUDIO_FILE_SIZE = 1024;
|
|
27
32
|
function matchSounds(sounds, keyword) {
|
|
28
33
|
if (!keyword)
|
|
29
34
|
return sounds;
|
|
@@ -50,6 +55,49 @@ function parseListArgs(input) {
|
|
|
50
55
|
function pickRandomSound(sounds) {
|
|
51
56
|
return sounds[Math.floor(Math.random() * sounds.length)];
|
|
52
57
|
}
|
|
58
|
+
function hasAudioMagic(buffer) {
|
|
59
|
+
if (buffer.length < 4)
|
|
60
|
+
return false;
|
|
61
|
+
if (buffer.subarray(0, 3).toString('ascii') === 'ID3')
|
|
62
|
+
return true;
|
|
63
|
+
if (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0)
|
|
64
|
+
return true;
|
|
65
|
+
if (buffer.subarray(0, 4).toString('ascii') === 'OggS')
|
|
66
|
+
return true;
|
|
67
|
+
if (buffer.subarray(0, 4).toString('ascii') === 'RIFF')
|
|
68
|
+
return true;
|
|
69
|
+
if (buffer.length >= 12 && buffer.subarray(4, 8).toString('ascii') === 'ftyp')
|
|
70
|
+
return true;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
function isLikelyTextResponse(buffer) {
|
|
74
|
+
const head = buffer.subarray(0, Math.min(buffer.length, 64)).toString('utf8').trimStart().toLowerCase();
|
|
75
|
+
return head.startsWith('<!doctype') || head.startsWith('<html') || head.startsWith('{') || head.startsWith('[');
|
|
76
|
+
}
|
|
77
|
+
async function validateAudioFile(filePath) {
|
|
78
|
+
try {
|
|
79
|
+
const stats = await fs_extra_1.default.stat(filePath);
|
|
80
|
+
if (!stats.isFile() || stats.size < MIN_AUDIO_FILE_SIZE)
|
|
81
|
+
return false;
|
|
82
|
+
const buffer = await fs_extra_1.default.readFile(filePath);
|
|
83
|
+
const head = buffer.subarray(0, 16);
|
|
84
|
+
return hasAudioMagic(head) && !isLikelyTextResponse(head);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function assertAudioResponse(contentType, data) {
|
|
91
|
+
if (data.length < MIN_AUDIO_FILE_SIZE) {
|
|
92
|
+
throw new Error(`downloaded audio is too small: ${data.length} bytes`);
|
|
93
|
+
}
|
|
94
|
+
if (contentType && !/audio|mpeg|octet-stream/i.test(contentType)) {
|
|
95
|
+
throw new Error(`unexpected content type: ${contentType}`);
|
|
96
|
+
}
|
|
97
|
+
if (!hasAudioMagic(data.subarray(0, 16)) || isLikelyTextResponse(data)) {
|
|
98
|
+
throw new Error('downloaded file does not look like an audio file');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
53
101
|
function formatSoundList(sounds, page, pageSize, keyword) {
|
|
54
102
|
const totalPages = Math.max(1, Math.ceil(sounds.length / pageSize));
|
|
55
103
|
const currentPage = Math.min(Math.max(page, 1), totalPages);
|
|
@@ -87,20 +135,33 @@ function apply(ctx, config) {
|
|
|
87
135
|
? [`未找到音效:${name}`, '你可能想找:', ...suggestions.map(s => `- ${s}`)].join('\n')
|
|
88
136
|
: `未找到音效:${name}`;
|
|
89
137
|
}
|
|
138
|
+
if (config.sendMode === 'remote') {
|
|
139
|
+
return koishi_1.h.audio(sound.url);
|
|
140
|
+
}
|
|
90
141
|
const fileName = `${encodeURIComponent(sound.name)}.mp3`;
|
|
91
142
|
const filePath = path_1.default.join(cacheDir, fileName);
|
|
143
|
+
const tempFilePath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
92
144
|
try {
|
|
93
|
-
if (!await
|
|
145
|
+
if (!await validateAudioFile(filePath)) {
|
|
146
|
+
await fs_extra_1.default.unlink(filePath).catch(() => { });
|
|
94
147
|
const response = await axios_1.default.get(sound.url, {
|
|
95
148
|
responseType: 'arraybuffer',
|
|
96
149
|
timeout: config.downloadTimeout,
|
|
97
150
|
});
|
|
98
|
-
|
|
151
|
+
const data = Buffer.from(response.data);
|
|
152
|
+
const contentType = response.headers['content-type'];
|
|
153
|
+
assertAudioResponse(typeof contentType === 'string' ? contentType : undefined, data);
|
|
154
|
+
await fs_extra_1.default.writeFile(tempFilePath, data);
|
|
155
|
+
if (!await validateAudioFile(tempFilePath)) {
|
|
156
|
+
throw new Error('downloaded audio failed validation');
|
|
157
|
+
}
|
|
158
|
+
await fs_extra_1.default.move(tempFilePath, filePath, { overwrite: true });
|
|
99
159
|
}
|
|
100
160
|
lastAccess.set(fileName, Date.now());
|
|
101
161
|
return koishi_1.h.audio((0, url_1.pathToFileURL)(filePath).href);
|
|
102
162
|
}
|
|
103
163
|
catch (error) {
|
|
164
|
+
await fs_extra_1.default.unlink(tempFilePath).catch(() => { });
|
|
104
165
|
logger.error(error);
|
|
105
166
|
return '下载或播放音效失败。';
|
|
106
167
|
}
|