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.mjs CHANGED
@@ -1,344 +1,306 @@
1
1
  // src/index.ts
2
- import { Schema, h, Logger, isNullable } from "koishi";
3
- import os from "os";
2
+ import { Schema, h, Logger } from "koishi";
4
3
  import fs from "fs";
5
- import crypto from "crypto";
6
4
  import path from "path";
7
- import { pathToFileURL } from "url";
5
+ import os from "os";
6
+ import crypto from "crypto";
7
+ import { spawn } from "child_process";
8
8
  var name = "music-to-voice";
9
9
  var logger = new Logger(name);
10
10
  var inject = {
11
- required: ["http", "i18n"],
12
- optional: ["puppeteer", "downloads", "ffmpeg", "silk"]
11
+ optional: ["downloads", "ffmpeg", "silk", "puppeteer"]
13
12
  };
14
13
  var usage = `
15
- ---
16
-
17
- \u672C\u63D2\u4EF6\u63D0\u4F9B\u201C\u70B9\u6B4C \u2192 \u5217\u8868\u9009\u5E8F\u53F7 \u2192 \u53D1\u9001\u8BED\u97F3/\u97F3\u9891/\u6587\u4EF6\u201D\u7684\u97F3\u4E50\u805A\u5408\u80FD\u529B\u3002
14
+ ### \u70B9\u6B4C\u8BED\u97F3\uFF08\u652F\u6301\u7FFB\u9875 + \u53EF\u9009 silk/ffmpeg\uFF09
18
15
 
19
- \u6570\u636E\u6765\u6E90\uFF1A**GD\u97F3\u4E50\u53F0 API**\uFF08https://music-api.gdstudio.xyz/api.php\uFF09
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
20
17
 
21
- ---
18
+ - **puppeteer \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09**
22
19
 
23
- ## \u5F00\u542F\u63D2\u4EF6\u524D\uFF0C\u8BF7\u786E\u4FDD\u4EE5\u4E0B\u670D\u52A1\u5DF2\u7ECF\u542F\u7528\uFF08\u53EF\u9009\u5B89\u88C5\uFF09
20
+ \u6B64\u5916\u53EF\u80FD\u8FD8\u9700\u8981\u8FD9\u4E9B\u670D\u52A1\u624D\u80FD\u53D1\u9001\u8BED\u97F3\uFF1A
24
21
 
25
- \u6240\u9700\u670D\u52A1\uFF1A
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**
26
24
 
27
- - puppeteer \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF0C\u7528\u4E8E\u56FE\u7247\u6B4C\u5355\uFF09
28
-
29
- \u6B64\u5916\u53EF\u80FD\u8FD8\u9700\u8981\u8FD9\u4E9B\u670D\u52A1\u624D\u80FD\u53D1\u9001\u8BED\u97F3\uFF08\u6700\u7A33\uFF1A\u4E0B\u8F7D + \u8F6C\u7801\uFF09\uFF1A
30
-
31
- - downloads \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09
32
- - ffmpeg \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF0C\u53EF\u80FD\u4F9D\u8D56 downloads\uFF09
33
- - silk \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09
34
-
35
- ---
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
36
26
  `;
37
- var Config = Schema.intersect([
38
- Schema.object({
39
- commandName: Schema.string().default("\u542C\u6B4C").description("\u6307\u4EE4\u540D\u79F0"),
40
- commandAlias: Schema.string().default("music2").description("\u6307\u4EE4\u522B\u540D\uFF08\u907F\u514D\u547D\u4EE4\u51B2\u7A81\uFF09")
41
- }).description("\u57FA\u7840\u8BBE\u7F6E"),
42
- Schema.object({
43
- apiBase: Schema.string().default("https://music-api.gdstudio.xyz/api.php").description("GD\u97F3\u4E50\u53F0 API \u5730\u5740"),
44
- source: Schema.union([
45
- Schema.const("netease").description("\u6E901"),
46
- Schema.const("tencent").description("\u6E902"),
47
- Schema.const("kugou").description("\u6E903"),
48
- Schema.const("kuwo").description("\u6E904"),
49
- Schema.const("migu").description("\u6E905")
50
- ]).default("kuwo").description("\u97F3\u6E90\uFF08\u4E0B\u62C9\u9009\u62E9\uFF09"),
51
- searchListCount: Schema.natural().min(5).max(50).default(20).description("\u6BCF\u9875\u663E\u793A\u6761\u6570")
52
- }).description("API \u8BBE\u7F6E"),
53
- Schema.object({
54
- imageMode: Schema.boolean().default(false).description("\u56FE\u7247\u6B4C\u5355\u6A21\u5F0F\uFF08\u9700\u8981 puppeteer\uFF0C\u53EF\u9009\u5B89\u88C5\uFF09"),
55
- textColor: Schema.string().role("color").default("rgba(255,255,255,1)").description("\u56FE\u7247\u6B4C\u5355\u6587\u5B57\u989C\u8272"),
56
- backgroundColor: Schema.string().role("color").default("rgba(0,0,0,1)").description("\u56FE\u7247\u6B4C\u5355\u80CC\u666F\u989C\u8272")
57
- }).description("\u6B4C\u5355\u6837\u5F0F"),
58
- Schema.object({
59
- nextPageCommand: Schema.string().default("\u4E0B\u4E00\u9875").description("\u4E0B\u4E00\u9875\u6307\u4EE4"),
60
- prevPageCommand: Schema.string().default("\u4E0A\u4E00\u9875").description("\u4E0A\u4E00\u9875\u6307\u4EE4"),
61
- exitCommandList: Schema.array(Schema.string()).default(["0", "\u9000\u51FA", "\u4E0D\u542C\u4E86"]).description("\u9000\u51FA\u6307\u4EE4\u5217\u8868"),
62
- menuExitCommandTip: Schema.boolean().default(false).description("\u662F\u5426\u5728\u6B4C\u5355\u5C3E\u90E8\u63D0\u793A\u9000\u51FA\u6307\u4EE4"),
63
- waitForTimeout: Schema.natural().min(5).max(180).default(45).description("\u7B49\u5F85\u8F93\u5165\u5E8F\u53F7\u8D85\u65F6\uFF08\u79D2\uFF09"),
64
- generationTip: Schema.string().default("\u751F\u6210\u8BED\u97F3\u4E2D\u2026").description("\u751F\u6210\u63D0\u793A\u6587\u672C")
65
- }).description("\u4EA4\u4E92\u8BBE\u7F6E"),
66
- Schema.object({
67
- sendAs: Schema.union([
68
- Schema.const("record").description("\u8BED\u97F3 record"),
69
- Schema.const("audio").description("\u97F3\u9891 audio"),
70
- Schema.const("file").description("\u6587\u4EF6 file")
71
- ]).default("record").description("\u53D1\u9001\u7C7B\u578B"),
72
- forceTranscode: Schema.boolean().default(true).description("\u6700\u7A33\u6A21\u5F0F\uFF1A\u4E0B\u8F7D + ffmpeg + silk \u8F6C\u7801\uFF08\u53EF\u9009\u4F9D\u8D56\uFF0C\u7F3A\u4F9D\u8D56\u81EA\u52A8\u964D\u7EA7\u76F4\u94FE\uFF09")
73
- }).description("\u53D1\u9001\u8BBE\u7F6E"),
74
- Schema.object({
75
- recallMenu: Schema.boolean().default(true).description("\u64A4\u56DE\u6B4C\u5355\u6D88\u606F"),
76
- recallGeneratingTip: Schema.boolean().default(true).description("\u64A4\u56DE\u751F\u6210\u63D0\u793A\u6D88\u606F"),
77
- recallSongMessage: Schema.boolean().default(false).description("\u64A4\u56DE\u6B4C\u66F2\u6D88\u606F\uFF08\u8BED\u97F3/\u97F3\u9891/\u6587\u4EF6\uFF09"),
78
- recallTimeoutTip: Schema.boolean().default(true).description("\u64A4\u56DE\u8D85\u65F6\u63D0\u793A"),
79
- recallExitTip: Schema.boolean().default(false).description("\u64A4\u56DE\u9000\u51FA\u63D0\u793A"),
80
- recallInvalidTip: Schema.boolean().default(false).description("\u64A4\u56DE\u5E8F\u53F7\u9519\u8BEF\u63D0\u793A"),
81
- recallFetchFailTip: Schema.boolean().default(true).description("\u64A4\u56DE\u83B7\u53D6\u5931\u8D25\u63D0\u793A")
82
- }).description("\u64A4\u56DE\u8BBE\u7F6E"),
83
- Schema.object({
84
- enableRateLimit: Schema.boolean().default(false).description("\u662F\u5426\u5F00\u542F\u9891\u7387\u9650\u5236"),
85
- rateLimitScope: Schema.union([
86
- Schema.const("user").description("\u6309\u7528\u6237"),
87
- Schema.const("channel").description("\u6309\u9891\u9053"),
88
- Schema.const("platform").description("\u6309\u5E73\u53F0")
89
- ]).default("user").description("\u9650\u5236\u8303\u56F4"),
90
- rateLimitIntervalSec: Schema.natural().min(1).max(3600).default(60).description("\u95F4\u9694\u79D2\u6570")
91
- }).description("\u9891\u7387\u9650\u5236")
92
- ]);
93
- function artistsToString(x) {
94
- if (!x) return "";
95
- if (Array.isArray(x)) return x.join(" / ");
96
- return String(x);
27
+ var Config = Schema.object({
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
+ // ✅ 后台显示品牌名(你要求的)
32
+ source: Schema.union([
33
+ Schema.const("netease").description("\u7F51\u6613\u4E91"),
34
+ Schema.const("tencent").description("QQ\u97F3\u4E50"),
35
+ Schema.const("kugou").description("\u9177\u72D7"),
36
+ Schema.const("kuwo").description("\u9177\u6211"),
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),
50
+ sendAs: Schema.union([
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;
64
+ }
65
+ function keyOf(session) {
66
+ return `${session.platform}:${session.userId || "unknown"}:${session.channelId || session.guildId || "unknown"}`;
97
67
  }
98
- async function safeDelete(session, messageId) {
99
- if (!messageId) return;
68
+ function normalizeArtist(a) {
69
+ if (!a) return "";
70
+ if (Array.isArray(a)) return a.join(" / ");
71
+ return String(a);
72
+ }
73
+ function formatMenu(state, config) {
74
+ const lines = [];
75
+ lines.push(`\u70B9\u6B4C\u5217\u8868\uFF08\u7B2C ${state.page} \u9875\uFF09`);
76
+ lines.push(`\u5173\u952E\u8BCD\uFF1A${state.keyword}`);
77
+ lines.push("");
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
+ });
83
+ lines.push("");
84
+ lines.push(`\u8BF7\u5728 ${config.waitForTimeout} \u79D2\u5185\u8F93\u5165\u6B4C\u66F2\u5E8F\u53F7`);
85
+ lines.push(`\u7FFB\u9875\uFF1A${config.prevPageCommand} / ${config.nextPageCommand}`);
86
+ if (config.menuExitCommandTip) lines.push(`\u9000\u51FA\uFF1A${config.exitCommandList.join(" / ")}`);
87
+ return lines.join("\n");
88
+ }
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;
100
96
  const channelId = session.channelId;
101
97
  if (!channelId) return;
102
- try {
103
- await session.bot.deleteMessage(channelId, messageId);
104
- } catch {
105
- }
106
- }
107
- function buildRateKey(session, scope) {
108
- if (scope === "platform") return session.platform;
109
- if (scope === "channel") return `${session.platform}:${session.channelId ?? ""}`;
110
- return `${session.platform}:${session.userId ?? ""}`;
98
+ setTimeout(() => {
99
+ ids.forEach((id) => session.bot.deleteMessage(channelId, id).catch(() => {
100
+ }));
101
+ }, sec * 1e3);
111
102
  }
112
- async function gdSearch(ctx, config, keyword, page) {
113
- const data = await ctx.http.get(config.apiBase, {
114
- timeout: 15e3,
115
- params: { types: "search", source: config.source, name: keyword, count: config.searchListCount, pages: page },
116
- headers: { "user-agent": "koishi-music-to-voice" }
103
+ async function apiSearch(ctx, config, keyword, page) {
104
+ const params = new URLSearchParams({
105
+ types: "search",
106
+ source: config.source,
107
+ name: keyword,
108
+ count: String(config.searchListCount),
109
+ pages: String(page)
117
110
  });
118
- if (!Array.isArray(data)) return [];
119
- return data.map((x) => ({
120
- id: String(x.id ?? x.url_id ?? ""),
121
- name: String(x.name ?? ""),
122
- artist: artistsToString(x.artist),
123
- album: String(x.album ?? ""),
124
- source: String(x.source ?? config.source)
125
- })).filter((x) => x.id && x.name);
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;
126
119
  }
127
- async function gdUrl(ctx, config, item) {
128
- const data = await ctx.http.get(config.apiBase, {
129
- timeout: 15e3,
130
- params: { types: "url", source: item.source || config.source, id: item.id },
131
- headers: { "user-agent": "koishi-music-to-voice" }
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
132
  });
133
- if (typeof data === "string") return data;
134
- if (data?.url) return String(data.url);
135
- if (data?.data?.url) return String(data.data.url);
136
- return "";
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;
137
136
  }
138
- async function downloadToTemp(ctx, src) {
139
- try {
140
- const file = await ctx.http.file(src);
141
- const ext = ".mp3";
142
- const fp = path.join(os.tmpdir(), `music_${crypto.randomBytes(8).toString("hex")}${ext}`);
143
- fs.writeFileSync(fp, Buffer.from(file.data));
144
- return fp;
145
- } catch (e) {
146
- return null;
147
- }
137
+ function tmpFile(ext) {
138
+ const id = crypto.randomBytes(8).toString("hex");
139
+ return path.join(os.tmpdir(), `koishi-music-${id}.${ext}`);
148
140
  }
149
- async function tryTranscode(ctx, localPath) {
141
+ async function downloadToFile(ctx, config, url, filePath) {
150
142
  const anyCtx = ctx;
151
- if (!anyCtx.ffmpeg || !anyCtx.silk) return localPath;
152
- const wav = localPath.replace(/\.\w+$/, "") + ".wav";
153
- const silk = localPath.replace(/\.\w+$/, "") + ".silk";
154
- try {
155
- await anyCtx.ffmpeg.convert(localPath, wav);
156
- await anyCtx.silk.encode(wav, silk);
157
- return silk;
158
- } catch {
159
- return localPath;
160
- } finally {
161
- try {
162
- fs.existsSync(wav) && fs.unlinkSync(wav);
163
- } catch {
164
- }
143
+ if (anyCtx.downloads?.download) {
144
+ await anyCtx.downloads.download(url, filePath, {
145
+ headers: { "user-agent": config.userAgent }
146
+ });
147
+ return;
165
148
  }
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));
166
155
  }
167
- async function renderListImage(ctx, html) {
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
+ });
162
+ }
163
+ async function encodeSilk(ctx, pcmPath) {
168
164
  const anyCtx = ctx;
169
- if (!anyCtx.puppeteer) return null;
170
- const page = await anyCtx.puppeteer.page();
171
- await page.setContent(html);
172
- const el = await page.$("#song-list");
173
- if (!el) return null;
174
- const buf = await el.screenshot({});
175
- await page.close();
176
- return buf;
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);
169
+ }
170
+ throw new Error("silk service encode not available");
177
171
  }
178
- function makeListText(config, list, page) {
179
- const start = (page - 1) * config.searchListCount;
180
- const lines = list.map((s, i) => `${start + i + 1}. ${s.name} -- ${s.artist}${s.album ? " -- " + s.album : ""}`);
181
- let tail = `
182
-
183
- \u7FFB\u9875\uFF1A${config.prevPageCommand} / ${config.nextPageCommand}`;
184
- if (config.menuExitCommandTip && config.exitCommandList?.length) {
185
- tail += `
186
- \u9000\u51FA\uFF1A${config.exitCommandList.join(" / ")}`;
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 {
185
+ }
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);
193
+ }
194
+ }
187
195
  }
188
- return `\u97F3\u4E50\u5217\u8868\uFF1A
189
- ${lines.join("\n")}${tail}
190
-
191
- \u8BF7\u5728 ${config.waitForTimeout} \u79D2\u5185\u8F93\u5165\u5E8F\u53F7\uFF1A`;
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));
192
199
  }
193
200
  function apply(ctx, config) {
194
- const lastUse = /* @__PURE__ */ new Map();
195
- ctx.command(`${config.commandName} <keyword:text>`, "\u97F3\u4E50\u805A\u5408\u70B9\u6B4C\u5E76\u53D1\u9001\u8BED\u97F3").alias(config.commandAlias).action(async (argv, keyword) => {
196
- const session = argv.session;
197
- const options = argv.options ?? {};
201
+ ctx.command(`${config.commandName} <keyword:text>`, "\u70B9\u6B4C\u5E76\u53D1\u9001\u8BED\u97F3/\u97F3\u9891").alias(config.commandAlias).action(async ({ session }, keyword) => {
198
202
  if (!session) return;
199
- keyword = (keyword ?? "").trim();
203
+ keyword = (keyword || "").trim();
200
204
  if (!keyword) return `\u7528\u6CD5\uFF1A${config.commandName} \u6B4C\u66F2\u540D`;
201
- if (config.enableRateLimit) {
202
- const scope = config.rateLimitScope ?? "user";
203
- const key = buildRateKey(session, scope);
204
- const now = Date.now();
205
- const last = lastUse.get(key) ?? 0;
206
- const interval = (config.rateLimitIntervalSec ?? 60) * 1e3;
207
- if (now - last < interval) {
208
- const remain = Math.ceil((interval - (now - last)) / 1e3);
209
- return `\u64CD\u4F5C\u592A\u9891\u7E41\uFF0C\u8BF7 ${remain} \u79D2\u540E\u518D\u8BD5\u3002`;
210
- }
211
- lastUse.set(key, now);
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;
210
+ try {
211
+ items = await apiSearch(ctx, config, keyword, 1);
212
+ } catch (e) {
213
+ logger.warn("search failed: %s", e.message);
214
+ return "\u641C\u7D22\u5931\u8D25\uFF08API \u4E0D\u53EF\u7528\u6216\u8D85\u65F6\uFF09\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002";
212
215
  }
213
- let page = 1;
214
- let menuMsgId = null;
215
- while (true) {
216
- let list = [];
217
- try {
218
- list = await gdSearch(ctx, config, keyword, page);
219
- } catch (e) {
220
- logger.warn(`[search] ${String(e?.message || e)}`);
221
- const ids = await session.send("\u641C\u7D22\u5931\u8D25\uFF08API \u4E0D\u53EF\u7528\u6216\u8D85\u65F6\uFF09\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
222
- const mid = Array.isArray(ids) ? ids[0] : ids;
223
- if (config.recallFetchFailTip) await safeDelete(session, mid);
224
- return;
225
- }
226
- if (!list.length) {
227
- const ids = await session.send("\u6CA1\u6709\u641C\u7D22\u5230\u7ED3\u679C\uFF0C\u8BF7\u6362\u4E2A\u5173\u952E\u8BCD\u8BD5\u8BD5\u3002");
228
- const mid = Array.isArray(ids) ? ids[0] : ids;
229
- if (config.recallFetchFailTip) await safeDelete(session, mid);
216
+ if (!items.length) return "\u6CA1\u6709\u641C\u7D22\u5230\u7ED3\u679C\u3002";
217
+ const state = {
218
+ userId: session.userId || "",
219
+ channelId: session.channelId || "",
220
+ keyword,
221
+ page: 1,
222
+ items,
223
+ menuMessageIds: [],
224
+ tipMessageIds: []
225
+ };
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);
231
+ }
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;
239
+ });
240
+ ctx.middleware(async (session, next) => {
241
+ const k = keyOf(session);
242
+ const state = pending.get(k);
243
+ if (!state) return next();
244
+ if ((session.userId || "") !== state.userId || (session.channelId || "") !== state.channelId) return next();
245
+ const content = (session.content || "").trim();
246
+ if (!content) return next();
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");
251
+ return;
252
+ }
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");
230
257
  return;
231
258
  }
232
- if (menuMsgId && config.recallMenu) await safeDelete(session, menuMsgId);
233
- if (config.imageMode) {
234
- const text2 = makeListText(config, list, page).replace(/\n/g, "<br/>");
235
- const html = `<!doctype html><html><head><meta charset="utf-8"/><style>
236
- body{margin:0;background:${config.backgroundColor};color:${config.textColor};font-size:16px;}
237
- #song-list{padding:18px;white-space:nowrap;}
238
- </style></head><body><div id="song-list">${text2}</div></body></html>`;
239
- const buf = await renderListImage(ctx, html);
240
- if (!buf) {
241
- const ids2 = await session.send("\u56FE\u7247\u6B4C\u5355\u751F\u6210\u5931\u8D25\uFF1A\u672A\u5B89\u88C5 puppeteer \u670D\u52A1\u6216\u5176\u4E0D\u53EF\u7528\u3002");
242
- const mid = Array.isArray(ids2) ? ids2[0] : ids2;
243
- if (config.recallFetchFailTip) await safeDelete(session, mid);
259
+ try {
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");
244
263
  return;
245
264
  }
246
- const ids = await session.send([h.image(buf, "image/png")]);
247
- menuMsgId = Array.isArray(ids) ? ids[0] : ids;
248
- } else {
249
- const txt = makeListText(config, list, page);
250
- const ids = await session.send(txt);
251
- menuMsgId = Array.isArray(ids) ? ids[0] : ids;
252
- }
253
- const input = await session.prompt(
254
- (s) => {
255
- const els = s.elements ?? [];
256
- return h.select(els, "text").join("");
257
- },
258
- { timeout: config.waitForTimeout * 1e3 }
259
- );
260
- if (isNullable(input)) {
261
- if (config.recallMenu) await safeDelete(session, menuMsgId);
262
- const ids = await session.send("\u8F93\u5165\u8D85\u65F6\uFF0C\u5DF2\u53D6\u6D88\u70B9\u6B4C\u3002");
263
- const mid = Array.isArray(ids) ? ids[0] : ids;
264
- if (config.recallTimeoutTip) await safeDelete(session, mid);
265
- return;
266
- }
267
- const text = String(input).trim();
268
- if (!text) continue;
269
- if (config.exitCommandList.includes(text)) {
270
- if (config.recallMenu) await safeDelete(session, menuMsgId);
271
- const ids = await session.send("\u5DF2\u9000\u51FA\u6B4C\u66F2\u9009\u62E9\u3002");
272
- const mid = Array.isArray(ids) ? ids[0] : ids;
273
- if (config.recallExitTip) await safeDelete(session, mid);
274
- return;
275
- }
276
- if (text === config.nextPageCommand) {
277
- page += 1;
278
- continue;
279
- }
280
- if (text === config.prevPageCommand) {
281
- page = Math.max(1, page - 1);
282
- continue;
283
- }
284
- if (!/^\d+$/.test(text)) continue;
285
- const idx = parseInt(text, 10);
286
- const start = (page - 1) * config.searchListCount;
287
- const local = idx - start - 1;
288
- if (local < 0 || local >= list.length) {
289
- if (config.recallMenu) await safeDelete(session, menuMsgId);
290
- const ids = await session.send("\u5E8F\u53F7\u8F93\u5165\u9519\u8BEF\uFF0C\u5DF2\u9000\u51FA\u6B4C\u66F2\u9009\u62E9\u3002");
291
- const mid = Array.isArray(ids) ? ids[0] : ids;
292
- if (config.recallInvalidTip) await safeDelete(session, mid);
293
- return;
294
- }
295
- const chosen = list[local];
296
- let tipMsgId = null;
297
- {
298
- const ids = await session.send(config.generationTip);
299
- tipMsgId = Array.isArray(ids) ? ids[0] : ids;
300
- }
301
- let playUrl = "";
302
- try {
303
- playUrl = await gdUrl(ctx, config, chosen);
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);
304
271
  } catch (e) {
305
- logger.warn(`[url] ${String(e?.message || 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");
306
274
  }
307
- if (!playUrl) {
308
- if (config.recallGeneratingTip) await safeDelete(session, tipMsgId);
309
- if (config.recallMenu) await safeDelete(session, menuMsgId);
310
- const ids = await session.send("\u83B7\u53D6\u6B4C\u66F2\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
311
- const mid = Array.isArray(ids) ? ids[0] : ids;
312
- if (config.recallFetchFailTip) await safeDelete(session, mid);
275
+ return;
276
+ }
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`);
313
288
  return;
314
289
  }
315
- try {
316
- let finalSrc = playUrl;
317
- if (config.forceTranscode) {
318
- const localPath = await downloadToTemp(ctx, playUrl);
319
- if (localPath) {
320
- const out = await tryTranscode(ctx, localPath);
321
- finalSrc = pathToFileURL(out).href;
322
- }
323
- }
324
- let sendIds;
325
- if (config.sendAs === "file") {
326
- sendIds = await session.send([h.file(finalSrc)]);
327
- } else if (config.sendAs === "audio") {
328
- sendIds = await session.send([h.audio(finalSrc)]);
329
- } else {
330
- sendIds = await session.send([h("record", { src: finalSrc })]);
331
- }
332
- const songMsgId = Array.isArray(sendIds) ? sendIds[0] : sendIds;
333
- if (config.recallSongMessage) await safeDelete(session, songMsgId);
334
- } catch (e) {
335
- logger.warn(`[send] ${String(e?.message || e)}`);
336
- const ids = await session.send("\u53D1\u9001\u5931\u8D25\uFF08\u53EF\u80FD\u7F3A\u5C11\u8F6C\u7801\u4F9D\u8D56\u6216\u94FE\u63A5\u5931\u6548\uFF09\u3002");
337
- const mid = Array.isArray(ids) ? ids[0] : ids;
338
- if (config.recallFetchFailTip) await safeDelete(session, mid);
339
- } finally {
340
- if (config.recallGeneratingTip) await safeDelete(session, tipMsgId);
341
- if (config.recallMenu) await safeDelete(session, menuMsgId);
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));
342
304
  }
343
305
  return;
344
306
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-music-to-voice",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "音乐聚合点播语音插件:搜索歌曲、翻页选择、序号点播,可选转码发送语音。数据来源:GD音乐台 API。",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",