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.js CHANGED
@@ -38,345 +38,307 @@ __export(index_exports, {
38
38
  });
39
39
  module.exports = __toCommonJS(index_exports);
40
40
  var import_koishi = require("koishi");
41
- var import_node_os = __toESM(require("os"));
42
41
  var import_node_fs = __toESM(require("fs"));
43
- var import_node_crypto = __toESM(require("crypto"));
44
42
  var import_node_path = __toESM(require("path"));
45
- var import_node_url = require("url");
43
+ var import_node_os = __toESM(require("os"));
44
+ var import_node_crypto = __toESM(require("crypto"));
45
+ var import_node_child_process = require("child_process");
46
46
  var name = "music-to-voice";
47
47
  var logger = new import_koishi.Logger(name);
48
48
  var inject = {
49
- required: ["http", "i18n"],
50
- optional: ["puppeteer", "downloads", "ffmpeg", "silk"]
49
+ optional: ["downloads", "ffmpeg", "silk", "puppeteer"]
51
50
  };
52
51
  var usage = `
53
- ---
54
-
55
- \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
52
+ ### \u70B9\u6B4C\u8BED\u97F3\uFF08\u652F\u6301\u7FFB\u9875 + \u53EF\u9009 silk/ffmpeg\uFF09
56
53
 
57
- \u6570\u636E\u6765\u6E90\uFF1A**GD\u97F3\u4E50\u53F0 API**\uFF08https://music-api.gdstudio.xyz/api.php\uFF09
54
+ \u5F00\u542F\u63D2\u4EF6\u524D\uFF0C\u8BF7\u786E\u4FDD\u4EE5\u4E0B\u670D\u52A1\u5DF2\u7ECF\u542F\u7528\uFF08\u53EF\u9009\u5B89\u88C5\uFF09\uFF1A
58
55
 
59
- ---
56
+ - **puppeteer \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09**
60
57
 
61
- ## \u5F00\u542F\u63D2\u4EF6\u524D\uFF0C\u8BF7\u786E\u4FDD\u4EE5\u4E0B\u670D\u52A1\u5DF2\u7ECF\u542F\u7528\uFF08\u53EF\u9009\u5B89\u88C5\uFF09
58
+ \u6B64\u5916\u53EF\u80FD\u8FD8\u9700\u8981\u8FD9\u4E9B\u670D\u52A1\u624D\u80FD\u53D1\u9001\u8BED\u97F3\uFF1A
62
59
 
63
- \u6240\u9700\u670D\u52A1\uFF1A
60
+ - **ffmpeg \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09**\uFF08\u6B64\u670D\u52A1\u53EF\u80FD\u989D\u5916\u4F9D\u8D56 **downloads** \u670D\u52A1\uFF09
61
+ - **silk \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09**
64
62
 
65
- - puppeteer \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF0C\u7528\u4E8E\u56FE\u7247\u6B4C\u5355\uFF09
66
-
67
- \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
68
-
69
- - downloads \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09
70
- - ffmpeg \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF0C\u53EF\u80FD\u4F9D\u8D56 downloads\uFF09
71
- - silk \u670D\u52A1\uFF08\u53EF\u9009\u5B89\u88C5\uFF09
72
-
73
- ---
63
+ > \u672C\u63D2\u4EF6\u4F7F\u7528\u97F3\u4E50\u805A\u5408\u63A5\u53E3\uFF08GD\u97F3\u4E50\u53F0 API\uFF09\uFF1Ahttps://music-api.gdstudio.xyz/api.php
74
64
  `;
75
- var Config = import_koishi.Schema.intersect([
76
- import_koishi.Schema.object({
77
- commandName: import_koishi.Schema.string().default("\u542C\u6B4C").description("\u6307\u4EE4\u540D\u79F0"),
78
- commandAlias: import_koishi.Schema.string().default("music2").description("\u6307\u4EE4\u522B\u540D\uFF08\u907F\u514D\u547D\u4EE4\u51B2\u7A81\uFF09")
79
- }).description("\u57FA\u7840\u8BBE\u7F6E"),
80
- import_koishi.Schema.object({
81
- apiBase: import_koishi.Schema.string().default("https://music-api.gdstudio.xyz/api.php").description("GD\u97F3\u4E50\u53F0 API \u5730\u5740"),
82
- source: import_koishi.Schema.union([
83
- import_koishi.Schema.const("netease").description("\u6E901"),
84
- import_koishi.Schema.const("tencent").description("\u6E902"),
85
- import_koishi.Schema.const("kugou").description("\u6E903"),
86
- import_koishi.Schema.const("kuwo").description("\u6E904"),
87
- import_koishi.Schema.const("migu").description("\u6E905")
88
- ]).default("kuwo").description("\u97F3\u6E90\uFF08\u4E0B\u62C9\u9009\u62E9\uFF09"),
89
- searchListCount: import_koishi.Schema.natural().min(5).max(50).default(20).description("\u6BCF\u9875\u663E\u793A\u6761\u6570")
90
- }).description("API \u8BBE\u7F6E"),
91
- import_koishi.Schema.object({
92
- imageMode: import_koishi.Schema.boolean().default(false).description("\u56FE\u7247\u6B4C\u5355\u6A21\u5F0F\uFF08\u9700\u8981 puppeteer\uFF0C\u53EF\u9009\u5B89\u88C5\uFF09"),
93
- textColor: import_koishi.Schema.string().role("color").default("rgba(255,255,255,1)").description("\u56FE\u7247\u6B4C\u5355\u6587\u5B57\u989C\u8272"),
94
- backgroundColor: import_koishi.Schema.string().role("color").default("rgba(0,0,0,1)").description("\u56FE\u7247\u6B4C\u5355\u80CC\u666F\u989C\u8272")
95
- }).description("\u6B4C\u5355\u6837\u5F0F"),
96
- import_koishi.Schema.object({
97
- nextPageCommand: import_koishi.Schema.string().default("\u4E0B\u4E00\u9875").description("\u4E0B\u4E00\u9875\u6307\u4EE4"),
98
- prevPageCommand: import_koishi.Schema.string().default("\u4E0A\u4E00\u9875").description("\u4E0A\u4E00\u9875\u6307\u4EE4"),
99
- exitCommandList: import_koishi.Schema.array(import_koishi.Schema.string()).default(["0", "\u9000\u51FA", "\u4E0D\u542C\u4E86"]).description("\u9000\u51FA\u6307\u4EE4\u5217\u8868"),
100
- menuExitCommandTip: import_koishi.Schema.boolean().default(false).description("\u662F\u5426\u5728\u6B4C\u5355\u5C3E\u90E8\u63D0\u793A\u9000\u51FA\u6307\u4EE4"),
101
- waitForTimeout: import_koishi.Schema.natural().min(5).max(180).default(45).description("\u7B49\u5F85\u8F93\u5165\u5E8F\u53F7\u8D85\u65F6\uFF08\u79D2\uFF09"),
102
- generationTip: import_koishi.Schema.string().default("\u751F\u6210\u8BED\u97F3\u4E2D\u2026").description("\u751F\u6210\u63D0\u793A\u6587\u672C")
103
- }).description("\u4EA4\u4E92\u8BBE\u7F6E"),
104
- import_koishi.Schema.object({
105
- sendAs: import_koishi.Schema.union([
106
- import_koishi.Schema.const("record").description("\u8BED\u97F3 record"),
107
- import_koishi.Schema.const("audio").description("\u97F3\u9891 audio"),
108
- import_koishi.Schema.const("file").description("\u6587\u4EF6 file")
109
- ]).default("record").description("\u53D1\u9001\u7C7B\u578B"),
110
- forceTranscode: import_koishi.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")
111
- }).description("\u53D1\u9001\u8BBE\u7F6E"),
112
- import_koishi.Schema.object({
113
- recallMenu: import_koishi.Schema.boolean().default(true).description("\u64A4\u56DE\u6B4C\u5355\u6D88\u606F"),
114
- recallGeneratingTip: import_koishi.Schema.boolean().default(true).description("\u64A4\u56DE\u751F\u6210\u63D0\u793A\u6D88\u606F"),
115
- recallSongMessage: import_koishi.Schema.boolean().default(false).description("\u64A4\u56DE\u6B4C\u66F2\u6D88\u606F\uFF08\u8BED\u97F3/\u97F3\u9891/\u6587\u4EF6\uFF09"),
116
- recallTimeoutTip: import_koishi.Schema.boolean().default(true).description("\u64A4\u56DE\u8D85\u65F6\u63D0\u793A"),
117
- recallExitTip: import_koishi.Schema.boolean().default(false).description("\u64A4\u56DE\u9000\u51FA\u63D0\u793A"),
118
- recallInvalidTip: import_koishi.Schema.boolean().default(false).description("\u64A4\u56DE\u5E8F\u53F7\u9519\u8BEF\u63D0\u793A"),
119
- recallFetchFailTip: import_koishi.Schema.boolean().default(true).description("\u64A4\u56DE\u83B7\u53D6\u5931\u8D25\u63D0\u793A")
120
- }).description("\u64A4\u56DE\u8BBE\u7F6E"),
121
- import_koishi.Schema.object({
122
- enableRateLimit: import_koishi.Schema.boolean().default(false).description("\u662F\u5426\u5F00\u542F\u9891\u7387\u9650\u5236"),
123
- rateLimitScope: import_koishi.Schema.union([
124
- import_koishi.Schema.const("user").description("\u6309\u7528\u6237"),
125
- import_koishi.Schema.const("channel").description("\u6309\u9891\u9053"),
126
- import_koishi.Schema.const("platform").description("\u6309\u5E73\u53F0")
127
- ]).default("user").description("\u9650\u5236\u8303\u56F4"),
128
- rateLimitIntervalSec: import_koishi.Schema.natural().min(1).max(3600).default(60).description("\u95F4\u9694\u79D2\u6570")
129
- }).description("\u9891\u7387\u9650\u5236")
130
- ]);
131
- function artistsToString(x) {
132
- if (!x) return "";
133
- if (Array.isArray(x)) return x.join(" / ");
134
- return String(x);
65
+ var Config = import_koishi.Schema.object({
66
+ commandName: import_koishi.Schema.string().description("\u6307\u4EE4\u540D\u79F0").default("\u542C\u6B4C"),
67
+ commandAlias: import_koishi.Schema.string().description("\u6307\u4EE4\u522B\u540D").default("music"),
68
+ apiBase: import_koishi.Schema.string().description("\u97F3\u4E50 API \u5730\u5740\uFF08GD\u97F3\u4E50\u53F0 API\uFF09").default("https://music-api.gdstudio.xyz/api.php"),
69
+ // ✅ 后台显示品牌名(你要求的)
70
+ source: import_koishi.Schema.union([
71
+ import_koishi.Schema.const("netease").description("\u7F51\u6613\u4E91"),
72
+ import_koishi.Schema.const("tencent").description("QQ\u97F3\u4E50"),
73
+ import_koishi.Schema.const("kugou").description("\u9177\u72D7"),
74
+ import_koishi.Schema.const("kuwo").description("\u9177\u6211"),
75
+ import_koishi.Schema.const("migu").description("\u54AA\u5495")
76
+ ]).description("\u97F3\u6E90\uFF08\u4E0B\u62C9\u9009\u62E9\uFF09").default("kuwo"),
77
+ searchListCount: import_koishi.Schema.natural().min(1).max(30).step(1).description("\u641C\u7D22\u5217\u8868\u6570\u91CF").default(20),
78
+ waitForTimeout: import_koishi.Schema.natural().min(5).max(300).step(1).description("\u7B49\u5F85\u8F93\u5165\u5E8F\u53F7\u8D85\u65F6\uFF08\u79D2\uFF09").default(45),
79
+ nextPageCommand: import_koishi.Schema.string().description("\u4E0B\u4E00\u9875\u6307\u4EE4").default("\u4E0B\u4E00\u9875"),
80
+ prevPageCommand: import_koishi.Schema.string().description("\u4E0A\u4E00\u9875\u6307\u4EE4").default("\u4E0A\u4E00\u9875"),
81
+ exitCommandList: import_koishi.Schema.array(import_koishi.Schema.string()).role("table").description("\u9000\u51FA\u6307\u4EE4\u5217\u8868\uFF08\u4E00\u884C\u4E00\u4E2A\uFF09").default(["0", "\u4E0D\u542C\u4E86", "\u9000\u51FA"]),
82
+ menuExitCommandTip: import_koishi.Schema.boolean().description("\u662F\u5426\u5728\u6B4C\u5355\u672B\u5C3E\u63D0\u793A\u9000\u51FA\u6307\u4EE4").default(false),
83
+ // ✅ 解决“太快撤回”的关键:默认 60 秒撤回歌单;并且默认“发送成功才撤回”
84
+ menuRecallSec: import_koishi.Schema.natural().min(0).max(3600).step(1).description("\u6B4C\u5355\u64A4\u56DE\u79D2\u6570\uFF080=\u4E0D\u64A4\u56DE\uFF09").default(60),
85
+ tipRecallSec: import_koishi.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),
86
+ recallOnlyAfterSuccess: import_koishi.Schema.boolean().description("\u4EC5\u5728\u53D1\u9001\u6210\u529F\u540E\u624D\u64A4\u56DE\uFF08\u63A8\u8350\u5F00\u542F\uFF09").default(true),
87
+ keepMenuIfSendFailed: import_koishi.Schema.boolean().description("\u53D1\u9001\u5931\u8D25\u65F6\u4FDD\u7559\u6B4C\u5355\uFF08\u63A8\u8350\u5F00\u542F\uFF09").default(true),
88
+ sendAs: import_koishi.Schema.union([
89
+ import_koishi.Schema.const("record").description("\u8BED\u97F3 record\uFF08\u63A8\u8350\uFF09"),
90
+ import_koishi.Schema.const("audio").description("\u97F3\u9891 audio"),
91
+ import_koishi.Schema.const("file").description("\u6587\u4EF6 file")
92
+ ]).description("\u53D1\u9001\u7C7B\u578B").default("record"),
93
+ // ✅ 装了 downloads+ffmpeg+silk 后会更稳(QQ 语音经常只认 silk)
94
+ forceTranscode: import_koishi.Schema.boolean().description("\u5F3A\u5236\u8F6C\u7801\uFF08\u9700\u8981 downloads + ffmpeg + silk\uFF1B\u66F4\u7A33\u4F46\u4F9D\u8D56\u66F4\u591A\uFF09").default(true),
95
+ maxSongDuration: import_koishi.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),
96
+ userAgent: import_koishi.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"),
97
+ generationTip: import_koishi.Schema.string().description("\u9009\u62E9\u5E8F\u53F7\u540E\u53D1\u9001\u7684\u63D0\u793A\u6587\u6848").default("\u97F3\u4E50\u751F\u6210\u4E2D\u2026")
98
+ });
99
+ var pending = /* @__PURE__ */ new Map();
100
+ function ms(sec) {
101
+ return Math.max(1, sec) * 1e3;
135
102
  }
136
- async function safeDelete(session, messageId) {
137
- if (!messageId) return;
103
+ function keyOf(session) {
104
+ return `${session.platform}:${session.userId || "unknown"}:${session.channelId || session.guildId || "unknown"}`;
105
+ }
106
+ function normalizeArtist(a) {
107
+ if (!a) return "";
108
+ if (Array.isArray(a)) return a.join(" / ");
109
+ return String(a);
110
+ }
111
+ function formatMenu(state, config) {
112
+ const lines = [];
113
+ lines.push(`\u70B9\u6B4C\u5217\u8868\uFF08\u7B2C ${state.page} \u9875\uFF09`);
114
+ lines.push(`\u5173\u952E\u8BCD\uFF1A${state.keyword}`);
115
+ lines.push("");
116
+ state.items.forEach((it, idx) => {
117
+ const n = idx + 1;
118
+ const artist = normalizeArtist(it.artist);
119
+ lines.push(`${n}. ${it.name}${artist ? ` - ${artist}` : ""}`);
120
+ });
121
+ lines.push("");
122
+ lines.push(`\u8BF7\u5728 ${config.waitForTimeout} \u79D2\u5185\u8F93\u5165\u6B4C\u66F2\u5E8F\u53F7`);
123
+ lines.push(`\u7FFB\u9875\uFF1A${config.prevPageCommand} / ${config.nextPageCommand}`);
124
+ if (config.menuExitCommandTip) lines.push(`\u9000\u51FA\uFF1A${config.exitCommandList.join(" / ")}`);
125
+ return lines.join("\n");
126
+ }
127
+ async function safeSend(session, content) {
128
+ const ids = await session.send(content);
129
+ if (Array.isArray(ids)) return ids.filter(Boolean);
130
+ return ids ? [ids] : [];
131
+ }
132
+ function recall(session, ids, sec) {
133
+ if (!ids?.length || sec <= 0) return;
138
134
  const channelId = session.channelId;
139
135
  if (!channelId) return;
140
- try {
141
- await session.bot.deleteMessage(channelId, messageId);
142
- } catch {
143
- }
136
+ setTimeout(() => {
137
+ ids.forEach((id) => session.bot.deleteMessage(channelId, id).catch(() => {
138
+ }));
139
+ }, sec * 1e3);
144
140
  }
145
- function buildRateKey(session, scope) {
146
- if (scope === "platform") return session.platform;
147
- if (scope === "channel") return `${session.platform}:${session.channelId ?? ""}`;
148
- return `${session.platform}:${session.userId ?? ""}`;
149
- }
150
- async function gdSearch(ctx, config, keyword, page) {
151
- const data = await ctx.http.get(config.apiBase, {
152
- timeout: 15e3,
153
- params: { types: "search", source: config.source, name: keyword, count: config.searchListCount, pages: page },
154
- headers: { "user-agent": "koishi-music-to-voice" }
141
+ async function apiSearch(ctx, config, keyword, page) {
142
+ const params = new URLSearchParams({
143
+ types: "search",
144
+ source: config.source,
145
+ name: keyword,
146
+ count: String(config.searchListCount),
147
+ pages: String(page)
148
+ });
149
+ const url = `${config.apiBase}?${params.toString()}`;
150
+ const data = await ctx.http.get(url, {
151
+ headers: { "user-agent": config.userAgent },
152
+ responseType: "json",
153
+ timeout: 15e3
155
154
  });
156
- if (!Array.isArray(data)) return [];
157
- return data.map((x) => ({
158
- id: String(x.id ?? x.url_id ?? ""),
159
- name: String(x.name ?? ""),
160
- artist: artistsToString(x.artist),
161
- album: String(x.album ?? ""),
162
- source: String(x.source ?? config.source)
163
- })).filter((x) => x.id && x.name);
155
+ if (!Array.isArray(data)) throw new Error("search response is not array");
156
+ return data;
164
157
  }
165
- async function gdUrl(ctx, config, item) {
166
- const data = await ctx.http.get(config.apiBase, {
167
- timeout: 15e3,
168
- params: { types: "url", source: item.source || config.source, id: item.id },
169
- headers: { "user-agent": "koishi-music-to-voice" }
158
+ async function apiGetSongUrl(ctx, config, item) {
159
+ const id = item.url_id || item.id;
160
+ const params = new URLSearchParams({
161
+ types: "url",
162
+ id: String(id),
163
+ source: item.source || config.source
170
164
  });
171
- if (typeof data === "string") return data;
172
- if (data?.url) return String(data.url);
173
- if (data?.data?.url) return String(data.data.url);
174
- return "";
165
+ const url = `${config.apiBase}?${params.toString()}`;
166
+ const data = await ctx.http.get(url, {
167
+ headers: { "user-agent": config.userAgent },
168
+ responseType: "json",
169
+ timeout: 15e3
170
+ });
171
+ const u = Array.isArray(data) ? data[0]?.url : data?.url;
172
+ if (!u) throw new Error("url not found from api");
173
+ return u;
175
174
  }
176
- async function downloadToTemp(ctx, src) {
177
- try {
178
- const file = await ctx.http.file(src);
179
- const ext = ".mp3";
180
- const fp = import_node_path.default.join(import_node_os.default.tmpdir(), `music_${import_node_crypto.default.randomBytes(8).toString("hex")}${ext}`);
181
- import_node_fs.default.writeFileSync(fp, Buffer.from(file.data));
182
- return fp;
183
- } catch (e) {
184
- return null;
185
- }
175
+ function tmpFile(ext) {
176
+ const id = import_node_crypto.default.randomBytes(8).toString("hex");
177
+ return import_node_path.default.join(import_node_os.default.tmpdir(), `koishi-music-${id}.${ext}`);
186
178
  }
187
- async function tryTranscode(ctx, localPath) {
179
+ async function downloadToFile(ctx, config, url, filePath) {
188
180
  const anyCtx = ctx;
189
- if (!anyCtx.ffmpeg || !anyCtx.silk) return localPath;
190
- const wav = localPath.replace(/\.\w+$/, "") + ".wav";
191
- const silk = localPath.replace(/\.\w+$/, "") + ".silk";
192
- try {
193
- await anyCtx.ffmpeg.convert(localPath, wav);
194
- await anyCtx.silk.encode(wav, silk);
195
- return silk;
196
- } catch {
197
- return localPath;
198
- } finally {
199
- try {
200
- import_node_fs.default.existsSync(wav) && import_node_fs.default.unlinkSync(wav);
201
- } catch {
202
- }
181
+ if (anyCtx.downloads?.download) {
182
+ await anyCtx.downloads.download(url, filePath, {
183
+ headers: { "user-agent": config.userAgent }
184
+ });
185
+ return;
203
186
  }
187
+ const buf = await ctx.http.get(url, {
188
+ headers: { "user-agent": config.userAgent },
189
+ responseType: "arraybuffer",
190
+ timeout: 3e4
191
+ });
192
+ import_node_fs.default.writeFileSync(filePath, Buffer.from(buf));
193
+ }
194
+ function runFfmpegToPcm(input, output) {
195
+ return new Promise((resolve, reject) => {
196
+ const p = (0, import_node_child_process.spawn)("ffmpeg", ["-y", "-i", input, "-ac", "1", "-ar", "48000", "-f", "s16le", output], { stdio: "ignore" });
197
+ p.on("error", reject);
198
+ p.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`ffmpeg exit code ${code}`)));
199
+ });
204
200
  }
205
- async function renderListImage(ctx, html) {
201
+ async function encodeSilk(ctx, pcmPath) {
206
202
  const anyCtx = ctx;
207
- if (!anyCtx.puppeteer) return null;
208
- const page = await anyCtx.puppeteer.page();
209
- await page.setContent(html);
210
- const el = await page.$("#song-list");
211
- if (!el) return null;
212
- const buf = await el.screenshot({});
213
- await page.close();
214
- return buf;
203
+ if (anyCtx.silk?.encode) {
204
+ const pcm = import_node_fs.default.readFileSync(pcmPath);
205
+ const out = await anyCtx.silk.encode(pcm, 48e3);
206
+ return Buffer.isBuffer(out) ? out : Buffer.from(out);
207
+ }
208
+ throw new Error("silk service encode not available");
215
209
  }
216
- function makeListText(config, list, page) {
217
- const start = (page - 1) * config.searchListCount;
218
- const lines = list.map((s, i) => `${start + i + 1}. ${s.name} -- ${s.artist}${s.album ? " -- " + s.album : ""}`);
219
- let tail = `
220
-
221
- \u7FFB\u9875\uFF1A${config.prevPageCommand} / ${config.nextPageCommand}`;
222
- if (config.menuExitCommandTip && config.exitCommandList?.length) {
223
- tail += `
224
- \u9000\u51FA\uFF1A${config.exitCommandList.join(" / ")}`;
210
+ async function sendSong(session, ctx, config, url) {
211
+ if (config.sendAs === "record" && config.forceTranscode) {
212
+ const anyCtx = ctx;
213
+ if (anyCtx.downloads && anyCtx.silk) {
214
+ try {
215
+ const inFile = tmpFile("mp3");
216
+ const pcmFile = tmpFile("pcm");
217
+ await downloadToFile(ctx, config, url, inFile);
218
+ await runFfmpegToPcm(inFile, pcmFile);
219
+ const silkBuf = await encodeSilk(ctx, pcmFile);
220
+ try {
221
+ import_node_fs.default.unlinkSync(inFile);
222
+ } catch {
223
+ }
224
+ try {
225
+ import_node_fs.default.unlinkSync(pcmFile);
226
+ } catch {
227
+ }
228
+ return await safeSend(session, (0, import_koishi.h)("record", { src: silkBuf }));
229
+ } catch (e) {
230
+ logger.warn("transcode/send record failed, fallback: %s", e.message);
231
+ }
232
+ }
225
233
  }
226
- return `\u97F3\u4E50\u5217\u8868\uFF1A
227
- ${lines.join("\n")}${tail}
228
-
229
- \u8BF7\u5728 ${config.waitForTimeout} \u79D2\u5185\u8F93\u5165\u5E8F\u53F7\uFF1A`;
234
+ if (config.sendAs === "record") return await safeSend(session, (0, import_koishi.h)("record", { src: url }));
235
+ if (config.sendAs === "audio") return await safeSend(session, import_koishi.h.audio(url));
236
+ return await safeSend(session, import_koishi.h.file(url));
230
237
  }
231
238
  function apply(ctx, config) {
232
- const lastUse = /* @__PURE__ */ new Map();
233
- ctx.command(`${config.commandName} <keyword:text>`, "\u97F3\u4E50\u805A\u5408\u70B9\u6B4C\u5E76\u53D1\u9001\u8BED\u97F3").alias(config.commandAlias).action(async (argv, keyword) => {
234
- const session = argv.session;
235
- const options = argv.options ?? {};
239
+ ctx.command(`${config.commandName} <keyword:text>`, "\u70B9\u6B4C\u5E76\u53D1\u9001\u8BED\u97F3/\u97F3\u9891").alias(config.commandAlias).action(async ({ session }, keyword) => {
236
240
  if (!session) return;
237
- keyword = (keyword ?? "").trim();
241
+ keyword = (keyword || "").trim();
238
242
  if (!keyword) return `\u7528\u6CD5\uFF1A${config.commandName} \u6B4C\u66F2\u540D`;
239
- if (config.enableRateLimit) {
240
- const scope = config.rateLimitScope ?? "user";
241
- const key = buildRateKey(session, scope);
242
- const now = Date.now();
243
- const last = lastUse.get(key) ?? 0;
244
- const interval = (config.rateLimitIntervalSec ?? 60) * 1e3;
245
- if (now - last < interval) {
246
- const remain = Math.ceil((interval - (now - last)) / 1e3);
247
- return `\u64CD\u4F5C\u592A\u9891\u7E41\uFF0C\u8BF7 ${remain} \u79D2\u540E\u518D\u8BD5\u3002`;
248
- }
249
- lastUse.set(key, now);
243
+ const k = keyOf(session);
244
+ const old = pending.get(k);
245
+ if (old?.timer) clearTimeout(old.timer);
246
+ pending.delete(k);
247
+ let items;
248
+ try {
249
+ items = await apiSearch(ctx, config, keyword, 1);
250
+ } catch (e) {
251
+ logger.warn("search failed: %s", e.message);
252
+ return "\u641C\u7D22\u5931\u8D25\uFF08API \u4E0D\u53EF\u7528\u6216\u8D85\u65F6\uFF09\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002";
250
253
  }
251
- let page = 1;
252
- let menuMsgId = null;
253
- while (true) {
254
- let list = [];
255
- try {
256
- list = await gdSearch(ctx, config, keyword, page);
257
- } catch (e) {
258
- logger.warn(`[search] ${String(e?.message || e)}`);
259
- const ids = await session.send("\u641C\u7D22\u5931\u8D25\uFF08API \u4E0D\u53EF\u7528\u6216\u8D85\u65F6\uFF09\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
260
- const mid = Array.isArray(ids) ? ids[0] : ids;
261
- if (config.recallFetchFailTip) await safeDelete(session, mid);
262
- return;
263
- }
264
- if (!list.length) {
265
- const ids = await session.send("\u6CA1\u6709\u641C\u7D22\u5230\u7ED3\u679C\uFF0C\u8BF7\u6362\u4E2A\u5173\u952E\u8BCD\u8BD5\u8BD5\u3002");
266
- const mid = Array.isArray(ids) ? ids[0] : ids;
267
- if (config.recallFetchFailTip) await safeDelete(session, mid);
254
+ if (!items.length) return "\u6CA1\u6709\u641C\u7D22\u5230\u7ED3\u679C\u3002";
255
+ const state = {
256
+ userId: session.userId || "",
257
+ channelId: session.channelId || "",
258
+ keyword,
259
+ page: 1,
260
+ items,
261
+ menuMessageIds: [],
262
+ tipMessageIds: []
263
+ };
264
+ pending.set(k, state);
265
+ const menuText = formatMenu(state, config);
266
+ state.menuMessageIds = await safeSend(session, menuText);
267
+ if (config.menuRecallSec > 0 && !config.recallOnlyAfterSuccess) {
268
+ recall(session, state.menuMessageIds, config.menuRecallSec);
269
+ }
270
+ state.timer = setTimeout(async () => {
271
+ const cur = pending.get(k);
272
+ if (!cur) return;
273
+ pending.delete(k);
274
+ await session.send("\u8F93\u5165\u8D85\u65F6\uFF0C\u5DF2\u53D6\u6D88\u70B9\u6B4C\u3002");
275
+ }, ms(config.waitForTimeout));
276
+ return;
277
+ });
278
+ ctx.middleware(async (session, next) => {
279
+ const k = keyOf(session);
280
+ const state = pending.get(k);
281
+ if (!state) return next();
282
+ if ((session.userId || "") !== state.userId || (session.channelId || "") !== state.channelId) return next();
283
+ const content = (session.content || "").trim();
284
+ if (!content) return next();
285
+ if (config.exitCommandList.map((s) => s.trim()).filter(Boolean).includes(content)) {
286
+ pending.delete(k);
287
+ if (state.timer) clearTimeout(state.timer);
288
+ await session.send("\u5DF2\u9000\u51FA\u6B4C\u66F2\u9009\u62E9\u3002");
289
+ return;
290
+ }
291
+ if (content === config.nextPageCommand || content === config.prevPageCommand) {
292
+ const target = content === config.nextPageCommand ? state.page + 1 : Math.max(1, state.page - 1);
293
+ if (target === state.page) {
294
+ await session.send("\u5DF2\u7ECF\u662F\u7B2C\u4E00\u9875\u3002");
268
295
  return;
269
296
  }
270
- if (menuMsgId && config.recallMenu) await safeDelete(session, menuMsgId);
271
- if (config.imageMode) {
272
- const text2 = makeListText(config, list, page).replace(/\n/g, "<br/>");
273
- const html = `<!doctype html><html><head><meta charset="utf-8"/><style>
274
- body{margin:0;background:${config.backgroundColor};color:${config.textColor};font-size:16px;}
275
- #song-list{padding:18px;white-space:nowrap;}
276
- </style></head><body><div id="song-list">${text2}</div></body></html>`;
277
- const buf = await renderListImage(ctx, html);
278
- if (!buf) {
279
- 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");
280
- const mid = Array.isArray(ids2) ? ids2[0] : ids2;
281
- if (config.recallFetchFailTip) await safeDelete(session, mid);
297
+ try {
298
+ const items = await apiSearch(ctx, config, state.keyword, target);
299
+ if (!items.length) {
300
+ await session.send("\u6CA1\u6709\u66F4\u591A\u7ED3\u679C\u4E86\u3002");
282
301
  return;
283
302
  }
284
- const ids = await session.send([import_koishi.h.image(buf, "image/png")]);
285
- menuMsgId = Array.isArray(ids) ? ids[0] : ids;
286
- } else {
287
- const txt = makeListText(config, list, page);
288
- const ids = await session.send(txt);
289
- menuMsgId = Array.isArray(ids) ? ids[0] : ids;
290
- }
291
- const input = await session.prompt(
292
- (s) => {
293
- const els = s.elements ?? [];
294
- return import_koishi.h.select(els, "text").join("");
295
- },
296
- { timeout: config.waitForTimeout * 1e3 }
297
- );
298
- if ((0, import_koishi.isNullable)(input)) {
299
- if (config.recallMenu) await safeDelete(session, menuMsgId);
300
- const ids = await session.send("\u8F93\u5165\u8D85\u65F6\uFF0C\u5DF2\u53D6\u6D88\u70B9\u6B4C\u3002");
301
- const mid = Array.isArray(ids) ? ids[0] : ids;
302
- if (config.recallTimeoutTip) await safeDelete(session, mid);
303
- return;
304
- }
305
- const text = String(input).trim();
306
- if (!text) continue;
307
- if (config.exitCommandList.includes(text)) {
308
- if (config.recallMenu) await safeDelete(session, menuMsgId);
309
- const ids = await session.send("\u5DF2\u9000\u51FA\u6B4C\u66F2\u9009\u62E9\u3002");
310
- const mid = Array.isArray(ids) ? ids[0] : ids;
311
- if (config.recallExitTip) await safeDelete(session, mid);
312
- return;
313
- }
314
- if (text === config.nextPageCommand) {
315
- page += 1;
316
- continue;
317
- }
318
- if (text === config.prevPageCommand) {
319
- page = Math.max(1, page - 1);
320
- continue;
321
- }
322
- if (!/^\d+$/.test(text)) continue;
323
- const idx = parseInt(text, 10);
324
- const start = (page - 1) * config.searchListCount;
325
- const local = idx - start - 1;
326
- if (local < 0 || local >= list.length) {
327
- if (config.recallMenu) await safeDelete(session, menuMsgId);
328
- const ids = await session.send("\u5E8F\u53F7\u8F93\u5165\u9519\u8BEF\uFF0C\u5DF2\u9000\u51FA\u6B4C\u66F2\u9009\u62E9\u3002");
329
- const mid = Array.isArray(ids) ? ids[0] : ids;
330
- if (config.recallInvalidTip) await safeDelete(session, mid);
331
- return;
332
- }
333
- const chosen = list[local];
334
- let tipMsgId = null;
335
- {
336
- const ids = await session.send(config.generationTip);
337
- tipMsgId = Array.isArray(ids) ? ids[0] : ids;
338
- }
339
- let playUrl = "";
340
- try {
341
- playUrl = await gdUrl(ctx, config, chosen);
303
+ state.page = target;
304
+ state.items = items;
305
+ const menuText = formatMenu(state, config);
306
+ const newIds = await safeSend(session, menuText);
307
+ state.menuMessageIds.push(...newIds);
308
+ if (config.menuRecallSec > 0 && !config.recallOnlyAfterSuccess) recall(session, newIds, config.menuRecallSec);
342
309
  } catch (e) {
343
- logger.warn(`[url] ${String(e?.message || e)}`);
310
+ logger.warn("page search failed: %s", e.message);
311
+ await session.send("\u7FFB\u9875\u5931\u8D25\uFF08API \u4E0D\u53EF\u7528\u6216\u8D85\u65F6\uFF09\u3002");
344
312
  }
345
- if (!playUrl) {
346
- if (config.recallGeneratingTip) await safeDelete(session, tipMsgId);
347
- if (config.recallMenu) await safeDelete(session, menuMsgId);
348
- const ids = await session.send("\u83B7\u53D6\u6B4C\u66F2\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
349
- const mid = Array.isArray(ids) ? ids[0] : ids;
350
- if (config.recallFetchFailTip) await safeDelete(session, mid);
313
+ return;
314
+ }
315
+ const n = Number(content);
316
+ if (!Number.isInteger(n) || n < 1 || n > state.items.length) return next();
317
+ if (state.timer) clearTimeout(state.timer);
318
+ const tipIds = await safeSend(session, config.generationTip);
319
+ state.tipMessageIds.push(...tipIds);
320
+ if (config.tipRecallSec > 0 && !config.recallOnlyAfterSuccess) recall(session, tipIds, config.tipRecallSec);
321
+ try {
322
+ const item = state.items[n - 1];
323
+ const songUrl = await apiGetSongUrl(ctx, config, item);
324
+ if (config.maxSongDuration > 0 && item.duration && item.duration / 60 > config.maxSongDuration) {
325
+ 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`);
351
326
  return;
352
327
  }
353
- try {
354
- let finalSrc = playUrl;
355
- if (config.forceTranscode) {
356
- const localPath = await downloadToTemp(ctx, playUrl);
357
- if (localPath) {
358
- const out = await tryTranscode(ctx, localPath);
359
- finalSrc = (0, import_node_url.pathToFileURL)(out).href;
360
- }
361
- }
362
- let sendIds;
363
- if (config.sendAs === "file") {
364
- sendIds = await session.send([import_koishi.h.file(finalSrc)]);
365
- } else if (config.sendAs === "audio") {
366
- sendIds = await session.send([import_koishi.h.audio(finalSrc)]);
367
- } else {
368
- sendIds = await session.send([(0, import_koishi.h)("record", { src: finalSrc })]);
369
- }
370
- const songMsgId = Array.isArray(sendIds) ? sendIds[0] : sendIds;
371
- if (config.recallSongMessage) await safeDelete(session, songMsgId);
372
- } catch (e) {
373
- logger.warn(`[send] ${String(e?.message || e)}`);
374
- 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");
375
- const mid = Array.isArray(ids) ? ids[0] : ids;
376
- if (config.recallFetchFailTip) await safeDelete(session, mid);
377
- } finally {
378
- if (config.recallGeneratingTip) await safeDelete(session, tipMsgId);
379
- if (config.recallMenu) await safeDelete(session, menuMsgId);
328
+ await sendSong(session, ctx, config, songUrl);
329
+ if (config.recallOnlyAfterSuccess) {
330
+ if (config.tipRecallSec > 0) recall(session, tipIds, 1);
331
+ if (config.menuRecallSec > 0) recall(session, state.menuMessageIds, 1);
332
+ }
333
+ pending.delete(k);
334
+ return;
335
+ } catch (e) {
336
+ logger.warn("send failed: %s", e.stack || e.message);
337
+ await session.send("\u83B7\u53D6/\u53D1\u9001\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002");
338
+ if (!config.keepMenuIfSendFailed) {
339
+ pending.delete(k);
340
+ } else {
341
+ state.timer = setTimeout(() => pending.delete(k), ms(config.waitForTimeout));
380
342
  }
381
343
  return;
382
344
  }