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/dist/index.d.mts +22 -22
- package/dist/index.d.ts +22 -22
- package/dist/index.js +260 -298
- package/dist/index.mjs +261 -299
- package/package.json +1 -1
- package/src/index.ts +352 -384
package/src/index.ts
CHANGED
|
@@ -1,460 +1,428 @@
|
|
|
1
|
-
import { Context, Schema, h, Logger, Session
|
|
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
|
|
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:
|
|
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
|
-
|
|
63
|
-
|
|
24
|
+
// 撤回策略
|
|
25
|
+
menuRecallSec: number
|
|
26
|
+
tipRecallSec: number
|
|
27
|
+
recallOnlyAfterSuccess: boolean
|
|
28
|
+
keepMenuIfSendFailed: boolean
|
|
64
29
|
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
Schema.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
Schema.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
156
|
-
album
|
|
157
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
178
|
-
if (
|
|
179
|
-
if (
|
|
180
|
-
return
|
|
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
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
227
|
-
// ✅ 完全可选:缺依赖就原样返回
|
|
223
|
+
async function downloadToFile(ctx: Context, config: Config, url: string, filePath: string) {
|
|
228
224
|
const anyCtx = ctx as any
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
294
|
+
keyword = (keyword || '').trim()
|
|
281
295
|
if (!keyword) return `用法:${config.commandName} 歌曲名`
|
|
282
296
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
363
|
-
|
|
337
|
+
return
|
|
338
|
+
})
|
|
364
339
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
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
|
}
|