From cfc608460275f6142a2351143ec93ff49d5f6bd8 Mon Sep 17 00:00:00 2001 From: Adam Iannazzone <46025473+jiannazzone@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:24:02 -0500 Subject: [PATCH] Initial implementation of RetroDECK Donation Bot with donation verification and role assignment features --- .env.example | 7 ++ .gitignore | 3 + Dockerfile | 6 ++ README.md | 163 ++++++++++++++++++++++++++++++++- docker-compose.yml | 8 ++ package.json | 14 +++ src/commands/claim-donation.js | 76 +++++++++++++++ src/deploy-commands.js | 29 ++++++ src/index.js | 44 +++++++++ src/services/opencollective.js | 157 +++++++++++++++++++++++++++++++ src/services/token-store.js | 22 +++++ src/setup-oauth.js | 136 +++++++++++++++++++++++++++ 12 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 src/commands/claim-donation.js create mode 100644 src/deploy-commands.js create mode 100644 src/index.js create mode 100644 src/services/opencollective.js create mode 100644 src/services/token-store.js create mode 100644 src/setup-oauth.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb91709 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DISCORD_BOT_TOKEN= # Discord bot token +DISCORD_GUILD_ID= # The RetroDECK Discord server ID +DISCORD_DONATOR_ROLE_ID= # The role ID to assign to verified donors +OC_CLIENT_ID= # OpenCollective OAuth app client ID +OC_CLIENT_SECRET= # OpenCollective OAuth app client secret +OC_REDIRECT_URI=http://localhost:3000/callback # OAuth redirect URI (default works for local setup) +OC_COLLECTIVE_SLUG= # The OpenCollective collective slug (e.g. "retrodeck") diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5235962 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..428142f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --production +COPY src/ ./src/ +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md index 25bb5bb..ad28b44 100644 --- a/README.md +++ b/README.md @@ -1 +1,162 @@ -# RetroDECK-Discord-Bot \ No newline at end of file +# RetroDECK Donation Bot + +A Discord bot that automatically assigns a **Donator** role to users who have donated to the [RetroDECK OpenCollective](https://opencollective.com/retrodeck). The bot verifies donations by cross-referencing the user's email against the OpenCollective GraphQL API. + +## How It Works + +1. A user runs the `/claim-donation` slash command in any channel. +2. A private modal (form) opens, prompting them to enter the email they used on OpenCollective. +3. The bot queries the OpenCollective API to check if that email belongs to a backer of the collective. +4. If a match is found, the bot assigns the Donator role and confirms. +5. If no match is found, the bot suggests checking the email or contacting a moderator. + +All interactions are **ephemeral** (only visible to the user), so the email address is never exposed to other members. + +## Project Structure + +``` +retrodeck-donation-bot/ +├── Dockerfile +├── docker-compose.yml +├── package.json +├── .env.example +├── data/ +│ └── oc-token.json # OAuth token (created by setup script, git-ignored) +├── src/ +│ ├── index.js # Bot entry point, client setup, event routing +│ ├── deploy-commands.js # Script to register slash commands with Discord +│ ├── setup-oauth.js # One-time OAuth setup script +│ ├── commands/ +│ │ └── claim-donation.js # Slash command definition, modal, and verification logic +│ └── services/ +│ ├── opencollective.js # OpenCollective GraphQL API query logic +│ └── token-store.js # OAuth token file read/write +``` + +## Prerequisites + +- **Node.js** 20 or later +- **Docker** and **Docker Compose** (for containerized deployment) +- A **Discord bot application** with the required permissions +- An **OpenCollective OAuth application** with admin access to the collective + +## Setup + +### 1. Create a Discord Bot Application + +1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application. +2. Navigate to **Bot** and generate a bot token. Save it for later. +3. Under **Privileged Gateway Intents**, enable **Server Members Intent** (required to assign roles). +4. Copy your **Application ID** from the **General Information** page. +5. Invite the bot to your server by opening this URL in your browser (replace `YOUR_APP_ID`): + ``` + https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&scope=bot+applications.commands&permissions=268435456 + ``` + This requests the `bot` and `applications.commands` scopes with the `Manage Roles` permission (`268435456`). + +### 2. Create an OpenCollective OAuth Application + +1. Log in to [OpenCollective](https://opencollective.com/) as an admin of the collective. +2. Go to `https://opencollective.com/{your-org}/admin/for-developers`. +3. Create a new OAuth application. +4. Set the callback URL to `http://localhost:3000/callback`. +5. Note the **Client ID** and **Client Secret**. + +### 3. Identify the Donator Role + +1. In your Discord server, create a role called "Donator" (or use an existing one). +2. Make sure the bot's role is **above** the Donator role in the role hierarchy (Server Settings > Roles), otherwise it won't be able to assign it. +3. Enable **Developer Mode** in Discord (Settings > Advanced) and right-click the role to copy its ID. + +### 4. Configure Environment Variables + +Copy the example environment file and fill in your values: + +```bash +cp .env.example .env +``` + +| Variable | Description | +|---|---| +| `DISCORD_BOT_TOKEN` | The bot token from the Discord Developer Portal | +| `DISCORD_GUILD_ID` | Your Discord server ID (right-click server name > Copy Server ID) | +| `DISCORD_DONATOR_ROLE_ID` | The role ID for the Donator role | +| `OC_CLIENT_ID` | OpenCollective OAuth app client ID | +| `OC_CLIENT_SECRET` | OpenCollective OAuth app client secret | +| `OC_REDIRECT_URI` | OAuth redirect URI (default: `http://localhost:3000/callback`) | +| `OC_COLLECTIVE_SLUG` | The OpenCollective collective slug (e.g. `retrodeck`) | + +### 5. Authenticate with OpenCollective + +Run the OAuth setup script to obtain an access token: + +```bash +npm install +npm run setup-oauth +``` + +A browser window will open asking you to authorize the app on OpenCollective with `email` and `account` scopes. After approval, the token is saved to `data/oc-token.json`. This only needs to be done once. If the token expires, re-run the command. + +### 6. Register the Slash Command + +Register the `/claim-donation` slash command with Discord: + +```bash +npm run deploy-commands +``` + +This registers the command as a **guild-specific** command for fast updates. You only need to re-run this if you change the command definition. + +### 7. Start the Bot + +#### With Docker (recommended) + +```bash +docker compose up -d +``` + +To view logs: + +```bash +docker compose logs -f donation-bot +``` + +To rebuild after code changes: + +```bash +docker compose up -d --build +``` + +#### Without Docker + +```bash +npm start +``` + +## OpenCollective API Details + +The bot uses the [OpenCollective GraphQL API v2](https://docs.opencollective.com/help/contributing/development/api) to verify donations, authenticating with an OAuth Bearer token. + +**Primary strategy:** Query the collective's members (backers) and match the provided email against each member's `emails` field. The query paginates through all members automatically. + +**Fallback strategy:** If the members query fails (e.g. due to permissions), the bot falls back to querying credit transactions and matching against the `fromAccount.emails` field. + +Both strategies perform **case-insensitive** email matching. + +The OAuth app requires the `email` and `account` scopes. + +## Edge Cases + +| Scenario | Behavior | +|---|---| +| User already has the Donator role | Skips the API call and tells the user they already have it | +| Email not found | Suggests double-checking the email or contacting a moderator | +| Guest/anonymous donation | Cannot be verified automatically; the bot mentions this possibility | +| OpenCollective API error | Responds with a generic error and logs the details to the console | +| OAuth token expired/invalid | Bot logs a clear error; re-run `npm run setup-oauth` to re-authenticate | +| No token file present | Bot refuses to start with instructions to run `npm run setup-oauth` | +| Discord rate limits | Handled automatically by Discord.js | + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a0350e1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + donation-bot: + build: . + container_name: retrodeck-donation-bot + restart: unless-stopped + env_file: .env + volumes: + - ./data:/app/data diff --git a/package.json b/package.json new file mode 100644 index 0000000..a14db27 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "retrodeck-donation-bot", + "version": "1.0.0", + "description": "Discord bot that assigns a Donator role to verified OpenCollective donors", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "deploy-commands": "node src/deploy-commands.js", + "setup-oauth": "node src/setup-oauth.js" + }, + "dependencies": { + "discord.js": "^14.16.3" + } +} diff --git a/src/commands/claim-donation.js b/src/commands/claim-donation.js new file mode 100644 index 0000000..6a3d5e9 --- /dev/null +++ b/src/commands/claim-donation.js @@ -0,0 +1,76 @@ +const { + SlashCommandBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder, +} = require('discord.js'); +const { verifyDonor } = require('../services/opencollective'); + +const MODAL_ID = 'claim-donation-modal'; +const EMAIL_INPUT_ID = 'email-input'; + +const command = new SlashCommandBuilder() + .setName('claim-donation') + .setDescription('Claim your Donator role by verifying your OpenCollective donation'); + +async function handleCommand(interaction) { + const modal = new ModalBuilder() + .setCustomId(MODAL_ID) + .setTitle('Verify Your Donation'); + + const emailInput = new TextInputBuilder() + .setCustomId(EMAIL_INPUT_ID) + .setLabel('Email used on OpenCollective') + .setPlaceholder('your-email@example.com') + .setStyle(TextInputStyle.Short) + .setRequired(true); + + modal.addComponents(new ActionRowBuilder().addComponents(emailInput)); + + await interaction.showModal(modal); +} + +async function handleModal(interaction) { + const donatorRoleId = process.env.DISCORD_DONATOR_ROLE_ID; + const member = interaction.member; + + if (member.roles.cache.has(donatorRoleId)) { + await interaction.reply({ + content: 'You already have the Donator role! Thank you for your support.', + flags: 64, + }); + return; + } + + await interaction.deferReply({ flags: 64 }); + + const email = interaction.fields.getTextInputValue(EMAIL_INPUT_ID).trim(); + + try { + const result = await verifyDonor(email); + + if (result.found) { + await member.roles.add(donatorRoleId); + await interaction.editReply({ + content: + 'Donation verified! You have been given the **Donator** role. Thank you for supporting RetroDECK!', + }); + } else { + await interaction.editReply({ + content: + "Could not find a donation matching that email. Please double-check the email you used on OpenCollective.\n\n" + + "If you donated as a guest or anonymously, automatic verification isn't possible. " + + 'Please contact a moderator for manual verification.', + }); + } + } catch (err) { + console.error('Error verifying donation:', err); + await interaction.editReply({ + content: + 'Something went wrong while verifying your donation. Please try again later or contact a moderator.', + }); + } +} + +module.exports = { command, handleCommand, handleModal, MODAL_ID }; diff --git a/src/deploy-commands.js b/src/deploy-commands.js new file mode 100644 index 0000000..d70d559 --- /dev/null +++ b/src/deploy-commands.js @@ -0,0 +1,29 @@ +const { REST, Routes } = require('discord.js'); +const { command } = require('./commands/claim-donation'); + +const token = process.env.DISCORD_BOT_TOKEN; +const guildId = process.env.DISCORD_GUILD_ID; + +if (!token || !guildId) { + console.error('Missing DISCORD_BOT_TOKEN or DISCORD_GUILD_ID environment variables.'); + process.exit(1); +} + +const rest = new REST().setToken(token); + +(async () => { + try { + console.log('Registering slash commands...'); + + const clientId = Buffer.from(token.split('.')[0], 'base64').toString(); + + await rest.put(Routes.applicationGuildCommands(clientId, guildId), { + body: [command.toJSON()], + }); + + console.log('Slash commands registered successfully.'); + } catch (err) { + console.error('Failed to register commands:', err); + process.exit(1); + } +})(); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..5e2a770 --- /dev/null +++ b/src/index.js @@ -0,0 +1,44 @@ +const { Client, GatewayIntentBits, Events } = require('discord.js'); +const { handleCommand, handleModal, MODAL_ID } = require('./commands/claim-donation'); +const { getAccessToken, TOKEN_PATH } = require('./services/token-store'); + +const ocToken = getAccessToken(); +if (!ocToken) { + console.error( + `No OpenCollective OAuth token found at ${TOKEN_PATH}.\n` + + 'Run "npm run setup-oauth" to authenticate with OpenCollective before starting the bot.' + ); + process.exit(1); +} +console.log('OpenCollective OAuth token loaded.'); + +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], +}); + +client.once(Events.ClientReady, (c) => { + console.log(`Logged in as ${c.user.tag}`); +}); + +client.on(Events.InteractionCreate, async (interaction) => { + try { + if (interaction.isChatInputCommand() && interaction.commandName === 'claim-donation') { + await handleCommand(interaction); + } else if (interaction.isModalSubmit() && interaction.customId === MODAL_ID) { + await handleModal(interaction); + } + } catch (err) { + console.error('Unhandled interaction error:', err); + const reply = { + content: 'An unexpected error occurred. Please try again later.', + flags: 64, + }; + if (interaction.replied || interaction.deferred) { + await interaction.editReply(reply).catch(() => {}); + } else { + await interaction.reply(reply).catch(() => {}); + } + } +}); + +client.login(process.env.DISCORD_BOT_TOKEN); diff --git a/src/services/opencollective.js b/src/services/opencollective.js new file mode 100644 index 0000000..f49d191 --- /dev/null +++ b/src/services/opencollective.js @@ -0,0 +1,157 @@ +const { getAccessToken, TOKEN_PATH } = require('./token-store'); + +const OC_API_URL = 'https://api.opencollective.com/graphql/v2'; + +const MEMBERS_QUERY = ` + query ($slug: String!, $limit: Int!, $offset: Int!) { + account(slug: $slug) { + members(role: BACKER, limit: $limit, offset: $offset) { + totalCount + nodes { + account { + name + slug + emails + } + totalDonations { + value + currency + } + createdAt + } + } + } + } +`; + +const TRANSACTIONS_QUERY = ` + query ($slug: String!, $limit: Int!, $offset: Int!) { + account(slug: $slug) { + transactions(type: CREDIT, limit: $limit, offset: $offset) { + totalCount + nodes { + fromAccount { + name + slug + emails + } + amount { + value + currency + } + createdAt + } + } + } + } +`; + +async function queryOC(query, variables) { + const token = getAccessToken(); + if (!token) { + throw new Error( + `No OpenCollective OAuth token found. Run "npm run setup-oauth" first. Expected token at: ${TOKEN_PATH}` + ); + } + + const response = await fetch(OC_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ query, variables }), + }); + + if (response.status === 401 || response.status === 403) { + throw new Error( + 'OpenCollective OAuth token is invalid or expired. Run "npm run setup-oauth" to re-authenticate.' + ); + } + + if (!response.ok) { + throw new Error(`OpenCollective API error: ${response.status}`); + } + + const json = await response.json(); + + if (json.errors) { + throw new Error(`OpenCollective GraphQL error: ${json.errors[0].message}`); + } + + return json.data; +} + +async function checkDonorByMembers(email) { + const slug = process.env.OC_COLLECTIVE_SLUG; + const limit = 100; + let offset = 0; + let totalCount = Infinity; + + while (offset < totalCount) { + const data = await queryOC(MEMBERS_QUERY, { slug, limit, offset }); + const members = data.account.members; + totalCount = members.totalCount; + + for (const member of members.nodes) { + const emails = member.account.emails || []; + if (emails.some((e) => e.toLowerCase() === email.toLowerCase())) { + return { + found: true, + name: member.account.name, + totalDonations: member.totalDonations, + }; + } + } + + offset += limit; + } + + return { found: false }; +} + +async function checkDonorByTransactions(email) { + const slug = process.env.OC_COLLECTIVE_SLUG; + const limit = 100; + let offset = 0; + let totalCount = Infinity; + + while (offset < totalCount) { + const data = await queryOC(TRANSACTIONS_QUERY, { slug, limit, offset }); + const transactions = data.account.transactions; + totalCount = transactions.totalCount; + + for (const tx of transactions.nodes) { + if (!tx.fromAccount) continue; + const emails = tx.fromAccount.emails || []; + if (emails.some((e) => e.toLowerCase() === email.toLowerCase())) { + return { + found: true, + name: tx.fromAccount.name, + }; + } + } + + offset += limit; + } + + return { found: false }; +} + +async function verifyDonor(email) { + try { + const result = await checkDonorByMembers(email); + if (result.found) return result; + } catch (err) { + console.warn('Members query failed, falling back to transactions:', err.message); + } + + try { + return await checkDonorByTransactions(email); + } catch (err) { + console.error('Transactions query also failed:', err.message); + throw new Error('Unable to verify donation status. Please try again later.'); + } +} + +module.exports = { verifyDonor }; diff --git a/src/services/token-store.js b/src/services/token-store.js new file mode 100644 index 0000000..edf3464 --- /dev/null +++ b/src/services/token-store.js @@ -0,0 +1,22 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const DATA_DIR = path.join(__dirname, '..', '..', 'data'); +const TOKEN_PATH = path.join(DATA_DIR, 'oc-token.json'); + +function getAccessToken() { + try { + const raw = fs.readFileSync(TOKEN_PATH, 'utf-8'); + const data = JSON.parse(raw); + return data.access_token || null; + } catch { + return null; + } +} + +function saveToken(tokenData) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokenData, null, 2)); +} + +module.exports = { getAccessToken, saveToken, TOKEN_PATH }; diff --git a/src/setup-oauth.js b/src/setup-oauth.js new file mode 100644 index 0000000..0dc94bd --- /dev/null +++ b/src/setup-oauth.js @@ -0,0 +1,136 @@ +const http = require('node:http'); +const crypto = require('node:crypto'); +const { execSync } = require('node:child_process'); +const { saveToken, TOKEN_PATH } = require('./services/token-store'); + +const CLIENT_ID = process.env.OC_CLIENT_ID; +const CLIENT_SECRET = process.env.OC_CLIENT_SECRET; +const REDIRECT_URI = process.env.OC_REDIRECT_URI || 'http://localhost:3000/callback'; +const SCOPES = 'email,account'; + +if (!CLIENT_ID || !CLIENT_SECRET) { + console.error('Missing OC_CLIENT_ID or OC_CLIENT_SECRET environment variables.'); + process.exit(1); +} + +const redirectUrl = new URL(REDIRECT_URI); +const PORT = parseInt(redirectUrl.port, 10) || 3000; +const CALLBACK_PATH = redirectUrl.pathname; + +const state = crypto.randomBytes(16).toString('hex'); + +const authUrl = new URL('https://opencollective.com/oauth/authorize'); +authUrl.searchParams.set('client_id', CLIENT_ID); +authUrl.searchParams.set('response_type', 'code'); +authUrl.searchParams.set('redirect_uri', REDIRECT_URI); +authUrl.searchParams.set('scope', SCOPES); +authUrl.searchParams.set('state', state); + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const returnedState = url.searchParams.get('state'); + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(`
${error}
`); + console.error(`Authorization failed: ${error}`); + shutdown(1); + return; + } + + if (returnedState !== state) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('You can close this window. The bot token has been saved.
'); + shutdown(0); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/html' }); + res.end(`${err.message}
`); + console.error('Token exchange error:', err.message); + shutdown(1); + } +}); + +function shutdown(code) { + server.close(() => process.exit(code)); + setTimeout(() => process.exit(code), 1000); +} + +server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error( + `Port ${PORT} is already in use. Either free the port or change OC_REDIRECT_URI to use a different port.` + ); + } else { + console.error('Server error:', err.message); + } + process.exit(1); +}); + +server.listen(PORT, () => { + console.log(`Listening on port ${PORT} for OAuth callback...`); + console.log(`\nOpen this URL in your browser to authorize:\n\n ${authUrl.toString()}\n`); + + try { + execSync(`open "${authUrl.toString()}"`); + console.log('(Browser opened automatically)'); + } catch { + console.log('(Could not open browser automatically — please open the URL manually)'); + } +});