UNPKG

koishi-plugin-memes

Version:

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

1,154 lines (1,148 loc) 47.4 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, autoRecall: () => autoRecall, getUserAvatar: () => getUserAvatar, inject: () => inject, logger: () => logger, name: () => name, parseTarget: () => parseTarget }); module.exports = __toCommonJS(src_exports); var import_koishi4 = require("koishi"); // src/api.ts var import_koishi = require("koishi"); var import_axios = __toESM(require("axios")); var import_fs = __toESM(require("fs")); var import_path = __toESM(require("path")); var MemeAPI = class { static { __name(this, "MemeAPI"); } ctx; apiConfigs = []; logger; configPath; /** * 创建一个 MemeAPI 实例 * @param ctx Koishi 上下文 * @param logger 日志记录器 * @param generator 表情生成器实例 */ constructor(ctx, logger2) { this.ctx = ctx; this.logger = logger2; this.configPath = import_path.default.resolve(this.ctx.baseDir, "data", "memes-api.json"); this.loadConfig(); } /** * 加载外部配置文件 * 如果配置文件不存在,会创建默认配置 * @private */ loadConfig() { if (!import_fs.default.existsSync(this.configPath)) { try { const defaultConfig = [ { description: "示例配置", apiEndpoint: "https://example.com/api?qq=${arg1}&target=${arg2}" } ]; import_fs.default.writeFileSync(this.configPath, JSON.stringify(defaultConfig, null, 2), "utf-8"); this.logger.info(`已创建配置文件:${this.configPath}`); this.apiConfigs = defaultConfig; } catch (err) { this.logger.error(`创建配置失败:${err.message}`); } return; } try { const content = import_fs.default.readFileSync(this.configPath, "utf-8"); this.apiConfigs = JSON.parse(content); this.logger.info(`已加载配置文件:${this.apiConfigs.length}项`); } catch (err) { this.logger.error(`加载配置失败:${err.message}`); } } /** * 注册所有子命令 * 包括 api、list 和 reload 子命令 * @param meme 父命令对象 */ registerCommands(meme) { const api = meme.subcommand(".api [type:string] [arg1:string] [arg2:string]", "使用自定义API生成表情").usage("输入类型并补充对应参数来生成对应表情,使用关键词匹配").example('memes.api 吃 @用户 - 生成"吃"表情').example("memes.api - 随机使用模板生成表情").action(async ({ session }, type, arg1, arg2) => { const index = !type ? Math.floor(Math.random() * this.apiConfigs.length) : this.apiConfigs.findIndex( (config2) => config2.description.split("|")[0].trim() === type.trim() ); if (index === -1) { return autoRecall(session, `未找到表情"${type}"`); } const config = this.apiConfigs[index]; const parsedArg1 = parseTarget(arg1); const parsedArg2 = parseTarget(arg2); let apiUrl = config.apiEndpoint.replace(/\${arg1}/g, parsedArg1).replace(/\${arg2}/g, parsedArg2); try { const response = await import_axios.default.get(apiUrl, { timeout: 8e3, validateStatus: /* @__PURE__ */ __name(() => true, "validateStatus"), responseType: "text" }); let imageUrl = apiUrl; if (response.headers["content-type"]?.includes("application/json")) { const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; if (data?.code === 200) imageUrl = data.data; } return (0, import_koishi.h)("image", { url: imageUrl }); } catch (err) { return autoRecall(session, "生成出错:" + err.message); } }); api.subcommand(".list [page:string]", "列出可用模板列表").usage('输入页码查看列表或使用"all"查看所有模板').example("memes.api.list - 查看第一页API模板列表").example("memes.api.list all - 查看所有API模板列表").action(({}, page) => { const ITEMS_PER_PAGE = 10; const showAll = page === "all"; const pageNum = typeof page === "string" ? parseInt(page) || 1 : page || 1; const typeDescriptions = this.apiConfigs.map((config) => config.description); const lines = []; let currentLine = ""; let currentWidth = 0; const MAX_WIDTH = 36; const SEPARATOR = " "; for (const description of typeDescriptions) { let descWidth = 0; for (const char of description) { descWidth += /[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef]/.test(char) ? 2 : 1; } if (currentWidth + descWidth + 1 > MAX_WIDTH && currentWidth > 0) { lines.push(currentLine); currentLine = description; currentWidth = descWidth; } else if (currentLine.length === 0) { currentLine = description; currentWidth = descWidth; } else { currentLine += SEPARATOR + description; currentWidth += 1 + descWidth; } } if (currentLine.length > 0) { lines.push(currentLine); } 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 ? `表情模板列表(共${this.apiConfigs.length}项) ` : totalPages > 1 ? `表情模板列表(${validPage}/${totalPages}页) ` : "表情模板列表\n"; return header + displayLines.join("\n"); }); api.subcommand(".reload", "重载自定义API配置", { authority: 3 }).usage("重新加载本地API配置文件").action(async ({ session }) => { try { const content = import_fs.default.readFileSync(this.configPath, "utf-8"); this.apiConfigs = JSON.parse(content); return `已重载配置文件:${this.apiConfigs.length}项`; } catch (err) { return autoRecall(session, "重载配置失败:" + err.message); } }); } }; // src/make.ts var import_koishi2 = require("koishi"); var import_fs2 = require("fs"); var import_path2 = __toESM(require("path")); var MemeMaker = class { static { __name(this, "MemeMaker"); } ctx; 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 } } }; /** * 创建表情生成器实例 */ constructor(ctx) { this.ctx = ctx; for (const key in this.IMAGE_CONFIG.styles) { const style = this.IMAGE_CONFIG.styles[key]; style.background = import_path2.default.resolve(__dirname, "./assets", style.background); } } /** * 将HTML内容渲染为图片 */ async htmlToImage(html, options = {}) { const page = await this.ctx.puppeteer.page(); try { await page.setViewport({ width: options.width, height: options.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) => { if (img.complete) return Promise.resolve(); return new Promise((resolve) => { img.addEventListener("load", resolve); img.addEventListener("error", resolve); }); }) )); return await page.screenshot({ type: "png", fullPage: false }); } catch (error) { throw new Error("图片渲染出错:" + error.message); } finally { await page.close(); } } /** * 将图片资源转为base64数据URL */ imageToDataUrl(imagePath) { const filePath = imagePath.replace("file://", ""); if ((0, import_fs2.existsSync)(filePath)) { return `data:image/jpeg;base64,${(0, import_fs2.readFileSync)(filePath).toString("base64")}`; } return imagePath.startsWith("http") ? imagePath : null; } /** * 生成头像效果合成图 */ async generateAvatarEffect(avatarUrl, style) { const styleConfig = this.IMAGE_CONFIG.styles[style] || this.IMAGE_CONFIG.styles.jiazi; const sizeConfig = this.IMAGE_CONFIG.sizes.standard; const avatarImageSrc = this.imageToDataUrl(avatarUrl); const backgroundImage = this.imageToDataUrl(`file://${styleConfig.background}`); const avatarHtml = 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}" />` : ""; 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}" /> ${avatarHtml} </div>`; return await this.htmlToImage(html, sizeConfig); } /** * 注册表情生成相关命令 */ registerCommands(parentCommand) { const make = parentCommand.subcommand(".make", "生成内置表情图片").usage("生成各种预设的表情图片").example('memes.make.jiazi @用户 - 使用指定用户头像生成"你要被夹"图片'); const registerStyle = /* @__PURE__ */ __name((name2, description) => { make.subcommand(`.${name2} [target:text]`, description).usage(`根据用户头像生成${description}`).example(`memes.make.${name2} @用户 - 使用指定用户头像生成图片`).example(`memes.make.${name2} 123456789 - 使用QQ号生成图片`).action(async (params, target) => { const session = params.session; const userId = target ? parseTarget(target) || session.userId : session.userId; try { const avatar = await getUserAvatar(session, userId); const result = await this.generateAvatarEffect(avatar, name2); return import_koishi2.h.image(result, "image/png"); } catch (error) { return autoRecall(session, "生成出错:" + error.message); } }); }, "registerStyle"); Object.keys(this.IMAGE_CONFIG.styles).forEach((style) => { const descriptions = { jiazi: '生成"你要被夹"图片', tntboom: '生成"你要被炸"图片' }; registerStyle(style, descriptions[style]); }); return make; } }; // src/generator.ts var import_koishi3 = require("koishi"); var import_axios2 = __toESM(require("axios")); var import_fs3 = __toESM(require("fs")); 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"); this.initCache(); } static { __name(this, "MemeGenerator"); } memeCache = []; cachePath; /** * 初始化模板缓存 * @private * @async */ async initCache() { if (!this.apiUrl) return; this.memeCache = await this.loadCache(); if (!this.memeCache.length) await this.refreshCache(); else this.logger.info(`已加载缓存文件:${this.memeCache.length}项`); } /** * 从本地文件加载缓存 * @private * @async * @returns {Promise<MemeInfo[]>} 模板信息数组 */ async loadCache() { try { if (import_fs3.default.existsSync(this.cachePath)) { const cacheData = JSON.parse(import_fs3.default.readFileSync(this.cachePath, "utf-8")); if (cacheData.time && cacheData.data) return cacheData.data; } } catch (e) { this.logger.warn(`读取缓存失败: ${e.message}`); } return []; } /** * 保存缓存到本地文件 * @private * @async * @param {MemeInfo[]} data - 要保存的模板数据 * @returns {Promise<void>} */ async saveCache(data) { try { import_fs3.default.writeFileSync(this.cachePath, JSON.stringify({ time: Date.now(), data }, null, 2), "utf-8"); this.logger.info(`已创建缓存文件:${data.length}项`); } catch (e) { this.logger.error(`保存缓存失败: ${e.message}`); } } /** * 刷新模板缓存 * @async * @returns {Promise<MemeInfo[]>} 刷新后的模板信息数组 */ async refreshCache() { try { const keys = await this.apiRequest(`${this.apiUrl}/memes/keys`); 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 this.apiRequest(`${this.apiUrl}/memes/${key}/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(`获取模板[${key}]信息失败:${e.message}`); return { id: key, keywords: [], tags: [], params_type: {} }; } })); await this.saveCache(templates); this.memeCache = templates; return templates; } catch (e) { this.logger.error(`刷新缓存失败: ${e.message}`); return []; } } /** * 发送API请求 * @async * @template T - 响应数据类型 * @param {string} url - 请求URL * @param {Object} options - 请求选项 * @param {string} [options.method='get'] - 请求方法 * @param {any} [options.data] - 请求数据 * @param {FormData} [options.formData] - 表单数据 * @param {string} [options.responseType='json'] - 响应类型 * @param {number} [options.timeout=8000] - 超时时间(毫秒) * @returns {Promise<T|null>} 响应数据或null */ async apiRequest(url, options = {}) { const { method = "get", data, formData, responseType = "json", timeout = 8e3 } = options; try { const response = await (0, import_axios2.default)({ url, method, data: formData || data, headers: formData ? { "Accept": "image/*,application/json" } : void 0, responseType: responseType === "arraybuffer" ? "arraybuffer" : "json", timeout, validateStatus: /* @__PURE__ */ __name(() => true, "validateStatus") }); if (response.status !== 200) { let errorMessage = `HTTP状态码 ${response.status}`; if (responseType === "arraybuffer") { try { const errText = Buffer.from(response.data).toString("utf-8"); const errJson = JSON.parse(errText); errorMessage = errJson.error || errJson.message || errorMessage; } catch { } } else if (response.data) { errorMessage = response.data.error || response.data.message || errorMessage; } this.logger.warn(`API请求失败: ${url} - ${errorMessage}`); return null; } return response.data; } catch (e) { this.logger.error(`API请求异常: ${url} - ${e.message}`); return null; } } /** * 获取模板详细信息 * @async * @param {MemeInfo} template - 模板信息 * @returns {Promise<{id: string, keywords: string[], imgReq: string, textReq: string, tags: string[]}>} 格式化后的模板详情 */ async getTemplateDetails(template) { const { id, keywords = [], tags = [], params_type: pt = {} } = template; const formatReq = /* @__PURE__ */ __name((min, max, type = "") => { if (min === max && min) return `${type}${min}`; if (min || max) return `${type}${min || 0}-${max || "∞"}`; return ""; }, "formatReq"); const imgReq = formatReq(pt.min_images, pt.max_images, "图片"); const textReq = formatReq(pt.min_texts, pt.max_texts, "文本"); return { id, keywords, imgReq, textReq, tags }; } /** * 验证参数是否符合模板要求 * @private * @param {Object} params - 验证参数 * @param {number} params.imageCount - 实际图片数量 * @param {number} params.minImages - 最少需要的图片数量 * @param {number} params.maxImages - 最多允许的图片数量 * @param {number} params.textCount - 实际文本数量 * @param {number} params.minTexts - 最少需要的文本数量 * @param {number} params.maxTexts - 最多允许的文本数量 * @throws {Error} 当参数不符合要求时抛出错误 */ validateParams({ imageCount, minImages, maxImages, textCount, minTexts, maxTexts }) { const formatRange = /* @__PURE__ */ __name((min, max) => { if (min === max) return `${min}`; if (min != null && max != null) return `${min}~${max}`; if (min != null) return `至少${min}`; if (max != null) return `最多${max}`; return ""; }, "formatRange"); const checkCount = /* @__PURE__ */ __name((count, min, max, type) => { if (min != null && count < min || max != null && min != null && count > max) { const range = formatRange(min, max); throw new Error(`当前${count}${type === "图片" ? "张" : "条"}${type},需要${range}${type === "图片" ? "张" : "条"}${type}`); } }, "checkCount"); checkCount(imageCount, minImages, maxImages, "图片"); checkCount(textCount, minTexts, maxTexts, "文本"); } /** * 匹配模板关键词 * @private * @param {string} key - 搜索关键词 * @returns {MemeInfo[]} 按匹配度排序的模板数组 */ matchTemplates(key) { if (!key || !this.memeCache.length) return []; const getPriority = /* @__PURE__ */ __name((template) => { if (template.id === key || template.keywords?.some((k) => k === key)) return 1; if (template.keywords?.some((k) => k.includes(key))) return 2; if (template.keywords?.some((k) => key.includes(k))) return 3; if (template.id.includes(key)) return 4; if (template.tags?.some((tag) => tag === key || tag.includes(key))) return 5; return 99; }, "getPriority"); return this.memeCache.map((template) => ({ template, priority: getPriority(template) })).filter((item) => item.priority < 99).sort((a, b) => a.priority - b.priority).map((item) => item.template); } /** * 查找表情包模板 * @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 this.apiRequest(`${this.apiUrl}/memes/${key}/info`); if (info) { this.refreshCache().catch((e) => { this.logger.warn(`刷新缓存失败: ${e.message}`); }); 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(); for (const template of this.memeCache) { keywordMap.set(template.id, template.id); if (template.keywords && Array.isArray(template.keywords)) { for (const keyword of template.keywords) { if (keyword) keywordMap.set(keyword, template.id); } } } return keywordMap; } /** * 生成表情包 * @async * @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: origImageInfos, texts: origTexts, options } = await this.parseArgs(session, args, templateInfo).catch((e) => { throw new Error(`参数解析失败: ${e.message}`); }); let imageInfos = [...origImageInfos]; let texts = [...origTexts]; const needSelfAvatar = min_images === 1 && !imageInfos.length || imageInfos.length && imageInfos.length + 1 === min_images; if (needSelfAvatar) { imageInfos = [{ userId: session.userId }, ...imageInfos]; } if (!texts.length && default_texts.length) { texts = [...default_texts]; } try { this.validateParams({ imageCount: imageInfos.length, minImages: min_images, maxImages: max_images, textCount: texts.length, minTexts: min_texts, maxTexts: max_texts }); } catch (e) { return autoRecall(session, e.message); } const imagesAndInfos = await this.fetchImages(session, imageInfos).catch((e) => { throw new Error(`获取图片失败: ${e.message}`); }); const imageBuffer = await this.renderMeme(tempId, texts, imagesAndInfos, options).catch((e) => { throw new Error(`生成表情失败: ${e.message}`); }); return (0, import_koishi3.h)("image", { url: `data:image/png;base64,${Buffer.from(imageBuffer).toString("base64")}` }); } catch (e) { return autoRecall(session, e.message); } } /** * 解析命令参数 * @private * @async * @param {any} session - 会话上下文 * @param {h[]} args - 参数元素数组 * @param {MemeInfo} templateInfo - 模板信息 * @returns {Promise<ResolvedArgs>} 解析后的参数 */ async parseArgs(session, args, templateInfo) { const imageInfos = []; const texts = []; const options = {}; let allText = ""; const processUserId = /* @__PURE__ */ __name((userId) => { if (userId) { imageInfos.push({ userId }); } }, "processUserId"); if (session.quote?.elements) { const processQuoteElement = /* @__PURE__ */ __name((e) => { if (e.type === "img" && e.attrs.src) imageInfos.push({ src: e.attrs.src }); if (e.children?.length) e.children.forEach(processQuoteElement); }, "processQuoteElement"); session.quote.elements.forEach(processQuoteElement); } const processTextContent = /* @__PURE__ */ __name((content) => { let processedContent = content.replace(/<at id=['"]?([0-9]+)['"]?\/>/g, (match, userId) => { processUserId(userId); return " "; }); processedContent = processedContent.replace(/<img[^>]*src=['"]([^'"]+)['"][^>]*\/?>/g, (match, src) => { if (src) { imageInfos.push({ src }); } return " "; }); return processedContent; }, "processTextContent"); const processElement = /* @__PURE__ */ __name((e) => { if (e.type === "text" && e.attrs.content) { allText += processTextContent(e.attrs.content) + " "; } else if (e.type === "at" && e.attrs.id) { processUserId(e.attrs.id); } else if (e.type === "img" && e.attrs.src) { imageInfos.push({ src: e.attrs.src }); } if (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 [, key, 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[key] = value; } } else if (token.startsWith("<at") || token.startsWith("@")) { const userId = token.startsWith("<at") ? parseTarget(token) : token.match(/@(\d+)/)?.[1]; if (userId) { processUserId(userId); } else if (token.startsWith("@")) { texts.push(token); } } else { const trimmedToken = token.replace(/^(['"])(.*)\1$/, "$2"); texts.push(trimmedToken); } }); } const properties = templateInfo?.params_type?.args_type?.args_model?.properties || {}; for (const key in properties) { if (key in options && key !== "user_infos") { const prop = properties[key]; const value = options[key]; if (prop.type === "integer" && typeof value !== "number") options[key] = parseInt(String(value), 10); else if (prop.type === "number" && typeof value !== "number") options[key] = parseFloat(String(value)); else if (prop.type === "boolean" && typeof value !== "boolean") options[key] = value === "true" || value === "1" || value === 1; } } return { imageInfos, texts, options }; } /** * 获取图片和用户信息 * @private * @async * @param {any} session - 会话上下文 * @param {ImageFetchInfo[]} imageInfos - 图片来源信息 * @returns {Promise<ImagesAndInfos>} 获取到的图片和用户信息 * @throws {Error} 获取图片失败时抛出错误 */ async fetchImages(session, imageInfos) { const images = []; const userInfos = []; await Promise.all(imageInfos.map(async (info, index) => { let url; let userInfo = {}; if ("src" in info) { url = info.src; } else if ("userId" in info) { url = await getUserAvatar(session, info.userId); userInfo = { name: info.userId }; } try { const response = await import_axios2.default.get(url, { responseType: "arraybuffer", timeout: 8e3 }); const buffer = Buffer.from(response.data); const contentType = response.headers["content-type"] || "image/png"; images[index] = new Blob([buffer], { type: contentType }); userInfos[index] = userInfo; } catch (error) { this.logger.warn(`获取图片失败: ${error.message}`); images[index] = new Blob([], { type: "image/png" }); userInfos[index] = userInfo; } })); return { images, userInfos }; } /** * 渲染表情包 * @private * @async * @param {string} tempId - 模板ID * @param {string[]} texts - 文本参数 * @param {ImagesAndInfos} imagesAndInfos - 图片和用户信息 * @param {Record<string, any>} options - 其他选项 * @returns {Promise<Buffer>} 生成的图片数据 */ async renderMeme(tempId, texts, imagesAndInfos, options) { const formData = new FormData(); texts.forEach((text) => formData.append("texts", text)); imagesAndInfos.images.forEach((img) => formData.append("images", img)); formData.append("args", JSON.stringify({ user_infos: imagesAndInfos.userInfos, ...options })); return this.apiRequest(`${this.apiUrl}/memes/${tempId}/`, { method: "post", formData, responseType: "arraybuffer", timeout: 1e4 }); } }; // src/index.ts var name = "memes"; var inject = { optional: ["puppeteer"] }; var logger = new import_koishi4.Logger("memes"); var Config = import_koishi4.Schema.object({ loadApi: import_koishi4.Schema.boolean().description("开启自定义 API 生成功能").default(false), genUrl: import_koishi4.Schema.string().description("MemeGenerator API 配置").default("http://localhost:2233"), useMiddleware: import_koishi4.Schema.boolean().description("开启中间件关键词匹配").default(false), requirePrefix: import_koishi4.Schema.boolean().description("开启关键词指令前缀").default(true) }); function parseTarget(arg) { try { const atElement = import_koishi4.h.select(import_koishi4.h.parse(arg), "at")[0]; if (atElement?.attrs?.id) return atElement.attrs.id; } catch { } const match = arg.match(/@(\d+)/); if (match) return match[1]; if (/^\d+$/.test(arg.trim())) { 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(async () => { await session.bot?.deleteMessage(session.channelId, msg.toString()); }, delay); return null; } catch (error) { return null; } } __name(autoRecall, "autoRecall"); function apply(ctx, config) { const apiUrl = !config.genUrl ? "" : config.genUrl.trim().replace(/\/+$/, ""); const memeGenerator = new MemeGenerator(ctx, logger, apiUrl); const memeMaker = new MemeMaker(ctx); let keywordToTemplateMap = /* @__PURE__ */ new Map(); let allKeywords = []; const meme = ctx.command("memes <key:string> [args:text]", "制作表情包").usage('输入模板ID或关键词并添加参数和选项来生成表情包\n例:memes 模板ID/关键词 文本/图片 -参数=值\n多个文本以空格分隔,包含空格的文本须带引号\n可手动添加图片或@用户添加头像\n需使用"."触发子指令,如:memes.list').example('memes ba_say 你好 -character=1 - 使用模板ID"ba_say"生成"心奈说:你好"的表情').example('memes 摸 @用户 - 使用关键词"摸"生成摸头表情').action(async ({ session }, key, args) => { if (!key) { return autoRecall(session, "请提供模板ID或关键词"); } const elements = args ? [(0, import_koishi4.h)("text", { content: args })] : []; return memeGenerator.generateMeme(session, key, elements); }); meme.subcommand(".list [page:string]", "列出可用模板列表").usage('输入页码查看列表或使用"all"查看所有模板').example("memes.list - 查看第一页模板列表").example("memes.list all - 查看所有模板列表").action(async ({ session }, page) => { let result; try { let keys; if (memeGenerator["memeCache"].length > 0) { keys = memeGenerator["memeCache"].map((t) => t.id); } else { const apiKeys = await memeGenerator["apiRequest"](`${memeGenerator["apiUrl"]}/memes/keys`); keys = apiKeys; } const allTemplates = await Promise.all(keys.map(async (key) => { const cachedTemplate = memeGenerator["memeCache"].find((t) => t.id === key); if (cachedTemplate) { const info = cachedTemplate; const keywords = info.keywords || []; const tags = info.tags || []; const pt = info.params_type || {}; let imgReq = ""; if (pt.min_images === pt.max_images) { imgReq = pt.min_images > 0 ? `图片${pt.min_images}` : ""; } else { imgReq = pt.min_images > 0 || pt.max_images > 0 ? `图片${pt.min_images}-${pt.max_images}` : ""; } let textReq = ""; if (pt.min_texts === pt.max_texts) { textReq = pt.min_texts > 0 ? `文本${pt.min_texts}` : ""; } else { textReq = pt.min_texts > 0 || pt.max_texts > 0 ? `文本${pt.min_texts}-${pt.max_texts}` : ""; } return { id: info.id, keywords, imgReq, textReq, tags }; } try { const info = await memeGenerator["apiRequest"](`${memeGenerator["apiUrl"]}/memes/${key}/info`); if (!info) return { id: key, keywords: [], imgReq: "", textReq: "", tags: [] }; const template = { id: key, keywords: info.keywords ? Array.isArray(info.keywords) ? info.keywords : [info.keywords] : [], tags: info.tags && Array.isArray(info.tags) ? info.tags : [], params_type: info.params_type || {} }; const keywords = template.keywords || []; const tags = template.tags || []; const pt = template.params_type || {}; let imgReq = ""; if (pt.min_images === pt.max_images) { imgReq = pt.min_images > 0 ? `图片${pt.min_images}` : ""; } else { imgReq = pt.min_images > 0 || pt.max_images > 0 ? `图片${pt.min_images}-${pt.max_images}` : ""; } let textReq = ""; if (pt.min_texts === pt.max_texts) { textReq = pt.min_texts > 0 ? `文本${pt.min_texts}` : ""; } else { textReq = pt.min_texts > 0 || pt.max_texts > 0 ? `文本${pt.min_texts}-${pt.max_texts}` : ""; } return { id: template.id, keywords, imgReq, textReq, tags }; } catch (err) { return { id: key, keywords: [], imgReq: "", textReq: "", tags: [] }; } })); const allKeywords2 = []; allTemplates.forEach((template) => { if (template.keywords.length > 0) allKeywords2.push(...template.keywords); else allKeywords2.push(template.id); }); const formattedLines = []; let currentLine = ""; for (const keyword of allKeywords2) { const separator = currentLine ? " " : ""; let displayWidth = 0; const stringToCheck = currentLine + separator + keyword; for (let i = 0; i < stringToCheck.length; i++) { displayWidth += /[\u4e00-\u9fa5\uff00-\uffff]/.test(stringToCheck[i]) ? 2 : 1; } if (displayWidth <= 36) { currentLine += separator + keyword; } else { formattedLines.push(currentLine); currentLine = keyword; } } if (currentLine) formattedLines.push(currentLine); const LINES_PER_PAGE = 10; const showAll2 = page === "all"; const pageNum = typeof page === "string" ? parseInt(page) || 1 : page || 1; const totalPages2 = Math.ceil(formattedLines.length / LINES_PER_PAGE); const validPage2 = Math.max(1, Math.min(pageNum, totalPages2)); const displayLines2 = showAll2 ? formattedLines : formattedLines.slice((validPage2 - 1) * LINES_PER_PAGE, validPage2 * LINES_PER_PAGE); result = { keys, totalTemplates: allTemplates.length, totalKeywords: allKeywords2.length, displayLines: displayLines2, totalPages: totalPages2, validPage: validPage2, showAll: showAll2 }; } catch (err) { return autoRecall(session, `获取模板列表失败: ${err.message}`); } const { totalTemplates, displayLines, totalPages, validPage, showAll } = result; const header = showAll ? `表情模板列表(共${totalTemplates}个) ` : totalPages > 1 ? `表情模板列表(${validPage}/${totalPages}页) ` : `表情模板列表(共${totalTemplates}个) `; return header + displayLines.join("\n"); }); meme.subcommand(".info [key:string]", "获取模板详细信息").usage("查看指定模板的详细信息和参数").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; const response = []; try { const previewImage = await memeGenerator["apiRequest"]( `${memeGenerator["apiUrl"]}/memes/${templateId}/preview`, { responseType: "arraybuffer", timeout: 8e3 } ); if (previewImage) { const base64 = Buffer.from(previewImage).toString("base64"); response.push((0, import_koishi4.h)("image", { url: `data:image/png;base64,${base64}` })); } } catch (previewErr) { logger.warn(`获取预览图失败: ${templateId}`); } const outputContent = []; const keywords = Array.isArray(template.keywords) ? template.keywords : [template.keywords].filter(Boolean); outputContent.push(`模板"${keywords.join(", ")}(${templateId})"详细信息:`); if (template.tags?.length) outputContent.push(`标签: ${template.tags.join(", ")}`); const pt = template.params_type || {}; outputContent.push("需要参数:"); outputContent.push(`- 图片: ${pt.min_images || 0}${pt.max_images !== pt.min_images ? `-${pt.max_images}` : ""}张`); outputContent.push(`- 文本: ${pt.min_texts || 0}${pt.max_texts !== pt.min_texts ? `-${pt.max_texts}` : ""}条`); if (pt.default_texts?.length) outputContent.push(`- 默认文本: ${pt.default_texts.join(", ")}`); if (pt.args_type?.args_model?.properties) { outputContent.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 propDesc = `- ${key2}`; if (prop.type) { let typeStr = prop.type; if (prop.type === "array" && prop.items?.$ref) { const refTypeName = prop.items.$ref.replace("#/$defs/", "").split("/")[0]; typeStr = `${prop.type}<${refTypeName}>`; } propDesc += ` (${typeStr})`; } if (prop.default !== void 0) propDesc += ` 默认值: ${JSON.stringify(prop.default)}`; if (prop.description) propDesc += ` - ${prop.description}`; if (prop.enum?.length) propDesc += ` [可选值: ${prop.enum.join(", ")}]`; outputContent.push(propDesc); } if (Object.keys(definitions).length > 0) { outputContent.push("类型定义:"); for (const typeName in definitions) { outputContent.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(", ")}]`; outputContent.push(propDesc); } } } } } if (pt.args_type?.parser_options?.length) { outputContent.push("命令行参数:"); pt.args_type.parser_options.forEach((opt) => { const names = opt.names.join(", "); const argInfo = opt.args?.length ? 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(" ") : ""; outputContent.push(`- ${names} ${argInfo}${opt.help_text ? ` - ${opt.help_text}` : ""}`); }); } if (pt.args_type?.args_examples?.length) { outputContent.push("参数示例:"); pt.args_type.args_examples.forEach((example, i) => { outputContent.push(`- 示例${i + 1}: ${JSON.stringify(example)}`); }); } if (template.shortcuts?.length) { outputContent.push("快捷指令:"); template.shortcuts.forEach((shortcut) => { outputContent.push(`- ${shortcut.humanized || shortcut.key}${shortcut.args?.length ? ` (参数: ${shortcut.args.join(" ")})` : ""}`); }); } if (template.date_created || template.date_modified) { outputContent.push(`创建时间: ${template.date_created} 修改时间: ${template.date_modified}`); } response.push((0, import_koishi4.h)("text", { content: outputContent.join("\n") })); return response; } catch (err) { return autoRecall(session, `未找到模板: ${key} - ${err.message}`); } }); meme.subcommand(".search <keyword:string>", "搜索表情模板").usage("根据关键词搜索表情模板").example('memes.search 吃 - 搜索包含"吃"关键词的表情模板').action(async ({ session }, keyword) => { if (!keyword) { return autoRecall(session, "请提供关键词"); } try { const results = await memeGenerator.matchTemplates(keyword); if (!results || results.length === 0) { return autoRecall(session, `未找到有关"${keyword}"的表情模板`); } const resultLines = results.map((t) => { let line = `${t.keywords}(${t.id})`; if (t.tags?.length > 0) { line += ` #${t.tags.join("#")}`; } return line; }); return `搜索结果(共${results.length}项): ` + resultLines.join("\n"); } catch (err) { return autoRecall(session, `未找到模板: ${err.message}`); } }); meme.subcommand(".refresh", "刷新表情模板缓存", { authority: 3 }).usage("手动刷新表情模板缓存数据").action(async ({ session }) => { try { const result = await memeGenerator.refreshCache(); if (config.useMiddleware) { keywordToTemplateMap.clear(); allKeywords = []; } return `已刷新缓存文件:${result.length}项`; } catch (err) { return autoRecall(session, `刷新缓存失败:${err.message}`); } }); if (config.useMiddleware) { ctx.on("message", async (session) => { if (allKeywords.length === 0) { keywordToTemplateMap = memeGenerator.getAllKeywordMappings(); allKeywords = Array.from(keywordToTemplateMap.keys()); } const rawContent = session.content; if (!rawContent) return; const elements = import_koishi4.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); const templateId = keywordToTemplateMap.get(key); if (!templateId) return; const paramElements = []; if (spaceIndex !== -1) { const remainingText = content.substring(spaceIndex + 1).trim(); if (remainingText) { paramElements.push((0, import_koishi4.h)("text", { content: remainingText })); } } for (let i = 0; i < elements.length; i++) { const element = elements[i]; if (element !== firstTextElement) { paramElements.push(element); } } await session.send(await memeGenerator.generateMeme(session, key, paramElements)); }); } memeMaker.registerCommands(meme); if (config.loadApi) { const externalApi = new MemeAPI(ctx, logger); externalApi.registerCommands(meme); } } __name(apply, "apply"); // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { Config, apply, autoRecall, getUserAvatar, inject, logger, name, parseTarget });