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