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/dist/index.mjs
CHANGED
|
@@ -1,366 +1,315 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { Schema,
|
|
3
|
-
import axios from "axios";
|
|
2
|
+
import { Schema, h, Logger } from "koishi";
|
|
4
3
|
import fs from "fs";
|
|
5
4
|
import path from "path";
|
|
6
5
|
import os from "os";
|
|
7
6
|
import crypto from "crypto";
|
|
8
|
-
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
var name = "music-to-voice";
|
|
9
9
|
var logger = new Logger(name);
|
|
10
|
+
var inject = {
|
|
11
|
+
optional: ["downloads", "ffmpeg", "silk", "puppeteer"]
|
|
12
|
+
};
|
|
13
|
+
var usage = `
|
|
14
|
+
### \u70B9\u6B4C\u8BED\u97F3\uFF08\u652F\u6301\u7FFB\u9875 + \u53EF\u9009 silk/ffmpeg\uFF09
|
|
15
|
+
|
|
16
|
+
\u5F00\u542F\u63D2\u4EF6\u524D\uFF0C\u8BF7\u786E\u4FDD\u4EE5\u4E0B\u670D\u52A1\u5DF2\u7ECF\u542F\u7528\uFF08\u53EF\u9009\u5B89\u88C5\uFF09\uFF1A
|
|
17
|
+
|
|
18
|
+
- **puppeteer \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09**
|
|
19
|
+
|
|
20
|
+
\u6B64\u5916\u53EF\u80FD\u8FD8\u9700\u8981\u8FD9\u4E9B\u670D\u52A1\u624D\u80FD\u53D1\u9001\u8BED\u97F3\uFF1A
|
|
21
|
+
|
|
22
|
+
- **ffmpeg \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09**\uFF08\u6B64\u670D\u52A1\u53EF\u80FD\u989D\u5916\u4F9D\u8D56 **downloads** \u670D\u52A1\uFF09
|
|
23
|
+
- **silk \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09**
|
|
24
|
+
|
|
25
|
+
> \u672C\u63D2\u4EF6\u4F7F\u7528\u97F3\u4E50\u805A\u5408\u63A5\u53E3\uFF08GD\u97F3\u4E50\u53F0 API\uFF09\uFF1Ahttps://music-api.gdstudio.xyz/api.php
|
|
26
|
+
`;
|
|
10
27
|
var Config = Schema.object({
|
|
11
|
-
commandName: Schema.string().
|
|
12
|
-
commandAlias: Schema.string().
|
|
13
|
-
apiBase: Schema.string().
|
|
28
|
+
commandName: Schema.string().description("\u6307\u4EE4\u540D\u79F0").default("\u542C\u6B4C"),
|
|
29
|
+
commandAlias: Schema.string().description("\u6307\u4EE4\u522B\u540D").default("music"),
|
|
30
|
+
apiBase: Schema.string().description("\u97F3\u4E50 API \u5730\u5740\uFF08GD\u97F3\u4E50\u53F0 API\uFF09").default("https://music-api.gdstudio.xyz/api.php"),
|
|
31
|
+
// ✅ 后台显示品牌名(你要求的)
|
|
14
32
|
source: Schema.union([
|
|
15
33
|
Schema.const("netease").description("\u7F51\u6613\u4E91"),
|
|
16
34
|
Schema.const("tencent").description("QQ\u97F3\u4E50"),
|
|
17
35
|
Schema.const("kugou").description("\u9177\u72D7"),
|
|
18
36
|
Schema.const("kuwo").description("\u9177\u6211"),
|
|
19
|
-
Schema.const("migu").description("\u54AA\u5495")
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
Schema.const("migu").description("\u54AA\u5495")
|
|
38
|
+
]).description("\u97F3\u6E90\uFF08\u4E0B\u62C9\u9009\u62E9\uFF09").default("kuwo"),
|
|
39
|
+
searchListCount: Schema.natural().min(1).max(30).step(1).description("\u641C\u7D22\u5217\u8868\u6570\u91CF").default(20),
|
|
40
|
+
waitForTimeout: Schema.natural().min(5).max(300).step(1).description("\u7B49\u5F85\u8F93\u5165\u5E8F\u53F7\u8D85\u65F6\uFF08\u79D2\uFF09").default(45),
|
|
41
|
+
nextPageCommand: Schema.string().description("\u4E0B\u4E00\u9875\u6307\u4EE4").default("\u4E0B\u4E00\u9875"),
|
|
42
|
+
prevPageCommand: Schema.string().description("\u4E0A\u4E00\u9875\u6307\u4EE4").default("\u4E0A\u4E00\u9875"),
|
|
43
|
+
exitCommandList: Schema.array(Schema.string()).role("table").description("\u9000\u51FA\u6307\u4EE4\u5217\u8868\uFF08\u4E00\u884C\u4E00\u4E2A\uFF09").default(["0", "\u4E0D\u542C\u4E86", "\u9000\u51FA"]),
|
|
44
|
+
menuExitCommandTip: Schema.boolean().description("\u662F\u5426\u5728\u6B4C\u5355\u672B\u5C3E\u63D0\u793A\u9000\u51FA\u6307\u4EE4").default(false),
|
|
45
|
+
// ✅ 解决“太快撤回”的关键:默认 60 秒撤回歌单;并且默认“发送成功才撤回”
|
|
46
|
+
menuRecallSec: Schema.natural().min(0).max(3600).step(1).description("\u6B4C\u5355\u64A4\u56DE\u79D2\u6570\uFF080=\u4E0D\u64A4\u56DE\uFF09").default(60),
|
|
47
|
+
tipRecallSec: Schema.natural().min(0).max(3600).step(1).description("\u201C\u751F\u6210\u4E2D\u201D\u63D0\u793A\u64A4\u56DE\u79D2\u6570\uFF080=\u4E0D\u64A4\u56DE\uFF09").default(10),
|
|
48
|
+
recallOnlyAfterSuccess: Schema.boolean().description("\u4EC5\u5728\u53D1\u9001\u6210\u529F\u540E\u624D\u64A4\u56DE\uFF08\u63A8\u8350\u5F00\u542F\uFF09").default(true),
|
|
49
|
+
keepMenuIfSendFailed: Schema.boolean().description("\u53D1\u9001\u5931\u8D25\u65F6\u4FDD\u7559\u6B4C\u5355\uFF08\u63A8\u8350\u5F00\u542F\uFF09").default(true),
|
|
29
50
|
sendAs: Schema.union([
|
|
30
|
-
Schema.const("record").description("\u8BED\u97F3 record"),
|
|
31
|
-
Schema.const("audio").description("\u97F3\u9891 audio")
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
function safeText(x) {
|
|
44
|
-
return typeof x === "string" ? x : x == null ? "" : String(x);
|
|
45
|
-
}
|
|
46
|
-
function normalizeSearchList(data) {
|
|
47
|
-
const arr = Array.isArray(data) ? data : Array.isArray(data?.result) ? data.result : Array.isArray(data?.data) ? data.data : Array.isArray(data?.songs) ? data.songs : [];
|
|
48
|
-
return arr.map((it) => {
|
|
49
|
-
const id = safeText(it?.id ?? it?.songid ?? it?.rid ?? it?.hash ?? it?.mid);
|
|
50
|
-
const name2 = safeText(it?.name ?? it?.songname ?? it?.title);
|
|
51
|
-
const artist = safeText(it?.artist) || safeText(it?.singer) || safeText(it?.author) || (Array.isArray(it?.artists) ? it.artists.map((a) => safeText(a?.name)).filter(Boolean).join("/") : "");
|
|
52
|
-
const album = safeText(it?.album ?? it?.albummid ?? it?.albumname);
|
|
53
|
-
return { id, name: name2, artist, album };
|
|
54
|
-
}).filter((x) => x.id && x.name);
|
|
55
|
-
}
|
|
56
|
-
function normalizeUrl(data) {
|
|
57
|
-
return safeText(data?.url) || safeText(data?.data?.url) || safeText(data?.result?.url) || safeText(data?.data) || "";
|
|
51
|
+
Schema.const("record").description("\u8BED\u97F3 record\uFF08\u63A8\u8350\uFF09"),
|
|
52
|
+
Schema.const("audio").description("\u97F3\u9891 audio"),
|
|
53
|
+
Schema.const("file").description("\u6587\u4EF6 file")
|
|
54
|
+
]).description("\u53D1\u9001\u7C7B\u578B").default("record"),
|
|
55
|
+
// ✅ 装了 downloads+ffmpeg+silk 后会更稳(QQ 语音经常只认 silk)
|
|
56
|
+
forceTranscode: Schema.boolean().description("\u5F3A\u5236\u8F6C\u7801\uFF08\u9700\u8981 downloads + ffmpeg + silk\uFF1B\u66F4\u7A33\u4F46\u4F9D\u8D56\u66F4\u591A\uFF09").default(true),
|
|
57
|
+
maxSongDuration: Schema.natural().min(0).max(180).step(1).description("\u6B4C\u66F2\u6700\u957F\u65F6\u957F\uFF08\u5206\u949F\uFF0C0=\u4E0D\u9650\u5236\uFF09").default(30),
|
|
58
|
+
userAgent: Schema.string().description("\u8BF7\u6C42 UA\uFF08\u90E8\u5206\u73AF\u5883\u53EF\u907F\u514D\u98CE\u63A7/403\uFF09").default("koishi-music-to-voice/1.0"),
|
|
59
|
+
generationTip: Schema.string().description("\u9009\u62E9\u5E8F\u53F7\u540E\u53D1\u9001\u7684\u63D0\u793A\u6587\u6848").default("\u97F3\u4E50\u751F\u6210\u4E2D\u2026")
|
|
60
|
+
});
|
|
61
|
+
var pending = /* @__PURE__ */ new Map();
|
|
62
|
+
function ms(sec) {
|
|
63
|
+
return Math.max(1, sec) * 1e3;
|
|
58
64
|
}
|
|
59
|
-
function
|
|
60
|
-
return
|
|
65
|
+
function keyOf(session) {
|
|
66
|
+
return `${session.platform}:${session.userId || "unknown"}:${session.channelId || session.guildId || "unknown"}`;
|
|
61
67
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (!messageId) return;
|
|
67
|
-
try {
|
|
68
|
-
await session.bot.deleteMessage(session.channelId, messageId);
|
|
69
|
-
} catch {
|
|
70
|
-
}
|
|
68
|
+
function normalizeArtist(a) {
|
|
69
|
+
if (!a) return "";
|
|
70
|
+
if (Array.isArray(a)) return a.join(" / ");
|
|
71
|
+
return String(a);
|
|
71
72
|
}
|
|
72
|
-
function
|
|
73
|
-
return h("record", { src });
|
|
74
|
-
}
|
|
75
|
-
function hAudio(src) {
|
|
76
|
-
return h("audio", { src });
|
|
77
|
-
}
|
|
78
|
-
function buildMenuText(config, keyword, list, page) {
|
|
73
|
+
function formatMenu(state, config) {
|
|
79
74
|
const lines = [];
|
|
80
|
-
lines.push(`
|
|
81
|
-
lines.push(`\u5173\u952E\u8BCD\uFF1A${keyword}`);
|
|
82
|
-
lines.push(`\u97F3\u6E90\uFF1A${config.source} \u7B2C ${page} \u9875`);
|
|
75
|
+
lines.push(`\u70B9\u6B4C\u5217\u8868\uFF08\u7B2C ${state.page} \u9875\uFF09`);
|
|
76
|
+
lines.push(`\u5173\u952E\u8BCD\uFF1A${state.keyword}`);
|
|
83
77
|
lines.push("");
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
lines.push(`${
|
|
88
|
-
}
|
|
78
|
+
state.items.forEach((it, idx) => {
|
|
79
|
+
const n = idx + 1;
|
|
80
|
+
const artist = normalizeArtist(it.artist);
|
|
81
|
+
lines.push(`${n}. ${it.name}${artist ? ` - ${artist}` : ""}`);
|
|
82
|
+
});
|
|
89
83
|
lines.push("");
|
|
90
|
-
lines.push(`\u8BF7\u5728 ${config.waitForTimeout} \u79D2\u5185\u8F93\u5165\u5E8F\u53F7
|
|
84
|
+
lines.push(`\u8BF7\u5728 ${config.waitForTimeout} \u79D2\u5185\u8F93\u5165\u6B4C\u66F2\u5E8F\u53F7`);
|
|
91
85
|
lines.push(`\u7FFB\u9875\uFF1A${config.prevPageCommand} / ${config.nextPageCommand}`);
|
|
92
|
-
if (config.menuExitCommandTip) {
|
|
93
|
-
lines.push(`\u9000\u51FA\uFF1A${config.exitCommandList.join(" / ")}`);
|
|
94
|
-
}
|
|
95
|
-
lines.push("");
|
|
96
|
-
lines.push("\u6570\u636E\u6765\u6E90\uFF1AGD\u97F3\u4E50\u53F0 API");
|
|
86
|
+
if (config.menuExitCommandTip) lines.push(`\u9000\u51FA\uFF1A${config.exitCommandList.join(" / ")}`);
|
|
97
87
|
return lines.join("\n");
|
|
98
88
|
}
|
|
99
|
-
async function
|
|
100
|
-
const
|
|
89
|
+
async function safeSend(session, content) {
|
|
90
|
+
const ids = await session.send(content);
|
|
91
|
+
if (Array.isArray(ids)) return ids.filter(Boolean);
|
|
92
|
+
return ids ? [ids] : [];
|
|
93
|
+
}
|
|
94
|
+
function recall(session, ids, sec) {
|
|
95
|
+
if (!ids?.length || sec <= 0) return;
|
|
96
|
+
const channelId = session.channelId;
|
|
97
|
+
if (!channelId) return;
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
ids.forEach((id) => session.bot.deleteMessage(channelId, id).catch(() => {
|
|
100
|
+
}));
|
|
101
|
+
}, sec * 1e3);
|
|
102
|
+
}
|
|
103
|
+
async function apiSearch(ctx, config, keyword, page) {
|
|
104
|
+
const params = new URLSearchParams({
|
|
101
105
|
types: "search",
|
|
102
106
|
source: config.source,
|
|
103
107
|
name: keyword,
|
|
104
|
-
count: config.searchListCount,
|
|
105
|
-
pages: page
|
|
106
|
-
};
|
|
107
|
-
const { data } = await axios.get(config.apiBase, { params, timeout: 15e3 });
|
|
108
|
-
return normalizeSearchList(data);
|
|
109
|
-
}
|
|
110
|
-
async function apiGetSongUrl(config, id) {
|
|
111
|
-
const params = { types: "url", source: config.source, id };
|
|
112
|
-
const { data } = await axios.get(config.apiBase, { params, timeout: 15e3 });
|
|
113
|
-
return normalizeUrl(data);
|
|
114
|
-
}
|
|
115
|
-
async function downloadToFile(url, filePath) {
|
|
116
|
-
const res = await axios.get(url, { responseType: "stream", timeout: 3e4 });
|
|
117
|
-
await new Promise((resolve, reject) => {
|
|
118
|
-
const ws = fs.createWriteStream(filePath);
|
|
119
|
-
res.data.pipe(ws);
|
|
120
|
-
ws.on("finish", () => resolve());
|
|
121
|
-
ws.on("error", reject);
|
|
108
|
+
count: String(config.searchListCount),
|
|
109
|
+
pages: String(page)
|
|
122
110
|
});
|
|
111
|
+
const url = `${config.apiBase}?${params.toString()}`;
|
|
112
|
+
const data = await ctx.http.get(url, {
|
|
113
|
+
headers: { "user-agent": config.userAgent },
|
|
114
|
+
responseType: "json",
|
|
115
|
+
timeout: 15e3
|
|
116
|
+
});
|
|
117
|
+
if (!Array.isArray(data)) throw new Error("search response is not array");
|
|
118
|
+
return data;
|
|
123
119
|
}
|
|
124
|
-
async function
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
120
|
+
async function apiGetSongUrl(ctx, config, item) {
|
|
121
|
+
const id = item.url_id || item.id;
|
|
122
|
+
const params = new URLSearchParams({
|
|
123
|
+
types: "url",
|
|
124
|
+
id: String(id),
|
|
125
|
+
source: item.source || config.source
|
|
126
|
+
});
|
|
127
|
+
const url = `${config.apiBase}?${params.toString()}`;
|
|
128
|
+
const data = await ctx.http.get(url, {
|
|
129
|
+
headers: { "user-agent": config.userAgent },
|
|
130
|
+
responseType: "json",
|
|
131
|
+
timeout: 15e3
|
|
132
|
+
});
|
|
133
|
+
const u = Array.isArray(data) ? data[0]?.url : data?.url;
|
|
134
|
+
if (!u) throw new Error("url not found from api");
|
|
135
|
+
return u;
|
|
128
136
|
}
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
const ids = await session.send(seg);
|
|
133
|
-
return Array.isArray(ids) ? ids[0] : ids;
|
|
137
|
+
function tmpFile(ext) {
|
|
138
|
+
const id = crypto.randomBytes(8).toString("hex");
|
|
139
|
+
return path.join(os.tmpdir(), `koishi-music-${id}.${ext}`);
|
|
134
140
|
}
|
|
135
|
-
async function
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return silkPath;
|
|
143
|
-
}
|
|
144
|
-
const rawPath = path.join(config.tempDir, `${cacheKey}.src`);
|
|
145
|
-
await downloadToFile(audioUrl, rawPath);
|
|
146
|
-
const wavPath = path.join(config.tempDir, `${cacheKey}.wav`);
|
|
147
|
-
const ffmpeg = ctx.ffmpeg;
|
|
148
|
-
try {
|
|
149
|
-
if (typeof ffmpeg.convert === "function") {
|
|
150
|
-
await ffmpeg.convert(rawPath, wavPath, {
|
|
151
|
-
format: "wav",
|
|
152
|
-
audioChannels: 1,
|
|
153
|
-
audioFrequency: 24e3
|
|
154
|
-
});
|
|
155
|
-
} else if (typeof ffmpeg.exec === "function") {
|
|
156
|
-
await ffmpeg.exec(["-y", "-i", rawPath, "-ac", "1", "-ar", "24000", wavPath]);
|
|
157
|
-
} else {
|
|
158
|
-
throw new Error("ffmpeg service API not recognized");
|
|
159
|
-
}
|
|
160
|
-
} catch (e) {
|
|
161
|
-
throw new Error(`ffmpeg \u8F6C\u7801\u5931\u8D25\uFF1A${e?.message || String(e)}`);
|
|
162
|
-
}
|
|
163
|
-
const silk = ctx.silk;
|
|
164
|
-
try {
|
|
165
|
-
if (typeof silk.encode === "function") {
|
|
166
|
-
await silk.encode(wavPath, silkPath, { rate: 24e3 });
|
|
167
|
-
} else if (typeof silk.encodeWav === "function") {
|
|
168
|
-
await silk.encodeWav(wavPath, silkPath, { rate: 24e3 });
|
|
169
|
-
} else {
|
|
170
|
-
throw new Error("silk service API not recognized");
|
|
171
|
-
}
|
|
172
|
-
} catch (e) {
|
|
173
|
-
throw new Error(`silk \u7F16\u7801\u5931\u8D25\uFF1A${e?.message || String(e)}`);
|
|
174
|
-
} finally {
|
|
175
|
-
try {
|
|
176
|
-
fs.unlinkSync(rawPath);
|
|
177
|
-
} catch {
|
|
178
|
-
}
|
|
179
|
-
try {
|
|
180
|
-
fs.unlinkSync(wavPath);
|
|
181
|
-
} catch {
|
|
182
|
-
}
|
|
141
|
+
async function downloadToFile(ctx, config, url, filePath) {
|
|
142
|
+
const anyCtx = ctx;
|
|
143
|
+
if (anyCtx.downloads?.download) {
|
|
144
|
+
await anyCtx.downloads.download(url, filePath, {
|
|
145
|
+
headers: { "user-agent": config.userAgent }
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
183
148
|
}
|
|
184
|
-
|
|
149
|
+
const buf = await ctx.http.get(url, {
|
|
150
|
+
headers: { "user-agent": config.userAgent },
|
|
151
|
+
responseType: "arraybuffer",
|
|
152
|
+
timeout: 3e4
|
|
153
|
+
});
|
|
154
|
+
fs.writeFileSync(filePath, Buffer.from(buf));
|
|
185
155
|
}
|
|
186
|
-
function
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
logger.info(`- puppeteer\u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09\uFF1A${hasPuppeteer ? "\u5DF2\u68C0\u6D4B\u5230" : "\u672A\u68C0\u6D4B\u5230"}`);
|
|
193
|
-
logger.info("\u6B64\u5916\u53EF\u80FD\u8FD8\u9700\u8981\u8FD9\u4E9B\u670D\u52A1\u624D\u80FD\u53D1\u9001\u8BED\u97F3\uFF1A");
|
|
194
|
-
logger.info(`- ffmpeg\u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09\uFF08\u6B64\u670D\u52A1\u53EF\u80FD\u989D\u5916\u4F9D\u8D56downloads\u670D\u52A1\uFF09\uFF1A${hasFfmpeg ? "\u5DF2\u68C0\u6D4B\u5230" : "\u672A\u68C0\u6D4B\u5230"}`);
|
|
195
|
-
logger.info(`- silk\u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09\uFF1A${hasSilk ? "\u5DF2\u68C0\u6D4B\u5230" : "\u672A\u68C0\u6D4B\u5230"}`);
|
|
196
|
-
logger.info(`- downloads\u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09\uFF1A${hasDownloads ? "\u5DF2\u68C0\u6D4B\u5230" : "\u672A\u68C0\u6D4B\u5230"}`);
|
|
197
|
-
logger.info("Music API \u51FA\u5904\uFF1AGD\u97F3\u4E50\u53F0 API\uFF08https://music-api.gdstudio.xyz/api.php\uFF09");
|
|
156
|
+
function runFfmpegToPcm(input, output) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const p = spawn("ffmpeg", ["-y", "-i", input, "-ac", "1", "-ar", "48000", "-f", "s16le", output], { stdio: "ignore" });
|
|
159
|
+
p.on("error", reject);
|
|
160
|
+
p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg exit code ${code}`)));
|
|
161
|
+
});
|
|
198
162
|
}
|
|
199
|
-
function
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
return
|
|
205
|
-
}
|
|
206
|
-
function isExit(input) {
|
|
207
|
-
const t = input.trim();
|
|
208
|
-
return config.exitCommandList.map((s) => s.trim()).includes(t);
|
|
209
|
-
}
|
|
210
|
-
function isExpired(state) {
|
|
211
|
-
return Date.now() > state.expiresAt;
|
|
212
|
-
}
|
|
213
|
-
async function refreshMenu(session, state) {
|
|
214
|
-
const list = await apiSearch(config, state.keyword, state.page);
|
|
215
|
-
state.list = list;
|
|
216
|
-
state.expiresAt = Date.now() + config.waitForTimeout * 1e3;
|
|
217
|
-
if (config.recallSearchMenuMessage) {
|
|
218
|
-
await tryRecall(session, state.menuMessageId);
|
|
219
|
-
state.menuMessageId = void 0;
|
|
220
|
-
}
|
|
221
|
-
if (!config.recallSearchMenuMessage) {
|
|
222
|
-
const text = buildMenuText(config, state.keyword, list, state.page);
|
|
223
|
-
const ids = await session.send(text);
|
|
224
|
-
state.menuMessageId = Array.isArray(ids) ? ids[0] : ids;
|
|
225
|
-
} else {
|
|
226
|
-
await session.send(`\u5DF2\u7FFB\u5230\u7B2C ${state.page} \u9875\uFF0C\u8BF7\u76F4\u63A5\u53D1\u9001\u5E8F\u53F7\uFF081-${list.length}\uFF09`);
|
|
227
|
-
}
|
|
163
|
+
async function encodeSilk(ctx, pcmPath) {
|
|
164
|
+
const anyCtx = ctx;
|
|
165
|
+
if (anyCtx.silk?.encode) {
|
|
166
|
+
const pcm = fs.readFileSync(pcmPath);
|
|
167
|
+
const out = await anyCtx.silk.encode(pcm, 48e3);
|
|
168
|
+
return Buffer.isBuffer(out) ? out : Buffer.from(out);
|
|
228
169
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
} catch {
|
|
245
|
-
await session.send("\u83B7\u53D6\u6B4C\u66F2\u76F4\u94FE\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\uFF0C\u6216\u66F4\u6362\u97F3\u6E90\u3002");
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
const cacheKey = md5(`${config.source}:${item.id}`);
|
|
249
|
-
let voiceId;
|
|
250
|
-
try {
|
|
251
|
-
const silkPath = await buildSilkIfPossible(ctx, config, songUrl, cacheKey);
|
|
252
|
-
if (silkPath) {
|
|
253
|
-
voiceId = await sendVoiceByFile(session, config, silkPath);
|
|
254
|
-
} else {
|
|
255
|
-
if (config.forceTranscode) {
|
|
256
|
-
await session.send(
|
|
257
|
-
`\u5F53\u524D\u914D\u7F6E\u4E3A\u3010\u5F3A\u5236 silk \u8F6C\u7801\u3011\u4F46\u672A\u68C0\u6D4B\u5230 ffmpeg/silk \u670D\u52A1\u3002
|
|
258
|
-
\u8BF7\u5728 Koishi \u63D2\u4EF6\u5E02\u573A\u5B89\u88C5\u5E76\u542F\u7528\uFF1Affmpeg\u3001silk\uFF08\u53EF\u80FD\u8FD8\u9700\u8981 downloads\uFF09\u3002`
|
|
259
|
-
);
|
|
260
|
-
return;
|
|
170
|
+
throw new Error("silk service encode not available");
|
|
171
|
+
}
|
|
172
|
+
async function sendSong(session, ctx, config, url) {
|
|
173
|
+
if (config.sendAs === "record" && config.forceTranscode) {
|
|
174
|
+
const anyCtx = ctx;
|
|
175
|
+
if (anyCtx.downloads && anyCtx.silk) {
|
|
176
|
+
try {
|
|
177
|
+
const inFile = tmpFile("mp3");
|
|
178
|
+
const pcmFile = tmpFile("pcm");
|
|
179
|
+
await downloadToFile(ctx, config, url, inFile);
|
|
180
|
+
await runFfmpegToPcm(inFile, pcmFile);
|
|
181
|
+
const silkBuf = await encodeSilk(ctx, pcmFile);
|
|
182
|
+
try {
|
|
183
|
+
fs.unlinkSync(inFile);
|
|
184
|
+
} catch {
|
|
261
185
|
}
|
|
262
|
-
|
|
186
|
+
try {
|
|
187
|
+
fs.unlinkSync(pcmFile);
|
|
188
|
+
} catch {
|
|
189
|
+
}
|
|
190
|
+
return await safeSend(session, h("record", { src: silkBuf }));
|
|
191
|
+
} catch (e) {
|
|
192
|
+
logger.warn("transcode/send record failed, fallback: %s", e.message);
|
|
263
193
|
}
|
|
264
|
-
} catch (e) {
|
|
265
|
-
await session.send(`\u751F\u6210\u8BED\u97F3\u5931\u8D25\uFF1A${e?.message || String(e)}
|
|
266
|
-
\u8BF7\u68C0\u67E5 ffmpeg/silk \u63D2\u4EF6\u662F\u5426\u542F\u7528\uFF0C\u6216\u5173\u95ED\u201C\u5F3A\u5236\u8F6C\u7801\u201D\u3002`);
|
|
267
|
-
return;
|
|
268
|
-
} finally {
|
|
269
|
-
state.tipMessageId = tipId;
|
|
270
|
-
state.voiceMessageId = voiceId;
|
|
271
194
|
}
|
|
272
|
-
if (config.recallSearchMenuMessage) await tryRecall(session, state.menuMessageId);
|
|
273
|
-
if (config.recallTipMessage) await tryRecall(session, state.tipMessageId);
|
|
274
|
-
if (config.recallVoiceMessage) await tryRecall(session, state.voiceMessageId);
|
|
275
|
-
pending.delete(getKey(session));
|
|
276
195
|
}
|
|
277
|
-
|
|
196
|
+
if (config.sendAs === "record") return await safeSend(session, h("record", { src: url }));
|
|
197
|
+
if (config.sendAs === "audio") return await safeSend(session, h.audio(url));
|
|
198
|
+
return await safeSend(session, h.file(url));
|
|
199
|
+
}
|
|
200
|
+
function apply(ctx, config) {
|
|
201
|
+
ctx.command(`${config.commandName} <keyword:text>`, "\u70B9\u6B4C\u5E76\u53D1\u9001\u8BED\u97F3/\u97F3\u9891").alias(config.commandAlias).action(async ({ session }, keyword) => {
|
|
278
202
|
if (!session) return;
|
|
279
203
|
keyword = (keyword || "").trim();
|
|
280
204
|
if (!keyword) return `\u7528\u6CD5\uFF1A${config.commandName} \u6B4C\u66F2\u540D`;
|
|
281
|
-
const
|
|
282
|
-
const
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
let list = [];
|
|
205
|
+
const k = keyOf(session);
|
|
206
|
+
const old = pending.get(k);
|
|
207
|
+
if (old?.timer) clearTimeout(old.timer);
|
|
208
|
+
pending.delete(k);
|
|
209
|
+
let items;
|
|
288
210
|
try {
|
|
289
|
-
|
|
290
|
-
} catch {
|
|
211
|
+
items = await apiSearch(ctx, config, keyword, 1);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
logger.warn("search failed: %s", e.message);
|
|
291
214
|
return "\u641C\u7D22\u5931\u8D25\uFF08API \u4E0D\u53EF\u7528\u6216\u8D85\u65F6\uFF09\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002";
|
|
292
215
|
}
|
|
293
|
-
if (!
|
|
216
|
+
if (!items.length) return "\u6CA1\u6709\u641C\u7D22\u5230\u7ED3\u679C\u3002";
|
|
294
217
|
const state = {
|
|
295
|
-
userId,
|
|
296
|
-
channelId,
|
|
297
|
-
guildId: session.guildId,
|
|
218
|
+
userId: session.userId || "",
|
|
219
|
+
channelId: session.channelId || "",
|
|
298
220
|
keyword,
|
|
299
221
|
page: 1,
|
|
300
|
-
|
|
301
|
-
|
|
222
|
+
items,
|
|
223
|
+
menuMessageIds: [],
|
|
224
|
+
tipMessageIds: []
|
|
302
225
|
};
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
await session.send(`\u5DF2\u8FDB\u5165\u9009\u6B4C\u6A21\u5F0F\uFF0C\u8BF7\u76F4\u63A5\u53D1\u9001\u5E8F\u53F7\uFF081-${list.length}\uFF09\uFF0C\u6216\u53D1\u9001\u201C${config.nextPageCommand}/${config.prevPageCommand}\u201D\u7FFB\u9875\u3002`);
|
|
226
|
+
pending.set(k, state);
|
|
227
|
+
const menuText = formatMenu(state, config);
|
|
228
|
+
state.menuMessageIds = await safeSend(session, menuText);
|
|
229
|
+
if (config.menuRecallSec > 0 && !config.recallOnlyAfterSuccess) {
|
|
230
|
+
recall(session, state.menuMessageIds, config.menuRecallSec);
|
|
309
231
|
}
|
|
310
|
-
|
|
232
|
+
state.timer = setTimeout(async () => {
|
|
233
|
+
const cur = pending.get(k);
|
|
234
|
+
if (!cur) return;
|
|
235
|
+
pending.delete(k);
|
|
236
|
+
await session.send("\u8F93\u5165\u8D85\u65F6\uFF0C\u5DF2\u53D6\u6D88\u70B9\u6B4C\u3002");
|
|
237
|
+
}, ms(config.waitForTimeout));
|
|
238
|
+
return;
|
|
311
239
|
});
|
|
312
240
|
ctx.middleware(async (session, next) => {
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
const state = pending.get(key);
|
|
241
|
+
const k = keyOf(session);
|
|
242
|
+
const state = pending.get(k);
|
|
316
243
|
if (!state) return next();
|
|
317
|
-
if (session.userId !== state.userId) return next();
|
|
318
|
-
if (isExpired(state)) {
|
|
319
|
-
pending.delete(key);
|
|
320
|
-
return next();
|
|
321
|
-
}
|
|
244
|
+
if ((session.userId || "") !== state.userId || (session.channelId || "") !== state.channelId) return next();
|
|
322
245
|
const content = (session.content || "").trim();
|
|
323
246
|
if (!content) return next();
|
|
324
|
-
if (
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
247
|
+
if (config.exitCommandList.map((s) => s.trim()).filter(Boolean).includes(content)) {
|
|
248
|
+
pending.delete(k);
|
|
249
|
+
if (state.timer) clearTimeout(state.timer);
|
|
250
|
+
await session.send("\u5DF2\u9000\u51FA\u6B4C\u66F2\u9009\u62E9\u3002");
|
|
328
251
|
return;
|
|
329
252
|
}
|
|
330
|
-
if (content === config.nextPageCommand) {
|
|
331
|
-
|
|
332
|
-
state.page
|
|
253
|
+
if (content === config.nextPageCommand || content === config.prevPageCommand) {
|
|
254
|
+
const target = content === config.nextPageCommand ? state.page + 1 : Math.max(1, state.page - 1);
|
|
255
|
+
if (target === state.page) {
|
|
256
|
+
await session.send("\u5DF2\u7ECF\u662F\u7B2C\u4E00\u9875\u3002");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
333
259
|
try {
|
|
334
|
-
await
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
260
|
+
const items = await apiSearch(ctx, config, state.keyword, target);
|
|
261
|
+
if (!items.length) {
|
|
262
|
+
await session.send("\u6CA1\u6709\u66F4\u591A\u7ED3\u679C\u4E86\u3002");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
state.page = target;
|
|
266
|
+
state.items = items;
|
|
267
|
+
const menuText = formatMenu(state, config);
|
|
268
|
+
const newIds = await safeSend(session, menuText);
|
|
269
|
+
state.menuMessageIds.push(...newIds);
|
|
270
|
+
if (config.menuRecallSec > 0 && !config.recallOnlyAfterSuccess) recall(session, newIds, config.menuRecallSec);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
logger.warn("page search failed: %s", e.message);
|
|
273
|
+
await session.send("\u7FFB\u9875\u5931\u8D25\uFF08API \u4E0D\u53EF\u7528\u6216\u8D85\u65F6\uFF09\u3002");
|
|
338
274
|
}
|
|
339
275
|
return;
|
|
340
276
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
277
|
+
const n = Number(content);
|
|
278
|
+
if (!Number.isInteger(n) || n < 1 || n > state.items.length) return next();
|
|
279
|
+
if (state.timer) clearTimeout(state.timer);
|
|
280
|
+
const tipIds = await safeSend(session, config.generationTip);
|
|
281
|
+
state.tipMessageIds.push(...tipIds);
|
|
282
|
+
if (config.tipRecallSec > 0 && !config.recallOnlyAfterSuccess) recall(session, tipIds, config.tipRecallSec);
|
|
283
|
+
try {
|
|
284
|
+
const item = state.items[n - 1];
|
|
285
|
+
const songUrl = await apiGetSongUrl(ctx, config, item);
|
|
286
|
+
if (config.maxSongDuration > 0 && item.duration && item.duration / 60 > config.maxSongDuration) {
|
|
287
|
+
await session.send(`\u8BE5\u6B4C\u66F2\u65F6\u957F\u8D85\u51FA\u9650\u5236\uFF08>${config.maxSongDuration} \u5206\u949F\uFF09\uFF0C\u5DF2\u53D6\u6D88\u53D1\u9001\u3002`);
|
|
345
288
|
return;
|
|
346
289
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
290
|
+
await sendSong(session, ctx, config, songUrl);
|
|
291
|
+
if (config.recallOnlyAfterSuccess) {
|
|
292
|
+
if (config.tipRecallSec > 0) recall(session, tipIds, 1);
|
|
293
|
+
if (config.menuRecallSec > 0) recall(session, state.menuMessageIds, 1);
|
|
294
|
+
}
|
|
295
|
+
pending.delete(k);
|
|
296
|
+
return;
|
|
297
|
+
} catch (e) {
|
|
298
|
+
logger.warn("send failed: %s", e.stack || e.message);
|
|
299
|
+
await session.send("\u83B7\u53D6/\u53D1\u9001\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
|
|
300
|
+
if (!config.keepMenuIfSendFailed) {
|
|
301
|
+
pending.delete(k);
|
|
302
|
+
} else {
|
|
303
|
+
state.timer = setTimeout(() => pending.delete(k), ms(config.waitForTimeout));
|
|
353
304
|
}
|
|
354
305
|
return;
|
|
355
306
|
}
|
|
356
|
-
const n = Number(content);
|
|
357
|
-
if (!Number.isInteger(n) || n < 1 || n > state.list.length) return next();
|
|
358
|
-
if (config.recallUserSelectMessage) await tryRecall(session, session.messageId);
|
|
359
|
-
await handlePick(session, state, n - 1);
|
|
360
307
|
});
|
|
361
308
|
}
|
|
362
309
|
export {
|
|
363
310
|
Config,
|
|
364
311
|
apply,
|
|
365
|
-
|
|
312
|
+
inject,
|
|
313
|
+
name,
|
|
314
|
+
usage
|
|
366
315
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-music-to-voice",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "音乐聚合点播语音插件:搜索歌曲、翻页选择、序号点播,可选转码发送语音。数据来源:GD音乐台 API。",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -38,10 +38,12 @@
|
|
|
38
38
|
"koishi": "^4.18.0"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"axios": "^1.
|
|
41
|
+
"axios": "^1.13.4"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"
|
|
44
|
+
"@types/node": "^25.1.0",
|
|
45
|
+
"koishi": "^4.18.10",
|
|
46
|
+
"koishi-plugin-puppeteer": "^3.9.0",
|
|
45
47
|
"tsup": "^8.2.4",
|
|
46
48
|
"typescript": "^5.4.5"
|
|
47
49
|
},
|