tech/discord

DISCORD

Discord integration skill.

production Discord REST API v10, Interactions Endpoint, Cloudflare Workers, Ed25519
improves: tech

Discord Integration

Discord bots work differently to Slack bots. Rather than polling a WebSocket gateway (which Workers can't maintain), Discord supports an Interactions Endpoint URL — Discord pushes every slash command and button click to your HTTP endpoint. Cloudflare Workers are ideal.

The key difference from Slack: Discord uses Ed25519 (not HMAC-SHA256) for request verification. You also respond differently — a deferred response (type 5) tells Discord to show "Bot is thinking..." while your Worker processes asynchronously and patches the message when ready.

The 2nth.ai pattern: /ask → Worker verifies Ed25519 → responds type 5 → waitUntil → Workers AI classify → Claude draft → PATCH original interaction response.

App setup

1. Create the app

  1. Go to discord.com/developers/applications → New Application
  2. Name it (e.g. 2nth Assistant) → Create
  3. Bot → Add Bot → copy the Bot Token
  4. General Information → copy the Application ID and Public Key
  5. OAuth2 → URL Generator → scopes: bot, applications.commands → copy invite URL → add to server

2. Set Interactions Endpoint URL

General Information → Interactions Endpoint URL → https://your-worker.workers.dev/discord

Discord will immediately POST a PING to verify the URL. Your Worker must verify the signature and respond with { "type": 1 } (PONG).

Required secrets

wrangler secret put DISCORD_PUBLIC_KEY    # from General Information → Public Key
wrangler secret put DISCORD_BOT_TOKEN     # from Bot → Token (xoxb-style, starts with Bot prefix)
wrangler secret put DISCORD_APP_ID        # Application ID (snowflake)

Ed25519 signature verification

Discord uses Ed25519, not HMAC. The signed message is timestamp + body (concatenated, not separated).

function hexToBytes(hex: string): Uint8Array {
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < hex.length; i += 2) {
    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
  }
  return bytes;
}

async function verifyDiscordSignature(request: Request, env: Env): Promise<{ valid: boolean; body: string }> {
  const signature = request.headers.get('X-Signature-Ed25519') ?? '';
  const timestamp  = request.headers.get('X-Signature-Timestamp') ?? '';
  const body = await request.text();

  if (!signature || !timestamp) return { valid: false, body };

  const key = await crypto.subtle.importKey(
    'raw',
    hexToBytes(env.DISCORD_PUBLIC_KEY),
    { name: 'Ed25519', namedCurve: 'Ed25519' },
    false,
    ['verify']
  );

  const valid = await crypto.subtle.verify(
    'Ed25519',
    key,
    hexToBytes(signature),
    new TextEncoder().encode(timestamp + body)
  );

  return { valid, body };
}

Main Worker handler

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (request.method !== 'POST') return new Response('Method not allowed', { status: 405 });

    const { valid, body } = await verifyDiscordSignature(request, env);
    if (!valid) return new Response('Invalid signature', { status: 401 });

    const interaction = JSON.parse(body);

    // Type 1 — Discord PING (sent on URL setup and periodically)
    if (interaction.type === 1) {
      return Response.json({ type: 1 }); // PONG
    }

    // Type 2 — Application Command (slash command)
    if (interaction.type === 2) {
      return handleSlashCommand(interaction, env, ctx);
    }

    // Type 3 — Message Component (button/select menu click)
    if (interaction.type === 3) {
      return handleComponent(interaction, env, ctx);
    }

    // Type 5 — Modal submit
    if (interaction.type === 5) {
      return handleModalSubmit(interaction, env, ctx);
    }

    return new Response('Unknown interaction type', { status: 400 });
  }
};

Deferred responses — the AI pattern

Discord expects a response within 3 seconds. AI takes longer. Respond with type 5 (DEFERRED) immediately, then patch the response when the AI is ready. Discord shows "Bot is thinking…" to the user in the meantime.

async function handleSlashCommand(
  interaction: any,
  env: Env,
  ctx: ExecutionContext
): Promise<Response> {
  const commandName = interaction.data.name;
  const options = interaction.data.options ?? [];
  const query = options.find((o: any) => o.name === 'question')?.value ?? '';

  // DEFER immediately — Discord sees this as a valid response < 3s
  const deferResponse = Response.json({
    type: 5,  // DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
    data: { flags: 0 }, // 0 = public, 64 = ephemeral (only visible to user)
  });

  // Process async after response is sent
  ctx.waitUntil(processSlashCommand(interaction, query, env));

  return deferResponse;
}

async function processSlashCommand(interaction: any, query: string, env: Env) {
  const { application_id, token } = interaction;

  try {
    // 1. Classify with Workers AI
    const classification = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
      messages: [
        { role: 'system', content: 'Classify as: erp_query | report | general | help. One word only.' },
        { role: 'user', content: query.slice(0, 500) },
      ],
    });
    const intent = classification.response.trim().toLowerCase();

    // 2. Draft reply with Claude
    const reply = await draftWithClaude(query, intent, env);

    // 3. PATCH the deferred response with the real content
    await patchInteractionResponse(application_id, token, {
      embeds: [buildEmbed(query, reply, intent)],
      components: [buildActionRow()],
    }, env);

  } catch (err) {
    // Patch with error message if something goes wrong
    await patchInteractionResponse(application_id, token, {
      content: '⚠️ Something went wrong. Please try again.',
    }, env);
  }
}

async function patchInteractionResponse(appId: string, token: string, data: any, env: Env) {
  await fetch(
    `https://discord.com/api/v10/webhooks/${appId}/${token}/messages/@original`,
    {
      method: 'PATCH',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`,
      },
      body: JSON.stringify(data),
    }
  );
}

Registering slash commands

Commands must be registered via the REST API before Discord shows them to users. Do this once on deploy, or on app boot for guild-specific commands.

// Register commands globally (takes up to 1 hour to propagate)
async function registerCommands(env: Env) {
  const commands = [
    {
      name: 'ask',
      description: 'Ask the 2nth AI assistant anything',
      options: [
        {
          name: 'question',
          description: 'Your question',
          type: 3, // STRING
          required: true,
        },
      ],
    },
    {
      name: 'invoice',
      description: 'Query invoice status',
      options: [
        { name: 'client', description: 'Client name', type: 3, required: false },
        { name: 'status', description: 'Status filter', type: 3, required: false,
          choices: [
            { name: 'Open', value: 'open' },
            { name: 'Overdue', value: 'overdue' },
            { name: 'Paid', value: 'paid' },
          ],
        },
      ],
    },
    {
      name: 'report',
      description: 'Generate a business report',
      options: [
        { name: 'type', description: 'Report type', type: 3, required: true,
          choices: [
            { name: 'Revenue', value: 'revenue' },
            { name: 'Invoices', value: 'invoices' },
            { name: 'Support', value: 'support' },
          ],
        },
      ],
    },
  ];

  await fetch(`https://discord.com/api/v10/applications/${env.DISCORD_APP_ID}/commands`, {
    method: 'PUT', // PUT replaces all global commands atomically
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`,
    },
    body: JSON.stringify(commands),
  });
}

// Register to a specific guild (instant, good for development)
async function registerGuildCommands(guildId: string, env: Env) {
  await fetch(
    `https://discord.com/api/v10/applications/${env.DISCORD_APP_ID}/guilds/${guildId}/commands`,
    { /* same as above */ }
  );
}

Embeds — rich message formatting

Embeds are Discord's equivalent of Block Kit. Use a left-border colour to indicate intent.

function buildEmbed(question: string, answer: string, intent: string) {
  const colours: Record<string, number> = {
    erp_query: 0x007a5a,  // green
    report:    0x1264a3,  // blue
    general:   0x5865F2,  // discord blurple
    help:      0xFEE75C,  // yellow
  };

  return {
    color: colours[intent] ?? 0x5865F2,
    author: {
      name: '2nth Assistant',
      icon_url: 'https://skills.2nth.ai/logo.png',
    },
    description: answer,
    footer: {
      text: `Powered by 2nth.ai · Workers AI + Claude`,
    },
    timestamp: new Date().toISOString(),
    fields: question ? [
      { name: 'Question', value: `> ${question}`, inline: false },
    ] : [],
  };
}

Message components — buttons and select menus

function buildActionRow() {
  return {
    type: 1, // ACTION_ROW
    components: [
      {
        type: 2, // BUTTON
        style: 3, // SUCCESS (green)
        label: '👍 Helpful',
        custom_id: 'feedback_positive',
      },
      {
        type: 2, // BUTTON
        style: 2, // SECONDARY
        label: '✏️ Refine',
        custom_id: 'feedback_refine',
      },
      {
        type: 2,
        style: 2,
        label: '📋 Save',
        custom_id: 'save_response',
      },
    ],
  };
}

// Handle button clicks
async function handleComponent(interaction: any, env: Env, ctx: ExecutionContext): Promise<Response> {
  const customId = interaction.data.custom_id;

  if (customId === 'save_response') {
    // Get the message content from the interaction
    const content = interaction.message.embeds[0]?.description ?? '';
    await env.DB.prepare(
      'INSERT INTO saved_responses (user_id, content, guild_id, saved_at) VALUES (?,?,?,?)'
    ).bind(interaction.member?.user?.id, content, interaction.guild_id, Date.now()).run();

    // Update message — remove buttons, add saved confirmation
    return Response.json({
      type: 7, // UPDATE_MESSAGE
      data: {
        embeds: interaction.message.embeds,
        components: [{
          type: 1,
          components: [{
            type: 2, style: 2,
            label: '✅ Saved to database',
            custom_id: 'saved_done',
            disabled: true,
          }],
        }],
      },
    });
  }

  // Acknowledge other button clicks with a hidden message
  return Response.json({
    type: 4, // CHANNEL_MESSAGE_WITH_SOURCE
    data: {
      content: customId === 'feedback_positive' ? '✅ Glad it helped!' : '✏️ What would you like changed?',
      flags: 64, // EPHEMERAL — only visible to the button clicker
    },
  });
}

Webhooks — simple one-way notifications

Webhooks don't require a bot token. Create one in any Discord channel and POST to it for alerts.

// Create webhook in Discord: Channel settings → Integrations → Webhooks → New Webhook
async function sendWebhook(webhookUrl: string, content: string, embed?: object) {
  await fetch(webhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      username: '2nth Alerts',
      avatar_url: 'https://skills.2nth.ai/logo.png',
      content,
      embeds: embed ? [embed] : [],
    }),
  });
}

// From a Pub/Sub trigger (GCP → Worker):
await sendWebhook(env.DISCORD_WEBHOOK_OPS, '', {
  color: 0xE01E5A, // red = alert
  title: '⚠️ Overdue Invoice Alert',
  description: 'Acme Corp owes **R 24,500** — 18 days overdue',
  fields: [
    { name: 'Invoice', value: '#INV-2024-0847', inline: true },
    { name: 'Due date', value: '14 Mar 2026', inline: true },
    { name: 'Action', value: '[View in ERP](https://erp.2nth.ai/invoices/0847)', inline: false },
  ],
  timestamp: new Date().toISOString(),
});

AI draft helper

async function draftWithClaude(query: string, intent: string, env: Env): Promise<string> {
  const systemPrompts: Record<string, string> = {
    erp_query: 'You are an ERP assistant. Answer concisely with key data points. Use Discord markdown formatting.',
    report:    'You are a business analyst. Provide structured summaries with bullet points and bold key figures.',
    general:   'You are a helpful business assistant. Be concise and direct. Use Discord markdown.',
    help:      'You are explaining the 2nth.ai Discord bot. List commands and capabilities clearly.',
  };

  const res = await fetch(env.AI_GATEWAY_URL + '/anthropic/v1/messages', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'x-api-key': env.ANTHROPIC_API_KEY },
    body: JSON.stringify({
      model: 'claude-sonnet-4-6',
      max_tokens: 500,
      system: systemPrompts[intent] ?? systemPrompts.general,
      messages: [{ role: 'user', content: query }],
    }),
  }).then(r => r.json());

  return res.content[0].text;
}

Interaction response type reference

TypeNameWhen to use
1PONGReply to Discord's PING on URL setup
4CHANNEL_MESSAGE_WITH_SOURCEInstant reply (< 3s, no AI)
5DEFERRED_CHANNEL_MESSAGEShow "thinking…" — use for AI, PATCH later
6DEFERRED_UPDATE_MESSAGEDefer update to an existing message
7UPDATE_MESSAGEReplace a component message in-place

Ephemeral flag (flags: 64) makes a reply only visible to the user who triggered it. Use for feedback confirmations and error messages.

Rate limits

EndpointLimitNotes
Global50 req/sec per botAcross all endpoints
POST /messages per channel5 req/5sBack off with exponential retry
Webhook30 req/minPer webhook URL
PATCH /messages/@original5 req/secFollowup to deferred interaction
Global commands PUT2 per dayUse guild commands in development

Gotchas