mirror of
https://github.com/RetroDECK/RetroDECK-Discord-Bot.git
synced 2026-04-29 14:46:38 +00:00
Compare commits
No commits in common. "e4e819a89187fa9bb8c976840274a17f5b33720b" and "ed1b4b70d82488cc80cb7b4638b053007f849968" have entirely different histories.
e4e819a891
...
ed1b4b70d8
|
|
@ -1,7 +0,0 @@
|
||||||
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
3
.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
node_modules/
|
|
||||||
.env
|
|
||||||
data/
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
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,162 +1 @@
|
||||||
# RetroDECK Donation Bot
|
# RetroDECK-Discord-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).
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
services:
|
|
||||||
donation-bot:
|
|
||||||
build: .
|
|
||||||
container_name: retrodeck-donation-bot
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file: .env
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
18
package.json
18
package.json
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"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",
|
|
||||||
"dotenv": "^17.2.4"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"undici": "^6.23.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
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 Supporter 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 Supporter 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 **Supporter** 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 };
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
46
src/index.js
46
src/index.js
|
|
@ -1,46 +0,0 @@
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
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 };
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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 };
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
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/x-www-form-urlencoded' },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
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