koishi-plugin-memes
Version:
生成 Meme 表情包,支持 MemeGenerator API、内置模板和自定义 API 接口
1,154 lines (1,148 loc) • 47.4 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 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
});