import { IAppAccessors, IConfigurationExtend, IConfigurationModify, IEnvironmentRead, IHttp, ILogger, IModify, IPersistence, IRead, } from '@rocket.chat/apps-engine/definition/accessors'; import {App} from '@rocket.chat/apps-engine/definition/App'; import { IMessage, IMessageAttachment, IMessageAttachmentAuthor, IMessageAttachmentTitle, IPostMessageSent, MessageActionType, } from '@rocket.chat/apps-engine/definition/messages'; import type {IMessageAction} from '@rocket.chat/apps-engine/definition/messages/IMessageAction'; import {IAppInfo} from '@rocket.chat/apps-engine/definition/metadata'; import {IRoom} from '@rocket.chat/apps-engine/definition/rooms'; import {ISetting, SettingType} from '@rocket.chat/apps-engine/definition/settings'; import {IUser} from '@rocket.chat/apps-engine/definition/users'; enum SourceType { Pixiv = 'Pixiv', DeviantArt = 'DeviantArt', AniDB = 'AniDB', MyAnimeList = 'MyAnimeList', AniList = 'AniList', BCY = 'BCY', EHentai = 'E-Hentai', IMDB = 'IMDB', Twitter = 'Twitter', Danbooru = 'Danbooru', YandeRe = 'Yande.re', Gelbooru = 'Gelbooru', AnimePictures = 'AnimePictures', Unknown = 'Unknown', } function getUrlType(url: string): SourceType { const lowerUrl = url.toLowerCase(); if (lowerUrl.includes('pixiv.net')) { return SourceType.Pixiv; } if (lowerUrl.includes('twitter.com') || lowerUrl.includes('x.com')) { return SourceType.Twitter; } if (lowerUrl.includes('danbooru.donmai.us')) { return SourceType.Danbooru; } if (lowerUrl.includes('yande.re')) { return SourceType.YandeRe; } if (lowerUrl.includes('gelbooru.com')) { return SourceType.Gelbooru; } if (lowerUrl.includes('anime-pictures.net')) { return SourceType.AnimePictures; } if (lowerUrl.includes('deviantart.com')) { return SourceType.DeviantArt; } if (lowerUrl.includes('anidb.net')) { return SourceType.AniDB; } if (lowerUrl.includes('myanimelist.net')) { return SourceType.MyAnimeList; } if (lowerUrl.includes('anilist.co')) { return SourceType.AniList; } if (lowerUrl.includes('bcy.net')) { return SourceType.BCY; } if (lowerUrl.includes('e-hentai.org')) { return SourceType.EHentai; } if (lowerUrl.includes('imdb.com')) { return SourceType.IMDB; } return SourceType.Unknown; } // 主要响应接口 interface ISauceNAOResponse { header: IHeader; results: Array; } // 头部信息接口 interface IHeader { // 用户ID user_id: string; // 账户类型 account_type: string; // 短时限 short_limit: string; // 长时限 long_limit: string; // 剩余长时限 long_remaining: number; // 剩余短时限 short_remaining: number; // 状态 status: number; // 请求结果 results_requested: string; // 索引 index: Record; // 搜索深度 search_depth: string; // 最小相似度 minimum_similarity: number; // 查询图片显示 query_image_display: string; // 查询图片 query_image: string; // 返回结果数 results_returned: number; } // 索引数据接口 interface IIndexData { // 状态 status: number; // 父ID parent_id: number; // ID id: number; // 结果 results: number; } // 搜索结果接口 interface IResult { header: IResultHeader; data: IResultData; } // 结果头部信息接口 interface IResultHeader { // 相似度 similarity: number; // 缩略图 thumbnail: string; // 索引ID index_id: number; // 索引名称 index_name: string; dupes: number; // 隐藏 hidden: number; } // 结果数据接口 interface IResultData { ext_urls?: Array; title?: string; // Pixiv 特有字段 pixiv_id?: number; member_name?: string; member_id?: number; // DeviantArt 特有字段 da_id?: string; author_name?: string; author_url?: string; // E-Hentai 特有字段 source?: string; creator?: Array; eng_name?: string; jp_name?: string; // BCY 特有字段 bcy_id?: number; member_link_id?: number; bcy_type?: string; // 动画特有字段 anidb_aid?: number; mal_id?: number; anilist_id?: number; part?: string; year?: string; est_time?: string; // 电影特有字段 imdb_id?: string; } interface IImageBlob { blob: Blob; suffix: string; } const defaultSauceNAOResponse: ISauceNAOResponse = { header: { user_id: '', account_type: '', short_limit: '', long_limit: '', long_remaining: 0, short_remaining: 0, status: 0, results_requested: '', index: {}, search_depth: '', minimum_similarity: 0, query_image_display: '', query_image: '', results_returned: 0, }, results: [], }; let myLogger: ILogger; let SauceNAOErrStr: string; const sauceNAOApiKeyID = 'sauceNAO_api_key'; export class PicSearcherApp extends App implements IPostMessageSent { private rootUrl: string = ''; private sauceNAOApiKey: string = 'ac635e5f5011234871260aac1d37ac8360ed979c'; constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { super(info, logger, accessors); myLogger = logger; } public async initialize(configurationExtend: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise { this.rootUrl = await environmentRead.getEnvironmentVariables().getValueByName('ROOT_URL'); myLogger.log('rootUrl:', this.rootUrl); return super.initialize(configurationExtend, environmentRead); } public checkPostMessageSent?(message: IMessage, read: IRead, http: IHttp): Promise { return Promise.resolve(true); } public async executePostMessageSent(message: IMessage, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify): Promise { const author = await read.getUserReader().getAppUser(); // 尝试获取imageurl, 如果image大于20MB则使用缩略图 if (!message.attachments || message.attachments.length <= 0) { return Promise.resolve(); } myLogger.info('message.attachments:', message.attachments); const imageBlobs = await processMessageImages(message, http, read); const result = await searchImageOnSauceNAO(this.sauceNAOApiKey, imageBlobs[0].blob, imageBlobs[0].suffix); await sendSearchResults(modify, message.room, result, author!); } public async onSettingUpdated(setting: ISetting, configurationModify: IConfigurationModify, read: IRead, http: IHttp): Promise { if (setting.id === sauceNAOApiKeyID) { this.sauceNAOApiKey = setting.value; } return super.onSettingUpdated(setting, configurationModify, read, http); } protected async extendConfiguration(configuration: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise { await configuration.settings.provideSetting({ id: sauceNAOApiKeyID, type: SettingType.STRING, packageValue: '', required: true, public: false, i18nLabel: 'sauceNAO_api_key', i18nDescription: 'sauceNAO_api_key_desc', }); } private async sendMessage(textMessage: string, room: IRoom, author: IUser, modify: IModify) { const messageBuilder = modify.getCreator().startMessage({ text: textMessage, } as IMessage); messageBuilder.setSender(author); messageBuilder.setRoom(room); return modify.getCreator().finish(messageBuilder); } } /** * 主函数:处理消息中的所有图片,并返回 Blob 数组 * @param message IMessage 消息对象 * @param http IHttp HTTP 访问接口 * @param context IRead 读取接口 * @returns Promise 包含所有图片 Blob 的数组 */ export async function processMessageImages(message: IMessage, http: IHttp, context: IRead): Promise> { const blobs: Array = []; // 检查消息是否包含附件 if (!message.attachments || message.attachments.length === 0) { throw new Error('No attachments found in the message.'); } // 遍历附件并处理图片 for (const attachment of message.attachments) { if (attachment.imageUrl) { try { const fileId = attachment.imageUrl.split('/')[2]; const r: IImageBlob = { // 下载图片并转换为 Blob blob: await getFileBlob(fileId, context), // 获取文件后缀 suffix: attachment.imageUrl.split('.').pop() || '', }; blobs.push(r); } catch (error) { throw new Error(`Error fetching image content: ${error.message}`); } } } return blobs; } /** * 辅助函数:获取文件Blob对象 */ export async function getFileBlob(fileId: string, read: IRead): Promise { try { const buffer = await read.getUploadReader().getBufferById(fileId); return new Blob([buffer]); } catch (error) { myLogger.error(`Error fetching file content: ${error.message}`); } return new Blob(); } async function searchImageOnSauceNAO(apiKey: string, image: Blob, suffix: string): Promise { // 1. 创建 FormData 并附加文件 const formData = new FormData(); formData.append('file', image, 'image.' + suffix); // 文件名可以随意 formData.append('api_key', apiKey); formData.append('output_type', '2'); // 详细输出 // 2. 发送 POST 请求到 SauceNAO try { const response = await fetch('https://saucenao.com/search.php', { method: 'POST', body: formData, }); // 3. 解析返回的 HTML 数据 if (response.ok) { const json = await response.text(); const result = JSON.parse(json) as ISauceNAOResponse; SauceNAOErrStr = ''; return result; } else { console.error('请求失败:', response.status, response.statusText); SauceNAOErrStr = '请求失败: ' + response.status + ' ' + response.statusText; } } catch (error) { console.error('搜索请求出错:', error); SauceNAOErrStr = '搜索请求出错: ' + error; } return defaultSauceNAOResponse; } /** * 根据相似度计算颜色(从红到绿) * @param similarity 相似度,范围 70% ~ 90% * @returns 十六进制颜色字符串 */ function getInterpolatedColor(similarity: number): string { // 确保相似度在 70% 到 90% 之间 const clampedSimilarity = Math.max(70, Math.min(90, similarity)); // 将相似度映射到 0-1 范围 const t = (clampedSimilarity - 70) / 20; // 计算红色和绿色分量 const red = Math.round(255 * (1 - t)); const green = Math.round(255 * t); // 将 RGB 值转换为十六进制颜色字符串 const redHex = red.toString(16).padStart(2, '0'); const greenHex = green.toString(16).padStart(2, '0'); return `#${redHex}${greenHex}00`; } async function sendSearchResults( modify: IModify, room: IRoom, results: ISauceNAOResponse, appUser: IUser, ) { const attachments: Array = []; // 将results.results按照相似度排序,但如果ext_urls没有的话排在最后面 results.results.sort((a, b) => { if (a.data.ext_urls && !b.data.ext_urls) { return -1; } if (!a.data.ext_urls && b.data.ext_urls) { return 1; } return b.header.similarity - a.header.similarity; }); for (const result of results.results) { const header = result.header; const data = result.data; // From percentage string to numeric value const similarityValue = header.similarity; if (similarityValue < 70) { continue; } // Dynamically generate color const color = getInterpolatedColor(similarityValue); const titleText = data.title || '未知标题'; const attachmentObject: IMessageAttachment = { title: { value: `${titleText} (${header.similarity}%)`, // Title content } as IMessageAttachmentTitle, text: header.index_name, thumbnailUrl: header.thumbnail, // Optional: Image URL collapsed: attachments.length !== 0, // First one expanded, others collapsed color, // Dynamically set color }; if (!header.thumbnail) { delete attachmentObject.thumbnailUrl; } attachmentObject.actions = new Array(); if (data.ext_urls) { for (const extUrl of data.ext_urls) { const urlType = getUrlType(extUrl); attachmentObject.actions.push({ type: MessageActionType.BUTTON, text: urlType.toString(), url: extUrl, } as IMessageAction); } } // Add more buttons for different sources if (data.member_id) { attachmentObject.actions.push({ type: MessageActionType.BUTTON, text: '作者Pixiv', url: `https://www.pixiv.net/users/${data.member_id}`, } as IMessageAction); } if (data.da_id) { attachmentObject.actions.push({ type: MessageActionType.BUTTON, text: '作者DeviantArt', url: data.author_url, } as IMessageAction); } if (data.bcy_id) { attachmentObject.actions.push({ type: MessageActionType.BUTTON, text: '作者BCY', url: `https://bcy.net/u/${data.member_link_id}`, } as IMessageAction); } if (data.anidb_aid) { attachmentObject.actions.push({ type: MessageActionType.BUTTON, text: 'AniDB', url: `https://anidb.net/anime/${data.anidb_aid}`, } as IMessageAction); } if (data.mal_id) { attachmentObject.actions.push({ type: MessageActionType.BUTTON, text: 'MyAnimeList', url: `https://myanimelist.net/anime/${data.mal_id}`, } as IMessageAction); } if (data.anilist_id) { attachmentObject.actions.push({ type: MessageActionType.BUTTON, text: 'AniList', url: `https://anilist.co/anime/${data.anilist_id}`, } as IMessageAction); } if (data.imdb_id) { attachmentObject.actions.push({ type: MessageActionType.BUTTON, text: 'IMDb', url: `https://www.imdb.com/title/${data.imdb_id}`, } as IMessageAction); } if (data.member_name !== undefined) { attachmentObject.author = { name: data.member_name, } as IMessageAttachmentAuthor; } if (data.author_name !== undefined) { attachmentObject.author = { name: data.author_name, } as IMessageAttachmentAuthor; } if (Array.isArray(data.creator)) { attachmentObject.author = { name: data.creator.join(', '), } as IMessageAttachmentAuthor; } if (data.eng_name !== undefined) { attachmentObject.author = { name: data.eng_name, } as IMessageAttachmentAuthor; } if (data.jp_name !== undefined) { attachmentObject.author = { name: data.jp_name, } as IMessageAttachmentAuthor; } attachments.push(attachmentObject); } const successStr = `搜索成功,30秒内剩余查询次数:${results.header.short_remaining}/${results.header.short_limit},24小时内剩余查询次数:${results.header.long_remaining}/${results.header.long_limit}`; const text = attachments.length > 0 ? successStr : SauceNAOErrStr.length > 0 ? SauceNAOErrStr : '没有找到相关结果'; const messageBuilder = modify.getCreator().startMessage() .setRoom(room) .setUsernameAlias(appUser.username) .setText(text) .setAttachments(attachments) .setGroupable(true); await modify.getCreator().finish(messageBuilder); } async function urlToBlob(url: string): Promise { try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.blob(); } catch (error) { throw new Error(`Failed to fetch image: ${error}`); } } // 从 URL 获取图片 Blob async function test() { const blob = await urlToBlob('https://www.z4a.net/images/2024/12/09/69054578_p0.jpg'); const result = await searchImageOnSauceNAO('ac635e5f5011234871260aac1d37ac8360ed979c', blob, 'jpg'); console.log(result); } // test(); export default PicSearcherApp;