CastyrDocs

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

  1. /castyr link: a private message with a code, a phrase, and an Open Castyr button.
  2. They sign in to Castyr, check the code, pick the phrase and approve.
  3. The message changes to ✅ Linked to Castyr account @username.
  4. /castyr whoami shows the linked account; /castyr unlink disconnects it.
  5. 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 dotenv

Add your secrets

.env
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.

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 in castyr-links.json.
  • Always fresh. startAutoRefresh() renews tokens before they expire and checks every link every 5 minutes.
  • Both sides agree. /castyr unlink removes the bot from the member's Castyr account pages; a sign-out on Castyr triggers onUnlinked, 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:

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: FileTokenStore is 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; identify covers everything above.

On this page