koishi-plugin-audiomeme 1.2.0 → 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 CHANGED
@@ -1,30 +1,39 @@
1
1
  # koishi-plugin-audiomeme
2
2
 
3
- Play meme sounds from a bundled JSON database.
3
+ 播放内置 JSON 数据库中的 meme 音效,并支持 ChatLuna 工具调用。
4
4
 
5
5
  ## Usage
6
6
 
7
7
  ```text
8
- memeaudio
9
- memeaudio <name>
10
- memeaudio.list [page]
11
- memeaudio.list [keyword]
12
- memeaudio.list [page] [keyword]
8
+ audiomeme
9
+ audiomeme <音效名>
10
+ audiomeme list [页码]
11
+ audiomeme list [关键词]
12
+ audiomeme list [页码] [关键词]
13
+ audiomeme random
13
14
  ```
14
15
 
15
- - `memeaudio` shows the first page of available sounds.
16
- - `memeaudio <name>` downloads and plays a sound.
17
- - `memeaudio.list` shows a paginated list without a wide table.
18
- - `memeaudio.list cat` searches by sound name.
16
+ - `audiomeme` 显示第一页可用音效。
17
+ - `audiomeme <音效名>` 下载并播放指定音效。
18
+ - `audiomeme list` 显示分页列表。
19
+ - `audiomeme list cat` 按音效名搜索。
20
+ - `audiomeme random` 随机播放一个音效。
21
+ - 兼容旧命令:`memeaudio`、`memeaudio.list`、`memeaudio.random`。
22
+
23
+ ## Audio Sending
24
+
25
+ 默认使用“远程链接”模式发送音效,适合 OneBot 等适配器,避免本地 `file://` 缓存路径在机器人端不可见导致 0 秒语音。
26
+
27
+ 如需让 Koishi 先下载音频再发送本地文件,可以在配置中把“音效发送模式”改为“缓存文件”。缓存模式会校验已下载文件,发现空文件、HTML 错误页或无效音频时会自动重新下载。
19
28
 
20
29
  ## ChatLuna Tool Calls
21
30
 
22
- This plugin can optionally integrate with `chatluna_character`.
31
+ 本插件可以选择接入 `chatluna_character`。
23
32
 
24
- - `enableAudioMemeXmlTool`: enables XML audio meme tool calls in ChatLuna replies.
25
- - `injectAudioMemeXmlToolAsReplyTool`: injects the same XML tool as an experimental reply tool field. When this is available, direct XML action execution is disabled to avoid double playback.
33
+ - `enableAudioMemeXmlTool`:启用 ChatLuna 回复中的 XML 音效工具调用。
34
+ - `injectAudioMemeXmlToolAsReplyTool`:将同一个 XML 音效工具注入实验性“工具调用回复”参数中;可用时会关闭直接 XML 动作执行,避免重复播放。
26
35
 
27
- XML examples:
36
+ XML 示例:
28
37
 
29
38
  ```xml
30
39
  <audiomeme name="bruh" />
@@ -32,13 +41,13 @@ XML examples:
32
41
  <audio-meme key="cat-laugh-meme-1" />
33
42
  ```
34
43
 
35
- Reply tool field name:
44
+ Reply tool 字段名:
36
45
 
37
46
  ```text
38
47
  audiomeme_play
39
48
  ```
40
49
 
41
- Reply tool payload example:
50
+ Reply tool 参数示例:
42
51
 
43
52
  ```json
44
53
  [{ "name": "bruh" }]
package/lib/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface Config {
9
9
  cacheMaxAge: number;
10
10
  pageSize: number;
11
11
  downloadTimeout: number;
12
+ sendMode: 'remote' | 'cache';
12
13
  enableAudioMemeXmlTool: boolean;
13
14
  injectAudioMemeXmlToolAsReplyTool: boolean;
14
15
  }
package/lib/index.js CHANGED
@@ -16,40 +16,108 @@ exports.inject = {
16
16
  optional: ['chatluna_character'],
17
17
  };
18
18
  exports.Config = koishi_1.Schema.object({
19
- cachePath: koishi_1.Schema.string().default('cache/audiomeme').description('Cache directory for audio files.'),
20
- cleanupInterval: koishi_1.Schema.number().default(10 * 60 * 1000).description('Cleanup interval in milliseconds (default 10 minutes).'),
21
- cacheMaxAge: koishi_1.Schema.number().default(60 * 60 * 1000).description('Maximum age for cached files in milliseconds (default 1 hour).'),
22
- pageSize: koishi_1.Schema.number().min(5).max(50).default(20).description('Number of meme sounds shown per list page.'),
23
- downloadTimeout: koishi_1.Schema.number().min(1000).default(30 * 1000).description('Download timeout in milliseconds.'),
24
- enableAudioMemeXmlTool: koishi_1.Schema.boolean().default(false).description('Enable XML audio meme tool calls from ChatLuna replies.'),
25
- injectAudioMemeXmlToolAsReplyTool: koishi_1.Schema.boolean().default(false).description('Inject the XML tool as an experimental ChatLuna reply tool field.'),
19
+ cachePath: koishi_1.Schema.string().default('cache/audiomeme').description('音频文件缓存目录。'),
20
+ cleanupInterval: koishi_1.Schema.number().default(10 * 60 * 1000).description('缓存清理间隔,单位为毫秒,默认 10 分钟。'),
21
+ cacheMaxAge: koishi_1.Schema.number().default(60 * 60 * 1000).description('缓存文件最长保留时间,单位为毫秒,默认 1 小时。'),
22
+ pageSize: koishi_1.Schema.number().min(5).max(50).default(20).description('列表每页显示的音效数量。'),
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('音效发送模式。'),
28
+ enableAudioMemeXmlTool: koishi_1.Schema.boolean().default(false).description('是否启用 ChatLuna 回复中的 XML 音效工具调用。'),
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;
30
35
  const normalizedKeyword = keyword.toLowerCase();
31
36
  return sounds.filter(sound => sound.name.toLowerCase().includes(normalizedKeyword));
32
37
  }
38
+ function parseListArgs(input) {
39
+ const normalizedInput = (input || '').trim();
40
+ if (!normalizedInput)
41
+ return { page: 1, keyword: undefined };
42
+ const [first, ...rest] = normalizedInput.split(/\s+/);
43
+ const page = Number(first);
44
+ if (Number.isInteger(page) && page > 0) {
45
+ return {
46
+ page,
47
+ keyword: rest.join(' ') || undefined,
48
+ };
49
+ }
50
+ return {
51
+ page: 1,
52
+ keyword: normalizedInput,
53
+ };
54
+ }
55
+ function pickRandomSound(sounds) {
56
+ return sounds[Math.floor(Math.random() * sounds.length)];
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
+ }
33
101
  function formatSoundList(sounds, page, pageSize, keyword) {
34
102
  const totalPages = Math.max(1, Math.ceil(sounds.length / pageSize));
35
103
  const currentPage = Math.min(Math.max(page, 1), totalPages);
36
104
  const start = (currentPage - 1) * pageSize;
37
105
  const pageSounds = sounds.slice(start, start + pageSize);
38
106
  const title = keyword
39
- ? `Meme sounds matching "${keyword}" (${sounds.length})`
40
- : `Meme sounds (${sounds.length})`;
107
+ ? `匹配 "${keyword}" 的音效 (${sounds.length})`
108
+ : `可用音效 (${sounds.length})`;
41
109
  if (!sounds.length) {
42
- return `No meme sounds found for "${keyword}".`;
110
+ return `没有找到匹配 "${keyword}" 的音效。`;
43
111
  }
44
112
  return [
45
- `${title} - page ${currentPage}/${totalPages}`,
113
+ `${title} - ${currentPage}/${totalPages} 页`,
46
114
  ...pageSounds.map((sound, index) => `${String(start + index + 1).padStart(3, ' ')}. ${sound.name}`),
47
115
  '',
48
- `Play: memeaudio <name>`,
49
- `Search: memeaudio.list <keyword>`,
116
+ `播放:audiomeme <音效名>`,
117
+ `搜索:audiomeme list <关键词>`,
50
118
  currentPage < totalPages
51
- ? `Next: memeaudio.list ${currentPage + 1}${keyword ? ` ${keyword}` : ''}`
52
- : 'End of results.',
119
+ ? `下一页:audiomeme list ${currentPage + 1}${keyword ? ` ${keyword}` : ''}`
120
+ : '已经是最后一页。',
53
121
  ].join('\n');
54
122
  }
55
123
  function apply(ctx, config) {
@@ -64,40 +132,74 @@ function apply(ctx, config) {
64
132
  if (!sound) {
65
133
  const suggestions = matchSounds(sounds, name).slice(0, 5).map(s => s.name);
66
134
  return suggestions.length
67
- ? [`Meme sound not found: ${name}`, 'Maybe you meant:', ...suggestions.map(s => `- ${s}`)].join('\n')
68
- : `Meme sound not found: ${name}`;
135
+ ? [`未找到音效:${name}`, '你可能想找:', ...suggestions.map(s => `- ${s}`)].join('\n')
136
+ : `未找到音效:${name}`;
137
+ }
138
+ if (config.sendMode === 'remote') {
139
+ return koishi_1.h.audio(sound.url);
69
140
  }
70
141
  const fileName = `${encodeURIComponent(sound.name)}.mp3`;
71
142
  const filePath = path_1.default.join(cacheDir, fileName);
143
+ const tempFilePath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
72
144
  try {
73
- if (!await fs_extra_1.default.pathExists(filePath)) {
145
+ if (!await validateAudioFile(filePath)) {
146
+ await fs_extra_1.default.unlink(filePath).catch(() => { });
74
147
  const response = await axios_1.default.get(sound.url, {
75
148
  responseType: 'arraybuffer',
76
149
  timeout: config.downloadTimeout,
77
150
  });
78
- await fs_extra_1.default.writeFile(filePath, response.data);
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 });
79
159
  }
80
160
  lastAccess.set(fileName, Date.now());
81
161
  return koishi_1.h.audio((0, url_1.pathToFileURL)(filePath).href);
82
162
  }
83
163
  catch (error) {
164
+ await fs_extra_1.default.unlink(tempFilePath).catch(() => { });
84
165
  logger.error(error);
85
- return 'Failed to download or play meme sound.';
166
+ return '下载或播放音效失败。';
86
167
  }
87
168
  };
88
- ctx.command('memeaudio <name:string>', 'Play a meme sound')
89
- .action(async ({ session }, name) => {
90
- if (!name)
91
- return formatSoundList(sounds, 1, config.pageSize);
92
- return playSound(name);
169
+ const playRandomSound = async () => {
170
+ const sound = pickRandomSound(sounds);
171
+ if (!sound)
172
+ return '当前没有可用音效。';
173
+ return playSound(sound.name);
174
+ };
175
+ const listSounds = (input) => {
176
+ const { page, keyword } = parseListArgs(input);
177
+ const matchedSounds = matchSounds(sounds, keyword);
178
+ return formatSoundList(matchedSounds, page, config.pageSize, keyword);
179
+ };
180
+ ctx.command('audiomeme [action:text]', '播放或查看音效 meme')
181
+ .alias('memeaudio')
182
+ .action(async ({ session }, action) => {
183
+ const normalizedAction = (action || '').trim();
184
+ if (!normalizedAction)
185
+ return listSounds();
186
+ const [command, ...rest] = normalizedAction.split(/\s+/);
187
+ const commandArgs = rest.join(' ');
188
+ if (command === 'list')
189
+ return listSounds(commandArgs);
190
+ if (command === 'random')
191
+ return playRandomSound();
192
+ return playSound(normalizedAction);
193
+ });
194
+ ctx.command('audiomeme.list [query:text]', '查看音效 meme 列表')
195
+ .alias('memeaudio.list')
196
+ .action(({ session }, query) => {
197
+ return listSounds(query);
93
198
  });
94
- ctx.command('memeaudio.list [pageOrKeyword:string] [keyword:text]', 'List meme sounds')
95
- .action(({ session }, pageOrKeyword, keyword) => {
96
- const page = Number(pageOrKeyword);
97
- const hasPage = Number.isInteger(page) && page > 0;
98
- const searchKeyword = hasPage ? keyword : [pageOrKeyword, keyword].filter(Boolean).join(' ');
99
- const matchedSounds = matchSounds(sounds, searchKeyword);
100
- return formatSoundList(matchedSounds, hasPage ? page : 1, config.pageSize, searchKeyword);
199
+ ctx.command('audiomeme.random', '随机播放音效 meme')
200
+ .alias('memeaudio.random')
201
+ .action(async () => {
202
+ return playRandomSound();
101
203
  });
102
204
  (0, chatluna_1.installChatlunaAudioMemeTools)({
103
205
  ctx,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-audiomeme",
3
3
  "description": "Play meme sounds from a JSON database with cache cleanup.",
4
- "version": "1.2.0",
4
+ "version": "1.2.2",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [