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/dist/index.d.mts +25 -14
- package/dist/index.d.ts +25 -14
- package/dist/index.js +311 -322
- package/dist/index.mjs +309 -322
- package/package.json +5 -3
- package/src/index.ts +384 -440
package/src/index.ts
CHANGED
|
@@ -1,516 +1,460 @@
|
|
|
1
|
-
import { Context, Schema, Logger,
|
|
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
|
|
8
|
+
export const name = 'music-to-voice'
|
|
9
9
|
const logger = new Logger(name)
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Schema.
|
|
60
|
-
Schema.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
98
|
-
album
|
|
155
|
+
artist: string
|
|
156
|
+
album: string
|
|
157
|
+
source: MusicSource
|
|
99
158
|
}
|
|
100
159
|
|
|
101
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
117
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
|
161
|
-
if (!messageId) return
|
|
214
|
+
async function downloadToTemp(ctx: Context, src: string): Promise<string | null> {
|
|
162
215
|
try {
|
|
163
|
-
await
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
170
|
-
|
|
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
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
278
|
+
// ✅ 解决 “session 可能为 undefined”
|
|
279
|
+
if (!session) return
|
|
280
|
+
keyword = (keyword ?? '').trim()
|
|
281
|
+
if (!keyword) return `用法:${config.commandName} 歌曲名`
|
|
255
282
|
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
const t = input.trim()
|
|
324
|
-
return config.exitCommandList.map(s => s.trim()).includes(t)
|
|
325
|
-
}
|
|
397
|
+
const chosen = list[local]
|
|
326
398
|
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
}
|