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