mioku-plugin-agent 0.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.
@@ -0,0 +1,340 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import type { Bot } from "mioku";
5
+ import type { MediaAttachment, MediaKind } from "./media";
6
+
7
+ const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
8
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
9
+
10
+ export interface DownloadedMedia {
11
+ kind: MediaKind;
12
+ messageId: string;
13
+ name: string;
14
+ size: number;
15
+ path: string;
16
+ remoteUrl?: string;
17
+ }
18
+
19
+ export interface DownloadResult {
20
+ dir: string;
21
+ files: DownloadedMedia[];
22
+ errors: string[];
23
+ }
24
+
25
+ function dateStamp(now = new Date()): string {
26
+ const pad = (value: number) => String(value).padStart(2, "0");
27
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
28
+ }
29
+
30
+ function sanitizeName(value: string): string {
31
+ const base = path
32
+ .basename(value)
33
+ .replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_")
34
+ .trim();
35
+ return base && base !== "." && base !== ".." ? base.slice(0, 120) : "";
36
+ }
37
+
38
+ function nameFromUrl(source: string): string {
39
+ try {
40
+ const url = new URL(source);
41
+ return sanitizeName(decodeURIComponent(path.basename(url.pathname)));
42
+ } catch {
43
+ return "";
44
+ }
45
+ }
46
+
47
+ function extensionFromContentType(contentType: string | null): string {
48
+ const type = String(contentType ?? "")
49
+ .split(";")[0]
50
+ .trim()
51
+ .toLowerCase();
52
+ const map: Record<string, string> = {
53
+ "image/png": ".png",
54
+ "image/jpeg": ".jpg",
55
+ "image/gif": ".gif",
56
+ "image/webp": ".webp",
57
+ "video/mp4": ".mp4",
58
+ "audio/mpeg": ".mp3",
59
+ "audio/wav": ".wav",
60
+ "application/pdf": ".pdf",
61
+ "application/zip": ".zip",
62
+ "text/plain": ".txt",
63
+ "application/json": ".json",
64
+ };
65
+ return map[type] ?? "";
66
+ }
67
+
68
+ async function uniquePath(dir: string, name: string): Promise<string> {
69
+ const ext = path.extname(name);
70
+ const stem = ext ? name.slice(0, -ext.length) : name;
71
+ let candidate = path.join(dir, name);
72
+ let index = 1;
73
+ while (fs.existsSync(candidate)) {
74
+ candidate = path.join(dir, `${stem}-${index}${ext}`);
75
+ index += 1;
76
+ }
77
+ return candidate;
78
+ }
79
+
80
+ function bareFileName(value: string | undefined): string {
81
+ if (!value) return "";
82
+ const trimmed = value.trim();
83
+ if (!trimmed) return "";
84
+ if (/^(?:https?|base64|data|file):/i.test(trimmed)) return "";
85
+ if (/[\\/]/.test(trimmed)) return "";
86
+ return trimmed;
87
+ }
88
+
89
+ function chooseFileName(
90
+ item: MediaAttachment,
91
+ fallbackName: string,
92
+ contentType: string | null,
93
+ index: number,
94
+ ): string {
95
+ const candidates = [
96
+ sanitizeName(item.name ?? ""),
97
+ sanitizeName(bareFileName(item.file)),
98
+ sanitizeName(bareFileName(item.path)),
99
+ sanitizeName(item.path ? path.basename(item.path) : ""),
100
+ sanitizeName(fallbackName),
101
+ ].filter(Boolean);
102
+
103
+ const withExtension = candidates.find((name) => path.extname(name));
104
+ const base =
105
+ withExtension ??
106
+ candidates[0] ??
107
+ `${item.kind}_${item.messageId || "msg"}_${index + 1}`;
108
+ return path.extname(base)
109
+ ? base
110
+ : `${base}${extensionFromContentType(contentType)}`;
111
+ }
112
+
113
+ export function toFileUrl(filePath: string): string {
114
+ const normalized = String(filePath ?? "").replace(/\\/g, "/");
115
+ return normalized.startsWith("/")
116
+ ? `file://${normalized}`
117
+ : `file:///${normalized}`;
118
+ }
119
+
120
+ export function imageMimeOf(filePath: string): string {
121
+ const ext = path.extname(filePath).toLowerCase();
122
+ if (ext === ".png") return "image/png";
123
+ if (ext === ".webp") return "image/webp";
124
+ if (ext === ".gif") return "image/gif";
125
+ return "image/jpeg";
126
+ }
127
+
128
+ export function fileToDataUrl(filePath: string, buffer: Buffer): string {
129
+ return `data:${imageMimeOf(filePath)};base64,${buffer.toString("base64")}`;
130
+ }
131
+
132
+ export async function readImageDataUrl(filePath: string): Promise<string> {
133
+ const buffer = await fsp.readFile(filePath);
134
+ return fileToDataUrl(filePath, buffer);
135
+ }
136
+
137
+ async function fetchBuffer(
138
+ source: string,
139
+ ): Promise<{ buffer: Buffer; contentType: string | null }> {
140
+ const response = await fetch(source, {
141
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
142
+ });
143
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
144
+ const buffer = Buffer.from(await response.arrayBuffer());
145
+ return { buffer, contentType: response.headers.get("content-type") };
146
+ }
147
+
148
+ async function readSource(
149
+ source: string,
150
+ ): Promise<{
151
+ buffer: Buffer;
152
+ contentType: string | null;
153
+ fallbackName: string;
154
+ }> {
155
+ if (/^https?:/i.test(source)) {
156
+ const { buffer, contentType } = await fetchBuffer(source);
157
+ return { buffer, contentType, fallbackName: nameFromUrl(source) };
158
+ }
159
+ if (source.startsWith("base64://")) {
160
+ return {
161
+ buffer: Buffer.from(source.slice("base64://".length), "base64"),
162
+ contentType: null,
163
+ fallbackName: "",
164
+ };
165
+ }
166
+ if (source.startsWith("data:")) {
167
+ const comma = source.indexOf(",");
168
+ const meta = comma >= 0 ? source.slice(0, comma) : "";
169
+ const payload = comma >= 0 ? source.slice(comma + 1) : source;
170
+ const contentType = /^data:([^;,]+)/i.exec(meta)?.[1] ?? null;
171
+ const isBase64 = /;base64/i.test(meta);
172
+ return {
173
+ buffer: Buffer.from(payload, isBase64 ? "base64" : "utf-8"),
174
+ contentType,
175
+ fallbackName: "",
176
+ };
177
+ }
178
+
179
+ const localPath = source.startsWith("file://")
180
+ ? source.slice("file://".length)
181
+ : source;
182
+ const stat = await fsp.stat(localPath).catch(() => null);
183
+ if (!stat?.isFile()) {
184
+ throw new Error(
185
+ `source is neither a URL nor a readable local file: ${source}`,
186
+ );
187
+ }
188
+ return {
189
+ buffer: await fsp.readFile(localPath),
190
+ contentType: null,
191
+ fallbackName: sanitizeName(path.basename(localPath)),
192
+ };
193
+ }
194
+
195
+ function candidateSources(item: MediaAttachment): string[] {
196
+ const candidates: string[] = [];
197
+ const push = (value: string | undefined) => {
198
+ if (value && !candidates.includes(value)) candidates.push(value);
199
+ };
200
+ push(item.url && /^https?:/i.test(item.url) ? item.url : undefined);
201
+ push(
202
+ item.file && /^(?:https?|base64|data):/i.test(item.file)
203
+ ? item.file
204
+ : undefined,
205
+ );
206
+ push(item.url);
207
+ push(item.path);
208
+ push(item.file);
209
+ return candidates;
210
+ }
211
+
212
+ async function platformLookup(
213
+ bot: Bot | undefined,
214
+ item: MediaAttachment,
215
+ ): Promise<{ sources: string[]; names: string[] }> {
216
+ const sources: string[] = [];
217
+ const names: string[] = [];
218
+ if (!bot || !item.fileId) return { sources, names };
219
+
220
+ const attempts: Array<[string, Record<string, unknown>]> = [
221
+ ["get_file", { file_id: item.fileId }],
222
+ ];
223
+ if (item.groupId) {
224
+ attempts.push([
225
+ "get_group_file_url",
226
+ { group_id: Number(item.groupId), file_id: item.fileId },
227
+ ]);
228
+ }
229
+ if (item.userId) {
230
+ attempts.push([
231
+ "get_private_file_url",
232
+ { user_id: Number(item.userId), file_id: item.fileId },
233
+ ]);
234
+ }
235
+
236
+ for (const [action, params] of attempts) {
237
+ try {
238
+ const result = (await bot.sendApi(action, params)) as Record<
239
+ string,
240
+ unknown
241
+ > | null;
242
+ if (!result || typeof result !== "object") continue;
243
+ for (const key of ["url", "file", "path"]) {
244
+ const value = result[key];
245
+ if (typeof value === "string" && value.trim())
246
+ sources.push(value.trim());
247
+ }
248
+ const base64 = result.base64 ?? result.data;
249
+ if (typeof base64 === "string" && base64.trim()) {
250
+ sources.push(`base64://${base64.trim()}`);
251
+ }
252
+ for (const key of ["file_name", "name"]) {
253
+ const value = result[key];
254
+ if (typeof value === "string" && value.trim()) names.push(value.trim());
255
+ }
256
+ } catch {
257
+ // 平台不支持该 action 时忽略,继续尝试下一个
258
+ }
259
+ }
260
+ return { sources, names };
261
+ }
262
+
263
+ function describeFields(item: MediaAttachment): string {
264
+ const fields = Object.entries(item)
265
+ .filter(
266
+ ([key, value]) =>
267
+ key !== "kind" && key !== "messageId" && value !== undefined,
268
+ )
269
+ .map(([key, value]) => `${key}=${String(value).slice(0, 60)}`);
270
+ return fields.join(", ") || "(no usable fields)";
271
+ }
272
+
273
+ async function resolveSource(
274
+ item: MediaAttachment,
275
+ bot: Bot | undefined,
276
+ ): Promise<{
277
+ buffer: Buffer;
278
+ contentType: string | null;
279
+ fallbackName: string;
280
+ }> {
281
+ const platform = await platformLookup(bot, item);
282
+ const candidates = [...candidateSources(item), ...platform.sources];
283
+ if (candidates.length === 0) {
284
+ throw new Error(`no downloadable source (${describeFields(item)})`);
285
+ }
286
+ let lastError = "";
287
+ for (const source of candidates) {
288
+ try {
289
+ const result = await readSource(source);
290
+ // 平台返回的原始文件名最可信,其次才是 URL 推断出来的名字
291
+ return {
292
+ ...result,
293
+ fallbackName: platform.names[0] ?? result.fallbackName,
294
+ };
295
+ } catch (err) {
296
+ lastError = String(err);
297
+ }
298
+ }
299
+ throw new Error(`${lastError} | fields: ${describeFields(item)}`);
300
+ }
301
+
302
+ export async function downloadMediaItems(
303
+ items: MediaAttachment[],
304
+ workspaceRoot: string,
305
+ options: { bot?: Bot } = {},
306
+ ): Promise<DownloadResult> {
307
+ const dir = path.join(workspaceRoot, "download", dateStamp());
308
+ const files: DownloadedMedia[] = [];
309
+ const errors: string[] = [];
310
+ if (items.length === 0) return { dir, files, errors };
311
+
312
+ await fsp.mkdir(dir, { recursive: true });
313
+ for (const [index, item] of items.entries()) {
314
+ try {
315
+ const { buffer, contentType, fallbackName } = await resolveSource(
316
+ item,
317
+ options.bot,
318
+ );
319
+ if (buffer.byteLength > MAX_DOWNLOAD_BYTES) {
320
+ errors.push(`#${index + 1} exceeds ${MAX_DOWNLOAD_BYTES} bytes`);
321
+ continue;
322
+ }
323
+ const name = chooseFileName(item, fallbackName, contentType, index);
324
+ const target = await uniquePath(dir, name);
325
+ await fsp.writeFile(target, buffer);
326
+ files.push({
327
+ kind: item.kind,
328
+ messageId: item.messageId,
329
+ name: path.basename(target),
330
+ size: buffer.byteLength,
331
+ path: target,
332
+ remoteUrl:
333
+ item.url && /^https?:/i.test(item.url) ? item.url : undefined,
334
+ });
335
+ } catch (err) {
336
+ errors.push(`#${index + 1} ${err}`);
337
+ }
338
+ }
339
+ return { dir, files, errors };
340
+ }
@@ -0,0 +1,47 @@
1
+ import type { ChatEmotionConfig } from "../types";
2
+
3
+ function normalizeName(value: unknown): string {
4
+ return String(value ?? "")
5
+ .trim()
6
+ .toLowerCase();
7
+ }
8
+
9
+ export class EmotionManager {
10
+ constructor(
11
+ private readonly store: (userId: number, emotion: string) => void,
12
+ ) {}
13
+
14
+ available(config: ChatEmotionConfig | null): string[] {
15
+ const names = Object.keys(config?.emotions ?? {})
16
+ .map(normalizeName)
17
+ .filter(Boolean);
18
+ return Array.from(new Set(["default", ...names]));
19
+ }
20
+
21
+ defaultEmotion(config: ChatEmotionConfig | null): string {
22
+ const available = this.available(config);
23
+ const candidate = normalizeName(config?.defaultEmotion);
24
+ return available.includes(candidate) ? candidate : "default";
25
+ }
26
+
27
+ resolve(stored: unknown, config: ChatEmotionConfig | null): string {
28
+ const available = this.available(config);
29
+ const candidate = normalizeName(stored);
30
+ if (candidate && available.includes(candidate)) return candidate;
31
+ return this.defaultEmotion(config);
32
+ }
33
+
34
+ getCurrent(stored: string, config: ChatEmotionConfig | null): string {
35
+ return this.resolve(stored, config);
36
+ }
37
+
38
+ setEmotion(
39
+ userId: number,
40
+ emotion: unknown,
41
+ config: ChatEmotionConfig | null,
42
+ ): string {
43
+ const next = this.resolve(emotion, config);
44
+ this.store(userId, next);
45
+ return next;
46
+ }
47
+ }