feat: implement discord role syncing

This commit is contained in:
mrjvs
2026-08-09 15:39:32 +02:00
parent 8372e673f2
commit 5ae36d378d
6 changed files with 215 additions and 26 deletions

View File

@@ -33,6 +33,7 @@ export default defineNuxtConfig({
githubApiToken: '',
stripeSecretKey: '',
stripeNotificationEmail: '',
grpcHost: '',
grpcApiKey: '',
mongoConnectionString: '',
@@ -43,6 +44,10 @@ export default defineNuxtConfig({
smtpSecure: true,
smtpFromEmail: '',
smtpFromName: '',
discordBotToken: '',
discordGuildId: '',
discordTesterRoleId: '',
discordSupporterRoleId: '',
public: {
apiBase: 'https://api.pretendo.cc',

107
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@discordjs/rest": "^2.6.3",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@nuxt/content": "^3.4.0",
"@nuxt/eslint": "^1.3.0",
@@ -18,6 +19,7 @@
"@pinia/nuxt": "^1.0.1",
"@pretendonetwork/grpc": "^2.5.4",
"better-sqlite3": "^12.11.1",
"discord-api-types": "^0.38.53",
"eslint": "^9.39.5",
"feed": "^6.0.0",
"mongodb": "^7.5.0",
@@ -537,6 +539,65 @@
"integrity": "sha512-3PxTH8itZzltK0U9jTwVVnjLXvnDYuq3m+QXsHkENxWiPRh4WaoLcs1SQjqgZ55kS+QyirpH5BVwzP2gMVG6EQ==",
"license": "MIT"
},
"node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz",
"integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.2.0",
"@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.5",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.50",
"magic-bytes.js": "^1.13.0",
"tslib": "^2.6.3",
"undici": "^6.27.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/undici": {
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/@discordjs/util": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@dxup/nuxt": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.5.6.tgz",
@@ -6011,6 +6072,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz",
"integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@shikijs/core": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.2.tgz",
@@ -7108,6 +7189,16 @@
"vue": "^3.0.0"
}
},
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
@@ -9453,6 +9544,15 @@
"integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==",
"license": "MIT"
},
"node_modules/discord-api-types": {
"version": "0.38.53",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.53.tgz",
"integrity": "sha512-HL1zz/UuZ+bbJjA/X8Kbxx9gk8v9rJAbTeWRNYKmIdjwJ7EovjlHgoJTxcLpATfNJ+AonOtMdy3Y5MVIJAAd/A==",
"license": "MIT",
"workspaces": [
"scripts/actions/documentation"
]
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -14212,6 +14312,12 @@
"yallist": "^3.0.2"
}
},
"node_modules/magic-bytes.js": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz",
"integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==",
"license": "MIT"
},
"node_modules/magic-regexp": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.10.0.tgz",
@@ -20511,7 +20617,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"devOptional": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": {

View File

@@ -13,6 +13,7 @@
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@discordjs/rest": "^2.6.3",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@nuxt/content": "^3.4.0",
"@nuxt/eslint": "^1.3.0",
@@ -22,6 +23,7 @@
"@pinia/nuxt": "^1.0.1",
"@pretendonetwork/grpc": "^2.5.4",
"better-sqlite3": "^12.11.1",
"discord-api-types": "^0.38.53",
"eslint": "^9.39.5",
"feed": "^6.0.0",
"mongodb": "^7.5.0",

View File

@@ -1,4 +1,5 @@
import { Stripe } from "stripe";
import { handleStripeEvent } from "~~/server/utils/handleStripeWebhook";
export default defineEventHandler(async (event): Promise<{ success: boolean, message?: string }> => {
const config = useRuntimeConfig(event);
@@ -21,7 +22,9 @@ export default defineEventHandler(async (event): Promise<{ success: boolean, mes
}
}
await handleStripeEvent(stripe, webhookEvent);
const notificationEmails: string[] = [];
if (config.stripeNotificationEmail) notificationEmails.push(config.stripeNotificationEmail);
await handleStripeEvent(event, stripe, webhookEvent, notificationEmails);
return {
success: true,

56
server/utils/discord.ts Normal file
View File

@@ -0,0 +1,56 @@
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v10';
import type { H3Event } from 'h3'
type DiscordIds = { guildId: string, supporterRoleId: string | null, testerRoleId: string | null }
type DiscordInstance = {
rest: REST,
ids: DiscordIds,
}
let discordInstance: DiscordInstance | null = null;
export function useDiscord(event: H3Event): DiscordInstance | null {
if (!discordInstance) {
const config = useRuntimeConfig(event);
if (config.discordBotToken && config.discordGuildId) {
discordInstance = {
rest: new REST({ version: '10' }).setToken(config.discordBotToken),
ids: {
guildId: config.discordGuildId,
testerRoleId: config.discordTesterRoleId ? config.discordTesterRoleId : null,
supporterRoleId: config.discordSupporterRoleId ? config.discordSupporterRoleId : null,
}
}
}
}
return discordInstance;
}
export async function assignDiscordMemberSupporterRole(discord: DiscordInstance, memberId: string, roleId: string) {
if (discord.ids.supporterRoleId) {
await discord.rest.put(Routes.guildMemberRole(discord.ids.guildId, memberId, discord.ids.supporterRoleId));
}
await discord.rest.put(Routes.guildMemberRole(discord.ids.guildId, memberId, roleId));
}
export async function assignDiscordMemberTesterRole(discord: DiscordInstance, memberId: string) {
if (discord.ids.testerRoleId) {
await discord.rest.put(Routes.guildMemberRole(discord.ids.guildId, memberId, discord.ids.testerRoleId));
}
}
export async function removeDiscordMemberSupporterRole(discord: DiscordInstance, memberId: string, roleId: string) {
if (discord.ids.supporterRoleId) {
await discord.rest.delete(Routes.guildMemberRole(discord.ids.guildId, memberId, discord.ids.supporterRoleId));
}
await discord.rest.delete(Routes.guildMemberRole(discord.ids.guildId, memberId, roleId));
}
export async function removeDiscordMemberTesterRole(discord: DiscordInstance, memberId: string) {
if (discord.ids.testerRoleId) {
await discord.rest.delete(Routes.guildMemberRole(discord.ids.guildId, memberId, discord.ids.testerRoleId));
}
}

View File

@@ -4,6 +4,7 @@ import { PnidDocument, usePapr } from "./papr";
import { Transporter } from "nodemailer";
import { PaprMatchKeysAndValues } from "papr";
import type { H3Event } from 'h3';
import { assignDiscordMemberSupporterRole, assignDiscordMemberTesterRole, removeDiscordMemberSupporterRole, removeDiscordMemberTesterRole, useDiscord } from "./discord";
async function sendEmailToCustomer(mailer: Transporter, customer: Stripe.Customer, ops: { pid: number, title: string, body: string }): Promise<void> {
try {
@@ -35,6 +36,7 @@ async function sendToNotificationEmails(mailer: Transporter, notificationEmails:
export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook: Stripe.Event, notificationEmails: string[]) {
const mailer = useMailer(event);
const discord = useDiscord(event);
const papr = await usePapr(event);
if (!mailer || !papr) {
@@ -134,14 +136,18 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
if (subscription.status === 'canceled' && currentSubscriptionId && subscription.id !== currentSubscriptionId) {
// Canceling old subscription, do nothing but update webhook date and remove Discord roles
if (product.metadata.beta === 'true') {
await util.removeDiscordMemberTesterRole(discordId).catch((error) => {
console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
});
if (discord && discordId) {
await removeDiscordMemberTesterRole(discord, discordId).catch((error) => {
console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
});
}
}
await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
if (discord && discordId && product.metadata.discord_role_id) {
await removeDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
}
await papr.Pnid.updateOne({
pid,
@@ -172,9 +178,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
updateData.server_access_level = 'test';
}
await util.assignDiscordMemberTesterRole(discordId).catch((error) => {
console.error(`Error assigning user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
});
if (discord && discordId) {
await assignDiscordMemberTesterRole(discord, discordId).catch((error) => {
console.error(`Error assigning user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
});
}
} else {
// * Assume any status other than active means payment has not been fulfilled
// * Once the payment goes through, status should update to active
@@ -183,9 +191,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
updateData.server_access_level = 'prod';
}
await util.removeDiscordMemberTesterRole(discordId).catch((error) => {
console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
});
if (discord && discordId) {
await removeDiscordMemberTesterRole(discord, discordId).catch((error) => {
console.error(`Error removing user Discord tester role | ${customer.id}, ${discordId}, ${pid} |`, error);
});
}
}
}
@@ -225,9 +235,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
body: `Thank you for purchasing the ${product.name} tier! We greatly value your support, thank you for helping keep Pretendo Network alive!\nIt may take a moment for your account dashboard to reflect these changes. Please wait a moment and refresh the dashboard to see them!`
})
await util.assignDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error assigning user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
if (discord && discordId && product.metadata.discord_role_id) {
await assignDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error assigning user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
}
await sendToNotificationEmails(mailer, notificationEmails, {
title: `New ${product.name} subscription`,
@@ -240,9 +252,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
body: `Your subscription for the ${product.name} tier has been canceled. We thank for your previous support, and hope you still enjoy the network! `
})
await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
if (discord && discordId && product.metadata.discord_role_id) {
await removeDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
}
await sendToNotificationEmails(mailer, notificationEmails, {
title: `Canceled ${product.name} subscription`,
@@ -255,9 +269,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
body: `Your subscription for the ${product.name} tier has been canceled due to non payment. We thank for your previous support, and hope you still enjoy the network! `
})
await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
if (discord && discordId && product.metadata.discord_role_id) {
await removeDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
}
await sendToNotificationEmails(mailer, notificationEmails, {
title: `Removed ${product.name} subscription`,
@@ -270,9 +286,11 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
body: `Your subscription for the ${product.name} tier has changed status to ${subscription.status}. This is usually caused by payment failure. Your account has been reverted back to default until payment resumes. If you believe this to be an error, please reach out for support on our Discord server, and we thank you for your previous support!`
})
await util.removeDiscordMemberSupporterRole(discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
if (discord && discordId && product.metadata.discord_role_id) {
await removeDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
console.error(`Error removing user Discord supporter role | ${customer.id}, ${discordId}, ${pid}, ${product.metadata.discord_role_id} |`, error);
});
}
await sendToNotificationEmails(mailer, notificationEmails, {
title: `Removed ${product.name} subscription`,