mioku-plugin-music 1.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/LICENSE +21 -0
- package/README.md +233 -0
- package/config.md +49 -0
- package/config.ts +11 -0
- package/index.ts +71 -0
- package/package.json +44 -0
- package/providers/applemusic-provider.ts +101 -0
- package/providers/factory.ts +81 -0
- package/providers/provider-labels.ts +10 -0
- package/render/search-list.ts +73 -0
- package/runtime-core/fallback.ts +56 -0
- package/runtime-core/message.ts +173 -0
- package/runtime-core/service.ts +413 -0
- package/runtime-core/session-store.ts +52 -0
- package/runtime.ts +24 -0
- package/skills.ts +115 -0
- package/types.ts +85 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AIService } from "../../../src/services/ai/types";
|
|
2
|
+
import { sendTextMessage } from "./message";
|
|
3
|
+
|
|
4
|
+
function normalizeErrorMessage(error: unknown): string {
|
|
5
|
+
if (error instanceof Error && error.message) return error.message;
|
|
6
|
+
if (typeof error === "string") return error;
|
|
7
|
+
try {
|
|
8
|
+
return JSON.stringify(error);
|
|
9
|
+
} catch {
|
|
10
|
+
return String(error);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function notifyFallback(options: {
|
|
15
|
+
ctx: any;
|
|
16
|
+
event: any;
|
|
17
|
+
aiService?: AIService;
|
|
18
|
+
instruction: string;
|
|
19
|
+
fallbackMessage: string;
|
|
20
|
+
error?: unknown;
|
|
21
|
+
}): Promise<void> {
|
|
22
|
+
if (options.error != null) {
|
|
23
|
+
options.ctx?.logger?.error?.(
|
|
24
|
+
`[music] ${options.instruction}\n执行错误: ${normalizeErrorMessage(options.error)}`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const chatRuntime = options.aiService?.getChatRuntime();
|
|
29
|
+
if (chatRuntime) {
|
|
30
|
+
try {
|
|
31
|
+
await chatRuntime.generateNotice({
|
|
32
|
+
event: options.event,
|
|
33
|
+
instruction: options.instruction,
|
|
34
|
+
send: true,
|
|
35
|
+
promptInjections: [
|
|
36
|
+
{
|
|
37
|
+
title: "Music Plugin Notice",
|
|
38
|
+
content:
|
|
39
|
+
"A music-related action was triggered. Judge whether the user likely intended this action or triggered it accidentally. If it looks accidental or like a casual mention, weave a natural reply into the conversation without mentioning the plugin, tools, or commands. If the user seems to want this feature, respond helpfully. Keep it concise and friendly in Chinese.",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
} catch (noticeError) {
|
|
45
|
+
options.ctx?.logger?.error?.(
|
|
46
|
+
`[music] notice 发送失败: ${normalizeErrorMessage(noticeError)}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await sendTextMessage(
|
|
52
|
+
options.ctx,
|
|
53
|
+
options.event,
|
|
54
|
+
options.fallbackMessage,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
|
|
3
|
+
function normalizeFileSource(file: string): string {
|
|
4
|
+
const value = String(file || "").trim();
|
|
5
|
+
if (!value) {
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
if (
|
|
9
|
+
value.startsWith("file://") ||
|
|
10
|
+
value.startsWith("base64://") ||
|
|
11
|
+
value.startsWith("http://") ||
|
|
12
|
+
value.startsWith("https://")
|
|
13
|
+
) {
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value)) {
|
|
17
|
+
return `file://${value}`;
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getBotAndTarget(ctx: any, event: any): {
|
|
23
|
+
bot: any;
|
|
24
|
+
groupId?: number;
|
|
25
|
+
userId?: number;
|
|
26
|
+
} {
|
|
27
|
+
const selfId = event?.self_id != null ? Number(event.self_id) : undefined;
|
|
28
|
+
const bot =
|
|
29
|
+
selfId != null && typeof ctx?.pickBot === "function"
|
|
30
|
+
? ctx.pickBot(selfId)
|
|
31
|
+
: undefined;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
bot,
|
|
35
|
+
groupId: event?.message_type === "group" ? Number(event.group_id) : undefined,
|
|
36
|
+
userId: event?.message_type !== "group" ? Number(event?.user_id) : undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function sendTextMessage(
|
|
41
|
+
ctx: any,
|
|
42
|
+
event: any,
|
|
43
|
+
text: string,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const { bot, groupId, userId } = getBotAndTarget(ctx, event);
|
|
46
|
+
const payload: any[] = [];
|
|
47
|
+
|
|
48
|
+
payload.push(ctx?.segment?.text ? ctx.segment.text(text) : { type: "text", text });
|
|
49
|
+
|
|
50
|
+
if (bot && groupId != null) {
|
|
51
|
+
await bot.sendGroupMsg(groupId, payload);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (bot && userId != null) {
|
|
55
|
+
await bot.sendPrivateMsg(userId, payload);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (typeof event?.reply === "function") {
|
|
59
|
+
await event.reply(text);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
throw new Error("当前上下文不支持文本发送");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function sendImageMessage(
|
|
66
|
+
ctx: any,
|
|
67
|
+
event: any,
|
|
68
|
+
imagePath: string,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const { bot, groupId, userId } = getBotAndTarget(ctx, event);
|
|
71
|
+
const sendPayload = async (source: string) => {
|
|
72
|
+
const payload: any[] = [];
|
|
73
|
+
payload.push(
|
|
74
|
+
ctx?.segment?.image
|
|
75
|
+
? ctx.segment.image(normalizeFileSource(source))
|
|
76
|
+
: { type: "image", file: normalizeFileSource(source) },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (bot && groupId != null) {
|
|
80
|
+
await bot.sendGroupMsg(groupId, payload);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (bot && userId != null) {
|
|
84
|
+
await bot.sendPrivateMsg(userId, payload);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (typeof event?.reply === "function") {
|
|
88
|
+
await event.reply(payload);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
throw new Error("当前上下文不支持图片发送");
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await sendPayload(imagePath);
|
|
96
|
+
} catch {
|
|
97
|
+
const buffer = await fs.readFile(imagePath);
|
|
98
|
+
const base64 = `base64://${buffer.toString("base64")}`;
|
|
99
|
+
await sendPayload(base64);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function sendRecordMessage(
|
|
104
|
+
ctx: any,
|
|
105
|
+
event: any,
|
|
106
|
+
audioPath: string,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
const { bot, groupId, userId } = getBotAndTarget(ctx, event);
|
|
109
|
+
const sendPayload = async (source: string) => {
|
|
110
|
+
const payload: any[] = [];
|
|
111
|
+
payload.push(
|
|
112
|
+
ctx?.segment?.record
|
|
113
|
+
? ctx.segment.record(normalizeFileSource(source))
|
|
114
|
+
: { type: "record", file: normalizeFileSource(source) },
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (bot && groupId != null) {
|
|
118
|
+
await bot.sendGroupMsg(groupId, payload);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (bot && userId != null) {
|
|
122
|
+
await bot.sendPrivateMsg(userId, payload);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (typeof event?.reply === "function") {
|
|
126
|
+
await event.reply(payload);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
throw new Error("当前上下文不支持语音发送");
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const canReadLocalFile =
|
|
133
|
+
audioPath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(audioPath);
|
|
134
|
+
|
|
135
|
+
let fileSendError: unknown;
|
|
136
|
+
try {
|
|
137
|
+
await sendPayload(audioPath);
|
|
138
|
+
return;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
fileSendError = error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!canReadLocalFile) {
|
|
144
|
+
throw fileSendError instanceof Error
|
|
145
|
+
? fileSendError
|
|
146
|
+
: new Error(String(fileSendError || "语音发送失败"));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let buffer: Buffer;
|
|
150
|
+
try {
|
|
151
|
+
buffer = await fs.readFile(audioPath);
|
|
152
|
+
} catch {
|
|
153
|
+
throw fileSendError instanceof Error
|
|
154
|
+
? fileSendError
|
|
155
|
+
: new Error(String(fileSendError || "语音发送失败"));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const base64 = `base64://${buffer.toString("base64")}`;
|
|
159
|
+
try {
|
|
160
|
+
await sendPayload(base64);
|
|
161
|
+
} catch (base64Error) {
|
|
162
|
+
const message = String(base64Error || "");
|
|
163
|
+
const timeoutLike =
|
|
164
|
+
message.includes("timeout") ||
|
|
165
|
+
message.includes("超时") ||
|
|
166
|
+
message.includes("timed out") ||
|
|
167
|
+
message.includes("ETIMEDOUT");
|
|
168
|
+
if (timeoutLike) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
throw new Error("语音发送失败:路径发送失败,base64 发送也失败");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
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";
|
|
4
|
+
import { MUSIC_DEFAULTS } from "../config";
|
|
5
|
+
import {
|
|
6
|
+
createMusicProvider,
|
|
7
|
+
getMusicProviderCandidates,
|
|
8
|
+
resolveMusicProviderName,
|
|
9
|
+
} from "../providers/factory";
|
|
10
|
+
import { renderMusicSearchListHtml } from "../render/search-list";
|
|
11
|
+
import { MusicSessionStore } from "./session-store";
|
|
12
|
+
import { notifyFallback } from "./fallback";
|
|
13
|
+
import {
|
|
14
|
+
sendImageMessage,
|
|
15
|
+
sendRecordMessage,
|
|
16
|
+
sendTextMessage,
|
|
17
|
+
} from "./message";
|
|
18
|
+
import type {
|
|
19
|
+
MusicBaseConfig,
|
|
20
|
+
MusicProviderName,
|
|
21
|
+
MusicSearchResult,
|
|
22
|
+
MusicSessionState,
|
|
23
|
+
} from "../types";
|
|
24
|
+
|
|
25
|
+
interface MusicPluginRuntimeDeps {
|
|
26
|
+
logger: { info: (msg: string) => void; warn: (msg: string) => void };
|
|
27
|
+
aiService?: AIService;
|
|
28
|
+
screenshotService?: ScreenshotService;
|
|
29
|
+
applemusicService?: AppleMusicServiceApi;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseListenIndex(text: string): number | null {
|
|
33
|
+
const match = text.match(/^\/?听\s*(\d{1,2})$/);
|
|
34
|
+
if (!match) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const idx = Number(match[1]);
|
|
38
|
+
if (!Number.isFinite(idx) || idx <= 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return idx;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseSearchKeyword(text: string): string | null {
|
|
45
|
+
const match = text.match(/^\/?点歌\s*(.+)$/);
|
|
46
|
+
if (!match) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const value = String(match[1] || "").trim();
|
|
50
|
+
return value ? value : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseListenKeyword(text: string): string | null {
|
|
54
|
+
const match = text.match(/^\/?听\s*(.+)$/);
|
|
55
|
+
if (!match) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const value = String(match[1] || "").trim();
|
|
59
|
+
if (!value || /^\d+$/.test(value)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class MusicPluginRuntime {
|
|
66
|
+
private readonly deps: MusicPluginRuntimeDeps;
|
|
67
|
+
private readonly sessions = new MusicSessionStore();
|
|
68
|
+
private config: MusicBaseConfig = MUSIC_DEFAULTS;
|
|
69
|
+
private static readonly COMMAND_REACTION_FACE_ID = 60;
|
|
70
|
+
|
|
71
|
+
constructor(deps: MusicPluginRuntimeDeps) {
|
|
72
|
+
this.deps = deps;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
updateConfig(nextConfig: MusicBaseConfig): void {
|
|
76
|
+
this.config = nextConfig;
|
|
77
|
+
const services = this.getProviderServices();
|
|
78
|
+
const configured = String(nextConfig.defaultProvider || "").trim();
|
|
79
|
+
const resolved = resolveMusicProviderName(configured, services);
|
|
80
|
+
if (!resolved) {
|
|
81
|
+
const candidates = getMusicProviderCandidates();
|
|
82
|
+
this.deps.logger.warn(
|
|
83
|
+
`music 未找到可用 provider。defaultProvider=${configured || "<empty>"},候选列表=${candidates.join(", ")}`,
|
|
84
|
+
);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (configured && configured !== resolved) {
|
|
88
|
+
this.deps.logger.warn(
|
|
89
|
+
`music defaultProvider=${configured} 不可用,已回退到 ${resolved}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setSessionMediaUserToken(event: any, token: string): void {
|
|
95
|
+
const trimmed = String(token || "").trim();
|
|
96
|
+
if (!trimmed) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const session = this.getOrCreateSession(event);
|
|
100
|
+
this.sessions.patch(event, {
|
|
101
|
+
provider: session.provider,
|
|
102
|
+
mediaUserToken: trimmed,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getSession(event: any): MusicSessionState | undefined {
|
|
107
|
+
return this.sessions.get(event);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async handleMessage(ctx: any, event: any): Promise<boolean> {
|
|
111
|
+
const text = String(ctx.text(event) || "").trim();
|
|
112
|
+
if (!text) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const searchKeyword = parseSearchKeyword(text);
|
|
117
|
+
if (searchKeyword) {
|
|
118
|
+
await this.tryReactToCommandMessage(ctx, event);
|
|
119
|
+
await this.searchAndSendList(ctx, event, searchKeyword);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const listenIndex = parseListenIndex(text);
|
|
124
|
+
if (listenIndex != null) {
|
|
125
|
+
await this.tryReactToCommandMessage(ctx, event);
|
|
126
|
+
await this.sendByIndex(ctx, event, listenIndex);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const listenKeyword = parseListenKeyword(text);
|
|
131
|
+
if (listenKeyword) {
|
|
132
|
+
await this.tryReactToCommandMessage(ctx, event);
|
|
133
|
+
await this.sendByKeyword(ctx, event, listenKeyword);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async searchSongs(event: any, query: string): Promise<MusicSearchResult> {
|
|
141
|
+
const session = this.getOrCreateSession(event);
|
|
142
|
+
const provider = this.createProvider(
|
|
143
|
+
session.provider,
|
|
144
|
+
session.mediaUserToken,
|
|
145
|
+
);
|
|
146
|
+
const result = await provider.searchSongs(query, this.config.searchLimit);
|
|
147
|
+
this.sessions.patch(event, {
|
|
148
|
+
lastSearch: result,
|
|
149
|
+
provider: session.provider,
|
|
150
|
+
mediaUserToken: session.mediaUserToken,
|
|
151
|
+
});
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async getSongDetail(event: any, songId: string) {
|
|
156
|
+
const session = this.getOrCreateSession(event);
|
|
157
|
+
const provider = this.createProvider(
|
|
158
|
+
session.provider,
|
|
159
|
+
session.mediaUserToken,
|
|
160
|
+
);
|
|
161
|
+
return provider.getSongDetail(songId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async getAlbumDetail(event: any, albumId: string) {
|
|
165
|
+
const session = this.getOrCreateSession(event);
|
|
166
|
+
const provider = this.createProvider(
|
|
167
|
+
session.provider,
|
|
168
|
+
session.mediaUserToken,
|
|
169
|
+
);
|
|
170
|
+
return provider.getAlbumDetail(albumId);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async sendSongById(ctx: any, event: any, songId: string): Promise<void> {
|
|
174
|
+
const session = this.getOrCreateSession(event);
|
|
175
|
+
const provider = this.createProvider(
|
|
176
|
+
session.provider,
|
|
177
|
+
session.mediaUserToken,
|
|
178
|
+
);
|
|
179
|
+
const result = await provider.downloadSong(songId);
|
|
180
|
+
this.deps.logger.info(
|
|
181
|
+
`[music] download songId=${songId} source=${result.sourceType} file=${result.filePath}`,
|
|
182
|
+
);
|
|
183
|
+
if (result.sourceType !== "hls") {
|
|
184
|
+
throw new Error("当前未获取到高质量 HLS 音频,请检查 media user token");
|
|
185
|
+
}
|
|
186
|
+
await sendRecordMessage(ctx, event, result.filePath);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async notifyFailure(
|
|
190
|
+
ctx: any,
|
|
191
|
+
event: any,
|
|
192
|
+
instruction: string,
|
|
193
|
+
fallbackMessage: string,
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
await notifyFallback({
|
|
196
|
+
ctx,
|
|
197
|
+
event,
|
|
198
|
+
aiService: this.deps.aiService,
|
|
199
|
+
instruction,
|
|
200
|
+
fallbackMessage,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async sendSongByQuery(ctx: any, event: any, query: string): Promise<void> {
|
|
205
|
+
const result = await this.searchSongs(event, query);
|
|
206
|
+
const first = result.tracks[0];
|
|
207
|
+
if (!first) {
|
|
208
|
+
await notifyFallback({
|
|
209
|
+
ctx,
|
|
210
|
+
event,
|
|
211
|
+
aiService: this.deps.aiService,
|
|
212
|
+
instruction: `用户说“听${query}”,但搜索不到可播放歌曲。请简短提示换关键词。`,
|
|
213
|
+
fallbackMessage: `没有找到和「${query}」相关的歌曲`,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await this.sendSongById(ctx, event, first.id);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async searchAndSendList(
|
|
222
|
+
ctx: any,
|
|
223
|
+
event: any,
|
|
224
|
+
keyword: string,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
try {
|
|
227
|
+
const result = await this.searchSongs(event, keyword);
|
|
228
|
+
if (!result.tracks.length) {
|
|
229
|
+
await notifyFallback({
|
|
230
|
+
ctx,
|
|
231
|
+
event,
|
|
232
|
+
aiService: this.deps.aiService,
|
|
233
|
+
instruction: `用户点歌关键词「${keyword}」没有结果,请自然建议换关键词。`,
|
|
234
|
+
fallbackMessage: `没有找到「${keyword}」的歌曲结果`,
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!this.deps.screenshotService) {
|
|
240
|
+
await sendTextMessage(
|
|
241
|
+
ctx,
|
|
242
|
+
event,
|
|
243
|
+
`已找到 ${result.tracks.length} 首。发送“听1”播放第一首。`,
|
|
244
|
+
);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const html = renderMusicSearchListHtml(result);
|
|
249
|
+
const imagePath = await this.deps.screenshotService.screenshot(html, {
|
|
250
|
+
width: 850,
|
|
251
|
+
height: 100, // 处理极端情况
|
|
252
|
+
fullPage: true,
|
|
253
|
+
type: "png",
|
|
254
|
+
themeMode: "auto",
|
|
255
|
+
});
|
|
256
|
+
await sendImageMessage(ctx, event, imagePath);
|
|
257
|
+
this.deps.logger.info(
|
|
258
|
+
`[music] search rendered query="${keyword}" count=${result.tracks.length}`,
|
|
259
|
+
);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
await notifyFallback({
|
|
262
|
+
ctx,
|
|
263
|
+
event,
|
|
264
|
+
aiService: this.deps.aiService,
|
|
265
|
+
instruction: `music 插件搜索失败。错误:${String(error)}。请简短告知稍后重试。`,
|
|
266
|
+
fallbackMessage: `点歌失败:${String(error)}`,
|
|
267
|
+
error,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async sendByIndex(
|
|
273
|
+
ctx: any,
|
|
274
|
+
event: any,
|
|
275
|
+
index: number,
|
|
276
|
+
): Promise<void> {
|
|
277
|
+
const session = this.getOrCreateSession(event);
|
|
278
|
+
const tracks = session.lastSearch?.tracks || [];
|
|
279
|
+
const target = tracks[index - 1];
|
|
280
|
+
if (!target) {
|
|
281
|
+
await notifyFallback({
|
|
282
|
+
ctx,
|
|
283
|
+
event,
|
|
284
|
+
aiService: this.deps.aiService,
|
|
285
|
+
instruction: `用户请求听第${index}首,但当前列表不足。请提示先点歌或检查编号。`,
|
|
286
|
+
fallbackMessage: `没有第 ${index} 首,请先“点歌 关键词”再选择`,
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
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,
|
|
301
|
+
});
|
|
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
|
+
} catch (error) {
|
|
313
|
+
await notifyFallback({
|
|
314
|
+
ctx,
|
|
315
|
+
event,
|
|
316
|
+
aiService: this.deps.aiService,
|
|
317
|
+
instruction: `用户请求“听${keyword}”时失败。错误:${String(error)}。请简短提示后重试。`,
|
|
318
|
+
fallbackMessage: "播放失败,请稍后重试。",
|
|
319
|
+
error,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private getOrCreateSession(event: any): MusicSessionState {
|
|
325
|
+
const current = this.sessions.get(event);
|
|
326
|
+
if (current) {
|
|
327
|
+
return current;
|
|
328
|
+
}
|
|
329
|
+
const provider = this.resolveProviderName(this.config.defaultProvider);
|
|
330
|
+
const next: MusicSessionState = {
|
|
331
|
+
provider,
|
|
332
|
+
updatedAt: Date.now(),
|
|
333
|
+
};
|
|
334
|
+
this.sessions.set(event, next);
|
|
335
|
+
return next;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private createProvider(provider: MusicProviderName, mediaUserToken?: string) {
|
|
339
|
+
const resolvedProvider = this.resolveProviderName(provider);
|
|
340
|
+
const finalMediaUserToken =
|
|
341
|
+
String(mediaUserToken || "").trim() ||
|
|
342
|
+
String(this.config.applemusic.defaultMediaUserToken || "").trim() ||
|
|
343
|
+
undefined;
|
|
344
|
+
|
|
345
|
+
return createMusicProvider(
|
|
346
|
+
resolvedProvider,
|
|
347
|
+
{
|
|
348
|
+
applemusic: this.deps.applemusicService,
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
mediaUserToken: finalMediaUserToken,
|
|
352
|
+
storefront: this.config.applemusic.storefront,
|
|
353
|
+
language: this.config.applemusic.language,
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private resolveProviderName(
|
|
359
|
+
preferredProviderName: unknown,
|
|
360
|
+
): MusicProviderName {
|
|
361
|
+
const services = this.getProviderServices();
|
|
362
|
+
const resolvedProvider = resolveMusicProviderName(
|
|
363
|
+
preferredProviderName,
|
|
364
|
+
services,
|
|
365
|
+
);
|
|
366
|
+
if (resolvedProvider) {
|
|
367
|
+
return resolvedProvider;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const candidates = getMusicProviderCandidates();
|
|
371
|
+
const configured = String(preferredProviderName || "").trim();
|
|
372
|
+
const configuredHint = configured
|
|
373
|
+
? `当前配置 defaultProvider=${configured}。`
|
|
374
|
+
: "当前配置未填写 defaultProvider。";
|
|
375
|
+
throw new Error(
|
|
376
|
+
`未找到可用的 music provider 服务。${configuredHint} 已注册 provider 列表:${candidates.join(", ")}。`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private getProviderServices() {
|
|
381
|
+
return {
|
|
382
|
+
applemusic: this.deps.applemusicService,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private async tryReactToCommandMessage(ctx: any, event: any): Promise<void> {
|
|
387
|
+
const messageId = Number(event?.message_id);
|
|
388
|
+
if (!Number.isFinite(messageId) || messageId <= 0) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const selfId = Number(event?.self_id || ctx?.self_id);
|
|
393
|
+
if (!Number.isFinite(selfId) || selfId <= 0) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const bot =
|
|
398
|
+
typeof ctx?.pickBot === "function" ? ctx.pickBot(selfId) : undefined;
|
|
399
|
+
if (!bot || typeof bot.api !== "function") {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
await bot.api("set_msg_emoji_like", {
|
|
405
|
+
message_id: messageId,
|
|
406
|
+
emoji_id: MusicPluginRuntime.COMMAND_REACTION_FACE_ID,
|
|
407
|
+
set: true,
|
|
408
|
+
});
|
|
409
|
+
} catch (error) {
|
|
410
|
+
this.deps.logger.warn(`music set_msg_emoji_like 失败: ${error}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { MusicSessionState } from "../types";
|
|
2
|
+
import { MUSIC_PROVIDER_NAMES } from "../types";
|
|
3
|
+
|
|
4
|
+
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
const DEFAULT_PROVIDER = MUSIC_PROVIDER_NAMES[0];
|
|
6
|
+
|
|
7
|
+
function buildSessionKey(event: any): string {
|
|
8
|
+
if (event?.group_id != null) {
|
|
9
|
+
return `group:${String(event.group_id)}`;
|
|
10
|
+
}
|
|
11
|
+
if (event?.user_id != null) {
|
|
12
|
+
return `private:${String(event.user_id)}`;
|
|
13
|
+
}
|
|
14
|
+
return `unknown:${Date.now()}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MusicSessionStore {
|
|
18
|
+
private readonly sessions = new Map<string, MusicSessionState>();
|
|
19
|
+
|
|
20
|
+
get(event: any): MusicSessionState | undefined {
|
|
21
|
+
this.cleanup();
|
|
22
|
+
return this.sessions.get(buildSessionKey(event));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
set(event: any, nextState: MusicSessionState): void {
|
|
26
|
+
this.cleanup();
|
|
27
|
+
this.sessions.set(buildSessionKey(event), nextState);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
patch(event: any, patchState: Partial<MusicSessionState>): MusicSessionState {
|
|
31
|
+
const current = this.get(event) || {
|
|
32
|
+
provider: DEFAULT_PROVIDER,
|
|
33
|
+
updatedAt: Date.now(),
|
|
34
|
+
};
|
|
35
|
+
const next = {
|
|
36
|
+
...current,
|
|
37
|
+
...patchState,
|
|
38
|
+
updatedAt: Date.now(),
|
|
39
|
+
};
|
|
40
|
+
this.set(event, next);
|
|
41
|
+
return next;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private cleanup(): void {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
for (const [key, state] of this.sessions.entries()) {
|
|
47
|
+
if (now - state.updatedAt > SESSION_TTL_MS) {
|
|
48
|
+
this.sessions.delete(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|