UNPKG

koishi-plugin-memes

Version:

生成 Meme 表情包,支持 MemeGenerator API (v1)、内置模板和自定义 API 接口

1,124 lines (1,116 loc) 53.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 src_exports = {}; __export(src_exports, { Config: () => Config, apply: () => apply, inject: () => inject, logger: () => logger, name: () => name, usage: () => usage }); module.exports = __toCommonJS(src_exports); var import_koishi5 = require("koishi"); // src/api.ts var import_koishi2 = require("koishi"); // src/utils.ts var import_koishi = require("koishi"); var import_fs = __toESM(require("fs")); function parseTarget(arg) { if (!arg) return ""; try { const atElement = import_koishi.h.select(import_koishi.h.parse(arg), "at")[0]; if (atElement?.attrs?.id) return atElement.attrs.id; } catch { } const match = arg.match(/@(\d+)/); if (match) return match[1]; const userId = arg.trim(); if (/^\d{5,10}$/.test(userId)) return userId; return arg; } __name(parseTarget, "parseTarget"); async function getUserAvatar(session, userId) { const targetId = userId || session.userId; return targetId === session.userId && session.user?.avatar ? session.user.avatar : `https://q1.qlogo.cn/g?b=qq&nk=${targetId}&s=640`; } __name(getUserAvatar, "getUserAvatar"); async function autoRecall(session, message, delay = 1e4) { if (!message) return null; try { const msg = typeof message === "string" ? await session.send(message) : message; setTimeout(() => session.bot?.deleteMessage(session.channelId, msg.toString()).catch(() => { }), delay); return null; } catch { return null; } } __name(autoRecall, "autoRecall"); function readJsonFile(filePath, logger2) { try { if (!import_fs.default.existsSync(filePath)) return null; const data = JSON.parse(import_fs.default.readFileSync(filePath, "utf-8")); return data; } catch (err) { logger2.error(`读取文件失败:${filePath} - ${err.message}`); return null; } } __name(readJsonFile, "readJsonFile"); function writeJsonFile(filePath, data, logger2) { try { import_fs.default.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); return true; } catch (err) { logger2.error(`写入文件失败:${filePath} - ${err.message}`); return false; } } __name(writeJsonFile, "writeJsonFile"); function loadOrCreateConfig(filePath, defaultConfig, logger2) { const data = readJsonFile(filePath, logger2); return data !== null ? data : writeJsonFile(filePath, defaultConfig, logger2) ? defaultConfig : defaultConfig; } __name(loadOrCreateConfig, "loadOrCreateConfig"); async function apiRequest(url, options = {}, logger2) { const { method = "get", data, formData, responseType = "json", timeout = 8e3 } = options; try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort("请求超时"), timeout); const fetchOptions = { method: method.toUpperCase(), signal: controller.signal, headers: formData ? { "Accept": "image/*,application/json" } : data ? { "Content-Type": "application/json" } : {}, body: formData || (data ? JSON.stringify(data) : void 0) }; const response = await fetch(url, fetchOptions); clearTimeout(timeoutId); if (!response.ok) { let errorMessage = `HTTP状态码 ${response.status}`; try { if (responseType === "arraybuffer") { const errText = Buffer.from(await response.arrayBuffer()).toString("utf-8"); try { errorMessage = JSON.parse(errText)?.error || errorMessage; } catch { } } else { const errJson = await response.json().catch(() => null); errorMessage = errJson?.error || errJson?.message || errorMessage; } } catch { } logger2.warn(`API请求失败: ${url} - ${errorMessage}`); return null; } return responseType === "arraybuffer" ? Buffer.from(await response.arrayBuffer()) : await response.json(); } catch (e) { logger2.error(`API请求异常: ${url} - ${e.message}`); return null; } } __name(apiRequest, "apiRequest"); async function renderTemplateListAsImage(ctx, title, templates) { const page = await ctx.puppeteer.page(); try { const columnCount = Math.min(Math.ceil(templates.length / 50), 6); const itemsPerColumn = Math.ceil(templates.length / columnCount); const columnWidth = 190; const containerWidth = columnWidth * columnCount + (columnCount - 1) * 8 + 24; const columns = Array.from( { length: columnCount }, (_, i) => templates.slice(i * itemsPerColumn, Math.min((i + 1) * itemsPerColumn, templates.length)) ); const html = ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> body { margin: 0; font-family: "PingFang SC", "Microsoft YaHei", sans-serif; color: #2b333e; font-size: 14px; } .container { margin: 12px; background: #fff; border-radius: 10px; box-shadow: 0 4px 16px rgba(0,0,0,0.1); padding: 14px; width: ${containerWidth}px; } header { text-align: center; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid rgba(0,0,0,0.08); } h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px 0; } .sub-title { font-size: 13px; color: #5c7080; } .columns-wrap { display: flex; gap: 8px; } .column { flex: 1; background: #f8f9fa; border-radius: 8px; border: 1px solid rgba(0,0,0,0.04); padding: 6px 4px; width: ${columnWidth}px; } .item { display: flex; align-items: center; padding: 3px 6px; border-radius: 4px; margin-bottom: 1px; } .item:hover { background: #edf4ff; } .icons { position: relative; width: 24px; height: 16px; margin-right: 8px; flex-shrink: 0; } .keywords { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500; font-size: 14.5px; } .kw { color: #2b333e; } .kw:not(:last-child):after { content: ","; color: #aaa; margin-right: 2px; } .icon { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; position: absolute; top: 0; border-radius: 3px; box-shadow: 0 1px 2px rgba(0,0,0,0.15); } .text-icon { background: #3e90ff; left: 0; z-index: 1; } .image-icon { background: #38b48b; left: 10px; z-index: 2; } .text-icon:only-child, .image-icon:only-child { left: 4px; } svg { width: 12px; height: 12px; } </style> </head> <body> <div class="container"> <header> <h1>${title}</h1> <div class="sub-title">共 ${templates.length} 个模板</div> </header> <div class="columns-wrap"> ${columns.map((items) => ` <div class="column"> ${items.map((template) => { const imgCount = (template.imgReq?.match(/图片(\d+)/)?.[1] || 0) * 1; const textCount = (template.textReq?.match(/文本(\d+)/)?.[1] || 0) * 1; return `<div class="item"> <div class="icons"> ${textCount > 0 ? '<span class="icon text-icon"><svg viewBox="0 0 16 16" fill="none" stroke="white" stroke-width="2"><path d="M2 4h12M4 8h8M2 12h12"/></svg></span>' : ""} ${imgCount > 0 ? '<span class="icon image-icon"><svg viewBox="0 0 16 16" fill="none" stroke="white" stroke-width="2"><rect x="2" y="2" width="12" height="12" rx="1"/><circle cx="5.5" cy="5.5" r="1"/><path d="M13 10l-3-3-6 6"/></svg></span>' : ""} </div> <div class="keywords"> ${template.keywords.map((k) => `<span class="kw">${k}</span>`).join("")} </div> </div>`; }).join("")} </div> `).join("")} </div> </div> </body> </html>`; await page.setViewport({ width: containerWidth + 24, height: 600, deviceScaleFactor: 1.5 }); await page.setContent(html); await page.waitForFunction(() => document.fonts.ready).catch(() => { }); const { width, height } = await page.evaluate(() => { const container = document.querySelector(".container"); return { width: container.offsetWidth + 24, height: container.offsetHeight + 24 }; }); await page.setViewport({ width, height, deviceScaleFactor: 1.5 }); return await page.screenshot({ type: "png", omitBackground: true }); } finally { await page.close(); } } __name(renderTemplateListAsImage, "renderTemplateListAsImage"); async function renderTemplateInfoAsImage(ctx, template, previewImgUrl) { const page = await ctx.puppeteer.page(); try { const keywords = Array.isArray(template.keywords) ? template.keywords : [template.keywords].filter(Boolean); const title = `${keywords.join(", ")} (${template.id})`; const tags = Array.isArray(template.tags) ? template.tags : []; const pt = template.params_type || {}; const sections = [ // 参数需求 { title: "参数需求", className: "requirements", itemClass: "req-item", items: [ `图片: ${pt.min_images || 0}${pt.min_images !== pt.max_images ? `-${pt.max_images || "∞"}` : ""}张`, `文本: ${pt.min_texts || 0}${pt.min_texts !== pt.max_texts ? `-${pt.max_texts || "∞"}` : ""}条`, ...pt.default_texts?.length ? [`默认文本: ${pt.default_texts.join(", ")}`] : [] ] }, // 其他参数 { title: "其他参数", items: Object.entries(pt.args_type?.args_model?.properties || {}).filter(([key]) => key !== "user_infos").map(([key, prop]) => { const prop_obj = prop; let desc = key; if (prop_obj.type) { let typeStr = prop_obj.type; if (prop_obj.type === "array" && prop_obj.items?.$ref) { typeStr = `${prop_obj.type}<${prop_obj.items.$ref.replace("#/$defs/", "").split("/")[0]}>`; } desc += ` (${typeStr})`; } if (prop_obj.default !== void 0) desc += ` 默认值: ${JSON.stringify(prop_obj.default)}`; if (prop_obj.description) desc += ` - ${prop_obj.description}`; if (prop_obj.enum?.length) desc += ` [可选值: ${prop_obj.enum.join(", ")}]`; return desc; }) }, // 命令行参数 { title: "命令行参数", items: (pt.args_type?.parser_options || []).map((opt) => { let desc = `${opt.names.join(", ")}`; if (opt.args?.length) { desc += ` ${opt.args.map((arg) => { let argDesc = arg.name; if (arg.value) argDesc += `:${arg.value}`; if (arg.default != null) argDesc += `=${arg.default}`; return argDesc; }).join(" ")}`; } return opt.help_text ? `${desc} - ${opt.help_text}` : desc; }) }, // 快捷指令 { title: "快捷指令", items: (template.shortcuts || []).map((s) => `${s.humanized || s.key}${s.args?.length ? ` (参数: ${s.args.join(" ")})` : ""}`) } ]; const timeInfo = [ template.date_created && `创建时间: ${template.date_created}`, template.date_modified && `修改时间: ${template.date_modified}` ].filter(Boolean); const html = ` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <style> body { margin: 0; font-family: "PingFang SC", "Microsoft YaHei", sans-serif; color: #2b333e; font-size: 14px; } .container { margin: 12px; background: #fff; border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.1); padding: 16px; max-width: 800px; } header { margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid rgba(0,0,0,0.08); } h1 { font-size: 22px; font-weight: 600; margin: 0 0 6px 0; } .tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .tag { background: #f0f5ff; color: #3e90ff; border-radius: 4px; padding: 2px 8px; font-size: 13px; } .preview { margin: 16px 0; text-align: center; } .preview img { max-width: 100%; max-height: 300px; border-radius: 8px; border: 1px solid rgba(0,0,0,0.1); } .section { margin-bottom: 16px; } .section h2 { font-size: 16px; font-weight: 600; margin: 0 0 8px 0; color: #3e90ff; } .item { margin-bottom: 8px; background: #f8f9fa; border-radius: 6px; padding: 8px 12px; } .requirements { display: flex; flex-wrap: wrap; gap: 12px; } .req-item { background: #f0f7ff; border-left: 3px solid #3e90ff; padding: 6px 10px; border-radius: 0 4px 4px 0; } .info-row { color: #666; font-size: 13px; margin-top: 16px; } </style> </head> <body> <div class="container"> <header> <h1>${title}</h1> ${tags.length ? `<div class="tags">${tags.map((tag) => `<span class="tag">${tag}</span>`).join("")}</div>` : ""} </header> ${previewImgUrl ? `<div class="preview"><img src="${previewImgUrl}" alt="预览图"></div>` : ""} ${sections.map((section) => section.items.length ? ` <div class="section"> <h2>${section.title}</h2> <div class="${section.className || "normal"}"> ${section.items.map((item) => `<div class="${section.itemClass || "item"}">${item}</div>`).join("")} </div> </div> ` : "").join("")} ${timeInfo.length ? `<div class="info-row">${timeInfo.join(" · ")}</div>` : ""} </div> </body> </html>`; await page.setViewport({ width: 800, height: 600, deviceScaleFactor: 1.5 }); await page.setContent(html); await page.waitForFunction(() => document.fonts.ready).catch(() => { }); const { width, height } = await page.evaluate(() => { const container = document.querySelector(".container"); return { width: container.offsetWidth + 24, height: container.offsetHeight + 24 }; }); await page.setViewport({ width, height, deviceScaleFactor: 1.5 }); return await page.screenshot({ type: "png", omitBackground: true }); } finally { await page.close(); } } __name(renderTemplateInfoAsImage, "renderTemplateInfoAsImage"); // src/api.ts var import_path = __toESM(require("path")); var MemeAPI = class { /** * 创建一个 MemeAPI 实例 * @param ctx Koishi 上下文对象 * @param logger 日志记录器 */ constructor(ctx, logger2) { this.ctx = ctx; this.logger = logger2; this.configPath = import_path.default.resolve(this.ctx.baseDir, "data", "memes-api.json"); const defaultConfig = [{ description: "示例配置", apiEndpoint: "https://example.com/api?qq=${arg1}&target=${arg2}" }]; loadOrCreateConfig(this.configPath, defaultConfig, this.logger); } static { __name(this, "MemeAPI"); } configPath; /** * 注册所有表情相关的子命令 * @param meme 父命令对象 */ registerCommands(meme) { const api = meme.subcommand("meme [page:string]", "自定义表情生成").usage("使用自定义 API 生成表情\n查看自定义 API 表情模板列表").example("meme all - 查看表情模板列表").action(async ({}, page) => { if (typeof page === "string" && page.trim().toLowerCase() === "make") return "请使用 meme.make 来生成自定义表情"; const apiConfigs = readJsonFile(this.configPath, this.logger) || []; const typeDescriptions = apiConfigs.map((config) => config.description); const lines = []; let currentLine = ""; let currentWidth = 0; const MAX_WIDTH = 36; for (const desc of typeDescriptions) { let descWidth = 0; for (const char of desc) { descWidth += /[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/.test(char) ? 2 : 1; } if (currentWidth + descWidth + 1 > MAX_WIDTH && currentWidth > 0) { lines.push(currentLine); currentLine = desc; currentWidth = descWidth; } else if (currentLine.length === 0) { currentLine = desc; currentWidth = descWidth; } else { currentLine += " " + desc; currentWidth += 1 + descWidth; } } if (currentLine) lines.push(currentLine); const ITEMS_PER_PAGE = 10; const showAll = page === "all"; const pageNum = parseInt(page) || 1; const totalPages = Math.ceil(lines.length / ITEMS_PER_PAGE); const validPage = Math.max(1, Math.min(pageNum, showAll ? 1 : totalPages)); const displayLines = showAll ? lines : lines.slice((validPage - 1) * ITEMS_PER_PAGE, validPage * ITEMS_PER_PAGE); const header = showAll || totalPages <= 1 ? `表情模板列表(共${apiConfigs.length}项) ` : `表情模板列表(${validPage}/${totalPages}页) `; return header + displayLines.join("\n"); }); api.subcommand(".make [type:string] [arg1:string] [arg2:string]", "生成自定义表情").usage("使用自定义 API 生成表情\n将替换${arg1}和${arg2}参数\n支持@用户和QQ号").action(async ({ session }, type, arg1, arg2) => { try { const apiConfigs = readJsonFile(this.configPath, this.logger) || []; const index = !type ? Math.floor(Math.random() * apiConfigs.length) : apiConfigs.findIndex( (config) => config.description.split("|")[0].trim() === type.trim() ); if (index === -1) return autoRecall(session, `未找到表情"${type}"`); const apiUrl = apiConfigs[index].apiEndpoint.replace(/\${arg1}/g, parseTarget(arg1 || "")).replace(/\${arg2}/g, parseTarget(arg2 || "")); const response = await fetch(apiUrl, { signal: AbortSignal.timeout(8e3) }); let imageUrl = apiUrl; if (response.headers.get("content-type")?.includes("application/json")) { const data = await response.json(); if (data?.code === 200) imageUrl = data.data; } return (0, import_koishi2.h)("image", { url: imageUrl }); } catch (err) { return autoRecall(session, "生成出错:" + err.message); } }); } }; // src/make.ts var import_koishi3 = require("koishi"); var import_path2 = __toESM(require("path")); var import_fs2 = __toESM(require("fs")); var MemeMaker = class { static { __name(this, "MemeMaker"); } ctx; /** * 图片配置参数 * @private * @readonly * @property {Object} sizes - 不同尺寸配置 * @property {Object} styles - 不同样式配置 */ IMAGE_CONFIG = { sizes: { standard: { width: 1280, height: 720 }, square: { width: 800, height: 800 }, small: { width: 640, height: 360 } }, styles: { jiazi: { background: "PCL-Jiazi.jpg", avatarSize: 400, avatarTop: 60, borderRadius: 8 }, tntboom: { background: "HMCL-Boom.jpg", avatarSize: 320, avatarTop: 20, borderRadius: 8 }, zhuo: { background: "PCLCE-Zhuo.jpg", avatarSize: 400, avatarTop: 60, borderRadius: 8 } } }; /** * 创建表情生成器实例 * @constructor * @param {Context} ctx - Koishi 上下文实例 */ constructor(ctx) { this.ctx = ctx; Object.keys(this.IMAGE_CONFIG.styles).forEach((key) => { this.IMAGE_CONFIG.styles[key].background = import_path2.default.resolve(__dirname, "./assets", this.IMAGE_CONFIG.styles[key].background); }); } /** * 将HTML内容渲染为图片 * @async * @param {string} html - 要渲染的HTML内容 * @param {Object} options - 渲染选项 * @param {number} [options.width] - 图片宽度 * @param {number} [options.height] - 图片高度 * @returns {Promise<Buffer>} 生成的图片Buffer * @throws {Error} 渲染过程中的错误 */ async htmlToImage(html, { width, height } = {}) { const page = await this.ctx.puppeteer.page(); try { await page.setViewport({ width, height, deviceScaleFactor: 2 }); await page.setContent( `<!DOCTYPE html><html><head><meta charset="UTF-8"><style>body{margin:0;padding:0;overflow:hidden;}</style></head><body>${html}</body></html>`, { waitUntil: "networkidle0" } ); await page.evaluate(() => Promise.all( Array.from(document.querySelectorAll("img")).map( (img) => img.complete ? Promise.resolve() : new Promise((resolve) => { img.addEventListener("load", resolve); img.addEventListener("error", resolve); }) ) )); return await page.screenshot({ type: "png", fullPage: false }); } finally { await page.close(); } } /** * 生成头像效果合成图 * @async * @param {string} avatarUrl - 头像URL或本地文件路径 * @param {string} style - 使用的样式名称 * @returns {Promise<Buffer>} 生成的图片Buffer * @throws {Error} 生成过程中的错误 */ async generateAvatarEffect(avatarUrl, style) { const styleConfig = this.IMAGE_CONFIG.styles[style] || this.IMAGE_CONFIG.styles.jiazi; const sizeConfig = this.IMAGE_CONFIG.sizes.standard; const getImageSrc = /* @__PURE__ */ __name((url) => { if (url?.startsWith("http")) return url; const filePath = url?.replace("file://", ""); return filePath && import_fs2.default.existsSync(filePath) ? `data:image/jpeg;base64,${import_fs2.default.readFileSync(filePath).toString("base64")}` : null; }, "getImageSrc"); const avatarImageSrc = getImageSrc(avatarUrl); const backgroundImage = getImageSrc(`file://${styleConfig.background}`); const html = ` <div style="width:${sizeConfig.width}px;height:${sizeConfig.height}px;position:relative;margin:0;padding:0;overflow:hidden;"> <img style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;" src="${backgroundImage}" /> ${avatarImageSrc ? `<img style="position:absolute;top:${styleConfig.avatarTop}px;left:50%;transform:translateX(-50%); width:${styleConfig.avatarSize}px;height:${styleConfig.avatarSize}px;object-fit:cover; z-index:2;border-radius:${styleConfig.borderRadius}px;box-shadow:0 5px 15px rgba(0,0,0,0.3);" src="${avatarImageSrc}" />` : ""} </div>`; return this.htmlToImage(html, sizeConfig); } /** * 注册表情生成相关命令 * @param {Command} parentCommand - 父命令实例 * @returns {Command} 创建的子命令 */ registerCommands(parentCommand) { const make = parentCommand.subcommand("make", "内置图片表情生成").usage("使用内置模板生成表情图片"); const descriptions = { jiazi: '生成"你要被夹"图片', tntboom: '生成"你要被炸"图片', zhuo: '生成"你要被捉"图片' }; Object.keys(this.IMAGE_CONFIG.styles).forEach((style) => { make.subcommand(`.${style} [target:text]`, descriptions[style]).usage(`根据用户头像生成${descriptions[style] || style} 不指定用户时使用自己的头像`).example(`make.${style} @用户 - 使用@用户的头像生成图片`).example(`make.${style} 123456789 - 使用指定QQ号生成图片`).action(async ({ session }, target) => { try { const userId = target ? parseTarget(target) || session.userId : session.userId; const avatar = await getUserAvatar(session, userId); const result = await this.generateAvatarEffect(avatar, style); return import_koishi3.h.image(result, "image/png"); } catch (error) { return autoRecall(session, "生成出错:" + error.message); } }); }); return make; } }; // src/generator.ts var import_koishi4 = require("koishi"); var import_path3 = __toESM(require("path")); var MemeGenerator = class { /** * 创建表情包生成器实例 * @param {Context} ctx - Koishi 上下文 * @param {Logger} logger - 日志记录器 * @param {string} apiUrl - API服务器地址 */ constructor(ctx, logger2, apiUrl = "") { this.ctx = ctx; this.logger = logger2; this.apiUrl = apiUrl; this.apiUrl = apiUrl?.trim().replace(/\/+$/, ""); this.cachePath = import_path3.default.resolve(this.ctx.baseDir, "data", "memes.json"); const cacheData = readJsonFile(this.cachePath, this.logger); this.memeCache = cacheData?.data || []; this.memeCache.length ? this.logger.info(`已加载缓存文件(${this.memeCache.length}项)`) : this.refreshCache(); } static { __name(this, "MemeGenerator"); } memeCache = []; cachePath; /** * 刷新模板缓存 * 从API获取最新的模板列表和信息,并更新本地缓存 * @returns {Promise<MemeInfo[]>} 更新后的模板列表 */ async refreshCache() { try { const keys = await apiRequest(`${this.apiUrl}/memes/keys`, {}, this.logger); if (!keys?.length) { this.logger.warn("获取模板列表失败或为空"); return []; } this.logger.info(`已获取模板ID: ${keys.length}个`); const templates = await Promise.all(keys.map(async (key) => { try { const info = await apiRequest(`${this.apiUrl}/memes/${key}/info`, {}, this.logger); return { id: key, keywords: info?.keywords ? (Array.isArray(info.keywords) ? info.keywords : [info.keywords]).filter(Boolean) : [], tags: info?.tags && Array.isArray(info.tags) ? info.tags : [], params_type: info?.params_type || {}, ...info || {} }; } catch (e) { this.logger.warn(`获取模板[${key}]信息失败:${e.message}`); return { id: key, keywords: [], tags: [], params_type: {} }; } })); writeJsonFile(this.cachePath, { time: Date.now(), data: templates }, this.logger); this.memeCache = templates; return templates; } catch (e) { this.logger.error(`刷新缓存失败: ${e.message}`); return []; } } /** * 匹配模板关键词 * 根据提供的关键词查找匹配的模板,并按优先级排序 * @param {string} key - 要匹配的关键词 * @returns {MemeInfo[]} 匹配到的模板列表,按匹配优先级排序 */ matchTemplates(key) { if (!key || !this.memeCache.length) return []; return this.memeCache.map((template) => { let priority = 99; if (template.id === key || template.keywords?.some((k) => k === key)) priority = 1; else if (template.keywords?.some((k) => k.includes(key))) priority = 2; else if (template.keywords?.some((k) => key.includes(k))) priority = 3; else if (template.id.includes(key)) priority = 4; else if (template.tags?.some((tag) => tag === key || tag.includes(key))) priority = 5; return { template, priority }; }).filter((item) => item.priority < 99).sort((a, b) => a.priority - b.priority).map((item) => item.template); } /** * 查找表情包模板 * 先从缓存中查找,如果找不到则尝试从API获取 * @param {string} key - 要查找的模板ID或关键词 * @param {boolean} fuzzy - 是否启用模糊匹配,默认为true * @returns {Promise<MemeInfo | null>} 找到的模板信息,如果未找到则返回null */ async findTemplate(key, fuzzy = true) { const matchedTemplates = fuzzy ? this.matchTemplates(key) : this.memeCache.filter((t) => t.id === key || t.keywords?.some((k) => k === key)); if (matchedTemplates.length > 0) return matchedTemplates[0]; if (this.apiUrl) { try { const info = await apiRequest(`${this.apiUrl}/memes/${key}/info`, {}, this.logger); if (info) { return { id: key, keywords: info.keywords ? (Array.isArray(info.keywords) ? info.keywords : [info.keywords]).filter(Boolean) : [], tags: info.tags && Array.isArray(info.tags) ? info.tags : [], params_type: info.params_type || {}, ...info || {} }; } } catch (e) { this.logger.warn(`从API获取模板[${key}]信息失败:${e.message}`); } } return null; } /** * 获取所有关键词到模板ID的映射 * 用于快速查找和自动补全功能 * @returns {Map<string, string>} 关键词到模板ID的映射表 */ getAllKeywordMappings() { const keywordMap = /* @__PURE__ */ new Map(); this.memeCache.forEach((template) => { keywordMap.set(template.id, template.id); if (Array.isArray(template.keywords)) { template.keywords.forEach((keyword) => keyword && keywordMap.set(keyword, template.id)); } }); return keywordMap; } /** * 生成表情包 * 解析指令参数,获取所需的图片和文本,然后调用API生成表情包 * @param {any} session - 消息会话对象 * @param {string} key - 模板ID或关键词 * @param {h[]} args - 参数元素列表 * @returns {Promise<h | string>} 生成的表情包图片元素或错误消息 */ async generateMeme(session, key, args) { try { const templateInfo = await this.findTemplate(key); if (!templateInfo) return autoRecall(session, `获取模板信息失败: ${key}`); const tempId = templateInfo.id || key; const { min_images = 0, max_images = 0, min_texts = 0, max_texts = 0, default_texts = [] } = templateInfo.params_type || {}; const imageInfos = []; const texts = []; const options = {}; let allText = ""; if (session.quote?.elements) { const processElement2 = /* @__PURE__ */ __name((e) => { if (e.type === "img" && e.attrs.src) imageInfos.push({ src: e.attrs.src }); if (e.children?.length) e.children.forEach(processElement2); }, "processElement"); session.quote.elements.forEach(processElement2); } const processElement = /* @__PURE__ */ __name((e) => { if (e.type === "text" && e.attrs.content) { let text = e.attrs.content.replace(/<at id=['"]?([0-9]+)['"]?\/>/g, (_, userId) => { userId && imageInfos.push({ userId }); return " "; }).replace(/<img[^>]*src=['"]([^'"]+)['"][^>]*\/?>/g, (_, src) => { src && imageInfos.push({ src }); return " "; }); allText += text + " "; } else if (e.type === "at" && e.attrs.id) imageInfos.push({ userId: e.attrs.id }); else if (e.type === "img" && e.attrs.src) imageInfos.push({ src: e.attrs.src }); e.children?.length && e.children.forEach(processElement); }, "processElement"); args.forEach(processElement); if (allText.trim()) { const tokens = allText.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; tokens.forEach((token) => { if (token.startsWith("-")) { const optMatch = token.match(/^-([a-zA-Z0-9_-]+)(?:=(.*))?$/); if (optMatch) { const [, key2, rawValue = "true"] = optMatch; let value = rawValue; if (rawValue === "true") value = true; else if (rawValue === "false") value = false; else if (/^-?\d+$/.test(rawValue)) value = parseInt(rawValue, 10); else if (/^-?\d+\.\d+$/.test(rawValue)) value = parseFloat(rawValue); options[key2] = value; } } else if (token.startsWith("<at") || token.startsWith("@")) { const userId = token.startsWith("<at") ? parseTarget(token) : token.match(/@(\d+)/)?.[1]; userId ? imageInfos.push({ userId }) : token.startsWith("@") && texts.push(token); } else { texts.push(token.replace(/^(['"])(.*)\1$/, "$2")); } }); } const properties = templateInfo?.params_type?.args_type?.args_model?.properties || {}; Object.entries(properties).forEach(([key2, prop]) => { const typedProp = prop; if (key2 in options && key2 !== "user_infos") { const value = options[key2]; if (typedProp.type === "integer" && typeof value !== "number") options[key2] = parseInt(String(value), 10); else if (typedProp.type === "number" && typeof value !== "number") options[key2] = parseFloat(String(value)); else if (typedProp.type === "boolean" && typeof value !== "boolean") options[key2] = value === "true" || value === "1" || value === 1; } }); let origImageInfos = [...imageInfos]; let origTexts = [...texts]; const needSelfAvatar = min_images === 1 && !origImageInfos.length || origImageInfos.length && origImageInfos.length + 1 === min_images; if (needSelfAvatar) origImageInfos = [{ userId: session.userId }, ...origImageInfos]; if (!origTexts.length && default_texts.length) origTexts = [...default_texts]; const checkCount = /* @__PURE__ */ __name((count, min, max, type) => { if (min != null && count < min || max != null && count > max) { const rangeText = min === max ? min : min != null && max != null ? `${min}~${max}` : min != null ? `至少${min}` : `最多${max}`; throw new Error(`当前${count}${type === "图片" ? "张" : "条"}${type},需要${rangeText}${type === "图片" ? "张" : "条"}${type}`); } }, "checkCount"); checkCount(origImageInfos.length, min_images, max_images, "图片"); checkCount(origTexts.length, min_texts, max_texts, "文本"); const images = []; const userInfos = []; await Promise.all(origImageInfos.map(async (info, index) => { try { const url = "src" in info ? info.src : await getUserAvatar(session, info.userId); const userInfo = "userId" in info ? { name: info.userId } : {}; const response = await fetch(url, { signal: AbortSignal.timeout(5e3) }); if (!response.ok) throw new Error(`HTTP状态码 ${response.status}`); const contentType = response.headers.get("content-type") || "image/png"; const buffer = Buffer.from(await response.arrayBuffer()); images[index] = new Blob([buffer], { type: contentType }); userInfos[index] = userInfo; } catch { images[index] = new Blob([], { type: "image/png" }); userInfos[index] = "userId" in info ? { name: info.userId } : {}; } })); const formData = new FormData(); origTexts.forEach((text) => formData.append("texts", text)); images.forEach((img) => formData.append("images", img)); formData.append("args", JSON.stringify({ user_infos: userInfos, ...options })); const imageBuffer = await apiRequest( `${this.apiUrl}/memes/${tempId}/`, { method: "post", formData, responseType: "arraybuffer", timeout: 1e4 }, this.logger ); if (!imageBuffer) return autoRecall(session, "生成表情包失败:未获取到 API 数据"); return (0, import_koishi4.h)("image", { url: `data:image/png;base64,${Buffer.from(imageBuffer).toString("base64")}` }); } catch (e) { return autoRecall(session, e.message); } } }; // src/index.ts var name = "memes"; var inject = { optional: ["puppeteer"] }; var logger = new import_koishi5.Logger("memes"); var usage = ` <div style="border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);"> <h2 style="margin-top: 0; color: #4a6ee0;">📌 插件说明</h2> <p>📖 <strong>使用文档</strong>:请点击左上角的 <strong>插件主页</strong> 查看插件使用文档</p> <p>🔍 <strong>更多插件</strong>:可访问 <a href="https://github.com/YisRime" style="color:#4a6ee0;text-decoration:none;">苡淞的 GitHub</a> 查看本人的所有插件</p> </div> <div style="border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);"> <h2 style="margin-top: 0; color: #e0574a;">❤️ 支持与反馈</h2> <p>🌟 喜欢这个插件?请在 <a href="https://github.com/YisRime" style="color:#e0574a;text-decoration:none;">GitHub</a> 上给我一个 Star!</p> <p>🐛 遇到问题?请通过 <strong>Issues</strong> 提交反馈,或加入 QQ 群 <a href="https://qm.qq.com/q/PdLMx9Jowq" style="color:#e0574a;text-decoration:none;"><strong>855571375</strong></a> 进行交流</p> </div> `; var Config = import_koishi5.Schema.object({ loadApi: import_koishi5.Schema.boolean().description("开启自定义 API 生成").default(false), loadInternal: import_koishi5.Schema.boolean().description("开启内置图片生成").default(false), genUrl: import_koishi5.Schema.string().description("MemeGenerator API 配置").default("http://localhost:2233"), useMiddleware: import_koishi5.Schema.boolean().description("开启关键词匹配中间件").default(false), requirePrefix: import_koishi5.Schema.boolean().description("开启关键词匹配指令前缀").default(true), blacklist: import_koishi5.Schema.string().description("禁止生成黑名单(英文逗号分隔)").role("textarea") }); function apply(ctx, config) { const apiUrl = config.genUrl?.trim().replace(/\/+$/, "") || ""; const memeGenerator = new MemeGenerator(ctx, logger, apiUrl); const memeMaker = new MemeMaker(ctx); let keywordMap = /* @__PURE__ */ new Map(); const blacklistArr = (config.blacklist || "").split(",").map((s) => s.trim()).filter(Boolean); const meme = ctx.command("memes [page:string]", "表情生成").usage("可通过 MemeGenerator 生成表情\n也可自定义 API 生成表情").example("memes - 查看所有表情模板").example("memes 2 - 仅在文本模式下查看第2页模板列表").action(async ({ session }, page) => { if (typeof page === "string" && page.trim().toLowerCase() === "make") return "请使用 memes.make 来生成表情"; try { let keys = memeGenerator["memeCache"].length > 0 ? memeGenerator["memeCache"].map((t) => t.id) : await apiRequest(`${apiUrl}/memes/keys`, {}, logger) || []; const allTemplates = await Promise.all(keys.map(async (key) => { const cachedTemplate = memeGenerator["memeCache"].find((t) => t.id === key); if (cachedTemplate) { const { id, keywords = [], tags = [], params_type: pt = {} } = cachedTemplate; const formatReq = /* @__PURE__ */ __name((min, max, type = "") => { if (min === max && min) return `${type}${min}`; if (min != null || max != null) return `${type}${min || 0}-${max || "∞"}`; return ""; }, "formatReq"); return { id, keywords: Array.isArray(keywords) ? keywords : [keywords].filter(Boolean), imgReq: formatReq(pt.min_images, pt.max_images, "图片"), textReq: formatReq(pt.min_texts, pt.max_texts, "文本"), tags: Array.isArray(tags) ? tags : [] }; } try { const info = await apiRequest(`${apiUrl}/memes/${key}/info`, {}, logger); if (!info) return { id: key, keywords: [], imgReq: "", textReq: "", tags: [] }; const { keywords = [], tags = [], params_type: pt = {} } = info; const formatReq = /* @__PURE__ */ __name((min, max, type = "") => { if (min === max && min) return `${type}${min}`; if (min != null || max != null) return `${type}${min || 0}-${max || "∞"}`; return ""; }, "formatReq"); return { id: key, keywords: Array.isArray(keywords) ? keywords : [keywords].filter(Boolean), imgReq: formatReq(pt?.min_images, pt?.max_images, "图片"), textReq: formatReq(pt?.min_texts, pt?.max_texts, "文本"), tags: Array.isArray(tags) ? tags : [] }; } catch { return { id: key, keywords: [], imgReq: "", textReq: "", tags: [] }; } })); if (ctx.puppeteer) { try { const pageTitle = `表情模板列表`; allTemplates.sort((a, b) => { const keyA = a.keywords[0] || a.id; const keyB = b.keywords[0] || b.id; return keyA.localeCompare(keyB, "zh-CN"); }); return renderTemplateListAsImage(ctx, pageTitle, allTemplates).then( (buffer) => (0, import_koishi5.h)("image", { url: `data:image/png;base64,${buffer.toString("base64")}` }) ); } catch (err) { logger.error("渲染模板列表图片失败:", err); } } const allKeywords = []; allTemplates.forEach((template) => { if (template.keywords.length > 0) allKeywords.push(...template.keywords); else allKeywords.push(template.id); }); const lines = []; let currentLine = ""; for (const keyword of allKeywords) { const separator = currentLine ? " " : ""; let displayWidth = 0; const testStr = currentLine + separator + keyword; for (let i = 0; i < testStr.length; i++) { displayWidth += /[\u4e00-\u9fa5\uff00-\uffff]/.test(testStr[i]) ? 2 : 1; } if (displayWidth <= 36) { currentLine += separator + keyword; } else { lines.push(currentLine); currentLine = keyword; } } if (currentLine) lines.push(currentLine); const LINES_PER_PAGE = 10; const showAll = page === "all"; const pageNum = typeof page === "string" ? parseInt(page) || 1 : 1; const totalPages = Math.ceil(lines.length / LINES_PER_PAGE); const validPage = Math.max(1, Math.min(pageNum, totalPages)); const displayLines = showAll ? lines : lines.slice((validPage - 1) * LINES_PER_PAGE, validPage * LINES_PER_PAGE); const header = showAll ? `表情模板列表(共${allTemplates.length}个) ` : totalPages > 1 ? `表情模板列表(${validPage}/${totalPages}页) ` : `表情模板列表(共${allTemplates.length}个) `; return header + displayLines.join("\n"); } catch (err) { return autoRecall(session, `获取模板列表失败: ${err.message}`); } }); meme.subcommand(".make <key:string> [args:text]", "Meme 表情生成").usage('使用关键词或模板ID生成表情\n可添加文本、用户头像、图片等内容\n可用"-参数=值"来设置参数').example('memes.make ba_say 你好 -character=1 - 使用"ba_say"生成角色"心奈"的表情').example('memes.make 摸 @用户 - 使用"摸"生成表情').action(async ({ session }, key, args) => { if (!key) return autoRecall(session, "请提供模板ID或关键词"); if (blacklistArr.includes(key)) return autoRecall(session, `已禁用生成该表情`); const elements = args ? [(0, import_koishi5.h)("text", { content: args })] : []; return memeGenerator.generateMeme(session, key, elements); }); meme.subcommand(".info [key:string]", "获取模板信息").usage("查看指定模板的详细信息和参数\n包括需要的图片和文本数量和可选参数及示例").example('memes.info ba_say - 查看"ba_say"模板的详细信息').example('memes.info 摸 - 查看"摸"模板的详细信息').action(async ({ session }, key) => { if (!key) return autoRecall(session, "请提供模板ID或关键词"); try { const template = await memeGenerator.findTemplate(key); if (!template) return autoRecall(session, `未找到表情模板"${key}"`); const templateId = template.id; let previewImageBuffer = null; let previewImageBase64 = null; try { previewImageBuffer = await apiRequest( `${apiUrl}/memes/${templateId}/preview`, { responseType: "arraybuffer", timeout: 8e3 }, logger ); if (previewImageBuffer) { previewImageBase64 = `data:image/png;base64,${Buffer.from(previewImageBuffer).toString("base64")}`; } } catch (err) { logger.warn(`获取预览图失败: ${templateId}`); } if (ctx.puppeteer) { try { const infoImage = await renderTemplateInfoAsImage(ctx, template, previewImageBase64); return (0, import_koishi5.h)("image", { url: `data:image/png;base64,${infoImage.toString("base64")}` }); } catch (err) { logger.error("渲染模板信息图片失败:", err); } } const response = []; if (previewImageBuffer) { response.push((0, import_koishi5.h)("image", { url: previewImageBase64 })); } const output = []; const keywords = Array.isArray(template.keywords) ? template.keywords : [template.keywords].filter(Boolean); output.push(`模板"${keywords.join(", ")}(${template.id})"详细信息:`); if (template.tags?.length) output.push(`标签: ${template.tags.join(", ")}`); const pt = template.params_type || {}; output.push("需要参数:"); output.push(`- 图片: ${pt.min_images || 0}${pt.max_images !== pt.min_images ? `-${pt.max_images}` : ""}张`); output.push(`- 文本: ${pt.min_texts || 0}${pt.max_texts !== pt.min_texts ? `-${pt.max_texts}` : ""}条`); if (pt.default_texts?.length) output.push(`- 默认文本: ${pt.default_texts.join(", ")}`); if (pt.args_type?.args_model?.properties) { output.push("其他参数:"); const properties = pt.args_type.args_model.properties; const definitions = pt.args_type.args_model.$defs || {}; for (const key2 in properties) { if (key2 === "user_infos") continue; const prop = properties[key2]; let desc = `- ${key2}`; if (prop.type) { let typeStr = prop.type; if (prop.type === "array" && prop.items?.$ref) { const refType = prop.items.$ref.replace("#/$defs/", "").split("/")[0]; typeStr = `${prop.type}<${refType}>`; } desc += ` (${typeStr})`; } if (prop.default !== void 0) desc += ` 默认值: ${JSON.stringify(prop.default)}`; if (prop.description) desc += ` - ${prop.description}`; if (prop.enum?.length) desc += ` [可选值: ${prop.enum.join(", ")}]`; output.push(desc); } if (Object.keys(definitions).length) { output.push("类型定义:"); for (const typeName in definitions) { output.push(`- ${typeName}:`); const typeDef = definitions[typeName]; if (typeDef.properties) { for (const propName in typeDef.properties) { const prop = typeDef.properties[propName]; let propDesc = ` • ${propName}`; if (prop.type) propDesc += ` (${prop.type})`; if (prop.default !== void 0) propDesc += ` 默认值: ${JSON.stringify(prop.default)}`; if (prop.description) propDesc += ` - ${prop.description}`; if (prop.enum?.length) propDesc += ` [可选值: ${prop.enum.join(", ")}]`; output.push(propDesc); } } } } } if (pt.args_type?.parser_options?.length) { output.push("命令行参数:"); pt.args_type.parser_options.forEach((opt) => { let desc = `- ${opt.names.join(", ")}`; if (opt.args?.length) { const argsText = opt.args.map((arg) => { let argDesc = arg.name; if (arg.value) argDesc += `:${arg.value}`; if (arg.default !== null && arg.default !== void 0) argDesc += `=${arg.default}`; return argDesc; }).join(" "); desc += ` ${argsText}`; } if (opt.help_text) desc += ` - ${opt.help_text}`; output.push(desc); }); } if (pt.args_type?.args_examples?.length) { output.push("参数示例:"); pt.args_type.args_examples.forEach((example, i) => { output.push(`- 示例${i + 1}: ${JSON.stringify(example)}`); }); } if (template.shortcuts?.length) { output.push("快捷指令:"); template.shortcuts.forEach((shortcut) => { output.push(`- ${shortcut.humanized || shortcut.key}${shortcut.args?.length ? ` (参数: ${shortcut.args.join(" ")})` : ""}`); }); } if (template.date_created || template.date_modified) { output.push(`创建时间: ${template.date_created} 修改时间: ${template.date_modified}`); } response.push((0, import_koishi5.h)("text", { content: output.join("\n") })); return response; } catch (err) { return autoRecall(session, `获取模板信息失败: ${err.message}`); } }); meme.subcommand(".search <keyword:string>", "搜索模板表情").usage("根据关键词搜索表情模板\n可搜索模板ID、关键词或标签").example('memes.search 吃 - 搜索包含"吃"的表情模板').action(async ({ session }, keyword) => { if (!keyword) return autoRecall(session, "请提供关键词"); try { const results = await memeGenerator.matchTemplates(keyword); if (!results?.length) return autoRecall(session, `未找到有关"${keyword}"的表情模板`); const resultLines = results.map((t) => { const keywords = Array.isArray(t.keywords) ? t.keywords.join(", ") : t.keywords || ""; let line = `${keywords}(${t.id})`; if (t.tags?.length) line += ` #${t.tags.join("#")}`; return line; }); return `搜索结果(共${results.length}项): ${resultLines.join("\n")}`; } catch (err) { return autoRecall(session, `搜索失败: ${err.message}`); } }); meme.subcommand(".reload", "刷新模板缓存", { authority: 3 }).usage("刷新模板缓存,重新获取模板信息").action(async ({ session }) => { try { const result = await memeGenerator.refreshCache(); if (config.useMiddleware) keywordMap.clear(); logger.info(`已刷新缓存文件(${result.length}项)`); return `已刷新缓存文件(${result.length}项)`; } catch (err) { return autoRecall(session, `刷新缓存失败:${err.message}`); } }); if (config.useMiddleware) { ctx.on("message", async (session) => { if (keywordMap.size === 0) { keywordMap = memeGenerator.getAllKeywordMappings(); } const rawContent = session.content; if (!rawContent) return; const elements = import_koishi5.h.parse(rawContent); const firstTextElement = elements.find((el) => el.type === "text"); if (!firstTextElement?.attrs?.content) return; let content = firstTextElement.attrs.content.trim(); if (config.requirePrefix) { const prefixes = [].concat(ctx.root.config.prefix).filter(Boolean); if (prefixes.length) { const matched = prefixes.find((p) => content.startsWith(p)); if (!matched) return; content = content.slice(matched.length).trim(); } } const spaceIndex = content.indexOf(" "); const key = spaceIndex === -1 ? content : content.substring(0, spaceIndex); if (blacklistArr.includes(key)) return; const templateId = keywordMap.get(key); if (!templateId) return; const paramElements = []; if (spaceIndex !== -1) { const remainingText = content.substring(spaceIndex + 1).trim(); if (remainingText) paramElements.push((0, import_koishi5.h)("text", { content: remainingText })); } elements.forEach((element) => { if (element !== firstTextElement) paramElements.push(element); }); await session.send(await memeGenerator.generateMeme(session,