UNPKG

koishi-plugin-emojiluna

Version:

Smart emoji management plugin with AI categorization

1,507 lines (1,490 loc) 62.3 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 __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __export = (target, all) => { for (var name2 in all) __defProp(target, name2, { get: all[name2], 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); // src/index.ts var index_exports = {}; __export(index_exports, { Config: () => Config, apply: () => apply, inject: () => inject, name: () => name }); module.exports = __toCommonJS(index_exports); var import_koishi6 = require("koishi"); // src/service.ts var import_koishi2 = require("koishi"); // src/utils.ts var import_koishi = require("koishi"); async function handleImageUpload(session, content, handler) { let elements = import_koishi.h.select(session.elements, "img"); if (elements.length === 0) { elements = import_koishi.h.select(import_koishi.h.parse(content), "img"); } if (elements.length === 0) { elements = import_koishi.h.select(session.quote?.elements ?? [], "img"); } if (elements.length === 0) { throw new Error("没有找到图片"); } const images = []; const readImage = /* @__PURE__ */ __name(async (url) => { const response = await session.app.http(url, { responseType: "arraybuffer", method: "get", headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36" } }); const buffer = response.data; images.push(Buffer.from(buffer)); }, "readImage"); for (const element of elements) { const url = element.attrs.url ?? element.attrs.src; if (url.startsWith("data:image") && url.includes("base64")) { const base64 = url.split(",")[1]; images.push(Buffer.from(base64, "base64")); } else { try { await readImage(url); } catch (error) { session.app.logger.warn( `读取图片 ${url} 失败,请检查聊天适配器`, error ); } } } return await handler(images); } __name(handleImageUpload, "handleImageUpload"); function getImageType(buffer, pure = false) { const first10Bytes = new Uint8Array(buffer).slice(0, 10); const type = Buffer.from(first10Bytes).toString("base64", 0, 10); if (type.startsWith("iVBORw0KGgoAAAANSUhEUg")) { return pure ? "png" : "image/png"; } else if (type.startsWith("/9j/4AAQSkZJRg")) { return pure ? "jpeg" : "image/jpeg"; } else if (type.startsWith("R0lGOD")) { return pure ? "gif" : "image/gif"; } else if (type.startsWith("UklGRg")) { return pure ? "webp" : "image/webp"; } return pure ? "jpeg" : "image/jpeg"; } __name(getImageType, "getImageType"); function generateId() { return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); } __name(generateId, "generateId"); function formatFileSize(bytes) { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; } __name(formatFileSize, "formatFileSize"); function chunkArray(array, size) { return array.reduce((result, item, index) => { const chunkIndex = Math.floor(index / size); if (!result[chunkIndex]) { result[chunkIndex] = []; } result[chunkIndex].push(item); return result; }, []); } __name(chunkArray, "chunkArray"); // src/service.ts var import_path = __toESM(require("path")); var import_promises = __toESM(require("fs/promises")); var import_crypto = require("crypto"); var import_count_tokens = require("koishi-plugin-chatluna/llm-core/utils/count_tokens"); var import_messages = require("@langchain/core/messages"); var import_string = require("koishi-plugin-chatluna/utils/string"); var tryParse = /* @__PURE__ */ __name((text) => { try { return JSON.parse(text.trim()); } catch { return null; } }, "tryParse"); var extractors = [ (text) => text.trim(), (text) => text.replace(/```(?:json|JSON)?\s*/g, "").replace(/```\s*$/g, ""), (text) => { const start = text.indexOf("{"), end = text.lastIndexOf("}"); return start !== -1 && end !== -1 && start < end ? text.substring(start, end + 1) : text; }, (text) => { const start = text.indexOf("{"); if (start === -1) return text; let count = 0, end = -1; for (let i = start; i < text.length; i++) { if (text[i] === "{") count++; else if (text[i] === "}" && --count === 0) { end = i; break; } } return end !== -1 ? text.substring(start, end + 1) : text; } ]; var EmojiLunaService = class extends import_koishi2.Service { constructor(ctx, config) { super(ctx, "emojiluna", true); this.config = config; defineDatabase(ctx); this.initializeStorage(); this.initializeAI(); } static { __name(this, "EmojiLunaService"); } _emojiStorage = {}; _categories = {}; _model = null; async initializeStorage() { const storageDir = import_path.default.resolve( this.ctx.baseDir, this.config.storagePath ); try { await import_promises.default.access(storageDir); } catch { await import_promises.default.mkdir(storageDir, { recursive: true }); } this.ctx.on("ready", async () => { await this.loadEmojis(); await this.loadCategories(); }); this.ctx.on("dispose", () => { this._emojiStorage = {}; this._categories = {}; this._model = null; }); } async initializeAI() { if (!this.config.autoCategorize && !this.config.autoAnalyze) return; try { const [platform, modelName] = (0, import_count_tokens.parseRawModelName)(this.config.model); await this.ctx.chatluna.awaitLoadPlatform(platform); this._model = await this.ctx.chatluna.createChatModel( platform, modelName ); this.ctx.logger.success("AI模型加载成功"); } catch (error) { this.ctx.logger.error("AI模型加载失败:", error); } } parseAIResult(result) { for (const extractor of extractors) { const extracted = extractor(result); const parsed = tryParse(extracted); if (parsed) return parsed; } this.ctx.logger.error(`AI结果解析失败: ${result}`); return null; } async categorizeEmoji(imageBase64) { if (!this._model || !this.config.autoCategorize) return null; try { const prompt = this.config.categorizePrompt.replace( "{categories}", this.config.categories.join(", ") ); const result = await this._model.invoke([ new import_messages.SystemMessage(prompt), new import_messages.HumanMessage({ content: "请分析这个表情包", additional_kwargs: { images: [imageBase64] } }) ]); const parsedResult = this.parseAIResult( (0, import_string.getMessageContent)(result.content) ); if (parsedResult?.newCategory) { const newCategory = parsedResult.newCategory; const exists = await this.getCategoryByName(newCategory); if (!exists) { await this.addCategory(newCategory, `AI建议的新分类`); } parsedResult.category = newCategory; } return parsedResult; } catch (error) { this.ctx.logger.error("AI分类失败:", error); return null; } } async analyzeEmoji(imageBase64) { if (!this._model || !this.config.autoAnalyze) return null; try { const result = await this._model.invoke([ new import_messages.SystemMessage(this.config.analyzePrompt), new import_messages.HumanMessage({ content: "请分析这个表情包", additional_kwargs: { images: [imageBase64] } }) ]); const parsedResult = this.parseAIResult( (0, import_string.getMessageContent)(result.content) ); if (parsedResult?.newCategory) { const newCategory = parsedResult.newCategory; const exists = await this.getCategoryByName(newCategory); if (!exists) { await this.addCategory(newCategory, `AI建议的新分类`); } parsedResult.category = newCategory; } return parsedResult; } catch (error) { this.ctx.logger.error("AI分析失败:", error); return null; } } async addEmoji(options, imageData, aiAnalysis = this.config.autoAnalyze) { const id = (0, import_crypto.randomUUID)(); const fileName = `${id}.png`; const storageDir = import_path.default.resolve( this.ctx.baseDir, this.config.storagePath ); const filePath = import_path.default.join(storageDir, fileName); await import_promises.default.mkdir(storageDir, { recursive: true }); await import_promises.default.writeFile(filePath, imageData); let finalOptions = { ...options }; if (aiAnalysis) { const imageBase64 = imageData.toString("base64"); const aiResult = await this.analyzeEmoji(imageBase64); if (aiResult) { finalOptions = { name: aiResult.name || options.name, category: aiResult.category || options.category || "其他", tags: [ .../* @__PURE__ */ new Set([...options.tags || [], ...aiResult.tags]) ], description: aiResult.description }; } } else if (this.config.autoCategorize && !options.category) { const imageBase64 = imageData.toString("base64"); const categorizeResult = await this.categorizeEmoji(imageBase64); if (categorizeResult) { finalOptions.category = categorizeResult.category; } } const emoji = { id, name: finalOptions.name, category: finalOptions.category || "其他", path: filePath, size: imageData.length, createdAt: /* @__PURE__ */ new Date(), tags: finalOptions.tags || [] }; this._emojiStorage[id] = emoji; await this.ctx.database.upsert("emojiluna_emojis", [ { id: emoji.id, name: emoji.name, category: emoji.category, path: emoji.path, size: emoji.size, created_at: emoji.createdAt, tags: JSON.stringify(emoji.tags) } ]); await this.updateCategoryEmojiCount(emoji.category); this.ctx.logger.success(`Emoji added: ${emoji.name} (${emoji.id})`); this.ctx.emit("emojiluna/emoji-added", emoji); return emoji; } async addEmojis(emojis, aiAnalysis) { const createdEmojis = []; for (const { options, buffer } of emojis) { try { const createdEmoji = await this.addEmoji( options, buffer, aiAnalysis ); createdEmojis.push(createdEmoji); } catch (error) { this.ctx.logger.error( `Failed to add emoji ${options.name}:`, error ); } } return createdEmojis; } async getEmojiByName(name2) { return Object.values(this._emojiStorage).find( (emoji) => emoji.name === name2 || emoji.tags.some((tag) => tag === name2) || emoji.category === name2 || emoji.id === name2 ) || null; } async getEmojisByName(name2) { return Object.values(this._emojiStorage).filter( (emoji) => emoji.name === name2 || emoji.tags.some((tag) => tag === name2) || emoji.category === name2 ); } async categorizeExistingEmojis() { if (!this._model || !this.config.autoCategorize) { return { success: 0, failed: 0 }; } let success = 0, failed = 0; for (const emoji of Object.values(this._emojiStorage)) { try { const imageBuffer = await import_promises.default.readFile(emoji.path); const imageBase64 = imageBuffer.toString("base64"); const result = await this.categorizeEmoji(imageBase64); if (result && result.category !== emoji.category) { await this.updateEmojiCategory(emoji.id, result.category); success++; } } catch (error) { this.ctx.logger.error(`分类表情包 ${emoji.id} 失败:`, error); failed++; } } return { success, failed }; } async getEmojiList(options = {}) { const { category, tags, limit = void 0, offset = 0 } = options; let emojis = Object.values(this._emojiStorage); if (category) { emojis = emojis.filter((emoji) => emoji.category === category); } if (tags?.length) { emojis = emojis.filter( (emoji) => tags.some((tag) => emoji.tags.includes(tag)) ); } if (!limit) { return emojis; } return emojis.slice(offset, offset + limit); } async searchEmoji(keyword) { const emojis = Object.values(this._emojiStorage); return emojis.filter( (emoji) => emoji.name.includes(keyword) || emoji.tags.some((tag) => tag.includes(keyword)) ); } async getEmojiById(id) { return this._emojiStorage[id] || null; } async deleteEmoji(id) { const emoji = this._emojiStorage[id]; if (!emoji) return false; try { await import_promises.default.unlink(emoji.path); delete this._emojiStorage[id]; await this.ctx.database.remove("emojiluna_emojis", { id }); await this.updateCategoryEmojiCount(emoji.category); this.ctx.emit("emojiluna/emoji-deleted", id); return true; } catch (error) { this.ctx.logger.error(`Failed to delete emoji ${id}:`, error); return false; } } async deleteAllEmojis() { try { const promises = Object.values(this._emojiStorage).map( (emoji) => this.deleteEmoji(emoji.id) ); const chunkedPromises = chunkArray(promises, 4); for (const chunk of chunkedPromises) { await Promise.all(chunk); } } catch (error) { this.ctx.logger.error("Failed to delete all emojis:", error); return false; } return true; } async addCategory(name2, description) { const id = generateId(); const category = { id, name: name2, description, emojiCount: 0, createdAt: /* @__PURE__ */ new Date() }; this._categories[id] = category; await this.ctx.database.upsert("emojiluna_categories", [ { id: category.id, name: category.name, description: category.description, emoji_count: category.emojiCount, created_at: category.createdAt } ]); this.ctx.emit("emojiluna/category-added", category); return category; } async getCategories() { return Object.values(this._categories); } async getCategoryByName(name2) { return Object.values(this._categories).find((cat) => cat.name === name2) || null; } async deleteCategory(id) { const category = this._categories[id]; if (!category) return false; const emojisInCategory = Object.values(this._emojiStorage).filter( (emoji) => emoji.category === category.name ); if (emojisInCategory.length > 0) { throw new Error(`分类 ${category.name} 中还有表情包,无法删除`); } delete this._categories[id]; await this.ctx.database.remove("emojiluna_categories", { id }); this.ctx.emit("emojiluna/category-deleted", id); return true; } async getAllTags() { const tags = /* @__PURE__ */ new Set(); Object.values(this._emojiStorage).forEach((emoji) => { emoji.tags.forEach((tag) => tags.add(tag)); }); return Array.from(tags); } async updateEmojiTags(id, tags) { const emoji = this._emojiStorage[id]; if (!emoji) return false; emoji.tags = tags; await this.ctx.database.upsert("emojiluna_emojis", [ { id: emoji.id, tags: JSON.stringify(emoji.tags) } ]); this.ctx.emit("emojiluna/emoji-updated", emoji); return true; } async updateEmojiCategory(id, category) { const emoji = this._emojiStorage[id]; if (!emoji) return false; emoji.category = category; await this.ctx.database.upsert("emojiluna_emojis", [ { id: emoji.id, category: emoji.category } ]); this.ctx.emit("emojiluna/emoji-updated", emoji); return true; } async loadEmojis() { const emojis = await this.ctx.database.select("emojiluna_emojis").execute(); for (const emojiData of emojis) { this._emojiStorage[emojiData.id] = { id: emojiData.id, name: emojiData.name, category: emojiData.category, path: emojiData.path, size: emojiData.size, createdAt: new Date(emojiData.created_at), tags: JSON.parse(emojiData.tags || "[]") }; } } async loadCategories() { const categories = await this.ctx.database.select("emojiluna_categories").execute(); for (const categoryData of categories) { this._categories[categoryData.id] = { id: categoryData.id, name: categoryData.name, description: categoryData.description, emojiCount: categoryData.emoji_count, createdAt: new Date(categoryData.created_at) }; } for (const category of this.config.categories) { const exists = Object.values(this._categories).find( (cat) => cat.name === category ); if (!exists) { await this.addCategory(category); } } } getEmojiCount() { return Object.keys(this._emojiStorage).length; } getCategoryCount() { return Object.keys(this._categories).length; } async updateCategoryEmojiCount(categoryName) { const count = Object.values(this._emojiStorage).filter( (emoji) => emoji.category === categoryName ).length; const category = Object.values(this._categories).find( (cat) => cat.name === categoryName ); if (category) { category.emojiCount = count; await this.ctx.database.upsert("emojiluna_categories", [ { id: category.id, emoji_count: count } ]); } } static inject = ["database", "chatluna"]; }; function defineDatabase(ctx) { ctx.database.extend( "emojiluna_emojis", { id: { type: "string", length: 254 }, name: { type: "string", length: 254 }, category: { type: "string", length: 254 }, path: { type: "string", length: 500 }, size: { type: "integer" }, created_at: { type: "timestamp" }, tags: { type: "string" } }, { autoInc: false, primary: "id" } ); ctx.database.extend( "emojiluna_categories", { id: { type: "string", length: 254 }, name: { type: "string", length: 254 }, description: { type: "string", length: 500 }, emoji_count: { type: "integer" }, created_at: { type: "timestamp" } }, { autoInc: false, primary: "id" } ); } __name(defineDatabase, "defineDatabase"); // src/commands.ts var import_koishi3 = require("koishi"); var import_promises2 = __toESM(require("fs/promises")); function applyCommands(ctx, config) { ctx.inject(["emojiluna"], (ctx2) => { const emojiluna = ctx2.emojiluna; ctx2.middleware(async (session, next) => { if (!config.triggerWithName) { return next(); } return next(async (next2) => { const emoji = await emojiluna.getEmojiByName(session.content); if (!emoji) { return next2(); } const imageBuffer = await import_promises2.default.readFile(emoji.path); const mimeType = getImageType(imageBuffer); const base64 = imageBuffer.toString("base64"); return import_koishi3.h.image(`data:${mimeType};base64,${base64}`); }); }); ctx2.command("emojiluna <name:string>", "表情包管理").action( async ({ session }, name2) => { if (!name2) return "请输入表情包名称"; const emoji = await emojiluna.getEmojiByName(name2); if (!emoji) return "表情包不存在"; const imageBuffer = await import_promises2.default.readFile(emoji.path); const mimeType = getImageType(imageBuffer); const base64 = imageBuffer.toString("base64"); return import_koishi3.h.image(`data:${mimeType};base64,${base64}`); } ); ctx2.command("emojiluna.add <name:string>", "添加表情包").option("category", "-c <category:string> 指定分类").option("tags", "-t <tags:string> 添加标签,用逗号分隔").option("no-ai", "-n 禁用AI分析").action(async ({ session, options }, name2) => { if (!name2) return "请输入表情包名称"; return await handleImageUpload( session, "", async (imageData) => { if (imageData.length === 0) { return "请上传图片"; } const tags = options.tags ? options.tags.split(",").map((t) => t.trim()) : []; try { const emoji = await emojiluna.addEmoji( { name: name2, category: options.category, tags }, imageData[0] ); let result = `表情包 "${name2}" 添加成功!ID: ${emoji.id}`; if (config.autoAnalyze && !options["no-ai"]) { result += "\n\nAI分析结果:"; result += ` 分类: ${emoji.category}`; result += ` 标签: ${emoji.tags.join(", ")}`; } return result; } catch (error) { return `添加失败: ${error.message}`; } } ); }); ctx2.command("emojiluna.list", "查看表情包列表").option("category", "-c <category:string> 按分类筛选").option("tags", "-t <tags:string> 按标签筛选,用逗号分隔").option("limit", "-l <limit:number> 限制显示数量", { fallback: 10 }).option("offset", "-o <offset:number> 偏移量", { fallback: 0 }).action(async ({ session, options }) => { const tags = options.tags ? options.tags.split(",").map((t) => t.trim()) : []; const emojis = await emojiluna.getEmojiList({ category: options.category, tags, limit: options.limit, offset: options.offset }); if (emojis.length === 0) { return "没有找到表情包"; } const total = emojiluna.getEmojiCount(); const list = await Promise.all( emojis.map(async (emoji, index) => { const imageBuffer = await import_promises2.default.readFile(emoji.path); const mimeType = getImageType(imageBuffer); const base64 = imageBuffer.toString("base64"); return `${options.offset + index + 1}. ${emoji.name} (${emoji.id}) <img src="data:${mimeType};base64,${base64}" />m 分类: ${emoji.category} 标签: ${emoji.tags.join(", ") || "无"} 大小: ${formatFileSize(emoji.size)}`; }) ).then((list2) => list2.join("\n\n")); return import_koishi3.h.parse( `表情包列表 (${emojis.length}/${total}): ${list}` ); }); ctx2.command("emojiluna.search <keyword:string>", "搜索表情包").action( async ({ session }, keyword) => { if (!keyword) return "请输入搜索关键词"; const emojis = await emojiluna.searchEmoji(keyword); if (emojis.length === 0) { return `没有找到包含 "${keyword}" 的表情包`; } const list = await Promise.all( emojis.map(async (emoji, index) => { const imageBuffer = await import_promises2.default.readFile(emoji.path); const mimeType = getImageType(imageBuffer); const base64 = imageBuffer.toString("base64"); return `${index + 1}. ${emoji.name} (${emoji.id}) <img src="data:${mimeType};base64,${base64}" /> 分类: ${emoji.category} 标签: ${emoji.tags.join(", ") || "无"}`; }) ).then((list2) => list2.join("\n\n")); return import_koishi3.h.parse(`搜索结果 (${emojis.length} 个): ${list}`); } ); ctx2.command("emojiluna.get <id:string>", "获取表情包").action( async ({ session }, id) => { if (!id) return "请输入表情包ID"; const emoji = await emojiluna.getEmojiById(id); if (!emoji) { return "表情包不存在"; } try { const imageBuffer = await import_promises2.default.readFile(emoji.path); return import_koishi3.h.image(imageBuffer, getImageType(imageBuffer)); } catch (error) { return "读取表情包文件失败"; } } ); ctx2.command("emojiluna.delete <id:string>", "删除表情包").action( async ({ session }, id) => { if (!id) return "请输入表情包ID"; const success = await emojiluna.deleteEmoji(id); if (success) { return "表情包删除成功"; } else { return "表情包不存在或删除失败"; } } ); ctx2.command("emojiluna.wipe", "删除所有表情包").action( async ({ session }) => { const success = await emojiluna.deleteAllEmojis(); if (success) { return "所有表情包删除成功"; } else { return "表情包不存在或删除失败"; } } ); ctx2.command("emojiluna.category", "分类管理"); ctx2.command("emojiluna.category.add <name:string>", "添加分类").option("description", "-d <description:string> 分类描述").action(async ({ session, options }, name2) => { if (!name2) return "请输入分类名称"; try { const category = await emojiluna.addCategory( name2, options.description ); return `分类 "${name2}" 添加成功!ID: ${category.id}`; } catch (error) { return `添加分类失败: ${error.message}`; } }); ctx2.command("emojiluna.category.list", "查看所有分类").action( async ({ session }) => { const categories = await emojiluna.getCategories(); if (categories.length === 0) { return "没有分类"; } const list = categories.map( (cat) => `${cat.name} (${cat.id}) 描述: ${cat.description || "无"} 表情包数量: ${cat.emojiCount}` ).join("\n\n"); return `分类列表 (${categories.length} 个): ${list}`; } ); ctx2.command("emojiluna.category.delete <id:string>", "删除分类").action( async ({ session }, id) => { if (!id) return "请输入分类ID"; try { const success = await emojiluna.deleteCategory(id); if (success) { return "分类删除成功"; } else { return "分类不存在"; } } catch (error) { return `删除分类失败: ${error.message}`; } } ); ctx2.command("emojiluna.tags", "标签管理"); ctx2.command("emojiluna.tags.list", "查看所有标签").action( async ({ session }) => { const tags = await emojiluna.getAllTags(); if (tags.length === 0) { return "没有标签"; } return `所有标签 (${tags.length} 个): ${tags.join(", ")}`; } ); ctx2.command( "emojiluna.tags.update <id:string> <tags:string>", "更新表情包标签" ).action(async ({ session }, id, tags) => { if (!id) return "请输入表情包ID"; if (!tags) return "请输入标签,用逗号分隔"; const tagList = tags.split(",").map((t) => t.trim()); const success = await emojiluna.updateEmojiTags(id, tagList); if (success) { return "标签更新成功"; } else { return "表情包不存在"; } }); ctx2.command( "emojiluna.category.update <id:string> <category:string>", "更新表情包分类" ).action(async ({ session }, id, category) => { if (!id) return "请输入表情包ID"; if (!category) return "请输入分类名称"; const success = await emojiluna.updateEmojiCategory(id, category); if (success) { return "分类更新成功"; } else { return "表情包不存在"; } }); ctx2.command("emojiluna.ai", "AI功能"); ctx2.command("emojiluna.ai.categorize", "批量AI分类现有表情包").action( async ({ session }) => { if (!config.autoCategorize) { return "AI分类功能未启用"; } await session.send("开始批量AI分类,请稍候..."); const result = await emojiluna.categorizeExistingEmojis(); return `批量分类完成! 成功: ${result.success} 个 失败: ${result.failed} 个`; } ); ctx2.command("emojiluna.ai.analyze <id:string>", "AI分析表情包").action( async ({ session }, id) => { if (!config.autoAnalyze) { return "AI分析功能未启用"; } const emoji = await emojiluna.getEmojiById(id); if (!emoji) { return "表情包不存在"; } try { const imageBuffer = await import_promises2.default.readFile(emoji.path); const imageBase64 = imageBuffer.toString("base64"); await session.send("正在AI分析,请稍候..."); const result = await emojiluna.analyzeEmoji(imageBase64); if (!result) { return "AI分析失败"; } let response = `AI分析结果: `; response += `建议名称: ${result.name} `; response += `建议分类: ${result.category} `; response += `建议标签: ${result.tags.join(", ")} `; response += `描述: ${result.description}`; if (result.newCategory && result.newCategory.length > 0) { response += ` 建议新分类: ${result.newCategory}`; } return response; } catch (error) { return `AI分析失败: ${error.message}`; } } ); ctx2.command("emojiluna.info", "查看插件信息").action( async ({ session }) => { const emojiCount = emojiluna.getEmojiCount(); const categoryCount = emojiluna.getCategoryCount(); const tags = await emojiluna.getAllTags(); let info = `表情包管理插件信息: 表情包数量: ${emojiCount} 分类数量: ${categoryCount} 标签数量: ${tags.length}`; if (config.autoCategorize || config.autoAnalyze) { info += ` AI功能状态:`; info += ` 自动分类: ${config.autoCategorize ? "启用" : "禁用"}`; info += ` 自动分析: ${config.autoAnalyze ? "启用" : "禁用"}`; info += ` 使用模型: ${config.model}`; } return info; } ); }); } __name(applyCommands, "applyCommands"); // src/autoCollector.ts var import_koishi4 = require("koishi"); var import_promises3 = __toESM(require("fs/promises")); var import_crypto2 = __toESM(require("crypto")); var AutoCollector = class { static { __name(this, "AutoCollector"); } ctx; config; options; emojiHashes = /* @__PURE__ */ new Set(); imageFeatures = /* @__PURE__ */ new Map(); static MAX_HASHES = 1e4; groupAutoCollectLimit = {}; constructor(ctx, config) { this.ctx = ctx; this.config = config; this.options = { minSize: config.minEmojiSize, maxSize: config.maxEmojiSize, similarityThreshold: config.similarityThreshold, whitelistGroups: config.whitelistGroups, groupAutoCollectLimit: config.groupAutoCollectLimit }; this.loadExistingHashes(); this.registerCommands(); } async loadExistingHashes() { try { const emojis = await this.ctx.emojiluna.getEmojiList({ limit: 1e4 }); for (const emoji of emojis) { try { const buffer = await import_promises3.default.readFile(emoji.path); const hash = this.calculateImageHash(buffer); this.emojiHashes.add(hash); const features = await this.extractImageFeatures(buffer); this.imageFeatures.set(hash, features); } catch (error) { this.ctx.logger.warn( `Failed to load hash for emoji ${emoji.id}: ${error.message}` ); } } } catch (error) { this.ctx.logger.warn( `Failed to load existing emoji hashes: ${error.message}` ); } } async checkHitLimit(session) { const groupId = session.guildId || session.channelId; const currentTime = Date.now(); if (!this.groupAutoCollectLimit[groupId]) { this.groupAutoCollectLimit[groupId] = { hourLimit: this.options.groupAutoCollectLimit[groupId]?.hourLimit || 20, dayLimit: this.options.groupAutoCollectLimit[groupId]?.dayLimit || 100, lastDayTimestamp: currentTime, lastHourTimestamp: currentTime }; } const limit = this.groupAutoCollectLimit[groupId]; const hourPassed = currentTime - limit.lastHourTimestamp >= 36e5; const dayPassed = currentTime - limit.lastDayTimestamp >= 864e5; if (hourPassed) { limit.lastHourTimestamp = currentTime; limit.hourLimit = this.options.groupAutoCollectLimit[groupId]?.hourLimit || 20; } if (dayPassed) { limit.lastDayTimestamp = currentTime; limit.dayLimit = this.options.groupAutoCollectLimit[groupId]?.dayLimit || 100; } if (limit.hourLimit <= 0 || limit.dayLimit <= 0) { return false; } limit.hourLimit--; limit.dayLimit--; return true; } registerCommands() { this.ctx.command("emojiluna.auto.status", "查看自动获取状态").action(async ({ session }) => { if (!this.config.autoCollect) { return "自动获取功能未启用"; } const stats = this.getStats(); let status = `自动获取状态: `; status += `状态: ${stats.isEnabled ? "运行中" : "已停止"} `; status += `最小大小: ${stats.options.minSize}KB `; status += `最大大小: ${stats.options.maxSize}MB `; status += `相似度阈值: ${stats.options.similarityThreshold} `; status += `白名单群数: ${stats.options.whitelistGroups.length} `; status += `已记录哈希数: ${stats.totalHashes}`; if (stats.options.whitelistGroups.length > 0) { status += ` 白名单群: ${stats.options.whitelistGroups.join("\n")}`; } return status; }); } start() { if (!this.config.autoCollect) { this.ctx.logger.info("Auto collect is disabled"); return; } this.ctx.on("message", async (session) => { if (!this.shouldProcessMessage(session)) return; const images = import_koishi4.h.select(session.elements, "img"); if (images.length === 0) return; if (!await this.checkHitLimit(session)) { this.ctx.logger.debug( `Hit auto collect limit for group ${session.guildId || session.channelId}` ); return; } for (const image of images) { await this.processImage(image, session); } }); this.ctx.logger.info("Auto collector started"); } shouldProcessMessage(session) { if (session.isDirect) return false; return this.options.whitelistGroups.includes(session.guildId); } async processImage(imageElement, session) { try { const imageInfo = await this.getImageInfo(imageElement); if (!imageInfo || !this.checkFileSize(imageInfo.size)) return; if (this.emojiHashes.has(imageInfo.hash)) { this.ctx.logger.debug("Duplicate image detected, skipping"); return; } if (await this.isSimilarToExisting(imageInfo)) { this.ctx.logger.debug("Similar image detected, skipping"); return; } await this.saveEmoji(imageInfo, session); } catch (error) { this.ctx.logger.warn(`Failed to process image: ${error.message}`); } } async getImageInfo(imageElement) { try { const buffer = await this.ctx.http.get( imageElement.attrs.url ?? imageElement.attrs.src, { responseType: "arraybuffer" } ); const imageBuffer = Buffer.from(buffer); return { buffer: imageBuffer, size: imageBuffer.length, hash: this.calculateImageHash(imageBuffer), format: this.detectImageFormat(imageBuffer) }; } catch (error) { this.ctx.logger.warn(`Failed to get image info: ${error.message}`); return null; } } checkFileSize(size) { const sizeKB = size / 1024; const sizeMB = sizeKB / 1024; if (sizeKB < this.options.minSize) { this.ctx.logger.debug( `Image too small: ${sizeKB.toFixed(2)}KB < ${this.options.minSize}KB` ); return false; } if (sizeMB > this.options.maxSize) { this.ctx.logger.debug( `Image too large: ${sizeMB.toFixed(2)}MB > ${this.options.maxSize}MB` ); return false; } return true; } calculateImageHash(buffer) { return import_crypto2.default.createHash("md5").update(buffer).digest("hex"); } detectImageFormat(buffer) { const header = buffer.subarray(0, 12); if (header[0] === 255 && header[1] === 216) return "jpeg"; if (header[0] === 137 && header[1] === 80 && header[2] === 78 && header[3] === 71) return "png"; if (header[0] === 71 && header[1] === 73 && header[2] === 70) return "gif"; if (header[0] === 82 && header[1] === 73 && header[2] === 70 && header[8] === 87 && header[9] === 69 && header[10] === 66 && header[11] === 80) return "webp"; return "unknown"; } async extractImageFeatures(buffer) { const dimensions = this.getImageDimensions(buffer); const phash = this.calculatePerceptualHash(buffer); const histogram = this.calculateHistogram(buffer); const aspectRatio = dimensions.width / dimensions.height; return { phash, histogram, aspectRatio, dimensions }; } getImageDimensions(buffer) { const header = buffer.subarray(0, 24); if (header[0] === 137 && header[1] === 80 && header[2] === 78 && header[3] === 71) { const width = header.readUInt32BE(16); const height = header.readUInt32BE(20); return { width, height }; } if (header[0] === 255 && header[1] === 216) { for (let i = 2; i < buffer.length - 8; i++) { if (buffer[i] === 255 && buffer[i + 1] === 192) { const height = buffer.readUInt16BE(i + 5); const width = buffer.readUInt16BE(i + 7); return { width, height }; } } } return { width: 0, height: 0 }; } calculatePerceptualHash(buffer) { const grayscale = this.convertToGrayscale(buffer); const resized = this.resizeImage(grayscale, 8, 8); const dct = this.applyDCT(resized); const median = this.calculateMedian(dct); let hash = ""; for (let i = 0; i < 64; i++) { hash += dct[i] > median ? "1" : "0"; } return hash; } convertToGrayscale(buffer) { const grayscale = []; const format = this.detectImageFormat(buffer); if (format === "png") { let offset = 8; while (offset < buffer.length) { const chunkLength = buffer.readUInt32BE(offset); const chunkType = buffer.subarray(offset + 4, offset + 8).toString("ascii"); if (chunkType === "IDAT") { const pixelData = buffer.subarray( offset + 8, offset + 8 + chunkLength ); for (let i = 0; i < pixelData.length; i += 4) { const r = pixelData[i]; const g = pixelData[i + 1]; const b = pixelData[i + 2]; const gray = Math.round( 0.299 * r + 0.587 * g + 0.114 * b ); grayscale.push(gray); } break; } offset += 12 + chunkLength; } } else { for (let i = 0; i < Math.min(buffer.length, 64 * 64); i++) { grayscale.push(buffer[i]); } } return grayscale.slice(0, 64); } resizeImage(pixels, width, height) { const resized = []; const sourceSize = Math.sqrt(pixels.length); const scaleX = sourceSize / width; const scaleY = sourceSize / height; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const sourceX = Math.floor(x * scaleX); const sourceY = Math.floor(y * scaleY); const index = sourceY * sourceSize + sourceX; resized.push(pixels[index] || 0); } } return resized; } applyDCT(pixels) { const N = 8; const dct = new Array(N * N); for (let u = 0; u < N; u++) { for (let v = 0; v < N; v++) { let sum = 0; for (let x = 0; x < N; x++) { for (let y = 0; y < N; y++) { sum += pixels[x * N + y] * Math.cos((2 * x + 1) * u * Math.PI / (2 * N)) * Math.cos((2 * y + 1) * v * Math.PI / (2 * N)); } } const cu = u === 0 ? 1 / Math.sqrt(2) : 1; const cv = v === 0 ? 1 / Math.sqrt(2) : 1; dct[u * N + v] = cu * cv / 4 * sum; } } return dct; } calculateMedian(values) { const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; } calculateHistogram(buffer) { const histogram = new Array(256).fill(0); const grayscale = this.convertToGrayscale(buffer); for (const pixel of grayscale) { if (pixel >= 0 && pixel <= 255) { histogram[pixel]++; } } const total = grayscale.length; return histogram.map((count) => count / total); } hammingDistance(hash1, hash2) { let distance = 0; for (let i = 0; i < Math.min(hash1.length, hash2.length); i++) { if (hash1[i] !== hash2[i]) { distance++; } } return distance; } histogramSimilarity(hist1, hist2) { let intersection = 0; let union = 0; for (let i = 0; i < Math.min(hist1.length, hist2.length); i++) { intersection += Math.min(hist1[i], hist2[i]); union += Math.max(hist1[i], hist2[i]); } return union === 0 ? 0 : intersection / union; } calculateSimilarityScore(features1, features2) { const phashDistance = this.hammingDistance( features1.phash, features2.phash ); const phashSimilarity = 1 - phashDistance / 64; const histogramSimilarity = this.histogramSimilarity( features1.histogram, features2.histogram ); const aspectRatioDiff = Math.abs( features1.aspectRatio - features2.aspectRatio ); const aspectRatioSimilarity = Math.max(0, 1 - aspectRatioDiff); const dimensionSimilarity = this.calculateDimensionSimilarity( features1.dimensions, features2.dimensions ); const weights = { phash: 0.4, histogram: 0.3, aspectRatio: 0.2, dimension: 0.1 }; return phashSimilarity * weights.phash + histogramSimilarity * weights.histogram + aspectRatioSimilarity * weights.aspectRatio + dimensionSimilarity * weights.dimension; } calculateDimensionSimilarity(dim1, dim2) { if (dim1.width === 0 || dim1.height === 0 || dim2.width === 0 || dim2.height === 0) { return 0; } const area1 = dim1.width * dim1.height; const area2 = dim2.width * dim2.height; const areaSimilarity = Math.min(area1, area2) / Math.max(area1, area2); return areaSimilarity; } async isSimilarToExisting(imageInfo) { try { const newFeatures = await this.extractImageFeatures( imageInfo.buffer ); for (const [_, existingFeatures] of this.imageFeatures) { const similarity = this.calculateSimilarityScore( newFeatures, existingFeatures ); if (similarity >= this.options.similarityThreshold) { this.ctx.logger.debug( `Similar image found: similarity=${similarity.toFixed(3)}, threshold=${this.options.similarityThreshold}` ); return true; } } return false; } catch (error) { this.ctx.logger.warn(`Failed to check similarity: ${error.message}`); return false; } } async saveEmoji(imageInfo, session) { try { if (this.emojiHashes.has(imageInfo.hash)) { this.ctx.logger.debug("Duplicate image detected, skipping"); return; } if (await this.isSimilarToExisting(imageInfo)) { this.ctx.logger.debug("Similar image detected, skipping"); return; } const timestamp = Date.now(); await import_promises3.default.mkdir(this.config.storagePath, { recursive: true }); const emojiName = `自动获取_${timestamp}`; await this.ctx.emojiluna.addEmoji( { name: emojiName, category: "其他", tags: ["自动获取", `来自群:${session.channelId}`] }, imageInfo.buffer ); this.emojiHashes.add(imageInfo.hash); const features = await this.extractImageFeatures(imageInfo.buffer); this.imageFeatures.set(imageInfo.hash, features); } catch (error) { this.ctx.logger.error(`Failed to save emoji: ${error.message}`); } } updateConfig(config) { this.config = config; this.options = { minSize: config.minEmojiSize, maxSize: config.maxEmojiSize, similarityThreshold: config.similarityThreshold, whitelistGroups: config.whitelistGroups, groupAutoCollectLimit: config.groupAutoCollectLimit }; } getStats() { return { totalHashes: this.emojiHashes.size, isEnabled: this.config.autoCollect, options: this.options }; } }; // src/index.ts var import_types = require("koishi-plugin-chatluna/llm-core/platform/types"); // src/backend.ts var import_promises4 = __toESM(require("fs/promises")); var import_path2 = require("path"); async function applyBackend(ctx, config) { if (config.injectVariables) { const emojis = await ctx.emojiluna.getEmojiList().then((res) => res.map((emoji) => `[${emoji.name}](${emoji.path})`)); ctx.effect(() => { const dispose = ctx.chatluna.variable.setVariable( "emojis", emojis.join(",") ); return () => dispose; }); } if (!config.backendServer) { return; } ctx.inject(["console", "server"], (ctx2) => { ctx2.console.addEntry({ dev: (0, import_path2.resolve)(__dirname, "../client/index.ts"), prod: (0, import_path2.resolve)(__dirname, "../dist") }); ctx2.console.addListener( "emojiluna/getEmojiList", async (options = {}) => { return await ctx2.emojiluna.getEmojiList(options); } ); ctx2.console.addListener("emojiluna/searchEmoji", async (keyword) => { return await ctx2.emojiluna.searchEmoji(keyword); }); ctx2.console.addListener("emojiluna/getCategories", async () => { return await ctx2.emojiluna.getCategories(); }); ctx2.console.addListener("emojiluna/getAllTags", async () => { return await ctx2.emojiluna.getAllTags(); }); ctx2.console.addListener( "emojiluna/updateEmojiTags", async (id, tags) => { return await ctx2.emojiluna.updateEmojiTags(id, tags); } ); ctx2.console.addListener( "emojiluna/updateEmojiCategory", async (id, category) => { return await ctx2.emojiluna.updateEmojiCategory(id, category); } ); ctx2.console.addListener("emojiluna/deleteEmoji", async (id) => { return await ctx2.emojiluna.deleteEmoji(id); }); ctx2.console.addListener( "emojiluna/addCategory", async (name2, description) => { return await ctx2.emojiluna.addCategory(name2, description); } ); ctx2.console.addListener("emojiluna/deleteCategory", async (id) => { return await ctx2.emojiluna.deleteCategory(id); }); ctx2.console.addListener("emojiluna/addEmoji", async (emojiData) => { const { name: name2, category, tags, imageData } = emojiData; if (!imageData || !name2) { throw new Error("图片数据和名称为必填项"); } const buffer = Buffer.from(imageData, "base64"); const options = { name: name2, category: category || "其他", tags: tags || [] }; return await ctx2.emojiluna.addEmoji(options, buffer); }); ctx2.console.addListener( "emojiluna/addEmojis", async (emojis, aiAnalysis) => { if (!emojis || !Array.isArray(emojis) || emojis.length === 0) { throw new Error("表情包数据数组为必填项"); } const emojisToCreate = emojis.map((emojiData) => { const { name: name2, category, tags, imageData } = emojiData; if (!imageData || !name2) { throw new Error("每个表情包的图片数据和名称都是必填项"); } const buffer = Buffer.from(imageData, "base64"); return { options: { name: name2, category: category || "其他", tags: tags || [] }, buffer }; }); return await ctx2.emojiluna.addEmojis(emojisToCreate, aiAnalysis); } ); ctx2.console.addListener("emojiluna/getBaseUrl", async () => { const selfUrl = config.selfUrl || ctx2.server.selfUrl; return selfUrl + config.backendPath; }); ctx2.console.addListener("emojiluna/analyzeEmoji", async (id) => { const emoji = await ctx2.emojiluna.getEmojiById(id); if (!emoji) { throw new Error("表情包不存在"); } try { const imageBuffer = await import_promises4.default.readFile(emoji.path); const imageBase64 = imageBuffer.toString("base64"); const result = await ctx2.emojiluna.analyzeEmoji(imageBase64); if (result) { const updates = []; if (result.category !== emoji.category) { await ctx2.emojiluna.updateEmojiCategory( id, result.category ); updates.push( `分类: ${emoji.category} → ${result.category}` ); } if (JSON.stringify(result.tags.sort()) !== JSON.stringify([...emoji.tags].sort())) { await ctx2.emojiluna.updateEmojiTags(id, result.tags); updates.push( `标签: [${emoji.tags.join(", ")}] → [${result.tags.join(", ")}]` ); } return { success: true, updates, result, oldData: { name: emoji.name,