Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions bot/wasteland-discord/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Discord bot token (from https://discord.com/developers/applications)
DISCORD_BOT_TOKEN=

# Channel ID where notifications are posted
DISCORD_CHANNEL_ID=

# DoltHub upstream database (for rig verification)
DOLTHUB_UPSTREAM=hop/wl-commons

# HTTP port for the notify endpoint
PORT=3141
242 changes: 242 additions & 0 deletions bot/wasteland-discord/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
const { Client, GatewayIntentBits, EmbedBuilder, REST, Routes, SlashCommandBuilder } = require("discord.js");
const express = require("express");

// --- Config ---

const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
const CHANNEL_ID = process.env.DISCORD_CHANNEL_ID;
const UPSTREAM = process.env.DOLTHUB_UPSTREAM || "hop/wl-commons";
const PORT = parseInt(process.env.PORT || "3141", 10);

if (!BOT_TOKEN || !CHANNEL_ID) {
console.error("Missing DISCORD_BOT_TOKEN or DISCORD_CHANNEL_ID");
process.exit(1);
}

// --- Rig verification cache ---

const rigCache = new Map(); // handle -> { verified: bool, expiresAt: number }
const RIG_CACHE_TTL = 5 * 60 * 1000; // 5 minutes

async function isRegisteredRig(handle) {
const cached = rigCache.get(handle);
if (cached && cached.expiresAt > Date.now()) return cached.verified;

try {
const [owner, db] = UPSTREAM.split("/");
const url = `https://www.dolthub.com/api/v1alpha1/${owner}/${db}/main?q=${encodeURIComponent(
`SELECT handle FROM rigs WHERE handle = '${handle.replace(/'/g, "''")}'`
)}`;
const res = await fetch(url);
if (!res.ok) {
console.warn(`DoltHub API returned ${res.status}, failing open for ${handle}`);
return true;
}
const data = await res.json();
const verified = data.rows && data.rows.length > 0;
rigCache.set(handle, { verified, expiresAt: Date.now() + RIG_CACHE_TTL });
return verified;
} catch (err) {
console.warn(`Rig verification failed for ${handle}, failing open:`, err.message);
return true;
}
}

// --- Color codes ---

const COLORS = {
posted: 0x2ECC71, // green
claimed: 0xF8A500, // yellow
completed: 0x348ABB, // blue
validated: 0x9B59B6, // purple
};

// --- Discord bot ---

const client = new Client({ intents: [GatewayIntentBits.Guilds] });
let notifyChannel = null;

client.once("ready", async () => {
console.log(`Bot ready as ${client.user.tag}`);
notifyChannel = await client.channels.fetch(CHANNEL_ID);
if (!notifyChannel) {
console.error(`Channel ${CHANNEL_ID} not found`);
process.exit(1);
}
console.log(`Posting to #${notifyChannel.name}`);
await registerCommands();
});

// --- Slash commands ---

async function registerCommands() {
const commands = [
new SlashCommandBuilder()
.setName("wasteland-status")
.setDescription("Show the Wasteland wanted board summary"),
new SlashCommandBuilder()
.setName("wasteland-rigs")
.setDescription("Show registered rigs"),
];

const rest = new REST().setToken(BOT_TOKEN);
try {
await rest.put(Routes.applicationCommands(client.user.id), {
body: commands.map((c) => c.toJSON()),
});
console.log("Slash commands registered");
} catch (err) {
console.error("Failed to register slash commands:", err.message);
}
}

client.on("interactionCreate", async (interaction) => {
if (!interaction.isChatInputCommand()) return;

if (interaction.commandName === "wasteland-status") {
await handleStatus(interaction);
} else if (interaction.commandName === "wasteland-rigs") {
await handleRigs(interaction);
}
});

async function queryDoltHub(sql) {
const [owner, db] = UPSTREAM.split("/");
const url = `https://www.dolthub.com/api/v1alpha1/${owner}/${db}/main?q=${encodeURIComponent(sql)}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`DoltHub API: ${res.status}`);
return res.json();
}

async function handleStatus(interaction) {
await interaction.deferReply();
try {
const data = await queryDoltHub(
"SELECT id, title, status, effort_level, claimed_by FROM wanted ORDER BY CASE status WHEN 'open' THEN 0 WHEN 'claimed' THEN 1 WHEN 'in_review' THEN 2 ELSE 3 END, priority ASC LIMIT 15"
);

if (!data.rows || data.rows.length === 0) {
await interaction.editReply("The wanted board is empty.");
return;
}

const lines = data.rows.map((r) => {
const status = r.status === "open" ? "\u{1F7E2}" : r.status === "claimed" ? "\u{1F7E1}" : "\u{1F535}";
const claim = r.claimed_by ? ` (${r.claimed_by})` : "";
return `${status} **${r.id}** \u2014 ${r.title}${claim}`;
});

const embed = new EmbedBuilder()
.setTitle("Wasteland Wanted Board")
.setDescription(lines.join("\n"))
.setColor(0x2ECC71)
.setFooter({ text: `Wasteland \u2014 ${UPSTREAM}` });

await interaction.editReply({ embeds: [embed] });
} catch (err) {
await interaction.editReply(`Failed to fetch board: ${err.message}`);
}
}

async function handleRigs(interaction) {
await interaction.deferReply();
try {
const data = await queryDoltHub(
"SELECT handle, display_name, trust_level, rig_type FROM rigs ORDER BY registered_at DESC LIMIT 15"
);

if (!data.rows || data.rows.length === 0) {
await interaction.editReply("No rigs registered yet.");
return;
}

const trustLabels = ["outsider", "registered", "contributor", "maintainer"];
const lines = data.rows.map((r) => {
const trust = trustLabels[r.trust_level] || `level ${r.trust_level}`;
return `**${r.handle}** \u2014 ${r.display_name || "\u2014"} (${r.rig_type}, ${trust})`;
});

const embed = new EmbedBuilder()
.setTitle("Registered Rigs")
.setDescription(lines.join("\n"))
.setColor(0x9B59B6)
.setFooter({ text: `Wasteland \u2014 ${UPSTREAM}` });

await interaction.editReply({ embeds: [embed] });
} catch (err) {
await interaction.editReply(`Failed to fetch rigs: ${err.message}`);
}
}

// --- HTTP notify endpoint ---

const app = express();
app.use(express.json());

app.post("/notify", async (req, res) => {
const { handle, event, title, id, evidence, effort } = req.body;

if (!handle || !event || !title || !id) {
return res.status(400).json({ error: "Missing required fields: handle, event, title, id" });
}

const valid = await isRegisteredRig(handle);
if (!valid) {
return res.status(403).json({ error: `Unknown rig: ${handle}` });
}

if (!notifyChannel) {
return res.status(503).json({ error: "Bot not ready" });
}

const color = COLORS[event] || 0x95A5A6;
const embed = new EmbedBuilder().setColor(color).setFooter({ text: `Wasteland \u2014 ${UPSTREAM}` });
const fields = [{ name: "By", value: handle, inline: true }];

switch (event) {
case "posted":
embed.setTitle(`New Wanted: ${id}`);
embed.setDescription(title);
if (effort) fields.push({ name: "Effort", value: effort, inline: true });
break;
case "claimed":
embed.setTitle(`Claimed: ${id}`);
embed.setDescription(title);
break;
case "completed":
embed.setTitle(`Completion: ${id}`);
embed.setDescription(title);
fields.push({ name: "Status", value: "in_review", inline: true });
if (evidence) fields.push({ name: "Evidence", value: evidence.substring(0, 200) });
break;
case "validated":
embed.setTitle(`Validated: ${id}`);
embed.setDescription(title);
break;
default:
embed.setTitle(`${event}: ${id}`);
embed.setDescription(title);
}

embed.addFields(fields);

try {
await notifyChannel.send({ embeds: [embed] });
res.json({ ok: true });
} catch (err) {
console.error("Failed to send Discord message:", err.message);
res.status(500).json({ error: "Failed to post to Discord" });
}
});

app.get("/health", (_req, res) => {
res.json({ ok: true, channel: CHANNEL_ID, upstream: UPSTREAM });
});

// --- Start ---

app.listen(PORT, () => {
console.log(`Notify endpoint listening on :${PORT}`);
});

client.login(BOT_TOKEN);
13 changes: 13 additions & 0 deletions bot/wasteland-discord/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "wasteland-discord-bot",
"version": "1.0.0",
"description": "Discord bot for Wasteland notifications — posts wanted board activity to a community channel",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"discord.js": "^14.16.0",
"express": "^4.21.0"
}
}
Loading