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/dist/index.d.mts +24 -13
- package/dist/index.d.ts +24 -13
- package/dist/index.js +247 -296
- package/dist/index.mjs +245 -296
- package/package.json +5 -3
- package/src/index.ts +296 -384
package/src/index.ts
CHANGED
|
@@ -1,516 +1,428 @@
|
|
|
1
|
-
import { Context, Schema, Logger,
|
|
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
|
|
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:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// 提示与撤回
|
|
33
|
+
maxSongDuration: number // 分钟,0=不限制
|
|
34
|
+
userAgent: string
|
|
42
35
|
generationTip: string
|
|
43
|
-
|
|
44
|
-
recallTipMessage: boolean
|
|
45
|
-
recallUserSelectMessage: boolean
|
|
46
|
-
recallVoiceMessage: boolean
|
|
36
|
+
}
|
|
47
37
|
|
|
48
|
-
|
|
49
|
-
|
|
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().
|
|
54
|
-
commandAlias: Schema.string().
|
|
68
|
+
commandName: Schema.string().description('指令名称').default('听歌'),
|
|
69
|
+
commandAlias: Schema.string().description('指令别名').default('music'),
|
|
55
70
|
|
|
56
|
-
apiBase: Schema.string()
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
searchListCount: Schema.
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
170
|
-
return
|
|
140
|
+
function keyOf(session: Session) {
|
|
141
|
+
return `${session.platform}:${session.userId || 'unknown'}:${session.channelId || session.guildId || 'unknown'}`
|
|
171
142
|
}
|
|
172
143
|
|
|
173
|
-
function
|
|
174
|
-
return
|
|
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
|
|
150
|
+
function formatMenu(state: PendingState, config: Config) {
|
|
178
151
|
const lines: string[] = []
|
|
179
|
-
lines.push(
|
|
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
|
-
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
lines.push(`${
|
|
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}
|
|
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
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
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
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
302
|
+
let items: SearchItem[]
|
|
423
303
|
try {
|
|
424
|
-
|
|
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 (!
|
|
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
|
-
|
|
437
|
-
|
|
316
|
+
items,
|
|
317
|
+
menuMessageIds: [],
|
|
318
|
+
tipMessageIds: [],
|
|
438
319
|
}
|
|
320
|
+
pending.set(k, state)
|
|
439
321
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
|
454
|
-
|
|
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 (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
495
|
-
if (state.page
|
|
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
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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.
|
|
388
|
+
if (!Number.isInteger(n) || n < 1 || n > state.items.length) return next()
|
|
512
389
|
|
|
513
|
-
if (
|
|
514
|
-
|
|
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
|
}
|