UNPKG

koishi-plugin-image-save-path

Version:

[<ruby>保存图片<rp>(</rp><rt>点我查看食用方法</rt><rp>)</rp></ruby>](https://www.npmjs.com/package/koishi-plugin-image-save-path) 💾支持[交互保存图片](https://i0.hdslb.com/bfs/article/a2780975ccbf74c422dd7f0333af0172312276085.png) 、 [回复保存图片](https://i0.hdslb.com/bfs/article/

783 lines (679 loc) 32.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const { Schema, h } = require("koishi"); const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); exports.name = "image-save-path"; exports.inject = { required: ['http'] }; exports.usage = ` --- <p><strong>配置项说明:</strong></p> <p><strong>保存图片 指令:</strong>用于触发后接收图片来保存。也可以用于保存被回复的图片(机器人未接收到的图片就存不了)。</p> --- 效果预览: <li><a href="https://i0.hdslb.com/bfs/article/a2780975ccbf74c422dd7f0333af0172312276085.png" target="_blank" referrerpolicy="no-referrer">交互保存图片</a></li> <li><a href="https://i0.hdslb.com/bfs/article/144dac10d99a911648b9016c620fa49a312276085.png" target="_blank" referrerpolicy="no-referrer">回复保存图片</a></li> <li><a href="https://i0.hdslb.com/bfs/article/a3f0844195795fc7e51f947e689fd744312276085.png" target="_blank" referrerpolicy="no-referrer">批量保存图片</a></li> <li><a href="https://i0.hdslb.com/bfs/article/23e26c25d805e0d5d5d76958e5950d56312276085.png" target="_blank" referrerpolicy="no-referrer">中间件批量保存图片</a></li> --- <h2>💡 使用示例</h2> <h3>快速保存</h3> <pre><code>保存图片 我的图片 -e jpg</code></pre> <p>👉 将图片保存为 <code>我的图片.jpg</code>。</p> <h3>路径选择</h3> <pre><code>保存图片 我的图片 表情包</code></pre> <p>👉 将图片保存到 <code>E:\\Images\\Memes</code>。</p> <h3>回复保存</h3> <pre><code>回复带图片的消息 + 保存图片 我的回复图片</code></pre> <p>👉 将回复消息中的图片保存为 <code>我的回复图片</code>。</p> --- <details> <summary>点击此处————查看完整使用方法说明</summary> <h2>🚀 使用方法</h2> <h3>1️⃣ 交互保存(默认路径,无需指定路径)</h3> <p>将 <code>Interaction_mode</code> 配置项选择到 <code>3</code>。</p> <pre><code>保存图片 图片名称</code></pre> <p><strong>行为说明:</strong></p> <ul> <li>插件会自动保存图片到默认路径,并以指定的图片名称保存。</li> <li>如果未设置默认路径,插件会提示错误:<code>没有设置默认保存路径</code>。</li> </ul> <h3>2️⃣ 交互保存(指定路径)</h3> <p>将 <code>Interaction_mode</code> 配置项选择到 <code>1</code>。</p> <pre><code>保存图片 图片名称 表情包</code></pre> <p><strong>行为说明:</strong></p> <ul> <li>插件会检查 <code>savePaths</code> 中是否存在路径备注为 <code>表情包</code> 的配置。</li> <li>如果匹配成功,图片将保存到对应路径。</li> <li>如果匹配失败,插件会提示用户重新输入路径备注。</li> </ul> <h3>3️⃣ 回复交互保存</h3> <pre><code>回复一条图片消息,并发送:保存图片 图片名称</code></pre> <p><strong>行为说明:</strong></p> <ul> <li>插件会提取回复消息中的图片,并保存为指定文件名。</li> <li>默认保存到第一个路径或用户指定路径。</li> </ul> <h3>4️⃣ 批量保存图片</h3> <pre><code>保存图片 批量保存 -e png</code></pre> <p><strong>行为说明:</strong></p> <ul> <li>插件会自动处理多张图片,避免文件重名冲突。</li> <li>若未启用 <code>checkDuplicate</code>,仅允许保存一张图片。</li> </ul> <h3>5️⃣ 中间件监听保存</h3> <pre><code>{ "groupListmapping": [ { "groupList": "123456", "enable": true, "defaultsavepath": "E:\\Images\\GroupChat", "count": 3 } ] }</code></pre> <p><strong>行为说明:</strong></p> <ul> <li>自动保存指定群聊/频道中的图片消息。</li> <li>支持自定义保存路径与触发条件。</li> </ul> <hr> <h2>🔧 高级功能</h2> <h3>1️⃣ 图片自动命名</h3> <p>插件会根据当前时间生成安全的文件名:</p> <pre><code>YYYY-MM-DD-HH-MM.png</code></pre> <h3>2️⃣ 重名检查</h3> <p>当启用 <code>checkDuplicate</code> 时,插件会自动为重名文件生成唯一文件名:</p> <pre><code>图片名称(1).png 图片名称(2).png</code></pre> <h3>3️⃣ 自定义路径映射</h3> <pre><code>{ "savePaths": [ { "name": "默认路径", "path": "E:\\Images\\Default" }, { "name": "表情包", "path": "E:\\Images\\Memes" } ] }</code></pre> <h3>4️⃣ 配合中间件监听群聊/频道</h3> <p>实时保存符合条件的图片消息到指定路径。</p> </details> --- </body> </html> <p></p> <p>推荐搭配一些从本地发图的插件使用哦\~</p> <ul> <li><a href="/market?keyword=emojihub-bili">emojihub-bili(这个可以自动发送本地表情包,与本插件组合实现“表情包小偷”)</a></li> <li><a href="/market?keyword=smmcat-photodisk">smmcat-photodisk(这个可以可视化选图,很好用~)</a></li> <li><a href="/market?keyword=booru-local">booru-local</a></li> <li><a href="/market?keyword=local-pic-selecter">local-pic-selecter</a></li> <li><a href="/market?keyword=get-images-from-local-path">get-images-from-local-path</a></li> </ul> `; exports.Config = Schema.intersect([ Schema.object({ Interaction_mode: Schema.union([ Schema.const('1').description('1.【保存图片 [图片重命名] [文件夹备注] [图片]】'), Schema.const('2').description('2.【保存图片 [文件夹备注] [图片重命名] [图片]】'), Schema.const('3').description('3.【保存图片 [图片重命名] [图片]】(仅存到第一个文件夹路径里)'), Schema.const('4').description('4.【保存图片 [文件夹备注] [图片]】(自动为图片重命名)'), Schema.const('5').description('5.【保存图片 [图片]】(自动为图片重命名,并且保存到第一个文件夹路径)'), ]).role('radio').description("交互模式选择:指令输入的参数规则<br>每个选项的效果图 请见`Preview`配置项展示的内容").required(), }).description('交互模式'), Schema.union([ Schema.object({ Interaction_mode: Schema.const("1").required(), Preview: Schema.array(Schema).role('checkbox').description(`<h3><a href="https://i0.hdslb.com/bfs/article/a3d7513782fcd223fc02cc1b107aba2b312276085.png" target="_blank" referrerpolicy="no-referrer">选项1 - 效果预览图(点我 以查看效果预览图)</a></h3>`), }), Schema.object({ Interaction_mode: Schema.const("2").required(), Preview: Schema.array(Schema).role('checkbox').description(`<h3><a href="https://i0.hdslb.com/bfs/article/7caf059b2874c2e3201669d51e614d35312276085.png" target="_blank" referrerpolicy="no-referrer">选项2 - 效果预览图(点我 以查看效果预览图)</a></h3>`), }), Schema.object({ Interaction_mode: Schema.const("3").required(), Preview: Schema.array(Schema).role('checkbox').description(`<h3><a href="https://i0.hdslb.com/bfs/article/fa61465af2ed31f85c537ddf598d6b3a312276085.png" target="_blank" referrerpolicy="no-referrer">选项3 - 效果预览图(点我 以查看效果预览图)</a></h3>`), }), Schema.object({ Interaction_mode: Schema.const("4").required(), Preview: Schema.array(Schema).role('checkbox').description(`<h3><a href="https://i0.hdslb.com/bfs/article/1309846507b81c4d0fa755553feebce6312276085.png" target="_blank" referrerpolicy="no-referrer">选项4 - 效果预览图(点我 以查看效果预览图)</a></h3>`), }), Schema.object({ Interaction_mode: Schema.const("5").required(), Preview: Schema.array(Schema).role('checkbox').description(`<h3><a href="https://i0.hdslb.com/bfs/article/4342e979dd9fac9a77fa519baa2a7c49312276085.png" target="_blank" referrerpolicy="no-referrer">选项5 - 效果预览图(点我 以查看效果预览图)</a></h3>`), }), Schema.object({}), ]), Schema.object({ commandName: Schema.string().description('注册的指令名称').default('保存图片'), showSavePath: Schema.boolean().description("保存成功后,告知具体文件保存路径,关闭后只会回复`图片已成功保存。`").default(false), checkDuplicate: Schema.boolean().description("开启后将检查重名文件,避免覆盖,若同名,则在文件名后加`(1)`,`(2)`... ...").default(true), imageSaveMode: Schema.boolean().description("开启后,默认选择了第一行的文件夹,可以缺省路径参数<br>当然也支持输入路径参数<br>[此配置项效果图](https://i0.hdslb.com/bfs/article/1d34ae45de7e3c875eec0caee5444149312276085.png)").default(false), savePaths: Schema.array(Schema.object({ name: Schema.string().description("文件夹备注"), path: Schema.string().description("文件夹路径"), })).role('table').description('用于设置图片保存路径的名称和地址映射').default([{ name: "第一个", path: "C:\\Program Files" }, { name: "第二个", path: "E:\\Music\\nums" }]), }).description('基础设置'), Schema.object({ ImageExtension: Schema.array( Schema.object( { prefix: Schema.string().description('前缀'), suffix: Schema.string().description('后缀'), } ) ).experimental().role('table') .description('图片`保存`时的`命名格式`、默认`扩展名`<br>▶仅第一行视为有效配置<br>前缀后缀对所有保存图片的名称生效,扩展名可以通过指令选项自定义<br><hr style="border: 2px solid red;">配置方法:变量请使用`${}`代替。<br>可用变量有:`session`、 `config`<br>日期:`YYYY`、 `MM`、 `DD`<br>随机数字:`A`、 `BB`、 `CCC`<br>▶详细说明 [请参考README](https://www.npmjs.com/package/koishi-plugin-image-save-path)'), autoRenameRules: Schema.string().role('textarea', { rows: [2, 4] }).default("${YYYY}-${MM}-${DD}-${BB}-${BB}-${BB}-${CCC}").experimental() .description("图片`自动重命名`时使用的名称格式<br>与上个配置项语法一致 支持使用变量替换"), }).description('文件保存设置'), Schema.object({ autosavePics: Schema.boolean().description("自动保存 的总开关:用于对重复一定次数的图进行保存<br>`如需查看详情日志,请开启consoleinfo配置项`"), }).description('进阶设置'), Schema.union([ Schema.object({ autosavePics: Schema.const(true).required(), groupListmapping: Schema.array(Schema.object({ enable: Schema.boolean().description('勾选后启用自动保存'), groupList: Schema.string().description('需要监听的群组ID').pattern(/^\S+$/), count: Schema.number().default(2).description('触发自动保存的重复次数'), defaultsavepath: Schema.string().description('保存到的文件夹路径'), })) .role('table') .description('各群组自动保存的路径映射 `注意不要多空格什么的(私信有private:的前缀)`') .default( [{ "enable": true, "groupList": "114514", "defaultsavepath": "C:\\Program Files" }, { "groupList": "private:1919810", "enable": true, "defaultsavepath": "C:\\Program Files" }] ), }), Schema.object({}), ]), Schema.object({ consoleinfo: Schema.boolean().default(false).description('日志调试模式'), command_of_get_link: Schema.boolean().default(false).description('是否开启`获取链接`的调试指令').experimental(), }).description('调试设置'), ]) function apply(ctx, config) { function loggerinfo(message, message2) { if (config.consoleinfo) { if (message2) { ctx.logger.info(`${message}${message2}`) } else { ctx.logger.info(message); } } } // 本地化支持 ctx.i18n.define("zh-CN", { commands: { // "保存图片": { [config.commandName]: { description: "保存图片到指定路径", messages: { "image_save_notfound_image": "请回复带有图片的消息。", "image_save_waitinput": "请发送图片:", "image_save_invalidimage": "输入的图片无效。", "image_save_path_select_prompt": "未开启重名检查时不允许一次性输入多张图片。", "image_save_path_invalid": "路径名称无效,请选择路径的名称(冒号左侧为名称):", "image_save_notselected": "请选择正确的路径名称。", "image_save_no_defaultpath": "没有设置默认保存路径。", "image_save_success": "图片已成功保存。", "image_save_error": "保存图片时出错:{0}", "image_save_location": "图片已保存到:{0}", "image_save_rename": "出现同名文件,已保存为 {0}({1}){2}", } }, "获取链接": { description: "获取图片链接", messages: { "image_save_notfound_image": "请回复带有图片的消息。", "image_save_waitinput": "请发送图片:", "image_save_invalidimage": "输入的图片无效。", "image_save_path_select_prompt": "未开启重名检查时不允许一次性输入多张图片。", "image_save_path_invalid": "路径名称无效,请选择路径的名称(冒号左侧为名称):", "image_save_notselected": "请选择正确的路径名称。", "image_save_no_defaultpath": "没有设置默认保存路径。", "image_save_success": "图片已成功保存。", "image_save_error": "保存图片时出错:{0}", "image_save_location": "图片已保存到:{0}", "image_save_rename": "出现同名文件,已保存为 {0}({1}){2}", } } } }); const interactionMode = config.Interaction_mode || '1'; // 提取 URL 的函数 const extractUrl = (content) => { let urls = h.select(content, 'img').map(item => item.attrs.src); if (urls?.length > 0) { return urls; } urls = h.select(content, 'mface').map(item => item.attrs.url); return urls?.length > 0 ? urls : null; }; const replacePlaceholders = (template, session, config) => { const date = new Date(); const variables = { 'YYYY': date.getFullYear(), // 年份,例如 2023 'MM': String(date.getMonth() + 1).padStart(2, '0'), // 月份,例如 01 到 12 'DD': String(date.getDate()).padStart(2, '0'), // 日期,例如 01 到 31 'A': String(Math.floor(Math.random() * 10)), // 一位随机数字,范围 0 到 9 'BB': String(Math.floor(Math.random() * 100)).padStart(2, '0'), // 两位随机数字,范围 00 到 99 'CCC': String(Math.floor(Math.random() * 1000)).padStart(3, '0'), // 三位随机数字,范围 000 到 999 }; return template.replace(/\$\{([^}]+)\}/g, (match, key) => { // 如果 key 是预定义的变量(如 YYYY, MM 等),直接替换 if (variables[key]) { return variables[key]; } // 如果 key 是 session 或 config 的字段,动态提取 const parts = key.split('.'); if (parts[0] === 'session' || parts[0] === 'config') { const obj = parts[0] === 'session' ? session : config; return parts.slice(1).reduce((acc, part) => acc?.[part], obj) || match; } // 如果 key 不是预定义的变量,返回原占位符 return match; }); }; const generateFilename = (session, config) => { // 替换占位符 let filename = replacePlaceholders(config.autoRenameRules, session, config); // 替换非法字符 filename = filename.replace(/[\u0000-\u001f\u007f-\u009f\/\\:*?"<>|]/g, '_'); loggerinfo(filename); return filename; }; const getImageInfoAndBuffer = async (url, safeFilename, ctx) => { try { const file = await ctx.http.file(url); loggerinfo('ctx.http.file result:\n', JSON.stringify(file, null, 2)); if (!file || !file.data) { throw new Error('无法获取图片数据'); } let detectedExtension = ''; // 优先根据 file.type 和 file.mime 确定后缀名 if (file.type === 'image/jpeg' || file.mime === 'image/jpeg') { detectedExtension = '.jpg'; } else if (file.type === 'image/png' || file.mime === 'image/png') { detectedExtension = '.png'; } else if (file.type === 'image/gif' || file.mime === 'image/gif') { detectedExtension = '.gif'; } else if (file.type === 'image/webp' || file.mime === 'image/webp') { detectedExtension = '.webp'; } else if (file.type === 'image/bmp' || file.mime === 'image/bmp') { detectedExtension = '.bmp'; } else if (file.type || file.mime) { // 如果有 type 或 mime,但不是常见的图片类型,则记录警告 ctx.logger.warn(`未知的图片类型,file.type=${file.type}, file.mime=${file.mime}`); detectedExtension = '.png'; // 默认使用 .png } else { // 如果没有任何类型信息,则记录错误 ctx.logger.error(`无法检测到文件类型,file.type=${file.type}, file.mime=${file.mime}`); detectedExtension = '.png'; // 默认使用 .png } const finalFilename = `${safeFilename}${detectedExtension}`; loggerinfo(finalFilename) return { buffer: Buffer.from(file.data), filename: finalFilename, extension: detectedExtension }; } catch (error) { ctx.logger.error(`获取图片信息和Buffer失败: ${error.message}`); throw error; } }; ctx.command(`${config.commandName} [参数...]`, { captureQuote: false }) .option('name', '-n <name:string> 严格指定图片重命名') .action(async ({ session, options }, ...args) => { let 文件名, 路径名称, 图片; switch (interactionMode) { case '1': [文件名, 路径名称, 图片] = args; break; case '2': [路径名称, 文件名, 图片] = args; break; case '3': [文件名, 图片] = args; break; case '4': [路径名称, 图片] = args; break; case '5': [图片] = args; break; default: [文件名, 路径名称, 图片] = args; } const quotemessage = session.quote?.content; let urlhselect; loggerinfo('session.content: ', session.content); // 处理图片源 if (quotemessage) { // 回复保存图片 urlhselect = extractUrl(quotemessage); if (!urlhselect) { await session.send(session.text(".image_save_notfound_image")) return; } loggerinfo('触发回复的目标消息内容: ', quotemessage); } else if (图片) { // 用户直接输入图片 urlhselect = extractUrl(图片); if (!urlhselect) { await session.send(session.text(".image_save_notfound_image")) return; } loggerinfo('用户直接输入的图片内容为: ', urlhselect); } else { // 交互保存图片 await session.send(session.text(".image_save_waitinput")) const image = await session.prompt(30000); urlhselect = extractUrl(image); if (!urlhselect) { //return '无法提取图片URL。'; await session.send(session.text(".image_save_invalidimage")) return; } loggerinfo('用户输入: ', image); } if (urlhselect.length > 1 && !config.checkDuplicate) { await session.send(session.text(".image_save_path_select_prompt")) return; } // 处理名称 if (文件名?.length <= 0) { // 如果长度小于等于 1,认为路径名称无效 文件名 = undefined; } else { loggerinfo('文件名: ', 文件名); } if (路径名称?.length <= 0) { // 如果长度小于等于 1,认为路径名称无效 路径名称 = undefined; } else { loggerinfo('路径名称: ', 路径名称); } // 选择保存路径 let selectedPath; if (interactionMode === '3' || interactionMode === '5' || (config.imageSaveMode && !路径名称)) { selectedPath = config.savePaths[0]?.path; if (!selectedPath) return session.text(".image_save_no_defaultpath"); } else if (interactionMode === '4') { const selected = config.savePaths.find(item => item.name === 路径名称); if (!selected) { await session.send(session.text(".image_save_path_invalid") + '\n' + config.savePaths.map(item => `${item.name}: ${item.path}`).join('\n')); const input = await session.prompt(30000); const selected = config.savePaths.find(item => item.name === input); if (!selected) return session.text(".image_save_notselected"); selectedPath = selected.path; } else { selectedPath = selected.path; } } else { if (路径名称) { const selected = config.savePaths.find(item => item.name === 路径名称); if (!selected) { await session.send(session.text(".image_save_path_invalid") + '\n' + config.savePaths.map(item => `${item.name}: ${item.path}`).join('\n')); const input = await session.prompt(30000); const selected = config.savePaths.find(item => item.name === input); if (!selected) return session.text(".image_save_notselected"); selectedPath = selected.path; } else { selectedPath = selected.path; } } else { await session.send(session.text(".image_save_path_invalid") + '\n' + config.savePaths.map(item => `${item.name}: ${item.path}`).join('\n')); const input = await session.prompt(30000); const selected = config.savePaths.find(item => item.name === input); if (!selected) return session.text(".image_save_notselected"); selectedPath = selected.path; } } // 处理文件名 let safeFilename; if (options.name) { safeFilename = options.name; } else if (!文件名) { safeFilename = generateFilename(session, config); } else { safeFilename = 文件名; } // 保存图片 try { await saveImages(urlhselect, selectedPath, safeFilename, config, session, ctx); } catch (error) { ctx.logger.error('保存图片时出错: ' + error.message); await session.send(session.text(`.image_save_error`, [error.message])); return; } }); if (config.command_of_get_link) { ctx.command('获取链接 [图片]') .action(async ({ session }, 图片) => { const quotemessage = session.quote?.content; let urlhselect; // 处理图片源 if (quotemessage) { urlhselect = extractUrl(quotemessage); loggerinfo('触发回复的目标消息内容: ', quotemessage); if (!urlhselect || urlhselect.length === 0) { await session.send(session.text(".image_save_notfound_image")); return; } else { let imageInfoList = []; for (const url of urlhselect) { try { const imageInfo = await getImageInfoAndBuffer(url, 'temp', ctx); // 使用一个临时文件名 imageInfoList.push({ url, type: imageInfo.extension, filename: imageInfo.filename }); } catch (error) { ctx.logger.error(`获取图片信息失败: ${error.message}`); await session.send(session.text(".image_save_failed_get_info")); return; // 只要有一个失败就直接返回 } } // 构造返回消息 let message = "图片信息:\n"; for (const info of imageInfoList) { message += `URL: ${info.url}\nType: ${info.type}\nFilename: ${info.filename}\n\n`; } await session.send(message); return; } } else if (图片) { urlhselect = extractUrl(图片); loggerinfo('用户直接输入的图片内容为: ', urlhselect); if (!urlhselect || urlhselect.length === 0) { await session.send(session.text(".image_save_notfound_image")); return; } else { let imageInfoList = []; for (const url of urlhselect) { try { const imageInfo = await getImageInfoAndBuffer(url, 'temp', ctx); // 使用一个临时文件名 imageInfoList.push({ url, type: imageInfo.extension, filename: imageInfo.filename }); } catch (error) { ctx.logger.error(`获取图片信息失败: ${error.message}`); await session.send(session.text(".image_save_failed_get_info")); return; // 只要有一个失败就直接返回 } } // 构造返回消息 let message = "图片信息:\n"; for (const info of imageInfoList) { message += `URL: ${info.url}\nType: ${info.type}\nFilename: ${info.filename}\n\n`; } await session.send(message); return; } } else { await session.send(session.text(".image_save_waitinput")); const image = await session.prompt(30000); urlhselect = extractUrl(image); loggerinfo('用户输入: ', image); if (!urlhselect || urlhselect.length === 0) { await session.send(session.text(".image_save_invalidimage")); return; } else { let imageInfoList = []; for (const url of urlhselect) { try { const imageInfo = await getImageInfoAndBuffer(url, 'temp', ctx); // 使用一个临时文件名 imageInfoList.push({ url, type: imageInfo.extension, filename: imageInfo.filename }); } catch (error) { ctx.logger.error(`获取图片信息失败: ${error.message}`); await session.send(session.text(".image_save_failed_get_info")); return; // 只要有一个失败就直接返回 } } // 构造返回消息 let message = "图片信息:\n"; for (const info of imageInfoList) { message += `URL: ${info.url}\nType: ${info.type}\nFilename: ${info.filename}\n\n`; } await session.send(message); return; } } }); } async function saveImages(urls, selectedPath, safeFilename, config, session, ctx) { let firstMessageSent = false; let duplicateMessages = []; for (let i = 0; i < urls.length; i++) { let url = urls[i]; try { const { buffer, filename, extension } = await getImageInfoAndBuffer(url, safeFilename, ctx); let fileRoot = path.join(selectedPath, safeFilename); let fileExt = `${extension}`; // 使用检测到的后缀 let targetPath = `${fileRoot}${fileExt}`; let index = 0; loggerinfo('提取到的图片链接:', url); if (config.checkDuplicate) { while (fs.existsSync(targetPath)) { index++; targetPath = `${fileRoot}(${index})${fileExt}`; } } try { if (buffer.byteLength === 0) throw new Error('下载的数据为空'); await fs.promises.writeFile(targetPath, Buffer.from(buffer)); if (index > 0) { duplicateMessages.push(session.text(`.image_save_rename`, [safeFilename, index, fileExt])); } else { if (!firstMessageSent) { if (config.showSavePath) { await session.send(session.text(`.image_save_location`, [targetPath])); } else { await session.send(session.text(`.image_save_success`)); } firstMessageSent = true; } } } catch (error) { ctx.logger.error('保存图片时出错: ' + error.message); await session.send(session.text(`.image_save_error`, [error.message])); } } catch (error) { ctx.logger.error('获取图片信息失败: ' + error.message); await session.send(session.text(`.image_save_error`, [error.message])); } } if (duplicateMessages.length > 0) { await session.send(duplicateMessages.join('\n')); } } async function calculateHash(filename) { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); const stream = fs.createReadStream(filename); stream.on('data', data => hash.update(data)); stream.on('end', () => resolve(hash.digest('hex'))); stream.on('error', err => reject(err)); }); } function loadHashRecords(filePath) { try { if (fs.existsSync(filePath)) { const fileContent = fs.readFileSync(filePath, 'utf8'); if (fileContent) { return JSON.parse(fileContent); } } } catch (error) { ctx.logger.error(`读取或解析哈希记录文件失败: ${error.message}`); } return {}; } function saveHashRecords(filePath, records) { fs.writeFileSync(filePath, JSON.stringify(records, null, 2), 'utf8'); } const root = path.join(ctx.baseDir, 'data', 'image-save-path'); try { fs.mkdirSync(root, { recursive: true }); } catch (error) { ctx.logger.error(`Error creating directory: ${error}`); } const hashRecordPath = path.join(ctx.baseDir, 'data', 'image-save-path', 'image-hash-records.json'); async function downloadAndSaveImage(url, outputPath, ctx, hashRecords, count, session, config) { try { // 使用 generateFilename 函数生成文件名 const safeFilename = generateFilename(session, config); const { buffer, filename, extension } = await getImageInfoAndBuffer(url, safeFilename, ctx); const finalPath = path.join(outputPath, filename); // 使用带后缀的文件名 const tempPath = `${finalPath}.tmp`; await fs.promises.writeFile(tempPath, buffer); const hash = await calculateHash(tempPath); if (!hashRecords[hash]) { hashRecords[hash] = { count: 0, path: "", saved: false }; } hashRecords[hash].count++; if (hashRecords[hash].count >= count && !hashRecords[hash].saved) { fs.renameSync(tempPath, finalPath); loggerinfo(`图片已保存到:${finalPath}`); hashRecords[hash].path = finalPath; hashRecords[hash].saved = true; } else { fs.unlinkSync(tempPath); } saveHashRecords(hashRecordPath, hashRecords); } catch (error) { ctx.logger.error(`处理图片失败:${error}`); } } if (config.autosavePics) { ctx.middleware(async (session, next) => { const channelId = session.channelId; const groupConfig = config.groupListmapping.find(group => group.groupList === channelId && group.enable); if (!groupConfig) { return next(); } const userMessagePic = session.content; const imageLinks = extractUrl(userMessagePic); if (!imageLinks || !imageLinks?.length) { return next(); } if (imageLinks?.length > 0) { loggerinfo(`收到图片消息,提取到链接:\n${imageLinks}`); } const hashRecords = loadHashRecords(hashRecordPath); for (const link of imageLinks) { await downloadAndSaveImage(link, groupConfig.defaultsavepath, ctx, hashRecords, groupConfig.count, session, config); } return next(); }); } } exports.apply = apply;