diff --git a/parsehub/parsehub.ts b/parsehub/parsehub.ts new file mode 100644 index 00000000..8a209078 --- /dev/null +++ b/parsehub/parsehub.ts @@ -0,0 +1,340 @@ +import { Plugin } from "@utils/pluginBase"; +import { Api } from "telegram"; +import { getPrefixes } from "@utils/pluginManager"; +import { sleep } from "telegram/Helpers"; + +const BOT_USERNAME = "ParseHubot"; +const POLL_INTERVAL_MS = 2000; +const MAX_WAIT_MS = 3 * 60 * 1000; +const RESULT_IDLE_MS = 5000; +const FETCH_LIMIT = 50; + +const PROGRESS_PREFIXES = [ + "解 析 中", + "已有相同任务正在解析", + "下 载 中", + "上 传 中", +] as const; + +const prefixes = getPrefixes(); +const mainPrefix = prefixes[0]; +const pluginName = "parsehub"; +const commandName = `${mainPrefix}${pluginName}`; + +const helpText = ` +依赖 @ParseHubot + +${commandName} 链接 解析社交媒体链接 + +示例: +${commandName} https://twitter.com/user/status/123 +${commandName} https://www.instagram.com/p/xxxx/ +`.trim(); + +const htmlEscape = (text: string): string => + text.replace( + /[&<>"']/g, + (ch) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[ch] || ch, + ); + +let hasStartedBot = false; + +const isProgressText = (text?: string | null): boolean => { + if (!text) return false; + const trimmed = text.trim(); + return PROGRESS_PREFIXES.some((prefix) => trimmed.startsWith(prefix)); +}; + +function extractLinks(text: string): string[] { + if (!text) return []; + const matches = text.match(/(?:https?:\/\/|www\.)\S+/gi) || []; + const sanitized = matches.map((raw) => { + const cleaned = raw.replace( + /[)\]\}\u3002\uff1a\uff01\uff1f\u3001\uff0c>]+$/u, + "", + ); + return cleaned.startsWith("http") ? cleaned : `https://${cleaned}`; + }); + return Array.from(new Set(sanitized.map((link) => link.trim()))).filter( + Boolean, + ); +} + +async function ensureBotReady(msg: Api.Message) { + const client = msg.client; + if (!client) return; + + try { + await client.invoke(new Api.contacts.Unblock({ id: BOT_USERNAME })); + } catch {} + + try { + const inputPeer = await client.getInputEntity(BOT_USERNAME); + await client.invoke( + new Api.account.UpdateNotifySettings({ + peer: new Api.InputNotifyPeer({ peer: inputPeer }), + settings: new Api.InputPeerNotifySettings({ + silent: true, + muteUntil: 2147483647, + }), + }), + ); + } catch {} + + if (hasStartedBot) { + return; + } + + try { + const history = await client.getMessages(BOT_USERNAME, { limit: 1 }); + if (history.length > 0) { + hasStartedBot = true; + return; + } + } catch {} + + try { + await client.invoke( + new Api.messages.StartBot({ + bot: BOT_USERNAME, + peer: BOT_USERNAME, + startParam: "", + }), + ); + hasStartedBot = true; + } catch { + try { + await client.sendMessage(BOT_USERNAME, { message: "/start" }); + hasStartedBot = true; + } catch {} + } +} + +async function getLatestBotMessageId(client: any): Promise { + if (!client) return 0; + try { + const history = await client.getMessages(BOT_USERNAME, { limit: 1 }); + if (history.length > 0) { + return history[0].id; + } + } catch {} + return 0; +} + +type RelayReason = "timeout" | "fetch_failed" | "send_failed" | "no_client"; + +interface RelayOutcome { + lastId: number; + forwarded: boolean; + reason?: RelayReason; + error?: string; +} + +const describeReason = (reason?: RelayReason): string => { + switch (reason) { + case "timeout": + return "等待超时"; + case "fetch_failed": + return "获取机器人消息失败"; + case "send_failed": + return "向机器人发送链接失败"; + case "no_client": + return "客户端未就绪"; + default: + return "原因未知"; + } +}; + +async function forwardChunk(client: any, peer: any, ids: number[]) { + await client.forwardMessages(peer, { + fromPeer: BOT_USERNAME, + messages: ids, + }); +} + +async function relayParseResult( + originMsg: Api.Message, + link: string, + baselineId: number, +): Promise { + const client = originMsg.client; + if (!client) { + return { lastId: baselineId, forwarded: false, reason: "no_client" }; + } + + try { + await client.sendMessage(BOT_USERNAME, { message: link }); + } catch (error: any) { + return { + lastId: baselineId, + forwarded: false, + reason: "send_failed", + error: error?.message || String(error), + }; + } + + const processedIds = new Set(); + const finalMessages = new Map(); + + const deadline = Date.now() + MAX_WAIT_MS; + let lastId = baselineId; + let lastFinalActivity = 0; + + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + + let messages: Api.Message[] = []; + try { + messages = await client.getMessages(BOT_USERNAME, { limit: FETCH_LIMIT }); + } catch (error: any) { + return { + lastId, + forwarded: false, + reason: "fetch_failed", + error: error?.message || String(error), + }; + } + + messages.sort((a, b) => a.id - b.id); + + for (const botMsg of messages) { + if (!botMsg || (botMsg as any).className === "MessageService") continue; + if (botMsg.out) continue; + if (botMsg.id <= lastId) continue; + if (processedIds.has(botMsg.id)) continue; + + processedIds.add(botMsg.id); + lastId = Math.max(lastId, botMsg.id); + + const text = botMsg.message?.trim(); + if (isProgressText(text)) { + continue; + } + + finalMessages.set(botMsg.id, botMsg); + lastFinalActivity = Date.now(); + } + + if ( + finalMessages.size > 0 && + Date.now() - lastFinalActivity >= RESULT_IDLE_MS + ) { + break; + } + } + + if (finalMessages.size === 0) { + return { lastId, forwarded: false, reason: "timeout" }; + } + + const sortedMessages = Array.from(finalMessages.values()).sort( + (a, b) => a.id - b.id, + ); + + let forwarded = false; + const fallbackTexts: string[] = []; + + for (let i = 0; i < sortedMessages.length; i += 100) { + const chunk = sortedMessages.slice(i, i + 100); + const ids = chunk.map((m) => m.id); + + try { + await forwardChunk(client, originMsg.peerId, ids); + forwarded = true; + } catch { + const snippet = chunk + .map((m) => m.message?.trim()) + .filter(Boolean) + .join("\n\n"); + fallbackTexts.push( + snippet.length + ? snippet + : `⚠️ 未能转发 @${BOT_USERNAME} 的多媒体结果,请前往私聊机器人查看。`, + ); + } + } + + if (!forwarded && fallbackTexts.length) { + try { + await client.sendMessage(originMsg.peerId, { + message: `📨 @${BOT_USERNAME} 返回内容:\n\n${fallbackTexts.join("\n\n")}`, + replyTo: originMsg.id, + }); + forwarded = true; + } catch {} + } + + return { + lastId, + forwarded, + reason: forwarded ? undefined : "timeout", + }; +} + +class ParseHubPlugin extends Plugin { + description: string = `\n${pluginName}\n\n${helpText}`; + cmdHandlers: Record Promise> = { + parsehub: async (msg: Api.Message) => { + const rawText = msg.message || ""; + const cleaned = rawText.replace( + new RegExp(`^${commandName}\\s*`, "i"), + "", + ); + const links = extractLinks(cleaned); + + if (!links.length) { + await msg.edit({ text: helpText, parseMode: "html" }); + return; + } + + await msg.edit({ + text: `✅ 已提交链接至 @${BOT_USERNAME},正在解析中,请等待。`, + parseMode: "html", + }); + + await ensureBotReady(msg); + const client = msg.client; + if (!client) { + await msg.edit({ + text: `❌ 无法获取 Telegram 客户端实例,请稍后重试。`, + }); + return; + } + + let baselineId = await getLatestBotMessageId(client); + + for (const link of links) { + const outcome = await relayParseResult(msg, link, baselineId); + baselineId = outcome.lastId; + + if (!outcome.forwarded) { + const reasonText = describeReason(outcome.reason); + const detail = + outcome.error && outcome.error !== "undefined" + ? `\n\n错误信息:${outcome.error}` + : ""; + await client.sendMessage(msg.peerId, { + message: `⚠️ 未能获取 ${htmlEscape(link)} 的最终结果(${reasonText})。请稍后重试或直接私聊 @${BOT_USERNAME}。${detail}`, + parseMode: "html", + replyTo: msg.id, + }); + } + + await sleep(600); + } + + try { + await msg.delete(); + } catch {} + }, + }; +} + +export default new ParseHubPlugin(); diff --git a/plugins.json b/plugins.json index 3926bee4..59d54c6d 100644 --- a/plugins.json +++ b/plugins.json @@ -267,6 +267,10 @@ "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/zpr/zpr.ts?raw=true", "desc": "二次元图片" }, + "parsehub": { + "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/parsehub/parsehub.ts?raw=true", + "desc": "社交媒体链接解析助手" + }, "pmcaptcha": { "url": "https://github.com/TeleBoxDev/TeleBox_Plugins/blob/main/pmcaptcha/pmcaptcha.ts?raw=true", "desc": "pmcaptcha私聊验证"