Initial implementation of RetroDECK Donation Bot with donation verification and role assignment features

This commit is contained in:
Adam Iannazzone 2026-02-07 15:24:02 -05:00
parent ed1b4b70d8
commit cfc6084602
12 changed files with 664 additions and 1 deletions

7
.env.example Normal file
View file

@ -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")

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
.env
data/

6
Dockerfile Normal file
View file

@ -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"]

163
README.md
View file

@ -1 +1,162 @@
# RetroDECK-Discord-Bot # 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).

8
docker-compose.yml Normal file
View file

@ -0,0 +1,8 @@
services:
donation-bot:
build: .
container_name: retrodeck-donation-bot
restart: unless-stopped
env_file: .env
volumes:
- ./data:/app/data

14
package.json Normal file
View file

@ -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"
}
}

View file

@ -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 };

29
src/deploy-commands.js Normal file
View file

@ -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);
}
})();

44
src/index.js Normal file
View file

@ -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);

View file

@ -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 };

View file

@ -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 };

136
src/setup-oauth.js Normal file
View file

@ -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(`<h1>Authorization failed</h1><p>${error}</p>`);
console.error(`Authorization failed: ${error}`);
shutdown(1);
return;
}
if (returnedState !== state) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<h1>State mismatch — possible CSRF attack</h1>');
console.error('State parameter mismatch.');
shutdown(1);
return;
}
if (!code) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<h1>No authorization code received</h1>');
console.error('No authorization code in callback.');
shutdown(1);
return;
}
try {
console.log('Exchanging authorization code for access token...');
const tokenResponse = await fetch('https://opencollective.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
redirect_uri: REDIRECT_URI,
}),
});
if (!tokenResponse.ok) {
const body = await tokenResponse.text();
throw new Error(`Token exchange failed (${tokenResponse.status}): ${body}`);
}
const tokenData = await tokenResponse.json();
if (!tokenData.access_token) {
throw new Error(`No access_token in response: ${JSON.stringify(tokenData)}`);
}
saveToken({
access_token: tokenData.access_token,
obtained_at: new Date().toISOString(),
});
console.log(`Token saved to ${TOKEN_PATH}`);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>Authorization successful!</h1><p>You can close this window. The bot token has been saved.</p>');
shutdown(0);
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end(`<h1>Token exchange failed</h1><p>${err.message}</p>`);
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)');
}
});