koishi-plugin-yesimbot-extension-sticker-manager
Version:
YesImBot 表情包管理扩展
541 lines (540 loc) • 20.5 kB
JavaScript
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