koishi-plugin-music-to-voice 1.0.0 → 1.0.1

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 CHANGED
@@ -1,516 +1,460 @@
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'
1
+ import { Context, Schema, h, Logger, Session, isNullable } from 'koishi'
5
2
  import os from 'node:os'
3
+ import fs from 'node:fs'
6
4
  import crypto from 'node:crypto'
5
+ import path from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
7
 
8
- export const name = 'music-voice-pro'
8
+ export const name = 'music-to-voice'
9
9
  const logger = new Logger(name)
10
10
 
11
- type MusicSource = 'netease' | 'tencent' | 'kugou' | 'kuwo' | 'migu' | 'baidu'
11
+ // 只声明可选依赖,不在后台日志刷屏
12
+ export const inject = {
13
+ required: ['http', 'i18n'],
14
+ optional: ['puppeteer', 'downloads', 'ffmpeg', 'silk'],
15
+ } as const
16
+
17
+ // ✅ 提示放插件设置页,不要后台日志输出
18
+ export const usage = `
19
+ ---
20
+
21
+ 本插件提供“点歌 → 列表选序号 → 发送语音/音频/文件”的音乐聚合能力。
22
+
23
+ 数据来源:**GD音乐台 API**(https://music-api.gdstudio.xyz/api.php)
24
+
25
+ ---
26
+
27
+ ## 开启插件前,请确保以下服务已经启用(可选安装)
28
+
29
+ 所需服务:
30
+
31
+ - puppeteer 服务(可选安装,用于图片歌单)
32
+
33
+ 此外可能还需要这些服务才能发送语音(最稳:下载 + 转码):
34
+
35
+ - downloads 服务(可选安装)
36
+ - ffmpeg 服务(可选安装,可能依赖 downloads)
37
+ - silk 服务(可选安装)
38
+
39
+ ---
40
+ `
41
+
42
+ type MusicSource = 'netease' | 'tencent' | 'kugou' | 'kuwo' | 'migu'
43
+ type SendAs = 'record' | 'audio' | 'file'
12
44
 
13
45
  export interface Config {
14
- // 命令
15
46
  commandName: string
16
47
  commandAlias: string
17
48
 
18
- // API
19
49
  apiBase: string
20
50
  source: MusicSource
21
51
  searchListCount: number
22
52
 
23
- // 交互
24
- waitForTimeout: number
53
+ imageMode: boolean
54
+ textColor?: string
55
+ backgroundColor?: string
56
+
25
57
  nextPageCommand: string
26
58
  prevPageCommand: string
27
59
  exitCommandList: string[]
28
60
  menuExitCommandTip: boolean
29
61
 
30
- // 图片歌单(预留)
31
- imageMode: boolean
32
-
33
- // 发送类型
34
- sendAs: 'record' | 'audio'
62
+ waitForTimeout: number
63
+ generationTip: string
35
64
 
36
- // 转码与缓存
65
+ sendAs: SendAs
37
66
  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
67
 
48
- // 调试
49
- loggerinfo: boolean
68
+ // 勾选=撤回/不保留
69
+ recallMenu: boolean
70
+ recallGeneratingTip: boolean
71
+ recallSongMessage: boolean
72
+ recallTimeoutTip: boolean
73
+ recallExitTip: boolean
74
+ recallInvalidTip: boolean
75
+ recallFetchFailTip: boolean
76
+
77
+ // 频率限制(可选)
78
+ enableRateLimit: boolean
79
+ rateLimitScope?: 'user' | 'channel' | 'platform'
80
+ rateLimitIntervalSec?: number
50
81
  }
51
82
 
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 = {
83
+ export const Config: Schema<Config> = Schema.intersect([
84
+ Schema.object({
85
+ commandName: Schema.string().default('听歌').description('指令名称'),
86
+ commandAlias: Schema.string().default('music2').description('指令别名(避免命令冲突)'),
87
+ }).description('基础设置'),
88
+
89
+ Schema.object({
90
+ apiBase: Schema.string().default('https://music-api.gdstudio.xyz/api.php').description('GD音乐台 API 地址'),
91
+ source: Schema.union([
92
+ Schema.const('netease').description('源1'),
93
+ Schema.const('tencent').description('源2'),
94
+ Schema.const('kugou').description('源3'),
95
+ Schema.const('kuwo').description('源4'),
96
+ Schema.const('migu').description('源5'),
97
+ ])
98
+ .default('kuwo')
99
+ .description('音源(下拉选择)'),
100
+ searchListCount: Schema.natural().min(5).max(50).default(20).description('每页显示条数'),
101
+ }).description('API 设置'),
102
+
103
+ Schema.object({
104
+ imageMode: Schema.boolean().default(false).description('图片歌单模式(需要 puppeteer,可选安装)'),
105
+ textColor: Schema.string().role('color').default('rgba(255,255,255,1)').description('图片歌单文字颜色'),
106
+ backgroundColor: Schema.string().role('color').default('rgba(0,0,0,1)').description('图片歌单背景颜色'),
107
+ }).description('歌单样式'),
108
+
109
+ Schema.object({
110
+ nextPageCommand: Schema.string().default('下一页').description('下一页指令'),
111
+ prevPageCommand: Schema.string().default('上一页').description('上一页指令'),
112
+ exitCommandList: Schema.array(Schema.string()).default(['0', '退出', '不听了']).description('退出指令列表'),
113
+ menuExitCommandTip: Schema.boolean().default(false).description('是否在歌单尾部提示退出指令'),
114
+ waitForTimeout: Schema.natural().min(5).max(180).default(45).description('等待输入序号超时(秒)'),
115
+ generationTip: Schema.string().default('生成语音中…').description('生成提示文本'),
116
+ }).description('交互设置'),
117
+
118
+ Schema.object({
119
+ sendAs: Schema.union([
120
+ Schema.const('record').description('语音 record'),
121
+ Schema.const('audio').description('音频 audio'),
122
+ Schema.const('file').description('文件 file'),
123
+ ])
124
+ .default('record')
125
+ .description('发送类型'),
126
+ forceTranscode: Schema.boolean()
127
+ .default(true)
128
+ .description('最稳模式:下载 + ffmpeg + silk 转码(可选依赖,缺依赖自动降级直链)'),
129
+ }).description('发送设置'),
130
+
131
+ Schema.object({
132
+ recallMenu: Schema.boolean().default(true).description('撤回歌单消息'),
133
+ recallGeneratingTip: Schema.boolean().default(true).description('撤回生成提示消息'),
134
+ recallSongMessage: Schema.boolean().default(false).description('撤回歌曲消息(语音/音频/文件)'),
135
+ recallTimeoutTip: Schema.boolean().default(true).description('撤回超时提示'),
136
+ recallExitTip: Schema.boolean().default(false).description('撤回退出提示'),
137
+ recallInvalidTip: Schema.boolean().default(false).description('撤回序号错误提示'),
138
+ recallFetchFailTip: Schema.boolean().default(true).description('撤回获取失败提示'),
139
+ }).description('撤回设置'),
140
+
141
+ Schema.object({
142
+ enableRateLimit: Schema.boolean().default(false).description('是否开启频率限制'),
143
+ rateLimitScope: Schema.union([
144
+ Schema.const('user').description('按用户'),
145
+ Schema.const('channel').description('按频道'),
146
+ Schema.const('platform').description('按平台'),
147
+ ]).default('user').description('限制范围'),
148
+ rateLimitIntervalSec: Schema.natural().min(1).max(3600).default(60).description('间隔秒数'),
149
+ }).description('频率限制'),
150
+ ])
151
+
152
+ interface SongItem {
95
153
  id: string
96
154
  name: string
97
- artist?: string
98
- album?: string
155
+ artist: string
156
+ album: string
157
+ source: MusicSource
99
158
  }
100
159
 
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
160
+ // --------- 小工具 ---------
110
161
 
111
- menuMessageId?: string
112
- tipMessageId?: string
113
- voiceMessageId?: string
162
+ function artistsToString(x: any): string {
163
+ if (!x) return ''
164
+ if (Array.isArray(x)) return x.join(' / ')
165
+ return String(x)
114
166
  }
115
167
 
116
- function safeText(x: unknown): string {
117
- return typeof x === 'string' ? x : x == null ? '' : String(x)
168
+ async function safeDelete(session: Session, messageId?: string | null) {
169
+ if (!messageId) return
170
+ const channelId = session.channelId
171
+ if (!channelId) return
172
+ try {
173
+ await session.bot.deleteMessage(channelId, messageId)
174
+ } catch {}
118
175
  }
119
176
 
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)
177
+ function buildRateKey(session: Session, scope: 'user' | 'channel' | 'platform') {
178
+ if (scope === 'platform') return session.platform
179
+ if (scope === 'channel') return `${session.platform}:${session.channelId ?? ''}`
180
+ return `${session.platform}:${session.userId ?? ''}`
140
181
  }
141
182
 
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
- )
183
+ async function gdSearch(ctx: Context, config: Config, keyword: string, page: number): Promise<SongItem[]> {
184
+ const data = await ctx.http.get(config.apiBase, {
185
+ timeout: 15000,
186
+ params: { types: 'search', source: config.source, name: keyword, count: config.searchListCount, pages: page },
187
+ headers: { 'user-agent': 'koishi-music-to-voice' },
188
+ })
189
+ if (!Array.isArray(data)) return []
190
+ return data
191
+ .map((x: any) => ({
192
+ id: String(x.id ?? x.url_id ?? ''),
193
+ name: String(x.name ?? ''),
194
+ artist: artistsToString(x.artist),
195
+ album: String(x.album ?? ''),
196
+ source: String(x.source ?? config.source) as MusicSource,
197
+ }))
198
+ .filter((x: SongItem) => x.id && x.name)
150
199
  }
151
200
 
152
- function md5(s: string) {
153
- return crypto.createHash('md5').update(s).digest('hex')
154
- }
201
+ async function gdUrl(ctx: Context, config: Config, item: SongItem): Promise<string> {
202
+ const data = await ctx.http.get(config.apiBase, {
203
+ timeout: 15000,
204
+ params: { types: 'url', source: item.source || config.source, id: item.id },
205
+ headers: { 'user-agent': 'koishi-music-to-voice' },
206
+ })
155
207
 
156
- function ensureDir(p: string) {
157
- fs.mkdirSync(p, { recursive: true })
208
+ if (typeof data === 'string') return data
209
+ if (data?.url) return String(data.url)
210
+ if (data?.data?.url) return String(data.data.url)
211
+ return ''
158
212
  }
159
213
 
160
- async function tryRecall(session: any, messageId?: string) {
161
- if (!messageId) return
214
+ async function downloadToTemp(ctx: Context, src: string): Promise<string | null> {
162
215
  try {
163
- await session.bot.deleteMessage(session.channelId, messageId)
164
- } catch {
165
- // ignore
216
+ const file = await ctx.http.file(src)
217
+ const ext = '.mp3'
218
+ const fp = path.join(os.tmpdir(), `music_${crypto.randomBytes(8).toString('hex')}${ext}`)
219
+ fs.writeFileSync(fp, Buffer.from(file.data))
220
+ return fp
221
+ } catch (e) {
222
+ return null
166
223
  }
167
224
  }
168
225
 
169
- function hRecord(src: string) {
170
- return h('record', { src })
171
- }
172
-
173
- function hAudio(src: string) {
174
- return h('audio', { src })
175
- }
226
+ async function tryTranscode(ctx: Context, localPath: string): Promise<string> {
227
+ // 完全可选:缺依赖就原样返回
228
+ const anyCtx = ctx as any
229
+ if (!anyCtx.ffmpeg || !anyCtx.silk) return localPath
176
230
 
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,
231
+ const wav = localPath.replace(/\.\w+$/, '') + '.wav'
232
+ const silk = localPath.replace(/\.\w+$/, '') + '.silk'
233
+ try {
234
+ await anyCtx.ffmpeg.convert(localPath, wav)
235
+ await anyCtx.silk.encode(wav, silk)
236
+ return silk
237
+ } catch {
238
+ return localPath
239
+ } finally {
240
+ try { fs.existsSync(wav) && fs.unlinkSync(wav) } catch {}
206
241
  }
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
242
  }
216
243
 
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
- })
244
+ async function renderListImage(ctx: Context, html: string): Promise<Buffer | null> {
245
+ const anyCtx = ctx as any
246
+ if (!anyCtx.puppeteer) return null
247
+ const page = await anyCtx.puppeteer.page()
248
+ await page.setContent(html)
249
+ const el = await page.$('#song-list')
250
+ if (!el) return null
251
+ const buf = (await el.screenshot({})) as Buffer
252
+ await page.close()
253
+ return buf
225
254
  }
226
255
 
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
256
+ function makeListText(config: Config, list: SongItem[], page: number) {
257
+ const start = (page - 1) * config.searchListCount
258
+ const lines = list.map((s, i) => `${start + i + 1}. ${s.name} -- ${s.artist}${s.album ? ' -- ' + s.album : ''}`)
259
+ let tail = `\n\n翻页:${config.prevPageCommand} / ${config.nextPageCommand}`
260
+ if (config.menuExitCommandTip && config.exitCommandList?.length) {
261
+ tail += `\n退出:${config.exitCommandList.join(' / ')}`
262
+ }
263
+ return `音乐列表:\n${lines.join('\n')}${tail}\n\n请在 ${config.waitForTimeout} 秒内输入序号:`
231
264
  }
232
265
 
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
- }
266
+ // --------- 主逻辑 ---------
239
267
 
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
268
+ export function apply(ctx: Context, config: Config) {
269
+ // 频率限制记录
270
+ const lastUse = new Map<string, number>()
248
271
 
249
- ensureDir(config.tempDir)
272
+ ctx.command(`${config.commandName} <keyword:text>`, '音乐聚合点歌并发送语音')
273
+ .alias(config.commandAlias)
274
+ .action(async (argv, keyword?: string) => {
275
+ const session = argv.session
276
+ const options = argv.options ?? {}
250
277
 
251
- const silkPath = path.join(config.tempDir, `${cacheKey}.silk`)
252
- if (config.cacheMinutes > 0 && fs.existsSync(silkPath)) {
253
- return silkPath
254
- }
278
+ // 解决 “session 可能为 undefined”
279
+ if (!session) return
280
+ keyword = (keyword ?? '').trim()
281
+ if (!keyword) return `用法:${config.commandName} 歌曲名`
255
282
 
256
- const rawPath = path.join(config.tempDir, `${cacheKey}.src`)
257
- await downloadToFile(audioUrl, rawPath)
283
+ // 频率限制(可选)
284
+ if (config.enableRateLimit) {
285
+ const scope = config.rateLimitScope ?? 'user'
286
+ const key = buildRateKey(session, scope)
287
+ const now = Date.now()
288
+ const last = lastUse.get(key) ?? 0
289
+ const interval = (config.rateLimitIntervalSec ?? 60) * 1000
290
+ if (now - last < interval) {
291
+ const remain = Math.ceil((interval - (now - last)) / 1000)
292
+ return `操作太频繁,请 ${remain} 秒后再试。`
293
+ }
294
+ lastUse.set(key, now)
295
+ }
258
296
 
259
- const wavPath = path.join(config.tempDir, `${cacheKey}.wav`)
260
- const ffmpeg: any = (ctx as any).ffmpeg
297
+ let page = 1
298
+ let menuMsgId: string | null = null
299
+
300
+ while (true) {
301
+ let list: SongItem[] = []
302
+ try {
303
+ list = await gdSearch(ctx, config, keyword, page)
304
+ } catch (e: any) {
305
+ logger.warn(`[search] ${String(e?.message || e)}`)
306
+ const ids = await session.send('搜索失败(API 不可用或超时),请稍后再试。')
307
+ const mid = Array.isArray(ids) ? ids[0] : (ids as any)
308
+ if (config.recallFetchFailTip) await safeDelete(session, mid)
309
+ return
310
+ }
261
311
 
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
- }
312
+ if (!list.length) {
313
+ const ids = await session.send('没有搜索到结果,请换个关键词试试。')
314
+ const mid = Array.isArray(ids) ? ids[0] : (ids as any)
315
+ if (config.recallFetchFailTip) await safeDelete(session, mid)
316
+ return
317
+ }
277
318
 
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
- }
319
+ // 发送歌单(文本/图片)
320
+ if (menuMsgId && config.recallMenu) await safeDelete(session, menuMsgId)
321
+
322
+ if (config.imageMode) {
323
+ const text = makeListText(config, list, page).replace(/\n/g, '<br/>')
324
+ const html = `<!doctype html><html><head><meta charset="utf-8"/><style>
325
+ body{margin:0;background:${config.backgroundColor};color:${config.textColor};font-size:16px;}
326
+ #song-list{padding:18px;white-space:nowrap;}
327
+ </style></head><body><div id="song-list">${text}</div></body></html>`
328
+ const buf = await renderListImage(ctx, html)
329
+
330
+ if (!buf) {
331
+ const ids = await session.send('图片歌单生成失败:未安装 puppeteer 服务或其不可用。')
332
+ const mid = Array.isArray(ids) ? ids[0] : (ids as any)
333
+ if (config.recallFetchFailTip) await safeDelete(session, mid)
334
+ return
335
+ }
336
+ const ids = await session.send([h.image(buf, 'image/png')])
337
+ menuMsgId = Array.isArray(ids) ? ids[0] : (ids as any)
338
+ } else {
339
+ const txt = makeListText(config, list, page)
340
+ const ids = await session.send(txt)
341
+ menuMsgId = Array.isArray(ids) ? ids[0] : (ids as any)
342
+ }
293
343
 
294
- return silkPath
295
- }
344
+ // 等待输入
345
+ const input = await session.prompt(
346
+ (s) => {
347
+ // ✅ elements 可能 undefined
348
+ const els = s.elements ?? []
349
+ return h.select(els, 'text').join('')
350
+ },
351
+ { timeout: config.waitForTimeout * 1000 }
352
+ )
353
+
354
+ if (isNullable(input)) {
355
+ if (config.recallMenu) await safeDelete(session, menuMsgId)
356
+ const ids = await session.send('输入超时,已取消点歌。')
357
+ const mid = Array.isArray(ids) ? ids[0] : (ids as any)
358
+ if (config.recallTimeoutTip) await safeDelete(session, mid)
359
+ return
360
+ }
296
361
 
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
- }
362
+ const text = String(input).trim()
363
+ if (!text) continue
311
364
 
312
- export function apply(ctx: Context, config: Config) {
313
- if (config.loggerinfo) logger.level = Logger.DEBUG
314
- logDepsHint(ctx)
365
+ // 退出
366
+ if (config.exitCommandList.includes(text)) {
367
+ if (config.recallMenu) await safeDelete(session, menuMsgId)
368
+ const ids = await session.send('已退出歌曲选择。')
369
+ const mid = Array.isArray(ids) ? ids[0] : (ids as any)
370
+ if (config.recallExitTip) await safeDelete(session, mid)
371
+ return
372
+ }
315
373
 
316
- const pending = new Map<string, PendingState>() // channelId -> state
374
+ // 翻页
375
+ if (text === config.nextPageCommand) {
376
+ page += 1
377
+ continue
378
+ }
379
+ if (text === config.prevPageCommand) {
380
+ page = Math.max(1, page - 1)
381
+ continue
382
+ }
317
383
 
318
- function getKey(session: any) {
319
- return String(session?.channelId || '')
320
- }
384
+ // 序号
385
+ if (!/^\d+$/.test(text)) continue
386
+ const idx = parseInt(text, 10)
387
+ const start = (page - 1) * config.searchListCount
388
+ const local = idx - start - 1
389
+ if (local < 0 || local >= list.length) {
390
+ if (config.recallMenu) await safeDelete(session, menuMsgId)
391
+ const ids = await session.send('序号输入错误,已退出歌曲选择。')
392
+ const mid = Array.isArray(ids) ? ids[0] : (ids as any)
393
+ if (config.recallInvalidTip) await safeDelete(session, mid)
394
+ return
395
+ }
321
396
 
322
- function isExit(input: string) {
323
- const t = input.trim()
324
- return config.exitCommandList.map(s => s.trim()).includes(t)
325
- }
397
+ const chosen = list[local]
326
398
 
327
- function isExpired(state: PendingState) {
328
- return Date.now() > state.expiresAt
329
- }
399
+ // 生成提示
400
+ let tipMsgId: string | null = null
401
+ {
402
+ const ids = await session.send(config.generationTip)
403
+ tipMsgId = Array.isArray(ids) ? ids[0] : (ids as any)
404
+ }
330
405
 
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
- }
406
+ // 获取播放 URL
407
+ let playUrl = ''
408
+ try {
409
+ playUrl = await gdUrl(ctx, config, chosen)
410
+ } catch (e: any) {
411
+ logger.warn(`[url] ${String(e?.message || e)}`)
412
+ }
349
413
 
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
- )
414
+ if (!playUrl) {
415
+ if (config.recallGeneratingTip) await safeDelete(session, tipMsgId)
416
+ if (config.recallMenu) await safeDelete(session, menuMsgId)
417
+ const ids = await session.send('获取歌曲失败,请稍后再试。')
418
+ const mid = Array.isArray(ids) ? ids[0] : (ids as any)
419
+ if (config.recallFetchFailTip) await safeDelete(session, mid)
387
420
  return
388
421
  }
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
422
 
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} 歌曲名`
423
+ // 发送(转码可选)
424
+ try {
425
+ let finalSrc = playUrl
426
+
427
+ if (config.forceTranscode) {
428
+ const localPath = await downloadToTemp(ctx, playUrl)
429
+ if (localPath) {
430
+ const out = await tryTranscode(ctx, localPath)
431
+ finalSrc = pathToFileURL(out).href
432
+ }
433
+ }
434
+
435
+ // ✅ 不要用 `+` 拼 Element;直接发 Fragment 数组
436
+ let sendIds: any
437
+ if (config.sendAs === 'file') {
438
+ sendIds = await session.send([h.file(finalSrc)])
439
+ } else if (config.sendAs === 'audio') {
440
+ sendIds = await session.send([h.audio(finalSrc)])
441
+ } else {
442
+ sendIds = await session.send([h('record', { src: finalSrc })])
443
+ }
444
+
445
+ const songMsgId = Array.isArray(sendIds) ? sendIds[0] : sendIds
446
+ if (config.recallSongMessage) await safeDelete(session, songMsgId)
447
+ } catch (e: any) {
448
+ logger.warn(`[send] ${String(e?.message || e)}`)
449
+ const ids = await session.send('发送失败(可能缺少转码依赖或链接失效)。')
450
+ const mid = Array.isArray(ids) ? ids[0] : (ids as any)
451
+ if (config.recallFetchFailTip) await safeDelete(session, mid)
452
+ } finally {
453
+ if (config.recallGeneratingTip) await safeDelete(session, tipMsgId)
454
+ if (config.recallMenu) await safeDelete(session, menuMsgId)
455
+ }
413
456
 
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
457
  return
420
458
  }
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
459
  })
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
460
  }