feat: add Zipline plugin with pre-send interception
Some checks failed
Build and deploy / deploy (push) Failing after 4s
Some checks failed
Build and deploy / deploy (push) Failing after 4s
This commit is contained in:
parent
077151f429
commit
eef6637715
6 changed files with 536 additions and 1 deletions
|
|
@ -1 +0,0 @@
|
||||||
More Plugins Coming Soon
|
|
||||||
297
plugins/zipline/js/index.ts
Normal file
297
plugins/zipline/js/index.ts
Normal 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,
|
||||||
|
})
|
||||||
131
plugins/zipline/js/lib/api.ts
Normal file
131
plugins/zipline/js/lib/api.ts
Normal 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
|
||||||
|
}
|
||||||
13
plugins/zipline/js/lib/types.ts
Normal file
13
plugins/zipline/js/lib/types.ts
Normal 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,
|
||||||
|
}
|
||||||
75
plugins/zipline/js/ui/Settings.tsx
Normal file
75
plugins/zipline/js/ui/Settings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
plugins/zipline/manifest.json
Normal file
20
plugins/zipline/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue