UNPKG

koishi-plugin-memes

Version:

MemeGenerator(RS)表情生成,全功能支持,可自由配置黑名单。

957 lines (953 loc) 47.7 kB
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 });