koishi-plugin-music-to-voice 1.0.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/src/index.ts ADDED
@@ -0,0 +1,516 @@
1
+ import { Context, Schema, Logger, h } from 'koishi'
2
+ import axios from 'axios'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import os from 'node:os'
6
+ import crypto from 'node:crypto'
7
+
8
+ export const name = 'music-voice-pro'
9
+ const logger = new Logger(name)
10
+
11
+ type MusicSource = 'netease' | 'tencent' | 'kugou' | 'kuwo' | 'migu' | 'baidu'
12
+
13
+ export interface Config {
14
+ // 命令
15
+ commandName: string
16
+ commandAlias: string
17
+
18
+ // API
19
+ apiBase: string
20
+ source: MusicSource
21
+ searchListCount: number
22
+
23
+ // 交互
24
+ waitForTimeout: number
25
+ nextPageCommand: string
26
+ prevPageCommand: string
27
+ exitCommandList: string[]
28
+ menuExitCommandTip: boolean
29
+
30
+ // 图片歌单(预留)
31
+ imageMode: boolean
32
+
33
+ // 发送类型
34
+ sendAs: 'record' | 'audio'
35
+
36
+ // 转码与缓存
37
+ forceTranscode: boolean
38
+ tempDir: string
39
+ cacheMinutes: number
40
+
41
+ // 提示与撤回
42
+ generationTip: string
43
+ recallSearchMenuMessage: boolean
44
+ recallTipMessage: boolean
45
+ recallUserSelectMessage: boolean
46
+ recallVoiceMessage: boolean
47
+
48
+ // 调试
49
+ loggerinfo: boolean
50
+ }
51
+
52
+ export const Config: Schema<Config> = Schema.object({
53
+ commandName: Schema.string().default('听歌').description('指令名称'),
54
+ commandAlias: Schema.string().default('music').description('指令别名'),
55
+
56
+ apiBase: Schema.string().default('https://music-api.gdstudio.xyz/api.php').description('音乐 API 地址(GD音乐台 API)'),
57
+ source: Schema.union([
58
+ Schema.const('netease').description('网易云'),
59
+ Schema.const('tencent').description('QQ音乐'),
60
+ Schema.const('kugou').description('酷狗'),
61
+ Schema.const('kuwo').description('酷我'),
62
+ Schema.const('migu').description('咪咕'),
63
+ Schema.const('baidu').description('百度'),
64
+ ]).default('netease').description('音源(下拉选择)'),
65
+ searchListCount: Schema.number().min(5).max(50).default(20).description('搜索列表数量'),
66
+
67
+ waitForTimeout: Schema.number().min(10).max(180).default(45).description('等待输入序号超时(秒)'),
68
+ nextPageCommand: Schema.string().default('下一页').description('下一页指令'),
69
+ prevPageCommand: Schema.string().default('上一页').description('上一页指令'),
70
+ exitCommandList: Schema.array(String).default(['0', '不听了', '退出']).description('退出指令列表'),
71
+ menuExitCommandTip: Schema.boolean().default(false).description('是否在歌单末尾提示退出指令'),
72
+
73
+ imageMode: Schema.boolean().default(false).description('图片歌单模式(可选:需要 puppeteer 插件,当前仅保留开关)'),
74
+
75
+ sendAs: Schema.union([
76
+ Schema.const('record').description('语音 record'),
77
+ Schema.const('audio').description('音频 audio'),
78
+ ]).default('record').description('发送类型'),
79
+
80
+ forceTranscode: Schema.boolean().default(true).description('是否强制转码为 silk(需要 ffmpeg + silk 插件)'),
81
+ tempDir: Schema.string().default(path.join(os.tmpdir(), 'koishi-music-voice')).description('临时目录'),
82
+ cacheMinutes: Schema.number().min(0).max(1440).default(120).description('缓存时长(分钟,0=不缓存)'),
83
+
84
+ generationTip: Schema.string().default('生成语音中...').description('用户选歌后提示'),
85
+
86
+ recallSearchMenuMessage: Schema.boolean().default(true).description('撤回:歌单消息'),
87
+ recallTipMessage: Schema.boolean().default(true).description('撤回:生成提示消息'),
88
+ recallUserSelectMessage: Schema.boolean().default(true).description('撤回:用户输入的序号消息'),
89
+ recallVoiceMessage: Schema.boolean().default(false).description('撤回:语音消息'),
90
+
91
+ loggerinfo: Schema.boolean().default(false).description('日志调试模式'),
92
+ }).description('点歌语音(支持翻页 + 可选 silk/ffmpeg)')
93
+
94
+ type SearchItem = {
95
+ id: string
96
+ name: string
97
+ artist?: string
98
+ album?: string
99
+ }
100
+
101
+ type PendingState = {
102
+ userId: string
103
+ channelId: string
104
+ guildId?: string
105
+
106
+ keyword: string
107
+ page: number
108
+ list: SearchItem[]
109
+ expiresAt: number
110
+
111
+ menuMessageId?: string
112
+ tipMessageId?: string
113
+ voiceMessageId?: string
114
+ }
115
+
116
+ function safeText(x: unknown): string {
117
+ return typeof x === 'string' ? x : x == null ? '' : String(x)
118
+ }
119
+
120
+ function normalizeSearchList(data: any): SearchItem[] {
121
+ const arr: any[] =
122
+ Array.isArray(data) ? data
123
+ : Array.isArray(data?.result) ? data.result
124
+ : Array.isArray(data?.data) ? data.data
125
+ : Array.isArray(data?.songs) ? data.songs
126
+ : []
127
+
128
+ return arr.map((it: any) => {
129
+ const id = safeText(it?.id ?? it?.songid ?? it?.rid ?? it?.hash ?? it?.mid)
130
+ const name = safeText(it?.name ?? it?.songname ?? it?.title)
131
+ const artist =
132
+ safeText(it?.artist) ||
133
+ safeText(it?.singer) ||
134
+ safeText(it?.author) ||
135
+ (Array.isArray(it?.artists) ? it.artists.map((a: any) => safeText(a?.name)).filter(Boolean).join('/') : '')
136
+
137
+ const album = safeText(it?.album ?? it?.albummid ?? it?.albumname)
138
+ return { id, name, artist, album }
139
+ }).filter((x: SearchItem) => x.id && x.name)
140
+ }
141
+
142
+ function normalizeUrl(data: any): string {
143
+ return (
144
+ safeText(data?.url) ||
145
+ safeText(data?.data?.url) ||
146
+ safeText(data?.result?.url) ||
147
+ safeText(data?.data) ||
148
+ ''
149
+ )
150
+ }
151
+
152
+ function md5(s: string) {
153
+ return crypto.createHash('md5').update(s).digest('hex')
154
+ }
155
+
156
+ function ensureDir(p: string) {
157
+ fs.mkdirSync(p, { recursive: true })
158
+ }
159
+
160
+ async function tryRecall(session: any, messageId?: string) {
161
+ if (!messageId) return
162
+ try {
163
+ await session.bot.deleteMessage(session.channelId, messageId)
164
+ } catch {
165
+ // ignore
166
+ }
167
+ }
168
+
169
+ function hRecord(src: string) {
170
+ return h('record', { src })
171
+ }
172
+
173
+ function hAudio(src: string) {
174
+ return h('audio', { src })
175
+ }
176
+
177
+ function buildMenuText(config: Config, keyword: string, list: SearchItem[], page: number) {
178
+ const lines: string[] = []
179
+ lines.push(`NetEase Music:`)
180
+ lines.push(`关键词:${keyword}`)
181
+ lines.push(`音源:${config.source} 第 ${page} 页`)
182
+ lines.push('')
183
+ for (let i = 0; i < list.length; i++) {
184
+ const it = list[i]
185
+ const meta = [it.artist, it.album].filter(Boolean).join(' - ')
186
+ lines.push(`${i + 1}. ${it.name}${meta ? ` -- ${meta}` : ''}`)
187
+ }
188
+ lines.push('')
189
+ lines.push(`请在 ${config.waitForTimeout} 秒内输入序号(1-${list.length})`)
190
+ lines.push(`翻页:${config.prevPageCommand} / ${config.nextPageCommand}`)
191
+ if (config.menuExitCommandTip) {
192
+ lines.push(`退出:${config.exitCommandList.join(' / ')}`)
193
+ }
194
+ lines.push('')
195
+ lines.push('数据来源:GD音乐台 API')
196
+ return lines.join('\n')
197
+ }
198
+
199
+ async function apiSearch(config: Config, keyword: string, page = 1): Promise<SearchItem[]> {
200
+ const params = {
201
+ types: 'search',
202
+ source: config.source,
203
+ name: keyword,
204
+ count: config.searchListCount,
205
+ pages: page,
206
+ }
207
+ const { data } = await axios.get(config.apiBase, { params, timeout: 15000 })
208
+ return normalizeSearchList(data)
209
+ }
210
+
211
+ async function apiGetSongUrl(config: Config, id: string): Promise<string> {
212
+ const params = { types: 'url', source: config.source, id }
213
+ const { data } = await axios.get(config.apiBase, { params, timeout: 15000 })
214
+ return normalizeUrl(data)
215
+ }
216
+
217
+ async function downloadToFile(url: string, filePath: string) {
218
+ const res = await axios.get(url, { responseType: 'stream', timeout: 30000 })
219
+ await new Promise<void>((resolve, reject) => {
220
+ const ws = fs.createWriteStream(filePath)
221
+ res.data.pipe(ws)
222
+ ws.on('finish', () => resolve())
223
+ ws.on('error', reject)
224
+ })
225
+ }
226
+
227
+ async function sendVoiceByUrl(session: any, config: Config, audioUrl: string) {
228
+ const seg = config.sendAs === 'record' ? hRecord(audioUrl) : hAudio(audioUrl)
229
+ const ids = await session.send(seg)
230
+ return Array.isArray(ids) ? ids[0] : ids
231
+ }
232
+
233
+ async function sendVoiceByFile(session: any, config: Config, absPath: string) {
234
+ const url = `file://${absPath.replace(/\\/g, '/')}`
235
+ const seg = config.sendAs === 'record' ? hRecord(url) : hAudio(url)
236
+ const ids = await session.send(seg)
237
+ return Array.isArray(ids) ? ids[0] : ids
238
+ }
239
+
240
+ /**
241
+ * 可选:使用 Koishi 市场的 ffmpeg/silk 服务
242
+ * 返回 silk 文件绝对路径;返回 null 表示无法转码(可降级直链)
243
+ */
244
+ async function buildSilkIfPossible(ctx: Context, config: Config, audioUrl: string, cacheKey: string): Promise<string | null> {
245
+ const hasFfmpeg = !!(ctx as any).ffmpeg
246
+ const hasSilk = !!(ctx as any).silk
247
+ if (!hasFfmpeg || !hasSilk) return null
248
+
249
+ ensureDir(config.tempDir)
250
+
251
+ const silkPath = path.join(config.tempDir, `${cacheKey}.silk`)
252
+ if (config.cacheMinutes > 0 && fs.existsSync(silkPath)) {
253
+ return silkPath
254
+ }
255
+
256
+ const rawPath = path.join(config.tempDir, `${cacheKey}.src`)
257
+ await downloadToFile(audioUrl, rawPath)
258
+
259
+ const wavPath = path.join(config.tempDir, `${cacheKey}.wav`)
260
+ const ffmpeg: any = (ctx as any).ffmpeg
261
+
262
+ try {
263
+ if (typeof ffmpeg.convert === 'function') {
264
+ await ffmpeg.convert(rawPath, wavPath, {
265
+ format: 'wav',
266
+ audioChannels: 1,
267
+ audioFrequency: 24000,
268
+ })
269
+ } else if (typeof ffmpeg.exec === 'function') {
270
+ await ffmpeg.exec(['-y', '-i', rawPath, '-ac', '1', '-ar', '24000', wavPath])
271
+ } else {
272
+ throw new Error('ffmpeg service API not recognized')
273
+ }
274
+ } catch (e: any) {
275
+ throw new Error(`ffmpeg 转码失败:${e?.message || String(e)}`)
276
+ }
277
+
278
+ const silk: any = (ctx as any).silk
279
+ try {
280
+ if (typeof silk.encode === 'function') {
281
+ await silk.encode(wavPath, silkPath, { rate: 24000 })
282
+ } else if (typeof silk.encodeWav === 'function') {
283
+ await silk.encodeWav(wavPath, silkPath, { rate: 24000 })
284
+ } else {
285
+ throw new Error('silk service API not recognized')
286
+ }
287
+ } catch (e: any) {
288
+ throw new Error(`silk 编码失败:${e?.message || String(e)}`)
289
+ } finally {
290
+ try { fs.unlinkSync(rawPath) } catch {}
291
+ try { fs.unlinkSync(wavPath) } catch {}
292
+ }
293
+
294
+ return silkPath
295
+ }
296
+
297
+ function logDepsHint(ctx: Context) {
298
+ const hasPuppeteer = !!(ctx as any).puppeteer
299
+ const hasFfmpeg = !!(ctx as any).ffmpeg
300
+ const hasSilk = !!(ctx as any).silk
301
+ const hasDownloads = !!(ctx as any).downloads
302
+
303
+ logger.info('开启插件前,请确保以下服务已经启用(可选安装):')
304
+ logger.info(`- puppeteer服务(可选安装):${hasPuppeteer ? '已检测到' : '未检测到'}`)
305
+ logger.info('此外可能还需要这些服务才能发送语音:')
306
+ logger.info(`- ffmpeg服务(可选安装)(此服务可能额外依赖downloads服务):${hasFfmpeg ? '已检测到' : '未检测到'}`)
307
+ logger.info(`- silk服务(可选安装):${hasSilk ? '已检测到' : '未检测到'}`)
308
+ logger.info(`- downloads服务(可选安装):${hasDownloads ? '已检测到' : '未检测到'}`)
309
+ logger.info('Music API 出处:GD音乐台 API(https://music-api.gdstudio.xyz/api.php)')
310
+ }
311
+
312
+ export function apply(ctx: Context, config: Config) {
313
+ if (config.loggerinfo) logger.level = Logger.DEBUG
314
+ logDepsHint(ctx)
315
+
316
+ const pending = new Map<string, PendingState>() // channelId -> state
317
+
318
+ function getKey(session: any) {
319
+ return String(session?.channelId || '')
320
+ }
321
+
322
+ function isExit(input: string) {
323
+ const t = input.trim()
324
+ return config.exitCommandList.map(s => s.trim()).includes(t)
325
+ }
326
+
327
+ function isExpired(state: PendingState) {
328
+ return Date.now() > state.expiresAt
329
+ }
330
+
331
+ async function refreshMenu(session: any, state: PendingState) {
332
+ const list = await apiSearch(config, state.keyword, state.page)
333
+ state.list = list
334
+ state.expiresAt = Date.now() + config.waitForTimeout * 1000
335
+
336
+ if (config.recallSearchMenuMessage) {
337
+ await tryRecall(session, state.menuMessageId)
338
+ state.menuMessageId = undefined
339
+ }
340
+
341
+ if (!config.recallSearchMenuMessage) {
342
+ const text = buildMenuText(config, state.keyword, list, state.page)
343
+ const ids = await session.send(text)
344
+ state.menuMessageId = Array.isArray(ids) ? ids[0] : ids
345
+ } else {
346
+ await session.send(`已翻到第 ${state.page} 页,请直接发送序号(1-${list.length})`)
347
+ }
348
+ }
349
+
350
+ async function handlePick(session: any, state: PendingState, pickIndex: number) {
351
+ const item = state.list[pickIndex]
352
+ if (!item) {
353
+ await session.send(`序号无效,请输入 1-${state.list.length},或输入 ${config.exitCommandList.join('/')} 退出。`)
354
+ return
355
+ }
356
+
357
+ // 提示:生成中...
358
+ let tipId: string | undefined
359
+ if (!config.recallTipMessage && config.generationTip?.trim()) {
360
+ const ids = await session.send(config.generationTip)
361
+ tipId = Array.isArray(ids) ? ids[0] : ids
362
+ }
363
+
364
+ // 取直链
365
+ let songUrl = ''
366
+ try {
367
+ songUrl = await apiGetSongUrl(config, item.id)
368
+ if (!songUrl) throw new Error('empty url')
369
+ } catch {
370
+ await session.send('获取歌曲直链失败,请稍后再试,或更换音源。')
371
+ return
372
+ }
373
+
374
+ const cacheKey = md5(`${config.source}:${item.id}`)
375
+ let voiceId: string | undefined
376
+
377
+ try {
378
+ const silkPath = await buildSilkIfPossible(ctx, config, songUrl, cacheKey)
379
+ if (silkPath) {
380
+ voiceId = await sendVoiceByFile(session, config, silkPath)
381
+ } else {
382
+ if (config.forceTranscode) {
383
+ await session.send(
384
+ `当前配置为【强制 silk 转码】但未检测到 ffmpeg/silk 服务。\n` +
385
+ `请在 Koishi 插件市场安装并启用:ffmpeg、silk(可能还需要 downloads)。`
386
+ )
387
+ return
388
+ }
389
+ voiceId = await sendVoiceByUrl(session, config, songUrl)
390
+ }
391
+ } catch (e: any) {
392
+ await session.send(`生成语音失败:${e?.message || String(e)}\n请检查 ffmpeg/silk 插件是否启用,或关闭“强制转码”。`)
393
+ return
394
+ } finally {
395
+ state.tipMessageId = tipId
396
+ state.voiceMessageId = voiceId
397
+ }
398
+
399
+ if (config.recallSearchMenuMessage) await tryRecall(session, state.menuMessageId)
400
+ if (config.recallTipMessage) await tryRecall(session, state.tipMessageId)
401
+ if (config.recallVoiceMessage) await tryRecall(session, state.voiceMessageId)
402
+
403
+ pending.delete(getKey(session))
404
+ }
405
+
406
+ // 主命令:听歌 关键词
407
+ ctx.command(`${config.commandName} <keyword:text>`, '点歌并发送语音(GD音乐台 API)')
408
+ .alias(config.commandAlias)
409
+ .action(async ({ session }, keyword) => {
410
+ if (!session) return
411
+ keyword = (keyword || '').trim()
412
+ if (!keyword) return `用法:${config.commandName} 歌曲名`
413
+
414
+ // ✅ DTS 关键修复:强制确认 userId/channelId 存在
415
+ const userId = session.userId
416
+ const channelId = session.channelId
417
+ if (!userId || !channelId) {
418
+ await session.send('当前适配器未提供 userId/channelId,无法进入选歌模式。')
419
+ return
420
+ }
421
+
422
+ let list: SearchItem[] = []
423
+ try {
424
+ list = await apiSearch(config, keyword, 1)
425
+ } catch {
426
+ return '搜索失败(API 不可用或超时),请稍后再试。'
427
+ }
428
+ if (!list.length) return '没有搜到结果,换个关键词试试。'
429
+
430
+ const state: PendingState = {
431
+ userId,
432
+ channelId,
433
+ guildId: session.guildId,
434
+ keyword,
435
+ page: 1,
436
+ list,
437
+ expiresAt: Date.now() + config.waitForTimeout * 1000,
438
+ }
439
+
440
+ if (!config.recallSearchMenuMessage) {
441
+ const text = buildMenuText(config, keyword, list, 1)
442
+ const ids = await session.send(text)
443
+ state.menuMessageId = Array.isArray(ids) ? ids[0] : ids
444
+ } else {
445
+ await session.send(`已进入选歌模式,请直接发送序号(1-${list.length}),或发送“${config.nextPageCommand}/${config.prevPageCommand}”翻页。`)
446
+ }
447
+
448
+ pending.set(channelId, state)
449
+ })
450
+
451
+ // 中间件:处理序号 / 翻页 / 退出
452
+ ctx.middleware(async (session, next) => {
453
+ const key = getKey(session)
454
+ if (!key) return next()
455
+
456
+ const state = pending.get(key)
457
+ if (!state) return next()
458
+
459
+ // 只允许发起者操作
460
+ if (session.userId !== state.userId) return next()
461
+
462
+ // 超时
463
+ if (isExpired(state)) {
464
+ pending.delete(key)
465
+ return next()
466
+ }
467
+
468
+ const content = (session.content || '').trim()
469
+ if (!content) return next()
470
+
471
+ // 退出
472
+ if (isExit(content)) {
473
+ if (config.recallSearchMenuMessage) await tryRecall(session, state.menuMessageId)
474
+ pending.delete(key)
475
+ if (!config.recallUserSelectMessage) await session.send('已退出选歌。')
476
+ return
477
+ }
478
+
479
+ // 下一页
480
+ if (content === config.nextPageCommand) {
481
+ if (config.recallUserSelectMessage) await tryRecall(session, session.messageId)
482
+ state.page += 1
483
+ try {
484
+ await refreshMenu(session, state)
485
+ } catch {
486
+ state.page -= 1
487
+ await session.send('翻页失败,请稍后再试。')
488
+ }
489
+ return
490
+ }
491
+
492
+ // 上一页
493
+ if (content === config.prevPageCommand) {
494
+ if (config.recallUserSelectMessage) await tryRecall(session, session.messageId)
495
+ if (state.page <= 1) {
496
+ await session.send('已经是第一页。')
497
+ return
498
+ }
499
+ state.page -= 1
500
+ try {
501
+ await refreshMenu(session, state)
502
+ } catch {
503
+ state.page += 1
504
+ await session.send('翻页失败,请稍后再试。')
505
+ }
506
+ return
507
+ }
508
+
509
+ // 序号
510
+ const n = Number(content)
511
+ if (!Number.isInteger(n) || n < 1 || n > state.list.length) return next()
512
+
513
+ if (config.recallUserSelectMessage) await tryRecall(session, session.messageId)
514
+ await handlePick(session, state, n - 1)
515
+ })
516
+ }