Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,4 @@ jobs:
with:
name: ${{ github.sha }}
path: dist/
include-hidden-files: true
11 changes: 10 additions & 1 deletion apps/supreme-discord-community-bot-node/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,13 @@ DISCORD_WELCOME_CHANNEL_NAME=
#
# https://discordjs.guide/creating-your-bot/command-deployment.html#command-registration
#
DISCORD_REGISTER_SLASH_COMMANDS=
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=
2 changes: 2 additions & 0 deletions apps/supreme-discord-community-bot-node/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
FunDiscordModule,
GeneralHelpDiscordModule,
NewMemberDiscordModule,
PatreonReportDiscordModule,
RoleAssignmentDiscordModule,
} from '@supremegaming/discord/community';
import { GatewayIntentBits, Partials } from 'discord.js';
Expand Down Expand Up @@ -54,6 +55,7 @@ new DiscordClientBootstrapper({
GeneralHelpDiscordModule,
NewMemberDiscordModule,
RoleAssignmentDiscordModule,
PatreonReportDiscordModule,
],
options: {
clientToken: process.env.DISCORD_API_TOKEN,
Expand Down
15 changes: 7 additions & 8 deletions libs/discord/bootstrap/src/lib/bootstrapper/discord-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
Expand All @@ -89,11 +92,7 @@ export class DiscordClientBootstrapper {
}
}
}

console.log(`Loaded ${moduleInstance.constructor.name}.`);

return this;
});
}
}

// Call the initial onReady handler to cache guild members
Expand Down Expand Up @@ -221,7 +220,7 @@ export class DiscordClientBootstrapper {
}

console.log('Successfully refreshed application (/) commands.');
} catch (err: any) {
} catch (err) {
console.error(err.message);
}
}
Expand Down
1 change: 1 addition & 0 deletions libs/discord/community/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CacheType>): Promise<void> {
if (interaction.isCommand() && interaction.commandName === 'patreon') {
const subCommandGroup = (interaction.options as CommandInteractionOptionResolver).getSubcommandGroup();
const subCommand = (interaction.options as CommandInteractionOptionResolver).getSubcommand();

Check warning on line 64 in libs/discord/community/src/lib/support/patreon/patreon-reporter.module.ts

View workflow job for this annotation

GitHub Actions / Lint / Affected

'subCommand' is assigned a value but never used

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<Tier, 'description' | 'amount_cents' | 'discord_role_ids' | 'title'>
> = 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: <t:${
Date.parse(member.lastChargeDate) / 1000
}:f>\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.',
});
}
}
}
}
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading