Zipline: new plugin, auto-upload attachments + auto-shorten links
This commit is contained in:
parent
35bc22df2e
commit
aa3b96dfed
5 changed files with 290 additions and 0 deletions
11
plugins/zipline/manifest.json
Normal file
11
plugins/zipline/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
plugins/zipline/src/index.ts
Normal file
21
plugins/zipline/src/index.ts
Normal 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,
|
||||
};
|
||||
103
plugins/zipline/src/lib/api.ts
Normal file
103
plugins/zipline/src/lib/api.ts
Normal 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;
|
||||
}
|
||||
105
plugins/zipline/src/patches/autoProcess.ts
Normal file
105
plugins/zipline/src/patches/autoProcess.ts
Normal 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);
|
||||
}
|
||||
50
plugins/zipline/src/ui/Settings.tsx
Normal file
50
plugins/zipline/src/ui/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue