koishi-plugin-music-to-voice 1.0.0 → 1.0.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/src/index.ts CHANGED
@@ -1,516 +1,428 @@
1
- import { Context, Schema, Logger, h } from 'koishi'
2
- import axios from 'axios'
1
+ import { Context, Schema, h, Logger, Session } from 'koishi'
3
2
  import fs from 'node:fs'
4
3
  import path from 'node:path'
5
4
  import os from 'node:os'
6
5
  import crypto from 'node:crypto'
6
+ import { spawn } from 'node:child_process'
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'
12
-
13
11
  export interface Config {
14
- // 命令
15
12
  commandName: string
16
13
  commandAlias: string
17
-
18
- // API
19
14
  apiBase: string
20
- source: MusicSource
15
+ source: 'netease' | 'tencent' | 'kugou' | 'kuwo' | 'migu'
21
16
  searchListCount: number
22
-
23
- // 交互
24
17
  waitForTimeout: number
18
+
25
19
  nextPageCommand: string
26
20
  prevPageCommand: string
27
21
  exitCommandList: string[]
28
22
  menuExitCommandTip: boolean
29
23
 
30
- // 图片歌单(预留)
31
- imageMode: boolean
32
-
33
- // 发送类型
34
- sendAs: 'record' | 'audio'
24
+ // 撤回策略
25
+ menuRecallSec: number
26
+ tipRecallSec: number
27
+ recallOnlyAfterSuccess: boolean
28
+ keepMenuIfSendFailed: boolean
35
29
 
36
- // 转码与缓存
30
+ // 发送
31
+ sendAs: 'record' | 'audio' | 'file'
37
32
  forceTranscode: boolean
38
- tempDir: string
39
- cacheMinutes: number
40
-
41
- // 提示与撤回
33
+ maxSongDuration: number // 分钟,0=不限制
34
+ userAgent: string
42
35
  generationTip: string
43
- recallSearchMenuMessage: boolean
44
- recallTipMessage: boolean
45
- recallUserSelectMessage: boolean
46
- recallVoiceMessage: boolean
36
+ }
47
37
 
48
- // 调试
49
- loggerinfo: boolean
38
+ /**
39
+ * 可选注入:不装也能跑,只是能力不同
40
+ * - downloads:下载到文件
41
+ * - silk:编码 silk(QQ 语音更稳)
42
+ * - ffmpeg:转 PCM(silk 前置)
43
+ * - puppeteer:未来可做图片歌单(你现在先不启用也没事)
44
+ */
45
+ export const inject = {
46
+ optional: ['downloads', 'ffmpeg', 'silk', 'puppeteer'],
50
47
  }
51
48
 
49
+ /**
50
+ * ✅ 这段会显示在“插件设置页”,不会在后台日志刷屏
51
+ */
52
+ export const usage = `
53
+ ### 点歌语音(支持翻页 + 可选 silk/ffmpeg)
54
+
55
+ 开启插件前,请确保以下服务已经启用(可选安装):
56
+
57
+ - **puppeteer 服务(可选安装)**
58
+
59
+ 此外可能还需要这些服务才能发送语音:
60
+
61
+ - **ffmpeg 服务(可选安装)**(此服务可能额外依赖 **downloads** 服务)
62
+ - **silk 服务(可选安装)**
63
+
64
+ > 本插件使用音乐聚合接口(GD音乐台 API):https://music-api.gdstudio.xyz/api.php
65
+ `
66
+
52
67
  export const Config: Schema<Config> = Schema.object({
53
- commandName: Schema.string().default('听歌').description('指令名称'),
54
- commandAlias: Schema.string().default('music').description('指令别名'),
68
+ commandName: Schema.string().description('指令名称').default('听歌'),
69
+ commandAlias: Schema.string().description('指令别名').default('music'),
55
70
 
56
- apiBase: Schema.string().default('https://music-api.gdstudio.xyz/api.php').description('音乐 API 地址(GD音乐台 API)'),
71
+ apiBase: Schema.string()
72
+ .description('音乐 API 地址(GD音乐台 API)')
73
+ .default('https://music-api.gdstudio.xyz/api.php'),
74
+
75
+ // ✅ 后台显示品牌名(你要求的)
57
76
  source: Schema.union([
58
77
  Schema.const('netease').description('网易云'),
59
78
  Schema.const('tencent').description('QQ音乐'),
60
79
  Schema.const('kugou').description('酷狗'),
61
80
  Schema.const('kuwo').description('酷我'),
62
81
  Schema.const('migu').description('咪咕'),
63
- Schema.const('baidu').description('百度'),
64
- ]).default('netease').description('音源(下拉选择)'),
65
- searchListCount: Schema.number().min(5).max(50).default(20).description('搜索列表数量'),
82
+ ]).description('音源(下拉选择)').default('kuwo'),
83
+
84
+ searchListCount: Schema.natural().min(1).max(30).step(1).description('搜索列表数量').default(20),
85
+ waitForTimeout: Schema.natural().min(5).max(300).step(1).description('等待输入序号超时(秒)').default(45),
66
86
 
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('是否在歌单末尾提示退出指令'),
87
+ nextPageCommand: Schema.string().description('下一页指令').default('下一页'),
88
+ prevPageCommand: Schema.string().description('上一页指令').default('上一页'),
89
+ exitCommandList: Schema.array(Schema.string()).role('table').description('退出指令列表(一行一个)').default(['0', '不听了', '退出']),
90
+ menuExitCommandTip: Schema.boolean().description('是否在歌单末尾提示退出指令').default(false),
72
91
 
73
- imageMode: Schema.boolean().default(false).description('图片歌单模式(可选:需要 puppeteer 插件,当前仅保留开关)'),
92
+ // 解决“太快撤回”的关键:默认 60 秒撤回歌单;并且默认“发送成功才撤回”
93
+ menuRecallSec: Schema.natural().min(0).max(3600).step(1).description('歌单撤回秒数(0=不撤回)').default(60),
94
+ tipRecallSec: Schema.natural().min(0).max(3600).step(1).description('“生成中”提示撤回秒数(0=不撤回)').default(10),
95
+ recallOnlyAfterSuccess: Schema.boolean().description('仅在发送成功后才撤回(推荐开启)').default(true),
96
+ keepMenuIfSendFailed: Schema.boolean().description('发送失败时保留歌单(推荐开启)').default(true),
74
97
 
75
98
  sendAs: Schema.union([
76
- Schema.const('record').description('语音 record'),
99
+ Schema.const('record').description('语音 record(推荐)'),
77
100
  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('用户选歌后提示'),
101
+ Schema.const('file').description('文件 file'),
102
+ ]).description('发送类型').default('record'),
85
103
 
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('撤回:语音消息'),
104
+ // ✅ 装了 downloads+ffmpeg+silk 后会更稳(QQ 语音经常只认 silk)
105
+ forceTranscode: Schema.boolean().description('强制转码(需要 downloads + ffmpeg + silk;更稳但依赖更多)').default(true),
106
+ maxSongDuration: Schema.natural().min(0).max(180).step(1).description('歌曲最长时长(分钟,0=不限制)').default(30),
90
107
 
91
- loggerinfo: Schema.boolean().default(false).description('日志调试模式'),
92
- }).description('点歌语音(支持翻页 + 可选 silk/ffmpeg)')
108
+ userAgent: Schema.string().description('请求 UA(部分环境可避免风控/403)').default('koishi-music-to-voice/1.0'),
109
+ generationTip: Schema.string().description('选择序号后发送的提示文案').default('音乐生成中…'),
110
+ })
93
111
 
94
112
  type SearchItem = {
95
113
  id: string
96
114
  name: string
97
- artist?: string
115
+ artist?: string[] | string
98
116
  album?: string
117
+ url_id?: string
118
+ pic_id?: string
119
+ source: string
120
+ duration?: number // 秒(有些源会返回)
99
121
  }
100
122
 
101
123
  type PendingState = {
102
124
  userId: string
103
125
  channelId: string
104
- guildId?: string
105
-
106
126
  keyword: string
107
127
  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)
128
+ items: SearchItem[]
129
+ menuMessageIds: string[]
130
+ tipMessageIds: string[]
131
+ timer?: NodeJS.Timeout
118
132
  }
119
133
 
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
- }
134
+ const pending = new Map<string, PendingState>()
159
135
 
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
- }
136
+ function ms(sec: number) {
137
+ return Math.max(1, sec) * 1000
167
138
  }
168
139
 
169
- function hRecord(src: string) {
170
- return h('record', { src })
140
+ function keyOf(session: Session) {
141
+ return `${session.platform}:${session.userId || 'unknown'}:${session.channelId || session.guildId || 'unknown'}`
171
142
  }
172
143
 
173
- function hAudio(src: string) {
174
- return h('audio', { src })
144
+ function normalizeArtist(a: any): string {
145
+ if (!a) return ''
146
+ if (Array.isArray(a)) return a.join(' / ')
147
+ return String(a)
175
148
  }
176
149
 
177
- function buildMenuText(config: Config, keyword: string, list: SearchItem[], page: number) {
150
+ function formatMenu(state: PendingState, config: Config) {
178
151
  const lines: string[] = []
179
- lines.push(`NetEase Music:`)
180
- lines.push(`关键词:${keyword}`)
181
- lines.push(`音源:${config.source} 第 ${page} 页`)
152
+ lines.push(`点歌列表(第 ${state.page} 页)`)
153
+ lines.push(`关键词:${state.keyword}`)
182
154
  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
- }
155
+ state.items.forEach((it, idx) => {
156
+ const n = idx + 1
157
+ const artist = normalizeArtist(it.artist)
158
+ lines.push(`${n}. ${it.name}${artist ? ` - ${artist}` : ''}`)
159
+ })
188
160
  lines.push('')
189
- lines.push(`请在 ${config.waitForTimeout} 秒内输入序号(1-${list.length})`)
161
+ lines.push(`请在 ${config.waitForTimeout} 秒内输入歌曲序号`)
190
162
  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')
163
+ if (config.menuExitCommandTip) lines.push(`退出:${config.exitCommandList.join(' / ')}`)
196
164
  return lines.join('\n')
197
165
  }
198
166
 
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)
167
+ async function safeSend(session: Session, content: any) {
168
+ const ids = await session.send(content)
169
+ if (Array.isArray(ids)) return ids.filter(Boolean)
170
+ return ids ? [ids] : []
209
171
  }
210
172
 
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)
173
+ function recall(session: Session, ids: string[], sec: number) {
174
+ if (!ids?.length || sec <= 0) return
175
+ const channelId = session.channelId
176
+ if (!channelId) return
177
+ setTimeout(() => {
178
+ ids.forEach((id) => session.bot.deleteMessage(channelId, id).catch(() => {}))
179
+ }, sec * 1000)
215
180
  }
216
181
 
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)
182
+ async function apiSearch(ctx: Context, config: Config, keyword: string, page: number) {
183
+ const params = new URLSearchParams({
184
+ types: 'search',
185
+ source: config.source,
186
+ name: keyword,
187
+ count: String(config.searchListCount),
188
+ pages: String(page),
189
+ })
190
+ const url = `${config.apiBase}?${params.toString()}`
191
+ const data = await ctx.http.get(url, {
192
+ headers: { 'user-agent': config.userAgent },
193
+ responseType: 'json',
194
+ timeout: 15000,
224
195
  })
196
+ if (!Array.isArray(data)) throw new Error('search response is not array')
197
+ return data as SearchItem[]
225
198
  }
226
199
 
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
200
+ async function apiGetSongUrl(ctx: Context, config: Config, item: SearchItem) {
201
+ const id = item.url_id || item.id
202
+ const params = new URLSearchParams({
203
+ types: 'url',
204
+ id: String(id),
205
+ source: item.source || config.source,
206
+ })
207
+ const url = `${config.apiBase}?${params.toString()}`
208
+ const data = await ctx.http.get(url, {
209
+ headers: { 'user-agent': config.userAgent },
210
+ responseType: 'json',
211
+ timeout: 15000,
212
+ })
213
+ const u = (Array.isArray(data) ? data[0]?.url : data?.url) as string | undefined
214
+ if (!u) throw new Error('url not found from api')
215
+ return u
231
216
  }
232
217
 
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
218
+ function tmpFile(ext: string) {
219
+ const id = crypto.randomBytes(8).toString('hex')
220
+ return path.join(os.tmpdir(), `koishi-music-${id}.${ext}`)
238
221
  }
239
222
 
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 {}
223
+ async function downloadToFile(ctx: Context, config: Config, url: string, filePath: string) {
224
+ const anyCtx = ctx as any
225
+ if (anyCtx.downloads?.download) {
226
+ await anyCtx.downloads.download(url, filePath, {
227
+ headers: { 'user-agent': config.userAgent },
228
+ })
229
+ return
292
230
  }
293
-
294
- return silkPath
231
+ const buf = await ctx.http.get<ArrayBuffer>(url, {
232
+ headers: { 'user-agent': config.userAgent },
233
+ responseType: 'arraybuffer',
234
+ timeout: 30000,
235
+ })
236
+ fs.writeFileSync(filePath, Buffer.from(buf))
295
237
  }
296
238
 
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)')
239
+ function runFfmpegToPcm(input: string, output: string): Promise<void> {
240
+ return new Promise((resolve, reject) => {
241
+ const p = spawn('ffmpeg', ['-y', '-i', input, '-ac', '1', '-ar', '48000', '-f', 's16le', output], { stdio: 'ignore' })
242
+ p.on('error', reject)
243
+ p.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg exit code ${code}`))))
244
+ })
310
245
  }
311
246
 
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
- }
247
+ async function encodeSilk(ctx: Context, pcmPath: string): Promise<Buffer> {
248
+ const anyCtx = ctx as any
249
+ if (anyCtx.silk?.encode) {
250
+ const pcm = fs.readFileSync(pcmPath)
251
+ const out = await anyCtx.silk.encode(pcm, 48000)
252
+ return Buffer.isBuffer(out) ? out : Buffer.from(out)
348
253
  }
254
+ throw new Error('silk service encode not available')
255
+ }
349
256
 
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)
257
+ /**
258
+ * 为什么“人家的插件可选安装也能成功”?
259
+ * 关键在于:它通常会在“能转 silk 就转”,否则回退为“直接发音频/文件/直链 record”。
260
+ * 这样没装依赖也能出结果,只是 QQ 语音成功率可能低一些。
261
+ */
262
+ async function sendSong(session: Session, ctx: Context, config: Config, url: string) {
263
+ // record + 强制转码:downloads -> ffmpeg -> silk(QQ 最稳)
264
+ if (config.sendAs === 'record' && config.forceTranscode) {
265
+ const anyCtx = ctx as any
266
+ if (anyCtx.downloads && anyCtx.silk) {
267
+ try {
268
+ const inFile = tmpFile('mp3')
269
+ const pcmFile = tmpFile('pcm')
270
+ await downloadToFile(ctx, config, url, inFile)
271
+ await runFfmpegToPcm(inFile, pcmFile)
272
+ const silkBuf = await encodeSilk(ctx, pcmFile)
273
+ try { fs.unlinkSync(inFile) } catch {}
274
+ try { fs.unlinkSync(pcmFile) } catch {}
275
+ return await safeSend(session, h('record', { src: silkBuf }))
276
+ } catch (e) {
277
+ logger.warn('transcode/send record failed, fallback: %s', (e as Error).message)
390
278
  }
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
279
  }
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
280
  }
405
281
 
406
- // 主命令:听歌 关键词
407
- ctx.command(`${config.commandName} <keyword:text>`, '点歌并发送语音(GD音乐台 API)')
282
+ // 回退策略:不装依赖也能发(但 QQ “语音 record(url)” 可能不稳定)
283
+ if (config.sendAs === 'record') return await safeSend(session, h('record', { src: url }))
284
+ if (config.sendAs === 'audio') return await safeSend(session, h.audio(url))
285
+ return await safeSend(session, h.file(url))
286
+ }
287
+
288
+ export function apply(ctx: Context, config: Config) {
289
+ // 主命令:听歌 <keyword>
290
+ ctx.command(`${config.commandName} <keyword:text>`, '点歌并发送语音/音频')
408
291
  .alias(config.commandAlias)
409
292
  .action(async ({ session }, keyword) => {
410
293
  if (!session) return
411
294
  keyword = (keyword || '').trim()
412
295
  if (!keyword) return `用法:${config.commandName} 歌曲名`
413
296
 
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
- }
297
+ const k = keyOf(session)
298
+ const old = pending.get(k)
299
+ if (old?.timer) clearTimeout(old.timer)
300
+ pending.delete(k)
421
301
 
422
- let list: SearchItem[] = []
302
+ let items: SearchItem[]
423
303
  try {
424
- list = await apiSearch(config, keyword, 1)
425
- } catch {
304
+ items = await apiSearch(ctx, config, keyword, 1)
305
+ } catch (e) {
306
+ logger.warn('search failed: %s', (e as Error).message)
426
307
  return '搜索失败(API 不可用或超时),请稍后再试。'
427
308
  }
428
- if (!list.length) return '没有搜到结果,换个关键词试试。'
309
+ if (!items.length) return '没有搜索到结果。'
429
310
 
430
311
  const state: PendingState = {
431
- userId,
432
- channelId,
433
- guildId: session.guildId,
312
+ userId: session.userId || '',
313
+ channelId: session.channelId || '',
434
314
  keyword,
435
315
  page: 1,
436
- list,
437
- expiresAt: Date.now() + config.waitForTimeout * 1000,
316
+ items,
317
+ menuMessageIds: [],
318
+ tipMessageIds: [],
438
319
  }
320
+ pending.set(k, state)
439
321
 
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}”翻页。`)
322
+ const menuText = formatMenu(state, config)
323
+ state.menuMessageIds = await safeSend(session, menuText)
324
+
325
+ // 你说的“太快撤回”就是这里:我们默认给 60 秒,并且发送成功才撤回
326
+ if (config.menuRecallSec > 0 && !config.recallOnlyAfterSuccess) {
327
+ recall(session, state.menuMessageIds, config.menuRecallSec)
446
328
  }
447
329
 
448
- pending.set(channelId, state)
330
+ state.timer = setTimeout(async () => {
331
+ const cur = pending.get(k)
332
+ if (!cur) return
333
+ pending.delete(k)
334
+ await session.send('输入超时,已取消点歌。')
335
+ }, ms(config.waitForTimeout))
336
+
337
+ return
449
338
  })
450
339
 
451
- // 中间件:处理序号 / 翻页 / 退出
340
+ // 捕获“序号 / 翻页 / 退出”
452
341
  ctx.middleware(async (session, next) => {
453
- const key = getKey(session)
454
- if (!key) return next()
455
-
456
- const state = pending.get(key)
342
+ const k = keyOf(session)
343
+ const state = pending.get(k)
457
344
  if (!state) return next()
458
345
 
459
- // 只允许发起者操作
460
- if (session.userId !== state.userId) return next()
461
-
462
- // 超时
463
- if (isExpired(state)) {
464
- pending.delete(key)
465
- return next()
466
- }
346
+ // 只允许同一用户、同一频道继续操作
347
+ if ((session.userId || '') !== state.userId || (session.channelId || '') !== state.channelId) return next()
467
348
 
468
349
  const content = (session.content || '').trim()
469
350
  if (!content) return next()
470
351
 
471
352
  // 退出
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
- }
353
+ if (config.exitCommandList.map(s => s.trim()).filter(Boolean).includes(content)) {
354
+ pending.delete(k)
355
+ if (state.timer) clearTimeout(state.timer)
356
+ await session.send('已退出歌曲选择。')
489
357
  return
490
358
  }
491
359
 
492
- // 上一页
493
- if (content === config.prevPageCommand) {
494
- if (config.recallUserSelectMessage) await tryRecall(session, session.messageId)
495
- if (state.page <= 1) {
360
+ // 翻页
361
+ if (content === config.nextPageCommand || content === config.prevPageCommand) {
362
+ const target = content === config.nextPageCommand ? state.page + 1 : Math.max(1, state.page - 1)
363
+ if (target === state.page) {
496
364
  await session.send('已经是第一页。')
497
365
  return
498
366
  }
499
- state.page -= 1
500
367
  try {
501
- await refreshMenu(session, state)
502
- } catch {
503
- state.page += 1
504
- await session.send('翻页失败,请稍后再试。')
368
+ const items = await apiSearch(ctx, config, state.keyword, target)
369
+ if (!items.length) {
370
+ await session.send('没有更多结果了。')
371
+ return
372
+ }
373
+ state.page = target
374
+ state.items = items
375
+ const menuText = formatMenu(state, config)
376
+ const newIds = await safeSend(session, menuText)
377
+ state.menuMessageIds.push(...newIds)
378
+ if (config.menuRecallSec > 0 && !config.recallOnlyAfterSuccess) recall(session, newIds, config.menuRecallSec)
379
+ } catch (e) {
380
+ logger.warn('page search failed: %s', (e as Error).message)
381
+ await session.send('翻页失败(API 不可用或超时)。')
505
382
  }
506
383
  return
507
384
  }
508
385
 
509
- // 序号
386
+ // 选择序号
510
387
  const n = Number(content)
511
- if (!Number.isInteger(n) || n < 1 || n > state.list.length) return next()
388
+ if (!Number.isInteger(n) || n < 1 || n > state.items.length) return next()
512
389
 
513
- if (config.recallUserSelectMessage) await tryRecall(session, session.messageId)
514
- await handlePick(session, state, n - 1)
390
+ if (state.timer) clearTimeout(state.timer)
391
+
392
+ const tipIds = await safeSend(session, config.generationTip)
393
+ state.tipMessageIds.push(...tipIds)
394
+ if (config.tipRecallSec > 0 && !config.recallOnlyAfterSuccess) recall(session, tipIds, config.tipRecallSec)
395
+
396
+ try {
397
+ const item = state.items[n - 1]
398
+ const songUrl = await apiGetSongUrl(ctx, config, item)
399
+
400
+ // 最长时长控制(只有 API 返回 duration 才会生效)
401
+ if (config.maxSongDuration > 0 && item.duration && item.duration / 60 > config.maxSongDuration) {
402
+ await session.send(`该歌曲时长超出限制(>${config.maxSongDuration} 分钟),已取消发送。`)
403
+ return
404
+ }
405
+
406
+ await sendSong(session, ctx, config, songUrl)
407
+
408
+ // ✅ 发送成功后才撤回(默认开启),解决你截图那种“瞬间撤回导致看不到/发不出来”
409
+ if (config.recallOnlyAfterSuccess) {
410
+ if (config.tipRecallSec > 0) recall(session, tipIds, 1)
411
+ if (config.menuRecallSec > 0) recall(session, state.menuMessageIds, 1)
412
+ }
413
+
414
+ pending.delete(k)
415
+ return
416
+ } catch (e) {
417
+ logger.warn('send failed: %s', (e as Error).stack || (e as Error).message)
418
+ await session.send('获取/发送失败,请稍后再试。')
419
+
420
+ if (!config.keepMenuIfSendFailed) {
421
+ pending.delete(k)
422
+ } else {
423
+ state.timer = setTimeout(() => pending.delete(k), ms(config.waitForTimeout))
424
+ }
425
+ return
426
+ }
515
427
  })
516
428
  }