koishi-plugin-memes
Version:
MemeGenerator(RS)表情生成,全功能支持,可自由配置黑名单。
957 lines (953 loc) • 47.7 kB
JavaScript
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
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 __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
Config: () => Config,
apply: () => apply,
inject: () => inject,
name: () => name,
usage: () => usage
});
module.exports = __toCommonJS(src_exports);
var import_koishi2 = require("koishi");
// src/provider.ts
var import_koishi = require("koishi");
async function getAvatar(session, userId) {
const targetId = userId || session.userId;
if (targetId === session.userId && session.author?.avatar) return session.author.avatar;
return session.bot?.getUser?.(targetId).then((u) => u?.avatar).catch(() => null) || "";
}
__name(getAvatar, "getAvatar");
var MemeProvider = class {
/**
* MemeProvider 构造函数。
* @param ctx - Koishi 的上下文对象。
* @param url - 后端 API 的地址。
* @param config - 插件的配置项。
*/
constructor(ctx, url, config) {
this.ctx = ctx;
this.url = url;
this.config = config;
this.logger = ctx.logger("memes");
}
static {
__name(this, "MemeProvider");
}
isRsApi = false;
cache = [];
keys = [];
logger;
config;
/**
* 启动并初始化 Provider。
* 它会检查 API 版本并根据配置拉取数据。
* @returns 返回一个包含版本号和模板总数的对象。
*/
async start() {
const endpoint = `${this.url}/meme/version`;
for (let i = 0; i <= 3; i++) {
try {
if (this.config.debug) this.logger.info(`[REQUEST] GET ${endpoint}`);
const versionRaw = await this.ctx.http.get(endpoint, { responseType: "text" });
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${versionRaw}`);
const version = versionRaw.replace(/"/g, "");
this.isRsApi = !version.startsWith("0.1.");
const count = await this.fetch();
return { version, count };
} catch (err) {
if (i === 3) throw err;
await new Promise((resolve) => setTimeout(resolve, 6e4));
}
}
}
/**
* 将非 rs API 返回的原始模板信息解析为标准 MemeInfo 格式。
* @param data - 从 API 获取的原始数据对象。
* @returns 标准化的 MemeInfo 对象。
*/
parseNonRsInfo(data) {
const params = data.params_type || {};
return {
key: data.key,
keywords: data.keywords || [],
minImages: params.min_images || 0,
maxImages: params.max_images || 0,
minTexts: params.min_texts || 0,
maxTexts: params.max_texts || 0,
defaultTexts: params.default_texts || [],
args: Object.entries(params.args_type?.args_model?.properties || {}).filter(([key]) => key !== "user_infos").map(([key, prop]) => ({
name: key,
type: prop.type,
default: prop.default,
description: prop.description,
choices: prop.enum || null
})),
tags: data.tags || [],
shortcuts: data.shortcuts || [],
date_created: data.date_created,
date_modified: data.date_modified
};
}
/**
* 根据配置从后端 API 拉取数据。
* 如果 `cacheAllInfo` 为 true,则缓存所有模板的详细信息;
* 否则,仅缓存模板的 key 列表。
* @returns 返回获取到的模板总数。
*/
async fetch() {
if (this.config.cacheAllInfo && this.isRsApi) {
const endpoint = `${this.url}/meme/infos`;
if (this.config.debug) this.logger.info(`[REQUEST] GET ${endpoint}`);
const data = await this.ctx.http.get(endpoint);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(data)}`);
this.cache = data.map((info) => ({
key: info.key,
keywords: info.keywords || [],
minImages: info.params.min_images,
maxImages: info.params.max_images,
minTexts: info.params.min_texts,
maxTexts: info.params.max_texts,
defaultTexts: info.params.default_texts || [],
args: (info.params.options || []).map((opt) => ({ ...opt })),
tags: info.tags || [],
shortcuts: info.shortcuts || [],
date_created: info.date_created,
date_modified: info.date_modified
}));
this.keys = this.cache.map((item) => item.key);
return this.cache.length;
}
const keysEndpoint = this.isRsApi ? `${this.url}/meme/keys` : `${this.url}/memes/keys`;
if (this.config.debug) this.logger.info(`[REQUEST] GET ${keysEndpoint}`);
const keys = await this.ctx.http.get(keysEndpoint);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(keys)}`);
this.keys = keys;
if (this.config.cacheAllInfo && !this.isRsApi) {
(async () => {
const batchSize = 10;
const tempCache = [];
for (let i = 0; i < keys.length; i += batchSize) {
const batch = keys.slice(i, i + batchSize);
const results = await Promise.allSettled(batch.map((key) => this.ctx.http.get(`${this.url}/memes/${key}/info`, { timeout: 3e4 })));
results.forEach((res) => {
if (res.status === "fulfilled" && res.value) tempCache.push(this.parseNonRsInfo(res.value));
});
}
this.cache = tempCache;
this.keys = this.cache.map((item) => item.key);
})();
}
return keys.length;
}
/**
* @description 根据会话的上下文(群组ID)和全局配置,计算并返回一个包含所有被禁用表情 key 的集合。
* @param {string} [guildId] - (可选) 当前会话的群组 ID。如果提供,将用于匹配特定群组的禁用规则。
* @returns {Set<string>} 一个包含所有在当前上下文中被禁用的表情 key 的集合,可用于快速过滤。
*/
getExclusionSet(guildId) {
const allBannedKeys = [];
if (!this.config.blacklist || !this.config.blacklist.length) return /* @__PURE__ */ new Set();
for (const rule of this.config.blacklist) {
if (!rule.guildId || rule.guildId === guildId) {
if (rule.keyId && typeof rule.keyId === "string") {
const keys = rule.keyId.split(",").map((key) => key.trim()).filter(Boolean);
allBannedKeys.push(...keys);
}
}
}
return new Set(allBannedKeys);
}
/**
* 快速判断一个词是否可以触发表情制作。
* 此方法仅操作本地缓存,不产生网络请求。
* @param word - 要检查的单词 (key 或 keyword)。
* @returns 如果可以触发则返回 true。
*/
isTriggerable(word) {
if (this.config.cacheAllInfo) return this.cache.some((t) => t.key === word || t.keywords.includes(word));
return this.keys.includes(word);
}
/**
* 根据关键词查找对应的快捷指令。
* 仅在 `cacheAllInfo` 模式下有效。
* @param word - 要查找的快捷指令关键词。
* @param session - 当前 Koishi 会话,用于检查黑名单。
* @returns 如果找到,则返回包含模板信息和快捷指令参数的对象,否则返回 null。
*/
findShortcut(word, session) {
if (!this.config.cacheAllInfo) return null;
const exclusionSet = this.getExclusionSet(session?.guildId);
for (const meme of this.cache) {
if (exclusionSet.has(meme.key)) continue;
if (!meme.shortcuts) continue;
for (const sc of meme.shortcuts) {
const shortcutKey = sc.pattern || sc.key;
let match = null;
try {
const jsPattern = shortcutKey.replace(/\(\?P</g, "(?<");
match = word.match(new RegExp(`^${jsPattern}$`));
} catch {
}
if (match) {
const shortcutArgs = [];
const options = sc.options;
if (options && typeof options === "object") {
for (const [key, value] of Object.entries(options)) {
if (typeof value === "boolean" && value === true) {
shortcutArgs.push(`--${key}`);
} else {
const formattedValue = String(value).includes(" ") ? `"${value}"` : value;
shortcutArgs.push(`--${key}=${formattedValue}`);
}
}
}
const args = sc.args;
if (Array.isArray(args)) {
for (const arg of args) {
if (typeof arg === "string") {
shortcutArgs.push(arg.replace(/\{(\w+)\}/g, (s, key) => match.groups?.[key] ?? s));
} else {
shortcutArgs.push(arg);
}
}
}
return { meme, shortcutArgs };
}
}
}
return null;
}
/**
* 根据 key 或关键词获取单个模板信息。
* - 缓存模式下:从内存中直接查找。
* - 非缓存模式下:通过网络请求获取。
* @param keyOrKeyword - 模板的 key 或关键词。
* @param session - 当前 Koishi 会话,用于检查黑名单。
* @returns 返回找到的 MemeInfo 对象,如果找不到或被禁用则返回 null。
*/
async getInfo(keyOrKeyword, session) {
const exclusionSet = this.getExclusionSet(session?.guildId);
const findInCache = /* @__PURE__ */ __name(() => this.cache.find((t) => t.key === keyOrKeyword || t.keywords.includes(keyOrKeyword)) || null, "findInCache");
let item;
if (this.config.cacheAllInfo) {
item = findInCache();
} else {
let key = keyOrKeyword;
if (!this.keys.includes(key)) {
if (this.isRsApi) {
const results = await this.search(key, session);
key = results[0];
if (!key) return null;
} else {
const found = findInCache();
if (!found) return null;
key = found.key;
}
}
if (exclusionSet.has(key)) return null;
try {
if (this.isRsApi) {
const endpoint = `${this.url}/memes/${key}/info`;
if (this.config.debug) this.logger.info(`[REQUEST] GET ${endpoint}`);
const info = await this.ctx.http.get(endpoint);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(info)}`);
item = {
key: info.key,
keywords: info.keywords || [],
minImages: info.params.min_images,
maxImages: info.params.max_images,
minTexts: info.params.min_texts,
maxTexts: info.params.max_texts,
defaultTexts: info.params.default_texts || [],
args: (info.params.options || []).map((opt) => ({ ...opt })),
tags: info.tags || [],
shortcuts: info.shortcuts || [],
date_created: info.date_created,
date_modified: info.date_modified
};
} else {
const endpoint = `${this.url}/memes/${key}/info`;
if (this.config.debug) this.logger.info(`[REQUEST] GET ${endpoint}`);
const data = await this.ctx.http.get(endpoint);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(data)}`);
item = this.parseNonRsInfo(data);
}
} catch (e) {
this.logger.warn(`获取模板 "${key}" 信息失败:`, e);
return null;
}
}
if (!item || exclusionSet.has(item.key)) return null;
return item;
}
/**
* 根据查询字符串搜索模板。
* - 缓存模式下:在本地进行带权重的模糊搜索。
* - 非缓存模式下:依赖服务端的搜索接口或进行简单的 key 匹配。
* @param query - 搜索关键词。
* @param session - 当前 Koishi 会话,用于过滤黑名单。
* @returns 返回匹配的 key 数组或 MemeInfo 数组。
*/
async search(query, session) {
const exclusionSet = this.getExclusionSet(session?.guildId);
if (this.config.cacheAllInfo) {
const results2 = this.cache.map((item) => {
let priority = 0;
if (item.key === query || item.keywords.includes(query)) priority = 5;
else if (item.keywords.some((k) => k.includes(query))) priority = 4;
else if (item.key.includes(query)) priority = 3;
else if (item.tags?.some((t) => t.includes(query))) priority = 2;
return { item, priority };
}).filter((p) => p.priority > 0).sort((a, b) => b.priority - a.priority).map((p) => p.item);
return results2.filter((item) => !exclusionSet.has(item.key));
}
let results;
if (this.isRsApi) {
const endpoint = `${this.url}/meme/search`;
const params = { query, include_tags: true };
if (this.config.debug) this.logger.info(`[REQUEST] GET ${endpoint} with params: ${JSON.stringify(params)}`);
results = await this.ctx.http.get(endpoint, { params });
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(results)}`);
} else {
results = this.keys.filter((key) => key.includes(query));
}
return results.filter((key) => !exclusionSet.has(key));
}
/**
* 随机获取一个模板信息。
* @param session - 当前 Koishi 会话。
* @param inputImages - 输入的有效图片数量(可选)。
* @param inputTexts - 输入的有效文本数量(可选)。
* @returns 返回找到的 MemeInfo 对象,如果找不到则返回 null。
*/
async getRandom(session, imgCnt = 0, txtCnt = 0) {
const banned = this.getExclusionSet(session?.guildId);
if (this.config.cacheAllInfo) {
const { useUserAvatar, fillDefaultText } = this.config;
const candidates = this.cache.filter((m) => {
if (banned.has(m.key)) return false;
const imgOk = imgCnt >= m.minImages || useUserAvatar && m.minImages - imgCnt === 1;
if (!imgOk) return false;
const canFill = fillDefaultText !== "disable" && m.defaultTexts.length > 0;
const txtOk = txtCnt >= m.minTexts || canFill && (fillDefaultText === "insufficient" || txtCnt === 0);
return txtOk;
});
return candidates.length ? candidates[Math.floor(Math.random() * candidates.length)] : null;
}
const keys = this.keys.filter((k) => !banned.has(k));
return keys.length ? this.getInfo(keys[Math.floor(Math.random() * keys.length)], session) : null;
}
/**
* 获取指定模板的预览图。
* @param key - 模板的 key。
* @returns 返回包含图片 Buffer 的 Promise,或在失败时返回错误信息的字符串。
*/
async getPreview(key) {
try {
let previewUrl = `${this.url}/memes/${key}/preview`;
if (this.isRsApi) {
const endpoint = `${this.url}/memes/${key}/preview`;
if (this.config.debug) this.logger.info(`[REQUEST] GET ${endpoint}`);
const { image_id } = await this.ctx.http.get(endpoint);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify({ image_id })}`);
previewUrl = `${this.url}/image/${image_id}`;
}
if (this.config.debug) this.logger.info(`[REQUEST] GET ${previewUrl}`);
const buffer = Buffer.from(await this.ctx.http.get(previewUrl, { responseType: "arraybuffer" }));
if (this.config.debug) this.logger.info(`[RESPONSE] Buffer size: ${buffer.length} bytes`);
return buffer;
} catch (e) {
this.logger.warn(`预览图 "${key}" 获取失败:`, e);
return `[预览图获取失败: ${e.message}]`;
}
}
/**
* 根据输入创建表情图片。
* @param key - 模板的 key。
* @param input - Koishi 的 h 元素数组,包含图片和文本。
* @param session - 当前 Koishi 会话对象。
* @returns 返回一个包含生成图片的 h 元素,或在失败时返回错误信息的字符串。
* @throws {Error} 当参数不足时,抛出名为 'MissError' 的错误。
*/
async create(key, input, session) {
const item = await this.getInfo(key, session);
if (!item) return `模板 "${key}" 不存在`;
const imgs = [];
let texts = [];
const args = {};
for (const el of input) {
if (el.type === "img" && el.attrs.src) {
imgs.push({ url: el.attrs.src });
} else if (el.type === "at" && el.attrs.id) {
const user = await session.bot.getUser?.(el.attrs.id).catch(() => null);
const name2 = user?.nick || user?.name || user?.username;
const avatarUrl = user?.avatar || await getAvatar(session, el.attrs.id);
if (avatarUrl) imgs.push({ url: avatarUrl, name: name2 });
} else if (el.type === "text" && el.attrs.content) {
el.attrs.content.trim().split(/\s+/).forEach((token) => {
if (!token) return;
const nameMatch = token.match(/^==(.+)$/);
if (nameMatch && imgs.length > 0) {
const lastImg = imgs[imgs.length - 1];
if (!lastImg.name) {
lastImg.name = nameMatch[1];
return;
}
}
const match = token.match(/^--([^=]+)(?:=(.*))?$/);
if (match) {
const k = match[1];
const v = match[2];
args[k] = v !== void 0 ? v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v : true;
} else {
texts.push(token);
}
});
}
}
if (this.config.useUserAvatar && item.minImages - imgs.length === 1) {
const selfAvatar = await getAvatar(session);
if (selfAvatar) imgs.unshift({ url: selfAvatar, name: session.username });
}
if (this.config.fillDefaultText !== "disable" && item.defaultTexts?.length > 0) {
if (this.config.fillDefaultText === "missing" && texts.length === 0) {
texts = [...item.defaultTexts];
} else if (this.config.fillDefaultText === "insufficient" && texts.length < item.minTexts) {
const needed = item.minTexts - texts.length;
texts.push(...item.defaultTexts.slice(texts.length, texts.length + needed));
}
}
if (this.config.ignoreExcess) {
if (imgs.length > item.maxImages) imgs.splice(item.maxImages);
if (texts.length > item.maxTexts) texts.splice(item.maxTexts);
} else {
if (imgs.length > item.maxImages) return `当前共有 ${imgs.length}/${item.maxImages} 张图片`;
if (texts.length > item.maxTexts) return `当前共有 ${texts.length}/${item.maxTexts} 条文本`;
}
if (imgs.length < item.minImages) {
const err = new Error(`当前共有 ${imgs.length}/${item.minImages} 张图片`);
err.name = "MissError";
throw err;
}
if (texts.length < item.minTexts) {
const err = new Error(`当前共有 ${texts.length}/${item.minTexts} 条文本`);
err.name = "MissError";
throw err;
}
try {
if (this.isRsApi) {
const imgUrls = imgs.map((img) => img.url);
const imgBuffers = await Promise.all(imgUrls.map((url) => this.ctx.http.get(url, { responseType: "arraybuffer" })));
const imgIds = await Promise.all(imgBuffers.map((buf) => this.upload(Buffer.from(buf))));
const payload = { images: imgIds.map((id, index) => ({ name: imgs[index].name || session.username || session.userId, id })), texts, options: args };
const endpoint = `${this.url}/memes/${key}`;
if (this.config.debug) this.logger.info(`[REQUEST] POST ${endpoint} with payload: ${JSON.stringify(payload)}`);
const res = await this.ctx.http.post(endpoint, payload, { timeout: 3e4 });
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(res)}`);
const imageEndpoint = `${this.url}/image/${res.image_id}`;
if (this.config.debug) this.logger.info(`[REQUEST] GET ${imageEndpoint}`);
const finalImg = await this.ctx.http.get(imageEndpoint, { responseType: "arraybuffer" });
if (this.config.debug) this.logger.info(`[RESPONSE] Buffer size: ${finalImg.byteLength} bytes`);
return import_koishi.h.image(Buffer.from(finalImg), "image/gif");
} else {
const form = new FormData();
texts.forEach((t) => form.append("texts", t));
const imageBuffers = await Promise.all(imgs.map((img) => this.ctx.http.get(img.url, { responseType: "arraybuffer" })));
imageBuffers.forEach((buffer) => {
form.append("images", new Blob([buffer]));
});
if (Object.keys(args).length) form.append("args", JSON.stringify(args));
const endpoint = `${this.url}/memes/${key}/`;
if (this.config.debug) {
const formEntries = { texts, images_count: imgs.length, args };
this.logger.info(`[REQUEST] POST ${endpoint} with FormData: ${JSON.stringify(formEntries)}`);
}
const result = await this.ctx.http.post(endpoint, form, { responseType: "arraybuffer", timeout: 3e4 });
if (this.config.debug) this.logger.info(`[RESPONSE] Buffer size: ${result.byteLength} bytes`);
return import_koishi.h.image(Buffer.from(result), "image/gif");
}
} catch (e) {
this.logger.warn(`图片生成失败 (${item.key}):`, e);
let data = e.response?.data;
try {
if (typeof data === "string") data = JSON.parse(data);
} catch {
}
return `图片生成失败: ${data?.detail || e.message}`;
}
}
/**
* 调用后端 API 渲染模板列表图片。
* @param session - 当前 Koishi 会话,用于过滤黑名单。
* @returns 返回包含图片的 Buffer 或错误信息字符串。
*/
async renderList(session) {
try {
if (this.isRsApi) {
const payload = {};
const meme_properties = {};
if (this.config.sortListBy) {
const [sortBy, sortDir] = this.config.sortListBy.split(/_(asc|desc)$/);
payload.sort_by = sortBy;
payload.sort_reverse = sortDir === "desc";
}
if (this.config.listTextTemplate) payload.text_template = this.config.listTextTemplate;
if (this.config.showListIcon !== void 0 && this.config.showListIcon !== null) payload.add_category_icon = this.config.showListIcon;
if (this.config.cacheAllInfo && this.config.markAsNewDays > 0) {
const now = /* @__PURE__ */ new Date();
const threshold = now.setDate(now.getDate() - this.config.markAsNewDays);
for (const meme of this.cache) {
const memeDate = Date.parse(meme.date_modified || meme.date_created);
if (memeDate > threshold) meme_properties[meme.key] = { ...meme_properties[meme.key], new: true };
}
}
const exclusionSet = this.getExclusionSet(session?.guildId);
for (const key of exclusionSet) meme_properties[key] = { ...meme_properties[key], disabled: true };
if (Object.keys(meme_properties).length > 0) payload.meme_properties = meme_properties;
const endpoint = `${this.url}/tools/render_list`;
if (this.config.debug) this.logger.info(`[REQUEST] POST ${endpoint} with payload: ${JSON.stringify(payload)}`);
const res = await this.ctx.http.post(endpoint, payload);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(res)}`);
const imageEndpoint = `${this.url}/image/${res.image_id}`;
if (this.config.debug) this.logger.info(`[REQUEST] GET ${imageEndpoint}`);
const buf = await this.ctx.http.get(imageEndpoint, { responseType: "arraybuffer" });
if (this.config.debug) this.logger.info(`[RESPONSE] Buffer size: ${buf.byteLength} bytes`);
return Buffer.from(buf);
} else {
const exclusionSet = this.getExclusionSet(session?.guildId);
const available = this.keys.filter((key) => key && !exclusionSet.has(key));
const payload = { meme_list: available.map((key) => ({ meme_key: key })) };
if (this.config.listTextTemplate) payload.text_template = this.config.listTextTemplate;
if (this.config.showListIcon !== void 0 && this.config.showListIcon !== null) payload.add_category_icon = this.config.showListIcon;
const endpoint = `${this.url}/memes/render_list`;
if (this.config.debug) {
const loggedPayload = { ...payload, meme_list_count: payload.meme_list.length };
delete loggedPayload.meme_list;
this.logger.info(`[REQUEST] POST ${endpoint} with payload: ${JSON.stringify(loggedPayload)}`);
}
const buf = await this.ctx.http.post(endpoint, payload, { responseType: "arraybuffer" });
if (this.config.debug) this.logger.info(`[RESPONSE] Buffer size: ${buf.byteLength} bytes`);
return Buffer.from(buf);
}
} catch (e) {
this.logger.warn("列表渲染失败:", e);
return `列表渲染失败: ${e.message}`;
}
}
/**
* 调用后端 API 渲染统计图片。
* @param title - 统计图标题。
* @param type - 统计类型 ('meme_count' 或 'time_count')。
* @param data - 统计数据,格式为 `[key, value]` 对的数组。
* @returns 返回包含图片的 Buffer 或错误信息字符串。
*/
async renderStatistics(title, type, data) {
try {
const payload = { title, statistics_type: type, data };
const endpoint = `${this.url}/tools/render_statistics`;
if (this.config.debug) this.logger.info(`[REQUEST] POST ${endpoint} with payload: ${JSON.stringify(payload)}`);
const res = await this.ctx.http.post(endpoint, payload);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(res)}`);
const imageEndpoint = `${this.url}/image/${res.image_id}`;
if (this.config.debug) this.logger.info(`[REQUEST] GET ${imageEndpoint}`);
const buf = await this.ctx.http.get(imageEndpoint, { responseType: "arraybuffer" });
if (this.config.debug) this.logger.info(`[RESPONSE] Buffer size: ${buf.byteLength} bytes`);
return Buffer.from(buf);
} catch (e) {
this.logger.warn("统计图渲染失败:", e);
return `统计图渲染失败: ${e.message}`;
}
}
/**
* 调用后端 API 对单张图片进行处理。
* @param endpoint - API 的端点名称。
* @param imageUrl - 要处理的图片的 URL。
* @param payload - (可选) 附加的请求体数据。
* @returns 返回处理结果,可能是包含图片的 h 元素或文本信息。
*/
async processImage(endpoint, imageUrl, payload = {}) {
try {
const buf = await this.ctx.http.get(imageUrl, { responseType: "arraybuffer" });
const image_id = await this.upload(Buffer.from(buf));
const finalPayload = { ...payload, image_id };
const apiEndpoint = `${this.url}/tools/image_operations/${endpoint}`;
if (this.config.debug) this.logger.info(`[REQUEST] POST ${apiEndpoint} with payload: ${JSON.stringify(finalPayload)}`);
if (endpoint === "inspect") {
const info = await this.ctx.http.post(apiEndpoint, finalPayload);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(info)}`);
let result = `图片信息:
- 尺寸: ${info.width}x${info.height}`;
result += info.is_multi_frame ? `
- 类型: GIF (${info.frame_count} 帧)` : "\n- 类型: 图片";
return result;
}
if (endpoint === "gif_split") {
const res2 = await this.ctx.http.post(apiEndpoint, finalPayload);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(res2)}`);
if (!res2.image_ids?.length) return "GIF 分解失败";
const mergedBuffer = await this.performMerge(res2.image_ids, "merge_horizontal", {});
return (0, import_koishi.h)("message", `GIF (${res2.image_ids.length}帧)分解成功`, import_koishi.h.image(mergedBuffer, "image/png"));
}
const res = await this.ctx.http.post(apiEndpoint, finalPayload);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(res)}`);
const imageEndpoint = `${this.url}/image/${res.image_id}`;
if (this.config.debug) this.logger.info(`[REQUEST] GET ${imageEndpoint}`);
const finalBuf = await this.ctx.http.get(imageEndpoint, { responseType: "arraybuffer" });
if (this.config.debug) this.logger.info(`[RESPONSE] Buffer size: ${finalBuf.byteLength} bytes`);
return import_koishi.h.image(Buffer.from(finalBuf), "image/png");
} catch (e) {
this.logger.warn(`图片 "${endpoint}" 处理失败:`, e);
return `图片处理失败: ${e.message}`;
}
}
/**
* 调用后端 API 对多张图片进行处理。
* @param endpoint - API 的端点名称。
* @param sources - 包含多个图片 URL 的数组。
* @param payload - (可选) 附加的请求体数据。
* @returns 返回处理后的图片 h 元素,或错误信息。
*/
async processImages(endpoint, sources, payload = {}) {
try {
const image_ids = await Promise.all(sources.map((url) => this.ctx.http.get(url, { responseType: "arraybuffer" }).then((buf) => this.upload(Buffer.from(buf)))));
const finalBuf = await this.performMerge(image_ids, endpoint, payload);
return import_koishi.h.image(finalBuf, "image/png");
} catch (e) {
this.logger.warn(`图片 "${endpoint}" 处理失败:`, e);
return `图片处理失败: ${e.message}`;
}
}
/**
* 封装图片合并端点的请求逻辑。
* @param image_ids - 已上传的图片 ID 列表。
* @param endpoint - API 的端点名称。
* @param payload - 附加的请求体数据。
* @returns 返回包含合并后图片的 Buffer。
*/
async performMerge(image_ids, endpoint, payload) {
const finalPayload = { ...payload, image_ids };
const apiEndpoint = `${this.url}/tools/image_operations/${endpoint}`;
if (this.config.debug) this.logger.info(`[REQUEST] POST ${apiEndpoint} with payload: ${JSON.stringify(finalPayload)}`);
const res = await this.ctx.http.post(apiEndpoint, finalPayload);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify(res)}`);
const imageEndpoint = `${this.url}/image/${res.image_id}`;
if (this.config.debug) this.logger.info(`[REQUEST] GET ${imageEndpoint}`);
const finalBuf = await this.ctx.http.get(imageEndpoint, { responseType: "arraybuffer" });
if (this.config.debug) this.logger.info(`[RESPONSE] Buffer size: ${finalBuf.byteLength} bytes`);
return Buffer.from(finalBuf);
}
/**
* 将图片 Buffer 上传到后端 API。
* @param buf - 包含图片数据的 Buffer。
* @returns 返回上传后得到的 image_id。
*/
async upload(buf) {
const payload = { type: "data", data: buf.toString("base64") };
const endpoint = `${this.url}/image/upload`;
if (this.config.debug) this.logger.info(`[REQUEST] POST ${endpoint} with payload: { type: 'data', data: 'base64_string(${buf.length} bytes)' }`);
const { image_id } = await this.ctx.http.post(endpoint, payload);
if (this.config.debug) this.logger.info(`[RESPONSE] Data: ${JSON.stringify({ image_id })}`);
return image_id;
}
};
// src/index.ts
var name = "memes";
var inject = ["http"];
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_koishi2.Schema.intersect([
import_koishi2.Schema.object({
apiUrl: import_koishi2.Schema.string().description("后端 API 地址").default("http://127.0.0.1:2233"),
cacheAllInfo: import_koishi2.Schema.boolean().description("缓存详细信息").default(true),
debug: import_koishi2.Schema.boolean().description("显示调试信息").default(false)
}).description("基础配置"),
import_koishi2.Schema.object({
useUserAvatar: import_koishi2.Schema.boolean().description("自动补充用户头像").default(true),
fillDefaultText: import_koishi2.Schema.union([
import_koishi2.Schema.const("disable").description("关闭"),
import_koishi2.Schema.const("insufficient").description("自动"),
import_koishi2.Schema.const("missing").description("仅无文本")
]).description("自动补充默认文本").default("missing"),
ignoreExcess: import_koishi2.Schema.boolean().description("自动忽略多余参数").default(true)
}).description("参数配置"),
import_koishi2.Schema.object({
triggerMode: import_koishi2.Schema.union([
import_koishi2.Schema.const("disable").description("关闭"),
import_koishi2.Schema.const("noprefix").description("无前缀"),
import_koishi2.Schema.const("prefix").description("有前缀")
]).description("关键词触发方式").default("disable"),
sendRandomInfo: import_koishi2.Schema.boolean().description("随机表情显示模板名").default(true),
blacklist: import_koishi2.Schema.array(import_koishi2.Schema.object({
guildId: import_koishi2.Schema.string().description("群号"),
keyId: import_koishi2.Schema.string().description("模板名")
})).description("表情禁用规则").role("table")
}).description("其它配置"),
import_koishi2.Schema.object({
sortListBy: import_koishi2.Schema.union([
import_koishi2.Schema.const("key_asc").description("表情名 (升)"),
import_koishi2.Schema.const("key_desc").description("表情名 (降)"),
import_koishi2.Schema.const("keywords_asc").description("关键词 (升)"),
import_koishi2.Schema.const("keywords_desc").description("关键词 (降)"),
import_koishi2.Schema.const("keywords_pinyin_asc").description("关键词拼音 (升)"),
import_koishi2.Schema.const("keywords_pinyin_desc").description("关键词拼音 (降)"),
import_koishi2.Schema.const("date_created_asc").description("创建时间 (升)"),
import_koishi2.Schema.const("date_created_desc").description("创建时间 (降)"),
import_koishi2.Schema.const("date_modified_asc").description("修改时间 (升)"),
import_koishi2.Schema.const("date_modified_desc").description("修改时间 (降)")
]).description("列表排序方式"),
listTextTemplate: import_koishi2.Schema.string().description("列表文字模板"),
showListIcon: import_koishi2.Schema.boolean().description("添加分类图标"),
markAsNewDays: import_koishi2.Schema.number().description('"新"标记天数')
}).description("菜单配置")
]);
async function apply(ctx, config) {
const url = config.apiUrl.trim().replace(/\/+$/, "");
const provider = new MemeProvider(ctx, url, config);
try {
const { version, count } = await provider.start();
ctx.logger.info(`MemeGenerator v${version} 已加载(模板数: ${count})`);
} catch (error) {
ctx.logger.error(`MemeGenerator 未加载: ${error}`);
return;
}
const cmd = ctx.command("memes", "表情生成").usage("通过 MemeGenerator API 生成表情");
cmd.subcommand(".list", "模板列表").usage("显示所有可用的表情模板列表").action(async ({ session }) => {
const result = await provider.renderList(session);
if (typeof result === "string") return result;
return import_koishi2.h.image(result, "image/png");
});
cmd.subcommand(".make <keyOrKeyword:string> [params:elements]", "表情生成").usage("根据模板名称或关键词制作表情").action(async ({ session }, keyOrKeyword, input) => {
if (!keyOrKeyword) return "请输入关键词";
let targetKey;
let initialInput = input ?? [];
const shortcut = provider.findShortcut(keyOrKeyword, session);
if (shortcut) {
targetKey = shortcut.meme.key;
const argsString = shortcut.shortcutArgs.join(" ");
const shortcutElements = import_koishi2.h.parse(argsString);
initialInput = [...shortcutElements, ...initialInput];
} else {
const item = await provider.getInfo(keyOrKeyword, session);
if (!item) return `模板 "${keyOrKeyword}" 不存在`;
targetKey = item.key;
}
try {
return await provider.create(targetKey, initialInput, session);
} catch (e) {
if (e?.name === "MissError") {
await session.send(`${e.message},请发送内容补充参数`);
const response = await session.prompt(6e4);
if (!response) return "已取消生成";
const combinedInput = [...initialInput, ...import_koishi2.h.parse(response)];
return provider.create(targetKey, combinedInput, session);
}
return `生成失败: ${e.message}`;
}
});
cmd.subcommand(".random [params:elements]", "随机表情").usage("随机选择一个模板并制作表情").action(async ({ session }, input = []) => {
let imgCnt = 0, txtCnt = 0;
for (const el of input) {
if (el.type === "img" || el.type === "at" && el.attrs.id) imgCnt++;
else if (el.type === "text") txtCnt += el.attrs.content.split(/\s+/).filter((t) => t && !/^(?:--|==)/.test(t)).length;
}
for (let i = 0; i < 3; i++) {
const item = await provider.getRandom(session, imgCnt, txtCnt);
if (!item) return "无可用模板";
try {
const res = await provider.create(item.key, input, session);
if (config.sendRandomInfo) await session.send(`模板名: ${item.keywords.join("/") || item.key} (${item.key})`);
return res;
} catch (e) {
ctx.logger.warn("表情随机失败:", e);
if (i == 2) return `表情随机失败: ${e.message}`;
}
}
});
cmd.subcommand(".info <keyOrKeyword:string>", "模板详情").usage("查询指定表情模板的详细信息").action(async ({ session }, keyOrKeyword) => {
if (!keyOrKeyword) return "请输入关键词";
const item = await provider.getInfo(keyOrKeyword, session);
if (!item) return `模板 "${keyOrKeyword}" 不存在`;
const output = [];
output.push(`${item.keywords.join("/") || item.key} (${item.key})`);
if (item.tags?.length) output.push(`标签: ${item.tags.join(", ")}`);
const inputParts = [];
if (item.maxTexts > 0) {
const textCount = item.minTexts === item.maxTexts ? item.minTexts : `${item.minTexts}-${item.maxTexts}`;
inputParts.push(`${textCount} 文本`);
}
if (item.maxImages > 0) {
const imageCount = item.minImages === item.maxImages ? item.minImages : `${item.minImages}-${item.maxImages}`;
inputParts.push(`${imageCount} 图片`);
}
if (inputParts.length > 0) {
let params_line = `参数: ${inputParts.join(",")}`;
if (item.defaultTexts?.length) params_line += ` [${item.defaultTexts.join(", ")}]`;
output.push(params_line);
}
if (item.args?.length) {
output.push("选项:");
for (const arg of item.args) {
let line = ` - ${arg.name} (${arg.type || "any"})`;
const details = [];
if (arg.default !== void 0 && arg.default !== null && arg.default !== "") details.push(`[${JSON.stringify(arg.default).replace(/"/g, "")}]`);
if (arg.choices?.length) details.push(`[${arg.choices.join(",")}]`);
let details_str = details.join(" ");
if (details_str) line += ` ${details_str}`;
if (arg.description) line += ` | ${arg.description}`;
output.push(line);
}
}
if (item.shortcuts?.length) {
output.push("快捷指令:");
const shortcuts_list = [];
for (const sc of item.shortcuts) {
const key = sc.humanized || sc.pattern || sc.key;
let shortcutInfo = key;
const options = sc.options;
if (options && Object.keys(options).length > 0) {
const opts = Object.entries(options).map(([k, v]) => `${k}=${v}`).join(",");
shortcutInfo += `(${opts})`;
} else {
const args = sc.args;
if (args && args.length > 0) shortcutInfo += `(${args.join(" ")})`;
}
shortcuts_list.push(shortcutInfo);
}
for (let i = 0; i < shortcuts_list.length; i += 2) {
const line = ` ${shortcuts_list[i]}${shortcuts_list[i + 1] ? " | " + shortcuts_list[i + 1] : ""}`;
output.push(line);
}
}
const textInfo = output.join("\n");
const preview = await provider.getPreview(item.key);
return (0, import_koishi2.h)("message", preview instanceof Buffer ? import_koishi2.h.image(preview, "image/gif") : "", textInfo);
});
cmd.subcommand(".search <query:string>", "搜索模板").usage("根据关键词搜索相关的表情模板").action(async ({ session }, query) => {
if (!query) return "请输入搜索关键词";
const results = await provider.search(query, session);
if (!results.length) return `"${query}" 无相关模板`;
let text;
if (results.every((r) => typeof r === "string")) {
text = results.map((k) => ` - ${k}`).join("\n");
} else {
text = results.map((t) => ` - [${t.key}] ${t.keywords.join(", ")}`).join("\n");
}
return `"${query}" 搜索结果(共 ${results.length} 条):
${text}`;
});
if (provider.isRsApi) {
cmd.subcommand(".stat <title:string> <type:string> <data:string>", "数据统计").usage("类型为meme_count/time_count\n数据为key1:value1,key2:value2...").action(async ({}, title, type, data) => {
if (!title || !type || !data) return "输入参数不足";
if (type !== "meme_count" && type !== "time_count") return "统计类型错误";
const parsedData = [];
for (const pair of data.split(",")) {
const [key, value] = pair.split(":");
if (!key || !value || isNaN(parseInt(value))) return `数据格式错误: "${pair}"`;
parsedData.push([key.trim(), parseInt(value.trim())]);
}
const result = await provider.renderStatistics(title, type, parsedData);
if (typeof result === "string") return result;
return import_koishi2.h.image(result, "image/png");
});
cmd.subcommand(".img <image:img>", "图片处理").usage("对单张图片进行处理").option("hflip", "-hf, --hflip 水平翻转").option("vflip", "-vf, --vflip 垂直翻转").option("grayscale", "-g, --grayscale 灰度化").option("invert", "-i, --invert 反色").option("rotate", "-r, --rotate <degrees:number> 旋转图片").option("resize", "-s, --resize <size:string> 调整尺寸 (宽|高)").option("crop", "-c, --crop <box:string> 裁剪图片 (左|上|右|下)").action(async ({ options }, image) => {
if (!image?.attrs?.src) return "请提供图片";
const { src } = image.attrs;
const activeOps = Object.keys(options).filter((key) => key !== "session");
if (activeOps.length > 1) return "请仅指定一种操作";
if (options.hflip) return provider.processImage("flip_horizontal", src);
if (options.vflip) return provider.processImage("flip_vertical", src);
if (options.grayscale) return provider.processImage("grayscale", src);
if (options.invert) return provider.processImage("invert", src);
if (options.rotate !== void 0) return provider.processImage("rotate", src, { degrees: options.rotate });
if (options.resize) {
const [width, height] = options.resize.split("|").map((s) => s.trim() ? Number(s) : void 0);
return provider.processImage("resize", src, { width, height });
}
if (options.crop) {
const [left, top, right, bottom] = options.crop.split("|").map((s) => s.trim() ? Number(s) : void 0);
return provider.processImage("crop", src, { left, top, right, bottom });
}
return provider.processImage("inspect", src);
});
cmd.subcommand(".gif <image:img>", "GIF 处理").usage("对单张 GIF 进行处理").option("split", "-s, --split 分解 GIF").option("reverse", "-r, --reverse 倒放 GIF").option("duration", "-d, --duration <duration:number> 调整帧间隔", { fallback: 0.1 }).action(async ({ options }, image) => {
if (!image?.attrs?.src) return "请提供图片";
const { src } = image.attrs;
if (options.split) return provider.processImage("gif_split", src);
if (options.reverse) return provider.processImage("gif_reverse", src);
if (options.duration !== void 0) return provider.processImage("gif_change_duration", src, { duration: options.duration });
return "请指定操作";
});
cmd.subcommand(".merge <images:elements>", "图片合并").usage("合并多张图片为一张图片或 GIF").option("horizontal", "-hz, --horizontal 水平合并").option("vertical", "-vt, --vertical 垂直合并").option("gif", "-g, --gif [duration:number] 合并为 GIF", { fallback: 0.1 }).action(({ options }, images) => {
const imgSrcs = images?.filter((el) => el?.type === "img" && el?.attrs?.src).map((el) => el.attrs.src);
if (!imgSrcs || imgSrcs.length < 2) return "请提供多张图片";
const activeOps = Object.keys(options).filter((key) => key !== "session");
if (activeOps.length > 1) return "请仅指定一种操作";
if (options.horizontal) return provider.processImages("merge_horizontal", imgSrcs);
if (options.vertical) return provider.processImages("merge_vertical", imgSrcs);
if ("gif" in options) {
const duration = typeof options.gif === "number" ? options.gif : 0.1;
return provider.processImages("gif_merge", imgSrcs, { duration });
}
return "请指定操作";
});
}
if (config.triggerMode !== "disable") {
const prefixes = Array.isArray(ctx.root.config.prefix) ? ctx.root.config.prefix : [ctx.root.config.prefix].filter(Boolean);
ctx.middleware(async (session, next) => {
let content = session.stripped.content.trim();
if (!content) return next();
if (config.triggerMode === "prefix") {
const prefix = prefixes.find((p) => content.startsWith(p));
if (!prefix) return next();
content = content.slice(prefix.length).trim();
}
const [word, ...args] = content.split(/\s+/);
const item = await provider.getInfo(word, session);
if (item) return session.execute(`memes.make ${content}`);
const shortcut = provider.findShortcut(word, session);
if (shortcut) {
const shortcutArgsString = shortcut.shortcutArgs.join(" ");
const userArgsString = args.join(" ");
return session.execute(`memes.make ${shortcut.meme.key} ${shortcutArgsString} ${userArgsString}`);
}
return next();
}, true);
}
}
__name(apply, "apply");
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Config,
apply,
inject,
name,
usage
});