koishi-plugin-emojiluna
Version:
Smart emoji management plugin with AI categorization
1,507 lines (1,490 loc) • 62.3 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 __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,