Guide: a Discord bot
A discord.js bot that lets members link their Castyr account with /castyr link.
Built with the Bot SDK and discord.js. It keeps links across restarts and tells members when a link ends.
What members see
/castyr link: a private message with a code, a phrase, and an Open Castyr button.- They sign in to Castyr, check the code, pick the phrase and approve.
- The message changes to ✅ Linked to Castyr account @username.
/castyr whoamishows the linked account;/castyr unlinkdisconnects it.- If they sign the bot out on sso.castyr.cloud, the bot DMs them that they're no longer connected.
Set up
Create the Discord application
At discord.com/developers/applications, copy the
Application ID; under Bot, reset and copy the token. Invite the bot to your server with the bot
and applications.commands scopes.
Register a Castyr Bot app
Under Developers, register a Bot app and copy its client_id.
Create the project
mkdir castyr-discord-bot && cd castyr-discord-bot
npm init -y && npm pkg set type=module
npm install discord.js @castyr/bots-auth dotenvAdd your secrets
DISCORD_TOKEN=your-discord-bot-token
DISCORD_APP_ID=your-discord-application-id
CASTYR_CLIENT_ID=client_…Add .env and castyr-links.json to .gitignore: they hold your secrets and your members' tokens.
The bot
Save as discord-bot.mjs and run node discord-bot.mjs.
// A Discord bot that links each member's Castyr account with /castyr link. Links are saved to a file
// and renewed automatically, so members link once and stay linked across bot restarts.
//
// npm install discord.js @castyr/bots-auth dotenv
// Put DISCORD_TOKEN, DISCORD_APP_ID and CASTYR_CLIENT_ID in .env, then: node discord-bot.mjs
//
// DISCORD_TOKEN / DISCORD_APP_ID: https://discord.com/developers/applications (Bot → Reset Token; General → Application ID)
// CASTYR_CLIENT_ID: register a "Bot" app at https://sso.castyr.cloud/developers
import "dotenv/config";
import {
ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, Events, GatewayIntentBits, MessageFlags, REST, Routes,
SlashCommandBuilder,
} from "discord.js";
import { ActivationDeniedError, ActivationExpiredError, CastyrBotAuth, FileTokenStore } from "@castyr/bots-auth";
const { DISCORD_TOKEN, DISCORD_APP_ID, CASTYR_CLIENT_ID } = process.env;
if (!DISCORD_TOKEN || !DISCORD_APP_ID || !CASTYR_CLIENT_ID) {
console.error("Set DISCORD_TOKEN, DISCORD_APP_ID and CASTYR_CLIENT_ID.");
process.exit(1);
}
// Links are saved per Discord user id in castyr-links.json (readable only by the bot's user).
// For several shards or servers, pass a database-backed store instead (see the package README).
const castyr = new CastyrBotAuth({
clientId: CASTYR_CLIENT_ID,
store: new FileTokenStore("castyr-links.json"),
// The member signed the bot out on sso.castyr.cloud (or the link lapsed): let them know.
onUnlinked: async (discordUserId, { reason, user }) => {
const account = user?.preferred_username ? ` **@${user.preferred_username}**` : "";
const why = reason === "revoked" ? "was signed out from your Castyr account" : "expired";
const member = await client.users.fetch(discordUserId).catch(() => null);
await member?.send(`🔌 You're no longer connected to Castyr: the link to${account} ${why}. Use \`/castyr link\` to connect again.`)
.catch(() => console.log(`couldn't DM ${discordUserId} about their Castyr link ending`));
},
});
// Renews tokens before they expire, and every 5 minutes checks for sign-outs on the Castyr site.
castyr.startAutoRefresh({ onError: (key, error) => console.error(`checking the Castyr link for ${key} failed:`, error.message) });
const linking = new Set(); // members with a code waiting, so they can't start two at once
const command = new SlashCommandBuilder()
.setName("castyr")
.setDescription("Your Castyr account")
.addSubcommand((s) => s.setName("link").setDescription("Link your Castyr account"))
.addSubcommand((s) => s.setName("whoami").setDescription("Show which Castyr account is linked"))
.addSubcommand((s) => s.setName("unlink").setDescription("Unlink your Castyr account"));
await new REST().setToken(DISCORD_TOKEN).put(Routes.applicationCommands(DISCORD_APP_ID), { body: [command.toJSON()] });
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, (c) => console.log(`Ready as ${c.user.tag}`));
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== "castyr") return;
const sub = interaction.options.getSubcommand();
try {
if (sub === "link") await link(interaction);
else if (sub === "whoami") await whoami(interaction);
else if (sub === "unlink") await unlink(interaction);
} catch (error) {
console.error(`/castyr ${sub} failed:`, error.message);
const reply = { content: "Something went wrong talking to Castyr. Try again in a moment." };
if (interaction.deferred || interaction.replied) await interaction.editReply(reply).catch(() => {});
else await interaction.reply({ ...reply, flags: MessageFlags.Ephemeral }).catch(() => {});
}
});
async function link(interaction) {
const userId = interaction.user.id;
if (linking.has(userId)) {
return interaction.reply({ content: "You already have a code waiting. Check your earlier message.", flags: MessageFlags.Ephemeral });
}
linking.add(userId);
try {
// Only the member sees this reply (ephemeral), so nobody else can grab their code.
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const activation = await castyr.startActivation({
platformName: `${interaction.client.user.username} on Discord`,
platformDescription: `Links the Discord account @${interaction.user.username} to your Castyr account.`,
platformIcon: interaction.client.user.displayAvatarURL({ extension: "png", size: 256 }),
scope: "identify",
});
const minutes = Math.round(activation.expiresIn / 60);
await interaction.editReply({
content: [
"**Link your Castyr account**",
`1. Open the link below and sign in to Castyr.`,
`2. Check the code is **\`${activation.userCode}\`**.`,
`3. Pick the phrase **${activation.confirmationPhrase}** and approve.`,
`The code works for ${minutes} minutes. Never share it.`,
].join("\n"),
components: [new ActionRowBuilder().addComponents(
new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel("Open Castyr").setURL(activation.verificationUriComplete),
)],
});
// Saved under the member's id as soon as they approve (replacing any earlier link).
await castyr.waitForAuthorization(activation, { key: userId });
const { user } = await castyr.getLink(userId);
await interaction.editReply({ content: `✅ Linked to Castyr account **@${user?.preferred_username ?? "unknown"}**.`, components: [] });
} catch (error) {
if (error instanceof ActivationDeniedError) {
await interaction.editReply({ content: "❌ Linking was cancelled (denied, or the wrong phrase was picked). Run `/castyr link` to try again.", components: [] });
} else if (error instanceof ActivationExpiredError) {
await interaction.editReply({ content: "⌛ The code expired. Run `/castyr link` to get a new one.", components: [] });
} else {
throw error;
}
} finally {
linking.delete(userId);
}
}
async function whoami(interaction) {
// Asks Castyr right now, so a sign-out on the site shows up immediately (and onUnlinked sends the DM).
if (!(await castyr.verifyLink(interaction.user.id))) {
return interaction.reply({ content: "You're not connected to Castyr. Use `/castyr link`.", flags: MessageFlags.Ephemeral });
}
const { user } = await castyr.getLink(interaction.user.id);
await interaction.reply({ content: `You're linked to **@${user?.preferred_username ?? "unknown"}** (${user?.name ?? "no display name"}).`, flags: MessageFlags.Ephemeral });
}
async function unlink(interaction) {
// Revokes the link at Castyr too, so it also disappears from the member's Castyr account pages.
await castyr.unlink(interaction.user.id);
await interaction.reply({ content: "Unlinked. The bot can no longer use your Castyr account, and it's gone from your Castyr devices.", flags: MessageFlags.Ephemeral });
}
client.login(DISCORD_TOKEN);
How it works
- Private codes. Replies are ephemeral, so only the member linking sees their code and phrase.
- Saved per member.
waitForAuthorization(activation, { key: userId })saves the link under the member's Discord id incastyr-links.json. - Always fresh.
startAutoRefresh()renews tokens before they expire and checks every link every 5 minutes. - Both sides agree.
/castyr unlinkremoves the bot from the member's Castyr account pages; a sign-out on Castyr triggersonUnlinked, which DMs the member.
What you can build with it
A link proves that a Discord member controls a Castyr account, and gives you that account's profile
(sub, name, username, avatar with the identify scope). Some ways to use that:
Verified role
Give linked members a role; take it away when the link ends.
Members-only commands
Gate commands and channels to linked members.
Castyr profiles
Show Castyr names and avatars in Discord.
One identity
Connect Discord activity to Castyr accounts in your own systems.
A "Castyr verified" role
The bot needs the Manage Roles permission, and its own role must sit above the one it hands out.
const GUILD_ID = process.env.DISCORD_GUILD_ID, ROLE_ID = process.env.CASTYR_ROLE_ID;
async function setVerified(userId, on) {
const guild = await client.guilds.fetch(GUILD_ID);
const member = await guild.members.fetch(userId).catch(() => null);
if (member) await (on ? member.roles.add(ROLE_ID) : member.roles.remove(ROLE_ID)).catch(() => {});
}
// after waitForAuthorization(...) succeeds: await setVerified(userId, true);
// in /castyr unlink and in onUnlinked: await setVerified(userId, false);Members-only commands and channels
if (!(await castyr.getAccessToken(interaction.user.id))) {
return interaction.reply({ content: "Link your Castyr account first: /castyr link", flags: MessageFlags.Ephemeral });
}Combine it with the verified role to open channels only to linked members.
Castyr profiles in Discord
Show a member's Castyr name and avatar in a /profile command or a welcome message, straight from
(await castyr.getLink(id)).user. verifyLink(id) refreshes it.
One identity across your services
Store the pair (Discord id, Castyr sub) in your database. sub never changes, even when the member renames
themselves on either side, so it's the right key to connect Discord activity with their Castyr account.
Before you go live
- One process:
FileTokenStoreis fine. Shards or several servers: use a database-backed store (see Where links are kept). - Keep
castyr-links.json(or the table) private and backed up, like passwords. - Ask for the fewest scopes you need;
identifycovers everything above.