koishi-plugin-music-to-voice 1.0.1 → 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,460 +1,428 @@
1
- import { Context, Schema, h, Logger, Session, isNullable } from 'koishi'
2
- import os from 'node:os'
1
+ import { Context, Schema, h, Logger, Session } from 'koishi'
3
2
  import fs from 'node:fs'
4
- import crypto from 'node:crypto'
5
3
  import path from 'node:path'
6
- import { pathToFileURL } from 'node:url'
4
+ import os from 'node:os'
5
+ import crypto from 'node:crypto'
6
+ import { spawn } from 'node:child_process'
7
7
 
8
8
  export const name = 'music-to-voice'
9
9
  const logger = new Logger(name)
10
10
 
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'
44
-
45
11
  export interface Config {
46
12
  commandName: string
47
13
  commandAlias: string
48
-
49
14
  apiBase: string
50
- source: MusicSource
15
+ source: 'netease' | 'tencent' | 'kugou' | 'kuwo' | 'migu'
51
16
  searchListCount: number
52
-
53
- imageMode: boolean
54
- textColor?: string
55
- backgroundColor?: string
17
+ waitForTimeout: number
56
18
 
57
19
  nextPageCommand: string
58
20
  prevPageCommand: string
59
21
  exitCommandList: string[]
60
22
  menuExitCommandTip: boolean
61
23
 
62
- waitForTimeout: number
63
- generationTip: string
24
+ // 撤回策略
25
+ menuRecallSec: number
26
+ tipRecallSec: number
27
+ recallOnlyAfterSuccess: boolean
28
+ keepMenuIfSendFailed: boolean
64
29
 
65
- sendAs: SendAs
30
+ // 发送
31
+ sendAs: 'record' | 'audio' | 'file'
66
32
  forceTranscode: boolean
33
+ maxSongDuration: number // 分钟,0=不限制
34
+ userAgent: string
35
+ generationTip: string
36
+ }
67
37
 
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
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'],
81
47
  }
82
48
 
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 {
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
+
67
+ export const Config: Schema<Config> = Schema.object({
68
+ commandName: Schema.string().description('指令名称').default('听歌'),
69
+ commandAlias: Schema.string().description('指令别名').default('music'),
70
+
71
+ apiBase: Schema.string()
72
+ .description('音乐 API 地址(GD音乐台 API)')
73
+ .default('https://music-api.gdstudio.xyz/api.php'),
74
+
75
+ // ✅ 后台显示品牌名(你要求的)
76
+ source: Schema.union([
77
+ Schema.const('netease').description('网易云'),
78
+ Schema.const('tencent').description('QQ音乐'),
79
+ Schema.const('kugou').description('酷狗'),
80
+ Schema.const('kuwo').description('酷我'),
81
+ Schema.const('migu').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),
86
+
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),
91
+
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),
97
+
98
+ sendAs: Schema.union([
99
+ Schema.const('record').description('语音 record(推荐)'),
100
+ Schema.const('audio').description('音频 audio'),
101
+ Schema.const('file').description('文件 file'),
102
+ ]).description('发送类型').default('record'),
103
+
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),
107
+
108
+ userAgent: Schema.string().description('请求 UA(部分环境可避免风控/403)').default('koishi-music-to-voice/1.0'),
109
+ generationTip: Schema.string().description('选择序号后发送的提示文案').default('音乐生成中…'),
110
+ })
111
+
112
+ type SearchItem = {
153
113
  id: string
154
114
  name: string
155
- artist: string
156
- album: string
157
- source: MusicSource
115
+ artist?: string[] | string
116
+ album?: string
117
+ url_id?: string
118
+ pic_id?: string
119
+ source: string
120
+ duration?: number // 秒(有些源会返回)
158
121
  }
159
122
 
160
- // --------- 小工具 ---------
123
+ type PendingState = {
124
+ userId: string
125
+ channelId: string
126
+ keyword: string
127
+ page: number
128
+ items: SearchItem[]
129
+ menuMessageIds: string[]
130
+ tipMessageIds: string[]
131
+ timer?: NodeJS.Timeout
132
+ }
161
133
 
162
- function artistsToString(x: any): string {
163
- if (!x) return ''
164
- if (Array.isArray(x)) return x.join(' / ')
165
- return String(x)
134
+ const pending = new Map<string, PendingState>()
135
+
136
+ function ms(sec: number) {
137
+ return Math.max(1, sec) * 1000
166
138
  }
167
139
 
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 {}
140
+ function keyOf(session: Session) {
141
+ return `${session.platform}:${session.userId || 'unknown'}:${session.channelId || session.guildId || 'unknown'}`
175
142
  }
176
143
 
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 ?? ''}`
144
+ function normalizeArtist(a: any): string {
145
+ if (!a) return ''
146
+ if (Array.isArray(a)) return a.join(' / ')
147
+ return String(a)
181
148
  }
182
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' },
150
+ function formatMenu(state: PendingState, config: Config) {
151
+ const lines: string[] = []
152
+ lines.push(`点歌列表(第 ${state.page} 页)`)
153
+ lines.push(`关键词:${state.keyword}`)
154
+ lines.push('')
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}` : ''}`)
188
159
  })
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)
160
+ lines.push('')
161
+ lines.push(`请在 ${config.waitForTimeout} 秒内输入歌曲序号`)
162
+ lines.push(`翻页:${config.prevPageCommand} / ${config.nextPageCommand}`)
163
+ if (config.menuExitCommandTip) lines.push(`退出:${config.exitCommandList.join(' / ')}`)
164
+ return lines.join('\n')
165
+ }
166
+
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] : []
199
171
  }
200
172
 
201
- async function gdUrl(ctx: Context, config: Config, item: SongItem): Promise<string> {
202
- const data = await ctx.http.get(config.apiBase, {
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)
180
+ }
181
+
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',
203
194
  timeout: 15000,
204
- params: { types: 'url', source: item.source || config.source, id: item.id },
205
- headers: { 'user-agent': 'koishi-music-to-voice' },
206
195
  })
196
+ if (!Array.isArray(data)) throw new Error('search response is not array')
197
+ return data as SearchItem[]
198
+ }
207
199
 
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 ''
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
212
216
  }
213
217
 
214
- async function downloadToTemp(ctx: Context, src: string): Promise<string | null> {
215
- try {
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
223
- }
218
+ function tmpFile(ext: string) {
219
+ const id = crypto.randomBytes(8).toString('hex')
220
+ return path.join(os.tmpdir(), `koishi-music-${id}.${ext}`)
224
221
  }
225
222
 
226
- async function tryTranscode(ctx: Context, localPath: string): Promise<string> {
227
- // ✅ 完全可选:缺依赖就原样返回
223
+ async function downloadToFile(ctx: Context, config: Config, url: string, filePath: string) {
228
224
  const anyCtx = ctx as any
229
- if (!anyCtx.ffmpeg || !anyCtx.silk) return localPath
230
-
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 {}
225
+ if (anyCtx.downloads?.download) {
226
+ await anyCtx.downloads.download(url, filePath, {
227
+ headers: { 'user-agent': config.userAgent },
228
+ })
229
+ return
241
230
  }
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))
242
237
  }
243
238
 
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
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
+ })
254
245
  }
255
246
 
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(' / ')}`
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)
262
253
  }
263
- return `音乐列表:\n${lines.join('\n')}${tail}\n\n请在 ${config.waitForTimeout} 秒内输入序号:`
254
+ throw new Error('silk service encode not available')
264
255
  }
265
256
 
266
- // --------- 主逻辑 ---------
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)
278
+ }
279
+ }
280
+ }
281
+
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
+ }
267
287
 
268
288
  export function apply(ctx: Context, config: Config) {
269
- // 频率限制记录
270
- const lastUse = new Map<string, number>()
271
-
272
- ctx.command(`${config.commandName} <keyword:text>`, '音乐聚合点歌并发送语音')
289
+ // 主命令:听歌 <keyword>
290
+ ctx.command(`${config.commandName} <keyword:text>`, '点歌并发送语音/音频')
273
291
  .alias(config.commandAlias)
274
- .action(async (argv, keyword?: string) => {
275
- const session = argv.session
276
- const options = argv.options ?? {}
277
-
278
- // ✅ 解决 “session 可能为 undefined”
292
+ .action(async ({ session }, keyword) => {
279
293
  if (!session) return
280
- keyword = (keyword ?? '').trim()
294
+ keyword = (keyword || '').trim()
281
295
  if (!keyword) return `用法:${config.commandName} 歌曲名`
282
296
 
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)
297
+ const k = keyOf(session)
298
+ const old = pending.get(k)
299
+ if (old?.timer) clearTimeout(old.timer)
300
+ pending.delete(k)
301
+
302
+ let items: SearchItem[]
303
+ try {
304
+ items = await apiSearch(ctx, config, keyword, 1)
305
+ } catch (e) {
306
+ logger.warn('search failed: %s', (e as Error).message)
307
+ return '搜索失败(API 不可用或超时),请稍后再试。'
295
308
  }
309
+ if (!items.length) return '没有搜索到结果。'
310
+
311
+ const state: PendingState = {
312
+ userId: session.userId || '',
313
+ channelId: session.channelId || '',
314
+ keyword,
315
+ page: 1,
316
+ items,
317
+ menuMessageIds: [],
318
+ tipMessageIds: [],
319
+ }
320
+ pending.set(k, state)
296
321
 
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
- }
311
-
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
- }
322
+ const menuText = formatMenu(state, config)
323
+ state.menuMessageIds = await safeSend(session, menuText)
318
324
 
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
- }
325
+ // ✅ 你说的“太快撤回”就是这里:我们默认给 60 秒,并且发送成功才撤回
326
+ if (config.menuRecallSec > 0 && !config.recallOnlyAfterSuccess) {
327
+ recall(session, state.menuMessageIds, config.menuRecallSec)
328
+ }
343
329
 
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
- }
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))
361
336
 
362
- const text = String(input).trim()
363
- if (!text) continue
337
+ return
338
+ })
364
339
 
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)
340
+ // 捕获“序号 / 翻页 / 退出”
341
+ ctx.middleware(async (session, next) => {
342
+ const k = keyOf(session)
343
+ const state = pending.get(k)
344
+ if (!state) return next()
345
+
346
+ // 只允许同一用户、同一频道继续操作
347
+ if ((session.userId || '') !== state.userId || (session.channelId || '') !== state.channelId) return next()
348
+
349
+ const content = (session.content || '').trim()
350
+ if (!content) return next()
351
+
352
+ // 退出
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('已退出歌曲选择。')
357
+ return
358
+ }
359
+
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) {
364
+ await session.send('已经是第一页。')
365
+ return
366
+ }
367
+ try {
368
+ const items = await apiSearch(ctx, config, state.keyword, target)
369
+ if (!items.length) {
370
+ await session.send('没有更多结果了。')
371
371
  return
372
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 不可用或超时)。')
382
+ }
383
+ return
384
+ }
373
385
 
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
- }
386
+ // 选择序号
387
+ const n = Number(content)
388
+ if (!Number.isInteger(n) || n < 1 || n > state.items.length) return next()
383
389
 
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
- }
390
+ if (state.timer) clearTimeout(state.timer)
396
391
 
397
- const chosen = list[local]
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)
398
395
 
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
- }
396
+ try {
397
+ const item = state.items[n - 1]
398
+ const songUrl = await apiGetSongUrl(ctx, config, item)
405
399
 
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
- }
400
+ // 最长时长控制(只有 API 返回 duration 才会生效)
401
+ if (config.maxSongDuration > 0 && item.duration && item.duration / 60 > config.maxSongDuration) {
402
+ await session.send(`该歌曲时长超出限制(>${config.maxSongDuration} 分钟),已取消发送。`)
403
+ return
404
+ }
413
405
 
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)
420
- return
421
- }
406
+ await sendSong(session, ctx, config, songUrl)
422
407
 
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
- }
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
+ }
456
413
 
457
- return
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))
458
424
  }
459
- })
425
+ return
426
+ }
427
+ })
460
428
  }