Compare commits

...

1 Commits

Author SHA1 Message Date
Timothy c610d95165 feat: use discord bot to link accounts 2026-03-05 11:34:24 -08:00
11 changed files with 481 additions and 187 deletions
-31
View File
@@ -1,31 +0,0 @@
name: Link Discord Account
description: Connect your GitHub and Discord for the bounty program
title: "link: @{{ github.actor }}"
labels: ["link-discord"]
body:
- type: markdown
attributes:
value: |
Link your Discord account to receive XP and role rewards when your bounty PRs are merged.
**How to find your Discord ID:**
1. Open Discord Settings > Advanced > Enable **Developer Mode**
2. Right-click your username > **Copy User ID**
- type: input
id: discord_id
attributes:
label: Discord User ID
description: "Your numeric Discord ID (not your username). Example: 123456789012345678"
placeholder: "123456789012345678"
validations:
required: true
- type: input
id: display_name
attributes:
label: Display Name (optional)
description: How you'd like to be credited
placeholder: "Jane Doe"
validations:
required: false
+1
View File
@@ -34,4 +34,5 @@ jobs:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_BOUNTY_WEBHOOK_URL }}
LURKR_API_KEY: ${{ secrets.LURKR_API_KEY }}
LURKR_GUILD_ID: ${{ secrets.LURKR_GUILD_ID }}
MONGODB_URI: ${{ secrets.MONGODB_URI }}
PR_NUMBER: ${{ github.event.pull_request.number }}
-126
View File
@@ -1,126 +0,0 @@
name: Link Discord account
description: Auto-creates a PR to add contributor to contributors.yml when a link-discord issue is opened
on:
issues:
types: [opened]
jobs:
link-discord:
if: contains(github.event.issue.labels.*.name, 'link-discord')
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: write
issues: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Parse issue and update contributors.yml
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const issue = context.payload.issue;
const githubUsername = issue.user.login;
// Parse the issue body for form fields
const body = issue.body || '';
// Extract Discord ID — look for the numeric value after the "Discord User ID" heading
const discordMatch = body.match(/### Discord User ID\s*\n\s*(\d{17,20})/);
if (!discordMatch) {
await github.rest.issues.createComment({
...context.repo,
issue_number: issue.number,
body: `Could not find a valid Discord ID in the issue body. Please make sure you entered a numeric ID (17-20 digits), not a username.\n\nExample: \`123456789012345678\``
});
await github.rest.issues.update({
...context.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned'
});
return;
}
const discordId = discordMatch[1];
// Extract display name (optional)
const nameMatch = body.match(/### Display Name \(optional\)\s*\n\s*(.+)/);
const displayName = nameMatch ? nameMatch[1].trim() : '';
// Check if user already exists
const yml = fs.readFileSync('contributors.yml', 'utf-8');
if (yml.includes(`github: ${githubUsername}`)) {
await github.rest.issues.createComment({
...context.repo,
issue_number: issue.number,
body: `@${githubUsername} is already in \`contributors.yml\`. If you need to update your Discord ID, please edit the file directly via PR.`
});
await github.rest.issues.update({
...context.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'completed'
});
return;
}
// Append entry to contributors.yml
let entry = ` - github: ${githubUsername}\n discord: "${discordId}"`;
if (displayName && displayName !== '_No response_') {
entry += `\n name: ${displayName}`;
}
entry += '\n';
const updated = yml.trimEnd() + '\n' + entry;
fs.writeFileSync('contributors.yml', updated);
// Set outputs for commit step
core.exportVariable('GITHUB_USERNAME', githubUsername);
core.exportVariable('DISCORD_ID', discordId);
core.exportVariable('ISSUE_NUMBER', issue.number.toString());
- name: Create PR
run: |
# Check if there are changes
if git diff --quiet contributors.yml; then
echo "No changes to contributors.yml"
exit 0
fi
BRANCH="docs/link-discord-${GITHUB_USERNAME}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add contributors.yml
git commit -m "docs: link @${GITHUB_USERNAME} to Discord"
git push origin "$BRANCH"
gh pr create \
--title "docs: link @${GITHUB_USERNAME} to Discord" \
--body "Adds @${GITHUB_USERNAME} (Discord \`${DISCORD_ID}\`) to \`contributors.yml\` for bounty XP tracking.
Closes #${ISSUE_NUMBER}" \
--base main \
--head "$BRANCH" \
--label "link-discord"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Notify on issue
uses: actions/github-script@v7
with:
script: |
const username = process.env.GITHUB_USERNAME;
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
await github.rest.issues.createComment({
...context.repo,
issue_number: issueNumber,
body: `A PR has been created to link your account. A maintainer will merge it shortly — once merged, you'll receive XP and Discord pings when your bounty PRs are merged.`
});
+1
View File
@@ -37,4 +37,5 @@ jobs:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_BOUNTY_WEBHOOK_URL }}
LURKR_API_KEY: ${{ secrets.LURKR_API_KEY }}
LURKR_GUILD_ID: ${{ secrets.LURKR_GUILD_ID }}
MONGODB_URI: ${{ secrets.MONGODB_URI }}
SINCE_DATE: ${{ github.event.inputs.since_date || '' }}
+4 -16
View File
@@ -1,22 +1,10 @@
# Identity mapping: GitHub username -> Discord ID
#
# This file links GitHub accounts to Discord accounts for the
# Integration Bounty Program. When a bounty PR is merged, the
# GitHub Action uses this file to ping the contributor on Discord.
# DEPRECATED: Contributor identities are now stored in MongoDB.
# Use /link-github <your-github-username> in Discord to link your account.
#
# HOW TO ADD YOURSELF:
# Open a "Link Discord Account" issue:
# https://github.com/aden-hive/hive/issues/new?template=link-discord.yml
# A GitHub Action will automatically add your entry here.
#
# To find your Discord ID:
# 1. Open Discord Settings > Advanced > Enable Developer Mode
# 2. Right-click your name > Copy User ID
#
# Format:
# - github: your-github-username
# discord: "your-discord-id" # quotes required (it's a number)
# name: Your Display Name # optional
# This file is kept as a fallback for local development when MONGODB_URI
# is not set. The bounty-tracker.ts script will prefer MongoDB when available.
contributors:
# - github: example-user
+4 -3
View File
@@ -105,7 +105,7 @@ See the [Setup Guide](setup-guide.md) for full configuration (Lurkr, webhooks, s
### Identity Linking
Contributors link GitHub ↔ Discord by opening a [Link Discord Account](https://github.com/aden-hive/hive/issues/new?template=link-discord.yml) issue. A GitHub Action auto-adds them to `contributors.yml` and closes the issue.
Contributors link GitHub ↔ Discord by typing `/link-github <username>` in Discord. The bounty bot verifies the GitHub account and stores the mapping in MongoDB.
Without this link, bounties are still tracked but Lurkr can't push XP to your Discord account.
@@ -120,7 +120,7 @@ Without this link, bounties are still tracked but Lurkr can't push XP to your Di
| Agent Builder role | Lurkr bot | Auto-assigned at level 5 |
| OSS Contributor role | Lurkr bot | Auto-assigned at level 15 |
| Core Contributor role | Maintainer | Manual (involves money) |
| Identity linking | contributors.yml | PR-based, reviewed by maintainers |
| Identity linking | Bounty bot + MongoDB | `/link-github` in Discord |
## Guides
@@ -142,4 +142,5 @@ Without this link, bounties are still tracked but Lurkr can't push XP to your Di
- `.github/workflows/weekly-leaderboard.yml` — Monday leaderboard post
- `scripts/bounty-tracker.ts` — Point calculation, Lurkr API, Discord formatting
- `scripts/setup-bounty-labels.sh` — One-time label setup
- `contributors.yml` GitHub ↔ Discord identity mapping
- `scripts/contributors-db.ts` — MongoDB-backed GitHub ↔ Discord identity store
- `contributors.yml` — Fallback identity mapping (deprecated, prefer MongoDB)
+1 -3
View File
@@ -6,9 +6,7 @@ Earn XP, Discord roles, and eventually real money by testing and building integr
### 1. Link your GitHub and Discord
Open a [Link Discord Account](https://github.com/aden-hive/hive/issues/new?template=link-discord.yml) issue — just paste your Discord ID and submit. A GitHub Action will automatically add you to `contributors.yml` and close the issue.
To find your Discord ID: Discord Settings > Advanced > Enable **Developer Mode**, then right-click your name > **Copy User ID**.
Type `/link-github <your-github-username>` in any Discord channel. The bot verifies your GitHub account and links it automatically.
Without this link, Lurkr can't push XP to your Discord account.
+3
View File
@@ -14,6 +14,9 @@
"frontend:build": "cd core/frontend && npm run build",
"frontend:preview": "cd core/frontend && npm run preview"
},
"dependencies": {
"mongodb": "^6.12.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env bun
/**
* Bounty Bot — Discord bot for the Integration Bounty Program.
*
* Commands:
* /link-github <username> — Link your Discord to a GitHub account
* /bounty-rank — Check your bounty stats
*
* Environment:
* DISCORD_BOT_TOKEN — Discord bot token
* DISCORD_APP_ID — Discord application ID
* MONGODB_URI — MongoDB connection string
* GITHUB_PAT — GitHub PAT (to verify usernames)
* LURKR_API_KEY — (optional) Lurkr API key for level lookups
* LURKR_GUILD_ID — (optional) Discord server ID
*
* Setup:
* 1. Create a Discord app at https://discord.com/developers/applications
* 2. Bot > create bot, copy token
* 3. OAuth2 > URL Generator > scopes: bot, applications.commands
* 4. Bot permissions: Send Messages, Use Slash Commands
* 5. Set env vars, then: bun run scripts/bounty-bot.ts
*/
import {
getDb,
closeDb,
addContributor,
findByDiscord,
ensureIndexes,
} from "./contributors-db";
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
const DISCORD_APP_ID = process.env.DISCORD_APP_ID;
const GITHUB_PAT = process.env.GITHUB_PAT;
const LURKR_API_KEY = process.env.LURKR_API_KEY;
const LURKR_GUILD_ID = process.env.LURKR_GUILD_ID;
if (!DISCORD_BOT_TOKEN || !DISCORD_APP_ID) {
console.error("Missing DISCORD_BOT_TOKEN or DISCORD_APP_ID");
process.exit(1);
}
const DISCORD_API = "https://discord.com/api/v10";
const GITHUB_API = "https://api.github.com";
// ---------------------------------------------------------------------------
// GitHub: verify username
// ---------------------------------------------------------------------------
async function verifyGitHubUser(username: string): Promise<boolean> {
if (!GITHUB_PAT) {
console.warn("No GITHUB_PAT set, skipping GitHub username verification");
return true;
}
const res = await fetch(`${GITHUB_API}/users/${username}`, {
headers: { Authorization: `Bearer ${GITHUB_PAT}` },
});
return res.ok;
}
// ---------------------------------------------------------------------------
// Lurkr: get user level
// ---------------------------------------------------------------------------
async function getLurkrLevel(discordId: string): Promise<{ level: number; xp: number } | null> {
if (!LURKR_API_KEY || !LURKR_GUILD_ID) return null;
const res = await fetch(
`https://api.lurkr.gg/v2/levels/${LURKR_GUILD_ID}/users/${discordId}`,
{ headers: { "X-API-Key": LURKR_API_KEY } }
);
if (!res.ok) return null;
const data = await res.json() as { level: { level: number; xp: number } };
return data.level;
}
// ---------------------------------------------------------------------------
// Discord: register slash commands
// ---------------------------------------------------------------------------
async function registerCommands() {
const commands = [
{
name: "link-github",
description: "Link your Discord account to GitHub for bounty XP",
options: [
{
name: "username",
description: "Your GitHub username",
type: 3, // STRING
required: true,
},
],
},
{
name: "bounty-rank",
description: "Check your bounty program level and stats",
},
];
const res = await fetch(`${DISCORD_API}/applications/${DISCORD_APP_ID}/commands`, {
method: "PUT",
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(commands),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Failed to register commands: ${res.status} ${err}`);
}
console.log("Slash commands registered");
}
// ---------------------------------------------------------------------------
// Discord: Gateway (WebSocket)
// ---------------------------------------------------------------------------
async function connectGateway() {
const gatewayRes = await fetch(`${DISCORD_API}/gateway/bot`, {
headers: { Authorization: `Bot ${DISCORD_BOT_TOKEN}` },
});
if (!gatewayRes.ok) throw new Error(`Gateway fetch failed: ${gatewayRes.status}`);
const { url } = (await gatewayRes.json()) as { url: string };
const ws = new WebSocket(`${url}?v=10&encoding=json`);
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let seq: number | null = null;
ws.addEventListener("message", async (event) => {
const data = JSON.parse(event.data as string);
seq = data.s ?? seq;
switch (data.op) {
case 10: // Hello
heartbeatInterval = setInterval(() => {
ws.send(JSON.stringify({ op: 1, d: seq }));
}, data.d.heartbeat_interval);
// Identify
ws.send(
JSON.stringify({
op: 2,
d: {
token: DISCORD_BOT_TOKEN,
intents: 0,
properties: { os: "linux", browser: "bounty-bot", device: "bounty-bot" },
},
})
);
break;
case 11: // Heartbeat ACK
break;
case 0: // Dispatch
if (data.t === "READY") {
console.log(`Bot online as ${data.d.user.username}#${data.d.user.discriminator}`);
} else if (data.t === "INTERACTION_CREATE") {
await handleInteraction(data.d);
}
break;
}
});
ws.addEventListener("close", (event) => {
console.log(`WebSocket closed: ${event.code} ${event.reason}`);
if (heartbeatInterval) clearInterval(heartbeatInterval);
setTimeout(connectGateway, 5000);
});
ws.addEventListener("error", (event) => {
console.error("WebSocket error:", event);
});
}
// ---------------------------------------------------------------------------
// Interaction handler
// ---------------------------------------------------------------------------
async function handleInteraction(interaction: any) {
const { type, data, member, user } = interaction;
if (type !== 2) return;
const discordUser = member?.user ?? user;
const discordId = discordUser.id;
if (data.name === "link-github") {
const githubUsername = data.options?.[0]?.value;
if (!githubUsername) {
await respondToInteraction(interaction, "Please provide your GitHub username.");
return;
}
await respondToInteraction(interaction, "Linking your account...", true);
// Verify the GitHub username exists
const exists = await verifyGitHubUser(githubUsername);
if (!exists) {
await editInteractionResponse(interaction, `\u274C GitHub user **${githubUsername}** not found.`);
return;
}
const db = await getDb();
const result = await addContributor(db, githubUsername, discordId);
const emoji = result.success ? "\u2705" : "\u274C";
await editInteractionResponse(interaction, `${emoji} ${result.message}`);
} else if (data.name === "bounty-rank") {
await respondToInteraction(interaction, "Checking your rank...", true);
try {
const db = await getDb();
const entry = await findByDiscord(db, discordId);
if (!entry) {
await editInteractionResponse(
interaction,
"You haven't linked your GitHub yet. Use `/link-github <username>` first."
);
return;
}
let msg = `**${entry.githubDisplay}** (linked)`;
const level = await getLurkrLevel(discordId);
if (level) {
msg += `\nLevel **${level.level}** | **${level.xp}** XP`;
}
await editInteractionResponse(interaction, msg);
} catch (err) {
console.error("Rank lookup failed:", err);
await editInteractionResponse(interaction, "Failed to look up your rank. Try again later.");
}
}
}
async function respondToInteraction(interaction: any, content: string, ephemeral = false) {
await fetch(`${DISCORD_API}/interactions/${interaction.id}/${interaction.token}/callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: 4,
data: { content, flags: ephemeral ? 64 : 0 },
}),
});
}
async function editInteractionResponse(interaction: any, content: string) {
await fetch(
`${DISCORD_API}/webhooks/${DISCORD_APP_ID}/${interaction.token}/messages/@original`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
}
);
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log("Starting Bounty Bot...");
const db = await getDb();
await ensureIndexes(db);
console.log("MongoDB connected");
await registerCommands();
await connectGateway();
}
main().catch(async (err) => {
console.error(err);
await closeDb();
process.exit(1);
});
// Graceful shutdown
process.on("SIGINT", async () => {
await closeDb();
process.exit(0);
});
+25 -8
View File
@@ -19,6 +19,7 @@
import { readFileSync } from "fs";
import { join } from "path";
import { getDb, closeDb, loadContributorMap } from "./contributors-db";
// ---------------------------------------------------------------------------
// Types
@@ -181,11 +182,10 @@ function parseContributorsYaml(raw: string): Contributor[] {
return contributors;
}
function loadContributors(): Map<string, Contributor> {
function loadContributorsFromYaml(): Map<string, Contributor> {
const map = new Map<string, Contributor>();
try {
// Resolve path relative to the script location (scripts/ dir → repo root)
const scriptDir = new URL(".", import.meta.url).pathname;
const raw = readFileSync(
join(scriptDir, "..", "contributors.yml"),
@@ -203,6 +203,20 @@ function loadContributors(): Map<string, Contributor> {
return map;
}
async function loadContributors(): Promise<Map<string, Contributor>> {
if (process.env.MONGODB_URI) {
try {
const db = await getDb();
const map = await loadContributorMap(db);
console.log(`Loaded ${map.size} contributors from MongoDB`);
return map;
} catch (err) {
console.warn(`MongoDB load failed, falling back to YAML: ${err}`);
}
}
return loadContributorsFromYaml();
}
function resolveDiscord(
githubUsername: string,
contributors: Map<string, Contributor>
@@ -285,7 +299,7 @@ function formatBountyNotification(bounty: BountyResult): string {
msg += `PR: ${bounty.pr.html_url}\n`;
if (!bounty.discordId) {
msg += `\n_\u{1F517} @${bounty.contributor}: link your Discord in \`contributors.yml\` to get pinged!_`;
msg += `\n_\u{1F517} @${bounty.contributor}: use \`/link-github\` in Discord to get pinged!_`;
}
return msg;
@@ -454,7 +468,7 @@ async function main() {
process.exit(1);
}
const contributors = loadContributors();
const contributors = await loadContributors();
if (mode === "notify") {
// Single bounty notification
@@ -523,10 +537,12 @@ async function main() {
}
// Run if invoked directly
main().catch((err) => {
console.error(err);
process.exit(1);
});
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => closeDb());
// Export for testing
export {
@@ -535,6 +551,7 @@ export {
formatBountyNotification,
formatLeaderboard,
loadContributors,
loadContributorsFromYaml,
resolveDiscord,
awardLurkrXP,
lurkrAddXP,
+146
View File
@@ -0,0 +1,146 @@
/**
* MongoDB-backed contributor identity store.
*
* Stores GitHub ↔ Discord mappings in a `contributors` collection.
* Used by bounty-bot.ts (read/write) and bounty-tracker.ts (read).
*
* Environment:
* MONGODB_URI — MongoDB connection string (required)
*/
import { MongoClient, type Collection, type Db } from "mongodb";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ContributorDoc {
github: string; // GitHub username (lowercased for lookups)
githubDisplay: string; // Original-case GitHub username
discord: string; // Discord user ID
name?: string;
linkedAt: Date;
}
// ---------------------------------------------------------------------------
// Connection
// ---------------------------------------------------------------------------
let _client: MongoClient | null = null;
let _db: Db | null = null;
export async function getDb(): Promise<Db> {
if (_db) return _db;
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error("Missing MONGODB_URI environment variable");
}
_client = new MongoClient(uri);
await _client.connect();
_db = _client.db("hive");
return _db;
}
export async function closeDb(): Promise<void> {
if (_client) {
await _client.close();
_client = null;
_db = null;
}
}
function collection(db: Db): Collection<ContributorDoc> {
return db.collection<ContributorDoc>("contributors");
}
// ---------------------------------------------------------------------------
// Read operations
// ---------------------------------------------------------------------------
export async function findByGithub(
db: Db,
githubUsername: string
): Promise<ContributorDoc | null> {
return collection(db).findOne({ github: githubUsername.toLowerCase() });
}
export async function findByDiscord(
db: Db,
discordId: string
): Promise<ContributorDoc | null> {
return collection(db).findOne({ discord: discordId });
}
export async function getAllContributors(
db: Db
): Promise<ContributorDoc[]> {
return collection(db).find().toArray();
}
/**
* Build a Map<github_lowercase, { github, discord, name }> for compatibility
* with bounty-tracker.ts's existing contributor resolution.
*/
export async function loadContributorMap(
db: Db
): Promise<Map<string, { github: string; discord: string; name?: string }>> {
const docs = await getAllContributors(db);
const map = new Map<string, { github: string; discord: string; name?: string }>();
for (const doc of docs) {
map.set(doc.github, {
github: doc.githubDisplay,
discord: doc.discord,
name: doc.name,
});
}
return map;
}
// ---------------------------------------------------------------------------
// Write operations
// ---------------------------------------------------------------------------
export async function addContributor(
db: Db,
githubUsername: string,
discordId: string,
name?: string
): Promise<{ success: boolean; message: string }> {
const existing = await findByGithub(db, githubUsername);
if (existing) {
return { success: false, message: `**${githubUsername}** is already linked.` };
}
const byDiscord = await findByDiscord(db, discordId);
if (byDiscord) {
return {
success: false,
message: `Your Discord is already linked to **${byDiscord.githubDisplay}**. To change it, ask a maintainer.`,
};
}
await collection(db).insertOne({
github: githubUsername.toLowerCase(),
githubDisplay: githubUsername,
discord: discordId,
name,
linkedAt: new Date(),
});
return {
success: true,
message: `Linked! **${githubUsername}** ↔ Discord. You'll now receive XP when your bounty PRs are merged.`,
};
}
// ---------------------------------------------------------------------------
// Index setup (run once)
// ---------------------------------------------------------------------------
export async function ensureIndexes(db: Db): Promise<void> {
const coll = collection(db);
await coll.createIndex({ github: 1 }, { unique: true });
await coll.createIndex({ discord: 1 }, { unique: true });
}