From eef66377159ed725dd37dd5415f92730afbe72e3 Mon Sep 17 00:00:00 2001 From: Rosie Val Date: Sun, 20 Sep 2026 21:26:19 -0400 Subject: [PATCH] feat: add Zipline plugin with pre-send interception --- plugins/readme.md | 1 - plugins/zipline/js/index.ts | 297 +++++++++++++++++++++++++++++ plugins/zipline/js/lib/api.ts | 131 +++++++++++++ plugins/zipline/js/lib/types.ts | 13 ++ plugins/zipline/js/ui/Settings.tsx | 75 ++++++++ plugins/zipline/manifest.json | 20 ++ 6 files changed, 536 insertions(+), 1 deletion(-) delete mode 100644 plugins/readme.md create mode 100644 plugins/zipline/js/index.ts create mode 100644 plugins/zipline/js/lib/api.ts create mode 100644 plugins/zipline/js/lib/types.ts create mode 100644 plugins/zipline/js/ui/Settings.tsx create mode 100644 plugins/zipline/manifest.json diff --git a/plugins/readme.md b/plugins/readme.md deleted file mode 100644 index 4c19f71..0000000 --- a/plugins/readme.md +++ /dev/null @@ -1 +0,0 @@ -More Plugins Coming Soon diff --git a/plugins/zipline/js/index.ts b/plugins/zipline/js/index.ts new file mode 100644 index 0000000..de4d49f --- /dev/null +++ b/plugins/zipline/js/index.ts @@ -0,0 +1,297 @@ +import { isExcludedDomain, shortenUrl, uploadFile } from './lib/api' +import { DEFAULT_STORAGE, type ZiplineStorage } from './lib/types' +import Settings from './ui/Settings' + +const TAG = '[Zipline]' +const URL_REGEX = /https?:\/\/[^\s<>"]+/g +const MESSAGE_ACTION_CREATORS_MODULE_ID = 7696 + +function extractFileInfo(att: any) { + const uri = + att?.uri || + att?.item?.uri || + att?.file?.uri || + att?.url || + att?.item?.url || + att?.path + const name = + att?.filename || + att?.name || + att?.item?.filename || + att?.item?.name || + att?.file?.name || + 'file' + const type = + att?.mimeType || + att?.type || + att?.item?.mimeType || + att?.item?.type || + att?.file?.type || + 'application/octet-stream' + return { uri, name, type } +} + +export default plugin<{ jsonStorage: ZiplineStorage }>({ + jsonStorage: { + load: true, + default: DEFAULT_STORAGE, + }, + + start(api) { + const cleanups: Array<() => void> = [] + + const showToast = (content: string) => { + try { + revenge.discord.actions.ToastActionCreators?.open?.({ content }) + } catch {} + } + + const setClipboard = (text: string) => { + try { + revenge.react.ReactNative?.Clipboard?.setString?.(text) + } catch {} + } + + const patchMessageActions = (mod: any) => { + const target = mod?.default ?? mod + if (!target || typeof target.sendMessage !== 'function') return + + try { + const unpatch = revenge.patcher.instead( + target, + 'sendMessage', + async (args: any[], orig: any) => { + const storage = api.jsonStorage.cache || DEFAULT_STORAGE + const token = storage.token?.trim() + + // If not configured, pass straight through + if (!token) { + return orig.apply(target, args) + } + + const [channelId, message, promise, options] = args + let content: string = message?.content ?? '' + + const rawAttachments: any[] = + options?.attachmentsToUpload ?? options?.attachments ?? [] + const wantsUpload = + storage.autoUpload !== false && rawAttachments.length > 0 + const wantsShorten = + storage.autoShorten !== false && !!content.trim() + + if (!wantsUpload && !wantsShorten) { + return orig.apply(target, args) + } + + const uploadedUrls: string[] = [] + + // 1. Intercept and upload attachments before sending + if (wantsUpload) { + showToast('Uploading to Zipline…') + + for (const raw of rawAttachments) { + const { uri, name, type } = extractFileInfo(raw) + if (!uri) continue + + try { + const uploaded = await uploadFile( + uri, + name, + type, + token, + storage.host, + ) + uploadedUrls.push(uploaded.url) + } catch (e: any) { + api.logger.error( + `${TAG} Failed to upload ${name}: ${e?.message ?? e}`, + ) + showToast(`Zipline upload error: ${e?.message ?? e}`) + } + } + + if (uploadedUrls.length > 0) { + // Strip native attachments so Discord does not upload or send them natively + if (options) { + if (options.attachmentsToUpload) { + options.attachmentsToUpload = [] + } + if (options.attachments) { + options.attachments = [] + } + } + + // Append Zipline links into message content + const urlsBlock = uploadedUrls.join('\n') + content = content ? `${content}\n${urlsBlock}` : urlsBlock + message.content = content + + setClipboard(uploadedUrls[uploadedUrls.length - 1]) + showToast('Uploaded to Zipline (link copied)') + } + } + + // 2. Intercept and shorten links in content before sending + if (wantsShorten) { + const rawMatches = content.match(URL_REGEX) ?? [] + const urls = [...new Set(rawMatches)].filter( + (u) => !isExcludedDomain(u, storage.host), + ) + + let shortenedCount = 0 + for (const url of urls) { + try { + const short = await shortenUrl(url, token, storage.host) + content = content.split(url).join(short) + shortenedCount++ + } catch (e: any) { + api.logger.error( + `${TAG} Failed to shorten ${url}: ${e?.message ?? e}`, + ) + } + } + + if (shortenedCount > 0) { + message.content = content + } + } + + return orig.apply(target, args) + }, + ) + cleanups.push(unpatch) + } catch (e) { + api.logger.error(`${TAG} Failed to patch sendMessage: ${e}`) + } + } + + // Hook MessageActions via module ID 7696 or filter lookup + try { + const mod = revenge.modules.metro.getInitializedModuleExports( + MESSAGE_ACTION_CREATORS_MODULE_ID, + ) + if (mod) patchMessageActions(mod) + } catch {} + + try { + const { filters, lookupModule, getModules } = revenge.modules.finders + const filter = filters.withProps('sendMessage', 'editMessage') + const matches = lookupModule(filter) + for (const m of matches || []) patchMessageActions(m) + const unsub = getModules(filter, (m) => patchMessageActions(m), { + returnNamespace: true, + }) + cleanups.push(() => unsub?.()) + } catch (e) { + api.logger.error(`${TAG} Error finding MessageActions: ${e}`) + } + + // Register Client Utils slash command if available + try { + const clientUtils = + (revenge as any)?.plugins?.clientUtils ?? + (globalThis as any).__c_utils + if (clientUtils?.registerCommand) { + clientUtils.registerCommand( + { + name: 'zipline', + displayName: 'zipline', + description: 'Shorten a URL with your configured Zipline instance', + options: [ + { + type: 3, // String + name: 'url', + displayName: 'url', + description: 'URL to shorten', + required: true, + }, + clientUtils.responseFormatOption, + { + type: 3, + name: 'ephemeral', + displayName: 'ephemeral', + description: 'Show response only to you (optional)', + required: false, + choices: [ + { name: 'True', displayName: 'True', value: 'true' }, + ], + }, + ], + execute: async (args: any, ctx: any) => { + const isEphemeral = + args.ephemeral === 'true' || args.ephemeral === 'yes' + const storage = api.jsonStorage.cache || DEFAULT_STORAGE + const token = storage.token?.trim() + + if (!token) { + return ctx.reply({ + ephemeral: true, + content: + '❌ No Zipline token configured in Zipline plugin settings.', + }) + } + + try { + const shortUrl = await shortenUrl( + args.url, + token, + storage.host, + ) + setClipboard(shortUrl) + + if (args.format === 'embed' && clientUtils.builders) { + const { EmbedBuilder } = clientUtils.builders + const embed = new EmbedBuilder() + .setTitle('Zipline Shortlink') + .setDescription(`[${shortUrl}](${shortUrl})`) + .setColor('#5865F2') + .addFields( + { name: 'Original', value: args.url, inline: false }, + { name: 'Shortened', value: shortUrl, inline: false }, + ) + .setFooter('Copied to clipboard!') + + return ctx.reply({ + ephemeral: isEphemeral, + embed, + }) + } + + ctx.reply({ + ephemeral: isEphemeral, + content: `🔗 Shortened link: ${shortUrl} *(copied to clipboard)*`, + }) + } catch (err: any) { + ctx.reply({ + ephemeral: true, + content: `❌ Zipline shorten error: ${err?.message || err}`, + }) + } + }, + }, + { + id: 'dev.everestmcarthur.zipline', + name: 'Zipline', + description: 'Zipline attachment and URL tools', + }, + ) + + cleanups.push(() => { + clientUtils.unregisterCommand('zipline') + }) + } + } catch (err) { + api.logger.warn(`${TAG} Could not register slash command: ${err}`) + } + + api.cleanup(() => { + for (const fn of cleanups) { + try { + fn() + } catch {} + } + }) + }, + + SettingsComponent: Settings, +}) diff --git a/plugins/zipline/js/lib/api.ts b/plugins/zipline/js/lib/api.ts new file mode 100644 index 0000000..a9187b1 --- /dev/null +++ b/plugins/zipline/js/lib/api.ts @@ -0,0 +1,131 @@ +const DEFAULT_HOST = 'i.allyapp.cc' +const UPLOAD_TIMEOUT_MS = 30000 + +export function formatHost(rawHost?: string): string { + const h = rawHost?.trim() + return (h || DEFAULT_HOST).replace(/^https?:\/\//, '').replace(/\/$/, '') +} + +export function getBaseUrl(rawHost?: string): string { + return `https://${formatHost(rawHost)}` +} + +export function isExcludedDomain(url: string, rawHost?: string): boolean { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return true + } + + const host = formatHost(rawHost) + 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 +} + +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 uploadFile( + fileUri: string, + name: string, + type: string, + token: string, + rawHost?: string, +): Promise { + if (!token?.trim()) throw new Error('No Zipline token configured') + + const form = new FormData() + form.append('file', { + uri: fileUri, + name: name || 'file.bin', + type: type || 'application/octet-stream', + } as any) + + const res = await withTimeout( + fetch(`${getBaseUrl(rawHost)}/api/upload`, { + method: 'POST', + headers: { authorization: token.trim() }, + 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, + token: string, + rawHost?: string, +): Promise { + if (!token?.trim()) throw new Error('No Zipline token configured') + + const res = await withTimeout( + fetch(`${getBaseUrl(rawHost)}/api/user/urls`, { + method: 'POST', + headers: { + authorization: token.trim(), + 'content-type': 'application/json', + }, + body: JSON.stringify({ destination }), + }), + 15000, + 'Shorten timed out', + ) + + 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/js/lib/types.ts b/plugins/zipline/js/lib/types.ts new file mode 100644 index 0000000..0c1ab51 --- /dev/null +++ b/plugins/zipline/js/lib/types.ts @@ -0,0 +1,13 @@ +export interface ZiplineStorage { + token?: string + host?: string + autoUpload: boolean + autoShorten: boolean +} + +export const DEFAULT_STORAGE: ZiplineStorage = { + token: '', + host: 'i.allyapp.cc', + autoUpload: true, + autoShorten: true, +} diff --git a/plugins/zipline/js/ui/Settings.tsx b/plugins/zipline/js/ui/Settings.tsx new file mode 100644 index 0000000..c5fab02 --- /dev/null +++ b/plugins/zipline/js/ui/Settings.tsx @@ -0,0 +1,75 @@ +import { Design } from '@revenge-mod/discord/design' +import { ScrollView, View } from 'react-native' +import type { PluginApi } from '@revenge-mod/plugins/types' +import type { ZiplineStorage } from '../lib/types' + +export default function Settings({ + api, +}: { + api: PluginApi<{ jsonStorage: ZiplineStorage }> +}) { + const { TableRowGroup, TableSwitchRow, TextInput, Text, Card, Stack } = Design + const storage = api.jsonStorage.use() + + return ( + + + + + + Zipline Integration + + + Upload attachments and shorten URLs through your self-hosted Zipline + instance. Requires an API token from your Zipline dashboard (Account + Settings → Token). + + + + + + api.jsonStorage.set({ host: v })} + /> + api.jsonStorage.set({ token: v })} + /> + + + + + api.jsonStorage.set({ autoUpload: v }) + } + /> + + api.jsonStorage.set({ autoShorten: v }) + } + /> + + + + ) +} diff --git a/plugins/zipline/manifest.json b/plugins/zipline/manifest.json new file mode 100644 index 0000000..e0992cb --- /dev/null +++ b/plugins/zipline/manifest.json @@ -0,0 +1,20 @@ +{ + "format": 1, + "id": "dev.everestmcarthur.zipline", + "name": "Zipline", + "description": "Uploads attachments and shortens links through a self-hosted Zipline instance instead of sending them natively.", + "author": "Rosie", + "icon": "ic_upload", + "version": "1.0.0", + "dependencies": { + "revenge.api": { + "version": ">=1 <2" + }, + "discord": { + "version": "*" + } + }, + "dist": { + "script": "index.js" + } +}