UNPKG

koishi-plugin-yesimbot-extension-sticker-manager

Version:
541 lines (540 loc) 20.5 kB
var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var service_exports = {}; __export(service_exports, { StickerService: () => StickerService }); module.exports = __toCommonJS(service_exports); var import_crypto = require("crypto"); var import_promises = require("fs/promises"); var import_koishi = require("koishi"); var import_shared = require("koishi-plugin-yesimbot/shared"); var import_path = __toESM(require("path")); var import_url = require("url"); const TableName = "yesimbot.stickers"; class StickerService { constructor(ctx, config) { this.ctx = ctx; this.config = config; this.logger = ctx[import_shared.Services.Logger].getLogger("[\u8868\u60C5\u7BA1\u7406]"); this.start(); } logger; static tablesRegistered = false; isReady = false; async start() { if (this.isReady) return; await this.initStorage(); await this.registerModels(); this.registerPromptSnippet(); this.isReady = true; this.logger.debug("\u8868\u60C5\u5305\u670D\u52A1\u5DF2\u5C31\u7EEA"); } whenReady() { return new Promise((resolve) => { if (this.isReady) { resolve(); } else { const check = () => { if (this.isReady) { resolve(); } else { setTimeout(check, 100); } }; check(); } }); } registerPromptSnippet() { const promptService = this.ctx[import_shared.Services.Prompt]; if (!promptService) { this.logger.warn("\u63D0\u793A\u8BCD\u670D\u52A1\u672A\u627E\u5230\uFF0C\u65E0\u6CD5\u6CE8\u518C\u5206\u7C7B\u5217\u8868"); return; } promptService.registerSnippet("sticker.categories", async () => { const categories = await this.getCategories(); return categories.join(", "); }); this.logger.debug("\u8868\u60C5\u5305\u5206\u7C7B\u5217\u8868\u5DF2\u6CE8\u518C\u5230\u63D0\u793A\u8BCD\u7CFB\u7EDF"); } async initStorage() { await (0, import_promises.mkdir)(this.config.storagePath, { recursive: true }); this.logger.info(`\u8868\u60C5\u5B58\u50A8\u76EE\u5F55\u5DF2\u521D\u59CB\u5316: ${this.config.storagePath}`); } async registerModels() { if (StickerService.tablesRegistered) return; StickerService.tablesRegistered = true; try { this.ctx.model.extend( TableName, { id: "string(64)", category: "string(255)", filePath: "string(255)", source: "json", createdAt: "timestamp" }, { primary: "id" } ); this.logger.debug("\u8868\u60C5\u5305\u8868\u5DF2\u521B\u5EFA"); } catch (error) { this.logger.error("\u521B\u5EFA\u8868\u60C5\u5305\u8868\u5931\u8D25", error); throw error; } } /** * 偷取表情包 * @param image_id string * @param session * @returns */ async stealSticker(image_id, session) { const assetService = this.ctx[import_shared.Services.Asset]; const imageDataForLLM = await assetService.read(image_id, { format: "data-url", image: { process: true, format: "jpeg" } }); const imageData = await assetService.read(image_id, { format: "buffer" }); const hash = (0, import_crypto.createHash)("sha256"); hash.update(image_id); const stickerId = hash.digest("hex"); const mimeType = imageDataForLLM.split(";")[0].split(":")[1]; const extension = this.getExtensionFromContentType(mimeType) || "png"; const destPath = import_path.default.resolve(this.config.storagePath, `${stickerId}.${extension}`); await (0, import_promises.writeFile)(destPath, imageData); const category = await this.classifySticker(imageDataForLLM); const record = { id: stickerId, category, filePath: destPath, source: { platform: session.platform, channelId: session.channelId, userId: session.userId, messageId: session.messageId }, createdAt: /* @__PURE__ */ new Date() }; await this.ctx.database.create(TableName, record); this.logger.debug(`\u5DF2\u4FDD\u5B58\u8868\u60C5: ${category} - ${stickerId}`); return record; } async classifySticker(imageData) { const categories = await this.getCategories(); const categoryList = categories.join(", "); const prompt = this.config.classificationPrompt.replace("{{categories}}", categoryList); const model = this.ctx[import_shared.Services.Model].getChatModel(this.config.classifiModel.providerName, this.config.classifiModel.modelId); if (!model || !model.isVisionModel()) { this.logger.error(`\u5F53\u524D\u6A21\u578B\u7EC4\u4E2D\u6CA1\u6709\u652F\u6301\u591A\u6A21\u6001\u7684\u6A21\u578B\u3002`); throw Error(); } try { const response = await model.chat({ messages: [ { role: "user", content: [ { type: "text", text: prompt }, // 使用动态生成的提示词 { type: "image_url", image_url: { url: imageData } } ] } ] }); return response.text.trim(); } catch (error) { this.logger.error("\u8868\u60C5\u5206\u7C7B\u5931\u8D25", error); return "\u5206\u7C7B\u5931\u8D25"; } } /** * 从外部文件夹导入表情包 * @param sourceDir 源文件夹路径 * @param session 会话对象(用于日志记录) * @returns 导入结果统计信息 */ async importFromDirectory(sourceDir, session) { const stats = { total: 0, success: 0, failed: 0, skipped: 0, failedFiles: [] }; if (!await this.dirExists(sourceDir)) { throw new Error(`\u6E90\u76EE\u5F55\u4E0D\u5B58\u5728: ${sourceDir}`); } const progressMsg = await session.sendQueued("\u5F00\u59CB\u5BFC\u5165\u8868\u60C5\u5305\uFF0C\u6B63\u5728\u626B\u63CF\u76EE\u5F55..."); try { const subdirs = await this.getValidSubdirectories(sourceDir); for (const [index, subdir] of subdirs.entries()) { const category = import_path.default.basename(subdir); const files = await this.getImageFiles(subdir); stats.total += files.length; for (const file of files) { try { const filePath = import_path.default.join(subdir, file); const result = await this.importSingleSticker(filePath, category); if (result === "success") { stats.success++; } else { stats.skipped++; } } catch (error) { stats.failed++; stats.failedFiles.push(file); this.logger.warn(`\u5BFC\u5165\u5931\u8D25: ${file} - ${error.message}`); } } } } finally { } return stats; } /** 获取有效的子目录列表 */ async getValidSubdirectories(dir) { const items = await (0, import_promises.readdir)(dir, { withFileTypes: true }); return items.filter((item) => item.isDirectory()).map((item) => import_path.default.join(dir, item.name)); } /** 获取目录下的所有图片文件 */ async getImageFiles(dir) { const items = await (0, import_promises.readdir)(dir, { withFileTypes: true }); return items.filter((item) => item.isFile() && this.isValidImageType(item.name)).map((item) => item.name); } /** 校验文件类型 */ isValidImageType(fileName) { const ext = import_path.default.extname(fileName).toLowerCase().slice(1); return ["jpg", "jpeg", "png", "gif", "webp"].includes(ext); } /** 计算文件哈希值 */ async calculateFileHash(filePath) { const buffer = await (0, import_promises.readFile)(filePath); const hash = (0, import_crypto.createHash)("sha256"); hash.update(buffer); return hash.digest("hex"); } async saveImageToLocal(url, content, contentType) { const id = (0, import_crypto.createHash)("sha256").update(url).digest("hex"); const extension = contentType.split("/")[1] || "bin"; const fileName = `${id}.${extension}`; const filePath = import_path.default.join(this.config.storagePath, fileName); await (0, import_promises.writeFile)(filePath, Buffer.from(content)); return { localPath: filePath }; } /** * 规范化 emojihub-bili URL * 处理特定格式的部分 URL */ normalizeEmojiHubUrl(rawUrl) { if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) { return rawUrl; } if (rawUrl.startsWith("https:https://")) { return rawUrl.replace("https:", ""); } if (rawUrl.startsWith("bfs/") || rawUrl.startsWith("/bfs/")) { return `https://i0.hdslb.com/${rawUrl.replace(/^\//, "")}`; } if (rawUrl.startsWith("meme/") || rawUrl.startsWith("/meme/")) { return `https://memes.none.bot/${rawUrl.replace(/^\//, "")}`; } return `https://i0.hdslb.com/bfs/${rawUrl}`; } /** 检查目录是否存在 */ async dirExists(dir) { try { await (0, import_promises.readdir)(dir); return true; } catch { return false; } } async getCategories() { const records = await this.ctx.database.select(TableName).execute(); return [...new Set(records.map((r) => r.category))]; } async getRandomSticker(category) { const records = await this.ctx.database.select(TableName).where({ category }).execute(); if (records.length === 0) return null; const randomIndex = Math.floor(Math.random() * records.length); const sticker = records[randomIndex]; const fileUrl = (0, import_url.pathToFileURL)(sticker.filePath).href; const ext = sticker.filePath.split(".").pop(); const b64 = await (0, import_promises.readFile)(sticker.filePath, "base64"); const base64Data = `data:image/${ext};base64,${b64}`; return import_koishi.h.image(base64Data, { "sub-type": "1" }); } async getStickersByCategory(category) { const records = await this.ctx.database.select(TableName).where({ category }).execute(); if (records.length === 0) return []; return records; } async importEmojiHubTxt(filePath, category, session) { const stats = { total: 0, success: 0, failed: 0, skipped: 0, failedUrls: [] }; let urls; try { const content = await (0, import_promises.readFile)(filePath, "utf-8"); urls = content.split("\n").map((url) => url.trim()).filter((url) => url.length > 0); } catch (error) { throw new Error(`\u65E0\u6CD5\u8BFB\u53D6\u6587\u4EF6: ${error.message}`); } stats.total = urls.length; if (stats.total === 0) { throw new Error("\u6587\u4EF6\u4E3A\u7A7A\u6216\u6CA1\u6709\u6709\u6548\u7684 URL"); } const progressMsg = await session.sendQueued(`\u5F00\u59CB\u5BFC\u5165\u8868\u60C5\u5305\uFF0C\u5171 ${stats.total} \u4E2A URL...`); try { const tempDir = import_path.default.join(this.config.storagePath, "temp"); await (0, import_promises.mkdir)(tempDir, { recursive: true }); this.logger.debug(`\u521B\u5EFA\u4E34\u65F6\u76EE\u5F55: ${tempDir}`); for (const [index, rawUrl] of urls.entries()) { if (index % 100 === 0 && progressMsg) { await session.sendQueued(`\u5DF2\u5904\u7406 ${index}/${urls.length} \u4E2A URL...`); } try { const url = this.normalizeEmojiHubUrl(rawUrl); const response = await this.fetchWithTimeout(url, 15e3); if (!response.ok) { throw new Error(`HTTP ${response.status} ${response.statusText}`); } const contentType = response.headers.get("content-type") || "image/jpeg"; const extension = this.getExtensionFromContentType(contentType) || "bin"; const fileHash = (0, import_crypto.createHash)("sha256").update(url).digest("hex"); const tempFilePath = import_path.default.join(this.config.storagePath, `${fileHash}.${extension}`); const buffer = await response.arrayBuffer(); await (0, import_promises.writeFile)(tempFilePath, Buffer.from(buffer)); this.logger.debug(`\u5DF2\u4E0B\u8F7D\u56FE\u7247: ${tempFilePath}`); const result = await this.importSingleSticker(tempFilePath, category, session); if (result === "success") { stats.success++; } else if (result === "duplicate") { stats.skipped++; try { await (0, import_promises.unlink)(tempFilePath); } catch (cleanupError) { this.logger.warn(`\u6E05\u7406\u4E34\u65F6\u6587\u4EF6\u5931\u8D25: ${tempFilePath}`, cleanupError); } } } catch (error) { stats.failed++; stats.failedUrls.push({ url: rawUrl, error: error.message }); this.logger.warn(`\u5BFC\u5165\u5931\u8D25: ${rawUrl} - ${error.message}`); } } } finally { if (progressMsg) { } } return stats; } /** * 根据Content-Type获取文件扩展名 */ getExtensionFromContentType(contentType) { const mimeMap = { "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/svg+xml": "svg", "image/bmp": "bmp" }; const cleanType = contentType.split(";")[0].trim().toLowerCase(); return mimeMap[cleanType] || null; } /** * 自定义 fetch 方法,带超时控制 */ async fetchWithTimeout(url, timeout) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { reject(new Error("\u8BF7\u6C42\u8D85\u65F6")); }, timeout); fetch(url).then((response) => { clearTimeout(timeoutId); resolve(response); }).catch((error) => { clearTimeout(timeoutId); reject(error); }); }); } /** * 清理临时目录 */ async cleanupTempDir(tempDir) { try { const files = await (0, import_promises.readdir)(tempDir); for (const file of files) { const filePath = import_path.default.join(tempDir, file); await (0, import_promises.unlink)(filePath); } await (0, import_promises.rmdir)(tempDir); this.logger.debug(`\u5DF2\u6E05\u7406\u4E34\u65F6\u76EE\u5F55: ${tempDir}`); } catch (error) { this.logger.warn(`\u6E05\u7406\u4E34\u65F6\u76EE\u5F55\u5931\u8D25: ${error.message}`); } } /** * 增强版 importSingleSticker 方法 */ async importSingleSticker(filePath, category, session) { if (!this.isValidImageFile(filePath)) { throw new Error("\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u7C7B\u578B"); } const fileHash = await this.calculateFileHash(filePath); const existing = await this.ctx.database.get(TableName, { id: fileHash }); if (existing.length > 0) { return "duplicate"; } const extension = import_path.default.extname(filePath) || ".png"; const destPath = import_path.default.resolve(this.config.storagePath, `${fileHash}${extension}`); await (0, import_promises.rename)(filePath, destPath); const record = { id: fileHash, category, filePath: destPath, source: { platform: session?.platform || "import", channelId: session?.channelId || "", userId: session?.userId || "", messageId: session?.messageId || "" }, createdAt: /* @__PURE__ */ new Date() }; await this.ctx.database.create(TableName, record); this.logger.info(`\u5DF2\u5BFC\u5165\u8868\u60C5: ${category}/${fileHash}${extension}`); return "success"; } /** * 增强版文件类型验证 */ isValidImageFile(filePath) { try { const extension = import_path.default.extname(filePath).toLowerCase().slice(1); return ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"].includes(extension); } catch { return false; } } async renameCategory(oldName, newName) { const result = await this.ctx.database.set(TableName, { category: oldName }, { category: newName }); const modified = result.matched; this.logger.info(`\u5DF2\u5C06\u5206\u7C7B "${oldName}" \u91CD\u547D\u540D\u4E3A "${newName}"\uFF0C\u66F4\u65B0\u4E86 ${modified} \u4E2A\u8868\u60C5\u5305`); return modified; } async deleteCategory(category) { const stickers = await this.ctx.database.get(TableName, { category: { $eq: category } }); const result = await this.ctx.database.remove(TableName, { category }); for (const sticker of stickers) { try { await (0, import_promises.unlink)(sticker.filePath); this.logger.debug(`\u5DF2\u5220\u9664\u8868\u60C5\u5305\u6587\u4EF6: ${sticker.filePath}`); } catch (error) { this.logger.warn(`\u5220\u9664\u6587\u4EF6\u5931\u8D25: ${sticker.filePath}`, error); } } this.logger.info(`\u5DF2\u5220\u9664\u5206\u7C7B "${category}"\uFF0C\u5171\u79FB\u9664 ${result.removed} \u4E2A\u8868\u60C5\u5305`); return result.removed; } /** * 合并两个分类 */ async mergeCategories(sourceCategory, targetCategory) { const result = await this.ctx.database.set(TableName, { category: sourceCategory }, { category: targetCategory }); this.logger.info(`\u5DF2\u5C06\u5206\u7C7B "${sourceCategory}" \u5408\u5E76\u5230 "${targetCategory}"\uFF0C\u79FB\u52A8\u4E86 ${result.modified} \u4E2A\u8868\u60C5\u5305`); return result.modified; } /** * 移动表情包到新分类 */ async moveSticker(stickerId, newCategory) { const result = await this.ctx.database.set(TableName, { id: stickerId }, { category: newCategory }); if (result.modified === 0) { throw new Error("\u672A\u627E\u5230\u8BE5\u8868\u60C5\u5305"); } this.logger.info(`\u5DF2\u5C06\u8868\u60C5\u5305 ${stickerId} \u79FB\u52A8\u5230\u5206\u7C7B "${newCategory}"`); return result.modified; } /** * 获取分类中的表情包数量 */ async getStickerCount(category) { const result = await this.ctx.database.get(TableName, { category: { $eq: category } }); return result.length; } /** * 获取指定表情包 */ async getSticker(stickerId) { const result = await this.ctx.database.get(TableName, { id: stickerId }); return result.length > 0 ? result[0] : null; } /** * 清理未使用的表情包 */ async cleanupUnreferenced() { const dbFiles = new Set((await this.ctx.database.select(TableName).execute()).map((r) => import_path.default.basename(r.filePath))); const fsFiles = await (0, import_promises.readdir)(this.config.storagePath); let deletedCount = 0; for (const file of fsFiles) { if (!dbFiles.has(file)) { try { await (0, import_promises.unlink)(import_path.default.join(this.config.storagePath, file)); this.logger.debug(`\u6E05\u7406\u672A\u5F15\u7528\u8868\u60C5: ${file}`); deletedCount++; } catch (error) { this.logger.warn(`\u6E05\u7406\u5931\u8D25: ${file}`, error); } } } return deletedCount; } } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { StickerService }); //# sourceMappingURL=service.js.map