mirror of
https://github.com/RetroDECK/RetroDECK-Discord-Bot.git
synced 2026-04-21 12:06:40 +00:00
Initial implementation of RetroDECK Donation Bot with donation verification and role assignment features
This commit is contained in:
parent
ed1b4b70d8
commit
cfc6084602
7
.env.example
Normal file
7
.env.example
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
6
Dockerfile
Normal file
6
Dockerfile
Normal 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
163
README.md
|
|
@ -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
8
docker-compose.yml
Normal 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
14
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/commands/claim-donation.js
Normal file
76
src/commands/claim-donation.js
Normal 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
29
src/deploy-commands.js
Normal 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
44
src/index.js
Normal 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);
|
||||||
157
src/services/opencollective.js
Normal file
157
src/services/opencollective.js
Normal 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 };
|
||||||
22
src/services/token-store.js
Normal file
22
src/services/token-store.js
Normal 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
136
src/setup-oauth.js
Normal 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)');
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue