mioku-plugin-music 1.1.0 → 2.1.0
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/README.md +2 -2
- package/index.ts +4 -4
- package/package.json +15 -4
- package/providers/applemusic-provider.ts +1 -1
- package/providers/factory.ts +1 -1
- package/render/search-list.ts +1 -1
- package/runtime-core/fallback.ts +6 -1
- package/runtime-core/message.ts +76 -0
- package/runtime-core/service.ts +59 -46
- package/runtime.ts +1 -1
- package/skills.ts +24 -7
package/README.md
CHANGED
package/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { definePlugin } from "mioki";
|
|
2
|
-
import type { AIService } from "
|
|
3
|
-
import type { ScreenshotService } from "
|
|
4
|
-
import type { ConfigService } from "
|
|
5
|
-
import type { AppleMusicServiceApi } from "
|
|
2
|
+
import type { AIService } from "mioku";
|
|
3
|
+
import type { ScreenshotService } from "mioku";
|
|
4
|
+
import type { ConfigService } from "mioku";
|
|
5
|
+
import type { AppleMusicServiceApi } from "mioku-service-applemusic";
|
|
6
6
|
import { resetMusicRuntimeState, setMusicRuntimeState } from "./runtime";
|
|
7
7
|
import { MusicPluginRuntime } from "./runtime-core/service";
|
|
8
8
|
import { MUSIC_DEFAULTS } from "./config";
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mioku-plugin-music",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "音乐插件:点歌、搜索音乐、听歌语音发送",
|
|
5
5
|
"main": "index.ts",
|
|
6
|
+
"type": "module",
|
|
6
7
|
"keywords": [
|
|
7
8
|
"mioku"
|
|
8
9
|
],
|
|
@@ -33,12 +34,22 @@
|
|
|
33
34
|
"role": "member"
|
|
34
35
|
},
|
|
35
36
|
{
|
|
36
|
-
"cmd": "
|
|
37
|
-
"desc": "
|
|
38
|
-
"usage": "
|
|
37
|
+
"cmd": "原曲 <编号>",
|
|
38
|
+
"desc": "发送上次搜索列表中的指定歌曲原曲文件",
|
|
39
|
+
"usage": "原曲1",
|
|
39
40
|
"role": "member"
|
|
40
41
|
}
|
|
41
42
|
]
|
|
42
43
|
}
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"mioku": "^0.8.0",
|
|
47
|
+
"mioki": "^0.16.0",
|
|
48
|
+
"mioku-service-applemusic": "^2.0.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"mioku": "workspace:*",
|
|
52
|
+
"mioki": "^0.16.0",
|
|
53
|
+
"mioku-service-applemusic": "workspace:*"
|
|
43
54
|
}
|
|
44
55
|
}
|
package/providers/factory.ts
CHANGED
package/render/search-list.ts
CHANGED
|
@@ -66,7 +66,7 @@ export function renderMusicSearchListHtml(search: MusicSearchResult): string {
|
|
|
66
66
|
<div class="grid gap-3">${items}</div>
|
|
67
67
|
<div class="mt-5 flex items-center justify-between text-[16px] text-slate-600 dark:text-slate-300">
|
|
68
68
|
<div>音源:${escapeHtml(providerLabel)}</div>
|
|
69
|
-
<div>发送「听1
|
|
69
|
+
<div>发送「听1」或「原曲1」试试看吧~</div>
|
|
70
70
|
</div>
|
|
71
71
|
</div>
|
|
72
72
|
`;
|
package/runtime-core/fallback.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AIService } from "
|
|
1
|
+
import type { AIService } from "mioku";
|
|
2
2
|
import { sendTextMessage } from "./message";
|
|
3
3
|
|
|
4
4
|
function normalizeErrorMessage(error: unknown): string {
|
|
@@ -25,6 +25,11 @@ export async function notifyFallback(options: {
|
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const text = options.ctx.text(options.event)?.trim() ?? "";
|
|
29
|
+
if (!text.startsWith("/")) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
const chatRuntime = options.aiService?.getChatRuntime();
|
|
29
34
|
if (chatRuntime) {
|
|
30
35
|
try {
|
package/runtime-core/message.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
2
3
|
|
|
3
4
|
function normalizeFileSource(file: string): string {
|
|
4
5
|
const value = String(file || "").trim();
|
|
@@ -100,6 +101,81 @@ export async function sendImageMessage(
|
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
export async function sendFileMessage(
|
|
105
|
+
ctx: any,
|
|
106
|
+
event: any,
|
|
107
|
+
filePath: string,
|
|
108
|
+
name: string,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const ext = path.extname(filePath);
|
|
111
|
+
const fileName = ext ? `${name}${ext}` : name;
|
|
112
|
+
const { bot, groupId, userId } = getBotAndTarget(ctx, event);
|
|
113
|
+
const sendPayload = async (source: string) => {
|
|
114
|
+
const payload: any[] = [];
|
|
115
|
+
payload.push(
|
|
116
|
+
ctx?.segment?.file
|
|
117
|
+
? ctx.segment.file(normalizeFileSource(source), { name: fileName })
|
|
118
|
+
: { type: "file", file: normalizeFileSource(source), name: fileName },
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (bot && groupId != null) {
|
|
122
|
+
await bot.sendGroupMsg(groupId, payload);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (bot && userId != null) {
|
|
126
|
+
await bot.sendPrivateMsg(userId, payload);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (typeof event?.reply === "function") {
|
|
130
|
+
await event.reply(payload);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
throw new Error("当前上下文不支持文件发送");
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const canReadLocalFile =
|
|
137
|
+
filePath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(filePath);
|
|
138
|
+
|
|
139
|
+
let fileSendError: unknown;
|
|
140
|
+
try {
|
|
141
|
+
await sendPayload(filePath);
|
|
142
|
+
return;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
fileSendError = error;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!canReadLocalFile) {
|
|
148
|
+
throw fileSendError instanceof Error
|
|
149
|
+
? fileSendError
|
|
150
|
+
: new Error(String(fileSendError || "文件发送失败"));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let buffer: Buffer;
|
|
154
|
+
try {
|
|
155
|
+
buffer = await fs.readFile(filePath);
|
|
156
|
+
} catch {
|
|
157
|
+
throw fileSendError instanceof Error
|
|
158
|
+
? fileSendError
|
|
159
|
+
: new Error(String(fileSendError || "文件发送失败"));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const base64 = `base64://${buffer.toString("base64")}`;
|
|
163
|
+
try {
|
|
164
|
+
await sendPayload(base64);
|
|
165
|
+
} catch (base64Error) {
|
|
166
|
+
const message = String(base64Error || "");
|
|
167
|
+
const timeoutLike =
|
|
168
|
+
message.includes("timeout") ||
|
|
169
|
+
message.includes("超时") ||
|
|
170
|
+
message.includes("timed out") ||
|
|
171
|
+
message.includes("ETIMEDOUT");
|
|
172
|
+
if (timeoutLike) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
throw new Error("文件发送失败:路径发送失败,base64 发送也失败");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
103
179
|
export async function sendRecordMessage(
|
|
104
180
|
ctx: any,
|
|
105
181
|
event: any,
|
package/runtime-core/service.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { AIService } from "
|
|
2
|
-
import type { ScreenshotService } from "
|
|
3
|
-
import type { AppleMusicServiceApi } from "
|
|
1
|
+
import type { AIService } from "mioku";
|
|
2
|
+
import type { ScreenshotService } from "mioku";
|
|
3
|
+
import type { AppleMusicServiceApi } from "mioku-service-applemusic";
|
|
4
4
|
import { MUSIC_DEFAULTS } from "../config";
|
|
5
5
|
import {
|
|
6
6
|
createMusicProvider,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
sendImageMessage,
|
|
15
15
|
sendRecordMessage,
|
|
16
16
|
sendTextMessage,
|
|
17
|
+
sendFileMessage,
|
|
17
18
|
} from "./message";
|
|
18
19
|
import type {
|
|
19
20
|
MusicBaseConfig,
|
|
@@ -41,25 +42,25 @@ function parseListenIndex(text: string): number | null {
|
|
|
41
42
|
return idx;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
function
|
|
45
|
-
const match = text.match(
|
|
45
|
+
function parseOriginalIndex(text: string): number | null {
|
|
46
|
+
const match = text.match(/^\/?原曲\s*(\d{1,2})$/);
|
|
46
47
|
if (!match) {
|
|
47
48
|
return null;
|
|
48
49
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
50
|
+
const idx = Number(match[1]);
|
|
51
|
+
if (!Number.isFinite(idx) || idx <= 0) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return idx;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
function
|
|
54
|
-
const match = text.match(
|
|
57
|
+
function parseSearchKeyword(text: string): string | null {
|
|
58
|
+
const match = text.match(/^\/?点歌\s*(.+)$/);
|
|
55
59
|
if (!match) {
|
|
56
60
|
return null;
|
|
57
61
|
}
|
|
58
62
|
const value = String(match[1] || "").trim();
|
|
59
|
-
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
return value;
|
|
63
|
+
return value ? value : null;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
export class MusicPluginRuntime {
|
|
@@ -123,14 +124,14 @@ export class MusicPluginRuntime {
|
|
|
123
124
|
const listenIndex = parseListenIndex(text);
|
|
124
125
|
if (listenIndex != null) {
|
|
125
126
|
await this.tryReactToCommandMessage(ctx, event);
|
|
126
|
-
await this.sendByIndex(ctx, event, listenIndex);
|
|
127
|
+
await this.sendByIndex(ctx, event, listenIndex, false);
|
|
127
128
|
return true;
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
131
|
+
const originalIndex = parseOriginalIndex(text);
|
|
132
|
+
if (originalIndex != null) {
|
|
132
133
|
await this.tryReactToCommandMessage(ctx, event);
|
|
133
|
-
await this.
|
|
134
|
+
await this.sendByIndex(ctx, event, originalIndex, true);
|
|
134
135
|
return true;
|
|
135
136
|
}
|
|
136
137
|
|
|
@@ -170,7 +171,13 @@ export class MusicPluginRuntime {
|
|
|
170
171
|
return provider.getAlbumDetail(albumId);
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
async sendSongById(
|
|
174
|
+
async sendSongById(
|
|
175
|
+
ctx: any,
|
|
176
|
+
event: any,
|
|
177
|
+
songId: string,
|
|
178
|
+
fileName?: string,
|
|
179
|
+
options?: { forceFile?: boolean },
|
|
180
|
+
): Promise<void> {
|
|
174
181
|
const session = this.getOrCreateSession(event);
|
|
175
182
|
const provider = this.createProvider(
|
|
176
183
|
session.provider,
|
|
@@ -180,10 +187,23 @@ export class MusicPluginRuntime {
|
|
|
180
187
|
this.deps.logger.info(
|
|
181
188
|
`[music] download songId=${songId} source=${result.sourceType} file=${result.filePath}`,
|
|
182
189
|
);
|
|
190
|
+
if (!result || typeof result !== "object") {
|
|
191
|
+
throw new Error(`downloadSong 返回异常: ${JSON.stringify(result)}`);
|
|
192
|
+
}
|
|
193
|
+
if ("tracks" in result && Array.isArray((result as any).tracks)) {
|
|
194
|
+
this.deps.logger.warn(`[music] downloadSong 返回了 tracks 字段`);
|
|
195
|
+
}
|
|
183
196
|
if (result.sourceType !== "hls") {
|
|
184
197
|
throw new Error("当前未获取到高质量 HLS 音频,请检查 media user token");
|
|
185
198
|
}
|
|
186
|
-
|
|
199
|
+
const isPrivate = event?.message_type !== "group";
|
|
200
|
+
const sendAsFile = isPrivate || options?.forceFile === true;
|
|
201
|
+
if (sendAsFile) {
|
|
202
|
+
const finalName = fileName || "song";
|
|
203
|
+
await sendFileMessage(ctx, event, result.filePath, finalName);
|
|
204
|
+
} else {
|
|
205
|
+
await sendRecordMessage(ctx, event, result.filePath);
|
|
206
|
+
}
|
|
187
207
|
}
|
|
188
208
|
|
|
189
209
|
async notifyFailure(
|
|
@@ -201,7 +221,12 @@ export class MusicPluginRuntime {
|
|
|
201
221
|
});
|
|
202
222
|
}
|
|
203
223
|
|
|
204
|
-
async sendSongByQuery(
|
|
224
|
+
async sendSongByQuery(
|
|
225
|
+
ctx: any,
|
|
226
|
+
event: any,
|
|
227
|
+
query: string,
|
|
228
|
+
options?: { forceFile?: boolean },
|
|
229
|
+
): Promise<void> {
|
|
205
230
|
const result = await this.searchSongs(event, query);
|
|
206
231
|
const first = result.tracks[0];
|
|
207
232
|
if (!first) {
|
|
@@ -209,13 +234,13 @@ export class MusicPluginRuntime {
|
|
|
209
234
|
ctx,
|
|
210
235
|
event,
|
|
211
236
|
aiService: this.deps.aiService,
|
|
212
|
-
instruction:
|
|
237
|
+
instruction: `用户说”听${query}”,但搜索不到可播放歌曲。请简短提示换关键词。`,
|
|
213
238
|
fallbackMessage: `没有找到和「${query}」相关的歌曲`,
|
|
214
239
|
});
|
|
215
240
|
return;
|
|
216
241
|
}
|
|
217
242
|
|
|
218
|
-
await this.sendSongById(ctx, event, first.id);
|
|
243
|
+
await this.sendSongById(ctx, event, first.id, first.title, options);
|
|
219
244
|
}
|
|
220
245
|
|
|
221
246
|
private async searchAndSendList(
|
|
@@ -240,7 +265,7 @@ export class MusicPluginRuntime {
|
|
|
240
265
|
await sendTextMessage(
|
|
241
266
|
ctx,
|
|
242
267
|
event,
|
|
243
|
-
`已找到 ${result.tracks.length}
|
|
268
|
+
`已找到 ${result.tracks.length} 首。发送”听1”播放第一首。`,
|
|
244
269
|
);
|
|
245
270
|
return;
|
|
246
271
|
}
|
|
@@ -255,7 +280,7 @@ export class MusicPluginRuntime {
|
|
|
255
280
|
});
|
|
256
281
|
await sendImageMessage(ctx, event, imagePath);
|
|
257
282
|
this.deps.logger.info(
|
|
258
|
-
`[music] search rendered query
|
|
283
|
+
`[music] search rendered query=”${keyword}” count=${result.tracks.length}`,
|
|
259
284
|
);
|
|
260
285
|
} catch (error) {
|
|
261
286
|
await notifyFallback({
|
|
@@ -273,6 +298,7 @@ export class MusicPluginRuntime {
|
|
|
273
298
|
ctx: any,
|
|
274
299
|
event: any,
|
|
275
300
|
index: number,
|
|
301
|
+
forceFile: boolean,
|
|
276
302
|
): Promise<void> {
|
|
277
303
|
const session = this.getOrCreateSession(event);
|
|
278
304
|
const tracks = session.lastSearch?.tracks || [];
|
|
@@ -283,39 +309,22 @@ export class MusicPluginRuntime {
|
|
|
283
309
|
event,
|
|
284
310
|
aiService: this.deps.aiService,
|
|
285
311
|
instruction: `用户请求听第${index}首,但当前列表不足。请提示先点歌或检查编号。`,
|
|
286
|
-
fallbackMessage: `没有第 ${index}
|
|
312
|
+
fallbackMessage: `没有第 ${index} 首,请先”点歌 关键词”再选择`,
|
|
287
313
|
});
|
|
288
314
|
return;
|
|
289
315
|
}
|
|
290
316
|
|
|
291
317
|
try {
|
|
292
|
-
await this.sendSongById(ctx, event, target.id
|
|
293
|
-
|
|
294
|
-
await notifyFallback({
|
|
295
|
-
ctx,
|
|
296
|
-
event,
|
|
297
|
-
aiService: this.deps.aiService,
|
|
298
|
-
instruction: `用户选择听第${index}首时下载失败。错误:${String(error)}。请简短道歉并建议重试。`,
|
|
299
|
-
fallbackMessage: "播放失败,请稍后重试。",
|
|
300
|
-
error,
|
|
318
|
+
await this.sendSongById(ctx, event, target.id, target.title, {
|
|
319
|
+
forceFile,
|
|
301
320
|
});
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
private async sendByKeyword(
|
|
306
|
-
ctx: any,
|
|
307
|
-
event: any,
|
|
308
|
-
keyword: string,
|
|
309
|
-
): Promise<void> {
|
|
310
|
-
try {
|
|
311
|
-
await this.sendSongByQuery(ctx, event, keyword);
|
|
312
321
|
} catch (error) {
|
|
313
322
|
await notifyFallback({
|
|
314
323
|
ctx,
|
|
315
324
|
event,
|
|
316
325
|
aiService: this.deps.aiService,
|
|
317
|
-
instruction:
|
|
318
|
-
fallbackMessage: "
|
|
326
|
+
instruction: `用户选择听第${index}首时下载失败。错误:${String(error)}。请简短道歉并建议重试。`,
|
|
327
|
+
fallbackMessage: "播放失败,请稍后重试",
|
|
319
328
|
error,
|
|
320
329
|
});
|
|
321
330
|
}
|
|
@@ -384,6 +393,10 @@ export class MusicPluginRuntime {
|
|
|
384
393
|
}
|
|
385
394
|
|
|
386
395
|
private async tryReactToCommandMessage(ctx: any, event: any): Promise<void> {
|
|
396
|
+
if (event?.message_type === "group") {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
387
400
|
const messageId = Number(event?.message_id);
|
|
388
401
|
if (!Number.isFinite(messageId) || messageId <= 0) {
|
|
389
402
|
return;
|
package/runtime.ts
CHANGED
package/skills.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AISkill, AITool } from "
|
|
1
|
+
import type { AISkill, AITool } from "mioku";
|
|
2
2
|
import { getMusicRuntimeState } from "./runtime";
|
|
3
3
|
|
|
4
4
|
const musicSkills: AISkill[] = [
|
|
@@ -60,7 +60,8 @@ const musicSkills: AISkill[] = [
|
|
|
60
60
|
} as AITool,
|
|
61
61
|
{
|
|
62
62
|
name: "send_music_song",
|
|
63
|
-
description:
|
|
63
|
+
description:
|
|
64
|
+
"发送歌曲(通过 song_id 或 query)。默认发语音;传 as_file=true 时直接发源文件",
|
|
64
65
|
parameters: {
|
|
65
66
|
type: "object",
|
|
66
67
|
properties: {
|
|
@@ -72,11 +73,17 @@ const musicSkills: AISkill[] = [
|
|
|
72
73
|
type: "string",
|
|
73
74
|
description: "关键词,内部会搜索后发送第一首",
|
|
74
75
|
},
|
|
76
|
+
as_file: {
|
|
77
|
+
type: "boolean",
|
|
78
|
+
description: "是否直接发送源文件",
|
|
79
|
+
default: false,
|
|
80
|
+
},
|
|
75
81
|
},
|
|
76
82
|
required: [],
|
|
77
83
|
},
|
|
78
84
|
handler: async (args: any, runtimeCtx?: any) => {
|
|
79
|
-
const
|
|
85
|
+
const runtimeState = getMusicRuntimeState();
|
|
86
|
+
const runtime = runtimeState.runtime;
|
|
80
87
|
const ctx = runtimeCtx?.ctx;
|
|
81
88
|
const event = runtimeCtx?.event || runtimeCtx?.rawEvent;
|
|
82
89
|
if (!runtime || !ctx || !event) {
|
|
@@ -85,19 +92,29 @@ const musicSkills: AISkill[] = [
|
|
|
85
92
|
|
|
86
93
|
const songId = String(args?.song_id || "").trim();
|
|
87
94
|
const query = String(args?.query || "").trim();
|
|
95
|
+
const asFile = args?.as_file === true;
|
|
88
96
|
if (!songId && !query) {
|
|
89
97
|
return "必须提供 song_id 或 query";
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
ctx.logger.info(
|
|
101
|
+
`[music] send_music_song called: songId=${songId}, query=${query}, asFile=${asFile}`,
|
|
102
|
+
);
|
|
103
|
+
|
|
92
104
|
try {
|
|
93
105
|
if (songId) {
|
|
94
|
-
await runtime.sendSongById(ctx, event, songId
|
|
95
|
-
|
|
106
|
+
await runtime.sendSongById(ctx, event, songId, undefined, {
|
|
107
|
+
forceFile: asFile,
|
|
108
|
+
});
|
|
109
|
+
return asFile ? "已发送歌曲源文件" : "已发送歌曲";
|
|
96
110
|
}
|
|
97
111
|
|
|
98
|
-
await runtime.sendSongByQuery(ctx, event, query
|
|
99
|
-
|
|
112
|
+
await runtime.sendSongByQuery(ctx, event, query, {
|
|
113
|
+
forceFile: asFile,
|
|
114
|
+
});
|
|
115
|
+
return asFile ? "已按关键词发送歌曲源文件" : "已按关键词发送歌曲";
|
|
100
116
|
} catch (error) {
|
|
117
|
+
ctx.logger.error(`[music] send_music_song failed: ${error}`);
|
|
101
118
|
await runtime.notifyFailure(
|
|
102
119
|
ctx,
|
|
103
120
|
event,
|