feat: add Zipline plugin with pre-send interception
Some checks failed
Build and deploy / deploy (push) Failing after 4s

This commit is contained in:
Rosie Val 2026-09-20 21:26:19 -04:00
parent 077151f429
commit eef6637715
6 changed files with 536 additions and 1 deletions

View file

@ -1 +0,0 @@
More Plugins Coming Soon

297
plugins/zipline/js/index.ts Normal file
View file

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

View file

@ -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<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 uploadFile(
fileUri: string,
name: string,
type: string,
token: string,
rawHost?: string,
): Promise<UploadedFile> {
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<string> {
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
}

View file

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

View file

@ -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 (
<ScrollView
style={{ flex: 1, backgroundColor: '#1E1F22' }}
contentContainerStyle={{ padding: 16, paddingBottom: 60 }}
keyboardShouldPersistTaps="handled"
>
<Stack spacing={16}>
<Card>
<View style={{ padding: 16 }}>
<Text variant="heading-md/semibold" style={{ color: '#FFFFFF' }}>
Zipline Integration
</Text>
<Text
variant="text-sm/normal"
color="text-muted"
style={{ marginTop: 6 }}
>
Upload attachments and shorten URLs through your self-hosted Zipline
instance. Requires an API token from your Zipline dashboard (Account
Settings → Token).
</Text>
</View>
</Card>
<TableRowGroup title="SERVER CONFIGURATION">
<TextInput
label="Zipline Host"
placeholder="i.allyapp.cc"
value={storage?.host ?? ''}
onChange={(v: string) => api.jsonStorage.set({ host: v })}
/>
<TextInput
label="API Token"
placeholder="Paste your Zipline token here"
value={storage?.token ?? ''}
secureTextEntry
onChange={(v: string) => api.jsonStorage.set({ token: v })}
/>
</TableRowGroup>
<TableRowGroup title="BEHAVIOR">
<TableSwitchRow
label="Auto-upload attachments"
subLabel="Intercepts attachments when sending, uploads directly to Zipline, replaces them with the Zipline link, and copies the link to clipboard."
value={storage?.autoUpload !== false}
onValueChange={(v: boolean) =>
api.jsonStorage.set({ autoUpload: v })
}
/>
<TableSwitchRow
label="Auto-shorten links"
subLabel="Intercepts messages with links, automatically shortens them via your Zipline instance before sending."
value={storage?.autoShorten !== false}
onValueChange={(v: boolean) =>
api.jsonStorage.set({ autoShorten: v })
}
/>
</TableRowGroup>
</Stack>
</ScrollView>
)
}

View file

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