Zipline: new plugin, auto-upload attachments + auto-shorten links

This commit is contained in:
Ally Founder 2026-08-20 11:56:01 -04:00
parent 35bc22df2e
commit aa3b96dfed
5 changed files with 290 additions and 0 deletions

View file

@ -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"
}
}

View file

@ -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,
};

View file

@ -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<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
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<UploadedFile> {
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<string> {
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;
}

View file

@ -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<string>();
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<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
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);
}

View file

@ -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 (
<SettingsScaffold>
<NoteBox>
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.
</NoteBox>
<TableRowGroup title="Server">
<TextInput
label="Zipline host"
placeholder="i.allyapp.cc"
value={z.host ?? ""}
onChange={(v: string) => { z.host = v; }}
/>
<TextInput
label="API token"
placeholder="paste your token here"
value={z.token ?? ""}
onChange={(v: string) => { z.token = v; }}
/>
</TableRowGroup>
<TableRowGroup title="Behavior">
<TableSwitchRow
label="Auto-upload attachments"
subLabel="After sending a file, it's re-uploaded to Zipline, the original message is deleted, and replaced with a new message containing just the Zipline link (copied to your clipboard)"
value={!!z.autoUpload}
onValueChange={(v: boolean) => { z.autoUpload = v; }}
/>
<TableSwitchRow
label="Auto-shorten links"
subLabel="After sending a message, links in it get replaced with Zipline short links via an edit"
value={!!z.autoShorten}
onValueChange={(v: boolean) => { z.autoShorten = v; }}
/>
</TableRowGroup>
</SettingsScaffold>
);
}