diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cfc38023..380bb6ce 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -86,3 +86,4 @@ jobs: with: name: ${{ github.sha }} path: dist/ + include-hidden-files: true diff --git a/apps/supreme-discord-community-bot-node/.env.sample b/apps/supreme-discord-community-bot-node/.env.sample index 99234960..afbad156 100644 --- a/apps/supreme-discord-community-bot-node/.env.sample +++ b/apps/supreme-discord-community-bot-node/.env.sample @@ -14,4 +14,13 @@ DISCORD_WELCOME_CHANNEL_NAME= # # https://discordjs.guide/creating-your-bot/command-deployment.html#command-registration # -DISCORD_REGISTER_SLASH_COMMANDS= \ No newline at end of file +DISCORD_REGISTER_SLASH_COMMANDS= + +PATREON_CAMPAIGN_ID= +PATREON_CLIENT_ID= +PATREON_CLIENT_SECRET= +PATREON_ACCESS_TOKEN= +PATREON_REFRESH_TOKEN= + +# Example: {"Supporter":[],"Backer":[]}. Where the key is the tier name on Patreon and the value is an array of Discord role IDs. +PATREON_TIERS_DISCORD_ROLES= \ No newline at end of file diff --git a/apps/supreme-discord-community-bot-node/src/main.ts b/apps/supreme-discord-community-bot-node/src/main.ts index ad34b2c4..4f80a207 100644 --- a/apps/supreme-discord-community-bot-node/src/main.ts +++ b/apps/supreme-discord-community-bot-node/src/main.ts @@ -7,6 +7,7 @@ import { FunDiscordModule, GeneralHelpDiscordModule, NewMemberDiscordModule, + PatreonReportDiscordModule, RoleAssignmentDiscordModule, } from '@supremegaming/discord/community'; import { GatewayIntentBits, Partials } from 'discord.js'; @@ -54,6 +55,7 @@ new DiscordClientBootstrapper({ GeneralHelpDiscordModule, NewMemberDiscordModule, RoleAssignmentDiscordModule, + PatreonReportDiscordModule, ], options: { clientToken: process.env.DISCORD_API_TOKEN, diff --git a/libs/discord/bootstrap/src/lib/bootstrapper/discord-bootstrap.ts b/libs/discord/bootstrap/src/lib/bootstrapper/discord-bootstrap.ts index 9eb0889a..f57f730c 100644 --- a/libs/discord/bootstrap/src/lib/bootstrapper/discord-bootstrap.ts +++ b/libs/discord/bootstrap/src/lib/bootstrapper/discord-bootstrap.ts @@ -25,7 +25,8 @@ export class DiscordClientBootstrapper { const slashCommands: SlashCommandTypes = []; if (options.modules.length > 0) { - options.modules.forEach((dm, index, arr) => { + for (let i = 0; i < options.modules.length; i++) { + const dm = options.modules[i]; const moduleInstance: DiscordFeatureModule = new dm(); if (moduleInstance.commands) { @@ -64,8 +65,10 @@ export class DiscordClientBootstrapper { this._onMessageDelete(moduleInstance.clientOnMessageDelete, moduleInstance); } + console.log(`Loaded ${moduleInstance.constructor.name}.`); + // Register slash commands after all module listeners have been initialized. - if (slashCommands.length > 0 && index === arr.length - 1) { + if (slashCommands.length > 0 && i === options.modules.length - 1) { if (process.env.DISCORD_REGISTER_SLASH_COMMANDS === 'false') { console.warn('Modules have configured slash commands but slash command registration is disabled.'); } else if ( @@ -89,11 +92,7 @@ export class DiscordClientBootstrapper { } } } - - console.log(`Loaded ${moduleInstance.constructor.name}.`); - - return this; - }); + } } // Call the initial onReady handler to cache guild members @@ -221,7 +220,7 @@ export class DiscordClientBootstrapper { } console.log('Successfully refreshed application (/) commands.'); - } catch (err: any) { + } catch (err) { console.error(err.message); } } diff --git a/libs/discord/community/src/index.ts b/libs/discord/community/src/index.ts index ccb4a3a9..3ede1146 100644 --- a/libs/discord/community/src/index.ts +++ b/libs/discord/community/src/index.ts @@ -8,3 +8,4 @@ export * from './lib/support/donate/donate-info.module'; export * from './lib/support/help/general-help.module'; export * from './lib/support/membership/new-member.module'; export * from './lib/support/roles/role-assignment.module'; +export * from './lib/support/patreon/patreon-reporter.module'; diff --git a/libs/discord/community/src/lib/miscellaneous/fun/fun.module.ts b/libs/discord/community/src/lib/miscellaneous/fun/fun.module.ts index 8e96c30c..19e8ce6c 100644 --- a/libs/discord/community/src/lib/miscellaneous/fun/fun.module.ts +++ b/libs/discord/community/src/lib/miscellaneous/fun/fun.module.ts @@ -46,7 +46,7 @@ export class FunDiscordModule implements SlashCommands, OnInteractionCreate { break; case 'wrongDiscord': interaction.reply({ - files: ['https://cdn.discordapp.com/attachments/262744296875229185/1092881251775545474/SupremeARKmod.png'], + files: ['http://static.supremegaming.gg/images/misc/SupremeARKmod.png'], }); break; default: diff --git a/libs/discord/community/src/lib/support/patreon/patreon-reporter.module.ts b/libs/discord/community/src/lib/support/patreon/patreon-reporter.module.ts new file mode 100644 index 00000000..bfe04d8d --- /dev/null +++ b/libs/discord/community/src/lib/support/patreon/patreon-reporter.module.ts @@ -0,0 +1,215 @@ +import { CacheType, CommandInteractionOptionResolver, EmbedBuilder, Interaction, PermissionFlagsBits } from 'discord.js'; +import { SlashCommandBuilder } from '@discordjs/builders'; + +import { PatreonCreatorClient, QueryBuilder, Tier } from 'patreon-api.ts'; + +import { OnInteractionCreate, SlashCommands, SlashCommandTypes } from '@supremegaming/discord/bootstrap'; + +export class PatreonReportDiscordModule implements SlashCommands, OnInteractionCreate { + public patreonClient = new PatreonCreatorClient({ + oauth: { + clientId: process.env.PATREON_CLIENT_ID, + clientSecret: process.env.PATREON_CLIENT_SECRET, + token: { + access_token: process.env.PATREON_ACCESS_TOKEN, + refresh_token: process.env.PATREON_REFRESH_TOKEN, + }, + }, + }); + + public commands(): SlashCommandTypes { + return [ + new SlashCommandBuilder() + .setName('patreon') + .setDescription('Patreon community integration - Admins only') + .addSubcommandGroup((group) => + group + .setName('list') + .setDescription('List patrons based on criteria') + .addSubcommand((subcommand) => + subcommand + .setName('active') + .setDescription('List active patrons') + .addBooleanOption((option) => + option.setName('ephemeral').setDescription('Send the message as ephemeral').setRequired(false) + ) + ) + ) + .addSubcommandGroup((group) => + group + .setName('audit') + .setDescription('Audit Patron by ID') + .addSubcommand((subcommand) => + subcommand + .setName('patron') + .setDescription('Get information about a patron by their ID') + .addStringOption((subcommand) => + subcommand + .setName('patron_id') + .setDescription('Get information about a patron by their ID') + .setRequired(true) + ) + .addBooleanOption((option) => + option.setName('ephemeral').setDescription('Send the message as ephemeral').setRequired(false) + ) + ) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), + ]; + } + + public async clientOnInteractionCreate(interaction: Interaction): Promise { + if (interaction.isCommand() && interaction.commandName === 'patreon') { + const subCommandGroup = (interaction.options as CommandInteractionOptionResolver).getSubcommandGroup(); + const subCommand = (interaction.options as CommandInteractionOptionResolver).getSubcommand(); + + const shouldBePersistent = + interaction.options.get('ephemeral')?.value !== undefined && + (interaction.options as CommandInteractionOptionResolver).getBoolean('ephemeral') === false; + + await interaction.deferReply({ ephemeral: !shouldBePersistent }); + + if (subCommandGroup === 'list') { + let definedTiers = process.env.PATREON_TIERS_DISCORD_ROLES; + + if (!definedTiers) { + await interaction.editReply({ + content: 'No defined tiers found. Please define application discord tiers and roles.', + }); + + return; + } else { + try { + definedTiers = JSON.parse(definedTiers); + } catch (err) { + await interaction.editReply({ + content: 'An error occurred while parsing the defined tiers. Please check the configuration.', + }); + + return; + } + } + + const memberQuery = QueryBuilder.campaignMembers + .addRelationships(['currently_entitled_tiers', 'user']) + .setAttributes({ + member: [ + 'full_name', + 'last_charge_date', + 'last_charge_status', + 'campaign_lifetime_support_cents', + 'currently_entitled_amount_cents', + 'patron_status', + ], + tier: ['amount_cents', 'description', 'discord_role_ids', 'title'], + user: ['created', 'social_connections'], + }) + .setRequestOptions({ + count: 1000, + }); + + try { + const members = await this.patreonClient.fetchCampaignMembers(process.env.PATREON_CAMPAIGN_ID, memberQuery); + + const mappedTiers: Record< + string, + Pick + > = members.included + .filter((tier) => tier.type === 'tier') + .reduce((acc, curr) => { + acc[curr.id] = curr.attributes; + return acc; + }, {}); + + const mappedUserSocialConnections = members.included + .filter((user) => user.type === 'user') + .reduce((acc, curr) => { + acc[curr.id] = curr.attributes['social_connections']; + return acc; + }, {}); + + const activeMembers = members.data + .filter((member) => member.attributes.patron_status === 'active_patron') + .map((member) => { + const tier = mappedTiers[member.relationships.currently_entitled_tiers.data[0].id]; + + // Find the mapped tier with the highest + const earnedTier = Object.values(mappedTiers).reduce((acc, currentMax) => { + if (acc === null) { + return currentMax; + } + + if ( + currentMax.amount_cents >= acc.amount_cents && + member.attributes.campaign_lifetime_support_cents >= currentMax.amount_cents + ) { + return currentMax; + } + + return acc; + }, null); + + const userSocialConnections = mappedUserSocialConnections[member.relationships.user.data.id]; + + return { + user: member.attributes.full_name, + tier: tier.title, + earnedTier: earnedTier ? earnedTier?.title : null, + amount: tier.amount_cents / 100, + lifetimeSupport: member.attributes.campaign_lifetime_support_cents / 100, + lastChargeDate: member.attributes.last_charge_date, + lastChargeStatus: member.attributes.last_charge_status, + discordId: userSocialConnections.discord ? userSocialConnections.discord.user_id : null, + }; + }); + + // Group active members into buckets of 24 + const activeMembersChunks = activeMembers.reduce((acc, curr, index) => { + const chunkIndex = Math.floor(index / 24); + if (!acc[chunkIndex]) { + acc[chunkIndex] = []; + } + acc[chunkIndex].push(curr); + return acc; + }, []); + + // One embed per chunk + const embeds = activeMembersChunks.map((chunk, index) => { + const embed = new EmbedBuilder({ + title: 'Active Patrons', + description: `Total Active Patrons: ${activeMembers.length}`, + color: 0x00ae86, + footer: { + text: `Page ${index + 1} of ${activeMembersChunks.length}`, + }, + fields: chunk.map((member) => ({ + name: member.user, + value: `Sub: ${member.tier}\nEarned: ${member.earnedTier}\nAmount: $${member.amount.toFixed( + 2 + )}\nLifetime: $${member.lifetimeSupport.toFixed(2)}\nLCD: \nDiscord ID: ${member.discordId !== null ? `<@${member.discordId}>` : 'N/A'}`, + })), + }); + + return embed; + }); + + await interaction.editReply({ + embeds, + }); + } catch (err) { + console.error(err); + + await interaction.editReply({ + content: 'An error occurred while fetching the list of active patrons.', + }); + } + } else if (subCommandGroup === 'audit') { + await interaction.editReply({ + content: 'Audit patron by ID feature is not yet implemented.', + }); + } + } + } +} diff --git a/package-lock.json b/package-lock.json index 9aa2d701..618f38b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "passport": "^0.4.1", "passport-jwt": "^4.0.0", "passport-steam": "^1.0.15", + "patreon-api.ts": "^0.11.0", "public-ip": "^4.0.4", "reflect-metadata": "^0.1.13", "rxjs": "~7.5.0", @@ -18437,6 +18438,18 @@ "node": ">=8" } }, + "node_modules/patreon-api.ts": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/patreon-api.ts/-/patreon-api.ts-0.11.0.tgz", + "integrity": "sha512-JI+SxBWp4J8L8HfciUWFItmJ1DJBZiaOoMzYVPur+qpwxGp+we5Si1CRr8e3Ouc+MJ0EUhhCsWk9ccmz8R2vUQ==", + "license": "MIT", + "engines": { + "node": ">=18.17" + }, + "funding": { + "url": "https://paypal.me/05ghostrider" + } + }, "node_modules/pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", @@ -36401,6 +36414,11 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==" }, + "patreon-api.ts": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/patreon-api.ts/-/patreon-api.ts-0.11.0.tgz", + "integrity": "sha512-JI+SxBWp4J8L8HfciUWFItmJ1DJBZiaOoMzYVPur+qpwxGp+we5Si1CRr8e3Ouc+MJ0EUhhCsWk9ccmz8R2vUQ==" + }, "pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", diff --git a/package.json b/package.json index c13ab71f..cf3c54dc 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "passport": "^0.4.1", "passport-jwt": "^4.0.0", "passport-steam": "^1.0.15", + "patreon-api.ts": "^0.11.0", "public-ip": "^4.0.4", "reflect-metadata": "^0.1.13", "rxjs": "~7.5.0",