mioku-plugin-music 3.0.3 → 3.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 +73 -0
- package/config.md +13 -1
- package/config.ts +1 -0
- package/dumps/factory.ts +78 -0
- package/dumps/file-source.ts +279 -0
- package/dumps/ncmdump.ts +22 -0
- package/dumps/types.ts +18 -0
- package/index.ts +56 -7
- package/package.json +6 -4
- package/runtime-core/auto-install.ts +54 -0
- package/runtime-core/dump-flow.ts +72 -0
- package/runtime-core/message.ts +47 -86
- package/runtime-core/optional-service.ts +14 -0
- package/runtime-core/service.ts +33 -0
- package/types.ts +5 -0
package/README.md
CHANGED
|
@@ -6,11 +6,84 @@
|
|
|
6
6
|
- 搜索结果截图列表
|
|
7
7
|
- 编号听歌(语音):`.听1`
|
|
8
8
|
- 编号听歌(原曲文件,群聊也直接发文件):`.原曲1`
|
|
9
|
+
- 私聊文件解密:私聊发送匹配后缀的文件
|
|
9
10
|
- AI skills:搜索、发送歌曲
|
|
10
11
|
|
|
11
12
|
## 已适配 Provider
|
|
12
13
|
|
|
13
14
|
- `applemusic`
|
|
15
|
+
- `netease`
|
|
16
|
+
|
|
17
|
+
## 已适配 Dump Provider
|
|
18
|
+
|
|
19
|
+
- `ncmdump`(`mioku-service-ncmdump`,支持 `.ncm`)
|
|
20
|
+
|
|
21
|
+
## 可选服务与自动安装
|
|
22
|
+
|
|
23
|
+
`defaultProvider` / `dumpProvider` 对应的服务都是可选安装:
|
|
24
|
+
|
|
25
|
+
1. 在配置中选择(如 `base.dumpProvider = "ncmdump"`)
|
|
26
|
+
2. 重启 Bot,插件检测到服务包未安装时自动在后台执行 `bun add mioku-service-ncmdump`(从 npm)
|
|
27
|
+
3. 再次重启后服务加载,功能生效
|
|
28
|
+
|
|
29
|
+
## Dump Provider 对接要求
|
|
30
|
+
|
|
31
|
+
`music` 插件通过"服务 API + dump provider 适配器"接入新的解密服务(如 kgm/qmc 等)。规范与 music provider 对称:
|
|
32
|
+
|
|
33
|
+
### 1. 服务 API 标准(服务包侧)
|
|
34
|
+
|
|
35
|
+
服务包(`mioku-service-<name>`)需导出 `defineService` 的 `ServiceRef` 并实现以下接口:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
export interface DumpConvertInput {
|
|
39
|
+
readonly inputPath: string; // 已下载到本地的加密文件
|
|
40
|
+
readonly outputDir?: string; // 缺省为输入文件所在目录
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DumpConvertOutput {
|
|
44
|
+
readonly filePath: string; // 解密后的本地文件
|
|
45
|
+
readonly fileName: string; // 输出文件名(含扩展名)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DumpServiceApi {
|
|
49
|
+
ensureReady(): Promise<unknown>; // 确保二进制/依赖可用
|
|
50
|
+
convert(input: DumpConvertInput): Promise<DumpConvertOutput>;
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
服务自身负责二进制/外部依赖的跨平台引导(Homebrew、gh-proxy 下载解压等),插件不感知平台差异。参考实现见 `mioku-service-ncmdump`。
|
|
55
|
+
|
|
56
|
+
### 2. 插件侧适配器标准(`DumpProvider`)
|
|
57
|
+
|
|
58
|
+
在 `dumps/` 下新增适配器并注册到 `dumps/factory.ts` 的 `DUMP_PROVIDER_REGISTRY`:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
export interface DumpProvider {
|
|
62
|
+
readonly name: DumpProviderName;
|
|
63
|
+
readonly extensions: readonly string[]; // 触发后缀,如 [".ncm"]
|
|
64
|
+
dump(target: DumpTarget): Promise<DumpResult>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DumpTarget {
|
|
68
|
+
readonly sourcePath: string; // 已下载到本地的加密文件
|
|
69
|
+
readonly fileName: string; // 原始文件名
|
|
70
|
+
readonly outputDir: string; // 输出目录(temp/ncmdump/jobs/<id>)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DumpResult {
|
|
74
|
+
readonly filePath: string; // 解密后的本地文件
|
|
75
|
+
readonly fileName: string; // 输出文件名
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
同时补齐:`types.ts` 的 `DumpProviderName` / `DUMP_PROVIDER_NAMES`、`config.md` 的 `base.dumpProvider` 选项、`package.json` 的 peer/devDependencies。
|
|
80
|
+
|
|
81
|
+
### 3. 行为约束
|
|
82
|
+
|
|
83
|
+
- 只在**私聊**触发;文件名后缀匹配 `extensions` 才下载,尽量不拉取无关文件
|
|
84
|
+
- **全程静默**:不回复任何进度或错误消息,成功直接发送结果文件;每个阶段的成败与原因都写入 ctx logger
|
|
85
|
+
- 每个文件独立转换、串行执行,转换与下载产物放在 `temp/ncmdump/jobs/<id>/`,发送完成后清理
|
|
86
|
+
- `convert` 一次只处理一个文件;失败抛 `Error`,由上层统一记录日志
|
|
14
87
|
|
|
15
88
|
## Provider 对接要求
|
|
16
89
|
|
package/config.md
CHANGED
|
@@ -19,12 +19,24 @@ fields:
|
|
|
19
19
|
label: Apple Music
|
|
20
20
|
- value: netease
|
|
21
21
|
label: NetEase Cloud Music
|
|
22
|
+
|
|
23
|
+
- key: base.dumpProvider
|
|
24
|
+
label: 文件解密服务
|
|
25
|
+
type: select
|
|
26
|
+
description: 私聊发送匹配后缀的文件时自动解密并回传
|
|
27
|
+
options:
|
|
28
|
+
- value: ""
|
|
29
|
+
label: 未启用
|
|
30
|
+
- value: ncmdump
|
|
31
|
+
label: ncmdump(网易云 .ncm 解密)
|
|
22
32
|
---
|
|
23
33
|
|
|
24
34
|
> 默认音乐源初始为「无」。请先在上方选择提供商,**重启 Bot 后会自动下载对应服务**。
|
|
25
|
-
|
|
35
|
+
>
|
|
36
|
+
> 文件解密初始为「未启用」。选择后 **重启 Bot 会自动从 npm 安装对应服务包**
|
|
26
37
|
```mioku-fields
|
|
27
38
|
keys:
|
|
28
39
|
- base.searchLimit
|
|
29
40
|
- base.defaultProvider
|
|
41
|
+
- base.dumpProvider
|
|
30
42
|
```
|
package/config.ts
CHANGED
package/dumps/factory.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { NcmdumpServiceApi } from "mioku-service-ncmdump";
|
|
2
|
+
import type { DumpProviderName } from "../types";
|
|
3
|
+
import { NcmdumpProvider } from "./ncmdump";
|
|
4
|
+
import type { DumpProvider } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface DumpProviderFactoryOptions {
|
|
7
|
+
ncmdump?: NcmdumpServiceApi;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface DumpProviderRegistryItem {
|
|
11
|
+
readonly name: DumpProviderName;
|
|
12
|
+
readonly serviceName: string;
|
|
13
|
+
readonly extensions: readonly string[];
|
|
14
|
+
isAvailable(services: DumpProviderFactoryOptions): boolean;
|
|
15
|
+
create(services: DumpProviderFactoryOptions): DumpProvider;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DUMP_PROVIDER_REGISTRY: DumpProviderRegistryItem[] = [
|
|
19
|
+
{
|
|
20
|
+
name: "ncmdump",
|
|
21
|
+
serviceName: "ncmdump",
|
|
22
|
+
extensions: [".ncm"],
|
|
23
|
+
isAvailable: (services) => Boolean(services.ncmdump),
|
|
24
|
+
create: (services) => {
|
|
25
|
+
if (!services.ncmdump) {
|
|
26
|
+
throw new Error("ncmdump 服务未加载");
|
|
27
|
+
}
|
|
28
|
+
return new NcmdumpProvider(services.ncmdump);
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function getDumpProviderRegistryItem(
|
|
34
|
+
providerName: string,
|
|
35
|
+
): DumpProviderRegistryItem | undefined {
|
|
36
|
+
const normalized = String(providerName || "").trim().toLowerCase();
|
|
37
|
+
return DUMP_PROVIDER_REGISTRY.find((item) => item.name === normalized);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveDumpProviderName(
|
|
41
|
+
preferredProviderName: unknown,
|
|
42
|
+
services: DumpProviderFactoryOptions,
|
|
43
|
+
): DumpProviderName | null {
|
|
44
|
+
const preferred = String(preferredProviderName || "").trim();
|
|
45
|
+
if (preferred) {
|
|
46
|
+
const item = getDumpProviderRegistryItem(preferred);
|
|
47
|
+
if (item?.isAvailable(services)) {
|
|
48
|
+
return item.name;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getDumpProviderCandidates(): DumpProviderName[] {
|
|
55
|
+
return DUMP_PROVIDER_REGISTRY.map((item) => item.name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createDumpProvider(
|
|
59
|
+
providerName: DumpProviderName,
|
|
60
|
+
services: DumpProviderFactoryOptions,
|
|
61
|
+
): DumpProvider {
|
|
62
|
+
const item = getDumpProviderRegistryItem(providerName);
|
|
63
|
+
if (!item) {
|
|
64
|
+
throw new Error(`不支持的 dump 服务: ${providerName}`);
|
|
65
|
+
}
|
|
66
|
+
if (!item.isAvailable(services)) {
|
|
67
|
+
throw new Error(`${item.serviceName} 服务未加载`);
|
|
68
|
+
}
|
|
69
|
+
return item.create(services);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function dumpProviderExtensions(
|
|
73
|
+
providerName: DumpProviderName,
|
|
74
|
+
): readonly string[] {
|
|
75
|
+
return (
|
|
76
|
+
getDumpProviderRegistryItem(providerName)?.extensions ?? []
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { Attachment, Logger, MessageEvent, MessageSegment } from "mioku";
|
|
4
|
+
|
|
5
|
+
export interface IncomingFile {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
saveTo(destPath: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type SegmentData = Readonly<Record<string, unknown>>;
|
|
11
|
+
|
|
12
|
+
const strOf = (data: SegmentData, key: string): string | undefined => {
|
|
13
|
+
const value = data[key];
|
|
14
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const isHttpUrl = (value: string | undefined): value is string =>
|
|
18
|
+
typeof value === "string" && /^https?:\/\//i.test(value);
|
|
19
|
+
|
|
20
|
+
const isAbsoluteFsPath = (value: string | undefined): value is string =>
|
|
21
|
+
typeof value === "string" &&
|
|
22
|
+
(/^\//.test(value) || /^[A-Za-z]:[\\/]/.test(value));
|
|
23
|
+
|
|
24
|
+
const toFsPath = (value: string): string => {
|
|
25
|
+
if (!value.startsWith("file://")) return value;
|
|
26
|
+
let p = decodeURIComponent(value.slice("file://".length));
|
|
27
|
+
if (/^\/[A-Za-z]:\//.test(p)) p = p.slice(1);
|
|
28
|
+
else if (!p.startsWith("/")) p = `/${p}`;
|
|
29
|
+
return p;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const baseNameOf = (value: string): string =>
|
|
33
|
+
path.basename(toFsPath(value).split("?")[0] ?? "");
|
|
34
|
+
|
|
35
|
+
const looksLikeFileName = (value: string | undefined): value is string =>
|
|
36
|
+
typeof value === "string" && /^[^/\\]+\.[A-Za-z0-9]{1,8}$/.test(value.trim());
|
|
37
|
+
|
|
38
|
+
const attachmentOf = (seg: MessageSegment): Attachment | undefined => seg.attachment;
|
|
39
|
+
|
|
40
|
+
async function downloadTo(url: string, destPath: string): Promise<void> {
|
|
41
|
+
const response = await fetch(url);
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(`文件下载失败: HTTP ${response.status}`);
|
|
44
|
+
}
|
|
45
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
46
|
+
await fs.writeFile(destPath, buffer);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function copyLocalFile(localPath: string, destPath: string): Promise<void> {
|
|
50
|
+
const local = toFsPath(localPath);
|
|
51
|
+
try {
|
|
52
|
+
await fs.copyFile(local, destPath);
|
|
53
|
+
} catch {
|
|
54
|
+
throw new Error(`本地文件读取失败(bot 与 NapCat 不在同一台机器时属预期): ${local}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface FileSource {
|
|
59
|
+
readonly url?: string;
|
|
60
|
+
readonly localPath?: string;
|
|
61
|
+
readonly base64?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function saveSource(source: FileSource, destPath: string): Promise<void> {
|
|
65
|
+
if (source.base64) {
|
|
66
|
+
await fs.writeFile(destPath, Buffer.from(source.base64, "base64"));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (isHttpUrl(source.url)) {
|
|
70
|
+
await downloadTo(source.url, destPath);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (source.localPath) {
|
|
74
|
+
await copyLocalFile(source.localPath, destPath);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
throw new Error("无法获取文件下载地址");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function describeSource(source: FileSource): string {
|
|
81
|
+
if (source.base64) return "get_file base64";
|
|
82
|
+
if (isHttpUrl(source.url)) return `url ${source.url.slice(0, 80)}`;
|
|
83
|
+
if (source.localPath) return `本地路径 ${source.localPath}`;
|
|
84
|
+
return "未知来源";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface OneBotGetFileResult {
|
|
88
|
+
readonly file?: string;
|
|
89
|
+
readonly url?: string;
|
|
90
|
+
readonly base64?: string;
|
|
91
|
+
readonly file_name?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function fromOneBot(
|
|
95
|
+
event: MessageEvent,
|
|
96
|
+
seg: MessageSegment,
|
|
97
|
+
logger: Logger,
|
|
98
|
+
): IncomingFile | null {
|
|
99
|
+
const data = seg.data as SegmentData;
|
|
100
|
+
const fileField = strOf(data, "file");
|
|
101
|
+
const name =
|
|
102
|
+
strOf(data, "file_name") ??
|
|
103
|
+
strOf(data, "name") ??
|
|
104
|
+
(looksLikeFileName(fileField) ? fileField.trim() : undefined) ??
|
|
105
|
+
(isAbsoluteFsPath(fileField) ? baseNameOf(fileField) : undefined) ??
|
|
106
|
+
attachmentOf(seg)?.name;
|
|
107
|
+
if (!name) return null;
|
|
108
|
+
|
|
109
|
+
const segmentUrl = strOf(data, "url");
|
|
110
|
+
const segmentLocalPaths = [strOf(data, "path"), fileField].filter(
|
|
111
|
+
(value): value is string => typeof value === "string" && isAbsoluteFsPath(value),
|
|
112
|
+
);
|
|
113
|
+
const idCandidates = [
|
|
114
|
+
strOf(data, "file_id"),
|
|
115
|
+
strOf(data, "fid"),
|
|
116
|
+
looksLikeFileName(fileField) ? undefined : fileField,
|
|
117
|
+
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
118
|
+
|
|
119
|
+
const get_file = async (fileId: string): Promise<OneBotGetFileResult | null> => {
|
|
120
|
+
try {
|
|
121
|
+
return await event.bot.sendApi<OneBotGetFileResult>("get_file", {
|
|
122
|
+
file_id: fileId,
|
|
123
|
+
file: fileId,
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logger.warn(`[music] get_file(${fileId.slice(0, 60)}) 失败: ${String(error)}`);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const sourcesFromGetFile = (result: OneBotGetFileResult | null): FileSource[] => {
|
|
132
|
+
if (!result) return [];
|
|
133
|
+
const sources: FileSource[] = [];
|
|
134
|
+
if (typeof result.base64 === "string" && result.base64) {
|
|
135
|
+
sources.push({ base64: result.base64 });
|
|
136
|
+
}
|
|
137
|
+
if (isHttpUrl(result.url)) {
|
|
138
|
+
sources.push({ url: result.url });
|
|
139
|
+
}
|
|
140
|
+
if (typeof result.file === "string" && result.file) {
|
|
141
|
+
sources.push({ localPath: result.file });
|
|
142
|
+
}
|
|
143
|
+
return sources;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
name,
|
|
148
|
+
async saveTo(destPath: string): Promise<void> {
|
|
149
|
+
let lastError: unknown;
|
|
150
|
+
|
|
151
|
+
const trySources = async (
|
|
152
|
+
sources: readonly FileSource[],
|
|
153
|
+
tag: string,
|
|
154
|
+
): Promise<boolean> => {
|
|
155
|
+
for (const source of sources) {
|
|
156
|
+
try {
|
|
157
|
+
await saveSource(source, destPath);
|
|
158
|
+
logger.info(`[music] ${name}: ${tag} 成功 (${describeSource(source)})`);
|
|
159
|
+
return true;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
lastError = error;
|
|
162
|
+
logger.warn(`[music] ${name}: ${tag} 失败 (${describeSource(source)}): ${String(error)}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// 新版 NapCat:段内自带真实下载 URL
|
|
169
|
+
if (isHttpUrl(segmentUrl)) {
|
|
170
|
+
if (await trySources([{ url: segmentUrl }], "消息段 url")) return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 新版 NapCat:get_file(enableLocalFile2Url 时返回 base64,否则返回 NapCat 本地路径)
|
|
174
|
+
for (const fileId of idCandidates) {
|
|
175
|
+
const result = await get_file(fileId);
|
|
176
|
+
if (await trySources(sourcesFromGetFile(result), `get_file(${fileId.slice(0, 60)})`)) return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// NapCat 扩展:私聊文件真实 URL
|
|
180
|
+
for (const fileId of idCandidates) {
|
|
181
|
+
try {
|
|
182
|
+
const result = await event.bot.sendApi<{ url?: string }>("get_private_file_url", {
|
|
183
|
+
file_id: fileId,
|
|
184
|
+
});
|
|
185
|
+
if (isHttpUrl(result?.url)) {
|
|
186
|
+
if (await trySources([{ url: result.url }], "get_private_file_url")) return;
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
logger.warn(`[music] get_private_file_url(${fileId.slice(0, 60)}) 失败: ${String(error)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 旧版 NapCat 兜底:段内是本机绝对路径(bot 与 NapCat 同机时可用)
|
|
194
|
+
if (await trySources(segmentLocalPaths.map((localPath) => ({ localPath })), "本地路径直读")) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
throw lastError ?? new Error("无法获取文件下载地址");
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface IcqqFriendFileApi {
|
|
204
|
+
pickFriend(userId: string): {
|
|
205
|
+
getFileInfo(fid: string): Promise<{ url?: string; file?: string; name?: string }>;
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function fromIcqq(event: MessageEvent, seg: MessageSegment): IncomingFile | null {
|
|
210
|
+
const data = seg.data as SegmentData;
|
|
211
|
+
const fileField = strOf(data, "file");
|
|
212
|
+
const fid = strOf(data, "fid") ?? fileField?.replace(/^fid:/, "");
|
|
213
|
+
const name = strOf(data, "name") ?? attachmentOf(seg)?.name;
|
|
214
|
+
if (!fid || !name) return null;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
name,
|
|
218
|
+
async saveTo(destPath: string): Promise<void> {
|
|
219
|
+
const bot = event.bot.as<IcqqFriendFileApi>();
|
|
220
|
+
if (typeof bot.pickFriend !== "function") {
|
|
221
|
+
throw new Error("当前 icqq bot 不支持 pickFriend,无法下载文件");
|
|
222
|
+
}
|
|
223
|
+
const info = await bot.pickFriend(String(event.user_id ?? "")).getFileInfo(fid);
|
|
224
|
+
await saveSource(
|
|
225
|
+
{
|
|
226
|
+
url: typeof info?.url === "string" ? info.url : undefined,
|
|
227
|
+
localPath: typeof info?.file === "string" ? info.file : undefined,
|
|
228
|
+
},
|
|
229
|
+
destPath,
|
|
230
|
+
);
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function fromAttachment(_event: MessageEvent, seg: MessageSegment): IncomingFile | null {
|
|
236
|
+
const data = seg.data as SegmentData;
|
|
237
|
+
const attachment = attachmentOf(seg);
|
|
238
|
+
const url = strOf(data, "url") ?? attachment?.url;
|
|
239
|
+
const name =
|
|
240
|
+
strOf(data, "name") ??
|
|
241
|
+
attachment?.name ??
|
|
242
|
+
(isHttpUrl(url) ? decodeURIComponent(baseNameOf(url)) : undefined);
|
|
243
|
+
if (!name || !isHttpUrl(url)) return null;
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
name,
|
|
247
|
+
async saveTo(destPath: string): Promise<void> {
|
|
248
|
+
await downloadTo(url, destPath);
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildIncomingFile(
|
|
254
|
+
event: MessageEvent,
|
|
255
|
+
seg: MessageSegment,
|
|
256
|
+
logger: Logger,
|
|
257
|
+
): IncomingFile | null {
|
|
258
|
+
const adapter = event.identity.adapter;
|
|
259
|
+
if (adapter === "onebotv11") return fromOneBot(event, seg, logger);
|
|
260
|
+
if (adapter === "icqq") return fromIcqq(event, seg);
|
|
261
|
+
return fromAttachment(event, seg);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function extractIncomingFiles(
|
|
265
|
+
event: MessageEvent,
|
|
266
|
+
extensions: readonly string[],
|
|
267
|
+
logger: Logger,
|
|
268
|
+
): IncomingFile[] {
|
|
269
|
+
const normalized = extensions.map((ext) => ext.toLowerCase());
|
|
270
|
+
const files: IncomingFile[] = [];
|
|
271
|
+
for (const seg of event.message.filterByType("file")) {
|
|
272
|
+
const file = buildIncomingFile(event, seg, logger);
|
|
273
|
+
if (!file) continue;
|
|
274
|
+
if (normalized.some((ext) => file.name.toLowerCase().endsWith(ext))) {
|
|
275
|
+
files.push(file);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return files;
|
|
279
|
+
}
|
package/dumps/ncmdump.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { NcmdumpServiceApi } from "mioku-service-ncmdump";
|
|
2
|
+
import type { DumpProviderName } from "../types";
|
|
3
|
+
import type { DumpProvider, DumpResult, DumpTarget } from "./types";
|
|
4
|
+
|
|
5
|
+
export class NcmdumpProvider implements DumpProvider {
|
|
6
|
+
readonly name: DumpProviderName = "ncmdump";
|
|
7
|
+
readonly extensions: readonly string[] = [".ncm"];
|
|
8
|
+
|
|
9
|
+
private readonly service: NcmdumpServiceApi;
|
|
10
|
+
|
|
11
|
+
constructor(service: NcmdumpServiceApi) {
|
|
12
|
+
this.service = service;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async dump(target: DumpTarget): Promise<DumpResult> {
|
|
16
|
+
const result = await this.service.convert({
|
|
17
|
+
inputPath: target.sourcePath,
|
|
18
|
+
outputDir: target.outputDir,
|
|
19
|
+
});
|
|
20
|
+
return { filePath: result.filePath, fileName: result.fileName };
|
|
21
|
+
}
|
|
22
|
+
}
|
package/dumps/types.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DumpProviderName } from "../types";
|
|
2
|
+
|
|
3
|
+
export interface DumpTarget {
|
|
4
|
+
readonly sourcePath: string;
|
|
5
|
+
readonly fileName: string;
|
|
6
|
+
readonly outputDir: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DumpResult {
|
|
10
|
+
readonly filePath: string;
|
|
11
|
+
readonly fileName: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DumpProvider {
|
|
15
|
+
readonly name: DumpProviderName;
|
|
16
|
+
readonly extensions: readonly string[];
|
|
17
|
+
dump(target: DumpTarget): Promise<DumpResult>;
|
|
18
|
+
}
|
package/index.ts
CHANGED
|
@@ -1,42 +1,76 @@
|
|
|
1
1
|
import { definePlugin } from "mioku";
|
|
2
|
-
import { AppleMusicService } from "mioku-service-applemusic";
|
|
3
|
-
import { NeteaseService } from "mioku-service-netease";
|
|
4
2
|
import { getService, Services } from "mioku";
|
|
3
|
+
import type { MessageEvent } from "mioku";
|
|
4
|
+
import type { AppleMusicServiceApi } from "mioku-service-applemusic";
|
|
5
|
+
import type { NeteaseServiceApi } from "mioku-service-netease";
|
|
6
|
+
import type { NcmdumpServiceApi } from "mioku-service-ncmdump";
|
|
5
7
|
import { MusicPluginRuntime } from "./runtime-core/service";
|
|
6
8
|
import { MUSIC_DEFAULTS } from "./config";
|
|
7
9
|
import type { MusicBaseConfig } from "./types";
|
|
8
10
|
import { createMusicSkills } from "./skills/music";
|
|
11
|
+
import { importOptionalService } from "./runtime-core/optional-service";
|
|
12
|
+
import { installMissingOptionalServices } from "./runtime-core/auto-install";
|
|
13
|
+
import { DumpFlow } from "./runtime-core/dump-flow";
|
|
14
|
+
import { dumpProviderExtensions } from "./dumps/factory";
|
|
15
|
+
import { extractIncomingFiles } from "./dumps/file-source";
|
|
9
16
|
|
|
10
17
|
function cloneConfig<T>(value: T): T {
|
|
11
18
|
return JSON.parse(JSON.stringify(value)) as T;
|
|
12
19
|
}
|
|
13
20
|
|
|
21
|
+
function mergeWithDefaults(next: unknown): MusicBaseConfig {
|
|
22
|
+
return { ...cloneConfig(MUSIC_DEFAULTS), ...(next as MusicBaseConfig) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type PrivateMessageRoute =
|
|
26
|
+
| "onebotv11:message.private"
|
|
27
|
+
| "icqq:message.private"
|
|
28
|
+
| "qq-official:message.private";
|
|
29
|
+
|
|
30
|
+
const PRIVATE_MESSAGE_ROUTES: readonly PrivateMessageRoute[] = [
|
|
31
|
+
"onebotv11:message.private",
|
|
32
|
+
"icqq:message.private",
|
|
33
|
+
"qq-official:message.private",
|
|
34
|
+
];
|
|
35
|
+
|
|
14
36
|
export default definePlugin({
|
|
15
37
|
name: "music",
|
|
16
38
|
async setup(ctx) {
|
|
17
39
|
const configService = getService(ctx, Services.Config);
|
|
18
40
|
const aiService = getService(ctx, Services.AI);
|
|
19
41
|
const screenshotService = getService(ctx, Services.Screenshot);
|
|
20
|
-
const
|
|
21
|
-
|
|
42
|
+
const [applemusicRef, neteaseRef, ncmdumpRef] = await Promise.all([
|
|
43
|
+
importOptionalService<AppleMusicServiceApi>(
|
|
44
|
+
"mioku-service-applemusic",
|
|
45
|
+
"AppleMusicService",
|
|
46
|
+
),
|
|
47
|
+
importOptionalService<NeteaseServiceApi>("mioku-service-netease", "NeteaseService"),
|
|
48
|
+
importOptionalService<NcmdumpServiceApi>("mioku-service-ncmdump", "NcmdumpService"),
|
|
49
|
+
]);
|
|
50
|
+
const applemusicService = applemusicRef ? getService(ctx, applemusicRef) : undefined;
|
|
51
|
+
const neteaseService = neteaseRef ? getService(ctx, neteaseRef) : undefined;
|
|
52
|
+
const ncmdumpService = ncmdumpRef ? getService(ctx, ncmdumpRef) : undefined;
|
|
22
53
|
let baseConfig = cloneConfig(MUSIC_DEFAULTS);
|
|
23
54
|
|
|
24
55
|
if (configService) {
|
|
25
56
|
await configService.registerConfig("music", "base", baseConfig);
|
|
26
57
|
const nextBase = await configService.getConfig("music", "base");
|
|
27
58
|
if (nextBase) {
|
|
28
|
-
baseConfig = nextBase
|
|
59
|
+
baseConfig = mergeWithDefaults(nextBase);
|
|
29
60
|
}
|
|
30
61
|
} else {
|
|
31
62
|
ctx.logger.warn("config-service 未加载,music 插件将使用默认配置");
|
|
32
63
|
}
|
|
33
64
|
|
|
65
|
+
installMissingOptionalServices(ctx.logger, baseConfig);
|
|
66
|
+
|
|
34
67
|
const runtime = new MusicPluginRuntime({
|
|
35
68
|
logger: ctx.logger,
|
|
36
69
|
aiService,
|
|
37
70
|
screenshotService,
|
|
38
71
|
applemusicService,
|
|
39
72
|
neteaseService,
|
|
73
|
+
ncmdumpService,
|
|
40
74
|
});
|
|
41
75
|
runtime.updateConfig(baseConfig);
|
|
42
76
|
|
|
@@ -48,13 +82,28 @@ export default definePlugin({
|
|
|
48
82
|
if (configService) {
|
|
49
83
|
disposers.push(
|
|
50
84
|
configService.onConfigChange("music", "base", (next) => {
|
|
51
|
-
baseConfig = next
|
|
85
|
+
baseConfig = mergeWithDefaults(next);
|
|
52
86
|
runtime.updateConfig(baseConfig);
|
|
87
|
+
installMissingOptionalServices(ctx.logger, baseConfig);
|
|
53
88
|
}),
|
|
54
89
|
);
|
|
55
90
|
}
|
|
56
91
|
|
|
57
|
-
const
|
|
92
|
+
const dumpFlow = new DumpFlow({ logger: ctx.logger });
|
|
93
|
+
disposers.push(
|
|
94
|
+
ctx.handle(PRIVATE_MESSAGE_ROUTES, (event) => {
|
|
95
|
+
const provider = runtime.resolveDumpProvider();
|
|
96
|
+
if (!provider) return;
|
|
97
|
+
const files = extractIncomingFiles(
|
|
98
|
+
event,
|
|
99
|
+
dumpProviderExtensions(provider.name),
|
|
100
|
+
ctx.logger,
|
|
101
|
+
);
|
|
102
|
+
for (const file of files) dumpFlow.enqueue(ctx, event, provider, file);
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const react = (event: MessageEvent) => runtime.tryReactToCommandMessage(ctx, event);
|
|
58
107
|
|
|
59
108
|
ctx.command({
|
|
60
109
|
prefixes: false,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mioku-plugin-music",
|
|
3
|
-
"version": "3.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"description": "音乐插件:点歌、搜索音乐、听歌语音发送、私聊 ncm 文件解密",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"keywords": [
|
|
@@ -21,11 +21,13 @@
|
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"mioku": "^1.0.0",
|
|
23
23
|
"mioku-service-applemusic": "^3.0.0",
|
|
24
|
-
"mioku-service-netease": "^2.0.0"
|
|
24
|
+
"mioku-service-netease": "^2.0.0",
|
|
25
|
+
"mioku-service-ncmdump": "^1.0.0"
|
|
25
26
|
},
|
|
26
27
|
"devDependencies": {
|
|
27
28
|
"mioku": "workspace:*",
|
|
28
29
|
"mioku-service-applemusic": "workspace:*",
|
|
29
|
-
"mioku-service-netease": "workspace:*"
|
|
30
|
+
"mioku-service-netease": "workspace:*",
|
|
31
|
+
"mioku-service-ncmdump": "workspace:*"
|
|
30
32
|
}
|
|
31
33
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { Logger } from "mioku";
|
|
5
|
+
import type { MusicBaseConfig } from "../types";
|
|
6
|
+
|
|
7
|
+
const SERVICE_PACKAGE_PREFIX = "mioku-service-";
|
|
8
|
+
const attempted = new Set<string>();
|
|
9
|
+
|
|
10
|
+
function desiredServiceShortNames(config: MusicBaseConfig): string[] {
|
|
11
|
+
const names: string[] = [];
|
|
12
|
+
for (const value of [config.defaultProvider, config.dumpProvider]) {
|
|
13
|
+
const short = String(value ?? "").trim().toLowerCase();
|
|
14
|
+
if (short) names.push(short);
|
|
15
|
+
}
|
|
16
|
+
return [...new Set(names)];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isPackageInstalled(pkgName: string): boolean {
|
|
20
|
+
return existsSync(path.join(process.cwd(), "node_modules", pkgName, "package.json"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function installMissingOptionalServices(
|
|
24
|
+
logger: Logger,
|
|
25
|
+
config: MusicBaseConfig,
|
|
26
|
+
): void {
|
|
27
|
+
for (const short of desiredServiceShortNames(config)) {
|
|
28
|
+
const pkgName = `${SERVICE_PACKAGE_PREFIX}${short}`;
|
|
29
|
+
if (attempted.has(pkgName) || isPackageInstalled(pkgName)) continue;
|
|
30
|
+
attempted.add(pkgName);
|
|
31
|
+
|
|
32
|
+
logger.info(
|
|
33
|
+
`[music] ${short} 已在配置中启用但服务包未安装,正在后台执行 bun add ${pkgName},完成后重启生效`,
|
|
34
|
+
);
|
|
35
|
+
const child = spawn("bun", ["add", pkgName], {
|
|
36
|
+
cwd: process.cwd(),
|
|
37
|
+
detached: true,
|
|
38
|
+
shell: process.platform === "win32",
|
|
39
|
+
stdio: "ignore",
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
});
|
|
42
|
+
child.on("error", (err: unknown) => {
|
|
43
|
+
logger.warn(`[music] 自动安装 ${pkgName} 失败: ${String(err)}`);
|
|
44
|
+
});
|
|
45
|
+
child.on("close", (code: number | null) => {
|
|
46
|
+
if (code === 0) {
|
|
47
|
+
logger.info(`[music] ${pkgName} 安装完成,重启后生效`);
|
|
48
|
+
} else {
|
|
49
|
+
logger.warn(`[music] 自动安装 ${pkgName} 失败(退出码 ${code ?? "unknown"})`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
child.unref();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { Logger, MessageEvent, MiokuContext } from "mioku";
|
|
5
|
+
import type { DumpProvider } from "../dumps/types";
|
|
6
|
+
import type { IncomingFile } from "../dumps/file-source";
|
|
7
|
+
import { sendFileMessage } from "./message";
|
|
8
|
+
|
|
9
|
+
const DUMP_TEMP_ROOT = path.join(process.cwd(), "temp", "ncmdump");
|
|
10
|
+
const JOBS_DIR = path.join(DUMP_TEMP_ROOT, "jobs");
|
|
11
|
+
|
|
12
|
+
function sanitizeFileName(name: string): string {
|
|
13
|
+
const base = path.basename(name).replace(/[\u0000-\u001f]/g, "");
|
|
14
|
+
return base.length > 0 ? base : `file-${Date.now()}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function stripExtension(fileName: string): string {
|
|
18
|
+
const index = fileName.lastIndexOf(".");
|
|
19
|
+
return index > 0 ? fileName.slice(0, index) : fileName;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function shortError(error: unknown): string {
|
|
23
|
+
return String(error)
|
|
24
|
+
.replace(/^Error:\s*/, "")
|
|
25
|
+
.trim()
|
|
26
|
+
.slice(0, 160);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function createJobDir(): Promise<string> {
|
|
30
|
+
const jobDir = path.join(JOBS_DIR, `${Date.now()}-${randomUUID().slice(0, 8)}`);
|
|
31
|
+
await fs.mkdir(jobDir, { recursive: true });
|
|
32
|
+
return jobDir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class DumpFlow {
|
|
36
|
+
private tail: Promise<void> = Promise.resolve();
|
|
37
|
+
|
|
38
|
+
constructor(private readonly deps: { logger: Logger }) {}
|
|
39
|
+
|
|
40
|
+
enqueue(ctx: MiokuContext, event: MessageEvent, provider: DumpProvider, file: IncomingFile): void {
|
|
41
|
+
this.tail = this.tail
|
|
42
|
+
.then(() => this.process(ctx, event, provider, file))
|
|
43
|
+
.catch((error: unknown) => {
|
|
44
|
+
this.deps.logger.warn(`[music] dump 任务异常: ${shortError(error)}`);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async process(
|
|
49
|
+
ctx: MiokuContext,
|
|
50
|
+
event: MessageEvent,
|
|
51
|
+
provider: DumpProvider,
|
|
52
|
+
file: IncomingFile,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const { logger } = this.deps;
|
|
55
|
+
const jobDir = await createJobDir();
|
|
56
|
+
try {
|
|
57
|
+
const sourcePath = path.join(jobDir, sanitizeFileName(file.name));
|
|
58
|
+
await file.saveTo(sourcePath);
|
|
59
|
+
const result = await provider.dump({
|
|
60
|
+
sourcePath,
|
|
61
|
+
fileName: file.name,
|
|
62
|
+
outputDir: jobDir,
|
|
63
|
+
});
|
|
64
|
+
await sendFileMessage(ctx, event, result.filePath, stripExtension(result.fileName));
|
|
65
|
+
logger.info(`[music] dump 完成: ${file.name} -> ${result.fileName} (${provider.name})`);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
logger.error(`[music] dump 失败 (${file.name}): ${shortError(error)}`);
|
|
68
|
+
} finally {
|
|
69
|
+
await fs.rm(jobDir, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
package/runtime-core/message.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "fs/promises";
|
|
2
2
|
import * as path from "path";
|
|
3
|
+
import type { MessageEvent, MessageSegment, MiokuContext } from "mioku";
|
|
3
4
|
|
|
4
5
|
function normalizeFileSource(file: string): string {
|
|
5
6
|
const value = String(file || "").trim();
|
|
@@ -15,112 +16,88 @@ function normalizeFileSource(file: string): string {
|
|
|
15
16
|
return value;
|
|
16
17
|
}
|
|
17
18
|
if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value)) {
|
|
18
|
-
|
|
19
|
+
const normalized = value.replace(/\\/g, "/");
|
|
20
|
+
return `file:///${normalized.replace(/^\//, "")}`;
|
|
19
21
|
}
|
|
20
22
|
return value;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
function getBotAndTarget(
|
|
24
|
-
bot:
|
|
25
|
+
function getBotAndTarget(event: MessageEvent): {
|
|
26
|
+
bot: MessageEvent["bot"];
|
|
25
27
|
groupId?: string;
|
|
26
28
|
userId?: string;
|
|
27
29
|
} {
|
|
28
|
-
const bot = event?.bot;
|
|
29
30
|
const groupId = String(event?.group_id ?? "").trim();
|
|
30
31
|
const userId = String(event?.user_id ?? "").trim();
|
|
31
32
|
|
|
32
33
|
return {
|
|
33
|
-
bot,
|
|
34
|
+
bot: event?.bot,
|
|
34
35
|
groupId: event?.message_type === "group" ? groupId : undefined,
|
|
35
36
|
userId: event?.message_type !== "group" ? userId : undefined,
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
ctx:
|
|
41
|
-
event:
|
|
42
|
-
|
|
40
|
+
async function sendSegments(
|
|
41
|
+
ctx: MiokuContext,
|
|
42
|
+
event: MessageEvent,
|
|
43
|
+
segments: readonly MessageSegment[],
|
|
43
44
|
): Promise<void> {
|
|
44
|
-
const { bot, groupId, userId } = getBotAndTarget(
|
|
45
|
-
const payload: any[] = [];
|
|
46
|
-
|
|
47
|
-
payload.push(ctx.segment.text(text));
|
|
48
|
-
|
|
45
|
+
const { bot, groupId, userId } = getBotAndTarget(event);
|
|
49
46
|
if (bot && groupId != null && groupId !== "") {
|
|
50
|
-
await bot.sendGroupMsg(groupId,
|
|
47
|
+
await bot.sendGroupMsg(groupId, [...segments]);
|
|
51
48
|
return;
|
|
52
49
|
}
|
|
53
50
|
if (bot && userId != null && userId !== "") {
|
|
54
|
-
await bot.sendPrivateMsg(userId,
|
|
51
|
+
await bot.sendPrivateMsg(userId, [...segments]);
|
|
55
52
|
return;
|
|
56
53
|
}
|
|
57
54
|
if (typeof event?.reply === "function") {
|
|
58
|
-
await event.reply(
|
|
55
|
+
await event.reply([...segments]);
|
|
59
56
|
return;
|
|
60
57
|
}
|
|
61
|
-
throw new Error("
|
|
58
|
+
throw new Error("当前上下文不支持消息发送");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function sendTextMessage(
|
|
62
|
+
ctx: MiokuContext,
|
|
63
|
+
event: MessageEvent,
|
|
64
|
+
text: string,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
await sendSegments(ctx, event, [ctx.segment.text(text)]);
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
export async function sendImageMessage(
|
|
65
|
-
ctx:
|
|
66
|
-
event:
|
|
70
|
+
ctx: MiokuContext,
|
|
71
|
+
event: MessageEvent,
|
|
67
72
|
imagePath: string,
|
|
68
73
|
): Promise<void> {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (bot && groupId != null && groupId !== "") {
|
|
75
|
-
await bot.sendGroupMsg(groupId, payload);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
if (bot && userId != null && userId !== "") {
|
|
79
|
-
await bot.sendPrivateMsg(userId, payload);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
if (typeof event?.reply === "function") {
|
|
83
|
-
await event.reply(payload);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
throw new Error("当前上下文不支持图片发送");
|
|
74
|
+
const send = async (source: string): Promise<void> => {
|
|
75
|
+
await sendSegments(ctx, event, [
|
|
76
|
+
ctx.segment.image(normalizeFileSource(source)),
|
|
77
|
+
]);
|
|
87
78
|
};
|
|
88
79
|
|
|
89
80
|
try {
|
|
90
|
-
await
|
|
81
|
+
await send(imagePath);
|
|
91
82
|
} catch {
|
|
92
83
|
const buffer = await fs.readFile(imagePath);
|
|
93
|
-
|
|
94
|
-
await sendPayload(base64);
|
|
84
|
+
await send(`base64://${buffer.toString("base64")}`);
|
|
95
85
|
}
|
|
96
86
|
}
|
|
97
87
|
|
|
98
88
|
export async function sendFileMessage(
|
|
99
|
-
ctx:
|
|
100
|
-
event:
|
|
89
|
+
ctx: MiokuContext,
|
|
90
|
+
event: MessageEvent,
|
|
101
91
|
filePath: string,
|
|
102
92
|
name: string,
|
|
103
93
|
): Promise<void> {
|
|
104
94
|
const ext = path.extname(filePath);
|
|
105
95
|
const fileName = ext ? `${name}${ext}` : name;
|
|
106
|
-
const { bot, groupId, userId } = getBotAndTarget(ctx, event);
|
|
107
|
-
const sendPayload = async (source: string) => {
|
|
108
|
-
const payload: any[] = [];
|
|
109
|
-
payload.push(ctx.segment.file(normalizeFileSource(source), { name: fileName }));
|
|
110
96
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (bot && userId != null && userId !== "") {
|
|
116
|
-
await bot.sendPrivateMsg(userId, payload);
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
if (typeof event?.reply === "function") {
|
|
120
|
-
await event.reply(payload);
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
throw new Error("当前上下文不支持文件发送");
|
|
97
|
+
const send = async (source: string): Promise<void> => {
|
|
98
|
+
await sendSegments(ctx, event, [
|
|
99
|
+
ctx.segment.file(normalizeFileSource(source), { name: fileName }),
|
|
100
|
+
]);
|
|
124
101
|
};
|
|
125
102
|
|
|
126
103
|
const canReadLocalFile =
|
|
@@ -128,7 +105,7 @@ export async function sendFileMessage(
|
|
|
128
105
|
|
|
129
106
|
let fileSendError: unknown;
|
|
130
107
|
try {
|
|
131
|
-
await
|
|
108
|
+
await send(filePath);
|
|
132
109
|
return;
|
|
133
110
|
} catch (error) {
|
|
134
111
|
fileSendError = error;
|
|
@@ -149,9 +126,8 @@ export async function sendFileMessage(
|
|
|
149
126
|
: new Error(String(fileSendError || "文件发送失败"));
|
|
150
127
|
}
|
|
151
128
|
|
|
152
|
-
const base64 = `base64://${buffer.toString("base64")}`;
|
|
153
129
|
try {
|
|
154
|
-
await
|
|
130
|
+
await send(`base64://${buffer.toString("base64")}`);
|
|
155
131
|
} catch (base64Error) {
|
|
156
132
|
const message = String(base64Error || "");
|
|
157
133
|
const timeoutLike =
|
|
@@ -167,28 +143,14 @@ export async function sendFileMessage(
|
|
|
167
143
|
}
|
|
168
144
|
|
|
169
145
|
export async function sendRecordMessage(
|
|
170
|
-
ctx:
|
|
171
|
-
event:
|
|
146
|
+
ctx: MiokuContext,
|
|
147
|
+
event: MessageEvent,
|
|
172
148
|
audioPath: string,
|
|
173
149
|
): Promise<void> {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (bot && groupId != null && groupId !== "") {
|
|
180
|
-
await bot.sendGroupMsg(groupId, payload);
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
if (bot && userId != null && userId !== "") {
|
|
184
|
-
await bot.sendPrivateMsg(userId, payload);
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
if (typeof event?.reply === "function") {
|
|
188
|
-
await event.reply(payload);
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
throw new Error("当前上下文不支持语音发送");
|
|
150
|
+
const send = async (source: string): Promise<void> => {
|
|
151
|
+
await sendSegments(ctx, event, [
|
|
152
|
+
ctx.segment.record(normalizeFileSource(source)),
|
|
153
|
+
]);
|
|
192
154
|
};
|
|
193
155
|
|
|
194
156
|
const canReadLocalFile =
|
|
@@ -196,7 +158,7 @@ export async function sendRecordMessage(
|
|
|
196
158
|
|
|
197
159
|
let fileSendError: unknown;
|
|
198
160
|
try {
|
|
199
|
-
await
|
|
161
|
+
await send(audioPath);
|
|
200
162
|
return;
|
|
201
163
|
} catch (error) {
|
|
202
164
|
fileSendError = error;
|
|
@@ -217,9 +179,8 @@ export async function sendRecordMessage(
|
|
|
217
179
|
: new Error(String(fileSendError || "语音发送失败"));
|
|
218
180
|
}
|
|
219
181
|
|
|
220
|
-
const base64 = `base64://${buffer.toString("base64")}`;
|
|
221
182
|
try {
|
|
222
|
-
await
|
|
183
|
+
await send(`base64://${buffer.toString("base64")}`);
|
|
223
184
|
} catch (base64Error) {
|
|
224
185
|
const message = String(base64Error || "");
|
|
225
186
|
const timeoutLike =
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ServiceRef } from "mioku";
|
|
2
|
+
|
|
3
|
+
export async function importOptionalService<T>(
|
|
4
|
+
moduleName: string,
|
|
5
|
+
exportName: string,
|
|
6
|
+
): Promise<ServiceRef<T> | undefined> {
|
|
7
|
+
try {
|
|
8
|
+
const mod = (await import(moduleName)) as Record<string, unknown>;
|
|
9
|
+
const ref = mod[exportName];
|
|
10
|
+
return ref ? (ref as ServiceRef<T>) : undefined;
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/runtime-core/service.ts
CHANGED
|
@@ -2,7 +2,14 @@ import type { AIService } from "mioku";
|
|
|
2
2
|
import type { ScreenshotService } from "mioku";
|
|
3
3
|
import type { AppleMusicServiceApi } from "mioku-service-applemusic";
|
|
4
4
|
import type { NeteaseServiceApi } from "mioku-service-netease";
|
|
5
|
+
import type { NcmdumpServiceApi } from "mioku-service-ncmdump";
|
|
5
6
|
import { MUSIC_DEFAULTS } from "../config";
|
|
7
|
+
import {
|
|
8
|
+
createDumpProvider,
|
|
9
|
+
getDumpProviderCandidates,
|
|
10
|
+
resolveDumpProviderName,
|
|
11
|
+
} from "../dumps/factory";
|
|
12
|
+
import type { DumpProvider } from "../dumps/types";
|
|
6
13
|
import {
|
|
7
14
|
createMusicProvider,
|
|
8
15
|
getMusicProviderCandidates,
|
|
@@ -30,6 +37,7 @@ interface MusicPluginRuntimeDeps {
|
|
|
30
37
|
screenshotService?: ScreenshotService;
|
|
31
38
|
applemusicService?: AppleMusicServiceApi;
|
|
32
39
|
neteaseService?: NeteaseServiceApi;
|
|
40
|
+
ncmdumpService?: NcmdumpServiceApi;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export class MusicPluginRuntime {
|
|
@@ -59,6 +67,31 @@ export class MusicPluginRuntime {
|
|
|
59
67
|
`music defaultProvider=${configured} 不可用,已回退到 ${resolved}`,
|
|
60
68
|
);
|
|
61
69
|
}
|
|
70
|
+
this.warnDumpProvider(nextConfig);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private warnDumpProvider(config: MusicBaseConfig): void {
|
|
74
|
+
const configured = String(config.dumpProvider || "").trim();
|
|
75
|
+
if (!configured) return;
|
|
76
|
+
if (resolveDumpProviderName(configured, this.getDumpServices())) return;
|
|
77
|
+
const candidates = getDumpProviderCandidates();
|
|
78
|
+
this.deps.logger.warn(
|
|
79
|
+
`music dumpProvider=${configured} 不可用,私聊文件解密未启用。候选列表=${candidates.join(", ")}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
resolveDumpProvider(): DumpProvider | null {
|
|
84
|
+
const services = this.getDumpServices();
|
|
85
|
+
const configured = String(this.config.dumpProvider || "").trim();
|
|
86
|
+
const resolved = resolveDumpProviderName(configured, services);
|
|
87
|
+
if (!resolved) return null;
|
|
88
|
+
return createDumpProvider(resolved, services);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private getDumpServices() {
|
|
92
|
+
return {
|
|
93
|
+
ncmdump: this.deps.ncmdumpService,
|
|
94
|
+
};
|
|
62
95
|
}
|
|
63
96
|
|
|
64
97
|
setSessionMediaUserToken(event: any, token: string): void {
|
package/types.ts
CHANGED
|
@@ -2,11 +2,16 @@ export type MusicProviderName = "applemusic" | "netease";
|
|
|
2
2
|
|
|
3
3
|
export const MUSIC_PROVIDER_NAMES: MusicProviderName[] = ["applemusic", "netease"];
|
|
4
4
|
|
|
5
|
+
export type DumpProviderName = "ncmdump";
|
|
6
|
+
|
|
7
|
+
export const DUMP_PROVIDER_NAMES: DumpProviderName[] = ["ncmdump"];
|
|
8
|
+
|
|
5
9
|
export type MusicProviderQuality = "standard" | "exhigh" | "lossless" | "hires";
|
|
6
10
|
|
|
7
11
|
export interface MusicBaseConfig {
|
|
8
12
|
searchLimit: number;
|
|
9
13
|
defaultProvider?: MusicProviderName | string;
|
|
14
|
+
dumpProvider?: DumpProviderName | string;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
export interface MusicTrack {
|