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.mjs CHANGED
@@ -1,366 +1,315 @@
1
1
  // src/index.ts
2
- import { Schema, Logger, h } from "koishi";
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
- var name = "music-voice-pro";
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().default("\u542C\u6B4C").description("\u6307\u4EE4\u540D\u79F0"),
12
- commandAlias: Schema.string().default("music").description("\u6307\u4EE4\u522B\u540D"),
13
- apiBase: Schema.string().default("https://music-api.gdstudio.xyz/api.php").description("\u97F3\u4E50 API \u5730\u5740\uFF08GD\u97F3\u4E50\u53F0 API\uFF09"),
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
- Schema.const("baidu").description("\u767E\u5EA6")
21
- ]).default("netease").description("\u97F3\u6E90\uFF08\u4E0B\u62C9\u9009\u62E9\uFF09"),
22
- searchListCount: Schema.number().min(5).max(50).default(20).description("\u641C\u7D22\u5217\u8868\u6570\u91CF"),
23
- waitForTimeout: Schema.number().min(10).max(180).default(45).description("\u7B49\u5F85\u8F93\u5165\u5E8F\u53F7\u8D85\u65F6\uFF08\u79D2\uFF09"),
24
- nextPageCommand: Schema.string().default("\u4E0B\u4E00\u9875").description("\u4E0B\u4E00\u9875\u6307\u4EE4"),
25
- prevPageCommand: Schema.string().default("\u4E0A\u4E00\u9875").description("\u4E0A\u4E00\u9875\u6307\u4EE4"),
26
- exitCommandList: Schema.array(String).default(["0", "\u4E0D\u542C\u4E86", "\u9000\u51FA"]).description("\u9000\u51FA\u6307\u4EE4\u5217\u8868"),
27
- menuExitCommandTip: Schema.boolean().default(false).description("\u662F\u5426\u5728\u6B4C\u5355\u672B\u5C3E\u63D0\u793A\u9000\u51FA\u6307\u4EE4"),
28
- imageMode: Schema.boolean().default(false).description("\u56FE\u7247\u6B4C\u5355\u6A21\u5F0F\uFF08\u53EF\u9009\uFF1A\u9700\u8981 puppeteer \u63D2\u4EF6\uFF0C\u5F53\u524D\u4EC5\u4FDD\u7559\u5F00\u5173\uFF09"),
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
- ]).default("record").description("\u53D1\u9001\u7C7B\u578B"),
33
- forceTranscode: Schema.boolean().default(true).description("\u662F\u5426\u5F3A\u5236\u8F6C\u7801\u4E3A silk\uFF08\u9700\u8981 ffmpeg + silk \u63D2\u4EF6\uFF09"),
34
- tempDir: Schema.string().default(path.join(os.tmpdir(), "koishi-music-voice")).description("\u4E34\u65F6\u76EE\u5F55"),
35
- cacheMinutes: Schema.number().min(0).max(1440).default(120).description("\u7F13\u5B58\u65F6\u957F\uFF08\u5206\u949F\uFF0C0=\u4E0D\u7F13\u5B58\uFF09"),
36
- generationTip: Schema.string().default("\u751F\u6210\u8BED\u97F3\u4E2D...").description("\u7528\u6237\u9009\u6B4C\u540E\u63D0\u793A"),
37
- recallSearchMenuMessage: Schema.boolean().default(true).description("\u64A4\u56DE\uFF1A\u6B4C\u5355\u6D88\u606F"),
38
- recallTipMessage: Schema.boolean().default(true).description("\u64A4\u56DE\uFF1A\u751F\u6210\u63D0\u793A\u6D88\u606F"),
39
- recallUserSelectMessage: Schema.boolean().default(true).description("\u64A4\u56DE\uFF1A\u7528\u6237\u8F93\u5165\u7684\u5E8F\u53F7\u6D88\u606F"),
40
- recallVoiceMessage: Schema.boolean().default(false).description("\u64A4\u56DE\uFF1A\u8BED\u97F3\u6D88\u606F"),
41
- loggerinfo: Schema.boolean().default(false).description("\u65E5\u5FD7\u8C03\u8BD5\u6A21\u5F0F")
42
- }).description("\u70B9\u6B4C\u8BED\u97F3\uFF08\u652F\u6301\u7FFB\u9875 + \u53EF\u9009 silk/ffmpeg\uFF09");
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 md5(s) {
60
- return crypto.createHash("md5").update(s).digest("hex");
65
+ function keyOf(session) {
66
+ return `${session.platform}:${session.userId || "unknown"}:${session.channelId || session.guildId || "unknown"}`;
61
67
  }
62
- function ensureDir(p) {
63
- fs.mkdirSync(p, { recursive: true });
64
- }
65
- async function tryRecall(session, messageId) {
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 hRecord(src) {
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(`NetEase Music:`);
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
- for (let i = 0; i < list.length; i++) {
85
- const it = list[i];
86
- const meta = [it.artist, it.album].filter(Boolean).join(" - ");
87
- lines.push(`${i + 1}. ${it.name}${meta ? ` -- ${meta}` : ""}`);
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\uFF081-${list.length}\uFF09`);
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 apiSearch(config, keyword, page = 1) {
100
- const params = {
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 sendVoiceByUrl(session, config, audioUrl) {
125
- const seg = config.sendAs === "record" ? hRecord(audioUrl) : hAudio(audioUrl);
126
- const ids = await session.send(seg);
127
- return Array.isArray(ids) ? ids[0] : ids;
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
- async function sendVoiceByFile(session, config, absPath) {
130
- const url = `file://${absPath.replace(/\\/g, "/")}`;
131
- const seg = config.sendAs === "record" ? hRecord(url) : hAudio(url);
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 buildSilkIfPossible(ctx, config, audioUrl, cacheKey) {
136
- const hasFfmpeg = !!ctx.ffmpeg;
137
- const hasSilk = !!ctx.silk;
138
- if (!hasFfmpeg || !hasSilk) return null;
139
- ensureDir(config.tempDir);
140
- const silkPath = path.join(config.tempDir, `${cacheKey}.silk`);
141
- if (config.cacheMinutes > 0 && fs.existsSync(silkPath)) {
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
- return silkPath;
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 logDepsHint(ctx) {
187
- const hasPuppeteer = !!ctx.puppeteer;
188
- const hasFfmpeg = !!ctx.ffmpeg;
189
- const hasSilk = !!ctx.silk;
190
- const hasDownloads = !!ctx.downloads;
191
- logger.info("\u5F00\u542F\u63D2\u4EF6\u524D\uFF0C\u8BF7\u786E\u4FDD\u4EE5\u4E0B\u670D\u52A1\u5DF2\u7ECF\u542F\u7528\uFF08\u53EF\u9009\u5B89\u88C5\uFF09\uFF1A");
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 apply(ctx, config) {
200
- if (config.loggerinfo) logger.level = Logger.DEBUG;
201
- logDepsHint(ctx);
202
- const pending = /* @__PURE__ */ new Map();
203
- function getKey(session) {
204
- return String(session?.channelId || "");
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
- async function handlePick(session, state, pickIndex) {
230
- const item = state.list[pickIndex];
231
- if (!item) {
232
- await session.send(`\u5E8F\u53F7\u65E0\u6548\uFF0C\u8BF7\u8F93\u5165 1-${state.list.length}\uFF0C\u6216\u8F93\u5165 ${config.exitCommandList.join("/")} \u9000\u51FA\u3002`);
233
- return;
234
- }
235
- let tipId;
236
- if (!config.recallTipMessage && config.generationTip?.trim()) {
237
- const ids = await session.send(config.generationTip);
238
- tipId = Array.isArray(ids) ? ids[0] : ids;
239
- }
240
- let songUrl = "";
241
- try {
242
- songUrl = await apiGetSongUrl(config, item.id);
243
- if (!songUrl) throw new Error("empty url");
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
- voiceId = await sendVoiceByUrl(session, config, songUrl);
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
- ctx.command(`${config.commandName} <keyword:text>`, "\u70B9\u6B4C\u5E76\u53D1\u9001\u8BED\u97F3\uFF08GD\u97F3\u4E50\u53F0 API\uFF09").alias(config.commandAlias).action(async ({ session }, keyword) => {
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 userId = session.userId;
282
- const channelId = session.channelId;
283
- if (!userId || !channelId) {
284
- await session.send("\u5F53\u524D\u9002\u914D\u5668\u672A\u63D0\u4F9B userId/channelId\uFF0C\u65E0\u6CD5\u8FDB\u5165\u9009\u6B4C\u6A21\u5F0F\u3002");
285
- return;
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
- list = await apiSearch(config, keyword, 1);
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 (!list.length) return "\u6CA1\u6709\u641C\u5230\u7ED3\u679C\uFF0C\u6362\u4E2A\u5173\u952E\u8BCD\u8BD5\u8BD5\u3002";
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
- list,
301
- expiresAt: Date.now() + config.waitForTimeout * 1e3
222
+ items,
223
+ menuMessageIds: [],
224
+ tipMessageIds: []
302
225
  };
303
- if (!config.recallSearchMenuMessage) {
304
- const text = buildMenuText(config, keyword, list, 1);
305
- const ids = await session.send(text);
306
- state.menuMessageId = Array.isArray(ids) ? ids[0] : ids;
307
- } else {
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
- pending.set(channelId, state);
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 key = getKey(session);
314
- if (!key) return next();
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 (isExit(content)) {
325
- if (config.recallSearchMenuMessage) await tryRecall(session, state.menuMessageId);
326
- pending.delete(key);
327
- if (!config.recallUserSelectMessage) await session.send("\u5DF2\u9000\u51FA\u9009\u6B4C\u3002");
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
- if (config.recallUserSelectMessage) await tryRecall(session, session.messageId);
332
- state.page += 1;
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 refreshMenu(session, state);
335
- } catch {
336
- state.page -= 1;
337
- await session.send("\u7FFB\u9875\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
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
- if (content === config.prevPageCommand) {
342
- if (config.recallUserSelectMessage) await tryRecall(session, session.messageId);
343
- if (state.page <= 1) {
344
- await session.send("\u5DF2\u7ECF\u662F\u7B2C\u4E00\u9875\u3002");
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
- state.page -= 1;
348
- try {
349
- await refreshMenu(session, state);
350
- } catch {
351
- state.page += 1;
352
- await session.send("\u7FFB\u9875\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
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
- name
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.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.7.7"
41
+ "axios": "^1.13.4"
42
42
  },
43
43
  "devDependencies": {
44
- "koishi": "^4.18.0",
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
  },