From 1d23374198f579a161f8fd5b5d74aea677b0a5f3 Mon Sep 17 00:00:00 2001 From: zen1zi Date: Wed, 24 Sep 2025 11:30:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Ebanana=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E5=9B=9E=E5=A4=8D=E5=9B=BE=E7=89=87=E9=80=9A?= =?UTF-8?q?=E8=BF=87Gemini=20Nano-Banana=E5=9C=A8=E4=BF=9D=E8=AF=81?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7=E7=9A=84=E6=83=85=E5=86=B5=E4=B8=8B?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E5=9B=BE+=E6=96=87=E7=94=9F=E5=9B=BE=20Updat?= =?UTF-8?q?e=20plugins.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- banana/banana.ts | 497 +++++++++++++++++++++++++++++++++++++++++++++++ plugins.json | 4 + 2 files changed, 501 insertions(+) create mode 100644 banana/banana.ts diff --git a/banana/banana.ts b/banana/banana.ts new file mode 100644 index 00000000..aacd85bf --- /dev/null +++ b/banana/banana.ts @@ -0,0 +1,497 @@ +import axios from "axios"; +import path from "path"; +import type { Low } from "lowdb"; +import { JSONFilePreset } from "lowdb/node"; +import { Plugin } from "@utils/pluginBase"; +import { Api } from "telegram"; +import { CustomFile } from "telegram/client/uploads.js"; +import { createDirectoryInAssets } from "@utils/pathHelpers"; + +interface BananaConfig { + apiKey: string; + maxBytes: number; +} + +const FIXED_MODEL = "gemini-2.5-flash-image-preview"; +const DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB +const MIN_ALLOWED_IMAGE_BYTES = 256 * 1024; // 256KB +const MAX_ALLOWED_IMAGE_BYTES = 25 * 1024 * 1024; // 25MB + +const CONFIG_DEFAULTS: BananaConfig = { + apiKey: "", + maxBytes: DEFAULT_MAX_IMAGE_BYTES, +}; + +const help_text = + "🎯 Nano-Banana 图像编辑插件\n" + + "• 回复图片并附带 .banana 提示词 调用 Gemini Nano-Banana 修改图像\n" + + "• .banana key <密钥> 配置 Gemini API Key\n" + + "• .banana limit <数值/MB> 调整图片大小上限(默认 10MB,可用 default 重置)\n" + + "• 使用 .help banana 随时查看此帮助"; + +const dataDir = createDirectoryInAssets("banana"); +const configPath = path.join(dataDir, "config.json"); +let dbPromise: Promise> | null = null; + +const htmlEscape = (input: string): string => + input.replace( + /[&<>"']/g, + (ch) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[ch] || ch, + ); + +const formatBytes = (bytes: number): string => { + if (bytes >= 1024 * 1024) { + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(mb >= 10 ? 0 : 1).replace(/\.0$/, "")}MB`; + } + if (bytes >= 1024) { + const kb = bytes / 1024; + return `${kb.toFixed(kb >= 10 ? 0 : 1).replace(/\.0$/, "")}KB`; + } + return `${bytes}B`; +}; + +function estimateMediaSizeBytes(message: Api.Message): number { + const doc = (message as any).document; + if (doc && typeof doc.size === "number") { + return Number(doc.size); + } + + const video = (message as any).video; + if (video && typeof video.size === "number") { + return Number(video.size); + } + + const gif = (message as any).gif; + if (gif && typeof gif.size === "number") { + return Number(gif.size); + } + + const photo = (message as any).photo; + if (photo && Array.isArray(photo.sizes)) { + let max = 0; + for (const size of photo.sizes) { + const progressive = (size as any).sizes; + if (Array.isArray(progressive) && progressive.length) { + const candidate = Math.max(...progressive); + if (candidate > max) max = candidate; + } + const directSize = (size as any).size; + if (typeof directSize === "number" && directSize > max) { + max = directSize; + } + } + return max; + } + + return 0; +} + +async function getDb(): Promise> { + if (!dbPromise) { + dbPromise = JSONFilePreset(configPath, { + ...CONFIG_DEFAULTS, + }); + } + const db = await dbPromise; + db.data ||= { ...CONFIG_DEFAULTS }; + db.data = { ...CONFIG_DEFAULTS, ...db.data }; + return db; +} + +async function getConfigValue( + key: TKey, +): Promise { + const db = await getDb(); + const value = db.data![key]; + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return CONFIG_DEFAULTS[key]; + } + } + return value ?? CONFIG_DEFAULTS[key]; +} + +async function setConfigValue( + key: TKey, + value: BananaConfig[TKey], +): Promise { + const db = await getDb(); + db.data![key] = value; + await db.write(); +} + +function parseSizeInput(raw: string): number | null { + const normalized = raw.trim().toLowerCase(); + if (!normalized) return null; + + const match = normalized.match(/^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/); + if (!match) return null; + + const value = parseFloat(match[1]); + if (!Number.isFinite(value) || value <= 0) return null; + + const unit = match[2] || "mb"; + const multiplier = + unit === "gb" + ? 1024 * 1024 * 1024 + : unit === "mb" + ? 1024 * 1024 + : unit === "kb" + ? 1024 + : 1; + + const bytes = Math.round(value * multiplier); + if (!Number.isFinite(bytes) || bytes <= 0) return null; + return bytes; +} + +function normalizeMaxBytes(bytes: number | string | null | undefined): number { + const numeric = typeof bytes === "number" ? bytes : Number(bytes); + if (!Number.isFinite(numeric) || numeric <= 0) { + return DEFAULT_MAX_IMAGE_BYTES; + } + const rounded = Math.round(numeric); + if (rounded < MIN_ALLOWED_IMAGE_BYTES) { + return MIN_ALLOWED_IMAGE_BYTES; + } + if (rounded > MAX_ALLOWED_IMAGE_BYTES) { + return MAX_ALLOWED_IMAGE_BYTES; + } + return rounded; +} + +async function resolveMaxImageBytes(): Promise { + const stored = await getConfigValue("maxBytes"); + return normalizeMaxBytes(stored as any); +} + +function maskKey(key: string): string { + if (!key) return "(未配置)"; + if (key.length <= 8) return `${key.slice(0, 2)}***${key.slice(-2)}`; + return `${key.slice(0, 4)}***${key.slice(-4)}`; +} + +function resolveMimeType(media: any): string { + const documentMime = media?.document?.mimeType; + if (typeof documentMime === "string" && documentMime.startsWith("image/")) { + return documentMime; + } + if (media?.photo) { + return "image/jpeg"; + } + return "image/png"; +} + +function extFromMime(mime: string): string { + if (mime.includes("jpeg")) return "jpg"; + if (mime.includes("png")) return "png"; + if (mime.includes("webp")) return "webp"; + if (mime.includes("gif")) return "gif"; + return "png"; +} + +async function handleConfig( + msg: Api.Message, + subcommand: string, + subValue: string, +): Promise { + switch (subcommand) { + case "key": { + if (!subValue) { + await msg.edit({ + text: "❌ 请提供 Gemini API Key,例如 `.banana key AIza...`", + }); + return; + } + await setConfigValue("apiKey", subValue.trim()); + await msg.edit({ text: "✅ 已更新 Gemini API Key" }); + return; + } + case "limit": { + if (!subValue) { + const current = await resolveMaxImageBytes(); + await msg.edit({ + text: `当前图片大小上限:${formatBytes(current)}(范围 ${formatBytes(MIN_ALLOWED_IMAGE_BYTES)} - ${formatBytes(MAX_ALLOWED_IMAGE_BYTES)})\n使用 .banana limit default 可恢复默认值`, + parseMode: "html", + }); + return; + } + + const lowered = subValue.trim().toLowerCase(); + if (lowered === "default" || lowered === "reset") { + await setConfigValue("maxBytes", DEFAULT_MAX_IMAGE_BYTES); + await msg.edit({ + text: `✅ 已恢复默认图片大小上限 ${formatBytes(DEFAULT_MAX_IMAGE_BYTES)}`, + }); + return; + } + + const parsed = parseSizeInput(subValue); + if (parsed === null) { + await msg.edit({ + text: "❌ 无法识别的大小,请使用如 `8`, `8MB`, `2048KB`、`0.5GB` 等格式。", + }); + return; + } + + if (parsed < MIN_ALLOWED_IMAGE_BYTES) { + await msg.edit({ + text: `❌ 数值过小,最小支持 ${formatBytes(MIN_ALLOWED_IMAGE_BYTES)}`, + }); + return; + } + + if (parsed > MAX_ALLOWED_IMAGE_BYTES) { + await msg.edit({ + text: `❌ 数值过大,最大支持 ${formatBytes(MAX_ALLOWED_IMAGE_BYTES)}`, + }); + return; + } + + await setConfigValue("maxBytes", parsed); + await msg.edit({ + text: `✅ 已将图片大小上限设置为 ${formatBytes(parsed)}`, + }); + return; + } + case "config": { + const [apiKey, maxBytes] = await Promise.all([ + getConfigValue("apiKey"), + resolveMaxImageBytes(), + ]); + await msg.edit({ + text: `🔧 当前配置\n• API Key: ${maskKey(apiKey)}\n• 图片大小上限: ${formatBytes(maxBytes)}`, + }); + return; + } + case "help": { + await msg.edit({ text: "ℹ️ 帮助内容已迁移至 `.help banana`" }); + return; + } + default: + await msg.edit({ text: "❓ 未知子命令,使用 `.help banana` 查看说明" }); + } +} + +async function handleImageEdit( + msg: Api.Message, + promptText: string, +): Promise { + const apiKey = (await getConfigValue("apiKey")).trim(); + if (!apiKey) { + await msg.edit({ + text: "❌ 未配置 Gemini API Key,请先执行 `.banana key <密钥>`", + }); + return; + } + + const prompt = promptText.trim(); + if (!prompt) { + await msg.edit({ + text: "❌ 请在命令后提供提示词,例如 `.banana 把猫换成骑士盔甲`", + }); + return; + } + + const replyMsg = await msg.getReplyMessage(); + if (!replyMsg || !replyMsg.media) { + await msg.edit({ text: "❌ 请回复一条包含图片的消息后再执行命令" }); + return; + } + + const client = (msg as any).client; + if (!client) { + await msg.edit({ text: "❌ 无法获取客户端实例" }); + return; + } + + const maxBytes = await resolveMaxImageBytes(); + const limitLabel = formatBytes(maxBytes); + const hintedSize = estimateMediaSizeBytes(replyMsg); + if (hintedSize > maxBytes) { + await msg.edit({ + text: `❌ 图片文件过大(${formatBytes(hintedSize)}),最大支持 ${limitLabel}`, + }); + return; + } + + await msg.edit({ text: "⬇️ 正在下载图片..." }); + + let mediaBuffer: Buffer | null = null; + try { + const mediaData = await client.downloadMedia(replyMsg.media, { + workers: 1, + }); + if (Buffer.isBuffer(mediaData)) { + mediaBuffer = mediaData; + } else if (mediaData && typeof (mediaData as any).read === "function") { + const chunks: Buffer[] = []; + for await (const chunk of mediaData as any) { + chunks.push(Buffer.from(chunk)); + } + mediaBuffer = Buffer.concat(chunks); + } + } catch (error) { + await msg.edit({ text: `❌ 图片下载失败: ${error}` }); + return; + } + + if (!mediaBuffer) { + await msg.edit({ text: "❌ 未能获取到图片数据" }); + return; + } + + if (mediaBuffer.length > maxBytes) { + await msg.edit({ + text: `❌ 图片文件过大(${formatBytes(mediaBuffer.length)}),最大支持 ${limitLabel}`, + }); + return; + } + + const mimeType = resolveMimeType(replyMsg.media); + const requestBody = { + contents: [ + { + role: "user", + parts: [ + { text: prompt }, + { + inlineData: { + mimeType, + data: mediaBuffer.toString("base64"), + }, + }, + ], + }, + ], + }; + + await msg.edit({ text: "🤖 正在调用 Gemini Nano-Banana 生成..." }); + + const url = `https://generativelanguage.googleapis.com/v1beta/models/${FIXED_MODEL}:generateContent?key=${encodeURIComponent(apiKey)}`; + + let responseData: any; + try { + const response = await axios.post(url, requestBody, { timeout: 120000 }); + responseData = response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const message = error.response?.data?.error?.message || error.message; + await msg.edit({ + text: `❌ Gemini 请求失败 (${status ?? "网络错误"}): ${message}`, + }); + } else { + await msg.edit({ text: `❌ 请求失败: ${(error as Error).message}` }); + } + return; + } + + const candidates: any[] = responseData?.candidates || []; + if (!candidates.length) { + await msg.edit({ text: "❌ 未收到模型返回结果" }); + return; + } + + const inlineParts: any[] = []; + const textParts: string[] = []; + + for (const candidate of candidates) { + const parts: any[] = candidate?.content?.parts || []; + for (const part of parts) { + if (part?.inlineData?.data) { + inlineParts.push(part.inlineData); + } + if (typeof part?.text === "string" && part.text.trim()) { + textParts.push(part.text.trim()); + } + } + } + + if (!inlineParts.length && !textParts.length) { + await msg.edit({ text: "❌ 模型未返回可用的图像或文本" }); + return; + } + + const captionBase = `提示: ${htmlEscape(prompt)}`; + const extraText = textParts.length + ? `\n\n${htmlEscape(textParts.join("\n"))}` + : ""; + const caption = `${captionBase}${extraText}`; + + let sent = false; + for (let index = 0; index < inlineParts.length; index += 1) { + const part = inlineParts[index]; + const data = part.data as string; + const mime = + typeof part.mimeType === "string" ? part.mimeType : "image/png"; + const buffer = Buffer.from(data, "base64"); + if (!buffer.length) continue; + + const fileName = `banana_${Date.now()}_${index}.${extFromMime(mime)}`; + const file = new CustomFile(fileName, buffer.length, "", buffer); + + await client.sendFile(msg.peerId, { + file, + caption: !sent ? caption : undefined, + parseMode: !sent ? "html" : undefined, + replyTo: replyMsg.id, + }); + sent = true; + } + + if (!sent && textParts.length) { + await client.sendMessage(msg.peerId, { + message: caption, + parseMode: "html", + replyTo: replyMsg.id, + }); + sent = true; + } + + if (sent) { + try { + await msg.delete(); + } catch (error) { + await msg.edit({ text: caption, parseMode: "html" }); + } + } else { + await msg.edit({ text: "❌ 未成功发送生成的内容" }); + } +} + +async function handleBananaCommand(msg: Api.Message): Promise { + const raw = msg.message || ""; + const trimmed = raw.trim(); + const tokens = trimmed ? trimmed.split(/\s+/) : []; + tokens.shift(); + const subcommand = (tokens[0] || "").toLowerCase(); + const subValue = tokens.slice(1).join(" ").trim(); + const textAfterCommand = trimmed.replace(/^\S+\s*/, "").trim(); + + const configCommands = new Set(["key", "limit", "config", "help"]); + if (configCommands.has(subcommand)) { + await handleConfig(msg, subcommand, subValue); + return; + } + + await handleImageEdit(msg, textAfterCommand); +} + +class BananaPlugin extends Plugin { + description: string = `Nano-Banana 图像编辑插件\n\n${help_text}`; + cmdHandlers: Record Promise> = { + banana: handleBananaCommand, + }; +} + +export default new BananaPlugin(); diff --git a/plugins.json b/plugins.json index 49f127ef..f87a82cd 100644 --- a/plugins.json +++ b/plugins.json @@ -286,5 +286,9 @@ "calc": { "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/calc/calc.ts?raw=true", "desc": "计算器" + }, + "banana": { + "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/banana/banana.ts?raw=true", + "desc": "Nano-Banana 图像编辑" } }