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
- Node.js 18+ (for native
fetch) - A Discord bot application with a token. Create one at discord.com/developers/applications.
- The bot invited to your server with the
applications.commandsandbotscopes.
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:
/search query:blue hair on pixiv from 2025/search query:by hews__ on twitter limit:3/artist handle:hews__
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
- Add a
/tagscommand using the /api/v1/tags endpoint for tag discovery. - Add autocomplete to the search command using Discord's autocomplete interactions.
- Rate limits and pricing for higher-throughput use cases.