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 CHANGED
@@ -4,8 +4,8 @@
4
4
 
5
5
  - 点歌搜索:`点歌 晴天`
6
6
  - 搜索结果截图列表
7
- - 编号听歌:`听1`
8
- - 关键词直听:`听晴天`
7
+ - 编号听歌(语音):`听1`
8
+ - 编号听歌(原曲文件,群聊也直接发文件):`原曲1`
9
9
  - AI skills:搜索、发送歌曲
10
10
 
11
11
  ## 已适配 Provider
package/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { definePlugin } from "mioki";
2
- import type { AIService } from "../../src/services/ai/types";
3
- import type { ScreenshotService } from "../../src/services/screenshot/types";
4
- import type { ConfigService } from "../../src/services/config/tpyes";
5
- import type { AppleMusicServiceApi } from "../../src/services/applemusic/types";
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": "1.1.0",
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
  }
@@ -1,4 +1,4 @@
1
- import type { AppleMusicServiceApi } from "../../../src/services/applemusic/types";
1
+ import type { AppleMusicServiceApi } from "mioku-service-applemusic";
2
2
  import type {
3
3
  DownloadSongResult,
4
4
  MusicAlbumDetail,
@@ -1,4 +1,4 @@
1
- import type { AppleMusicServiceApi } from "../../../src/services/applemusic/types";
1
+ import type { AppleMusicServiceApi } from "mioku-service-applemusic";
2
2
  import { AppleMusicProvider } from "./applemusic-provider";
3
3
  import {
4
4
  type MusicProvider,
@@ -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」播放第一首歌曲</div>
69
+ <div>发送「听1」或「原曲1」试试看吧~</div>
70
70
  </div>
71
71
  </div>
72
72
  `;
@@ -1,4 +1,4 @@
1
- import type { AIService } from "../../../src/services/ai/types";
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 {
@@ -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,
@@ -1,6 +1,6 @@
1
- import type { AIService } from "../../../src/services/ai/types";
2
- import type { ScreenshotService } from "../../../src/services/screenshot/types";
3
- import type { AppleMusicServiceApi } from "../../../src/services/applemusic/types";
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 parseSearchKeyword(text: string): string | null {
45
- const match = text.match(/^\/?点歌\s*(.+)$/);
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 value = String(match[1] || "").trim();
50
- return value ? value : null;
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 parseListenKeyword(text: string): string | null {
54
- const match = text.match(/^\/?听\s*(.+)$/);
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
- if (!value || /^\d+$/.test(value)) {
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 listenKeyword = parseListenKeyword(text);
131
- if (listenKeyword) {
131
+ const originalIndex = parseOriginalIndex(text);
132
+ if (originalIndex != null) {
132
133
  await this.tryReactToCommandMessage(ctx, event);
133
- await this.sendByKeyword(ctx, event, listenKeyword);
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(ctx: any, event: any, songId: string): Promise<void> {
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
- await sendRecordMessage(ctx, event, result.filePath);
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(ctx: any, event: any, query: string): Promise<void> {
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: `用户说“听${query}”,但搜索不到可播放歌曲。请简短提示换关键词。`,
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} 首。发送“听1”播放第一首。`,
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="${keyword}" count=${result.tracks.length}`,
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
- } catch (error) {
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: `用户请求“听${keyword}”时失败。错误:${String(error)}。请简短提示后重试。`,
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
@@ -3,7 +3,7 @@ import {
3
3
  getPluginRuntimeState,
4
4
  resetPluginRuntimeState,
5
5
  setPluginRuntimeState,
6
- } from "../../src";
6
+ } from "mioku";
7
7
 
8
8
  export interface MusicRuntimeState {
9
9
  runtime?: MusicPluginRuntime;
package/skills.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AISkill, AITool } from "../../src";
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: "发送歌曲语音(通过 song_id 或 query)",
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 runtime = getMusicRuntimeState().runtime;
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
- return "已发送歌曲";
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
- return "已按关键词发送歌曲";
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,