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