From 1f1075409f809fee2febcaaa2f57d24405cc7580 Mon Sep 17 00:00:00 2001 From: mxgic1337_ <60188749+mxgic1337@users.noreply.github.com> Date: Sat, 21 Oct 2023 21:00:37 +0200 Subject: [PATCH] Renamed to Emote Cloner + added BetterTTV support --- {gh_assets => .github}/example1.gif | Bin README.md | 29 ++++---- bot.ts | 4 +- commands/commands.ts | 5 +- commands/impl/EmoteCommand.ts | 105 +++++++++++++++++++++------- example.env | 6 +- package.json | 25 +++++-- util/BetterTTVUtil.ts | 37 ++++++++++ util/SevenTVUtil.ts | 24 ++++--- util/URLUtil.ts | 37 ++++++++++ 10 files changed, 213 insertions(+), 59 deletions(-) rename {gh_assets => .github}/example1.gif (100%) create mode 100644 util/BetterTTVUtil.ts create mode 100644 util/URLUtil.ts diff --git a/gh_assets/example1.gif b/.github/example1.gif similarity index 100% rename from gh_assets/example1.gif rename to .github/example1.gif diff --git a/README.md b/README.md index c0076f7..9951cea 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,22 @@ -# 7TV Importer -**7TV Importer (Unofficial)** is a Discord bot that can import emotes from 7TV into Discord. +# Twitch Cloner +**Twitch Cloner** is a Discord bot that clones emotes from BetterTTV and 7TV into Discord. -7TV Importer is **not affiliated** with 7TV. +Twitch Cloner is **not affiliated** with Twitch, BetterTTV, FrankerFaceZ or 7TV. -[Add 7TV Importer to your server](https://discord.com/api/oauth2/authorize?client_id=1163079809719611413&permissions=8799314249792&scope=bot) +## 🔗 Links +- [🤖 Invite Link](https://discord.com/api/oauth2/authorize?client_id=1163079809719611413&permissions=8799314249792&scope=bot) -## Usage -To add an emote, use the **/emote** command. +## ✨ Features +- Cloning any emote from [BetterTTV](https://betterttv.com). +- Cloning any emote from [7TV](https://7tv.app). -![/emote Usage example](./gh_assets/example1.gif) +## ⚙️ Commands +- 🔧 **/emote** - Clones any emote from BTTV/7TV. + - 🔒 This command requires **Manage Expressions** permission. + - ⚙️ Parameters: + - 🔗 **url** (Required) - Emote URL (Supported platforms: BTTV, 7TV) + - 🔼 **size** - Emote size - 1x, 2x, 4x (3x for BTTV) + - 📝 **name** - Override default emote name + - 🛑 **disable_animations** - If animated, emote will be uploaded as static image -Command options: -- url (Required) - Emote URL -- size - Emote size (1x, 2x or 4x) -- name - Overwrite emote name -- disable_animations - If the emote is animated, it will be uploaded as non-animated image \ No newline at end of file +![/emote Usage example](.github/example1.gif) \ No newline at end of file diff --git a/bot.ts b/bot.ts index 1f58d1f..eba156d 100644 --- a/bot.ts +++ b/bot.ts @@ -5,8 +5,8 @@ import {EmoteCommand} from "./commands/impl/EmoteCommand"; dotenv.config() -export const clientId = '' + process.env.CLIENT_ID; -export const token: string = '' + process.env.TOKEN +export const clientId = '' + (process.env.DEV_MODE === "true" ? process.env.DEV_CLIENT_ID : process.env.CLIENT_ID); +export const token: string = '' + (process.env.DEV_MODE === "true" ? process.env.DEV_TOKEN : process.env.TOKEN) const client = new Client({ intents: [GatewayIntentBits.Guilds] }); client.on('ready', (c)=>{ diff --git a/commands/commands.ts b/commands/commands.ts index 0531968..35cc138 100644 --- a/commands/commands.ts +++ b/commands/commands.ts @@ -1,5 +1,4 @@ import { - ApplicationCommandOptionType, ChatInputCommandInteraction, PermissionFlagsBits, REST, @@ -12,7 +11,7 @@ import {clientId, token} from "../bot"; const commandData = [ new SlashCommandBuilder() .setName("emote") - .setDescription("Adds an 7TV emote.") + .setDescription("Adds an BetterTTV/7TV emote.") .addStringOption(option => option.setName('url') .setDescription("Emote URL") @@ -23,7 +22,7 @@ const commandData = [ .addChoices( {name: '1x', value: '1x'}, {name: '2x', value: '2x'}, - {name: '4x', value: '4x'}, + {name: '4x (3x on BTTV)', value: '4x'}, )) .addStringOption(option => option.setName('name') diff --git a/commands/impl/EmoteCommand.ts b/commands/impl/EmoteCommand.ts index e3e8087..218fb3b 100644 --- a/commands/impl/EmoteCommand.ts +++ b/commands/impl/EmoteCommand.ts @@ -1,6 +1,8 @@ import {Command} from "../commands"; import {ChatInputCommandInteraction, EmbedBuilder} from "discord.js"; -import {getEmoteDataFromURL, isURLValid} from "../../util/SevenTVUtil"; +import {Emote, getEmoteDataFromURL as getBTTVEmote} from "../../util/BetterTTVUtil"; +import {getEmoteDataFromURL as get7TVEmote} from "../../util/SevenTVUtil"; +import {isEmoteURL} from "../../util/URLUtil"; export class EmoteCommand extends Command { constructor() { @@ -11,15 +13,50 @@ export class EmoteCommand extends Command { const disableAnimationsOption = interaction.options.get('disable_animations'); if (urlOption === null) return; const emoteURL: string = urlOption.value as string - if (isURLValid(emoteURL, 'emotes')) { - const emote = await getEmoteDataFromURL(emoteURL); - const name = nameOption !== null ? nameOption.value : emote.name + const platform = isEmoteURL(emoteURL); + if (platform !== undefined) { + let emote: Emote | undefined; + switch (platform) { + case '7tv': + emote = await get7TVEmote(emoteURL) + break + case 'bttv': + emote = await getBTTVEmote(emoteURL) + break + default: + emote = undefined + } + if (emote === undefined) return; + const name: string = nameOption !== null ? nameOption.value as string : emote.name const disableAnimations = disableAnimationsOption !== null ? disableAnimationsOption.value as boolean : false + + const animatedURL = emote.hostURL.replace('{{size}}', sizeOption !== null ? platform === 'bttv' && sizeOption.value === '4x' ? '3x' : '4x' : '1x') + '.gif'; + const animatedFullURL = emote.hostURL.replace('{{size}}', platform === 'bttv' ? '3x' : '4x') + '.gif'; + const staticURL = emote.hostURL.replace('{{size}}', sizeOption !== null ? platform === 'bttv' && sizeOption.value === '4x' ? '3x' : '4x' : platform === 'bttv' ? '3x' : '4x') + '.webp'; + const staticFullURL = emote.hostURL.replace('{{size}}', platform === 'bttv' ? '3x' : '4x') + '.webp'; + + let platformIcon: string; + let platformText: string; + switch (emote.platform) { + case "bttv": + platformIcon = '<:BetterTTV:1165355805487399097>'; + platformText = 'BetterTTV' + break; + case "ffz": + platformIcon = '<:FrankerFaceZ:1165355987096580097>'; + platformText = 'FrankerFaceZ' + break; + case "7tv": + platformIcon = '<:7TV:1165355988841402458>'; + platformText = '7TV' + break; + } + const embed = new EmbedBuilder() - .setTitle(`${emote.name} by ${emote.owner.display_name}`) + .setTitle(`${emote.name} by ${emote.author.name}`) .setAuthor({ - name: emote.owner.display_name, - iconURL: 'https:' + emote.owner.avatar_url + name: emote.author.name, + iconURL: emote.author.avatar }) .setTimestamp() .setFooter({ @@ -41,7 +78,7 @@ export class EmoteCommand extends Command { }, { name: 'Emote Author', - value: emote.owner.display_name, + value: emote.author.name, inline: true, }, { @@ -49,20 +86,29 @@ export class EmoteCommand extends Command { value: emote.animated ? `Yes${disableAnimations ? ' (Disabled)' : ''}` : 'No', inline: true, }, + { + name: 'Platform', + value: `${platformIcon} ${platformText}`, + inline: true, + }, ]) - .setThumbnail(emote.animated ? 'https:' + emote.host.url + `/4x.gif` : 'https:' + emote.host.url + `/4x.webp`) - interaction.reply({embeds: [embed]}).then(()=>{ + .setThumbnail(emote.animated ? emote.hostURL.replace('{{size}}', sizeOption !== null ? platform === "bttv" && (sizeOption.value as string) === "4x" ? "3x" : sizeOption.value as string : '4x.gif') : emote.hostURL.replace('{{size}}', sizeOption !== null ? platform === "bttv" && (sizeOption.value as string) === "4x" ? "3x" : sizeOption.value as string : platform === 'bttv' ? '3x.webp' : '4x.webp')) + interaction.reply({embeds: [embed]}).then(() => { if (!interaction.guild) return; + if (emote === undefined) return; interaction.guild.emojis.create({ - attachment: emote.animated && !disableAnimations ? 'https:' + emote.host.url + `/${sizeOption !== null ? sizeOption.value : '1x'}.gif` : 'https:' + emote.host.url + `/${sizeOption !== null ? sizeOption.value : '4x'}.webp`, - name: name + attachment: emote.animated && !disableAnimations ? + animatedURL : + staticURL, + name: name }).then(emoji => { console.error(`Uploaded emote in guild ${interaction.guildId}.`) + if (emote === undefined) return; const embed = new EmbedBuilder() - .setTitle(`${emote.name} by ${emote.owner.display_name}`) + .setTitle(`${emote.name} by ${emote.author.name}`) .setAuthor({ - name: emote.owner.display_name, - iconURL: 'https:' + emote.owner.avatar_url + name: emote.author.name, + iconURL: emote.author.avatar }) .setFooter({ text: 'Added by ' + interaction.user.username, @@ -70,7 +116,7 @@ export class EmoteCommand extends Command { }) .setTimestamp() .setColor('#00ff59') - .setDescription(`Successfully added <${emoji.animated ? 'a' : ''}:${emoji.name}:${emoji.id}> **${emote.name}${name !== emote.name ? ` (${name})` : ''}** emote to Discord\nSelected size: \`${sizeOption !== null ? sizeOption.value : emote.animated && !disableAnimations? '1x (Default)' : '4x (Default)'}\``) + .setDescription(`Successfully added <${emoji.animated ? 'a' : ''}:${emoji.name}:${emoji.id}> **${emote.name}${name !== emote.name ? ` (${name})` : ''}** emote to Discord\nSelected size: \`${sizeOption !== null ? sizeOption.value : emote.animated && !disableAnimations ? '1x (Default)' : '4x (Default)'}\``) .setFields([ { name: 'State', @@ -84,7 +130,7 @@ export class EmoteCommand extends Command { }, { name: 'Emote Author', - value: emote.owner.display_name, + value: emote.author.name, inline: true, }, { @@ -92,16 +138,22 @@ export class EmoteCommand extends Command { value: emote.animated ? `Yes${disableAnimations ? ' (Disabled)' : ''}` : 'No', inline: true, }, + { + name: 'Platform', + value: `${platformIcon} ${platformText}`, + inline: true, + }, ]) - .setThumbnail(emote.animated ? 'https:' + emote.host.url + '/4x.gif' : 'https:' + emote.host.url + '/4x.webp') + .setThumbnail(emote.animated ? animatedFullURL : staticFullURL) interaction.editReply({embeds: [embed]}) }).catch(err => { console.error(`Emote upload in guild ${interaction.guildId} failed: ${err.message}`) + if (emote === undefined) return; const embed = new EmbedBuilder() - .setTitle(`${emote.name} by ${emote.owner.display_name}`) + .setTitle(`${emote.name} by ${emote.author.name}`) .setAuthor({ - name: emote.owner.display_name, - iconURL: 'https:' + emote.owner.avatar_url + name: emote.author.name, + iconURL: emote.author.avatar }) .setDescription(`Selected size: \`${sizeOption !== null ? sizeOption.value : emote.animated && !disableAnimations ? '1x (Default)' : '4x (Default)'}\``) .setTimestamp() @@ -128,7 +180,7 @@ export class EmoteCommand extends Command { }, { name: 'Emote Author', - value: emote.owner.display_name, + value: emote.author.name, inline: true, }, { @@ -136,8 +188,13 @@ export class EmoteCommand extends Command { value: emote.animated ? `Yes${disableAnimations ? ' (Disabled)' : ''}` : 'No', inline: true, }, + { + name: 'Platform', + value: `${platformIcon} ${platformText}`, + inline: true, + }, ]) - .setThumbnail(emote.animated ? 'https:' + emote.host.url + '/4x.gif' : 'https:' + emote.host.url + '/4x.webp') + .setThumbnail(emote.animated ? animatedFullURL : staticFullURL) interaction.editReply({embeds: [embed]}) }) }) @@ -151,7 +208,7 @@ export class EmoteCommand extends Command { }) .setTimestamp() .setColor('#ff2020') - .setDescription("`❌` Invalid 7TV emote URL.") + .setDescription("`❌` Invalid emote URL.\nCurrently supported platforms: `BetterTTV, 7TV`") console.log(interaction.user) interaction.reply({embeds: [embed]}).then() } diff --git a/example.env b/example.env index c9e825f..94bbc9b 100644 --- a/example.env +++ b/example.env @@ -1,2 +1,6 @@ CLIENT_ID= -TOKEN= \ No newline at end of file +TOKEN= + +DEV_MODE=false +DEV_CLIENT_ID= +DEV_TOKEN= \ No newline at end of file diff --git a/package.json b/package.json index e73b646..4406034 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,29 @@ { - "name": "7tv-importer", - "version": "1.0.1", - "description": "Discord bot that imports 7TV emotes.", - "main": "index.js", + "name": "emote-cloner", + "version": "1.1.0", + "description": "Discord bot that clones BTTV/7TV emotes.", + "main": "dist/bot.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "dev": "ts-node-dev bot.ts", + "start": "node dist/bot.js", + "build": "tsc" }, + "homepage": "https://github.com/mxgic1337/emote-cloner", "keywords": [], "author": "mxgic1337_", - "license": "ISC", + "bugs": { + "url": "https://github.com/mxgic1337/emote-cloner/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/mxgic1337/emote-cloner" + }, + "license": "GPL-3.0", "dependencies": { "discord.js": "^14.13.0", "dotenv": "^16.3.1" + }, + "devDependencies": { + "ts-node-dev": "^2.0.0" } } diff --git a/util/BetterTTVUtil.ts b/util/BetterTTVUtil.ts new file mode 100644 index 0000000..30764f4 --- /dev/null +++ b/util/BetterTTVUtil.ts @@ -0,0 +1,37 @@ + + +export async function getEmoteDataFromURL(url: string) { + return await getEmoteData(url.split('/')[4].split('/')[0]) +} + +export async function getEmoteData(id: string) { + const response = await fetch('https://api.betterttv.net/3/emotes/' + id) + if (response.ok) { + const emote = await response.json(); + return { + platform: 'bttv', + name: emote.code, + hostURL: 'https://cdn.betterttv.net/emote/'+emote.id+'/{{size}}', + animated: emote.animated, + author: { + name: emote.user.displayName, + avatar: 'https://pbs.twimg.com/profile_images/1615415316657364994/r3yTCKWx_400x400.jpg', + } + } as Emote + }else{ + const text = await response.text() + console.error(text) + return undefined + } +} + +export type Emote = { + platform: 'bttv' | 'ffz' | '7tv', + name: string, + hostURL: string, + animated: boolean, + author: { + name: string, + avatar: string + } +} \ No newline at end of file diff --git a/util/SevenTVUtil.ts b/util/SevenTVUtil.ts index d3ec822..ffd1f96 100644 --- a/util/SevenTVUtil.ts +++ b/util/SevenTVUtil.ts @@ -1,13 +1,5 @@ -export function isURLValid(url: string, urlType: 'emotes' | 'users' | 'emote-sets') { - switch (urlType) { - default: - return /^((http|https):\/\/)(www.|)7tv.app\/emotes\/[a-zA-Z0-9]{24}/gi.test(url) - case 'users': - return /^((http|https):\/\/)(www.|)7tv.app\/users\/[a-zA-Z0-9]{24}/gi.test(url) - case 'emote-sets': - return /^((http|https):\/\/)(www.|)7tv.app\/emote-sets\/[a-zA-Z0-9]{24}/gi.test(url) - } -} +import {Emote} from "./BetterTTVUtil"; + export async function getEmoteDataFromURL(url: string) { return await getEmoteData(url.split('/')[4].split('/')[0]) @@ -16,7 +8,17 @@ export async function getEmoteDataFromURL(url: string) { export async function getEmoteData(id: string) { const response = await fetch('https://7tv.io/v3/emotes/' + id) if (response.ok) { - return await response.json() + const emote = await response.json(); + return { + platform: '7tv', + name: emote.name, + hostURL: 'https:' + emote.host.url + '/{{size}}', + animated: emote.animated, + author: { + name: emote.owner.display_name, + avatar: 'https:' + emote.owner.avatar_url, + } + } as Emote }else{ const text = await response.text() console.error(text) diff --git a/util/URLUtil.ts b/util/URLUtil.ts new file mode 100644 index 0000000..b06476f --- /dev/null +++ b/util/URLUtil.ts @@ -0,0 +1,37 @@ +/** Checks if provided URL points to BetterTTV */ +export function isBTTVURLValid(url: string, urlType: 'emotes' | 'users') { + switch (urlType) { + default: + return /^((http|https):\/\/)(www.|)betterttv.com\/emotes\/[a-zA-Z0-9]{24}/gi.test(url) + case 'users': + return /^((http|https):\/\/)(www.|)betterttv.com\/users\/[a-zA-Z0-9]{24}/gi.test(url) + } +} + +/** Checks if provided URL points to FrankerFaceZ */ +export function isFFZURLValid(url: string) { + return /^((http|https):\/\/)(www.|)frankerfacez.com\/emoticon\/*/gi.test(url) +} + +/** Checks if provided URL points to 7TV */ +export function is7TVURLValid(url: string, urlType: 'emotes' | 'users' | 'emote-sets') { + switch (urlType) { + default: + return /^((http|https):\/\/)(www.|)7tv.app\/emotes\/[a-zA-Z0-9]{24}/gi.test(url) + case 'users': + return /^((http|https):\/\/)(www.|)7tv.app\/users\/[a-zA-Z0-9]{24}/gi.test(url) + case 'emote-sets': + return /^((http|https):\/\/)(www.|)7tv.app\/emote-sets\/[a-zA-Z0-9]{24}/gi.test(url) + } +} + +/** + * Checks if provided URL points to any emote provider + * @returns platform Platform ID (bttv, ffz, 7tv or undefined) + */ +export function isEmoteURL(url: string) { + if (is7TVURLValid(url, 'emotes')) return '7tv' + // else if (isFFZURLValid(url)) return 'ffz' + else if (isBTTVURLValid(url, 'emotes')) return 'bttv' + else return undefined +} \ No newline at end of file