From aa3b96dfed9e873bb119520c69f617069867cd40 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Thu, 20 Aug 2026 11:56:01 -0400 Subject: [PATCH] Zipline: new plugin, auto-upload attachments + auto-shorten links --- plugins/zipline/manifest.json | 11 +++ plugins/zipline/src/index.ts | 21 +++++ plugins/zipline/src/lib/api.ts | 103 ++++++++++++++++++++ plugins/zipline/src/patches/autoProcess.ts | 105 +++++++++++++++++++++ plugins/zipline/src/ui/Settings.tsx | 50 ++++++++++ 5 files changed, 290 insertions(+) create mode 100644 plugins/zipline/manifest.json create mode 100644 plugins/zipline/src/index.ts create mode 100644 plugins/zipline/src/lib/api.ts create mode 100644 plugins/zipline/src/patches/autoProcess.ts create mode 100644 plugins/zipline/src/ui/Settings.tsx diff --git a/plugins/zipline/manifest.json b/plugins/zipline/manifest.json new file mode 100644 index 0000000..01149b2 --- /dev/null +++ b/plugins/zipline/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Zipline", + "description": "Uploads attachments and shortens links through a self-hosted Zipline instance instead of sending them natively.", + "authors": [ + { "name": "Rosie" } + ], + "main": "src/index.ts", + "vendetta": { + "icon": "ic_upload" + } +} diff --git a/plugins/zipline/src/index.ts b/plugins/zipline/src/index.ts new file mode 100644 index 0000000..8296b07 --- /dev/null +++ b/plugins/zipline/src/index.ts @@ -0,0 +1,21 @@ +import { logger } from "@vendetta"; +import { id } from "@vendetta/plugin"; +import { applyPatches } from "@shared/lib/patcher"; +import { guardPlugin } from "@shared/lib/guard"; +import patchAutoProcess from "./patches/autoProcess"; +import { zStorage } from "./lib/api"; +import Settings from "./ui/Settings"; + +let unpatchAll: () => void = () => {}; + +export default { + onLoad: () => { + zStorage(); + + unpatchAll = guardPlugin(id, () => applyPatches("Zipline", logger, { + autoProcess: patchAutoProcess, + })); + }, + onUnload: () => unpatchAll(), + settings: Settings, +}; diff --git a/plugins/zipline/src/lib/api.ts b/plugins/zipline/src/lib/api.ts new file mode 100644 index 0000000..a404849 --- /dev/null +++ b/plugins/zipline/src/lib/api.ts @@ -0,0 +1,103 @@ +import { storage } from "@vendetta/plugin"; + +const DEFAULT_HOST = "i.allyapp.cc"; + +interface ZiplineStorage { + token?: string; + host?: string; + autoUpload: boolean; + autoShorten: boolean; +} + +export function zStorage(): ZiplineStorage { + const s = storage as any; + s.autoUpload ??= true; + s.autoShorten ??= true; + return s; +} + +function host(): string { + const h = zStorage().host?.trim(); + return (h || DEFAULT_HOST).replace(/^https?:\/\//, "").replace(/\/$/, ""); +} + +export function baseUrl(): string { + return `https://${host()}`; +} + +export function isConfigured(): boolean { + return !!zStorage().token?.trim(); +} + +export function isExcludedDomain(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return true; + } + + const excluded = ["discord.com", "discordapp.com", "cdn.discordapp.com", "media.discordapp.net", "discord.gg", host()]; + return excluded.some((d) => parsed.hostname === d || parsed.hostname.endsWith(`.${d}`)); +} + +export interface UploadedFile { + url: string; + name: string; +} + +const UPLOAD_TIMEOUT_MS = 30000; + +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + promise.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); }, + ); + }); +} + +export async function uploadFromUrl(sourceUrl: string, name: string, type: string): Promise { + const token = zStorage().token?.trim(); + if (!token) throw new Error("No Zipline token configured"); + + const form = new FormData(); + form.append("file", { uri: sourceUrl, name, type } as any); + + const res = await withTimeout( + fetch(`${baseUrl()}/api/upload`, { + method: "POST", + headers: { authorization: token }, + body: form, + }), + UPLOAD_TIMEOUT_MS, + "Upload timed out", + ); + + if (!res.ok) throw new Error(`Zipline upload failed (${res.status}): ${await res.text().catch(() => "")}`); + + const json = await res.json(); + const uploaded = json?.files?.[0]; + if (!uploaded?.url) throw new Error("Zipline upload response was missing a file URL"); + + return { url: uploaded.url, name: uploaded.name }; +} + +export async function shortenUrl(destination: string): Promise { + const token = zStorage().token?.trim(); + if (!token) throw new Error("No Zipline token configured"); + + const res = await fetch(`${baseUrl()}/api/user/urls`, { + method: "POST", + headers: { authorization: token, "content-type": "application/json" }, + body: JSON.stringify({ destination }), + }); + + if (!res.ok) throw new Error(`Zipline shorten failed (${res.status}): ${await res.text().catch(() => "")}`); + + const json = await res.json(); + if (!json?.url) throw new Error("Zipline shorten response was missing a URL"); + + return json.url; +} diff --git a/plugins/zipline/src/patches/autoProcess.ts b/plugins/zipline/src/patches/autoProcess.ts new file mode 100644 index 0000000..d4e6c58 --- /dev/null +++ b/plugins/zipline/src/patches/autoProcess.ts @@ -0,0 +1,105 @@ +import { findByProps, findByStoreName } from "@vendetta/metro"; +import { clipboard, FluxDispatcher } from "@vendetta/metro/common"; +import { showToast } from "@vendetta/ui/toasts"; +import { isConfigured, isExcludedDomain, shortenUrl, uploadFromUrl, zStorage } from "../lib/api"; + +const TAG = "[Zipline]"; +const URL_REGEX = /https?:\/\/[^\s<>"]+/g; + +const UserStore = findByStoreName("UserStore"); +const MessageActions = findByProps("sendMessage", "editMessage") as any; + +const MAX_TRACKED = 200; +const processedMessages = new Set(); + +function alreadyProcessed(key: string): boolean { + if (processedMessages.has(key)) return true; + processedMessages.add(key); + if (processedMessages.size > MAX_TRACKED) { + processedMessages.delete(processedMessages.values().next().value); + } + return false; +} + +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + promise.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); }, + ); + }); +} + +async function handleMessageCreate(event: any) { + const message = event?.message; + if (!message?.id) return; + if (message.state === "SENDING") return; + if (!isConfigured()) return; + + const currentUserId = UserStore?.getCurrentUser?.()?.id; + if (!currentUserId || message.author?.id !== currentUserId) return; + + const attachments: any[] = message.attachments ?? []; + const wantsUpload = zStorage().autoUpload && attachments.length > 0; + const wantsShorten = zStorage().autoShorten && !!message.content; + if (!wantsUpload && !wantsShorten) return; + if (alreadyProcessed(message.nonce ?? message.id)) return; + + let content: string = message.content ?? ""; + const uploadedUrls: string[] = []; + + if (wantsUpload) { + for (const att of attachments) { + try { + const uploaded = await uploadFromUrl(att.url, att.filename ?? "file", att.content_type ?? "application/octet-stream"); + uploadedUrls.push(uploaded.url); + } catch (e: any) { + console.error(TAG, "Failed to upload", att?.filename, e?.message ?? e); + } + } + if (uploadedUrls.length) content = [content, ...uploadedUrls].filter(Boolean).join("\n"); + } + + if (wantsShorten) { + const rawMatches = content.match(URL_REGEX) ?? []; + const urls = [...new Set(rawMatches)].filter((u) => !isExcludedDomain(u)); + for (const url of urls) { + try { + const short = await shortenUrl(url); + content = content.split(url).join(short); + } catch (e: any) { + console.error(TAG, "Failed to shorten", url, e?.message ?? e); + } + } + } + + if (content === (message.content ?? "")) return; + + try { + if (uploadedUrls.length) { + await withTimeout(MessageActions.deleteMessage(message.channel_id, message.id), 15000, "deleteMessage timed out"); + await withTimeout( + MessageActions.sendMessage( + message.channel_id, + { content, tts: false, invalidEmojis: [], validNonShortcutEmojis: [] }, + true, + {}, + ), + 15000, + "sendMessage timed out", + ); + clipboard.setString(uploadedUrls[uploadedUrls.length - 1]); + } else { + await withTimeout(MessageActions.editMessage(message.channel_id, message.id, { content }), 15000, "editMessage timed out"); + } + } catch (e: any) { + console.error(TAG, "Failed to replace message content:", e?.message ?? e); + showToast(`Zipline: failed to update message (${e?.message ?? e})`, undefined); + } +} + +export default function patchAutoProcess(): () => void { + FluxDispatcher.subscribe("MESSAGE_CREATE", handleMessageCreate); + return () => FluxDispatcher.unsubscribe("MESSAGE_CREATE", handleMessageCreate); +} diff --git a/plugins/zipline/src/ui/Settings.tsx b/plugins/zipline/src/ui/Settings.tsx new file mode 100644 index 0000000..61de34c --- /dev/null +++ b/plugins/zipline/src/ui/Settings.tsx @@ -0,0 +1,50 @@ +import { React } from "@vendetta/metro/common"; +import { storage } from "@vendetta/plugin"; +import { useProxy } from "@vendetta/storage"; +import { TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table"; +import SettingsScaffold from "@shared/ui/SettingsScaffold"; +import NoteBox from "@shared/ui/NoteBox"; +import { zStorage } from "../lib/api"; + +export default function Settings() { + useProxy(storage); + const z = zStorage(); + + return ( + + + Requires a Zipline API token (Zipline dashboard - Account Settings - copy your token). + Nothing is sent anywhere until a token is set below. Don't have your own Zipline? + Create an account at i.allyapp.cc and register with your Discord. + + + { z.host = v; }} + /> + { z.token = v; }} + /> + + + { z.autoUpload = v; }} + /> + { z.autoShorten = v; }} + /> + + + ); +}