Guides / Discord Bot

Discord Bot

Build a Discord bot with /search and /artist slash commands that return art metadata in rich embeds. Uses discord.js v14 and Node.js 18+.

Prerequisites

Set up the project

mkdir lagoon-bot && cd lagoon-bot npm init -y npm install discord.js

Create a .env file with your bot credentials. Both values are in the Discord developer portal.

DISCORD_TOKEN=your_bot_token CLIENT_ID=your_application_id

Register slash commands

Create deploy-commands.mjs. Run this once to register your commands with Discord.

// deploy-commands.mjs import { REST, Routes, SlashCommandBuilder } from "discord.js"; const token = process.env.DISCORD_TOKEN; const clientId = process.env.CLIENT_ID; const commands = [ new SlashCommandBuilder() .setName("search") .setDescription("Search Lagoon for art metadata") .addStringOption(opt => opt.setName("query") .setDescription("Natural language search query") .setRequired(true)) .addIntegerOption(opt => opt.setName("limit") .setDescription("Number of results (1-10)") .setMinValue(1) .setMaxValue(10)), new SlashCommandBuilder() .setName("artist") .setDescription("Look up an artist across platforms") .addStringOption(opt => opt.setName("handle") .setDescription("Artist handle to look up") .setRequired(true)), ]; const rest = new REST().setToken(token); const data = await rest.put( Routes.applicationCommands(clientId), { body: commands.map(c => c.toJSON()) } ); console.log(`Registered ${data.length} commands.`);

Run it:

node --env-file=.env deploy-commands.mjs

Build the bot

Create bot.mjs with the command handlers.

// bot.mjs import { Client, GatewayIntentBits, EmbedBuilder } from "discord.js"; const LAGOON = "https://lagoon.io/api/v1"; const client = new Client({ intents: [GatewayIntentBits.Guilds] }); // Helper: call Lagoon API and return parsed JSON async function lagoon(endpoint, params) { const url = new URL(`${LAGOON}/${endpoint}`); for (const [k, v] of Object.entries(params)) { url.searchParams.set(k, String(v)); } const resp = await fetch(url); return resp.json(); } client.on("interactionCreate", async interaction => { if (!interaction.isChatInputCommand()) return; try { if (interaction.commandName === "search") { await handleSearch(interaction); } else if (interaction.commandName === "artist") { await handleArtist(interaction); } } catch (err) { console.error(err); const msg = { content: `Something went wrong: ${err.message}`, ephemeral: true }; if (interaction.deferred) { await interaction.editReply(msg); } else { await interaction.reply(msg); } } }); // /search handler async function handleSearch(interaction) { await interaction.deferReply(); const query = interaction.options.getString("query"); const limit = interaction.options.getInteger("limit") || 5; const data = await lagoon("search", { nl: query, limit, }); if (!data.ok) { return interaction.editReply(`Error: ${data.error}`); } if (data.results.length === 0) { return interaction.editReply("No results found."); } const embed = new EmbedBuilder() .setTitle(`Search: ${query}`) .setColor(0x8154a3); for (const post of data.results) { const tags = post.tags .filter(t => !t.startsWith("rating:")) .slice(0, 5) .join(", "); const link = post.source_url ? `[View source](${post.source_url})` : "(source removed)"; embed.addFields({ name: (post.title || `Post #${post.id}`).slice(0, 256), value: `By **${post.artist_handle}** on ${post.platform_name}\n${tags}\n${link}`, }); } // Show parsed filters in the footer if (data.filters) { const parts = Object.entries(data.filters) .filter(([k]) => k !== "sfw") .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`); if (parts.length) { embed.setFooter({ text: `Parsed: ${parts.join(" | ")}` }); } } await interaction.editReply({ embeds: [embed] }); } // /artist handler async function handleArtist(interaction) { await interaction.deferReply(); const handle = interaction.options.getString("handle"); const data = await lagoon("artist", { handle }); if (!data.ok) { return interaction.editReply(`Artist not found: **${handle}**`); } const { artist } = data; const embed = new EmbedBuilder() .setTitle(artist.display_name || artist.handle) .setColor(0x8154a3) .addFields( { name: "Posts", value: String(artist.post_count), inline: true }, { name: "Platforms", value: String(artist.platforms.length), inline: true }, ); for (const p of artist.platforms) { const value = p.profile_url ? `[${p.handle}](${p.profile_url})` : p.handle; embed.addFields({ name: p.platform_name, value, inline: true, }); } await interaction.editReply({ embeds: [embed] }); } client.once("ready", () => { console.log(`Logged in as ${client.user.tag}`); }); client.login(process.env.DISCORD_TOKEN);

Run the bot

node --env-file=.env bot.mjs

Try it in your Discord server:

Authenticate with an API key

Unauthenticated requests are rate-limited to 30 per minute. To use a higher-limit tier, add your API key to .env and pass it in the fetch header.

// In .env, add: // LAGOON_KEY=lg_your_key_here // Update the lagoon() helper: async function lagoon(endpoint, params) { const url = new URL(`${LAGOON}/${endpoint}`); for (const [k, v] of Object.entries(params)) { url.searchParams.set(k, String(v)); } const headers = {}; if (process.env.LAGOON_KEY) { headers["X-API-Key"] = process.env.LAGOON_KEY; } const resp = await fetch(url, { headers }); return resp.json(); }

Next steps