GitHub avatar

Fox's Blog

Discord bot 100% serverless: Hono + Cloudflare Workers

How I replaced a Discord bot that cost me 50€/month with zero euros

100% serverless Discord bot: Hono + Cloudflare Workers = 💸 zero

I spent a few months running classic Discord bots on my own machine.

Always-open WebSocket connection. Bot auto-reconnects at 3 AM. Bot crashes because I looked at the sheep wrong. The bill goes up.

Then one day I realized: why keep a connection alive? Discord can just POST the stuff you care about. You reply, done.

Since 2021, Discord has had interaction endpoints.

It's just HTTP. No WebSocket. No persistent state. You get a request, you send JSON, that's it. The next request comes on its own.

And the best part: Cloudflare Workers is free up to 100k requests/day. For 90% of bots, that's 0€/month.

This article shows you how to build a Discord bot without WebSocket using Hono (ultra-light web framework) and Cloudflare Workers. I'll show you two real projects: Nibi (bot for learning Japanese, TTS, cool) and Konosuba-RPG (a full Discord game with real-time image rendering xD).

WebSocket vs. Interaction Endpoints: why it was a bad idea

Imagine a Minecraft world where you gotta keep the connection open even when you're not playing.

And the server auto-reconnects every time it crashes. You gotta handle timeouts, exponential backoff reconnections, all that shitty boilerplate everyone hates. Just to receive interactions.

Interaction endpoints are the opposite. Discord POSTs to your URL. You reply. Done.

If your server crashes? Discord retries 2-3 times and moves on. Zero drama.

Cost before: 50€/month on Heroku just to keep a Node process alive.

Cost after: 0€/month on Cloudflare up to 100k requests/day.

The architecture: what's it actually about?

Discord POSTs a request to your endpoint.

Discord: "Hey! User clicked /ping!"
      ↓
   Your URL (Cloudflare Worker)
      ↓
   You verify it's really Discord (signature check)
      ↓
   You parse the interaction type
      ↓
   You run the handler
      ↓
   You return JSON
      ↓
Discord: "Cool, I'll show that to the user"

It's pure HTTP. No magic. No heavy library.

Hono + Cloudflare Workers: the cheap combo

Hono is a web framework that weighs 12KB. It runs everywhere: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... same code everywhere.

Cloudflare Workers is compute at the edge. Your requests hit the nearest server. Response time: <100ms. Cost: free up to 100k requests/day.

The Hono + Cloudflare combo is the perfect match for a Discord bot.

Here's the minimal code for a complete bot:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Get the headers
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. Verify it's really Discord (not spam)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Parse what it sent
  const interaction = JSON.parse(body);

  // 4. Reply based on type
  if (interaction.type === 1) {
    // Discord test (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // It's a slash command
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

Like, 30 lines and it's a working bot.

No bot.login(). No event emitter. No callback hell. Just HTTP.

To deploy on Cloudflare:

npm install -D wrangler
npx wrangler deploy

Boom. You get a URL like https://my-bot.workers.dev/interactions.

You drop that in the Discord Developer Portal under "INTERACTIONS ENDPOINT URL", and Discord starts sending your interactions there.

Verifying the signature: no fake requests

Discord signs every request with a public key. If you get a request with a bad signature? That's spam. Ignore and move on.

The discord-interactions package does the job:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // exact raw text (not parsed JSON!)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // from Discord Dev Portal
);

Important trap: the signature depends on the exact body. If you parse JSON and re-stringify, or if you log the body, you break the signature.

Verify first. Parse after. That order matters.

Case 1: Nibi (Japanese learning bot)

Nibi is a Discord bot for learning Japanese. Simple commands:

  • /dictionary kanji → shows definitions
  • /pronounce テキスト → generates TTS (text-to-speech)
  • /hello → welcome message

Each command is a TypeScript file:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

A command implements this interface:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<InteractionResponse>;
}

The /pronounce command: making the bot talk

This one's wild. You send text (romaji, hiragana, kanji, whatever), the bot converts it to hiragana, generates TTS via VOICEVOX or Google TTS, and sends an audio message on Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Generates TTS for Japanese text',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Text to pronounce',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Convert romaji → hiragana with Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. Generate TTS audio
      const audioBuffer = await generateTTS(hiragana);

      // 3. Upload the file to Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Send the message with the audio
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // duration in seconds
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `Pronunciation for "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'Error: could not generate audio xD',
          flags: 64  // ephemeral (private message)
        }
      };
    }
  }
};

It's crazy: you call an external API, upload a file to Discord, send a message with the file. All without WebSocket, just HTTP.

Persistence with Supabase

Nibi uses Supabase as a key-value store. To check if a user is registered:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // Add the user
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

It's very basic (no real SQL queries, just JSON), but it works. For small bots it's perfect.

Case 2: Konosuba-RPG (Discord game with image rendering)

Okay this one's insane.

Konosuba-RPG is a full game on Discord. You fight mobs, gain XP, equip accessories, level up. Each battle generates an image in real-time. No pre-rendered spritesheets. The image is composed dynamically from the player's stats, the mob, and the combat state.

And the image gets generated in <500ms on Cloudflare Workers. Literally.

The rendering architecture

Discord (you click "Attack")
    ↓
Cloudflare Worker receives the interaction
    ↓
Game state update (XP, HP, etc.)
    ↓
Generates JSX with Satori
    ↓
Converts SVG → PNG with Resvg (Wasm)
    ↓
Uploads the image to Discord
    ↓
Sends the message with the image

All of that in under a second. It's nuts.

Image rendering on Workers

Konosuba uses Satori (JSX → SVG) and Resvg (SVG → PNG):

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. Create JSX for the UI
  const jsx = (
    <div style={{ display: 'flex', gap: '20px' }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

You write normal JSX. That becomes SVG. SVG becomes PNG. <100ms on a Cloudflare Worker.

You get how powerful this is? It's just... beautiful xD

Game state and progression

Player data lives in Supabase:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// The player won
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

Every action (attack, defend, heal) updates the stats in the database. Then you regenerate the image with the new stats.

Interactions: gameplay buttons

The game uses button interactions for combat actions:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (blue)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (grey)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

When you click "Attack", Discord POSTs an interaction with custom_id: 'battle_attack'. The handler routes it:

if (interaction.type === 3) {
  // Component interaction (button click, etc.)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

And boom, you calculate damage, update the database, regenerate the image, send it.

It's a complete turn-based game with zero persistent connection. Just stateless HTTP. Completely busted xD

Supabase: the database built for Workers

Traditional databases (PostgreSQL, MySQL, MongoDB) are designed around persistent TCP connections. You open a socket, keep it alive, send queries. Problem: Cloudflare Workers don't support persistent TCP connections. Every request is an ephemeral process. The moment you respond to the client, the Worker disappears.

You can't do this:

// This WON'T work on Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // persistent TCP connection = dead

Even native PostgreSQL drivers like pg or postgres.js rely on TCP connections. On Workers, they crash.

Supabase solves all of this.

Supabase is a REST API on top of PostgreSQL. You make normal HTTP requests. Every call is independent, no persistent connection, no state to manage. It's perfectly suited for the serverless model.

// This works PERFECTLY on Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

The Supabase client (@supabase/supabase-js) uses fetch under the hood. And fetch is native on Workers. Zero config, zero driver, zero persistent connection.

Database Workers compatible? Why
Supabase ✅ Yes Stateless REST API, pure HTTP
PlanetScale (MySQL) ⚠️ Partial HTTPS-only connection, no long transactions
Neon ⚠️ Partial Serverless branches but TCP driver needed
Turso (libSQL) ⚠️ Partial HTTP possible but limited
Prisma/Prisma Postgres ❌ No Requires persistent TCP
MongoDB Atlas ❌ No TCP driver, no native REST API
Redis (Upstash) ✅ Yes REST API over HTTP

The real advantage of Supabase isn't just the DB -- it's the whole ecosystem being designed edge-first:

  • Auth: REST API for sessions, works without state
  • Storage: File upload/download via HTTP
  • Realtime: Optional WebSocket, but you can also poll via REST
  • Row Level Security: security rules live in the DB, not your backend

For a serverless Discord bot, Supabase is the simplest and most reliable choice. No driver to configure, no connection to maintain, no timeouts. Just HTTP requests.

If you want a real example, look at Nibi above: its persistence code is literally readJson() and writeJson() on Supabase. No migrations, no complex schemas, no crazy config. It works out of the box. And if your bot gets big, you can migrate to real SQL queries without changing providers.

Polyfills: when Node wants to run on Workers

Some packages expect Node APIs. Kuromoji (kanji parser) uses XMLHttpRequest. Workers have fetch, not XMLHttpRequest.

Simple solution: add a polyfill at the top of index.ts:

// Polyfill XMLHttpRequest for kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Minimal stub
  } as any;
}

Or make a dedicated module:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

It's basic hacking, but it works.

Towards an npm package: hono-discord-interactions

Doing it by hand, making a bot involves a lot of boilerplate:

  • Verify the Discord signature
  • Route interaction types
  • Handle commands, components, modals
  • Return valid JSON

You could abstract all that into an npm package. Like:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

Boom. 20 lines instead of 200. It'd cut Nibi in half easily.

Idea for later xD

Deploy

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "my-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

Resulting URL: https://my-bot.workers.dev/interactions

Cost: free up to 100k requests/day. Beyond that: $0.50/million.

Spoiler: you'll never hit 100k requests unless you have 10,000 active users.

Vercel

npm run vercel:deploy

URL: https://my-bot-xyz.vercel.app/api/interactions

Same, free.

Both at once

Hono runs everywhere. You can deploy the same code on BOTH Cloudflare and Vercel. Useful for redundancy or testing before choosing.

Quick checklist

  1. Create an Application on Discord Developer Portal
  2. Copy PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Create the project:
  4. Write index.ts (signature verify + routing)
  5. Register slash commands (once):
  6. Deploy:
  7. Set the URL in Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord tests the connection (you must respond to PING)
  9. Invite the bot to a server
  10. You're done

Pros vs Limitations

Pros

  • Cheap (free up to 100k req/day)
  • Scalable (no connection management)
  • Simple (no WebSocket boilerplate)
  • Fast (Cloudflare = edge servers)
  • Portable (Hono code = multiple hosts)

Limitations

  • No real-time server events (member join, role added, message deleted, etc.) -- you only get interactions (slash commands, buttons, modals)
  • 3-second timeout to respond -- otherwise Discord shows "Application did not respond"
  • If you need real events -- you'll need a separate webhook HTTP endpoint or an auxiliary WebSocket connection

For 90% of bots (everything slash-command based)? You're good.

To wrap up

I spent a lot of time optimizing KonosubaRPG and Nibi to save either as many requests as possible, or to reduce hot CPU time, or to reduce cold boot. Result is I have some serious perf across the board.
You should know I started cloudifying (I don't even know if that's a word) most of my projects because I had a monumental laziness to keep hosting them on my own VM. Seriously, I think it's GitHub Actions that saved my ass. Workers too, but when I realized I could make daemons with GitHub Actions and schedules, that really saved me for real.

I'll probably write an article about a project called email-autoreply, so subscribe to the RSS feed to see it drop soon :)).

The 3 things to remember:

  1. Interaction endpoints = HTTP serverless -- No WebSocket, no persistent connection. Discord POSTs, you reply. Free on Cloudflare.
  2. Hono is the perfect tool -- Lightweight framework (12KB), multi-runtime, zero dependencies. Same code on Cloudflare, Vercel, Node, everywhere.
  3. Rendering images on Workers = insane -- Satori + Resvg (Wasm) lets you compose dynamic UIs in JSX and convert them to PNG in <100ms. A full game can run on this.

It's sick xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

Discord bot 100% serverless : Hono + Cloudflare Workers

Comment j'ai remplacé un bot Discord qui me coûtait 50€/mois par

Discord bot 100% serverless : Hono + Cloudflare Workers = 💸 zéro

J'ai passé quelques mois à maintenir des bot Discord classique sur ma propre machine.

Connexion WebSocket toujours ouverte. Le bot reconnecte tout seul à 3h du matin. Le bot crash parce que je regarde les moutons mal de travers. La facture monte.

Un jour j'ai découvert : pourquoi maintenir une connexion ? Discord peut te POST juste les trucs qui t'intéressent. Tu réponds, c'est bon.

Depuis 2021, Discord propose les interaction endpoints.

C'est juste HTTP. Pas de WebSocket. Pas d'état persistant. Tu reçois une requête, tu envoies du JSON, c'est fini. La requête suivante arrive toute seule.

Et le meilleur : Cloudflare Workers c'est gratuit jusqu'à 100k requêtes/jour. Pour 90% des bots, c'est 0€/mois.

Cet article te montre comment faire un bot Discord sans WebSocket en utilisant Hono (framework web ultra-léger) et Cloudflare Workers. Je vais te montrer deux projets réels : Nibi (bot pour apprendre le japonais, TTS, cool) et Konosuba-RPG (un jeu Discord complet avec rendu d'image en temps réel xD).

WebSocket vs. Interaction Endpoints : pourquoi c'était une mauvaise idée

Imagine un jeu Minecraft où tu dois garder la connexion ouverte même quand tu ne joues pas.

Et le serveur se reconnecte automatiquement chaque fois qu'il crash. Tu dois gérer les timeouts, les reconnections exponentielles, tout le boilerplate de merde qu'on deteste. Juste pour recevoir des interactions.

Les interaction endpoints c'est l'inverse. Discord POST sur ton URL. Tu réponds. C'est fini.

Si ton serveur crash ? Discord retry 2-3 fois et passe à autre chose. Zero drama.

Coût avant : 50€/mois sur Heroku juste pour qu'un processus Node reste vivant.

Coût après : 0€/mois sur Cloudflare jusqu'à 100k requêtes/jour.

L'architecture : c'est quoi au juste ?

Discord POSTe une requête sur ton endpoint.

Discord: "Eh! L'utilisateur a cliqué sur /ping!"
      ↓
   Ton URL (Cloudflare Worker)
      ↓
Tu vérifie que c'est vraiment Discord (signature check)
      ↓
Tu parse le type d'interaction
      ↓
Tu exécute le handler
      ↓
Tu retourne du JSON
      ↓
Discord: "Cool, je vais afficher ça à l'utilisateur"

C'est HTTP pur. Pas de magie. Pas de libraire lourde.

Hono + Cloudflare Workers : le combo économe

Hono c'est un framework web qui pèse 12KB. Il tourne partout : Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... le même code partout.

Cloudflare Workers c'est du compute au edge. Tes requêtes arrivent chez le serveur le plus proche. Temps de réponse : <100ms. Coût : gratuit jusqu'à 100k requêtes/jour.

La combo Hono + Cloudflare c'est le match parfait pour un bot Discord.

Voilà le code minimal d'un bot complet :

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Récupère les headers
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. Vérifie que c'est vraiment Discord (pas du spam)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Parse ce qu'il a envoyé
  const interaction = JSON.parse(body);

  // 4. Répond selon le type
  if (interaction.type === 1) {
    // Discord test (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // C'est une slash command
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

Genre, 30 lignes et c'est un bot fonctionnel.

Pas de bot.login(). Pas de event emitter. Pas de callback hell. Juste HTTP.

Pour deploy sur Cloudflare :

npm install -D wrangler
npx wrangler deploy

Boom. Tu as une URL genre https://mon-bot.workers.dev/interactions.

Tu mets ça dans Discord Developer Portal sous "INTERACTIONS ENDPOINT URL", et Discord commence à envoyer tes interactions là-bas.

Vérifier la signature : pas de fake requests

Discord signe chaque requête avec une clé publique. Si tu reçois une requête avec une mauvaise signature ? C'est du spam. Ignore et continue.

Le paquet discord-interactions fait le job :

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // texte brut exact (pas JSON parsé !)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // de Discord Dev Portal
);

Piège important : la signature dépend du body exact. Si tu parse JSON et re-stringify, ou si tu log le body, tu casses la signature.

Vérifie d'abord. Parse après. C'est l'ordre qui compte.

Cas 1 : Nibi (bot apprentissage du japonais)

Nibi c'est un bot Discord pour apprendre le japonais. Commandes simples :

  • /dictionary kanji → affiche les définitions
  • /pronounce テキスト → génère du TTS (text-to-speech)
  • /hello → message d'accueil

Chaque commande c'est un fichier TypeScript :

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

Une commande implémente cette interface :

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<interactionresponse>;
}

La commande /pronounce : faire parler le bot

C'est celle qui est bien chelou. Tu envoies du texte (romaji, hiragana, kanji, n'importe quoi), le bot convertit ça en hiragana, génère du TTS via VOICEVOX ou Google TTS, et envoie un message audio sur Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Génère du TTS pour du texte japonais',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Texte à prononcer',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Convertir romaji → hiragana avec Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. Générer l'audio TTS
      const audioBuffer = await generateTTS(hiragana);

      // 3. Upload le fichier vers Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Envoyer le message avec l'audio
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // durée en secondes
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `Prononciation pour "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'Erreur : impossible de générer l\'audio xD',
          flags: 64  // ephemeral (message privé)
        }
      };
    }
  }
};

C'est fou : tu appelles une API externe, tu upload un fichier vers Discord, tu envoies un message avec le fichier. Tout ça sans WebSocket, juste HTTP.

Persistance avec Supabase

Nibi utilise Supabase comme key-value store. Pour vérifier si un utilisateur est enregistré :

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // Ajouter l'utilisateur
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

C'est très basique (pas de vraies requêtes SQL, juste du JSON), mais ça marche. Pour les petits bots c'est parfait.

Cas 2 : Konosuba-RPG (jeu Discord avec rendu image)

Okay celle-là c'est fou.

Konosuba-RPG c'est un jeu complet sur Discord. Tu combats des mobs, tu gagnes de l'XP, tu équipes des accessoires, tu montes de niveau. Chaque battle génère une image en temps réel. Pas de spritesheet pre-rendu. L'image est composée dynamiquement depuis les stats du joueur, le mob, et l'état du combat.

Et l'image se genere en <500ms sur Cloudflare Workers. Littéralement.

L'architecture du rendu

Discord (tu cliques "Attack")
    ↓
Cloudflare Worker reçoit l'interaction
    ↓
Mise à jour du game state (XP, HP, etc.)
    ↓
Génère du JSX avec Satori
    ↓
Convertit SVG → PNG avec Resvg (Wasm)
    ↓
Upload l'image vers Discord
    ↓
Envoie le message avec l'image

Tout ça en moins d'une seconde. C'est dingue.

Rendu d'image côté Workers

Konosuba utilise Satori (JSX → SVG) et Resvg (SVG → PNG) :

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. Créer JSX pour le UI
  const jsx = (
    <div style="{{" display:="" 'flex',="" gap:="" '20px'="" }}="">
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

Tu écris du JSX normal. Ça devient SVG. SVG devient PNG. <100ms sur un Cloudflare Worker.

Tu captes la puissance ? C'est juste... beau xD

Game state et progression

Les données du joueur sont dans Supabase :

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// Le joueur a gagné
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

Chaque action (attaque, défense, soin) met à jour les stats en base. Et ensuite tu régenère l'image avec les nouvelles stats.

Interactions : les boutons du gameplay

Le jeu utilise des button interactions pour les actions en combat :

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (bleu)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (gris)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

Quand tu cliques "Attack", Discord POST une interaction avec custom_id: 'battle_attack'. Le handler route ça :

if (interaction.type === 3) {
  // Component interaction (button click, etc.)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

Et boom, tu calcules les dégâts, tu updates la base, tu regenere l'image, tu envoies.

C'est un jeu turn-based complet sans aucune persistence de connection. Juste HTTP stateless. Complètement pété xD

Supabase : la DB faite pour les Workers

Les bases de données classiques (PostgreSQL, MySQL, MongoDB) sont conçues pour des connexions TCP persistantes. Tu ouvres un socket, tu gardes la connexion ouverte, tu envoies des requêtes. Problème : Cloudflare Workers ne supporte pas les connexions TCP persistantes. Chaque requête est un processus éphémère. Dès que tu réponds au client, le Worker disparaît.

Tu peux pas faire ça :

// Ça marche PAS sur Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // connexion TCP persistante = dead

Et même les drivers PostgreSQL natifs comme pg ou postgres.js utilisent des connexions TCP. Sur Workers, ils plantent.

Supabase résout tout ça.

Supabase c'est une API REST au-dessus de PostgreSQL. Tu fais des requêtes HTTP normales. Chaque appel est indépendant, pas de connexion persistante, pas d'état à gérer. C'est parfaitement adapté au modèle serverless.

// Ça marche PARFAITEMENT sur Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Le client Supabase (@supabase/supabase-js) utilise fetch sous le capot. Et fetch est natif sur Workers. Zéro configuration, zéro driver, zéro connexion persistante.

Base de données Compatible Workers ? Pourquoi
Supabase ✅ Oui REST API sans état, HTTP pur
PlanetScale (MySQL) ⚠️ Partiel Connexion HTTPS uniquement, pas de transactions longues
Neon ⚠️ Partiel Branches serverless mais driver TCP nécessaire
Turso (libSQL) ⚠️ Partiel HTTP possible mais limogé
Prisma/Prisma Postgres ❌ Non Nécessite TCP persistant
MongoDB Atlas ❌ Non Driver TCP, pas de REST API native
Redis (Upstash) ✅ Oui REST API HTTP

Le vrai avantage de Supabase c'est pas juste la DB -- c'est tout l'écosystème qui est pensé edge-first :

  • Auth : API REST pour gérer les sessions, fonctionne sans état
  • Storage : Upload/download de fichiers via HTTP
  • Realtime : WebSocket optionnel, mais tu peux aussi poller via REST
  • Row Level Security : les règles de sécurité sont dans la DB, pas dans ton backend

Pour un bot Discord serverless, Supabase c'est le choix le plus simple et le plus fiable. Pas de driver à configurer, pas de connexion à maintenir, pas de timeouts. Juste des requêtes HTTP.

Si tu veux un vrai exemple, regarde Nibi plus haut : le code de persistance c'est littéralement du readJson() et writeJson() sur Supabase. Pas de migrations, pas de schémas complexes, pas de config de ouf. Ça marche direct. Et si ton bot devient gros, tu migres vers des vraies requêtes SQL sans changer de provider.

Polyfills : quand Node veut tourner sur Workers

Certains packages s'attendent à des APIs Node. Kuromoji (parser kanji) utilise XMLHttpRequest. Les Workers ont fetch, pas XMLHttpRequest.

Solution simple : ajouter un polyfill au top du index.ts :

// Polyfill XMLHttpRequest pour kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Stub minimal
  } as any;
}

Ou faire un module dédié :

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

C'est du hack basique, mais ça marche.

Vers un paquet npm : hono-discord-interactions

À la main, faire un bot c'est beaucoup de boilerplate :

  • Vérifie la signature Discord
  • Route les types d'interaction
  • Gère les commandes, components, modals
  • Retourne du JSON valide

On pourrait abstraire tout ça dans un paquet npm. Genre :

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

Boum. 20 lignes au lieu de 200. Ça réduirait Nibi de la moitié facile.

Idée pour plus tard xD

Deploy

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

URL résultante : https://mon-bot.workers.dev/interactions

Coût : gratuit jusqu'à 100k requêtes/jour. Au-delà : $0.50/million.

Spoiler : tu dépenseras jamais les 100k requêtes à moins d'avoir 10 000 utilisateurs actifs.

Vercel

npm run vercel:deploy

URL : https://mon-bot-xyz.vercel.app/api/interactions

Pareil, gratuit.

Les deux à la fois

Hono tourne partout. Tu peux déployer le même code sur Cloudflare ET Vercel. Utile pour la redondance ou tester avant de choisir.

Checkliste rapide

  1. Créer une Application sur Discord Developer Portal
  2. Copier PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Créer le projet :
  4. Écrire index.ts (vérif signature + routing)
  5. Enregistrer les slash commands (une fois) :
  6. Deploy :
  7. Mettre l'URL dans Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord test la connexion (tu dois répondre au PING)
  9. Inviter le bot sur un serveur
  10. C'est bon

Avantages vs Limitations

Avantages

  • Pas cher (gratuit jusqu'à 100k req/jour)
  • Scalable (pas de connection gestion)
  • Simple (pas de WebSocket boilerplate)
  • Rapide (Cloudflare = serveurs au edge)
  • Portable (code Hono = plusieurs hosts)

Limitations

  • Pas d'événements server en temps réel (membre rejoint, rôle ajouté, message supprimé, etc.) -- tu reçois uniquement les interactions (slash commands, buttons, modals)
  • Timeout de 3 secondes pour répondre -- sinon Discord affiche "Application did not respond"
  • Si tu besoin de vraies events -- faut un webhook HTTP séparé ou une connexion WebSocket auxiliaire

Pour 90% des bots (tout basé sur slash commands) ? C'est bon.

Pour conclure

J'ai passé pas mal de temps à optimiser KonosubaRPG et Nibi pour économiser soit le plus de requêtes possibles, soit pour réduire le temps de processeur à chaud, soit pour réduire le boot cold. Résultat, j'ai de sacrés perfs sur à peu près tout.
Faut savoir que j'avais commencé à cloudifier (je sais même pas si ca se dit) la plupart de mes projets parce que j'avais une flemme monumentale de continuer à les héberger sur ma propre VM. Vraiment, je crois que ce sont les Github Actions qui m'ont sauvé la peau du cul. Les workers aussi, mais en fait quand j'ai vu que je pouvais faire des daemons avec les Github Actions et les schedules, ça m'a vraiment sauvé en sah.

Je vais sans doute écrire un article sur un projet nommé email-autoreply, donc abonnez vous au fil RSS pour le voir sortir prochainement :)).

Les 3 trucs à retenir :

  1. Interaction endpoints = HTTP serverless -- Pas de WebSocket, pas de connection persistente. Discord POST, tu réponds. Gratuit sur Cloudflare.
  2. Hono c'est l'outil parfait -- Framework léger (12KB), multi-runtime, zéro dépendances. Code identique sur Cloudflare, Vercel, Node, partout.
  3. Renderimage sur Workers = fou -- Satori + Resvg (Wasm) te laisse composer des UI dynamiques en JSX et les convertir en PNG en <100ms. Un jeu complet peut tourner sur ça.

C'est malade xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

100% 无服务 Discord 机器人:Hono + Cloudflare Workers

如何将我每月50欧元的Discord机器人替换为零成本 -- 交互端点、Hono、Workers、实时图像渲染以及无需WebSocket的完整游戏。

Discord bot 100% 无服务器 : Hono + Cloudflare Workers = 💸 零成本

我花了几个月在自己的机器上维护传统的 Discord bot。

WebSocket 连接永远开着。Bot 凌晨三点自动重连。Bot 崩了只是因为我看羊的眼神不对。账单一直涨。

有一天我发现:为什么要保持连接?Discord 可以只 POST 你感兴趣的东西。你回复,完事。

从 2021 年起,Discord 提供了 interaction endpoints。

就是纯 HTTP。没有 WebSocket。没有持久状态。你收到请求,返回 JSON,结束。下一条请求自己会来。

最棒的是:Cloudflare Workers 免费直到每天 10 万次请求。对 90% 的 bot 来说,这就是 0€/月。

这篇文章会教你如何用 Hono(超轻量 web 框架)和 Cloudflare Workers 做一个不需要 WebSocket 的 Discord bot。我会给你看两个真实项目:Nibi(学日语的 bot,带 TTS,很酷)和 Konosuba-RPG(一个 完整的 Discord 游戏,带实时图片渲染 xD)。

WebSocket vs. Interaction Endpoints : 为什么之前是个馊主意

想象一下一个 Minecraft 服务器,你不玩的时候也得保持连接开着。

而且服务器每次崩了都会自动重连。你得处理超时、指数退避重连,所有那些我们讨厌的垃圾样板代码。就为了接收交互。

Interaction endpoints 正好相反。Discord POST 到你的 URL。你回复。完事。

如果你的服务器崩了?Discord 重试 2-3 次然后继续。零 drama。

之前成本:每个月 50€ 在 Heroku 上,就为了让一个 Node 进程活着。

之后成本:每个月 0€ 在 Cloudflare 上,直到 10 万次请求/天。

架构 : 到底是什么?

Discord POST 一个请求到你的 endpoint。

Discord: "嘿!用户点了 /ping!"
      ↓
   你的 URL (Cloudflare Worker)
      ↓
   你验证是不是真的 Discord (签名检查)
      ↓
   你解析 interaction 类型
      ↓
   你执行 handler
      ↓
   你返回 JSON
      ↓
Discord: "好的,我会把这个显示给用户"

就是纯 HTTP。没有魔法。没有重库。

Hono + Cloudflare Workers : 省钱组合

Hono 是一个 12KB 的 web 框架。哪里都能跑:Cloudflare Workers、Vercel、AWS Lambda、Deno、Bun... 同样的代码到处跑。

Cloudflare Workers 是在边缘计算。你的请求到达最近的服务器。响应时间:<100ms。成本:免费直到每天 10 万次请求。

Hono + Cloudflare 的组合对 Discord bot 来说简直是绝配。

这是一个完整 bot 的最小代码:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. 获取 headers
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. 验证真的是 Discord(不是垃圾请求)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. 解析它发来的内容
  const interaction = JSON.parse(body);

  // 4. 根据类型回复
  if (interaction.type === 1) {
    // Discord 测试 (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // 这是一个斜杠命令
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

就 30 行,一个能用的 bot 就好了。

没有 bot.login()。没有 event emitter。没有回调地狱。只有 HTTP。

部署到 Cloudflare:

npm install -D wrangler
npx wrangler deploy

Boom。你得到一个 URL 比如 https://mon-bot.workers.dev/interactions。

把这个放到 Discord Developer Portal 的 "INTERACTIONS ENDPOINT URL" 里,Discord 就会开始把交互发到那里。

验证签名 : 拒绝假请求

Discord 用公钥给每个请求签名。如果你收到一个签名不对的请求?那就是垃圾。忽略它,继续。

discord-interactions 这个包帮你搞定:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // 精确的原始文本(不是解析后的 JSON!)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // 从 Discord Dev Portal 获取
);

重要陷阱:签名依赖于 精确的 body。如果你解析 JSON 再重新序列化,或者你 log 了 body,签名就坏了。

先验证。再解析。顺序很重要。

案例 1 : Nibi(日语学习 bot)

Nibi 是一个学日语的 Discord bot。简单的命令:

  • /dictionary kanji → 显示释义
  • /pronounce テキスト → 生成 TTS(文本转语音)
  • /hello → 欢迎消息

每个命令是一个 TypeScript 文件:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

每个命令实现这个接口:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<InteractionResponse>;
}

/pronounce 命令 : 让 bot 说话

这个最骚。你发一段文本(罗马音、平假名、汉字,随便什么),bot 把它转成平假名,通过 VOICEVOX 或 Google TTS 生成语音,然后在 Discord 上发一条音频消息。

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: '为日语文本生成 TTS',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: '要发音的文本',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. 用 Kuroshiro 把罗马音转成平假名
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. 生成 TTS 音频
      const audioBuffer = await generateTTS(hiragana);

      // 3. 上传文件到 Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. 发送带音频的消息
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // 时长(秒)
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `"${text}" 的发音` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: '错误:无法生成音频 xD',
          flags: 64  // ephemeral(私密消息)
        }
      };
    }
  }
};

离谱的是:你调用外部 API,上传文件到 Discord,用这个文件发送消息。全程没有 WebSocket,只有 HTTP。

用 Supabase 做持久化

Nibi 用 Supabase 做 key-value 存储。检查用户是否已注册:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // 添加用户
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

很基础(不是真正的 SQL 查询,只是 JSON),但能用。对小 bot 来说完美。

案例 2 : Konosuba-RPG(带图片渲染的 Discord 游戏)

好吧这个是真的离谱。

Konosuba-RPG 是一个 Discord 上的完整游戏。你打怪、赚经验、装备饰品、升级。每场战斗都实时生成一张图片。没有预渲染的 spritesheet。图片是根据玩家属性、怪物和战斗状态动态合成的。

而且图片在 Cloudflare Workers 上 <500ms 就能生成。真的。

渲染架构

Discord(你点了 "Attack")
    ↓
Cloudflare Worker 收到 interaction
    ↓
更新游戏状态(经验值、血量等)
    ↓
用 Satori 生成 JSX
    ↓
用 Resvg (Wasm) 把 SVG 转成 PNG
    ↓
上传图片到 Discord
    ↓
发送带图片的消息

全部在一秒内完成。简直了。

在 Workers 上渲染图片

Konosuba 使用 Satori(JSX → SVG)和 Resvg(SVG → PNG):

import Satori from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. 创建 UI 的 JSX
  const jsx = (
    <div style={{ display: 'flex', gap: '20px' }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

你写普通的 JSX。它变成 SVG。SVG 变成 PNG。在 Cloudflare Worker 上 <100ms。

你感受到这威力了吗?就是... 美 xD

游戏状态与进度

玩家数据存在 Supabase 里:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// 玩家赢了
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

每个动作(攻击、防御、治疗)都会更新数据库里的数据。然后用新数据重新生成图片。

交互 : 游戏按钮

游戏用 button interactions 来做战斗操作:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary(蓝色)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary(灰色)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

当你点 "Attack",Discord POST 一个带有 custom_id: 'battle_attack' 的 interaction。handler 会路由它:

if (interaction.type === 3) {
  // Component interaction(按钮点击等)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

然后 boom,你计算伤害,更新数据库,重新生成图片,发送。

这是一个完整的回合制游戏,没有任何持久连接。纯 HTTP 无状态。彻底离谱 xD

Supabase:为 Workers 而生的数据库

传统数据库(PostgreSQL、MySQL、MongoDB)都是为持久 TCP 连接设计的。你打开一个 socket,保持连接,发送查询。问题在于:Cloudflare Workers 不支持持久 TCP 连接。每个请求都是一个短暂的进程。一旦你响应客户端,Worker 就消失了。

你不能这样做:

// 这在 Workers 上不 ⚠️ 工作
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // 持久 TCP 连接 = 死

就连 pg 或 postgres.js 这样的原生 PostgreSQL 驱动也都使用 TCP 连接。在 Workers 上它们会崩溃。

Supabase 解决了一切问题。

Supabase 是在 PostgreSQL 之上的 REST API。你发起普通的 HTTP 请求。每次调用都是独立的,没有持久连接,没有需要管理的状态。它完美适配 serverless 模型。

// 这在 Workers 上完美工作
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Supabase 客户端(@supabase/supabase-js)底层使用的是 fetch。而 fetch 在 Workers 上是原生支持的。零配置、零驱动、零持久连接。

数据库 Workers 兼容? 原因
Supabase ✅ 是 无状态 REST API,纯 HTTP
PlanetScale (MySQL) ⚠️ 部分 仅 HTTPS 连接,不支持长事务
Neon ⚠️ 部分 Serverless 分支但需要 TCP 驱动
Turso (libSQL) ⚠️ 部分 HTTP 可行但有限制
Prisma/Prisma Postgres ❌ 否 需要持久 TCP
MongoDB Atlas ❌ 否 TCP 驱动,没有原生 REST API
Redis (Upstash) ✅ 是 基于 HTTP 的 REST API

Supabase 的真正优势不仅仅是数据库----而是整个生态系统都是为边缘计算设计的:

  • Auth:用于会话的 REST API,无状态运行
  • Storage:通过 HTTP 上传/下载文件
  • Realtime:可选的 WebSocket,但也可以通过 REST 轮询
  • Row Level Security:安全规则存在于数据库中,不在你的后端

对于 serverless Discord 机器人来说,Supabase 是最简单、最可靠的选择。无需配置驱动,无需维护连接,无需超时。只需要 HTTP 请求。

如果你想看实际例子,看看上面的 Nibi:它的持久化代码就是 Supabase 上的 readJson() 和 writeJson()。无需迁移、无需复杂 schema、无需疯狂配置。开箱即用。而且如果你的机器人做大了,你可以在不更换提供商的情况下迁移到真正的 SQL 查询。

Polyfills : 当 Node 想在 Workers 上跑的时候

有些包依赖 Node 的 API。Kuromoji(日语解析器)用了 XMLHttpRequest。Workers 有 fetch,没有 XMLHttpRequest。

简单方案:在 index.ts 顶部加个 polyfill:

// 为 kuromoji 做 XMLHttpRequest polyfill
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // 最小 stub
  } as any;
}

或者建一个专用模块:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

就是基础 hack,但能用。

做一个 npm 包 : hono-discord-interactions

手动做 bot 有很多样板代码:

  • 验证 Discord 签名
  • 路由 interaction 类型
  • 处理命令、组件、弹窗
  • 返回合法的 JSON

我们可以把这些抽象成一个 npm 包。比如:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

砰。20 行代替 200 行。Nibi 的代码量能轻松减半。

以后再说 xD

部署

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

结果 URL:https://mon-bot.workers.dev/interactions

成本:免费直到每天 10 万次请求。超出后:$0.50/百万次。

剧透:除非你有 1 万个活跃用户,否则你永远花不到那 10 万次请求。

Vercel

npm run vercel:deploy

URL:https://mon-bot-xyz.vercel.app/api/interactions

同样免费。

两者同时跑

Hono 哪里都能跑。你可以把同一份代码部署到 Cloudflare 和 Vercel。用来做冗余或者先测试再决定。

快速清单

  1. 在 Discord Developer Portal 创建一个 Application
  2. 复制 PUBLIC_KEY、BOT_TOKEN、APP_ID
  3. 创建项目:
  4. 写 index.ts(签名验证 + 路由)
  5. 注册斜杠命令(一次即可):
  6. 部署:
  7. 把 URL 放到 Discord(Developer Portal → Application → Interactions Endpoint URL)
  8. Discord 测试连接(你必须回复 PING)
  9. 把 bot 邀请到服务器
  10. 搞定

优势 vs 局限

优势

  • 便宜(免费直到每天 10 万次请求)
  • 可伸缩(无需管理连接)
  • 简单(没有 WebSocket 样板代码)
  • 快速(Cloudflare = 边缘服务器)
  • 可移植(Hono 代码 = 多个平台)

局限

  • 没有实时服务器事件(成员加入、角色添加、消息删除等)----你只收到交互(斜杠命令、按钮、弹窗)
  • 3 秒超时来回复----否则 Discord 显示 "Application did not respond"
  • 如果你需要真正的事件----需要单独的 HTTP webhook 或辅助 WebSocket 连接

对 90% 的 bot(都基于斜杠命令)来说?够用了。

总结

我花了不少时间优化 KonosubaRPG 和 Nibi,要么为了节省尽可能多的请求,要么为了减少热 CPU 时间,要么为了减少冷启动。结果就是,几乎所有地方的性能都相当不错。 要知道,我开始把大部分项目搬到云上(我都不知道这个词对不对)是因为我真的懒得继续在自己 VM 上托管它们了。真的,我觉得是 GitHub Actions 救了我的老命。Workers 也是,但当我发现可以用 GitHub Actions 加定时任务做守护进程时,真的救了我一命。

我可能会写一篇关于 email-autoreply 项目的文章,所以订阅 RSS 源等着看吧 :))。

要记住的 3 件事:

  1. Interaction endpoints = HTTP 无服务器 -- 没有 WebSocket,没有持久连接。Discord POST,你回复。在 Cloudflare 上免费。
  2. Hono 是完美工具 -- 轻量框架(12KB),多运行时,零依赖。在 Cloudflare、Vercel、Node 上代码都一样。
  3. 在 Workers 上渲染图片 = 离谱 -- Satori + Resvg (Wasm) 让你用 JSX 组合动态 UI,在 <100ms 内转成 PNG。一个完整的游戏可以跑在这上面。

太牛了 xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

100%サーバーレスDiscordボット:Hono + Cloudflare Workers

月50€かかってたDiscordボットをゼロユーロにした方法 --

Discord bot 完全サーバーレス : Hono + Cloudflare Workers = 💸 ゼロ

何ヶ月か自宅マシンで普通のDiscord botを動かし続けてたんだよね。

WebSocket接続は常時開きっぱなし。朝の3時に勝手に再接続するbot。羊を変な目で見ただけでbotがクラッシュする。請求書が上がっていく。

ある日気づいたんだ:なんで接続を維持する必要あんの? Discordは興味あるやつだけPOSTしてくれる。返事すれば終わり。

2021年からDiscordはinteraction endpointsを提供してる。

ただのHTTP。WebSocketなし。永続ステートなし。リクエストを受け取って、JSON返して、終わり。次のリクエストは勝手に来る。

で、最高なのは:Cloudflare Workersが無料で1日10万リクエストまで。90%のbotなら月0€。

この記事ではWebSocketなしでDiscord botを作る方法を紹介するよ。使うのはHono(超軽量Webフレームワーク)とCloudflare Workers。実際の2つのプロジェクトを見せていく:Nibi(日本語学習bot、TTS付き、かっこいい)とKonosuba-RPG(画像レンダリングするDiscordの_フル_ゲーム xD)。

WebSocket vs. Interaction Endpoints : なんでダメだったのか

プレイしてない時も接続を開きっぱなしにしなきゃいけないMinecraftを想像してみてよ。

しかもサーバーがクラッシュするたびに自動で再接続するんだぜ。タイムアウト処理して、指数バックオフして、誰も好きじゃないクソったれなボイラープレートを全部やらなきゃいけない。ただのインタラクションを受け取るためだけに。

Interaction endpointsは真逆。DiscordがURLにPOSTする。お前が返事する。終わり。

サーバーがクラッシュしたら? Discordが2-3回リトライして次行く。ドラマゼロ。

前のコスト: HerokuでNodeプロセス生かしておくだけで月50€。

後のコスト: Cloudflareだと1日10万リクエストまで月0€。

アーキテクチャ : 実際どういうこと?

Discordがお前のエンドポイントにリクエストをPOSTする。

Discord: "なあ!ユーザーが /ping をクリックしたぞ!"
      ↓
   お前のURL (Cloudflare Worker)
      ↓
   本当にDiscordか確認する (署名チェック)
      ↓
   インタラクションタイプをパース
      ↓
   ハンドラを実行
      ↓
   JSONを返す
      ↓
   Discord: "よし、これをユーザーに表示するぜ"

純粋なHTTP。魔法なし。重いライブラリなし。

Hono + Cloudflare Workers : ケチケチコンボ

Honoは12KBのWebフレームワーク。どこでも動く:Cloudflare Workers、Vercel、AWS Lambda、Deno、Bun...同じコードがどこでも動く。

Cloudflare Workersはエッジでのコンピューティング。リクエストは一番近いサーバーに届く。応答時間:<100ms。コスト:1日10万リクエストまで無料。

Hono + CloudflareのコンボはDiscord botに完璧。

これが完全なbotの最小コード:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. ヘッダーを取得
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. 本当にDiscordか確認(スパム対策)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. 送られてきたものをパース
  const interaction = JSON.parse(body);

  // 4. タイプに応じて応答
  if (interaction.type === 1) {
    // Discordのテスト(PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // スラッシュコマンド
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

30行くらいで動くbotができる。

bot.login() もいらない。イベントエミッターもいらない。コールバック地獄もいらない。ただのHTTP。

Cloudflareにデプロイするには:

npm install -D wrangler
npx wrangler deploy

Boom。https://mon-bot.workers.dev/interactions みたいなURLができる。

それをDiscord Developer Portalの"INTERACTIONS ENDPOINT URL"に入れるだけで、Discordがそこにインタラクションを送り始める。

署名の検証 : 偽リクエストの防止

Discordは全リクエストに公開鍵で署名してる。もし間違った署名のリクエストが来たら? それはスパム。無視して次行け。

discord-interactions パッケージがやってくれる:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // 生のテキストそのまま(パースしたJSONじゃない!)
  signature,         // x-signature-ed25519 ヘッダー
  timestamp,         // x-signature-timestamp ヘッダー
  publicKey          // Discord Dev Portalから
);

重要な落とし穴: 署名は_正確な_ボディに依存してる。JSONパースして再stringifyしたり、bodyをログに出したりすると署名が壊れる。

先に検証。後でパース。この順番が大事。

ケース1 : Nibi(日本語学習bot)

NibiはDiscordで日本語を学ぶためのbot。シンプルなコマンド:

  • /dictionary kanji → 定義を表示
  • /pronounce テキスト → TTS(テキスト読み上げ)生成
  • /hello → ウェルカムメッセージ

各コマンドが1つのTypeScriptファイル:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

コマンドはこのインターフェースを実装する:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<InteractionResponse>;
}

/pronounce コマンド : botに喋らせる

これがめっちゃヤバいやつ。テキスト(ローマ字、ひらがな、漢字、何でも)を送ると、botがひらがなに変換して、VOICEVOXかGoogle TTSでTTS生成して、Discordに音声メッセージを送る。

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: '日本語テキストのTTSを生成',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: '発音するテキスト',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. ローマ字 → ひらがなに変換(Kuroshiro使用)
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. TTS音声を生成
      const audioBuffer = await generateTTS(hiragana);

      // 3. Discordにファイルをアップロード
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. 音声付きメッセージを送信
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // 秒単位の再生時間
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `「${text}」の発音` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'エラー : 音声生成できなかった xD',
          flags: 64  // ephemeral(プライベートメッセージ)
        }
      };
    }
  }
};

やばくない? 外部API呼んで、ファイルをDiscordにアップロードして、そのファイル付きメッセージを送信する。全部WebSocketなし、HTTPだけで。

Supabaseでの永続化

NibiはSupabaseをkey-valueストアとして使ってる。ユーザーが登録済みか確認するには:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // ユーザーを追加
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

めっちゃベーシック(本物のSQLクエリじゃなくてただのJSON)だけど動く。小さいbotには完璧。

ケース2 : Konosuba-RPG(画像レンダリングするDiscordゲーム)

これマジでヤバい。

Konosuba-RPGはDiscord上の完全なゲーム。敵と戦って、経験値稼いで、アクセサリー装備して、レベル上げる。各バトルで画像がリアルタイム生成される。事前レンダリングされたスプライトシートなし。プレイヤーのステータス、敵、戦闘状態から動的に画像が構成される。

しかもその画像、Cloudflare Workers上で<500msで生成される。文字通り。

レンダリングのアーキテクチャ

Discord("Attack"をクリック)
    ↓
Cloudflare Workerがインタラクションを受け取る
    ↓
ゲーム状態を更新(XP、HPなど)
    ↓
SatoriでJSXを生成
    ↓
Resvg(Wasm)でSVG → PNGに変換
    ↓
画像をDiscordにアップロード
    ↓
画像付きメッセージを送信

全部1秒未満。やばすぎ。

Workers側での画像レンダリング

KonosubaはSatori(JSX → SVG)とResvg(SVG → PNG)を使ってる:

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. UI用のJSXを作成
  const jsx = (
    <div style={{ display: 'flex', gap: '20px' }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

普通のJSXを書く。それがSVGになる。SVGがPNGになる。Cloudflare Workerで<100ms。

このパワー分かる? ただただ...美しい xD

ゲーム状態と進行

プレイヤーデータはSupabaseに保存:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// プレイヤーが勝った
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

各アクション(攻撃、防御、回復)でDBのステータスを更新。そして新しいステータスで画像を再生成。

インタラクション : ゲームプレイのボタン

ゲームは戦闘アクションにボタンインタラクションを使ってる:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary(青)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary(グレー)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

"Attack"をクリックすると、Discordが custom_id: 'battle_attack' 付きのインタラクションをPOSTする。ハンドラがルーティング:

if (interaction.type === 3) {
  // コンポーネントインタラクション(ボタンクリックなど)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

で、ダメージ計算して、DB更新して、画像再生成して、送信。

接続の永続化ゼロの完全ターン制ゲーム。ただのステートレスHTTP。完全にぶっ壊れてる xD

Supabase: Workersのために作られたデータベース

従来のデータベース(PostgreSQL、MySQL、MongoDB)は永続的なTCP接続のために設計されている。ソケットを開いて、接続を維持して、クエリを送る。問題:Cloudflare Workersは永続的なTCP接続をサポートしていない。各リクエストは一時的なプロセスで、クライアントに応答した瞬間にWorkerは消える。

こんなことはできない:

// これはWorkersでは動かない
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // 永続TCP接続 = 死

pgやpostgres.jsのようなネイティブなPostgreSQLドライバーでさえTCP接続を使っている。Workersではクラッシュする。

Supabaseが全部解決する。

SupabaseはPostgreSQLの上にあるREST API。普通のHTTPリクエストを送るだけ。各呼び出しは独立していて、永続接続不要、管理する状態もなし。サーバーレスモデルに完全に適合してる。

// これはWorkersで完全に動く
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Supabaseクライアント(@supabase/supabase-js)は内部でfetchを使ってる。そしてfetchはWorkersでネイティブ。設定ゼロ、ドライバーゼロ、永続接続ゼロ。

データベース Workers対応? 理由
Supabase ✅ はい ステートレスREST API、純粋HTTP
PlanetScale (MySQL) ⚠️ 一部 HTTPSのみ、長いトランザクション不可
Neon ⚠️ 一部 サーバーレスブランチだがTCPドライバーが必要
Turso (libSQL) ⚠️ 一部 HTTP可能だが制限あり
Prisma/Prisma Postgres ❌ いいえ 永続TCPが必要
MongoDB Atlas ❌ いいえ TCPドライバー、ネイティブREST APIなし
Redis (Upstash) ✅ はい HTTP上のREST API

Supabaseの本当の利点はDBだけじゃない -- エコシステム全体がエッジファーストで設計されてること:

  • Auth: セッション用REST API、ステートレスで動作
  • Storage: HTTP経由でファイルアップロード/ダウンロード
  • Realtime: オプションのWebSocket、REST経由のポーリングも可能
  • Row Level Security: セキュリティルールはDB側にあり、バックエンドには不要

サーバーレスDiscordボットには、Supabaseが最もシンプルで信頼できる選択肢。設定するドライバーなし、維持する接続なし、タイムアウトなし。ただのHTTPリクエスト。

実際の例が見たいなら、上のNibiを見てみて:その永続化コードは文字通りSupabase上のreadJson()とwriteJson()。マイグレーションなし、複雑なスキーマなし、狂った設定なし。箱から出してすぐ動く。そしてボットが大きくなったら、プロバイダーを変えずに本物のSQLクエリに移行できる。

ポリフィル : NodeがWorkersで動こうとする時

一部のパッケージはNode APIを期待してる。Kuromoji(漢字パーサー)は XMLHttpRequest を使ってる。Workersには fetch はあるけど XMLHttpRequest はない。

シンプルな解決策:index.tsの先頭にポリフィルを追加:

// kuromoji用のXMLHttpRequestポリフィル
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // 最小限のスタブ
  } as any;
}

または専用モジュールにする:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

ベーシックなハックだけど動く。

npmパッケージ化に向けて : hono-discord-interactions

手動でbotを作るのはボイラープレートが多い:

  • Discord署名の検証
  • インタラクションタイプのルーティング
  • コマンド、コンポーネント、モーダルの処理
  • 有効なJSONの返却

これらを全部npmパッケージに抽象化できる。こんな感じ:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

Boom。200行が20行に。Nibiのコードが簡単に半分になる。

後でやるかもな xD

デプロイ

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

結果のURL:https://mon-bot.workers.dev/interactions

コスト:1日10万リクエストまで無料。超えると月$0.50/100万。

ネタバレ:アクティブユーザー1万人いない限り10万リクエスト超えないから。

Vercel

npm run vercel:deploy

URL:https://mon-bot-xyz.vercel.app/api/interactions

同じく無料。

両方同時に

Honoはどこでも動く。同じコードをCloudflareとVercelの両方にデプロイできる。冗長化とか、選ぶ前にテストするのに便利。

クイックチェックリスト

  1. Discord Developer Portalでアプリケーションを作成
  2. PUBLIC_KEY、BOT_TOKEN、APP_IDをコピー
  3. プロジェクトを作成:
  4. index.tsを書く(署名検証 + ルーティング)
  5. スラッシュコマンドを登録(1回だけ):
  6. デプロイ:
  7. URLをDiscordに設定(Developer Portal → アプリケーション → Interactions Endpoint URL)
  8. Discordが接続テスト(PINGに応答する必要あり)
  9. botをサーバーに招待
  10. 完了

メリット vs 制限

メリット

  • 安い(1日10万リクエストまで無料)
  • スケーラブル(接続管理不要)
  • シンプル(WebSocketのボイラープレートなし)
  • 高速(Cloudflare = エッジサーバー)
  • ポータブル(Honoコード = 複数のホスト)

制限

  • リアルタイムサーバーイベントなし(メンバー参加、ロール追加、メッセージ削除など)-- 受け取れるのはインタラクションのみ(スラッシュコマンド、ボタン、モーダル)
  • 応答のタイムアウト3秒 -- 超えるとDiscordが「Application did not respond」と表示
  • 本当のイベントが必要なら -- 別のHTTPウェブフックか補助的なWebSocket接続が必要

90%のbot(スラッシュコマンドベースの全部)なら? これで十分。

まとめ

KonosubaRPGとNibiの最適化にかなり時間を費やしたよ。できるだけリクエストを節約するためだったり、処理時間を削るためだったり、コールドブートを減らすためだったり。結果、ほぼ全部で結構なパフォーマンスが出てる。
実は自分のVMでホストし続けるのがめっちゃ面倒くさくなって、ほとんどのプロジェクトをクラウド化(この言葉正しいか知らんけど)し始めたんだよね。ホント、GitHub Actionsに命救われたわ。Workersもだけど、GitHub Actionsとスケジュールでデーモン作れるの知った時はマジで助かった。

email-autoreplyってプロジェクトの記事も書くと思うから、RSSフィード登録して待っててね :))。

覚えておくべき3つのこと:

  1. インタラクションエンドポイント = HTTPサーバーレス -- WebSocketなし、永続接続なし。DiscordがPOST、お前が応答。Cloudflareで無料。
  2. Honoが完璧なツール -- 軽量フレームワーク(12KB)、マルチランタイム、依存関係ゼロ。Cloudflare、Vercel、Node、どこでも同じコード。
  3. Workersでの画像レンダリング = ヤバい -- Satori + Resvg(Wasm)で動的UIをJSXで構成して<100msでPNGに変換できる。完全なゲームがこれで動く。

マジでヤバい xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

100% 서버리스 Discord 봇: Hono + Cloudflare Workers

월 50€나 들던 Discord 봇을 0원으로 교체한 방법 -- 인터랙션 엔드포인트, Hono, Workers, 실시간

Discord bot 100% serverless: Hono + Cloudflare Workers = 💸 제로

내 개인 서버에서 일반 Discord 봇 돌리다가 몇 달을 버텼어.

WebSocket 계속 연결된 상태. 새벽 3시마다 봇이 알아서 재접속. 양을 이상하게 쳐다봤다고 봇이 팡 터짐. 요금은 계속 올라가고.

어느 날 깨달았어: 왜 연결을 유지해야 하는데? Discord가 니가 관심 있는 것만 POST로 보내주면 되잖아. 니가 응답하면 끝.

2021년부터 Discord는 interaction endpoints를 제공하고 있어.

그냥 HTTP야. WebSocket 없음. 상태 저장 없음. 요청 받고, JSON 보내고, 끝. 다음 요청은 알아서 옴.

그리고 최고: Cloudflare Workers는 무료임, 하루 10만 요청까지. 90% 봇은 한 달에 0€임.

이 글은 Hono (초경량 웹 프레임워크)랑 Cloudflare Workers로 WebSocket 없이 Discord 봇 만드는 법을 알려줄 거야. 실제 프로젝트 두 개를 보여줄게: Nibi (일본어 학습 봇, TTS, 짱)랑 Konosuba-RPG (실시간 이미지 렌더링 있는 완전체 Discord 게임 xD).

WebSocket vs Interaction Endpoints: 왜 별로였는가

니가 게임 안 할 때도 연결을 계속 열어둬야 하는 Minecraft 서버를 상상해봐.

그리고 서버가 터질 때마다 자동으로 재접속함. 타임아웃 처리해야 하고, 지수 백오프 재접속 해야 하고, 우리가 다 좆같아하는 boilerplate를 다 처리해야 해. 그냥 interaction 받으려고.

Interaction endpoints는 반대야. Discord가 니 URL로 POST함. 니가 응답함. 끝.

서버가 터지면? Discord가 2-3번 재시도하고 넘어감. 노 드라마.

전 비용: Heroku에서 Node 프로세스 하나 살리려고 한 달에 50€.

후 비용: Cloudflare에서 하루 10만 요청까지 한 달에 0€.

아키텍처: 대체 뭔데?

Discord가 니 endpoint로 요청을 POST함.

Discord: "야! 유저가 /ping 눌렀어!"
      ↓
   니 URL (Cloudflare Worker)
      ↓
   진짜 Discord가 보낸 건지 확인 (서명 검증)
      ↓
   interaction 타입 파싱
      ↓
   핸들러 실행
      ↓
   JSON 반환
      ↓
Discord: "좋아, 이걸 유저한테 보여줄게"

순수 HTTP임. 마법 없음. 무거운 라이브러리 없음.

Hono + Cloudflare Workers: 지갑을 지키는 조합

Hono는 12KB짜리 웹 프레임워크야. 어디서든 돌아감: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... 같은 코드가 어디서든 돌아감.

Cloudflare Workers는 엣지에서 계산하는 거야. 니 요청이 제일 가까운 서버로 감. 응답 시간: <100ms. 비용: 하루 10만 요청까지 무료.

Hono + Cloudflare 조합은 Discord 봇에 완벽함.

전체 봇의 최소 코드는 이거야:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. 헤더 가져오기
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. 진짜 Discord인지 확인 (스팸 아님)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. 받은 거 파싱
  const interaction = JSON.parse(body);

  // 4. 타입에 따라 응답
  if (interaction.type === 1) {
    // Discord 테스트 (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // 슬래시 커맨드
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

봐봐, 30줄이고 작동하는 봇이야.

bot.login() 없음. event emitter 없음. callback hell 없음. 그냥 HTTP.

Cloudflare에 배포:

npm install -D wrangler
npx wrangler deploy

붐. https://mon-bot.workers.dev/interactions 같은 URL이 나옴.

Discord Developer Portal의 "INTERACTIONS ENDPOINT URL"에 넣으면 Discord가 거기로 interaction을 보내기 시작함.

서명 확인: 가짜 요청 금지

Discord는 모든 요청에 공개 키로 서명함. 서명이 잘못된 요청이 오면? 스팸임. 무시하고 계속 감.

discord-interactions 패키지가 처리해줌:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // 정확한 원본 텍스트 (JSON 파싱하면 안 됨!)
  signature,         // x-signature-ed25519 헤더
  timestamp,         // x-signature-timestamp 헤더
  publicKey          // Discord Dev Portal에서 가져옴
);

중요한 함정: 서명은 정확한 body에 의존함. JSON 파싱하고 다시 문자열로 만들거나, body를 로그로 찍으면 서명이 깨짐.

먼저 검증. 그 다음 파싱. 순서가 중요함.

사례 1: Nibi (일본어 학습 봇)

Nibi는 일본어 학습용 Discord 봇이야. 간단한 명령어:

  • /dictionary kanji → 정의 표시
  • /pronounce テキスト → TTS (text-to-speech) 생성
  • /hello → 환영 메시지

각 명령어는 TypeScript 파일 하나씩:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

명령어는 이 인터페이스를 구현함:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<InteractionResponse>;
}

/pronounce 명령어: 봇 말하게 하기

이게 제일 신기해. 텍스트(로마지, 히라가나, 칸지, 뭐든)를 보내면 봇이 히라가나로 변환하고, VOICEVOX나 Google TTS로 TTS를 생성하고, Discord에 오디오 메시지를 보냄.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: '일본어 텍스트 TTS 생성',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: '발음할 텍스트',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Kuroshiro로 로마지 → 히라가나 변환
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. TTS 오디오 생성
      const audioBuffer = await generateTTS(hiragana);

      // 3. Discord에 파일 업로드
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. 오디오와 함께 메시지 전송
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // 초 단위 길이
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `"${text}" 발음` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: '에러: 오디오 생성 불가 xD',
          flags: 64  // ephemeral (비공개 메시지)
        }
      };
    }
  }
};

미쳤지: 외부 API 호출하고, Discord에 파일 업로드하고, 파일로 메시지 보냄. 이 모든 게 WebSocket 없이, 그냥 HTTP로.

Supabase로 데이터 유지

Nibi는 Supabase를 key-value 저장소로 씀. 유저가 등록됐는지 확인:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // 유저 추가
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

매우 기본적이지만 (진짜 SQL 쿼리 없이 그냥 JSON), 작동은 함. 작은 봇에는 완벽해.

사례 2: Konosuba-RPG (이미지 렌더링 Discord 게임)

자 이건 진짜 미친 거야.

Konosuba-RPG는 Discord 위의 완전체 게임이야. 몹과 싸우고, XP 얻고, 악세서리 장착하고, 레벨 업 함. 전투마다 이미지를 실시간으로 생성함. 미리 렌더링된 스프라이트 시트 없음. 플레이어 스탯, 몹, 전투 상태로 동적으로 이미지를 구성함.

그리고 이미지가 Cloudflare Workers에서 <500ms만에 생성됨. 말 그대로.

렌더링 아키텍처

Discord ("Attack" 클릭)
    ↓
Cloudflare Worker가 interaction 받음
    ↓
게임 상태 업데이트 (XP, HP 등)
    ↓
Satori로 JSX 생성
    ↓
Resvg (Wasm)로 SVG → PNG 변환
    ↓
Discord에 이미지 업로드
    ↓
이미지와 함께 메시지 전송

이 모든 게 1초도 안 걸림. 존나 대박이야.

Workers에서 이미지 렌더링

Konosuba는 Satori (JSX → SVG)랑 Resvg (SVG → PNG)를 씀:

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. UI용 JSX 생성
  const jsx = (
    <div style={{ display: 'flex', gap: '20px' }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

평범한 JSX를 쓰면 됨. 그게 SVG가 됨. SVG가 PNG가 됨. Cloudflare Worker에서 <100ms.

파워를 이해하겠어? 그냥... 아름다워 xD

게임 상태와 진행

플레이어 데이터는 Supabase에 있음:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// 플레이어 승리
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

모든 액션 (공격, 방어, 힐)이 DB에 스탯을 업데이트함. 그리고 새 스탯으로 이미지를 다시 생성함.

인터랙션: 게임플레이 버튼

게임은 전투 액션에 button interaction을 사용함:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (파랑)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (회색)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

"Attack"을 클릭하면 Discord가 custom_id: 'battle_attack'를 가진 interaction을 POST함. 핸들러가 이걸 라우팅:

if (interaction.type === 3) {
  // 컴포넌트 interaction (버튼 클릭 등)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

그리고 붐, 데미지 계산하고, DB 업데이트하고, 이미지 다시 생성하고, 보냄.

연결 유지 하나 없이 완전한 턴제 게임임. 그냥 HTTP stateless. 완전 개쩔어 xD

Supabase: Workers를 위해 만들어진 DB

전통적인 DB들(PostgreSQL, MySQL, MongoDB)은 지속적인 TCP 연결을 위해 설계됐어. 소켓 열고, 연결 유지하고, 쿼리 보내고. 문제: Cloudflare Workers는 지속적인 TCP 연결을 지원하지 않음. 각 요청은 임시 프로세스라서, 클라이언트에 응답하는 순간 Worker가 사라짐.

이런 건 안 됨:

// 이건 Workers에서 작동 안 함
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // 지속적 TCP 연결 = 죽음

pg나 postgres.js 같은 네이티브 PostgreSQL 드라이버도 TCP 연결을 씀. Workers에서 죽음.

Supabase가 다 해결함.

Supabase는 PostgreSQL 위에 있는 REST API임. 평범한 HTTP 요청을 보내면 됨. 각 호출은 독립적이고, 지속 연결 없고, 관리할 상태도 없음. 서버리스 모델에 완벽함.

// 이건 Workers에서 완벽하게 작동함
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Supabase 클라이언트(@supabase/supabase-js)는 내부적으로 fetch를 씀. 그리고 fetch는 Workers에서 네이티브임. 설정 제로, 드라이버 제로, 지속 연결 제로.

DB Workers 호환? 이유
Supabase ✅ 예 무상태 REST API, 순수 HTTP
PlanetScale (MySQL) ⚠️ 부분적 HTTPS 전용, 긴 트랜잭션 불가
Neon ⚠️ 부분적 서버리스 브랜치지만 TCP 드라이버 필요
Turso (libSQL) ⚠️ 부분적 HTTP 가능하지만 제한적
Prisma/Prisma Postgres ❌ 아니오 지속적 TCP 필요
MongoDB Atlas ❌ 아니오 TCP 드라이버, 네이티브 REST API 없음
Redis (Upstash) ✅ 예 HTTP 기반 REST API

Supabase의 진짜 장점은 DB만이 아님 -- 생태계 전체가 edge-first로 설계됐다는 거:

  • Auth: 세션용 REST API, 무상태로 작동
  • Storage: HTTP로 파일 업로드/다운로드
  • Realtime: 선택적 WebSocket, REST로 폴링도 가능
  • Row Level Security: 보안 규칙이 DB에 있고, 백엔드에는 없음

서버리스 Discord 봇에는 Supabase가 가장 간단하고 믿을 수 있는 선택임. 설정할 드라이버 없음, 유지할 연결 없음, 타임아웃 없음. 그냥 HTTP 요청.

실제 예시를 보고 싶으면 위에 Nibi를 봐: 그 영속성 코드는 말 그대로 Supabase에서 readJson()하고 writeJson()임. 마이그레이션 없음, 복잡한 스키마 없음, 미친 설정 없음. 바로 작동함. 그리고 봇이 커지면, 제공자 변경 없이 진짜 SQL 쿼리로 마이그레이션 가능함.

Polyfills: Node 라이브러리를 Workers에서 돌릴 때

어떤 패키지들은 Node API를 기대함. Kuromoji (칸지 파서)는 XMLHttpRequest를 씀. Workers는 fetch는 있어도 XMLHttpRequest는 없음.

간단한 해결책: index.ts 상단에 polyfill 추가:

// kuromoji용 XMLHttpRequest 폴리필
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // 최소한의 스텁
  } as any;
}

아니면 전용 모듈로 만들기:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

기본적인 핵이지만 작동은 함.

npm 패키지로: hono-discord-interactions

수동으로 봇 만들면 boilerplate가 너무 많음:

  • Discord 서명 확인
  • interaction 타입 라우팅
  • 명령어, 컴포넌트, 모달 처리
  • 유효한 JSON 반환

이걸 다 npm 패키지로 추상화할 수 있음. 이런 식으로:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

붐. 200줄 대신 20줄. Nibi를 절반으로 줄일 수 있음.

나중에 할 생각 xD

배포

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

결과 URL: https://mon-bot.workers.dev/interactions

비용: 하루 10만 요청까지 무료. 초과 시: $0.50/백만.

스포일러: 사용자 10,000명 없으면 10만 요청 절대 못 넘김.

Vercel

npm run vercel:deploy

URL: https://mon-bot-xyz.vercel.app/api/interactions

마찬가지로 무료.

둘 다 동시에

Hono는 어디서든 돌아감. 같은 코드를 Cloudflare랑 Vercel 둘 다 배포할 수 있음. 중복성이나 고르기 전에 테스트할 때 유용함.

빠른 체크리스트

  1. Discord Developer Portal에서 Application 만들기
  2. PUBLIC_KEY, BOT_TOKEN, APP_ID 복사
  3. 프로젝트 생성:
  4. index.ts 작성 (서명 확인 + 라우팅)
  5. 슬래시 명령어 등록 (한 번만):
  6. 배포:
  7. Discord에 URL 넣기 (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord가 연결 테스트 (PING에 응답해야 함)
  9. 서버에 봇 초대
  10. 끝

장점 vs 한계

장점

  • 싸다 (하루 10만 req까지 무료)
  • 확장 가능 (연결 관리 필요 없음)
  • 간단함 (WebSocket boilerplate 없음)
  • 빠름 (Cloudflare = 엣지 서버)
  • 이식성 좋음 (Hono 코드 = 여러 호스트)

한계

  • 실시간 서버 이벤트 없음 (멤버 입장, 역할 추가, 메시지 삭제 등) -- interaction만 받음 (슬래시 명령어, 버튼, 모달)
  • 응답 제한 3초 -- 안 하면 Discord가 "Application did not respond" 띄움
  • 진짜 이벤트가 필요하면 -- 별도 HTTP webhook이나 보조 WebSocket 연결 필요

90% 봇 (슬래시 명령어 기반)이면? 충분함.

마무리

KonosubaRPG랑 Nibi 최적화하는 데 꽤 시간을 썼어. 요청을 최대한 줄이거나, 핫 프로세스 시간을 줄이거나, 콜드 부트를 줄이려고. 결과적으로 거의 모든 면에서 쩌는 성능을 냈어. 내 VM에 계속 호스팅하기가 귀찮아서 프로젝트들을 cloud화하기 시작했거든 (이 말이 맞는지도 모르겠다 xD). 진짜, Github Actions가 내 엉덩이를 구했어. Workers도 좋지만, Github Actions로 스케줄된 데몬을 만들 수 있다는 걸 알았을 때 진짜 살았어.

아마 email-autoreply라는 프로젝트에 대한 글도 쓸 거니까 RSS 피드 구독하고 기다려줘 :))

기억할 3가지:

  1. Interaction endpoints = serverless HTTP -- WebSocket 없음, 지속 연결 없음. Discord가 POST하고 니가 응답함. Cloudflare에서 무료.
  2. Hono가 완벽한 도구임 -- 가벼운 프레임워크 (12KB), 멀티 런타임, 의존성 제로. Cloudflare, Vercel, Node, 어디서든 같은 코드.
  3. Workers에서 이미지 렌더링 = 미쳤음 -- Satori + Resvg (Wasm)로 JSX로 동적 UI를 구성하고 <100ms만에 PNG로 변환. 완전한 게임이 이걸로 돌아감.

존나 쩔어 xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

%100 sunucusuz Discord botu: Hono + Cloudflare Workers

Bana ayda 50€'ya mal olan bir Discord botunu sıfır euro ile nasıl

Discord bot %100 serverless : Hono + Cloudflare Workers = 💸 sıfır

Birkaç ay boyunca klasik Discord botlarını kendi makinemde çalıştırdım.

WebSocket bağlantısı hep açık. Bot gece 3'te kendi kendine yeniden bağlanıyor. Bot çöküyor çünkü koyunlara yanlış baktım resmen. Fatura yükseliyor.

Bir gün fark ettim: neden bir bağlantıyı açık tutayım ki ? Discord sana sadece ilgini çeken şeyleri POST'layabilir. Sen cevaplarsın, olur biter.

2021'den beri Discord interaction endpoints sunuyor.

Sadece HTTP. WebSocket yok. Kalıcı durum yok. Bir istek alırsın, JSON gönderirsin, biter. Sonraki istek kendi gelir.

En iyisi: Cloudflare Workers ücretsiz, günde 100k isteğe kadar. Botların %90'ı için ayda 0€.

Bu yazıda sana WebSocket olmadan Discord botu nasıl yapılır göstereceğim, Hono (ultra hafif web framework) ve Cloudflare Workers kullanarak. Sana iki gerçek proje göstereceğim: Nibi (Japonca öğrenme botu, TTS, havalı) ve Konosuba-RPG (gerçek zamanlı image render'lı komple bir Discord oyunu xD).

WebSocket vs. Interaction Endpoints : neden kötü bir fikirdi

Oynamadığın zaman bile bağlantıyı açık tutman gereken bir Minecraft oyunu düşünelim.

Ve sunucu her çöktüğünde kendi kendine yeniden bağlanıyor. Timeout'ları, exponential reconnection'ları, nefret ettiğimiz tüm o boktan boilerplate'leri yönetmek zorundasın. Sırf interaction almak için.

Interaction endpoints tam tersi. Discord senin URL'ine POST atar. Sen cevaplarsın. Biter.

Sunucun çöktü mü ? Discord 2-3 kere tekrar dener ve geçer. Sıfır drama.

Öncesi maliyet : Heroku'da sadece bir Node prosesini canlı tutmak için aylık 50€.

Sonrası maliyet : Cloudflare'da günde 100k isteğe kadar ayda 0€.

Mimari : tam olarak ne bu ?

Discord endpoint'ine POST isteği atar.

Discord: "Hey! Kullanıcı /ping'e tıkladı!"
      ↓
   Senin URL'n (Cloudflare Worker)
      ↓
   Gerçekten Discord mu diye kontrol edersin (imza doğrulama)
      ↓
   Interaction türünü parse edersin
      ↓
   Handler'ı çalıştırırsın
      ↓
   JSON döndürürsün
      ↓
Discord: "Havalı, bunu kullanıcıya göstereceğim"

Saf HTTP. Sihir yok. Ağır kütüphane yok.

Hono + Cloudflare Workers : ekonomik combo

Hono 12KB ağırlığında bir web framework. Her yerde çalışır: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... aynı kod her yerde.

Cloudflare Workers edge'de compute demek. İsteklerin en yakın sunucuya gider. Yanıt süresi: <100ms. Maliyet: günde 100k isteğe kadar ücretsiz.

Hono + Cloudflare bir Discord botu için mükemmel eşleşme.

İşte komple bir botun minimal kodu:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Header'ları al
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. Gerçekten Discord mu diye kontrol et (spam değil)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Ne gönderdiğini parse et
  const interaction = JSON.parse(body);

  // 4. Türe göre cevap ver
  if (interaction.type === 1) {
    // Discord test (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // Bu bir slash command
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

Yani 30 satırda çalışan bir bot.

bot.login() yok. Event emitter yok. Callback hell yok. Sadece HTTP.

Cloudflare'e deploy etmek için:

npm install -D wrangler
npx wrangler deploy

Boom. https://mon-bot.workers.dev/interactions gibi bir URL'n olur.

Bunu Discord Developer Portal'da "INTERACTIONS ENDPOINT URL" kısmına koyarsın ve Discord interaction'larını oraya göndermeye başlar.

İmza doğrulama : sahte istek yok

Discord her isteği bir public key ile imzalar. Yanlış imzalı istek mi geldi ? Spamdır. Görmezden gel ve devam et.

discord-interactions paketi işi yapıyor:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // aynen ham metin (parse edilmiş JSON değil !)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // Discord Dev Portal'dan
);

Önemli tuzak : imza body'nin birebir aynısına bağlı. JSON parse edip tekrar stringify edersen veya body'i loglarsan imzayı bozarsın.

Önce doğrula. Sonra parse et. Sıra önemli.

Vaka 1 : Nibi (Japonca öğrenme botu)

Nibi Japonca öğrenmek için bir Discord botu. Basit komutlar:

  • /dictionary kanji → tanımları gösterir
  • /pronounce テキスト → TTS (text-to-speech) üretir
  • /hello → karşılama mesajı

Her komut bir TypeScript dosyası:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

Bir komut şu interface'i implemente eder:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<InteractionResponse>;
}

/pronounce komutu : botu konuşturmak

İşte bu çılgınca olan. Metin gönderirsin (romaji, hiragana, kanji, her neyse), bot bunu hiragana'ya çevirir, VOICEVOX veya Google TTS ile TTS üretir ve Discord'a sesli mesaj gönderir.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Japonca metin için TTS üretir',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Telaffuz edilecek metin',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Kuroshiro ile romaji → hiragana çevir
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. TTS sesi oluştur
      const audioBuffer = await generateTTS(hiragana);

      // 3. Dosyayı Discord'a yükle
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Sesli mesajı gönder
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // saniye cinsinden süre
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `"${text}" için telaffuz` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'Hata : ses oluşturulamadı xD',
          flags: 64  // ephemeral (gizli mesaj)
        }
      };
    }
  }
};

Deli işi: harici bir API çağırırsın, Discord'a dosya yüklersin, dosyayla mesaj gönderirsin. WebSocket olmadan, sadece HTTP ile.

Supabase ile kalıcılık

Nibi, key-value store olarak Supabase kullanıyor. Kullanıcının kayıtlı olup olmadığını kontrol etmek için:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // Kullanıcıyı ekle
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

Çok basit (gerçek SQL sorgusu değil, sadece JSON), ama iş görüyor. Küçük botlar için mükemmel.

Vaka 2 : Konosuba-RPG (görsel render'lı Discord oyunu)

Tamam bu çılgınca.

Konosuba-RPG Discord üzerinde komple bir oyun. Moblarla savaşırsın, XP kazanırsın, eşya takarsın, seviye atlarsın. Her savaş gerçek zamanlı görsel üretir. Önceden render edilmiş spritesheet yok. Görsel, oyuncunun statlarına, mob'a ve savaş durumuna göre dinamik olarak oluşturulur.

Ve görsel Cloudflare Workers üzerinde <500ms'de oluşur. Gerçekten.

Render mimarisi

Discord ("Attack" butonuna tıklarsın)
    ↓
Cloudflare Worker interaction'ı alır
    ↓
Oyun durumu güncellenir (XP, HP, vb.)
    ↓
Satori ile JSX oluşturulur
    ↓
Resvg (Wasm) ile SVG → PNG dönüşümü
    ↓
Görsel Discord'a yüklenir
    ↓
Görselle birlikte mesaj gönderilir

Hepsi bir saniyeden kısa sürede. İnanılmaz.

Workers tarafında görsel render

Konosuba Satori (JSX → SVG) ve Resvg (SVG → PNG) kullanıyor:

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. UI için JSX oluştur
  const jsx = (
    <div style={{ display: 'flex', gap: '20px' }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

Normal JSX yazarsın. SVG'ye dönüşür. SVG, PNG olur. Cloudflare Worker'da <100ms.

Gücü anlıyor musun ? Sadece... çok güzel xD

Oyun durumu ve ilerleme

Oyuncu verileri Supabase'te:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// Oyuncu kazandı
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

Her eylem (saldırı, savunma, iyileşme) veritabanındaki statları günceller. Sonra yeni statlarla görseli yeniden oluşturursun.

Interaction'lar : oynanış butonları

Oyun, savaş eylemleri için button interaction kullanıyor:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (mavi)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (gri)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

"Attack" butonuna tıkladığında Discord custom_id: 'battle_attack' ile bir interaction POST'lar. Handler şöyle yönlendirir:

if (interaction.type === 3) {
  // Component interaction (buton tıklaması, vb.)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

Ve boom, hasarı hesaplarsın, veritabanını güncellersin, görseli yeniden oluşturursun, gönderirsin.

Hiçbir bağlantı kalıcılığı olmayan komple bir sıra tabanlı oyun. Sadece HTTP stateless. Tamamen kırık xD

Supabase: Workers için yapılmış veritabanı

Geleneksel veritabanları (PostgreSQL, MySQL, MongoDB) kalıcı TCP bağlantıları için tasarlanmıştır. Bir soket açarsın, bağlantıyı canlı tutarsın, sorgular gönderirsin. Sorun: Cloudflare Workers kalıcı TCP bağlantılarını desteklemez. Her istek geçici bir süreçtir. İstemciye yanıt verdiğin an Worker kaybolur.

Bunu yapamazsın:

// Bu Workers'ta ÇALIŞMAZ
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // kalıcı TCP bağlantısı = ölü

pg veya postgres.js gibi native PostgreSQL sürücüleri bile TCP bağlantıları kullanır. Workers'ta çökerler.

Supabase her şeyi çözüyor.

Supabase, PostgreSQL üzerinde bir REST API'sidir. Normal HTTP istekleri yaparsın. Her çağrı bağımsızdır, kalıcı bağlantı yok, yönetilecek durum yok. Serverless modeli için mükemmel.

// Bu Workers'ta MÜKEMMEL çalışır
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Supabase istemcisi (@supabase/supabase-js) altında fetch kullanır. Ve fetch Workers'ta native. Sıfır yapılandırma, sıfır sürücü, sıfır kalıcı bağlantı.

Veritabanı Workers uyumlu mu? Neden
Supabase ✅ Evet Durumsuz REST API, saf HTTP
PlanetScale (MySQL) ⚠️ Kısmen Yalnızca HTTPS bağlantısı, uzun işlemler yok
Neon ⚠️ Kısmen Serverless dallanma ama TCP sürücüsü gerekli
Turso (libSQL) ⚠️ Kısmen HTTP mümkün ama sınırlı
Prisma/Prisma Postgres ❌ Hayır Kalıcı TCP gerektirir
MongoDB Atlas ❌ Hayır TCP sürücüsü, native REST API yok
Redis (Upstash) ✅ Evet HTTP üzerinden REST API

Supabase'in gerçek avantajı sadece DB değil -- tüm ekosistemin edge-first düşünülerek tasarlanmış olması:

  • Auth: Oturumlar için REST API, durumsuz çalışır
  • Storage: HTTP ile dosya yükleme/indirme
  • Realtime: İsteğe bağlı WebSocket, ama REST ile de poll yapabilirsin
  • Row Level Security: güvenlik kuralları DB'de yaşar, backend'inde değil

Serverless bir Discord botu için Supabase en basit ve en güvenilir seçimdir. Yapılandırılacak sürücü yok, korunacak bağlantı yok, zaman aşımı yok. Sadece HTTP istekleri.

Gerçek bir örnek istersen, yukarıdaki Nibi'ye bak: kalıcılık kodu tam anlamıyla Supabase üzerinde readJson() ve writeJson(). Migration yok, karmaşık şema yok, çılgın yapılandırma yok. Kutudan çıktığı gibi çalışır. Ve botun büyürse, sağlayıcı değiştirmeden gerçek SQL sorgularına geçebilirsin.

Polyfill'ler : Node Workers'ta çalışmak istediğinde

Bazı paketler Node API'leri bekler. Kuromoji (kanji parser) XMLHttpRequest kullanır. Workers'ta XMLHttpRequest değil fetch var.

Basit çözüm: index.ts'nin tepesine bir polyfill ekle:

// Kuromoji için XMLHttpRequest polyfill
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Minimal stub
  } as any;
}

Ya da ayrı bir modül yap:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

Basit bir hack, ama çalışıyor.

Bir npm paketine doğru : hono-discord-interactions

Elden bot yapmak çok boilerplate:

  • Discord imzasını doğrula
  • Interaction türlerini yönlendir
  • Komutları, component'leri, modal'ları yönet
  • Geçerli JSON döndür

Bunların hepsini bir npm paketinde soyutlayabiliriz. Şöyle:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

Bam. 200 satır yerine 20 satır. Nibi'yi rahatça yarıya indirir.

Sonra düşünürüz xD

Deploy

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

Sonuç URL: https://mon-bot.workers.dev/interactions

Maliyet: günde 100k isteğe kadar ücretsiz. Üstü: $0.50/milyon.

Spoiler: 10.000 aktif kullanıcın olmadıkça 100k isteği asla geçmezsin.

Vercel

npm run vercel:deploy

URL: https://mon-bot-xyz.vercel.app/api/interactions

Aynı, ücretsiz.

İkisi birden

Hono her yerde çalışır. Aynı kodu Cloudflare VE Vercel'e deploy edebilirsin. Yedeklilik için veya seçmeden önce test etmek için kullanışlı.

Hızlı kontrol listesi

  1. Discord Developer Portal'da bir Application oluştur
  2. PUBLIC_KEY, BOT_TOKEN, APP_ID'yi kopyala
  3. Projeyi oluştur:
  4. index.ts yaz (imza doğrulama + yönlendirme)
  5. Slash command'ları kaydet (bir kere):
  6. Deploy et:
  7. URL'yi Discord'a ekle (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord bağlantıyı test eder (PING'e cevap vermelisin)
  9. Botu bir sunucuya davet et
  10. Oldu bu

Avantajlar vs Sınırlamalar

Avantajlar

  • Ucuz (günde 100k isteğe kadar ücretsiz)
  • Ölçeklenebilir (bağlantı yönetimi yok)
  • Basit (WebSocket boilerplate yok)
  • Hızlı (Cloudflare = edge sunucular)
  • Taşınabilir (Hono kodu = birden çok host)

Sınırlamalar

  • Gerçek zamanlı sunucu olayları yok (üye katıldı, rol eklendi, mesaj silindi, vb.) -- sadece interaction alırsın (slash commands, butonlar, modallar)
  • Cevap vermek için 3 saniye timeout -- yoksa Discord "Application did not respond" gösterir
  • Gerçek event'ler gerekiyorsa -- ayrı bir HTTP webhook veya ek WebSocket bağlantısı gerekir

Botların %90'ı için (slash command tabanlı her şey) ? Yeter.

Sonuç olarak

KonosubaRPG ve Nibi'yi optimize etmek için epey zaman harcadım -- ya mümkün olduğunca az istek yapmak için, ya sıcak işlemci süresini azaltmak için, ya da soğuk başlatmayı düşürmek için. Sonuç olarak, neredeyse her şeyde epey iyi performans aldım. Şunu bilmelisin ki projelerimin çoğunu cloud'a taşımaya (buna denir mi bilmiyorum) başlamıştım çünkü kendi VM'de barındırmaya devam etmek için inanılmaz bir üşengeçliğim vardı. Gerçekten, Github Actions benim kıçımı kurtardı diyebilirim. Workers da öyle, ama aslında Github Actions ve schedule'larla daemon yapabildiğimi görünce gerçekten kurtulmuştum valla.

Muhtemelen email-autoreply adlı bir proje hakkında yazı yazacağım, o yüzden çıktığını görmek için RSS beslemesine abone olun :))

Unutulmaması gereken 3 şey:

  1. Interaction endpoints = HTTP serverless -- WebSocket yok, kalıcı bağlantı yok. Discord POST atar, sen cevaplarsın. Cloudflare'da ücretsiz.
  2. Hono mükemmel araç -- Hafif framework (12KB), çoklu runtime, sıfır bağımlılık. Cloudflare, Vercel, Node, her yerde aynı kod.
  3. Workers'ta görsel render = deli işi -- Satori + Resvg (Wasm) ile dinamik UI'ları JSX'te oluşturup <100ms'de PNG'ye çevirebilirsin. Komple bir oyun bununla çalışabilir.

Hastalık bu xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

Bot Discord 100% serverless: Hono + Cloudflare Workers

Come ho sostituito un bot Discord che mi costava 50€/mese con zero

Discord bot 100% serverless : Hono + Cloudflare Workers = 💸 zero

Ho passato qualche mese a mantenere bot Discord classici sulla mia macchina.

Connessione WebSocket sempre aperta. Il bot si riconnette da solo alle 3 di notte. Il bot crasha perché guardo le pecore storte. La bolletta sale.

Un giorno ho scoperto: perché mantenere una connessione ? Discord può POSTarti solo le cose che ti interessano. Rispondi, fatto.

Dal 2021, Discord offre gli interaction endpoints.

È solo HTTP. Niente WebSocket. Niente stato persistente. Ricevi una richiesta, mandi del JSON, è finita. La richiesta successiva arriva da sola.

E il meglio: Cloudflare Workers è gratuito fino a 100k richieste/giorno. Per il 90% dei bot, sono 0€/mese.

Questo articolo ti mostra come fare un bot Discord senza WebSocket usando Hono (framework web ultra-leggero) e Cloudflare Workers. Ti mostrerò due progetti reali: Nibi (bot per imparare il giapponese, TTS, figo) e Konosuba-RPG (un gioco Discord completo con rendering dell'immagine in tempo reale xD).

WebSocket vs. Interaction Endpoints : perché era una brutta idea

Immagina un gioco Minecraft dove devi tenere la connessione aperta anche quando non giochi.

E il server si riconnette automaticamente ogni volta che crasha. Devi gestire i timeout, le riconnessioni esponenziali, tutto il boilerplate di merda che odiamo. Solo per ricevere interazioni.

Gli interaction endpoints sono l'opposto. Discord POSTa sulla tua URL. Rispondi. Fine.

Se il tuo server crasha? Discord riprova 2-3 volte e passa oltre. Zero drama.

Costo prima: 50€/mese su Heroku solo per tenere vivo un processo Node.

Costo dopo: 0€/mese su Cloudflare fino a 100k richieste/giorno.

L'architettura : cos'è di preciso?

Discord POSTa una richiesta sul tuo endpoint.

Discord: "Ehi! L'utente ha cliccato su /ping!"
      ↓
   La tua URL (Cloudflare Worker)
      ↓
Verifichi che sia davvero Discord (controllo firma)
      ↓
Parso il tipo di interazione
      ↓
Esegui l'handler
      ↓
Ritorni JSON
      ↓
Discord: "Figo, mostro questo all'utente"

È HTTP puro. Nessuna magia. Nessuna libreria pesante.

Hono + Cloudflare Workers : il combo economico

Hono è un framework web che pesa 12KB. Gira ovunque: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... lo stesso codice dappertutto.

Cloudflare Workers è compute al edge. Le tue richieste arrivano al server più vicino. Tempo di risposta: <100ms. Costo: gratuito fino a 100k richieste/giorno.

Il combo Hono + Cloudflare è la partita perfetta per un bot Discord.

Ecco il codice minimo di un bot completo:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Prendi gli header
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. Verifica che sia davvero Discord (niente spam)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Parso quello che ha mandato
  const interaction = JSON.parse(body);

  // 4. Rispondi in base al tipo
  if (interaction.type === 1) {
    // Test Discord (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // È una slash command
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

Tipo, 30 righe ed è un bot funzionante.

Niente bot.login(). Niente event emitter. Niente callback hell. Solo HTTP.

Per deploy su Cloudflare:

npm install -D wrangler
npx wrangler deploy

Boom. Hai una URL tipo https://mon-bot.workers.dev/interactions.

La metti nel Discord Developer Portal sotto "INTERACTIONS ENDPOINT URL", e Discord inizia a mandarti le interazioni lì.

Verificare la firma : niente fake requests

Discord firma ogni richiesta con una chiave pubblica. Se ricevi una richiesta con una firma sbagliata? È spam. Ignora e vai avanti.

Il pacchetto discord-interactions fa il lavoro:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // testo esatto (non JSON parsato!)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // dal Discord Dev Portal
);

Trappola importante: la firma dipende dal body esatto. Se parsi JSON e re-stringifichi, o se logghi il body, rompi la firma.

Verifica prima. Parsa dopo. È l'ordine che conta.

Caso 1 : Nibi (bot apprendimento giapponese)

Nibi è un bot Discord per imparare il giapponese. Comandi semplici:

  • /dictionary kanji → mostra le definizioni
  • /pronounce テキスト → genera TTS (text-to-speech)
  • /hello → messaggio di benvenuto

Ogni comando è un file TypeScript:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

Un comando implementa questa interfaccia:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<InteractionResponse>;
}

Il comando /pronounce : far parlare il bot

Questo è quello più pazzesco. Mandi del testo (romaji, hiragana, kanji, qualsiasi cosa), il bot converte in hiragana, genera TTS via VOICEVOX o Google TTS, e invia un messaggio audio su Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Genera TTS per testo giapponese',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Testo da pronunciare',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Convertire romaji → hiragana con Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. Generare l'audio TTS
      const audioBuffer = await generateTTS(hiragana);

      // 3. Upload del file su Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Inviare il messaggio con l'audio
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // durata in secondi
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `Pronuncia per "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'Errore: impossibile generare l\'audio xD',
          flags: 64  // ephemeral (messaggio privato)
        }
      };
    }
  }
};

È pazzesco: chiami un'API esterna, carichi un file su Discord, invii un messaggio con il file. Tutto senza WebSocket, solo HTTP.

Persistenza con Supabase

Nibi usa Supabase come key-value store. Per verificare se un utente è registrato:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // Aggiungere l'utente
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

È molto basico (niente vere query SQL, solo JSON), ma funziona. Per i bot piccoli è perfetto.

Caso 2 : Konosuba-RPG (gioco Discord con rendering immagine)

Ok, questa è pazzesca.

Konosuba-RPG è un gioco completo su Discord. Combatti mob, guadagni XP, equipaggi accessori, sali di livello. Ogni battaglia genera un'immagine in tempo reale. Niente spritesheet pre-renderizzato. L'immagine è composta dinamicamente dalle stats del giocatore, il mob, e lo stato del combattimento.

E l'immagine si genera in <500ms su Cloudflare Workers. Letteralmente.

L'architettura del rendering

Discord (clicchi "Attack")
    ↓
Cloudflare Worker riceve l'interazione
    ↓
Aggiornamento del game state (XP, HP, ecc.)
    ↓
Genera JSX con Satori
    ↓
Converte SVG → PNG con Resvg (Wasm)
    ↓
Upload dell'immagine su Discord
    ↓
Invia il messaggio con l'immagine

Tutto in meno di un secondo. È pazzesco.

Rendering immagine lato Workers

Konosuba usa Satori (JSX → SVG) e Resvg (SVG → PNG):

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. Creare JSX per la UI
  const jsx = (
    <div style={{ display: 'flex', gap: '20px' }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

Scrivi JSX normale. Diventa SVG. SVG diventa PNG. <100ms su un Cloudflare Worker.

Capisci la potenza? È tipo... bellissimo xD

Game state e progressione

I dati del giocatore sono su Supabase:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// Il giocatore ha vinto
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

Ogni azione (attacco, difesa, cura) aggiorna le stats nel database. E poi rigeneri l'immagine con le nuove stats.

Interazioni : i bottoni del gameplay

Il gioco usa button interactions per le azioni in combattimento:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (blu)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (grigio)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

Quando clicchi "Attack", Discord POSTa un'interazione con custom_id: 'battle_attack'. L'handler la smista:

if (interaction.type === 3) {
  // Component interaction (click bottone, ecc.)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

E boom, calcoli i danni, aggiorni il database, rigeneri l'immagine, invii.

È un gioco turn-based completo senza nessuna persistenza di connessione. Solo HTTP stateless. Completamente rotto xD

Supabase: il database fatto per Workers

I database tradizionali (PostgreSQL, MySQL, MongoDB) sono progettati per connessioni TCP persistenti. Apri un socket, tieni la connessione aperta, invii query. Problema: Cloudflare Workers non supporta connessioni TCP persistenti. Ogni richiesta è un processo effimero. Nel momento in cui rispondi al client, il Worker sparisce.

Non puoi fare questo:

// Questo NON funziona su Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // connessione TCP persistente = morto

Anche i driver PostgreSQL nativi come pg o postgres.js usano connessioni TCP. Su Workers, crashano.

Supabase risolve tutto.

Supabase è un'API REST sopra PostgreSQL. Fai normali richieste HTTP. Ogni chiamata è indipendente, nessuna connessione persistente, nessuno stato da gestire. È perfetto per il modello serverless.

// Questo funziona PERFETTAMENTE su Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Il client Supabase (@supabase/supabase-js) usa fetch sotto il cofano. E fetch è nativo su Workers. Zero configurazione, zero driver, zero connessione persistente.

Database Compatibile con Workers? Perché
Supabase ✅ Sì API REST senza stato, HTTP puro
PlanetScale (MySQL) ⚠️ Parziale Solo connessione HTTPS, niente transazioni lunghe
Neon ⚠️ Parziale Ramificazioni serverless ma driver TCP necessario
Turso (libSQL) ⚠️ Parziale HTTP possibile ma limitato
Prisma/Prisma Postgres ❌ No Richiede TCP persistente
MongoDB Atlas ❌ No Driver TCP, niente API REST nativa
Redis (Upstash) ✅ Sì API REST su HTTP

Il vero vantaggio di Supabase non è solo il DB -- è l'intero ecosistema pensato per l'edge:

  • Auth: API REST per le sessioni, funziona senza stato
  • Storage: Caricamento/scaricamento file via HTTP
  • Realtime: WebSocket opzionale, ma puoi anche fare poll via REST
  • Row Level Security: le regole di sicurezza vivono nel DB, non nel tuo backend

Per un bot Discord serverless, Supabase è la scelta più semplice e affidabile. Nessun driver da configurare, nessuna connessione da mantenere, nessun timeout. Solo richieste HTTP.

Se vuoi un esempio reale, guarda Nibi qui sopra: il suo codice di persistenza è letteralmente readJson() e writeJson() su Supabase. Nessuna migrazione, nessuno schema complesso, nessuna configurazione pazzesca. Funziona subito. E se il tuo bot diventa grande, puoi migrare a vere query SQL senza cambiare fornitore.

Polyfills : quando Node vuole girare su Workers

Alcuni pacchetti si aspettano API Node. Kuromoji (parser kanji) usa XMLHttpRequest. I Workers hanno fetch, non XMLHttpRequest.

Soluzione semplice: aggiungere un polyfill all'inizio di index.ts:

// Polyfill XMLHttpRequest per kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Stub minimale
  } as any;
}

O fare un modulo dedicato:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

È un hack basico, ma funziona.

Verso un pacchetto npm : hono-discord-interactions

A mano, fare un bot è tanto boilerplate:

  • Verificare la firma Discord
  • Smistare i tipi di interazione
  • Gestire comandi, components, modals
  • Ritornare JSON valido

Si potrebbe astrarre tutto questo in un pacchetto npm. Tipo:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

Boom. 20 righe invece di 200. Ridurrebbe Nibi della metà facile.

Idea per dopo xD

Deploy

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

URL risultante: https://mon-bot.workers.dev/interactions

Costo: gratuito fino a 100k richieste/giorno. Oltre: $0.50/milione.

Spoiler: non raggiungerai mai le 100k richieste a meno di avere 10 000 utenti attivi.

Vercel

npm run vercel:deploy

URL: https://mon-bot-xyz.vercel.app/api/interactions

Stesso, gratuito.

Entrambi insieme

Hono gira ovunque. Puoi deployare lo stesso codice su Cloudflare E Vercel. Utile per ridondanza o per testare prima di scegliere.

Checklist veloce

  1. Creare un'Applicazione su Discord Developer Portal
  2. Copiare PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Creare il progetto:
  4. Scrivere index.ts (verifica firma + routing)
  5. Registrare le slash commands (una volta):
  6. Deploy:
  7. Mettere l'URL in Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord testa la connessione (devi rispondere al PING)
  9. Invitare il bot su un server
  10. Fatto

Vantaggi vs Limitazioni

Vantaggi

  • Economico (gratuito fino a 100k req/giorno)
  • Scalabile (niente gestione connessioni)
  • Semplice (niente boilerplate WebSocket)
  • Veloce (Cloudflare = server al edge)
  • Portabile (codice Hono = più host)

Limitazioni

  • Niente eventi server in tempo reale (membro entra, ruolo aggiunto, messaggio eliminato, ecc.) -- ricevi solo interazioni (slash commands, buttons, modals)
  • Timeout di 3 secondi per rispondere -- altrimenti Discord mostra "Application did not respond"
  • Se ti servono veri eventi -- serve un webhook HTTP separato o una connessione WebSocket ausiliaria

Per il 90% dei bot (tutto basato su slash commands)? È ok.

Per concludere

Ho passato un bel po' di tempo a ottimizzare KonosubaRPG e Nibi per risparmiare più richieste possibili, o per ridurre il tempo di processore a caldo, o per ridurre il boot cold. Risultato, ho delle prestazioni pazzesche su praticamente tutto.
Devo dire che avevo iniziato a "nuvolificare" (non so nemmeno se si dice) la maggior parte dei miei progetti perché avevo una pigrizia monumentale di continuare a ospitarli sulla mia VM. Davvero, credo che siano le Github Actions che mi hanno salvato il culo. I workers anche, ma in realtà quando ho visto che potevo fare daemon con le Github Actions e gli schedule, mi ha davvero salvato fra'.

Scriverò probabilmente un articolo su un progetto chiamato email-autoreply, quindi iscriviti al feed RSS per vederlo uscire a breve :)).

Le 3 cose da ricordare:

  1. Interaction endpoints = HTTP serverless -- Niente WebSocket, niente connessione persistente. Discord POSTa, tu rispondi. Gratuito su Cloudflare.
  2. Hono è lo strumento perfetto -- Framework leggero (12KB), multi-runtime, zero dipendenze. Codice identico su Cloudflare, Vercel, Node, ovunque.
  3. Render immagine su Workers = pazzesco -- Satori + Resvg (Wasm) ti permette di comporre UI dinamiche in JSX e convertirle in PNG in <100ms. Un gioco completo può girare su questo.

È malato xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

Discord-Bot 100% serverless: Hono + Cloudflare Workers

Wie ich einen Discord-Bot, der mich 50€/Monat kostete, durch null

Discord Bot 100% serverless: Hono + Cloudflare Workers = 💸 null

Ich hab ein paar Monate damit verbracht, normale Discord-Bots auf meinem eigenen Rechner zu hosten.

WebSocket-Verbindung immer offen. Der Bot verbindet sich um 3 Uhr morgens neu. Der Bot crasht, weil ich die Schafe komisch angucke. Die Rechnung wird höher.

Eines Tages hab ichs kapiert: warum ne Verbindung offen halten? Discord kann dir einfach per POST schicken, was dich interessiert. Du antwortest, fertig.

Seit 2021 bietet Discord die Interaction Endpoints an.

Das ist nur HTTP. Kein WebSocket. Kein persistentes State. Du kriegst eine Anfrage, du schickst JSON zurück, fertig. Die nächste Anfrage kommt von alleine.

Und das Beste: Cloudflare Workers ist kostenlos bis 100k Anfragen/Tag. Für 90% der Bots heißt das 0€/Monat.

Dieser Artikel zeigt dir, wie du einen Discord-Bot ohne WebSocket baust – mit Hono (ultraleichtem Web-Framework) und Cloudflare Workers. Ich zeig dir zwei echte Projekte: Nibi (Bot zum Japanisch lernen, TTS, nice) und Konosuba-RPG (ein komplettes Discord-Spiel mit Echtzeit-Bildrendering xD).

WebSocket vs. Interaction Endpoints: warum das eine schlechte Idee war

Stell dir ein Minecraft-Spiel vor, bei dem du die Verbindung offen halten musst, auch wenn du grad nicht spielst.

Und der Server verbindet sich automatisch neu, jedes Mal wenn er crasht. Du musst Timeouts behandeln, exponentielle Backoffs, den ganzen beschissenen Boilerplate, den wir alle hassen. Nur um Interactions zu empfangen.

Interaction Endpoints sind das Gegenteil. Discord POSTet auf deine URL. Du antwortest. Fertig.

Wenn dein Server crasht? Discord retried 2-3 Mal und macht dann weiter. Null Drama.

Kosten vorher: 50€/Monat auf Heroku, nur damit ein Node-Prozess am Leben bleibt.

Kosten nachher: 0€/Monat auf Cloudflare bis 100k Anfragen/Tag.

Die Architektur: worum gehts eigentlich?

Discord POSTet eine Anfrage an deinen Endpoint.

Discord: "Ey! Der User hat auf /ping geklickt!"
      ↓
   Deine URL (Cloudflare Worker)
      ↓
   Du checkst, obs wirklich Discord ist (Signatur-Prüfung)
      ↓
   Du parsed den Interaction-Typ
      ↓
   Du führst den Handler aus
      ↓
   Du gibst JSON zurück
      ↓
   Discord: "Cool, ich zeig das dem User an"

Reines HTTP. Kein Zauber. Keine dicken Libraries.

Hono + Cloudflare Workers: das sparsame Duo

Hono ist ein Web-Framework, das 12KB wiegt. Es läuft überall: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... derselbe Code überall.

Cloudflare Workers ist Compute am Edge. Deine Anfragen landen beim nächsten Server. Antwortzeit: <100ms. Kosten: kostenlos bis 100k Anfragen/Tag.

Das Duo Hono + Cloudflare ist der perfekte Match für einen Discord-Bot.

Hier ist der minimale Code für einen kompletten Bot:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Header holen
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. Prüfen, obs wirklich Discord ist (kein Spam)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Parsen was geschickt wurde
  const interaction = JSON.parse(body);

  // 4. Antwort je nach Typ
  if (interaction.type === 1) {
    // Discord-Test (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // Das ist eine Slash Command
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

So, 30 Zeilen und das ist ein funktionierender Bot.

Kein bot.login(). Kein Event Emitter. Kein Callback-Hell. Nur HTTP.

Zum Deploy auf Cloudflare:

npm install -D wrangler
npx wrangler deploy

Boom. Du hast ne URL wie https://mein-bot.workers.dev/interactions.

Das trägst du im Discord Developer Portal unter "INTERACTIONS ENDPOINT URL" ein, und Discord fängt an, deine Interactions dorthin zu schicken.

Signatur prüfen: keine Fake Requests

Discord signiert jede Anfrage mit einem öffentlichen Schlüssel. Wenn du ne Anfrage mit falscher Signatur kriegst? Das ist Spam. Ignorieren und weitermachen.

Das Paket discord-interactions macht den Job:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // exakter roher Text (nicht geparstes JSON!)
  signature,         // Header x-signature-ed25519
  timestamp,         // Header x-signature-timestamp
  publicKey          // vom Discord Dev Portal
);

Wichtige Falle: Die Signatur hängt vom exakten Body ab. Wenn du JSON parst und wieder stringifyst, oder den Body loggst, zerstörst du die Signatur.

Erst prüfen. Dann parsen. Die Reihenfolge ist entscheidend.

Fall 1: Nibi (Bot zum Japanisch lernen)

Nibi ist ein Discord-Bot zum Japanisch lernen. Einfache Commands:

  • /dictionary kanji → zeigt die Definitionen an
  • /pronounce テキスト → generiert TTS (Text-to-Speech)
  • /hello → Begrüßungsnachricht

Jeder Command ist eine eigene TypeScript-Datei:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

Ein Command implementiert dieses Interface:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<InteractionResponse>;
}

Der /pronounce-Command: den Bot zum Sprechen bringen

Das ist der, der richtig abgedreht ist. Du schickst Text (Romaji, Hiragana, Kanji, egal), der Bot konvertiert das in Hiragana, generiert TTS über VOICEVOX oder Google TTS und schickt eine Audio-Nachricht auf Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Generiert TTS für japanischen Text',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Text zum Aussprechen',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Romaji → Hiragana mit Kuroshiro konvertieren
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. TTS-Audio generieren
      const audioBuffer = await generateTTS(hiragana);

      // 3. Datei zu Discord hochladen
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Nachricht mit Audio senden
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // Dauer in Sekunden
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `Aussprache für "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'Fehler: Konnte Audio nicht generieren xD',
          flags: 64  // ephemeral (private Nachricht)
        }
      };
    }
  }
};

Ist das verrückt: du rufst eine externe API auf, uploadest ne Datei zu Discord, schickst ne Nachricht mit der Datei. Alles ohne WebSocket, nur HTTP.

Persistenz mit Supabase

Nibi nutzt Supabase als Key-Value-Store. Um zu prüfen, ob ein User registriert ist:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // User hinzufügen
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

Das ist sehr basic (keine echten SQL-Queries, nur JSON), aber es funktioniert. Für kleine Bots ist das perfekt.

Fall 2: Konosuba-RPG (Discord-Spiel mit Bildrendering)

Okay, das hier ist abgefahren.

Konosuba-RPG ist ein komplettes Spiel auf Discord. Du kämpfst gegen Mobs, sammelst XP, rüstest Accessoires aus, steigst Level auf. Jeder Battle generiert ein Bild in Echtzeit. Kein vorgerendertes Spritesheet. Das Bild wird dynamisch aus den Stats des Spielers, dem Mob und dem Kampfzustand zusammengesetzt.

Und das Bild wird in <500ms auf Cloudflare Workers generiert. Wirklich.

Die Rendering-Architektur

Discord (du klickst "Attack")
    ↓
Cloudflare Worker empfängt die Interaction
    ↓
Game-State aktualisieren (XP, HP, etc.)
    ↓
JSX mit Satori generieren
    ↓
SVG → PNG mit Resvg (Wasm) konvertieren
    ↓
Bild zu Discord hochladen
    ↓
Nachricht mit Bild senden

Das alles in unter einer Sekunde. Ist der Wahnsinn.

Bildrendering auf den Workers

Konosuba verwendet Satori (JSX → SVG) und Resvg (SVG → PNG):

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. JSX für das UI erstellen
  const jsx = (
    <div style="{{" display:="" 'flex',="" gap:="" '20px'="" }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

Du schreibst normales JSX. Das wird zu SVG. SVG wird zu PNG. <100ms auf einem Cloudflare Worker.

Checkst du die Power? Das ist einfach... schön xD

Game State und Progression

Die Spielerdaten liegen in Supabase:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// Der Spieler hat gewonnen
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

Jede Aktion (Angriff, Verteidigung, Heilung) updated die Stats in der Datenbank. Und danach wird das Bild mit den neuen Stats neu generiert.

Interactions: die Gameplay-Buttons

Das Spiel nutzt Button Interactions für die Kampfaktionen:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (blau)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (grau)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

Wenn du "Attack" klickst, POSTet Discord eine Interaction mit custom_id: 'battle_attack'. Der Handler routet das:

if (interaction.type === 3) {
  // Component Interaction (Button-Klick, etc.)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

Und boom, du berechnest den Schaden, updatest die Datenbank, generierst das Bild neu, sendest es.

Es ist ein komplettes rundenbasiertes Spiel ohne jede Verbindungspersistenz. Nur HTTP stateless. Völlig kaputt xD

Supabase: die Datenbank, die für Workers gemacht ist

Traditionelle Datenbanken (PostgreSQL, MySQL, MongoDB) sind für persistente TCP-Verbindungen ausgelegt. Du öffnest einen Socket, hältst die Verbindung offen, sendest Abfragen. Problem: Cloudflare Workers unterstützen keine persistenten TCP-Verbindungen. Jede Anfrage ist ein flüchtiger Prozess. Sobald du antwortest, verschwindet der Worker.

Das geht nicht:

// Das funktioniert NICHT auf Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // persistente TCP-Verbindung = tot

Selbst native PostgreSQL-Treiber wie pg oder postgres.js nutzen TCP-Verbindungen. Auf Workers crashen sie.

Supabase löst das alles.

Supabase ist eine REST-API auf Basis von PostgreSQL. Du machst normale HTTP-Anfragen. Jeder Aufruf ist unabhängig, keine persistente Verbindung, kein zu verwaltender Zustand. Perfekt für das serverless Modell.

// Das funktioniert PERFEKT auf Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Der Supabase-Client (@supabase/supabase-js) verwendet fetch unter der Haube. Und fetch ist nativ auf Workers. Null Konfiguration, null Treiber, null persistente Verbindung.

Datenbank Workers-kompatibel? Warum
Supabase ✅ Ja Zustandslose REST-API, reines HTTP
PlanetScale (MySQL) ⚠️ Teilweise Nur HTTPS-Verbindung, keine langen Transaktionen
Neon ⚠️ Teilweise Serverless Branches, aber TCP-Treiber nötig
Turso (libSQL) ⚠️ Teilweise HTTP möglich, aber eingeschränkt
Prisma/Prisma Postgres ❌ Nein Benötigt persistentes TCP
MongoDB Atlas ❌ Nein TCP-Treiber, keine native REST-API
Redis (Upstash) ✅ Ja REST-API über HTTP

Der wahre Vorteil von Supabase ist nicht nur die DB -- es ist das gesamte Ökosystem, das für Edge-Compute designt ist:

  • Auth: REST-API für Sessions, funktioniert zustandslos
  • Storage: Datei-Upload/Download via HTTP
  • Realtime: Optionales WebSocket, aber du kannst auch per REST pollan
  • Row Level Security: Sicherheitsregeln leben in der DB, nicht in deinem Backend

Für einen serverless Discord-Bot ist Supabase die einfachste und zuverlässigste Wahl. Kein Treiber zum Konfigurieren, keine Verbindung zum Aufrechterhalten, keine Timeouts. Nur HTTP-Anfragen.

Wenn du ein echtes Beispiel willst, schau dir Nibi oben an: Sein Persistenz-Code ist buchstäblich readJson() und writeJson() auf Supabase. Keine Migrationen, keine komplexen Schemas, keine verrückte Konfiguration. Es funktioniert sofort. Und wenn dein Bot groß wird, kannst du auf echte SQL-Abfragen migrieren, ohne den Anbieter zu wechseln.

Polyfills: wenn Node auf Workers laufen will

Manche Packages erwarten Node-APIs. Kuromoji (Kanji-Parser) nutzt XMLHttpRequest. Workers haben fetch, nicht XMLHttpRequest.

Einfache Lösung: ein Polyfill oben in index.ts einfügen:

// Polyfill XMLHttpRequest für kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Minimaler Stub
  } as any;
}

Oder ein dediziertes Modul:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

Ist ein simpler Hack, aber es funktioniert.

Auf dem Weg zu einem npm-Paket: hono-discord-interactions

Von Hand einen Bot zu bauen bedeutet viel Boilerplate:

  • Discord-Signatur prüfen
  • Interaction-Types routen
  • Commands, Components, Modals verwalten
  • Valides JSON zurückgeben

Man könnte das alles in ein npm-Paket abstrahieren. So was wie:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

Bäm. 20 Zeilen statt 200. Das würde Nibi locker um die Hälfte reduzieren.

Idee für später xD

Deploy

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mein-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

Resultierende URL: https://mein-bot.workers.dev/interactions

Kosten: kostenlos bis 100k Anfragen/Tag. Danach: $0.50/Million.

Spoiler: du wirst die 100k Anfragen nie erreichen, außer du hast 10.000 aktive User.

Vercel

npm run vercel:deploy

URL: https://mein-bot-xyz.vercel.app/api/interactions

Genauso, kostenlos.

Beide gleichzeitig

Hono läuft überall. Du kannst denselben Code auf Cloudflare UND Vercel deployen. Nützlich für Redundanz oder zum Testen vor der Entscheidung.

Schnell-Checkliste

  1. Application im Discord Developer Portal erstellen
  2. PUBLIC_KEY, BOT_TOKEN, APP_ID kopieren
  3. Projekt erstellen:
  4. index.ts schreiben (Signatur-Prüfung + Routing)
  5. Slash Commands registrieren (einmalig):
  6. Deployen:
  7. URL in Discord eintragen (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord testet die Verbindung (du musst auf PING antworten)
  9. Bot auf einen Server einladen
  10. Fertig

Vorteile vs. Einschränkungen

Vorteile

  • Günstig (kostenlos bis 100k Anfragen/Tag)
  • Skalierbar (kein Verbindungsmanagement)
  • Einfach (kein WebSocket-Boilerplate)
  • Schnell (Cloudflare = Server am Edge)
  • Portabel (Hono-Code = mehrere Hosts)

Einschränkungen

  • Keine Echtzeit-Server-Events (Member joined, Rolle hinzugefügt, Nachricht gelöscht, etc.) -- du kriegst nur Interactions (Slash Commands, Buttons, Modals)
  • 3 Sekunden Timeout zum Antworten -- sonst zeigt Discord "Application did not respond" an
  • Wenn du echte Events brauchst -- brauchst du einen separaten HTTP-Webhook oder eine zusätzliche WebSocket-Verbindung

Für 90% der Bots (alles auf Slash Commands basierend)? Reicht das.

Fazit

Ich hab ne ganze Menge Zeit damit verbracht, KonosubaRPG und Nibi zu optimieren – entweder um so viele Requests wie möglich zu sparen, oder um die heiße Rechenzeit zu reduzieren, oder um den Cold Boot zu verkürzen. Ergebnis: ich hab auf fast allem verdammt gute Performance.
Du musst wissen, ich hatte angefangen, den Großteil meiner Projekte zu cloudifizieren (keine Ahnung ob man das so sagt), weil ich eine monumentale Lust hatte, sie weiter auf meiner eigenen VM zu hosten. Wirklich, ich glaube, die GitHub Actions haben mir den Arsch gerettet. Die Workers auch, aber als ich gesehen hab, dass ich mit GitHub Actions und Schedules Dämonen bauen kann, hat mich das echt gerettet, Alter.

Ich werde wahrscheinlich einen Artikel über ein Projekt namens email-autoreply schreiben, also abonniert den RSS-Feed, damit ihrs seht wenns rauskommt :))

Die 3 Dinge zum Merken:

  1. Interaction Endpoints = HTTP serverless -- Kein WebSocket, keine persistente Verbindung. Discord POSTet, du antwortest. Kostenlos auf Cloudflare.
  2. Hono ist das perfekte Tool -- Leichtes Framework (12KB), Multi-Runtime, null Abhängigkeiten. Identischer Code auf Cloudflare, Vercel, Node, überall.
  3. Bildrendering auf Workers = verrückt -- Satori + Resvg (Wasm) lassen dich dynamische UIs in JSX bauen und in <100ms in PNG konvertieren. Ein komplettes Spiel kann darauf laufen.

Das ist krank xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

100% serverless бот Discord: Hono + Cloudflare Workers

Как я заменил Discord-бота, который обходился мне в 50€/месяц, на

Discord bot 100% serverless : Hono + Cloudflare Workers = 💸 ноль

Я несколько месяцев держал обычных Discord ботов на своей машине.

WebSocket соединение всегда открыто. Бот сам переподключается в 3 ночи. Бот падает, потому что я косо посмотрел на овец. Счёт растёт.

Однажды я понял: зачем держать соединение? Discord может POSTить тебе только то, что интересно. Ты отвечаешь -- готово.

С 2021 года Discord поддерживает interaction endpoints.

Это просто HTTP. Никакого WebSocket. Никакого состояния. Ты получаешь запрос, шлёшь JSON, всё. Следующий запрос приходит сам.

И самое крутое: Cloudflare Workers -- это бесплатно до 100k запросов/день. Для 90% ботов это 0€/мес.

Эта статья покажет, как сделать Discord бота без WebSocket, используя Hono (ультра-лёгкий веб-фреймворк) и Cloudflare Workers. Я покажу два реальных проекта: Nibi (бот для изучения японского, TTS, круто) и Konosuba-RPG (полноценная Discord-игра с рендером изображений в реальном времени xD).

WebSocket vs. Interaction Endpoints : почему это было плохой идеей

Представь Minecraft, где тебе нужно держать соединение открытым, даже когда ты не играешь.

А сервер переподключается автоматически каждый раз, когда падает. Ты должен обрабатывать таймауты, экспоненциальные реконнекты, весь этот грёбаный boilerplate, который мы ненавидим. Просто чтобы получать взаимодействия.

Interaction endpoints -- всё наоборот. Discord POST'ит на твой URL. Ты отвечаешь. Готово.

Если твой сервер упал? Discord повторяет 2-3 раза и идёт дальше. Ноль драмы.

Цена до : 50€/мес на Heroku просто чтобы процесс Node оставался живым.

Цена после : 0€/мес на Cloudflare до 100k запросов/день.

Архитектура : что это вообще такое?

Discord POST'ит запрос на твой endpoint.

Discord: "Эй! Пользователь нажал на /ping!"
      ↓
   Твой URL (Cloudflare Worker)
      ↓
   Ты проверяешь, что это действительно Discord (проверка подписи)
      ↓
   Ты парсишь тип взаимодействия
      ↓
   Ты выполняешь handler
      ↓
   Ты возвращаешь JSON
      ↓
Discord: "Ок, я покажу это пользователю"

Чистый HTTP. Никакой магии. Никаких тяжёлых библиотек.

Hono + Cloudflare Workers : экономная комбинация

Hono -- это веб-фреймворк весом 12KB. Работает везде: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... один и тот же код везде.

Cloudflare Workers -- это вычисления на edge. Твои запросы приходят на ближайший сервер. Время ответа: <100ms. Цена: бесплатно до 100k запросов/день.

Связка Hono + Cloudflare -- идеальный мэтч для Discord бота.

Вот минимальный код полноценного бота:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Получаем заголовки
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. Проверяем, что это реально Discord (не спам)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Парсим, что он прислал
  const interaction = JSON.parse(body);

  // 4. Отвечаем по типу
  if (interaction.type === 1) {
    // Проверка Discord (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // Это слэш-команда
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

Типа, 30 строк и это рабочий бот.

Никакого bot.login(). Никаких event emitter'ов. Никакого callback hell. Просто HTTP.

Чтобы задеплоить на Cloudflare:

npm install -D wrangler
npx wrangler deploy

Бум. У тебя URL типа https://mon-bot.workers.dev/interactions.

Вставляешь это в Discord Developer Portal в "INTERACTIONS ENDPOINT URL", и Discord начинает слать твои взаимодействия туда.

Проверка подписи : никаких фейковых запросов

Discord подписывает каждый запрос публичным ключом. Если пришёл запрос с неправильной подписью? Это спам. Игнорируй и живи дальше.

Пакет discord-interactions делает всю работу:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // точный сырой текст (не паршеный JSON!)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // из Discord Dev Portal
);

Важная ловушка : подпись зависит от точного body. Если ты спарсишь JSON и обратно превратишь в строку, или залогируешь body -- подпись сломается.

Сначала проверяй. Потом парси. Порядок важен.

Кейс 1 : Nibi (бот для изучения японского)

Nibi -- это Discord бот для изучения японского. Простые команды:

  • /dictionary kanji → показывает определения
  • /pronounce テキスト → генерирует TTS (text-to-speech)
  • /hello → приветственное сообщение

Каждая команда -- отдельный TypeScript файл:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

Команда реализует такой интерфейс:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<InteractionResponse>;
}

Команда /pronounce : заставляем бота говорить

Эта самая безумная. Ты шлёшь текст (ромадзи, хирагана, кандзи, что угодно), бот конвертит это в хирагану, генерирует TTS через VOICEVOX или Google TTS, и отправляет аудиосообщение в Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Генерирует TTS для японского текста',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Текст для произношения',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Конвертируем ромадзи → хирагану через Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. Генерируем TTS аудио
      const audioBuffer = await generateTTS(hiragana);

      // 3. Загружаем файл в Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Отправляем сообщение с аудио
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // длительность в секундах
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `Произношение для "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'Ошибка : не удалось сгенерировать аудио xD',
          flags: 64  // ephemeral (приватное сообщение)
        }
      };
    }
  }
};

Это же безумие: ты вызываешь внешнее API, загружаешь файл в Discord, отправляешь сообщение с файлом. Всё без WebSocket, просто HTTP.

Хранение данных с Supabase

Nibi использует Supabase как key-value store. Чтобы проверить, зарегистрирован ли пользователь:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // Добавляем пользователя
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

Очень базово (никаких настоящих SQL запросов, просто JSON), но работает. Для маленьких ботов идеально.

Кейс 2 : Konosuba-RPG (игра в Discord с рендером изображений)

Окей, вот это уже жесть.

Konosuba-RPG -- это полноценная игра в Discord. Ты сражаешься с мобами, получаешь XP, экипируешь аксессуары, повышаешь уровень. Каждая битва генерирует изображение в реальном времени. Никаких предварительно отрендеренных спрайтов. Изображение составляется динамически на основе статов игрока, моба и состояния битвы.

И изображение генерируется за <500ms на Cloudflare Workers. Буквально.

Архитектура рендера

Discord (ты нажимаешь "Attack")
    ↓
Cloudflare Worker получает взаимодействие
    ↓
Обновление игрового состояния (XP, HP, и т.д.)
    ↓
Генерация JSX с помощью Satori
    ↓
Конвертация SVG → PNG с помощью Resvg (Wasm)
    ↓
Загрузка изображения в Discord
    ↓
Отправка сообщения с изображением

Всё это меньше чем за секунду. Это чертовски круто.

Рендер изображений на Workers

Konosuba использует Satori (JSX → SVG) и Resvg (SVG → PNG):

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. Создаём JSX для интерфейса
  const jsx = (
    <div style={{ display: 'flex', gap: '20px' }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

Ты пишешь обычный JSX. Он становится SVG. SVG становится PNG. <100ms на Cloudflare Worker.

Прочувствуй мощь? Это просто... красиво xD

Игровое состояние и прогрессия

Данные игрока хранятся в Supabase:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// Игрок победил
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

Каждое действие (атака, защита, лечение) обновляет статы в базе. А затем ты заново генерируешь изображение с новыми статами.

Взаимодействия : кнопки геймплея

Игра использует button interactions для действий в бою:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (синий)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (серый)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

Когда ты нажимаешь "Attack", Discord POST'ит взаимодействие с custom_id: 'battle_attack'. Handler направляет это:

if (interaction.type === 3) {
  // Component interaction (нажатие кнопки и т.д.)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

И бум, ты вычисляешь урон, обновляешь базу, регенерируешь изображение, отправляешь.

Это полноценная пошаговая игра без единого постоянного соединения. Просто HTTP без состояния. Полный отрыв xD

Supabase: база данных, созданная для Workers

Традиционные базы данных (PostgreSQL, MySQL, MongoDB) спроектированы для постоянных TCP-соединений. Ты открываешь сокет, держишь соединение, отправляешь запросы. Проблема: Cloudflare Workers не поддерживает постоянные TCP-соединения. Каждый запрос -- это эфемерный процесс. Как только ты отвечаешь клиенту, Worker исчезает.

Ты не можешь сделать так:

// Это НЕ сработает на Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // постоянное TCP-соединение = мертво

Даже нативные драйверы PostgreSQL вроде pg или postgres.js используют TCP-соединения. На Workers они падают.

Supabase решает всё это.

Supabase -- это REST API поверх PostgreSQL. Ты делаешь обычные HTTP-запросы. Каждый вызов независим, нет постоянного соединения, нет состояния, которым нужно управлять. Это идеально подходит для serverless-модели.

// Это отлично работает на Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Клиент Supabase (@supabase/supabase-js) использует fetch под капотом. А fetch -- нативный на Workers. Ноль конфигурации, ноль драйверов, ноль постоянных соединений.

База данных Совместима с Workers? Почему
Supabase ✅ Да REST API без состояния, чистый HTTP
PlanetScale (MySQL) ⚠️ Частично Только HTTPS, нет длинных транзакций
Neon ⚠️ Частично Serverless-ветки, но нужен TCP-драйвер
Turso (libSQL) ⚠️ Частично HTTP возможен, но ограничен
Prisma/Prisma Postgres ❌ Нет Требует постоянного TCP
MongoDB Atlas ❌ Нет TCP-драйвер, нет нативного REST API
Redis (Upstash) ✅ Да REST API через HTTP

Настоящее преимущество Supabase -- не только БД, а вся экосистема, спроектированная для edge:

  • Auth: REST API для сессий, работает без состояния
  • Storage: Загрузка/скачивание файлов через HTTP
  • Realtime: Опциональный WebSocket, но можно делать poll через REST
  • Row Level Security: правила безопасности живут в БД, а не в твоём бэкенде

Для serverless Discord-бота Supabase -- самый простой и надёжный выбор. Никаких драйверов для настройки, никаких соединений для поддержания, никаких таймаутов. Просто HTTP-запросы.

Хочешь реальный пример? Посмотри на Nibi выше: его код сохранения -- буквально readJson() и writeJson() на Supabase. Никаких миграций, никаких сложных схем, никакой безумной конфигурации. Работает из коробки. А если твой бот вырастет, ты можешь перейти на настоящие SQL-запросы без смены провайдера.

Полифиллы : когда Node хочет работать на Workers

Некоторые пакеты ожидают Node API. Kuromoji (парсер кандзи) использует XMLHttpRequest. У Workers есть fetch, нет XMLHttpRequest.

Простое решение: добавить полифилл в начале index.ts:

// Полифилл XMLHttpRequest для kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Минимальная заглушка
  } as any;
}

Или вынести в отдельный модуль:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

Это базовый хак, но работает.

К npm пакету : hono-discord-interactions

Вручную делать бота -- много boilerplate:

  • Проверка подписи Discord
  • Маршрутизация типов взаимодействий
  • Обработка команд, компонентов, модалок
  • Возврат валидного JSON

Можно было бы абстрагировать всё это в npm пакет. Типа:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

Бац. 20 строк вместо 200. Это бы уменьшило Nibi минимум вдвое.

Идея на потом xD

Деплой

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

Результирующий URL: https://mon-bot.workers.dev/interactions

Цена: бесплатно до 100k запросов/день. Выше: $0.50/миллион.

Спойлер: ты никогда не потратишь 100k запросов, если у тебя не 10 000 активных пользователей.

Vercel

npm run vercel:deploy

URL: https://mon-bot-xyz.vercel.app/api/interactions

То же самое, бесплатно.

Оба сразу

Hono работает везде. Ты можешь задеплоить один и тот же код на Cloudflare И Vercel. Полезно для отказоустойчивости или тестирования перед выбором.

Быстрый чеклист

  1. Создать приложение в Discord Developer Portal
  2. Скопировать PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Создать проект:
  4. Написать index.ts (проверка подписи + маршрутизация)
  5. Зарегистрировать слэш-команды (один раз):
  6. Задеплоить:
  7. Вставить URL в Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord тестирует соединение (ты должен ответить на PING)
  9. Пригласить бота на сервер
  10. Готово

Преимущества vs Ограничения

Преимущества

  • Дёшево (бесплатно до 100k запросов/день)
  • Масштабируется (никакого управления соединениями)
  • Просто (никакого WebSocket boilerplate)
  • Быстро (Cloudflare = сервера на edge)
  • Портативно (код на Hono = несколько хостов)

Ограничения

  • Нет событий сервера в реальном времени (участник зашёл, роль добавлена, сообщение удалено и т.д.) -- ты получаешь только взаимодействия (слэш-команды, кнопки, модалки)
  • Таймаут 3 секунды для ответа -- иначе Discord показывает "Application did not respond"
  • Если нужны настоящие события -- нужен отдельный HTTP вебхук или вспомогательное WebSocket соединение

Для 90% ботов (всё на слэш-командах)? Норм.

В заключение

Я потратил немало времени, оптимизируя KonosubaRPG и Nibi -- чтобы экономить либо количество запросов, либо время процессора на горячую, либо холодный старт. В итоге у меня весьма крутые показатели почти везде. Надо сказать, я начал «облачить» (даже не знаю, существует ли такое слово) большинство своих проектов, потому что мне было дико лень продолжать их хостить на своей VM. Серьёзно, кажется, Github Actions спасли мне шкуру. Workers тоже, но когда я понял, что можно делать демонов с Github Actions и расписаниями -- это меня реально спасло, чувак.

Я скорее всего напишу статью о проекте email-autoreply, так что подписывайся на RSS, чтобы не пропустить :)).

3 вещи, которые нужно запомнить:

  1. Interaction endpoints = HTTP serverless -- Никакого WebSocket, никаких постоянных соединений. Discord POST'ит, ты отвечаешь. Бесплатно на Cloudflare.
  2. Hono -- идеальный инструмент -- Лёгкий фреймворк (12KB), мульти-рантайм, ноль зависимостей. Одинаковый код на Cloudflare, Vercel, Node, везде.
  3. Рендер изображений на Workers = безумие -- Satori + Resvg (Wasm) позволяют собирать динамические UI в JSX и конвертировать их в PNG за <100ms. Полноценная игра может работать на этом.

Это просто имба xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

Bot de Discord 100% serverless: Hono + Cloudflare Workers

Cómo reemplacé un bot de Discord que me costaba 50€/mes por cero

Discord bot 100% serverless : Hono + Cloudflare Workers = 💸 cero

Pasé varios meses manteniendo bots de Discord clásicos en mi propia máquina.

Conexión WebSocket siempre abierta. El bot se reconecta solo a las 3 AM. El bot crashea porque miro mal a las ovejas. La factura sube.

Un día descubrí: ¿para qué mantener una conexión? Discord puede hacerte POST solo de las cosas que te interesan. Respondes y ya está.

Desde 2021, Discord ofrece los interaction endpoints.

Es solo HTTP. Nada de WebSocket. Sin estado persistente. Recibes una request, envías JSON, se acabó. La siguiente request llega solita.

Y lo mejor: Cloudflare Workers es gratis hasta 100k requests/día. Para el 90% de los bots, son 0€/mes.

Este artículo te muestra cómo hacer un bot de Discord sin WebSocket usando Hono (framework web ultra-ligero) y Cloudflare Workers. Te voy a mostrar dos proyectos reales: Nibi (bot para aprender japonés, TTS, cool) y Konosuba-RPG (un juego de Discord completo con renderizado de imagen en tiempo real xD).

WebSocket vs. Interaction Endpoints : por qué era una mala idea

Imagina un juego de Minecraft donde tienes que mantener la conexión abierta incluso cuando no estás jugando.

Y el servidor se reconecta automáticamente cada vez que crashea. Tienes que manejar los timeouts, las reconexiones exponenciales, todo el boilerplate de mierda que odiamos. Solo para recibir interacciones.

Los interaction endpoints son lo contrario. Discord hace POST a tu URL. Respondes. Se acabó.

¿Que tu servidor crashea? Discord reintenta 2-3 veces y sigue con lo suyo. Cero drama.

Costo antes : 50€/mes en Heroku solo para mantener un proceso Node vivo.

Costo después : 0€/mes en Cloudflare hasta 100k requests/día.

La arquitectura : ¿qué es exactamente?

Discord hace POST de una request a tu endpoint.

Discord: "¡Eh! El usuario hizo clic en /ping!"
      ↓
   Tu URL (Cloudflare Worker)
      ↓
   Verificas que sea realmente Discord (signature check)
      ↓
   Parseas el tipo de interacción
      ↓
   Ejecutas el handler
      ↓
   Devuelves JSON
      ↓
Discord: "Cool, voy a mostrarle esto al usuario"

Es HTTP puro. Sin magia. Sin librerías pesadas.

Hono + Cloudflare Workers : el combo ahorrador

Hono es un framework web que pesa 12KB. Funciona en todas partes: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... el mismo código en todos lados.

Cloudflare Workers es compute en el edge. Tus requests llegan al servidor más cercano. Tiempo de respuesta: <100ms. Costo: gratis hasta 100k requests/día.

El combo Hono + Cloudflare es el match perfecto para un bot de Discord.

Aquí está el código mínimo de un bot completo:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Obtén los headers
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. Verifica que sea realmente Discord (nada de spam)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Parsea lo que envió
  const interaction = JSON.parse(body);

  // 4. Responde según el tipo
  if (interaction.type === 1) {
    // Discord test (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // Es una slash command
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

Como, 30 líneas y ya tienes un bot funcional.

Nada de bot.login(). Nada de event emitter. Nada de callback hell. Solo HTTP.

Para deploy en Cloudflare:

npm install -D wrangler
npx wrangler deploy

Boom. Tienes una URL tipo https://mon-bot.workers.dev/interactions.

Pones eso en Discord Developer Portal bajo "INTERACTIONS ENDPOINT URL", y Discord empieza a enviar tus interacciones allí.

Verificar la firma : nada de fake requests

Discord firma cada request con una clave pública. ¿Recibes una request con mala firma? Es spam. Ignóralo y sigue.

El paquete discord-interactions hace el trabajo:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // texto bruto exacto (¡no JSON parseado!)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // de Discord Dev Portal
);

Trampa importante : la firma depende del body exacto. Si parseas JSON y lo re-stringificas, o si logueas el body, rompes la firma.

Verifica primero. Parsea después. Ese es el orden que importa.

Caso 1 : Nibi (bot de aprendizaje de japonés)

Nibi es un bot de Discord para aprender japonés. Comandos simples:

  • /dictionary kanji → muestra las definiciones
  • /pronounce テキスト → genera TTS (text-to-speech)
  • /hello → mensaje de bienvenida

Cada comando es un archivo TypeScript:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

Un comando implementa esta interfaz:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<interactionresponse>;
}

El comando /pronounce : hacer hablar al bot

Este es el bien loco. Envías texto (romaji, hiragana, kanji, lo que sea), el bot lo convierte a hiragana, genera TTS con VOICEVOX o Google TTS, y envía un mensaje de audio en Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Genera TTS para texto japonés',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Texto a pronunciar',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Convertir romaji → hiragana con Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. Generar el audio TTS
      const audioBuffer = await generateTTS(hiragana);

      // 3. Subir el archivo a Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Enviar el mensaje con el audio
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // duración en segundos
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `Pronunciación para "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'Error: no se pudo generar el audio xD',
          flags: 64  // ephemeral (mensaje privado)
        }
      };
    }
  }
};

Es una locura: llamas a una API externa, subes un archivo a Discord, envías un mensaje con el archivo. Todo sin WebSocket, solo HTTP.

Persistencia con Supabase

Nibi usa Supabase como key-value store. Para verificar si un usuario está registrado:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // Añadir el usuario
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

Es muy básico (nada de SQL de verdad, solo JSON), pero funciona. Para bots pequeños es perfecto.

Caso 2 : Konosuba-RPG (juego de Discord con renderizado de imagen)

Okay, este está loco.

Konosuba-RPG es un juego completo en Discord. Combates mobs, ganas XP, equipas accesorios, subes de nivel. Cada batalla genera una imagen en tiempo real. Nada de spritesheet pre-renderizado. La imagen se compone dinámicamente desde las stats del jugador, el mob, y el estado del combate.

Y la imagen se genera en <500ms en Cloudflare Workers. Literalmente.

La arquitectura del renderizado

Discord (haces clic en "Attack")
    ↓
Cloudflare Worker recibe la interacción
    ↓
Actualización del game state (XP, HP, etc.)
    ↓
Genera JSX con Satori
    ↓
Convierte SVG → PNG con Resvg (Wasm)
    ↓
Sube la imagen a Discord
    ↓
Envía el mensaje con la imagen

Todo esto en menos de un segundo. Es una locura.

Renderizado de imagen en Workers

Konosuba usa Satori (JSX → SVG) y Resvg (SVG → PNG):

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. Crear JSX para la UI
  const jsx = (
    <div style="{{" display:="" 'flex',="" gap:="" '20px'="" }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

Escribes JSX normal. Se convierte en SVG. SVG se convierte en PNG. <100ms en un Cloudflare Worker.

¿Captas el poder? Es simplemente... hermoso xD

Game state y progresión

Los datos del jugador están en Supabase:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// El jugador ganó
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

Cada acción (ataque, defensa, cura) actualiza las stats en la base de datos. Y luego regeneras la imagen con las nuevas stats.

Interacciones : los botones del gameplay

El juego usa button interactions para las acciones en combate:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (azul)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (gris)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

Cuando haces clic en "Attack", Discord hace POST de una interacción con custom_id: 'battle_attack'. El handler enruta eso:

if (interaction.type === 3) {
  // Component interaction (clic en botón, etc.)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

Y boom, calculas el daño, actualizas la base, regeneras la imagen, envías.

Es un juego por turnos completo sin ninguna persistencia de conexión. Solo HTTP stateless. Totalmente roto xD

Supabase: la base de datos hecha para Workers

Las bases de datos tradicionales (PostgreSQL, MySQL, MongoDB) están diseñadas para conexiones TCP persistentes. Abres un socket, mantienes la conexión abierta, envías consultas. Problema: Cloudflare Workers no soporta conexiones TCP persistentes. Cada petición es un proceso efímero. En cuanto respondes al cliente, el Worker desaparece.

No puedes hacer esto:

// Esto NO funciona en Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // conexión TCP persistente = muerto

Incluso los drivers nativos de PostgreSQL como pg o postgres.js usan conexiones TCP. En Workers, se caen.

Supabase lo resuelve todo.

Supabase es una API REST sobre PostgreSQL. Haces peticiones HTTP normales. Cada llamada es independiente, sin conexión persistente, sin estado que gestionar. Es perfecto para el modelo serverless.

// Esto funciona PERFECTAMENTE en Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

El cliente de Supabase (@supabase/supabase-js) usa fetch internamente. Y fetch es nativo en Workers. Cero configuración, cero driver, cero conexión persistente.

Base de datos ¿Compatible con Workers? Por qué
Supabase ✅ Sí API REST sin estado, HTTP puro
PlanetScale (MySQL) ⚠️ Parcial Conexión HTTPS solamente, sin transacciones largas
Neon ⚠️ Parcial Ramas serverless pero necesita driver TCP
Turso (libSQL) ⚠️ Parcial HTTP posible pero limitado
Prisma/Prisma Postgres ❌ No Necesita TCP persistente
MongoDB Atlas ❌ No Driver TCP, sin API REST nativa
Redis (Upstash) ✅ Sí API REST sobre HTTP

La verdadera ventaja de Supabase no es solo la BD -- es todo el ecosistema pensado para edge:

  • Auth: API REST para sesiones, funciona sin estado
  • Storage: Subida/descarga de archivos vía HTTP
  • Realtime: WebSocket opcional, pero puedes hacer poll vía REST
  • Row Level Security: las reglas de seguridad viven en la BD, no en tu backend

Para un bot de Discord serverless, Supabase es la opción más simple y fiable. Sin driver que configurar, sin conexión que mantener, sin timeouts. Solo peticiones HTTP.

Si quieres un ejemplo real, mira Nibi más arriba: su código de persistencia es literalmente readJson() y writeJson() sobre Supabase. Sin migraciones, sin esquemas complejos, sin configuraciones locas. Funciona de inmediato. Y si tu bot crece, puedes migrar a consultas SQL reales sin cambiar de proveedor.

Polyfills : cuando Node quiere funcionar en Workers

Algunos paquetes esperan APIs de Node. Kuromoji (parser de kanji) usa XMLHttpRequest. Los Workers tienen fetch, no XMLHttpRequest.

Solución simple: añadir un polyfill al inicio del index.ts:

// Polyfill XMLHttpRequest para kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Stub mínimo
  } as any;
}

O hacer un módulo dedicado:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

Es un hack básico, pero funciona.

Hacia un paquete npm : hono-discord-interactions

A mano, hacer un bot tiene mucho boilerplate:

  • Verificar la firma de Discord
  • Enrutar los tipos de interacción
  • Manejar comandos, components, modals
  • Devolver JSON válido

Podríamos abstraer todo eso en un paquete npm. Algo como:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

Bum. 20 líneas en vez de 200. Reduciría Nibi a la mitad fácil.

Idea para después xD

Deploy

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

URL resultante: https://mon-bot.workers.dev/interactions

Costo: gratis hasta 100k requests/día. Más allá: $0.50/millón.

Spoiler: nunca gastarás las 100k requests a menos que tengas 10 000 usuarios activos.

Vercel

npm run vercel:deploy

URL: https://mon-bot-xyz.vercel.app/api/interactions

Igual, gratis.

Ambos a la vez

Hono funciona en todos lados. Puedes desplegar el mismo código en Cloudflare Y Vercel. Útil para redundancia o probar antes de elegir.

Checklist rápida

  1. Crear una Application en Discord Developer Portal
  2. Copiar PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Crear el proyecto:
  4. Escribir index.ts (verificar firma + routing)
  5. Registrar los slash commands (una vez):
  6. Deploy:
  7. Poner la URL en Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord prueba la conexión (debes responder al PING)
  9. Invitar el bot a un servidor
  10. Ya está

Ventajas vs Limitaciones

Ventajas

  • Barato (gratis hasta 100k req/día)
  • Escalable (sin gestión de conexiones)
  • Simple (sin boilerplate de WebSocket)
  • Rápido (Cloudflare = servidores en el edge)
  • Portable (código Hono = varios hosts)

Limitaciones

  • Sin eventos de servidor en tiempo real (miembro se une, rol añadido, mensaje eliminado, etc.) -- solo recibes interacciones (slash commands, buttons, modals)
  • Timeout de 3 segundos para responder -- si no, Discord muestra "Application did not respond"
  • Si necesitas eventos de verdad -- necesitas un webhook HTTP separado o una conexión WebSocket auxiliar

¿Para el 90% de los bots (todo basado en slash commands)? Está bien.

Para concluir

Pasé bastante tiempo optimizando KonosubaRPG y Nibi para ahorrar la mayor cantidad de requests posibles, o para reducir el tiempo de procesador en caliente, o para reducir el boot cold. Resultado, tengo unas buenas perfos en casi todo. Hay que saber que empecé a cloudificar (ni siquiera sé si esa palabra existe) la mayoría de mis proyectos porque tenía una pereza monumental de seguir alojándolos en mi propia VM. De verdad, creo que fueron las Github Actions las que me salvaron el pellejo. Los workers también, pero en cuanto vi que podía hacer daemons con las Github Actions y los schedules, eso sí que me salvó de verdad.

Seguramente escribiré un artículo sobre un proyecto llamado email-autoreply, así que suscríbete al feed RSS para verlo salir próximamente :))

Las 3 cosas para recordar:

  1. Interaction endpoints = HTTP serverless -- Nada de WebSocket, nada de conexión persistente. Discord hace POST, tú respondes. Gratis en Cloudflare.
  2. Hono es la herramienta perfecta -- Framework ligero (12KB), multi-runtime, cero dependencias. Código idéntico en Cloudflare, Vercel, Node, en todos lados.
  3. Renderizar imagen en Workers = locura -- Satori + Resvg (Wasm) te deja componer UIs dinámicas en JSX y convertirlas a PNG en <100ms. Un juego completo puede funcionar con eso.

Es una locura xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

Discord bot 100% serverless : Hono + Cloudflare Workers

Como substituí um bot Discord que me custava 50€/mês por

Discord bot 100% serverless : Hono + Cloudflare Workers = 💸 zero

Passei alguns meses mantendo bots Discord clássicos na minha própria máquina.

Conexão WebSocket sempre aberta. O bot reconecta sozinho às 3 da manhã. O bot crasha porque eu olhei pro lado errado. A conta sobe.

Um dia descobri: por que manter uma conexão? O Discord pode te enviar só as coisas que te interessam. Você responde, pronto.

Desde 2021, o Discord oferece os interaction endpoints.

É só HTTP. Sem WebSocket. Sem estado persistente. Você recebe uma requisição, envia JSON, acabou. A próxima requisição chega sozinha.

E o melhor: Cloudflare Workers é gratuito até 100k requisições/dia. Para 90% dos bots, são 0€/mês.

Este artigo mostra como fazer um bot Discord sem WebSocket usando Hono (framework web ultra-leve) e Cloudflare Workers. Vou mostrar dois projetos reais: Nibi (bot para aprender japonês, TTS, legal) e Konosuba-RPG (um jogo Discord completo com renderização de imagem em tempo real xD).

WebSocket vs. Interaction Endpoints: por que era uma má ideia

Imagine um jogo Minecraft onde você precisa manter a conexão aberta mesmo quando não está jogando.

E o servidor se reconecta automaticamente toda vez que crasha. Você precisa gerenciar timeouts, reconexões exponenciais, todo aquele boilerplate chato que a gente odeia. Só para receber interações.

Os interaction endpoints são o oposto. O Discord faz POST na sua URL. Você responde. Acabou.

Se seu servidor crasha? O Discord tenta de novo 2-3 vezes e segue em frente. Zero drama.

Custo antes: 50€/mês no Heroku só pra manter um processo Node vivo.

Custo depois: 0€/mês no Cloudflare até 100k requisições/dia.

A arquitetura: o que é exatamente?

O Discord faz POST de uma requisição no seu endpoint.

Discord: "Ei! O usuário clicou em /ping!"
      ↓
   Sua URL (Cloudflare Worker)
      ↓
Você verifica se é realmente o Discord (verificação de assinatura)
      ↓
Você parseia o tipo de interação
      ↓
Você executa o handler
      ↓
Você retorna JSON
      ↓
Discord: "Legal, vou exibir isso pro usuário"

É HTTP puro. Sem magia. Sem bibliotecas pesadas.

Hono + Cloudflare Workers: o combo econômico

Hono é um framework web que pesa 12KB. Roda em qualquer lugar: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... o mesmo código em qualquer lugar.

Cloudflare Workers é computação no edge. Suas requisições chegam no servidor mais próximo. Tempo de resposta: <100ms. Custo: gratuito até 100k requisições/dia.

O combo Hono + Cloudflare é a combinação perfeita para um bot Discord.

Aqui está o código mínimo de um bot completo:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Pega os headers
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. Verifica se é realmente o Discord (não é spam)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Parseia o que foi enviado
  const interaction = JSON.parse(body);

  // 4. Responde conforme o tipo
  if (interaction.type === 1) {
    // Teste do Discord (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // É uma slash command
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

Tipo, 30 linhas e é um bot funcional.

Sem bot.login(). Sem event emitter. Sem callback hell. Só HTTP.

Para fazer deploy no Cloudflare:

npm install -D wrangler
npx wrangler deploy

Boom. Você tem uma URL tipo https://meu-bot.workers.dev/interactions.

Você coloca isso no Discord Developer Portal em "INTERACTIONS ENDPOINT URL", e o Discord começa a enviar suas interações pra lá.

Verificar a assinatura: sem fake requests

O Discord assina cada requisição com uma chave pública. Se você recebe uma requisição com assinatura errada? É spam. Ignore e continue.

O pacote discord-interactions faz o trabalho:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // texto bruto exato (não JSON parseado!)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // do Discord Dev Portal
);

Armadilha importante: a assinatura depende do body exato. Se você parsear JSON e re-stringificar, ou se der log no body, você quebra a assinatura.

Verifique primeiro. Parseie depois. É a ordem que importa.

Caso 1: Nibi (bot de aprendizado de japonês)

Nibi é um bot Discord para aprender japonês. Comandos simples:

  • /dictionary kanji → exibe as definições
  • /pronounce テキスト → gera TTS (text-to-speech)
  • /hello → mensagem de boas-vindas

Cada comando é um arquivo TypeScript:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

Um comando implementa esta interface:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<interactionresponse>;
}

O comando /pronounce: fazer o bot falar

Esse é o mais doido. Você envia texto (romaji, hiragana, kanji, qualquer coisa), o bot converte pra hiragana, gera TTS via VOICEVOX ou Google TTS, e envia uma mensagem de áudio no Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Gera TTS para texto em japonês',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Texto a ser pronunciado',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Converter romaji → hiragana com Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. Gerar o áudio TTS
      const audioBuffer = await generateTTS(hiragana);

      // 3. Fazer upload do arquivo para o Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Enviar a mensagem com o áudio
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // duração em segundos
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `Pronúncia para "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'Erro: não foi possível gerar o áudio xD',
          flags: 64  // ephemeral (mensagem privada)
        }
      };
    }
  }
};

É loucura: você chama uma API externa, faz upload de um arquivo pro Discord, envia uma mensagem com o arquivo. Tudo isso sem WebSocket, só HTTP.

Persistência com Supabase

Nibi usa Supabase como key-value store. Para verificar se um usuário está registrado:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // Adicionar o usuário
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

É bem básico (sem queries SQL de verdade, só JSON), mas funciona. Para bots pequenos é perfeito.

Caso 2: Konosuba-RPG (jogo Discord com renderização de imagem)

Ok, essa é louca.

Konosuba-RPG é um jogo completo no Discord. Você luta contra mobs, ganha XP, equipa acessórios, sobe de nível. Cada batalha gera uma imagem em tempo real. Sem spritesheet pré-renderizada. A imagem é composta dinamicamente a partir das stats do jogador, do mob e do estado do combate.

E a imagem é gerada em <500ms no Cloudflare Workers. Literalmente.

A arquitetura de renderização

Discord (você clica "Attack")
    ↓
Cloudflare Worker recebe a interação
    ↓
Atualização do game state (XP, HP, etc.)
    ↓
Gera JSX com Satori
    ↓
Converte SVG → PNG com Resvg (Wasm)
    ↓
Faz upload da imagem para o Discord
    ↓
Envia a mensagem com a imagem

Tudo isso em menos de um segundo. É impressionante.

Renderização de imagem nos Workers

Konosuba usa Satori (JSX → SVG) e Resvg (SVG → PNG):

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. Criar JSX para a UI
  const jsx = (
    <div style="{{" display:="" 'flex',="" gap:="" '20px'="" }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

Você escreve JSX normal. Vira SVG. SVG vira PNG. <100ms em um Cloudflare Worker.

Saca o poder? É simplesmente... lindo xD

Game state e progressão

Os dados do jogador ficam no Supabase:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// O jogador venceu
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

Cada ação (ataque, defesa, cura) atualiza as stats no banco. E aí você regenera a imagem com as novas stats.

Interações: os botões do gameplay

O jogo usa button interactions para as ações em combate:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (azul)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (cinza)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

Quando você clica em "Attack", o Discord faz POST de uma interação com custom_id: 'battle_attack'. O handler roteia isso:

if (interaction.type === 3) {
  // Component interaction (clique em botão, etc.)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

E pronto, você calcula o dano, atualiza o banco, regenera a imagem, envia.

É um jogo turn-based completo sem nenhuma persistência de conexão. Apenas HTTP stateless. Completamente louco xD

Supabase: a DB feita para os Workers

Bancos de dados clássicos (PostgreSQL, MySQL, MongoDB) são projetados para conexões TCP persistentes. Você abre um socket, mantém a conexão aberta, envia queries. Problema: Cloudflare Workers não suporta conexões TCP persistentes. Cada requisição é um processo efêmero. Assim que você responde ao cliente, o Worker desaparece.

Você não pode fazer isso:

// Isso NÃO funciona no Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // conexão TCP persistente = morto

E até mesmo os drivers nativos de PostgreSQL como pg ou postgres.js usam conexões TCP. No Workers, eles quebram.

Supabase resolve tudo isso.

Supabase é uma API REST sobre PostgreSQL. Você faz requisições HTTP normais. Cada chamada é independente, sem conexão persistente, sem estado para gerenciar. É perfeitamente adaptado ao modelo serverless.

// Isso funciona PERFEITAMENTE no Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

O cliente Supabase (@supabase/supabase-js) usa fetch por baixo dos panos. E fetch é nativo no Workers. Zero configuração, zero driver, zero conexão persistente.

Banco de dados Compatível com Workers? Por quê
Supabase ✅ Sim REST API sem estado, HTTP puro
PlanetScale (MySQL) ⚠️ Parcial Conexão HTTPS apenas, sem transações longas
Neon ⚠️ Parcial Branches serverless mas driver TCP necessário
Turso (libSQL) ⚠️ Parcial HTTP possível mas limitado
Prisma/Prisma Postgres ❌ Não Requer TCP persistente
MongoDB Atlas ❌ Não Driver TCP, sem REST API nativa
Redis (Upstash) ✅ Sim REST API HTTP

A verdadeira vantagem do Supabase não é só o banco -- é todo o ecossistema que é pensado edge-first:

  • Auth: API REST para gerenciar sessões, funciona sem estado
  • Storage: Upload/download de arquivos via HTTP
  • Realtime: WebSocket opcional, mas você também pode fazer polling via REST
  • Row Level Security: as regras de segurança estão no banco, não no seu backend

Para um bot Discord serverless, Supabase é a escolha mais simples e confiável. Sem driver para configurar, sem conexão para manter, sem timeouts. Apenas requisições HTTP.

Se você quiser um exemplo real, olhe o Nibi acima: o código de persistência é literalmente readJson() e writeJson() no Supabase. Sem migrations, sem schemas complexos, sem configuração louca. Funciona direto. E se seu bot crescer, você migra para queries SQL de verdade sem mudar de provider.

Polyfills: quando o Node quer rodar no Workers

Alguns pacotes esperam APIs do Node. Kuromoji (parser de kanji) usa XMLHttpRequest. Os Workers têm fetch, não XMLHttpRequest.

Solução simples: adicionar um polyfill no topo do index.ts:

// Polyfill XMLHttpRequest para kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Stub mínimo
  } as any;
}

Ou fazer um módulo dedicado:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

É um hack básico, mas funciona.

Rumo a um pacote npm: hono-discord-interactions

Na mão, fazer um bot dá muito boilerplate:

  • Verificar a assinatura do Discord
  • Rotear os tipos de interação
  • Gerenciar comandos, components, modals
  • Retornar JSON válido

Dá pra abstrair tudo isso em um pacote npm. Tipo:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

Boom. 20 linhas em vez de 200. Reduziria o Nibi pela metade fácil.

Ideia para depois xD

Deploy

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "meu-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

URL resultante: https://meu-bot.workers.dev/interactions

Custo: gratuito até 100k requisições/dia. Acima disso: $0.50/milhão.

Spoiler: você nunca vai gastar as 100k requisições a menos que tenha 10.000 usuários ativos.

Vercel

npm run vercel:deploy

URL: https://meu-bot-xyz.vercel.app/api/interactions

Mesma coisa, gratuito.

Os dois ao mesmo tempo

Hono roda em qualquer lugar. Você pode fazer deploy do mesmo código no Cloudflare E no Vercel. Útil para redundância ou testar antes de escolher.

Checklist rápida

  1. Criar uma Application no Discord Developer Portal
  2. Copiar PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Criar o projeto:
  4. Escrever index.ts (verificação de assinatura + roteamento)
  5. Registrar as slash commands (uma vez):
  6. Fazer deploy:
  7. Colocar a URL no Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. O Discord testa a conexão (você precisa responder ao PING)
  9. Convidar o bot para um servidor
  10. Pronto

Vantagens vs Limitações

Vantagens

  • Barato (gratuito até 100k req/dia)
  • Escalável (sem gerenciamento de conexão)
  • Simples (sem boilerplate de WebSocket)
  • Rápido (Cloudflare = servidores no edge)
  • Portátil (código Hono = vários hosts)

Limitações

  • Sem eventos de servidor em tempo real (membro entrou, cargo adicionado, mensagem deletada, etc.) -- você recebe apenas as interações (slash commands, buttons, modals)
  • Timeout de 3 segundos para responder -- senão o Discord exibe "Application did not respond"
  • Se precisar de eventos reais -- precisa de um webhook HTTP separado ou uma conexão WebSocket auxiliar

Para 90% dos bots (tudo baseado em slash commands)? Está de bom tamanho.

Para concluir

Passei bastante tempo otimizando KonosubaRPG e Nibi para economizar o máximo de requisições possível, ou para reduzir o tempo de processamento a quente, ou para reduzir o boot cold. Resultado, tenho performances incríveis em praticamente tudo.
Saiba que eu comecei a "cloudificar" (nem sei se isso existe) a maioria dos meus projetos porque tinha uma preguiça monumental de continuar hospedando eles na minha própria VM. Sério, acho que foram as Github Actions que me salvaram a pele. Os workers também, mas na verdade quando vi que podia fazer daemons com as Github Actions e os schedules, isso realmente me salvou.

Vou provavelmente escrever um artigo sobre um projeto chamado email-autoreply, então assinem o feed RSS para vê-lo sair em breve :)).

Os 3 pontos para reter:

  1. Interaction endpoints = HTTP serverless -- Sem WebSocket, sem conexão persistente. O Discord faz POST, você responde. Gratuito no Cloudflare.
  2. Hono é a ferramenta perfeita -- Framework leve (12KB), multi-runtime, zero dependências. Código idêntico no Cloudflare, Vercel, Node, em qualquer lugar.
  3. Renderizar imagem no Workers = loucura -- Satori + Resvg (Wasm) permite compor UIs dinâmicas em JSX e convertê-las em PNG em <100ms. Um jogo completo pode rodar nisso.

É doente xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

Discord bot 100% serverless : Hono + Cloudflare Workers

Bagaimana saya mengganti bot Discord yang menghabiskan 50€/bulan menjadi

Discord bot 100% serverless : Hono + Cloudflare Workers = 💸 nol

Saya menghabiskan beberapa bulan memelihara bot Discord biasa di mesin saya sendiri.

Koneksi WebSocket selalu terbuka. Bot reconnect sendiri jam 3 pagi. Bot crash karena saya lihat dompet salah. Tagihan membengkak.

Suatu hari saya menemukan: kenapa harus maintain koneksi ? Discord bisa POST langsung hal-hal yang kamu minati. Kamu jawab, selesai.

Sejak 2021, Discord menyediakan interaction endpoints.

Ini cuma HTTP. Tanpa WebSocket. Tanpa state persisten. Kamu terima request, kirim JSON, selesai. Request berikutnya datang sendiri.

Dan yang terbaik: Cloudflare Workers itu gratis sampai 100k request/hari. Untuk 90% bot, ini 0€/bulan.

Artikel ini menunjukkan cara membuat bot Discord tanpa WebSocket menggunakan Hono (framework web ultra-ringan) dan Cloudflare Workers. Saya akan tunjukkan dua proyek nyata: Nibi (bot belajar bahasa Jepang, TTS, keren) dan Konosuba-RPG (game Discord lengkap dengan render gambar real-time xD).

WebSocket vs. Interaction Endpoints : kenapa dulu itu ide buruk

Bayangkan game Minecraft dimana kamu harus menjaga koneksi tetap terbuka bahkan saat tidak bermain.

Dan server reconnect otomatis setiap kali crash. Kamu harus handle timeout, exponential backoff, semua boilerplate menyebalkan yang kita benci. Hanya untuk menerima interaksi.

Interaction endpoints kebalikannya. Discord POST ke URL kamu. Kamu jawab. Selesai.

Jika server kamu crash? Discord retry 2-3 kali lalu lanjut. Zero drama.

Biaya dulu : 50€/bulan di Heroku cuma untuk menjaga proses Node tetap hidup.

Biaya sekarang : 0€/bulan di Cloudflare sampai 100k request/hari.

Arsitekturnya : sebenarnya apa sih?

Discord POST request ke endpoint kamu.

Discord: "Eh! User klik /ping!"
      ↓
   URL kamu (Cloudflare Worker)
      ↓
Kamu verifikasi itu benar-benar Discord (signature check)
      ↓
Kamu parse tipe interaksi
      ↓
Kamu eksekusi handler
      ↓
Kamu return JSON
      ↓
Discord: "OK, saya tampilkan itu ke user"

Ini HTTP murni. Tanpa sihir. Tanpa library berat.

Hono + Cloudflare Workers : combo hemat

Hono adalah framework web seberat 12KB. Jalan di mana saja: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... kode yang sama di mana saja.

Cloudflare Workers adalah compute di edge. Request kamu tiba di server terdekat. Response time: <100ms. Biaya: gratis sampai 100k request/hari.

Kombo Hono + Cloudflare adalah pasangan sempurna untuk bot Discord.

Ini kode minimal bot lengkap:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Ambil headers
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. Verifikasi itu benar-benar Discord (bukan spam)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Parse apa yang dikirim
  const interaction = JSON.parse(body);

  // 4. Jawab sesuai tipe
  if (interaction.type === 1) {
    // Discord test (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // Ini slash command
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

Kayak, 30 baris dan jadi bot fungsional.

Ga ada bot.login(). Ga ada event emitter. Ga ada callback hell. Cuma HTTP.

Untuk deploy ke Cloudflare:

npm install -D wrangler
npx wrangler deploy

Boom. Kamu punya URL kayak https://mon-bot.workers.dev/interactions.

Kamu masukin itu ke Discord Developer Portal di "INTERACTIONS ENDPOINT URL", dan Discord mulai kirim interaksi kamu ke sana.

Verifikasi signature : no fake requests

Discord menandatangani setiap request dengan public key. Jika kamu terima request dengan signature salah? Itu spam. Abaikan dan lanjut.

Paket discord-interactions yang handle:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // teks mentah persis (bukan JSON yang sudah diparse!)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // dari Discord Dev Portal
);

Jebakan penting: signature tergantung body yang persis. Jika kamu parse JSON lalu re-stringify, atau jika kamu log body, signature-nya rusak.

Verifikasi dulu. Parse setelahnya. Urutannya penting.

Kasus 1 : Nibi (bot belajar bahasa Jepang)

Nibi adalah bot Discord untuk belajar bahasa Jepang. Perintah sederhana:

  • /dictionary kanji → menampilkan definisi
  • /pronounce テキスト → generate TTS (text-to-speech)
  • /hello → pesan sambutan

Setiap perintah adalah file TypeScript:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

Sebuah command mengimplementasikan interface ini:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<InteractionResponse>;
}

Command /pronounce : bikin bot bicara

Ini yang agak gila. Kamu kirim teks (romaji, hiragana, kanji, apapun), bot mengonversinya ke hiragana, generate TTS via VOICEVOX atau Google TTS, dan kirim pesan audio ke Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Generate TTS untuk teks bahasa Jepang',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Teks yang akan diucapkan',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Konversi romaji → hiragana dengan Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. Generate audio TTS
      const audioBuffer = await generateTTS(hiragana);

      // 3. Upload file ke Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Kirim pesan dengan audio
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // durasi dalam detik
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `Pengucapan untuk "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'Error : gagal generate audio xD',
          flags: 64  // ephemeral (pesan pribadi)
        }
      };
    }
  }
};

Gila sih: kamu panggil API eksternal, upload file ke Discord, kirim pesan dengan file. Semua tanpa WebSocket, cuma HTTP.

Persistensi dengan Supabase

Nibi menggunakan Supabase sebagai key-value store. Untuk cek apakah user terdaftar:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // Tambah user
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

Ini sangat basic (bukan SQL query beneran, cuma JSON), tapi berhasil. Untuk bot kecil ini sempurna.

Kasus 2 : Konosuba-RPG (game Discord dengan render gambar)

OK yang ini gila.

Konosuba-RPG adalah game lengkap di Discord. Kamu lawan mob, dapat XP, pakai aksesoris, naik level. Setiap battle menghasilkan gambar real-time. Bukan spritesheet pre-render. Gambar dikomposisi secara dinamis dari stats pemain, mob, dan status pertarungan.

Dan gambar digenerate dalam <500ms di Cloudflare Workers. Beneran.

Arsitektur render

Discord (kamu klik "Attack")
    ↓
Cloudflare Worker menerima interaksi
    ↓
Update game state (XP, HP, dll)
    ↓
Generate JSX dengan Satori
    ↓
Konversi SVG → PNG dengan Resvg (Wasm)
    ↓
Upload gambar ke Discord
    ↓
Kirim pesan dengan gambar

Semua dalam kurang dari satu detik. Gila banget.

Render gambar di sisi Workers

Konosuba menggunakan Satori (JSX → SVG) dan Resvg (SVG → PNG):

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. Buat JSX untuk UI
  const jsx = (
    <div style="{{" display:="" 'flex',="" gap:="" '20px'="" }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

Kamu nulis JSX biasa. Jadi SVG. SVG jadi PNG. <100ms di Cloudflare Worker.

Bayangin kekuatannya? Ini... indah banget xD

Game state dan progresi

Data pemain disimpan di Supabase:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// Pemain menang
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

Setiap aksi (serang, bertahan, heal) mengupdate stats di database. Lalu kamu regenerate gambar dengan stats baru.

Interaksi : tombol gameplay

Game menggunakan button interactions untuk aksi dalam pertarungan:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (biru)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (abu-abu)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

Saat kamu klik "Attack", Discord POST interaksi dengan custom_id: 'battle_attack'. Handler me-routenya:

if (interaction.type === 3) {
  // Component interaction (klik tombol, dll)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

Dan boom, kamu hitung damage, update database, regen gambar, kirim.

Ini game turn-based lengkap tanpa persistence koneksi sama sekali. Cuma HTTP stateless. Benar-benar gila xD

Supabase : DB yang dibuat untuk Workers

Database tradisional (PostgreSQL, MySQL, MongoDB) dirancang untuk koneksi TCP persisten. Kamu buka socket, jaga koneksi tetap terbuka, kirim query. Masalahnya: Cloudflare Workers tidak mendukung koneksi TCP persisten. Setiap request adalah proses ephemeral. Begitu kamu menjawab client, Worker-nya hilang.

Kamu ga bisa lakuin ini:

// Ini GA BISA di Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // koneksi TCP persisten = mati

Bahkan driver PostgreSQL native seperti pg atau postgres.js menggunakan koneksi TCP. Di Workers, mereka crash.

Supabase menyelesaikan semua itu.

Supabase adalah REST API di atas PostgreSQL. Kamu lakukan request HTTP biasa. Setiap panggilan independen, tanpa koneksi persisten, tanpa state yang perlu dikelola. Ini sangat cocok dengan model serverless.

// Ini BERHASIL SEMPURNA di Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Client Supabase (@supabase/supabase-js) menggunakan fetch di dalamnya. Dan fetch adalah native di Workers. Zero konfigurasi, zero driver, zero koneksi persisten.

Database Kompatibel Workers ? Kenapa
Supabase ✅ Ya REST API tanpa state, HTTP murni
PlanetScale (MySQL) ⚠️ Sebagian Koneksi HTTPS saja, tidak ada transaksi panjang
Neon ⚠️ Sebagian Serverless branches tapi driver TCP diperlukan
Turso (libSQL) ⚠️ Sebagian HTTP dimungkinkan tapi terbatas
Prisma/Prisma Postgres ❌ Tidak Membutuhkan TCP persisten
MongoDB Atlas ❌ Tidak Driver TCP, tidak ada REST API native
Redis (Upstash) ✅ Ya REST API HTTP

Keunggulan sebenarnya Supabase bukan cuma DB -- seluruh ekosistemnya dirancang edge-first:

  • Auth : REST API untuk manage session, bekerja tanpa state
  • Storage : Upload/download file via HTTP
  • Realtime : WebSocket opsional, tapi kamu juga bisa poll via REST
  • Row Level Security : aturan keamanan ada di DB, bukan di backend kamu

Untuk bot Discord serverless, Supabase adalah pilihan paling sederhana dan paling reliable. Tidak ada driver yang perlu dikonfigurasi, tidak ada koneksi yang perlu di-maintain, tidak ada timeout. Hanya request HTTP.

Jika kamu mau contoh nyata, lihat Nibi di atas: kode persistensi-nya literally readJson() dan writeJson() di Supabase. Tidak ada migrasi, tidak ada skema kompleks, tidak ada konfigurasi gila. Langsung jalan. Dan jika bot kamu menjadi besar, kamu migrasi ke SQL query beneran tanpa ganti provider.

Polyfills : saat Node mau jalan di Workers

Beberapa package mengharapkan API Node. Kuromoji (parser kanji) menggunakan XMLHttpRequest. Workers punya fetch, bukan XMLHttpRequest.

Solusi sederhana: tambah polyfill di atas index.ts:

// Polyfill XMLHttpRequest untuk kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Stub minimal
  } as any;
}

Atau buat module dedicated:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

Ini hack basic, tapi berhasil.

Dengan cara manual, bikin bot itu banyak boilerplate:

  • Verifikasi signature Discord
  • Route tipe interaksi
  • Handle commands, components, modals
  • Return JSON valid

Kita bisa abstrak semua itu dalam satu paket npm. Kayak:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

Boom. 20 baris daripada 200. Ini bakal ngurangin Nibi setengahnya dengan mudah.

Ide buat nanti xD

Deploy

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

URL hasil: https://mon-bot.workers.dev/interactions

Biaya: gratis sampai 100k request/hari. Lebih dari itu: $0.50/juta.

Spoiler: kamu ga bakal pernah capai 100k request kecuali punya 10.000 user aktif.

Vercel

npm run vercel:deploy

URL: https://mon-bot-xyz.vercel.app/api/interactions

Sama, gratis.

Keduanya sekaligus

Hono jalan di mana saja. Kamu bisa deploy kode yang sama di Cloudflare DAN Vercel. Berguna untuk redundansi atau testing sebelum memilih.

Checklist cepat

  1. Buat Application di Discord Developer Portal
  2. Copy PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Buat project:
  4. Tulis index.ts (verif signature + routing)
  5. Daftarkan slash commands (sekali):
  6. Deploy:
  7. Masukkan URL di Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord test koneksi (kamu harus jawab PING)
  9. Invite bot ke server
  10. Selesai

Kelebihan vs Kekurangan

Kelebihan

  • Murah (gratis sampai 100k req/hari)
  • Scalable (tanpa connection management)
  • Sederhana (tanpa WebSocket boilerplate)
  • Cepat (Cloudflare = server di edge)
  • Portabel (kode Hono = banyak host)

Kekurangan

  • Tidak ada event server real-time (member join, role ditambahkan, pesan dihapus, dll.) -- kamu hanya menerima interaksi (slash commands, buttons, modals)
  • Timeout 3 detik untuk merespon -- jika tidak, Discord tampilkan "Application did not respond"
  • Jika butuh event beneran -- perlu webhook HTTP terpisah atau koneksi WebSocket tambahan

Untuk 90% bot (semua berbasis slash commands)? Ini sudah cukup.

Kesimpulan

Saya menghabiskan cukup banyak waktu mengoptimalkan KonosubaRPG dan Nibi untuk menghemat sebanyak mungkin request, atau mengurangi waktu prosesor panas, atau mengurangi cold boot. Hasilnya, saya punya performa gila di hampir semuanya.
Perlu diketahui bahwa saya mulai cloudify (saya bahkan ga tau itu istilah beneran) sebagian besar proyek saya karena rasa males yang luar biasa untuk terus hosting di VM sendiri. Sungguh, saya rasa Github Actions-lah yang menyelamatkan pantat saya. Workers juga, tapi sebenarnya ketika saya lihat saya bisa bikin daemon dengan Github Actions dan schedules, itu benar-benar nyelametin gw bro.

Saya mungkin akan menulis artikel tentang proyek bernama email-autoreply, jadi subscribe ke RSS feed biar lihat saat rilis :)).

3 hal yang perlu diingat:

  1. Interaction endpoints = HTTP serverless -- Tanpa WebSocket, tanpa koneksi persisten. Discord POST, kamu jawab. Gratis di Cloudflare.
  2. Hono alat yang sempurna -- Framework ringan (12KB), multi-runtime, zero dependensi. Kode identik di Cloudflare, Vercel, Node, di mana saja.
  3. Render gambar di Workers = gila -- Satori + Resvg (Wasm) memungkinkan kamu komposisi UI dinamis dalam JSX dan konversi ke PNG dalam <100ms. Game lengkap bisa jalan di atasnya.

Ini gila banget xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

Discord bot 100% सर्वरलेस : Hono + Cloudflare Workers

कैसे मैंने एक Discord बॉट को जो मुझे 50€/माह खर्च कराता था, शून्य

Discord bot 100% सर्वरलेस : Hono + Cloudflare Workers = 💸 शून्य

मैंने कुछ महीने अपनी खुद की मशीन पर सामान्य Discord बॉट्स को मेंटेन करने में बिताए।

WebSocket कनेक्शन हमेशा खुला रहता है। बॉट सुबह 3 बजे खुद रीकनेक्ट करता है। बॉट क्रैश हो जाता है बिना किसी कारण के। बिल बढ़ता जाता है।

एक दिन मुझे पता चला : कनेक्शन क्यों बनाए रखें ? Discord सिर्फ वो चीज़ें POST कर सकता है जिनमें तुम्हारी रुचि हो। तुम जवाब दो, बस हो गया।

2021 से, Discord इंटरैक्शन एंडपॉइंट्स प्रदान करता है।

यह सिर्फ HTTP है। कोई WebSocket नहीं। कोई स्थायी स्थिति नहीं। तुम्हें एक रिक्वेस्ट मिलती है, तुम JSON भेजते हो, बस हो गया। अगली रिक्वेस्ट अपने आप आ जाती है।

और सबसे अच्छी बात : Cloudflare Workers मुफ़्त है 100k रिक्वेस्ट/दिन तक। 90% बॉट्स के लिए, यह 0€/माह है।

यह लेख तुम्हें दिखाता है कि बिना WebSocket के Hono (अल्ट्रा-लाइटवेट वेब फ्रेमवर्क) और Cloudflare Workers का उपयोग करके Discord बॉट कैसे बनाया जाता है। मैं तुम्हें दो वास्तविक प्रोजेक्ट दिखाऊंगा : Nibi (जापानी सीखने के लिए बॉट, TTS, कूल) और Konosuba-RPG (रीयल-टाइम इमेज रेंडरिंग के साथ एक पूरा Discord गेम xD)।

WebSocket बनाम इंटरैक्शन एंडपॉइंट्स : यह बुरा विचार क्यों था

एक Minecraft गेम की कल्पना करो जहाँ तुम्हें कनेक्शन खुला रखना है भले ही तुम खेल नहीं रहे हो।

और सर्वर हर बार क्रैश होने पर अपने आप रीकनेक्ट होता है। तुम्हें टाइमआउट, एक्सपोनेंशियल रीकनेक्शन, वह सब बेकार बॉयलरप्लेट संभालना होता है जिससे हम नफरत करते हैं। सिर्फ इंटरैक्शन प्राप्त करने के लिए।

इंटरैक्शन एंडपॉइंट्स इसका उल्टा है। Discord तुम्हारे URL पर POST करता है। तुम जवाब देते हो। बस हो गया।

अगर तुम्हारा सर्वर क्रैश हो जाए ? Discord 2-3 बार रीट्राई करता है और आगे बढ़ जाता है। कोई ड्रामा नहीं।

पहले की लागत : Heroku पर 50€/माह सिर्फ एक Node प्रोसेस को जीवित रखने के लिए।

बाद की लागत : Cloudflare पर 0€/माह 100k रिक्वेस्ट/दिन तक।

आर्किटेक्चर : यह आखिर है क्या ?

Discord तुम्हारे एंडपॉइंट पर एक रिक्वेस्ट POST करता है।

Discord: "अरे! उपयोगकर्ता ने /ping पर क्लिक किया!"
      ↓
   तुम्हारा URL (Cloudflare Worker)
      ↓
तुम जाँच करते हो कि यह सच में Discord है (सिग्नेचर चेक)
      ↓
तुम इंटरैक्शन टाइप को पार्स करते हो
      ↓
तुम हैंडलर को एक्सीक्यूट करते हो
      ↓
तुम JSON लौटाते हो
      ↓
Discord: "बढ़िया, मैं यह उपयोगकर्ता को दिखाऊंगा"

यह शुद्ध HTTP है। कोई जादू नहीं। कोई भारी लाइब्रेरी नहीं।

Hono + Cloudflare Workers : किफायती कॉम्बो

Hono एक वेब फ्रेमवर्क है जिसका वजन 12KB है। यह हर जगह चलता है : Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... एक ही कोड हर जगह।

Cloudflare Workers एज पर कंप्यूट है। तुम्हारी रिक्वेस्ट सबसे नज़दीकी सर्वर पर पहुँचती है। रिस्पॉन्स टाइम : <100ms. लागत : 100k रिक्वेस्ट/दिन तक मुफ़्त।

Hono + Cloudflare का कॉम्बो Discord बॉट के लिए परफेक्ट मैच है।

यह रहा एक पूर्ण बॉट का न्यूनतम कोड :

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. हेडर प्राप्त करो
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. जाँच करो कि यह सच में Discord है (स्पैम नहीं)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. उसने जो भेजा है उसे पार्स करो
  const interaction = JSON.parse(body);

  // 4. टाइप के अनुसार जवाब दो
  if (interaction.type === 1) {
    // Discord test (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // यह एक स्लैश कमांड है
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

बस, 30 लाइनें और यह एक काम करने वाला बॉट है।

कोई bot.login() नहीं। कोई event emitter नहीं। कोई callback hell नहीं। बस HTTP।

Cloudflare पर डिप्लॉय करने के लिए :

npm install -D wrangler
npx wrangler deploy

बूम। तुम्हें एक URL मिलता है जैसे https://mon-bot.workers.dev/interactions।

इसे Discord Developer Portal में "INTERACTIONS ENDPOINT URL" के तहत डालो, और Discord वहाँ तुम्हारी इंटरैक्शन भेजना शुरू कर देगा।

सिग्नेचर वेरिफिकेशन : कोई फ़ेक रिक्वेस्ट नहीं

Discord हर रिक्वेस्ट पर पब्लिक की से साइन करता है। अगर तुम्हें गलत सिग्नेचर वाली रिक्वेस्ट मिले ? यह स्पैम है। इसे अनदेखा करो और आगे बढ़ो।

discord-interactions पैकेज यह काम करता है :

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // सटीक रॉ टेक्स्ट (पार्स किया हुआ JSON नहीं!)
  signature,         // हेडर x-signature-ed25519
  timestamp,         // हेडर x-signature-timestamp
  publicKey          // Discord Dev Portal से
);

महत्वपूर्ण ट्रैप : सिग्नेचर सटीक body पर निर्भर करता है। अगर तुम JSON पार्स करके फिर से stringify करते हो, या body को लॉग करते हो, तो सिग्नेचर टूट जाता है।

पहले जाँच करो। बाद में पार्स करो। यही सही क्रम है।

केस 1 : Nibi (जापानी सीखने का बॉट)

Nibi जापानी सीखने के लिए एक Discord बॉट है। सरल कमांड्स :

  • /dictionary kanji → परिभाषाएँ दिखाता है
  • /pronounce テキスト → TTS (टेक्स्ट-टू-स्पीच) जनरेट करता है
  • /hello → स्वागत संदेश

हर कमांड एक TypeScript फ़ाइल है :

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

एक कमांड इस इंटरफ़ेस को इम्प्लीमेंट करती है :

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<interactionresponse>;
}

/pronounce कमांड : बॉट को बोलने दो

यह वाली काफी अजीब है। तुम टेक्स्ट भेजते हो (रोमाजी, हीरागाना, कांजी, कुछ भी), बॉट इसे हीरागाना में बदलता है, VOICEVOX या Google TTS के ज़रिए TTS जनरेट करता है, और Discord पर एक ऑडियो संदेश भेजता है।

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'जापानी टेक्स्ट के लिए TTS जनरेट करता है',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'उच्चारण करने के लिए टेक्स्ट',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Kuroshiro से रोमाजी → हीरागाना
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. TTS ऑडियो जनरेट करो
      const audioBuffer = await generateTTS(hiragana);

      // 3. फ़ाइल को Discord पर अपलोड करो
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. ऑडियो के साथ संदेश भेजो
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // सेकंड में अवधि
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `Prononciation pour "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'त्रुटि : ऑडियो जनरेट नहीं कर सका xD',
          flags: 64  // ephemeral (निजी संदेश)
        }
      };
    }
  }
};

यह पागलपन है : तुम एक बाहरी API कॉल करते हो, Discord पर एक फ़ाइल अपलोड करते हो, फ़ाइल के साथ एक संदेश भेजते हो। यह सब बिना WebSocket के, सिर्फ HTTP से।

Supabase के साथ पर्सिस्टेंस

Nibi Supabase को key-value स्टोर के रूप में उपयोग करता है। यह जाँचने के लिए कि कोई उपयोगकर्ता पंजीकृत है या नहीं :

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // उपयोगकर्ता जोड़ो
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

यह बहुत बुनियादी है (कोई असली SQL क्वेरी नहीं, सिर्फ JSON), लेकिन यह काम करता है। छोटे बॉट्स के लिए यह परफेक्ट है।

केस 2 : Konosuba-RPG (इमेज रेंडरिंग के साथ Discord गेम)

ठीक है यह वाला पागलपन है।

Konosuba-RPG Discord पर एक पूरा गेम है। तुम मॉब्स से लड़ते हो, XP कमाते हो, एक्सेसरीज़ इक्विप करते हो, लेवल अप करते हो। हर बैटल एक रीयल-टाइम इमेज जनरेट करती है। कोई प्री-रेंडर्ड स्प्राइटशीट नहीं। इमेज प्लेयर के स्टैट्स, मॉब और बैटल की स्थिति से डायनामिक रूप से कंपोज़ होती है।

और इमेज Cloudflare Workers पर <500ms में जनरेट होती है। सचमुच।

रेंडर आर्किटेक्चर

Discord (तुम "Attack" पर क्लिक करते हो)
    ↓
Cloudflare Worker इंटरैक्शन प्राप्त करता है
    ↓
गेम स्टेट अपडेट (XP, HP, आदि)
    ↓
Satori के साथ JSX जनरेट करो
    ↓
Resvg (Wasm) से SVG → PNG बदलो
    ↓
इमेज Discord पर अपलोड करो
    ↓
इमेज के साथ संदेश भेजो

यह सब एक सेकंड से भी कम में। यह कमाल है।

Workers पर इमेज रेंडरिंग

Konosuba Satori (JSX → SVG) और Resvg (SVG → PNG) का उपयोग करता है :

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. UI के लिए JSX बनाओ
  const jsx = (
    <div style="{{" display:="" 'flex',="" gap:="" '20px'="" }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

तुम सामान्य JSX लिखते हो। वह SVG बन जाता है। SVG PNG बन जाता है। Cloudflare Worker पर <100ms।

समझ रहे हो कितनी ताकत है ? यह बस... सुंदर है xD

गेम स्टेट और प्रोग्रेस

प्लेयर डेटा Supabase में है :

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// प्लेयर जीत गया
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

हर एक्शन (हमला, बचाव, उपचार) डेटाबेस में स्टैट्स अपडेट करता है। और फिर तुम नए स्टैट्स के साथ इमेज दोबारा जनरेट करते हो।

इंटरैक्शन : गेमप्ले के बटन

गेम कॉम्बैट एक्शन के लिए बटन इंटरैक्शन का उपयोग करता है :

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (नीला)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (ग्रे)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

जब तुम "Attack" पर क्लिक करते हो, Discord custom_id: 'battle_attack' के साथ एक इंटरैक्शन POST करता है। हैंडलर इसे रूट करता है :

if (interaction.type === 3) {
  // Component interaction (बटन क्लिक, आदि)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

और बूम, तुम नुकसान की गणना करते हो, डेटाबेस अपडेट करते हो, इमेज रीजनरेट करते हो, भेजते हो।

यह बिना किसी कनेक्शन पर्सिस्टेंस के एक पूरा टर्न-बेस्ड गेम है। बस HTTP स्टेटलेस। पूरी तरह से पागलपन xD

Supabase : Workers के लिए बनी DB

पारंपरिक डेटाबेस (PostgreSQL, MySQL, MongoDB) स्थायी TCP कनेक्शन के लिए डिज़ाइन किए गए हैं। तुम एक सॉकेट खोलते हो, कनेक्शन खुला रखते हो, क्वेरी भेजते हो। समस्या : Cloudflare Workers स्थायी TCP कनेक्शन सपोर्ट नहीं करता। हर रिक्वेस्ट एक अस्थायी प्रक्रिया है। जैसे ही तुम क्लाइंट को जवाब देते हो, Worker गायब हो जाता है।

तुम यह नहीं कर सकते :

// यह Workers पर काम नहीं करता
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // स्थायी TCP कनेक्शन = डेड

और यहाँ तक कि नेटिव PostgreSQL ड्राइवर जैसे pg या postgres.js भी TCP कनेक्शन का उपयोग करते हैं। Workers पर, वे क्रैश हो जाते हैं।

Supabase यह सब हल करता है।

Supabase PostgreSQL के ऊपर एक REST API है। तुम सामान्य HTTP रिक्वेस्ट करते हो। हर कॉल स्वतंत्र है, कोई स्थायी कनेक्शन नहीं, कोई स्टेट मैनेज नहीं करना। यह सर्वरलेस मॉडल के लिए पूरी तरह उपयुक्त है।

// यह Workers पर पूरी तरह काम करता है
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Supabase क्लाइंट (@supabase/supabase-js) अंदरूनी रूप से fetch का उपयोग करता है। और fetch Workers पर नेटिव है। जीरो कॉन्फ़िगरेशन, जीरो ड्राइवर, जीरो स्थायी कनेक्शन।

डेटाबेस Workers के साथ संगत ? क्यों
Supabase ✅ हाँ REST API स्टेटलेस, शुद्ध HTTP
PlanetScale (MySQL) ⚠️ आंशिक केवल HTTPS कनेक्शन, कोई लंबा ट्रांज़ैक्शन नहीं
Neon ⚠️ आंशिक सर्वरलेस ब्रांचेज़ लेकिन TCP ड्राइवर आवश्यक
Turso (libSQL) ⚠️ आंशिक HTTP संभव लेकिन सीमित
Prisma/Prisma Postgres ❌ नहीं स्थायी TCP आवश्यक
MongoDB Atlas ❌ नहीं TCP ड्राइवर, कोई नेटिव REST API नहीं
Redis (Upstash) ✅ हाँ REST API HTTP

Supabase का असली फायदा सिर्फ DB नहीं है -- पूरा इकोसिस्टम एज-फर्स्ट सोचा गया है :

  • Auth : सेशन मैनेज करने के लिए REST API, स्टेटलेस काम करता है
  • Storage : HTTP के ज़रिए फ़ाइल अपलोड/डाउनलोड
  • Realtime : वैकल्पिक WebSocket, लेकिन REST के ज़रिए पोल भी कर सकते हो
  • Row Level Security : सुरक्षा नियम DB में हैं, तुम्हारे बैकएंड में नहीं

सर्वरलेस Discord बॉट के लिए, Supabase सबसे सरल और सबसे भरोसेमंद विकल्प है। कोई ड्राइवर कॉन्फ़िगर नहीं करना, कोई कनेक्शन बनाए नहीं रखना, कोई टाइमआउट नहीं। बस HTTP रिक्वेस्ट।

अगर तुम एक वास्तविक उदाहरण चाहते हो, ऊपर Nibi देखो : पर्सिस्टेंस कोड सचमुच Supabase पर readJson() और writeJson() है। कोई माइग्रेशन नहीं, कोई जटिल स्कीमा नहीं, कोई पागल कॉन्फ़िग नहीं। यह सीधे काम करता है। और अगर तुम्हारा बॉट बड़ा हो जाता है, तुम प्रोवाइडर बदले बिना असली SQL क्वेरी पर माइग्रेट कर सकते हो।

पॉलीफ़िल्स : जब Node Workers पर चलना चाहता है

कुछ पैकेज Node APIs की उम्मीद करते हैं। Kuromoji (कांजी पार्सर) XMLHttpRequest का उपयोग करता है। Workers के पास XMLHttpRequest नहीं, fetch है।

सरल उपाय : index.ts के शीर्ष पर एक पॉलीफ़िल जोड़ो :

// kuromoji के लिए XMLHttpRequest पॉलीफ़िल
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // न्यूनतम स्टब
  } as any;
}

या एक समर्पित मॉड्यूल बनाओ :

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

यह बुनियादी हैक है, लेकिन काम करता है।

एक npm पैकेज की ओर : hono-discord-interactions

हाथ से बॉट बनाने में बहुत बॉयलरप्लेट है :

  • Discord सिग्नेचर जाँच करो
  • इंटरैक्शन टाइप रूट करो
  • कमांड्स, कम्पोनेंट्स, मॉडल्स संभालो
  • मान्य JSON लौटाओ

हम यह सब एक npm पैकेज में एब्स्ट्रैक्ट कर सकते हैं। जैसे :

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

बूम। 200 के बजाय 20 लाइनें। यह Nibi को आसानी से आधा कर देगा।

बाद के लिए विचार xD

डिप्लॉय

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

परिणामी URL : https://mon-bot.workers.dev/interactions

लागत : 100k रिक्वेस्ट/दिन तक मुफ़्त। उससे अधिक : $0.50/मिलियन।

स्पॉइलर : जब तक तुम्हारे 10,000 सक्रिय उपयोगकर्ता न हों, तुम कभी 100k रिक्वेस्ट नहीं पहुँचोगे।

Vercel

npm run vercel:deploy

URL : https://mon-bot-xyz.vercel.app/api/interactions

वैसे ही, मुफ़्त।

दोनों एक साथ

Hono हर जगह चलता है। तुम एक ही कोड Cloudflare और Vercel दोनों पर डिप्लॉय कर सकते हो। रिडंडेंसी के लिए या चुनने से पहले परीक्षण के लिए उपयोगी।

त्वरित चेकलिस्ट

  1. Discord Developer Portal पर एक Application बनाओ
  2. PUBLIC_KEY, BOT_TOKEN, APP_ID कॉपी करो
  3. प्रोजेक्ट बनाओ :
  4. index.ts लिखो (सिग्नेचर वेरिफिकेशन + रूटिंग)
  5. स्लैश कमांड्स रजिस्टर करो (एक बार) :
  6. डिप्लॉय करो :
  7. Discord में URL डालो (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord कनेक्शन टेस्ट करता है (तुम्हें PING का जवाब देना होगा)
  9. बॉट को एक सर्वर पर इनवाइट करो
  10. हो गया

लाभ बनाम सीमाएँ

लाभ

  • सस्ता (100k req/दिन तक मुफ़्त)
  • स्केलेबल (कोई कनेक्शन मैनेजमेंट नहीं)
  • सरल (कोई WebSocket बॉयलरप्लेट नहीं)
  • तेज़ (Cloudflare = एज सर्वर)
  • पोर्टेबल (Hono कोड = कई होस्ट)

सीमाएँ

  • कोई रीयल-टाइम सर्वर इवेंट नहीं (मेंबर जॉइन, रोल ऐड, मैसेज डिलीट, आदि) -- तुम्हें केवल इंटरैक्शन मिलते हैं (स्लैश कमांड्स, बटन, मॉडल्स)
  • जवाब देने के लिए 3 सेकंड का टाइमआउट -- नहीं तो Discord "Application did not respond" दिखाता है
  • अगर तुम्हें असली इवेंट चाहिए -- एक अलग HTTP वेबहुक या सहायक WebSocket कनेक्शन चाहिए

90% बॉट्स के लिए (सभी स्लैश कमांड-आधारित) ? यह ठीक है।

निष्कर्ष

मैंने KonosubaRPG और Nibi को ऑप्टिमाइज़ करने में काफी समय बिताया, या तो जितना संभव हो उतनी रिक्वेस्ट बचाने के लिए, या हॉट प्रोसेसर समय कम करने के लिए, या कोल्ड बूट कम करने के लिए। परिणामस्वरूप, मुझे लगभग हर चीज़ पर शानदार परफॉरमेंस मिली। यह जान लो कि मैंने अपने अधिकांश प्रोजेक्ट्स को क्लाउडिफाई करना शुरू कर दिया था (मुझे नहीं पता यह शब्द सही है या नहीं) क्योंकि मुझे उन्हें अपनी खुद की VM पर होस्ट करते रहने में बहुत आलस आ रहा था। सच में, मुझे लगता है कि Github Actions ने मेरी जान बचाई। Workers ने भी, लेकिन जब मैंने देखा कि मैं Github Actions और शेड्यूल के साथ डेमॉन बना सकता हूँ, तो इसने सच में मुझे बहुत बचाया।

मैं शायद email-autoreply नामक एक प्रोजेक्ट पर एक लेख लिखूंगा, इसलिए इसे जल्द ही देखने के लिए RSS फ़ीड को सब्सक्राइब करो :))।

याद रखने वाली 3 बातें :

  1. इंटरैक्शन एंडपॉइंट्स = HTTP सर्वरलेस -- कोई WebSocket नहीं, कोई स्थायी कनेक्शन नहीं। Discord POST करता है, तुम जवाब देते हो। Cloudflare पर मुफ़्त।
  2. Hono सही टूल है -- हल्का फ्रेमवर्क (12KB), मल्टी-रनटाइम, शून्य डिपेंडेंसी। Cloudflare, Vercel, Node, हर जगह एक जैसा कोड।
  3. Workers पर इमेज रेंडरिंग = पागलपन -- Satori + Resvg (Wasm) तुम्हें JSX में डायनामिक UI कंपोज़ करने और उन्हें <100ms में PNG में बदलने देता है। एक पूरा गेम इस पर चल सकता है।

यह कमाल है xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

بوت Discord بدون سيرفر 100% : Hono + Cloudflare Workers

كيف استبدلت بوت Discord كان يكلفني 50€/شهر بصفر يورو -- نقاط التفاعل،

بوت Discord بدون سيرفر 100% : Hono + Cloudflare Workers = 💸 صفر

قضيت بضعة أشهر في صيانة بوتات Discord تقليدية على جهازي الخاص.

اتصال WebSocket مفتوح دائمًا. البوت يعيد الاتصال تلقائيًا في الساعة 3 صباحًا. البوت يتعطل لأنني نظرت إلى الخروف بشكل خاطئ. الفاتورة ترتفع.

في يوم اكتشفت: لماذا نحافظ على اتصال؟ Discord يمكنه أن يرسل لك POST فقط الأشياء التي تهمك. ترد، ينتهي الأمر.

منذ 2021، Discord يقدم نقاط التفاعل (interaction endpoints).

إنها مجرد HTTP. لا WebSocket. لا حالة دائمة. تستقبل طلبًا، ترسل JSON، ينتهي. الطلب التالي يأتي بمفرده.

والأفضل: Cloudflare Workers مجاني حتى 100k طلب/يوم. لـ 90% من البوتات، هذا يساوي 0€/شهر.

هذه المقالة تريك كيفية عمل بوت Discord بدون WebSocket باستخدام Hono (إطار ويب فائق الخفة) و Cloudflare Workers. سأريك مشروعين حقيقيين: Nibi (بوت لتعلم اليابانية، TTS، رائع) و Konosuba-RPG (لعبة Discord كاملة مع عرض صور في الوقت الفعلي xD).

WebSocket مقابل Interaction Endpoints : لماذا كانت فكرة سيئة

تخيل لعبة Minecraft حيث يجب عليك إبقاء الاتصال مفتوحًا حتى عندما لا تلعب.

والسيرفر يعيد الاتصال تلقائيًا كلما تعطل. يجب عليك إدارة المهلات، وإعادة الاتصال الأسي، وكل هذا البويلربليت السيئ الذي نكرهه. فقط لتلقي التفاعلات.

Interaction endpoints هو العكس. Discord يرسل POST على رابطك. ترد. ينتهي.

إذا تعطل سيرفرك؟ Discord يحاول 2-3 مرات ويمر إلى شيء آخر. لا دراما.

التكلفة قبل : 50€/شهر على Heroku فقط لإبقاء عملية Node حية.

التكلفة بعد : 0€/شهر على Cloudflare حتى 100k طلب/يوم.

البنية : ما هي بالضبط؟

Discord يرسل POST على نقطة النهاية الخاصة بك.

Discord: "مرحبًا! المستخدم نقر على /ping!"
      ↓
   رابطك (Cloudflare Worker)
      ↓
تتحقق أنه بالفعل Discord (التحقق من التوقيع)
      ↓
تحلل نوع التفاعل
      ↓
تنفذ المعالج
      ↓
ترجع JSON
      ↓
Discord: "رائع، سأعرض هذا للمستخدم"

إنها HTTP خالص. لا سحر. لا مكتبات ثقيلة.

Hono + Cloudflare Workers : التركيبة الاقتصادية

Hono هو إطار ويب وزنه 12KB. يعمل في كل مكان: Cloudflare Workers، Vercel، AWS Lambda، Deno، Bun... نفس الكود في كل مكان.

Cloudflare Workers هي حوسبة عند الحافة. طلباتك تصل إلى أقرب سيرفر. زمن الاستجابة: <100ms. التكلفة: مجاني حتى 100k طلب/يوم.

تركيبة Hono + Cloudflare هي المطابقة المثالية لبوت Discord.

هذا هو الكود الأدنى لبوت كامل:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. استرجاع الهيدرات
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. التحقق من أنه بالفعل Discord (ليس سبام)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. تحليل ما أرسله
  const interaction = JSON.parse(body);

  // 4. الرد حسب النوع
  if (interaction.type === 1) {
    // اختبار Discord (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // إنها slash command
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

يعني، 30 سطرًا وهذا بوت يعمل.

لا bot.login(). لا event emitter. لا callback hell. فقط HTTP.

للنشر على Cloudflare:

npm install -D wrangler
npx wrangler deploy

بوم. لديك رابط مثل https://mon-bot.workers.dev/interactions.

تضع هذا في Discord Developer Portal تحت "INTERACTIONS ENDPOINT URL"، وDiscord يبدأ بإرسال تفاعلاتك هناك.

التحقق من التوقيع : لا طلبات مزيفة

Discord يوقع كل طلب بمفتاح عمومي. إذا استقبلت طلبًا بتوقيع خاطئ؟ إنه سبام. تجاهل واستمر.

حزمة discord-interactions تقوم بالمهمة:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // النص الخام بالضبط (ليس JSON محلل!)
  signature,         // الهيدر x-signature-ed25519
  timestamp,         // الهيدر x-signature-timestamp
  publicKey          // من Discord Dev Portal
);

فخ مهم : التوقيع يعتمد على الـ body بالضبط. إذا حللت JSON ثم أعدت تحويله إلى نص، أو إذا سجلت الـ body، فسوف تكسر التوقيع.

تحقق أولاً. حلل بعد ذلك. هذا هو الترتيب المهم.

حالة 1 : Nibi (بوت تعلم اليابانية)

Nibi هو بوت Discord لتعلم اللغة اليابانية. أوامر بسيطة:

  • /dictionary kanji → يعرض التعريفات
  • /pronounce テキスト → يولد TTS (تحويل النص إلى كلام)
  • /hello → رسالة ترحيب

كل أمر عبارة عن ملف TypeScript:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

الأمر يطبق هذه الواجهة:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<interactionresponse>;
}

أمر /pronounce : جعل البوت يتكلم

هذا هو الأمر الغريب. ترسل نصًا (romaji، hiragana، kanji، أي شيء)، البوت يحوله إلى hiragana، يولد TTS عبر VOICEVOX أو Google TTS، ويرسل رسالة صوتية على Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'يولد TTS للنص الياباني',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'النص للنطق',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. تحويل romaji → hiragana باستخدام Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. توليد الصوت TTS
      const audioBuffer = await generateTTS(hiragana);

      // 3. رفع الملف إلى Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. إرسال الرسالة مع الصوت
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // المدة بالثواني
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `النطق لـ "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'خطأ: تعذر توليد الصوت xD',
          flags: 64  // ephemeral (رسالة خاصة)
        }
      };
    }
  }
};

إنه جنون: تستدعي API خارجي، ترفع ملفًا إلى Discord، ترسل رسالة مع الملف. كل هذا بدون WebSocket، فقط HTTP.

التخزين المستمر مع Supabase

Nibi يستخدم Supabase كمخزن key-value. للتحقق مما إذا كان المستخدم مسجلاً:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // إضافة المستخدم
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

هذا بسيط جدًا (لا استعلامات SQL حقيقية، مجرد JSON)، لكنه يعمل. للبوتات الصغيرة هذا ممتاز.

حالة 2 : Konosuba-RPG (لعبة Discord مع عرض صور)

حسنًا هذه مجنونة.

Konosuba-RPG هي لعبة كاملة على Discord. تحارب الوحوش، تكتسب XP، تجهز الإكسسوارات، ترتفع في المستوى. كل معركة تولد صورة في الوقت الفعلي. لا spritesheet محضّر مسبقًا. الصورة تُركب ديناميكيًا من إحصائيات اللاعب، والوحش، وحالة المعركة.

والصورة تُولد في <500ms على Cloudflare Workers. حرفيًا.

بنية العرض

Discord (تضغط "Attack")
    ↓
Cloudflare Worker يستقبل التفاعل
    ↓
تحديث حالة اللعبة (XP, HP, إلخ.)
    ↓
توليد JSX باستخدام Satori
    ↓
تحويل SVG → PNG باستخدام Resvg (Wasm)
    ↓
رفع الصورة إلى Discord
    ↓
إرسال الرسالة مع الصورة

كل هذا في أقل من ثانية. هذا مذهل.

عرض الصور على Workers

Konosuba يستخدم Satori (JSX → SVG) و Resvg (SVG → PNG):

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. إنشاء JSX للواجهة
  const jsx = (
    <div style="{{" display:="" 'flex',="" gap:="" '20px'="" }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

تكتب JSX عادي. يصبح SVG. SVG يصبح PNG. <100ms على Cloudflare Worker.

هل تستوعب القوة؟ إنه فقط... جميل xD

حالة اللعبة والتقدم

بيانات اللاعب موجودة في Supabase:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// اللاعب فاز
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

كل إجراء (هجوم، دفاع، علاج) يحدث الإحصائيات في قاعدة البيانات. ثم تعيد توليد الصورة مع الإحصائيات الجديدة.

التفاعلات : أزرار اللعب

اللعبة تستخدم تفاعلات الأزرار للإجراءات في المعركة:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (أزرق)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (رمادي)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

عندما تضغط "Attack"، Discord يرسل POST للتفاعل مع custom_id: 'battle_attack'. المعالج يوجه هذا:

if (interaction.type === 3) {
  // Component interaction (نقر زر، إلخ.)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

وبوم، تحسب الضرر، تحدث قاعدة البيانات، تعيد توليد الصورة، ترسل.

إنها لعبة كاملة تعتمد على الأدوار بدون أي استمرارية اتصال. مجرد HTTP بدون حالة. مكسور تمامًا xD

Supabase : قاعدة البيانات المصممة للـ Workers

قواعد البيانات التقليدية (PostgreSQL، MySQL، MongoDB) مصممة لاتصالات TCP مستمرة. تفتح socket، تبقي الاتصال مفتوحًا، ترسل استعلامات. المشكلة: Cloudflare Workers لا يدعم اتصالات TCP المستمرة. كل طلب هو عملية مؤقتة. بمجرد أن ترد على العميل، يختفي Worker.

لا يمكنك فعل هذا:

// هذا لا يعمل على Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // اتصال TCP مستمر = ميت

وحتى مشغلات PostgreSQL الأصلية مثل pg أو postgres.js تستخدم اتصالات TCP. على Workers، تتعطل.

Supabase يحل كل هذا.

Supabase هو REST API فوق PostgreSQL. تقوم باستعلامات HTTP عادية. كل استدعاء مستقل، لا اتصال مستمر، لا حالة لإدارتها. إنه متكيف تمامًا مع النموذج الـ serverless.

// هذا يعمل بشكل ممتاز على Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

عميل Supabase (@supabase/supabase-js) يستخدم fetch تحت الغطاء. و fetch أصلي على Workers. صفر إعداد، صفر مشغل، صفر اتصال مستمر.

قاعدة البيانات متوافقة مع Workers؟ لماذا
Supabase ✅ نعم REST API بدون حالة، HTTP خالص
PlanetScale (MySQL) ⚠️ جزئي اتصال HTTPS فقط، لا معاملات طويلة
Neon ⚠️ جزئي فروع serverless لكن مشغل TCP ضروري
Turso (libSQL) ⚠️ جزئي HTTP ممكن لكن محدود
Prisma/Prisma Postgres ❌ لا يحتاج TCP مستمر
MongoDB Atlas ❌ لا مشغل TCP، لا REST API أصلي
Redis (Upstash) ✅ نعم REST API HTTP

الميزة الحقيقية لـ Supabase ليست فقط قاعدة البيانات -- بل النظام البيئي بأكمله مصمم edge-first:

  • Auth : REST API لإدارة الجلسات، يعمل بدون حالة
  • Storage : رفع/تنزيل الملفات عبر HTTP
  • Realtime : WebSocket اختياري، لكن يمكنك أيضًا الاستعلام عبر REST
  • Row Level Security : قواعد الأمان في قاعدة البيانات، وليس في الـ backend الخاص بك

لبوت Discord serverless، Supabase هو الخيار الأبسط والأكثر موثوقية. لا مشغل لتكوينه، لا اتصال لصيانته، لا مهلات. مجرد استعلامات HTTP.

إذا أردت مثالًا حقيقيًا، انظر إلى Nibi أعلاه: كود التخزين المستمر هو حرفيًا readJson() و writeJson() على Supabase. لا ترحيلات، لا مخططات معقدة، لا إعداد مجنون. يعمل فورًا. وإذا أصبح بوتك كبيرًا، تهاجر إلى استعلامات SQL حقيقية دون تغيير المزود.

Polyfills : عندما يريد Node العمل على Workers

بعض الحزم تتوقع واجهات برمجة Node. Kuromoji (محلل kanji) يستخدم XMLHttpRequest. الـ Workers لديهم fetch، وليس XMLHttpRequest.

حل بسيط: إضافة polyfill في أعلى index.ts:

// Polyfill XMLHttpRequest لـ kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Stub أدنى
  } as any;
}

أو عمل وحدة مخصصة:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

إنه hack أساسي، لكنه يعمل.

نحو حزمة npm : hono-discord-interactions

يدويًا، عمل بوت يتطلب الكثير من الـ boilerplate:

  • تحقق من توقيع Discord
  • توجيه أنواع التفاعل
  • إدارة الأوامر، المكونات، النوافذ المنبثقة
  • إرجاع JSON صالح

يمكننا تجريد كل هذا في حزمة npm. مثل:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

بوم. 20 سطرًا بدلاً من 200. سيقلص Nibi إلى النصف بسهولة.

فكرة لوقت لاحق xD

النشر

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

الرابط الناتج: https://mon-bot.workers.dev/interactions

التكلفة: مجاني حتى 100k طلب/يوم. بعد ذلك: $0.50/مليون.

حرق: لن تنفق 100k طلب أبدًا إلا إذا كان لديك 10,000 مستخدم نشط.

Vercel

npm run vercel:deploy

الرابط: https://mon-bot-xyz.vercel.app/api/interactions

نفس الشيء، مجاني.

كليهما معًا

Hono يعمل في كل مكان. يمكنك نشر نفس الكود على Cloudflare AND Vercel. مفيد للتكرار أو الاختبار قبل الاختيار.

قائمة سريعة

  1. إنشاء تطبيق على Discord Developer Portal
  2. نسخ PUBLIC_KEY، BOT_TOKEN، APP_ID
  3. إنشاء المشروع:
  4. كتابة index.ts (تحقق التوقيع + التوجيه)
  5. تسجيل الـ slash commands (مرة واحدة):
  6. النشر:
  7. وضع الرابط في Discord (Developer Portal ← Application ← Interactions Endpoint URL)
  8. Discord يختبر الاتصال (يجب الرد على PING)
  9. دعوة البوت إلى سيرفر
  10. تم الأمور

المزايا والعيوب

المزايا

  • غير مكلف (مجاني حتى 100k طلب/يوم)
  • قابل للتوسع (لا إدارة اتصال)
  • بسيط (لا boilerplate لـ WebSocket)
  • سريع (Cloudflare = سيرفرات عند الحافة)
  • محمول (كود Huno = عدة مستضيفين)

العيوب

  • لا أحداث سيرفر في الوقت الفعلي (عضو ينضم، دور يضاف، رسالة تُحذف، إلخ.) -- تستقبل فقط التفاعلات (slash commands، أزرار، نوافذ منبثقة)
  • مهلة 3 ثوانٍ للرد -- وإلا Discord يعرض "Application did not respond"
  • إذا احتجت أحداثًا حقيقية -- تحتاج webhook HTTP منفصل أو اتصال WebSocket مساعد

لـ 90% من البوتات (كلها مبنية على slash commands)؟ هذا كافٍ.

للختام

قضيت وقتًا لا بأس به في تحسين KonosubaRPG و Nibi لتوفير أكبر عدد ممكن من الطلبات، أو لتقليل وقت المعالجة الساخنة، أو لتقليل boot البارد. النتيجة، لدي أداء رائع في كل شيء تقريبًا. يجب أن تعلم أنني بدأت في نقل (لا أعرف حتى إذا كانت الكلمة صحيحة) معظم مشاريعي إلى السحابة لأنني كنت أكسل بشكل هائل عن الاستمرار في استضافتها على جهاز VM الخاص بي. حقًا، أعتقد أن Github Actions هي التي أنقذت جلدي. الـ Workers أيضًا، لكن في الحقيقة عندما رأيت أنه يمكنني عمل daemons مع Github Actions والجداول الزمنية، هذا أنقذني حقًا.

سأكتب على الأرجح مقالة عن مشروع اسمه email-autoreply، لذا اشتركوا في خلاصة RSS لرؤيتها قريبًا :)). x 3 أشياء يجب تذكرها:

  1. Interaction endpoints = HTTP serverless -- لا WebSocket، لا اتصال مستمر. Discord يرسل POST، ترد. مجاني على Cloudflare.
  2. Hono هو الأداة المثالية -- إطار خفيف (12KB)، متعدد بيئات التشغيل، صفر تبعيات. كود متطابق على Cloudflare، Vercel، Node، في كل مكان.
  3. عرض الصور على Workers = جنون -- Satori + Resvg (Wasm) تسمح لك بتركيب واجهات ديناميكية في JSX وتحويلها إلى PNG في <100ms. لعبة كاملة يمكنها العمل على هذا.

إنه مكسور xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

Discord bot 100% serverless : Hono + Cloudflare Workers

Cách tôi thay thế một bot Discord tốn 50€/tháng bằng

Discord bot 100% serverless : Hono + Cloudflare Workers = 💸 zero

Tôi đã dành vài tháng để duy trì các bot Discord truyền thống trên máy cá nhân.

Kết nối WebSocket luôn mở. Bot tự kết nối lại lúc 3 giờ sáng. Bot crash vì tôi nhìn cừu không đúng cách. Hóa đơn tăng dần.

Một ngày tôi phát hiện: tại sao phải duy trì một kết nối? Discord có thể POST chỉ những thứ bạn quan tâm. Bạn trả lời, xong.

Từ 2021, Discord đã cung cấp interaction endpoints.

Chỉ là HTTP. Không WebSocket. Không trạng thái liên tục. Bạn nhận một request, bạn gửi JSON, xong. Request tiếp theo tự đến.

Và hay nhất: Cloudflare Workers miễn phí đến 100k request/ngày. Với 90% bot, đó là 0€/tháng.

Bài viết này sẽ chỉ bạn cách làm một bot Discord không WebSocket dùng Hono (web framework siêu nhẹ) và Cloudflare Workers. Tôi sẽ giới thiệu hai dự án thực tế: Nibi (bot học tiếng Nhật, TTS, ngầu) và Konosuba-RPG (một game Discord hoàn chỉnh với render ảnh thời gian thực xD).

WebSocket vs. Interaction Endpoints : tại sao đó là ý tưởng tồi

Hãy tưởng tượng một game Minecraft nơi bạn phải giữ kết nối mở ngay cả khi không chơi.

Và server tự động kết nối lại mỗi khi nó crash. Bạn phải xử lý timeout, kết nối lại lũy thừa, tất cả boilerplate chết tiệt mà ai cũng ghét. Chỉ để nhận interactions.

Interaction endpoints thì ngược lại. Discord POST vào URL của bạn. Bạn trả lời. Xong.

Nếu server của bạn crash? Discord thử lại 2-3 lần rồi bỏ qua. Zero drama.

Chi phí trước : 50€/tháng trên Heroku chỉ để giữ một tiến trình Node sống.

Chi phí sau : 0€/tháng trên Cloudflare đến 100k request/ngày.

Kiến trúc : nó thực sự là gì ?

Discord POST một request đến endpoint của bạn.

Discord: "Này! Người dùng đã bấm /ping!"
      ↓
   URL của bạn (Cloudflare Worker)
      ↓
   Bạn kiểm tra xem có thực sự là Discord không (kiểm tra chữ ký)
      ↓
   Bạn phân tích loại interaction
      ↓
   Bạn thực thi handler
      ↓
   Bạn trả về JSON
      ↓
   Discord: "Tốt, tôi sẽ hiển thị cái này cho người dùng"

Thuần HTTP. Không ma thuật. Không thư viện nặng.

Hono + Cloudflare Workers : combo tiết kiệm

Hono là web framework chỉ nặng 12KB. Nó chạy được ở mọi nơi: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... cùng một code ở mọi nơi.

Cloudflare Workers là compute tại edge. Request của bạn đến server gần nhất. Thời gian phản hồi: <100ms. Chi phí: miễn phí đến 100k request/ngày.

Combo Hono + Cloudflare là sự kết hợp hoàn hảo cho một bot Discord.

Đây là code tối thiểu của một bot hoàn chỉnh:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Lấy headers
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. Xác minh đó thực sự là Discord (không phải spam)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Parse nội dung gửi đến
  const interaction = JSON.parse(body);

  // 4. Trả lời theo loại
  if (interaction.type === 1) {
    // Discord test (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // Đây là slash command
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

Kiểu, 30 dòng và đó là một bot hoạt động.

Không có bot.login(). Không event emitter. Không callback hell. Chỉ HTTP.

Để deploy lên Cloudflare:

npm install -D wrangler
npx wrangler deploy

Boom. Bạn có một URL kiểu https://mon-bot.workers.dev/interactions.

Bạn đặt nó trong Discord Developer Portal dưới "INTERACTIONS ENDPOINT URL", và Discord bắt đầu gửi interactions của bạn đến đó.

Xác minh chữ ký : không có fake requests

Discord ký mỗi request bằng public key. Nếu bạn nhận request với chữ ký sai? Đó là spam. Bỏ qua và tiếp tục.

Package discord-interactions làm việc đó:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // văn bản thô chính xác (không phải JSON đã parse!)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // từ Discord Dev Portal
);

Bẫy quan trọng : chữ ký phụ thuộc vào body chính xác. Nếu bạn parse JSON rồi stringify lại, hoặc nếu bạn log body, bạn làm hỏng chữ ký.

Xác minh trước. Parse sau. Thứ tự mới là quan trọng.

Case 1 : Nibi (bot học tiếng Nhật)

Nibi là bot Discord để học tiếng Nhật. Các lệnh đơn giản:

  • /dictionary kanji → hiển thị định nghĩa
  • /pronounce テキスト → tạo TTS (text-to-speech)
  • /hello → tin nhắn chào mừng

Mỗi lệnh là một file TypeScript:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

Một lệnh implement interface này:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<interactionresponse>;
}

Lệnh /pronounce : bắt bot nói chuyện

Đây là lệnh khá kỳ lạ. Bạn gửi văn bản (romaji, hiragana, kanji, bất cứ thứ gì), bot chuyển đổi nó thành hiragana, tạo TTS qua VOICEVOX hoặc Google TTS, và gửi tin nhắn âm thanh lên Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Tạo TTS cho văn bản tiếng Nhật',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Văn bản cần phát âm',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. Chuyển romaji → hiragana với Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. Tạo audio TTS
      const audioBuffer = await generateTTS(hiragana);

      // 3. Upload file lên Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Gửi tin nhắn kèm audio
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // thời gian tính bằng giây
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `Phát âm cho "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'Lỗi : không thể tạo audio xD',
          flags: 64  // ephemeral (tin nhắn riêng tư)
        }
      };
    }
  }
};

Thật điên rồ: bạn gọi API bên ngoài, upload file lên Discord, gửi tin nhắn kèm file. Tất cả không cần WebSocket, chỉ HTTP.

Lưu trữ với Supabase

Nibi sử dụng Supabase như key-value store. Để kiểm tra người dùng đã đăng ký chưa:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // Thêm người dùng
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

Rất cơ bản (không có truy vấn SQL thực sự, chỉ JSON), nhưng nó hoạt động. Với bot nhỏ thì hoàn hảo.

Case 2 : Konosuba-RPG (game Discord với render ảnh)

Okay cái này điên rồ đây.

Konosuba-RPG là một game hoàn chỉnh trên Discord. Bạn chiến đấu với quái, kiếm XP, trang bị phụ kiện, lên cấp. Mỗi trận chiến tạo ra một ảnh thời gian thực. Không spritesheet được render sẵn. Ảnh được tạo động từ chỉ số người chơi, quái vật, và trạng thái chiến đấu.

Và ảnh được tạo trong <500ms trên Cloudflare Workers. Thật đấy.

Kiến trúc render

Discord (bạn bấm "Attack")
    ↓
Cloudflare Worker nhận interaction
    ↓
Cập nhật game state (XP, HP, v.v.)
    ↓
Tạo JSX với Satori
    ↓
Chuyển đổi SVG → PNG với Resvg (Wasm)
    ↓
Upload ảnh lên Discord
    ↓
Gửi tin nhắn kèm ảnh

Tất cả trong chưa đầy một giây. Thật kinh ngạc.

Render ảnh trên Workers

Konosuba sử dụng Satori (JSX → SVG) và Resvg (SVG → PNG):

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. Tạo JSX cho UI
  const jsx = (
    <div style="{{" display:="" 'flex',="" gap:="" '20px'="" }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

Bạn viết JSX bình thường. Nó thành SVG. SVG thành PNG. <100ms trên Cloudflare Worker.

Bạn thấy sức mạnh chưa? Nó chỉ là... đẹp xD

Game state và tiến trình

Dữ liệu người chơi được lưu trong Supabase:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// Người chơi đã thắng
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

Mỗi hành động (tấn công, phòng thủ, hồi máu) cập nhật chỉ số trong database. Và sau đó bạn render lại ảnh với chỉ số mới.

Interactions : các nút gameplay

Game sử dụng button interactions cho các hành động trong chiến đấu:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (xanh dương)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (xám)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

Khi bạn bấm "Attack", Discord POST một interaction với custom_id: 'battle_attack'. Handler route nó:

if (interaction.type === 3) {
  // Component interaction (bấm nút, v.v.)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

Và boom, bạn tính sát thương, cập nhật database, render lại ảnh, gửi đi.

Đó là một game turn-based hoàn chỉnh không cần bất kỳ kết nối liên tục nào. Chỉ HTTP stateless. Hoàn toàn điên rồ xD

Supabase : DB dành cho Workers

Các database truyền thống (PostgreSQL, MySQL, MongoDB) được thiết kế cho kết nối TCP liên tục. Bạn mở socket, giữ kết nối, gửi truy vấn. Vấn đề: Cloudflare Workers không hỗ trợ kết nối TCP liên tục. Mỗi request là một tiến trình tạm thời. Ngay khi bạn trả lời client, Worker biến mất.

Bạn không thể làm thế này:

// Cái này KHÔNG chạy trên Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // kết nối TCP liên tục = chết

Và ngay cả driver PostgreSQL gốc như pg hay postgres.js cũng dùng kết nối TCP. Trên Workers, chúng bị crash.

Supabase giải quyết tất cả.

Supabase là REST API trên nền PostgreSQL. Bạn thực hiện các request HTTP thông thường. Mỗi lần gọi độc lập, không cần kết nối liên tục, không cần quản lý trạng thái. Hoàn toàn phù hợp với mô hình serverless.

// Cái này chạy HOÀN HẢO trên Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Client Supabase (@supabase/supabase-js) sử dụng fetch bên dưới. Và fetch là native trên Workers. Zero cấu hình, zero driver, zero kết nối liên tục.

Database Tương thích Workers ? Tại sao
Supabase ✅ Có REST API không trạng thái, HTTP thuần
PlanetScale (MySQL) ⚠️ Một phần Chỉ kết nối HTTPS, không có transaction dài
Neon ⚠️ Một phần Branch serverless nhưng cần driver TCP
Turso (libSQL) ⚠️ Một phần HTTP khả thi nhưng giới hạn
Prisma/Prisma Postgres ❌ Không Cần TCP liên tục
MongoDB Atlas ❌ Không Driver TCP, không có REST API gốc
Redis (Upstash) ✅ Có REST API HTTP

Lợi thế thực sự của Supabase không chỉ là DB -- mà là toàn bộ hệ sinh thái được thiết kế edge-first:

  • Auth : REST API để quản lý session, hoạt động không trạng thái
  • Storage : Upload/download file qua HTTP
  • Realtime : WebSocket tùy chọn, nhưng bạn cũng có thể poll qua REST
  • Row Level Security : rules bảo mật nằm trong DB, không phải backend

Với bot Discord serverless, Supabase là lựa chọn đơn giản và đáng tin cậy nhất. Không cần cấu hình driver, không cần duy trì kết nối, không có timeout. Chỉ các request HTTP.

Nếu bạn muốn một ví dụ thực tế, hãy xem Nibi ở trên: code lưu trữ chỉ đơn giản là readJson() và writeJson() trên Supabase. Không migration, không schema phức tạp, không cấu hình gì cả. Chạy ngay. Và nếu bot của bạn lớn lên, bạn có thể chuyển sang truy vấn SQL thực sự mà không cần đổi provider.

Polyfills : khi Node muốn chạy trên Workers

Một số package mong đợi API Node. Kuromoji (parser kanji) sử dụng XMLHttpRequest. Workers có fetch, không phải XMLHttpRequest.

Giải pháp đơn giản: thêm polyfill ở đầu index.ts:

// Polyfill XMLHttpRequest cho kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Stub tối thiểu
  } as any;
}

Hoặc tạo một module riêng:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

Đó là hack cơ bản, nhưng nó hoạt động.

Hướng tới một package npm : hono-discord-interactions

Làm thủ công, tạo một bot là rất nhiều boilerplate:

  • Xác minh chữ ký Discord
  • Route các loại interaction
  • Xử lý commands, components, modals
  • Trả về JSON hợp lệ

Chúng ta có thể trừu tượng hóa tất cả vào một package npm. Kiểu:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `Hi ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

Bùm. 20 dòng thay vì 200. Nó sẽ giảm Nibi xuống một nửa dễ dàng.

Ý tưởng để sau xD

Deploy

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

URL kết quả: https://mon-bot.workers.dev/interactions

Chi phí: miễn phí đến 100k request/ngày. Quá mức: $0.50/triệu.

Spoiler: bạn sẽ không bao giờ dùng hết 100k request trừ khi bạn có 10.000 người dùng hoạt động.

Vercel

npm run vercel:deploy

URL: https://mon-bot-xyz.vercel.app/api/interactions

Tương tự, miễn phí.

Cả hai cùng lúc

Hono chạy được ở mọi nơi. Bạn có thể deploy cùng một code lên cả Cloudflare VÀ Vercel. Hữu ích cho dự phòng hoặc thử nghiệm trước khi chọn.

Checklist nhanh

  1. Tạo Application trên Discord Developer Portal
  2. Sao chép PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Tạo project:
  4. Viết index.ts (xác minh chữ ký + routing)
  5. Đăng ký slash commands (một lần):
  6. Deploy:
  7. Đặt URL trong Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord kiểm tra kết nối (bạn phải trả lời PING)
  9. Mời bot vào server
  10. Xong

Ưu điểm vs Hạn chế

Ưu điểm

  • Rẻ (miễn phí đến 100k req/ngày)
  • Co giãn (không cần quản lý kết nối)
  • Đơn giản (không boilerplate WebSocket)
  • Nhanh (Cloudflare = server tại edge)
  • Di động (code Hono = nhiều host)

Hạn chế

  • Không có sự kiện server thời gian thực (thành viên tham gia, role được thêm, tin nhắn bị xóa, v.v.) -- bạn chỉ nhận được interactions (slash commands, buttons, modals)
  • Timeout 3 giây để trả lời -- nếu không Discord hiển thị "Application did not respond"
  • Nếu bạn cần sự kiện thực sự -- cần một webhook HTTP riêng hoặc kết nối WebSocket phụ trợ

Với 90% bot (tất cả dựa trên slash commands)? Ổn cả.

Kết luận

Tôi đã dành khá nhiều thời gian để tối ưu KonosubaRPG và Nibi nhằm tiết kiệm càng nhiều request càng tốt, hoặc giảm thời gian xử lý nóng, hoặc giảm boot cold. Kết quả là tôi có hiệu suất khá ấn tượng trên hầu hết mọi thứ. Cần biết rằng tôi đã bắt đầu cloud hóa (tôi cũng không biết từ này có tồn tại không) phần lớn các dự án của mình vì tôi cực kỳ lười tiếp tục tự host chúng trên VM cá nhân. Thực sự, tôi nghĩ chính Github Actions đã cứu tôi. Workers cũng vậy, nhưng thực ra khi tôi thấy mình có thể tạo daemon với Github Actions và schedule, điều đó thực sự đã cứu tôi.

Tôi có lẽ sẽ viết một bài về dự án tên là email-autoreply, vì vậy hãy đăng ký theo dõi RSS để xem nó ra mắt sắp tới :)).

3 điều cần nhớ:

  1. Interaction endpoints = HTTP serverless -- Không WebSocket, không kết nối liên tục. Discord POST, bạn trả lời. Miễn phí trên Cloudflare.
  2. Hono là công cụ hoàn hảo -- Framework nhẹ (12KB), đa runtime, zero dependencies. Code giống hệt trên Cloudflare, Vercel, Node, mọi nơi.
  3. Render ảnh trên Workers = điên rồ -- Satori + Resvg (Wasm) cho phép bạn tạo UI động bằng JSX và chuyển đổi thành PNG trong <100ms. Một game hoàn chỉnh có thể chạy trên nó.

Thật bệnh hoạn xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

Discord bot 100% serverless : Hono + Cloudflare Workers

วิธีที่ผมเปลี่ยน Discord bot ที่เสียค่าใช้จ่าย 50€/เดือน ให้เหลือศูนย์ยูโร --

Discord bot 100% serverless : Hono + Cloudflare Workers = 💸 ศูนย์

ผมใช้เวลาหลายเดือนในการดูแล Discord bot ทั่วไปบนเครื่องของตัวเอง

การเชื่อมต่อ WebSocket เปิดตลอดเวลา บอท reconnect เองตอนตีสาม บอท crash เพราะผมมองแกะผิดทาง ค่าใช้จ่ายพุ่ง

วันหนึ่งผมค้นพบ: ทำไมต้องรักษาการเชื่อมต่อ ? Discord สามารถ POST เฉพาะสิ่งที่คุณสนใจได้ คุณตอบกลับ ก็จบ

ตั้งแต่ปี 2021 Discord มี interaction endpoints

มันคือ HTTP ล้วน ๆ ไม่มี WebSocket ไม่มีสถานะคงที่ คุณได้รับ request ส่ง JSON กลับไป ก็จบ request ถัดไปมาก็เอง

และที่เจ๋งที่สุด: Cloudflare Workers ฟรี สูงถึง 100k requests/วัน สำหรับ 90% ของบอททั้งหมด นั่นคือ 0€/เดือน

บทความนี้จะแสดงวิธีทำ Discord bot โดยไม่ใช้ WebSocket โดยใช้ Hono (เว็บเฟรมเวิร์กที่เบามาก) และ Cloudflare Workers ผมจะพาคุณดูโปรเจกต์จริงสองโปรเจกต์: Nibi (บอทเรียนภาษาญี่ปุ่น, TTS, เจ๋ง) และ Konosuba-RPG (เกม Discord เต็มรูปแบบ พร้อมเรนเดอร์ภาพแบบเรียลไทม์ xD)

WebSocket vs. Interaction Endpoints : ทำไมมันถึงเป็นความคิดที่ไม่ดี

ลองนึกภาพเกม Minecraft ที่คุณต้องรักษาการเชื่อมต่อไว้ตลอดเวลาแม้ตอนคุณไม่ได้เล่น

และเซิร์ฟเวอร์ reconnect อัตโนมัติทุกครั้งที่มัน crash คุณต้องจัดการ timeouts, exponential backoff, boilerplate ที่น่าเบื่อทั้งหมดที่เราเกลียด เพียงแค่เพื่อรับ interactions

Interaction endpoints คือสิ่งที่ตรงกันข้าม Discord POST ไปที่ URL ของคุณ คุณตอบกลับ ก็จบ

ถ้าเซิร์ฟเวอร์คุณล่ม ? Discord retry 2-3 ครั้งแล้วข้ามไป ไม่มีดราม่า

ค่าใช้จ่ายก่อน : 50€/เดือนบน Heroku เพียงเพื่อให้ process Node อยู่รอด

ค่าใช้จ่ายหลัง : 0€/เดือนบน Cloudflare สูงถึง 100k requests/วัน

สถาปัตยกรรม : มันคืออะไรกันแน่ ?

Discord POST request ไปที่ endpoint ของคุณ

Discord: "เฮ้! ผู้ใช้คลิก /ping!"
      ↓
   URL ของคุณ (Cloudflare Worker)
      ↓
   คุณตรวจสอบว่ามันคือ Discord จริงไหม (ตรวจสอบลายเซ็น)
      ↓
   คุณแยกประเภทของ interaction
      ↓
   คุณ execute handler
      ↓
   คุณคืนค่า JSON
      ↓
Discord: "เยี่ยม, ฉันจะแสดงสิ่งนั้นให้ผู้ใช้"

มันคือ HTTP บริสุทธิ์ ไม่มีเวทมนตร์ ไม่มี libraire ที่หนัก

Hono + Cloudflare Workers : คอมโบที่ประหยัด

Hono คือเว็บเฟรมเวิร์กที่หนัก 12KB มันทำงานได้ทุกที่: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... โค้ดเดียวกันทุกที่

Cloudflare Workers คือการประมวลผลที่ edge request ของคุณไปถึงเซิร์ฟเวอร์ที่ใกล้ที่สุด เวลาตอบสนอง: <100ms ค่าใช้จ่าย: ฟรีสูงถึง 100k requests/วัน

คอมโบ Hono + Cloudflare คือคู่ที่สมบูรณ์แบบสำหรับ Discord bot

นี่คือโค้ดขั้นต่ำของบอทที่สมบูรณ์:

import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. ดึง headers
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. ตรวจสอบว่ามันคือ Discord จริง (ไม่ใช่สแปม)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. แยกข้อมูลที่ส่งมา
  const interaction = JSON.parse(body);

  // 4. ตอบกลับตามประเภท
  if (interaction.type === 1) {
    // Discord test (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // มันคือ slash command
    const name = interaction.data.name;
    if (name === 'ping') {
      return c.json({
        type: 4,
        data: { content: 'Pong!' }
      });
    }
  }

  return c.json({ type: 4, data: { content: 'Unknown command' } });
});

export default app;

แค่ 30 บรรทัดและมันคือบอทที่ใช้งานได้

ไม่จำเป็นต้อง bot.login() ไม่มี event emitter ไม่มี callback hell แค่ HTTP

สำหรับ deploy บน Cloudflare:

npm install -D wrangler
npx wrangler deploy

บูม คุณมี URL แบบ https://mon-bot.workers.dev/interactions

ใส่ URL นั้นใน Discord Developer Portal ภายใต้ "INTERACTIONS ENDPOINT URL" แล้ว Discord จะเริ่มส่ง interactions ของคุณไปที่นั่น

ตรวจสอบลายเซ็น : ไม่มี requests ปลอม

Discord เซ็นทุก request ด้วย public key ถ้าคุณได้รับ request ที่มีลายเซ็นผิด ? นั่นคือสแปม ไม่สนใจแล้วไปต่อ

แพ็กเกจ discord-interactions จัดการให้:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // ข้อความดิบที่แน่นอน (ไม่ใช่ JSON ที่ parse แล้ว!)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // จาก Discord Dev Portal
);

ข้อผิดพลาดสำคัญ : ลายเซ็นขึ้นอยู่กับ body ที่แน่นอน ถ้าคุณ parse JSON แล้ว re-stringify, หรือ log body, คุณจะทำลายลายเซ็น

ตรวจสอบก่อน Parse ทีหลัง นั่นคือลำดับที่สำคัญ

กรณีที่ 1 : Nibi (บอทเรียนภาษาญี่ปุ่น)

Nibi คือ Discord bot สำหรับเรียนภาษาญี่ปุ่น คำสั่งง่าย ๆ:

  • /dictionary kanji → แสดงคำจำกัดความ
  • /pronounce テキスト → สร้าง TTS (text-to-speech)
  • /hello → ข้อความต้อนรับ

แต่ละคำสั่งคือไฟล์ TypeScript:

src/commands/
├── dictionary.ts
├── pronounce.ts
├── hello.ts
└── ...

แต่ละคำสั่ง implement interface นี้:

interface Command {
  data: {
    name: string;
    description: string;
    options?: SlashCommandOption[];
  };
  execute(
    interaction: Interaction,
    env: Bindings
  ): Promise<InteractionResponse>;
}

คำสั่ง /pronounce : ทำให้บอทพูดได้

อันนี้ค่อนข้างแปลก คุณส่งข้อความ (romaji, hiragana, kanji, อะไรก็ได้) บอทแปลงเป็น hiragana, สร้าง TTS ผ่าน VOICEVOX หรือ Google TTS, และส่งข้อความเสียงบน Discord

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'สร้าง TTS สำหรับข้อความภาษาญี่ปุ่น',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'ข้อความที่จะออกเสียง',
        required: true
      }
    ]
  },

  async execute(interaction, env) {
    const text = interaction.data.options[0].value;

    try {
      // 1. แปลง romaji → hiragana ด้วย Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. สร้างไฟล์เสียง TTS
      const audioBuffer = await generateTTS(hiragana);

      // 3. อัปโหลดไฟล์ไปยัง Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. ส่งข้อความพร้อมเสียง
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // ความยาวเป็นวินาที
        env.BOT_TOKEN
      );

      return {
        type: 4,
        data: { content: `การออกเสียงสำหรับ "${text}"` }
      };
    } catch (err) {
      return {
        type: 4,
        data: {
          content: 'ข้อผิดพลาด: ไม่สามารถสร้างเสียงได้ xD',
          flags: 64  // ephemeral (ข้อความส่วนตัว)
        }
      };
    }
  }
};

มันบ้ามาก: คุณเรียก API ภายนอก, อัปโหลดไฟล์ไปยัง Discord, ส่งข้อความพร้อมไฟล์ ทั้งหมดนี้ไม่มี WebSocket, แค่ HTTP

Persistence ด้วย Supabase

Nibi ใช้ Supabase เป็น key-value store เพื่อตรวจสอบว่าผู้ใช้ลงทะเบียนแล้วหรือไม่:

const DatabaseUtils = new DatabaseUtils({
  SUPABASE_URL: env.SUPABASE_URL,
  SUPABASE_ANON_KEY: env.SUPABASE_ANON_KEY
});

const users = await DatabaseUtils.readJson('users');
const user = users.find(u => u.id === interaction.member.user.id);

if (!user) {
  // เพิ่มผู้ใช้
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

มันง่ายมาก (ไม่ใช่ SQL query จริง ๆ, แค่ JSON), แต่มันใช้งานได้ สำหรับบอทเล็ก ๆ มันสมบูรณ์แบบ

กรณีที่ 2 : Konosuba-RPG (เกม Discord พร้อมเรนเดอร์ภาพ)

โอเค อันนี้บ้ามาก

Konosuba-RPG คือ เกมเต็มรูปแบบ บน Discord คุณสู้กับ mobs, ได้ XP, สวมใส่อุปกรณ์, เพิ่มเลเวล ทุก battle สร้าง ภาพ แบบเรียลไทม์ ไม่มี spritesheet ที่ pre-render ไว้ล่วงหน้า ภาพถูกประกอบแบบไดนามิกจาก stats ของผู้เล่น, mob, และสถานะการต่อสู้

และภาพถูกสร้างใน <500ms บน Cloudflare Workers จริง ๆ นะ

สถาปัตยกรรมการเรนเดอร์

Discord (คุณคลิก "Attack")
    ↓
Cloudflare Worker รับ interaction
    ↓
อัปเดต game state (XP, HP, ฯลฯ)
    ↓
สร้าง JSX ด้วย Satori
    ↓
แปลง SVG → PNG ด้วย Resvg (Wasm)
    ↓
อัปโหลดภาพไปยัง Discord
    ↓
ส่งข้อความพร้อมภาพ

ทั้งหมดนี้ในเวลาน้อยกว่าหนึ่งวินาที มันสุดยอดมาก

การเรนเดอร์ภาพบน Workers

Konosuba ใช้ Satori (JSX → SVG) และ Resvg (SVG → PNG):

import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

async function renderBattle(gameState: GameState) {
  const resvg = await initWasm();

  // 1. สร้าง JSX สำหรับ UI
  const jsx = (
    <div style="{{" display:="" 'flex',="" gap:="" '20px'="" }}>
      <div>
        <h1>{gameState.player.name}</h1>
        <p>HP: {gameState.player.hp}/{gameState.player.maxHp}</p>
      </div>
      <div>
        <h1>{gameState.enemy.name}</h1>
        <p>HP: {gameState.enemy.hp}/{gameState.enemy.maxHp}</p>
      </div>
    </div>
  );

  // 2. JSX → SVG
  const svg = await satori.render(jsx, {
    width: 1200,
    height: 800,
    fonts: [/* ... */]
  });

  // 3. SVG → PNG
  const png = resvg.render(svg).asPng();

  return png;  // Uint8Array
}

คุณเขียน JSX ปกติ มันกลายเป็น SVG SVG กลายเป็น PNG <100ms บน Cloudflare Worker

คุณเห็นพลังไหม ? มันแค่... สวยงาม xD

Game state และความก้าวหน้า

ข้อมูลของผู้เล่นอยู่ใน Supabase:

const player = await db
  .from('players')
  .select('*')
  .eq('discord_id', interaction.user.id)
  .single();

// ผู้เล่นชนะ
const { data: updated } = await db
  .from('players')
  .update({
    level: player.level + 1,
    xp: player.xp + xpGain
  })
  .eq('id', player.id);

ทุกการกระทำ (โจมตี, ป้องกัน, รักษา) อัปเดต stats ในฐานข้อมูล จากนั้นคุณสร้างภาพใหม่ด้วย stats ที่อัปเดต

Interactions : ปุ่มของ gameplay

เกมใช้ button interactions สำหรับการกระทำในการต่อสู้:

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (สีน้ำเงิน)
      label: 'Attack',
      custom_id: 'battle_attack'
    },
    {
      type: 2,
      style: 2,  // Secondary (สีเทา)
      label: 'Defend',
      custom_id: 'battle_defend'
    }
  ]
}

เมื่อคุณคลิก "Attack", Discord POST interaction ด้วย custom_id: 'battle_attack' handler จะ route ไปที่:

if (interaction.type === 3) {
  // Component interaction (button click, ฯลฯ)
  const customId = interaction.data.custom_id;

  if (customId === 'battle_attack') {
    return await handleAttack(interaction, env);
  }
  if (customId === 'battle_defend') {
    return await handleDefend(interaction, env);
  }
}

แล้วบูม, คุณคำนวณดาเมจ, อัปเดตฐานข้อมูล, สร้างภาพใหม่, ส่งออกไป

มันคือเกม turn-based ที่สมบูรณ์แบบโดยไม่ต้องมีการเชื่อมต่อแบบถาวรเลย แค่ HTTP stateless บ้ามาก xD

Supabase : DB ที่ออกแบบมาสำหรับ Workers

ฐานข้อมูลทั่วไป (PostgreSQL, MySQL, MongoDB) ถูกออกแบบมาสำหรับการเชื่อมต่อ TCP แบบถาวร คุณเปิด socket, รักษาการเชื่อมต่อไว้, ส่ง queries ปัญหาคือ: Cloudflare Workers ไม่รองรับการเชื่อมต่อ TCP แบบถาวร แต่ละ request คือ process ที่เกิดขึ้นชั่วคราว ทันทีที่คุณตอบกลับ client, Worker จะหายไป

คุณทำแบบนี้ไม่ได้:

// สิ่งนี้ใช้ไม่ได้บน Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // การเชื่อมต่อ TCP แบบถาวร = ตาย

และแม้แต่ native PostgreSQL drivers อย่าง pg หรือ postgres.js ก็ใช้การเชื่อมต่อ TCP บน Workers พวกมันพัง

Supabase แก้ปัญหาทั้งหมดนี้

Supabase คือ REST API ที่อยู่บน PostgreSQL คุณทำ HTTP requests ปกติ แต่ละการเรียกเป็นอิสระ ไม่มีการเชื่อมต่อถาวร ไม่มีสถานะที่ต้องจัดการ มันเหมาะสมกับโมเดล serverless อย่างสมบูรณ์แบบ

// สิ่งนี้ใช้ได้อย่างสมบูรณ์แบบบน Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Client Supabase (@supabase/supabase-js) ใช้ fetch ภายใต้ฝาครอบ และ fetch เป็น native บน Workers ไม่ต้อง config, ไม่ต้อง driver, ไม่ต้องเชื่อมต่อถาวร

ฐานข้อมูล เข้ากันได้กับ Workers ? เหตุผล
Supabase ✅ ใช่ REST API ไม่มีสถานะ, HTTP ล้วน ๆ
PlanetScale (MySQL) ⚠️ บางส่วน การเชื่อมต่อ HTTPS เท่านั้น, ไม่มี transactions ยาว
Neon ⚠️ บางส่วน serverless branches แต่ต้องใช้ TCP driver
Turso (libSQL) ⚠️ บางส่วน HTTP เป็นไปได้แต่จำกัด
Prisma/Prisma Postgres ❌ ไม่ ต้องใช้ TCP แบบถาวร
MongoDB Atlas ❌ ไม่ TCP driver, ไม่มี REST API พื้นฐาน
Redis (Upstash) ✅ ใช่ REST API HTTP

ข้อได้เปรียบที่แท้จริงของ Supabase ไม่ใช่แค่ DB -- มันคือระบบนิเวศทั้งหมดที่ถูกออกแบบมาให้ edge-first:

  • Auth : REST API สำหรับจัดการ sessions, ทำงานแบบไม่มีสถานะ
  • Storage : อัปโหลด/ดาวน์โหลดไฟล์ผ่าน HTTP
  • Realtime : WebSocket เป็นตัวเลือก, แต่คุณสามารถ poll ผ่าน REST ได้เช่นกัน
  • Row Level Security : กฎความปลอดภัยอยู่ใน DB, ไม่ใช่ใน backend ของคุณ

สำหรับ Discord bot แบบ serverless, Supabase คือตัวเลือกที่ง่ายและน่าเชื่อถือที่สุด ไม่ต้องตั้งค่า driver, ไม่ต้องรักษาการเชื่อมต่อ, ไม่ต้องกังวล timeouts แค่ HTTP requests

ถ้าคุณต้องการตัวอย่างจริง, ดู Nibi ด้านบน: โค้ด persistence คือแค่ readJson() และ writeJson() บน Supabase ไม่ต้อง migrations, ไม่ต้อง schemas ซับซ้อน, ไม่ต้อง config เยอะ มันทำงานได้ทันที และถ้าบอทคุณใหญ่ขึ้น, คุณย้ายไปใช้ SQL queries จริงโดยไม่ต้องเปลี่ยน provider

Polyfills : เมื่อ Node ต้องการทำงานบน Workers

บางแพ็กเกจคาดหวัง Node APIs Kuromoji (parser คันจิ) ใช้ XMLHttpRequest Workers มี fetch, ไม่มี XMLHttpRequest

วิธีแก้ simples: เพิ่ม polyfill ที่ด้านบนของ index.ts:

// Polyfill XMLHttpRequest สำหรับ kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Stub ขั้นต่ำ
  } as any;
}

หรือสร้าง module เฉพาะ:

// src/utils/polyfills.ts
export function setupPolyfills() {
  if (!globalThis.XMLHttpRequest) { /* ... */ }
  if (!globalThis.Buffer) { /* ... */ }
}

// src/index.ts
import { setupPolyfills } from './utils/polyfills';
setupPolyfills();

มันคือ hack พื้นฐาน, แต่มันใช้งานได้

สู่ npm package: hono-discord-interactions

การทำบอทด้วยมือต้องใช้ boilerplate เยอะ:

  • ตรวจสอบลายเซ็น Discord
  • Route ประเภท interactions
  • จัดการ commands, components, modals
  • คืนค่า JSON ที่ถูกต้อง

เราสามารถรวมทุกอย่างไว้ใน npm package แบบ:

import { createDiscordHandler } from 'hono-discord-interactions';

const handler = createDiscordHandler({
  publicKey: env.PUBLIC_KEY,
  commands: [
    {
      name: 'ping',
      execute: async (interaction) => ({
        type: 4,
        data: { content: 'Pong!' }
      })
    },
    {
      name: 'hello',
      execute: async (interaction) => ({
        type: 4,
        data: { content: `สวัสดี ${interaction.member.user.username}!` }
      })
    }
  ]
});

const app = new Hono();
app.post('/interactions', handler);
export default app;

บูม 20 บรรทัดแทน 200 บรรทัด มันจะลด Nibi ลงครึ่งหนึ่งได้ง่าย ๆ

ไอเดียไว้ทีหลัง xD

Deploy

Cloudflare Workers

npm install -D wrangler

# wrangler.toml
[env.production]
name = "mon-bot"
main = "src/index.ts"

# Secrets
wrangler secret put PUBLIC_KEY --env production
wrangler secret put BOT_TOKEN --env production
wrangler secret put SUPABASE_URL --env production

# Deploy
wrangler deploy --env production

URL ที่ได้: https://mon-bot.workers.dev/interactions

ค่าใช้จ่าย: ฟรี สูงถึง 100k requests/วัน เกินกว่านั้น: $0.50/ล้าน

Spoiler: คุณจะไม่มีวันถึง 100k requests เว้นแต่คุณจะมีผู้ใช้ที่ใช้งานอยู่ 10,000 คน

Vercel

npm run vercel:deploy

URL: https://mon-bot-xyz.vercel.app/api/interactions

เหมือนกัน, ฟรี

ทั้งสองอย่างพร้อมกัน

Hono ทำงานได้ทุกที่ คุณสามารถ deploy โค้ดเดียวกันบน Cloudflare และ Vercel มีประโยชน์สำหรับความซ้ำซ้อน หรือทดสอบก่อนตัดสินใจ

Checklist ด่วน

  1. สร้าง Application บน Discord Developer Portal
  2. คัดลอก PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. สร้างโปรเจกต์:
  4. เขียน index.ts (ตรวจสอบลายเซ็น + routing)
  5. ลงทะเบียน slash commands (ครั้งเดียว):
  6. Deploy:
  7. ใส่ URL ใน Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord ทดสอบการเชื่อมต่อ (คุณต้องตอบกลับ PING)
  9. เชิญบอทเข้าสู่เซิร์ฟเวอร์
  10. เสร็จ

ข้อดี vs ข้อจำกัด

ข้อดี

  • ไม่แพง (ฟรีสูงถึง 100k req/วัน)
  • Scalable (ไม่ต้องจัดการการเชื่อมต่อ)
  • ง่าย (ไม่มี WebSocket boilerplate)
  • เร็ว (Cloudflare = เซิร์ฟเวอร์ที่ edge)
  • พกพาได้ (โค้ด Hono = หลาย hosts)

ข้อจำกัด

  • ไม่มี events จากเซิร์ฟเวอร์แบบเรียลไทม์ (สมาชิกเข้าร่วม, บทบาทถูกเพิ่ม, ข้อความถูกลบ, ฯลฯ) -- คุณได้รับเฉพาะ interactions (slash commands, buttons, modals)
  • Timeout 3 วินาทีในการตอบกลับ -- มิฉะนั้น Discord จะแสดง "Application did not respond"
  • ถ้าคุณต้องการ events จริง -- ต้องมี webhook HTTP แยก หรือการเชื่อมต่อ WebSocket เสริม

สำหรับ 90% ของบอท (ทั้งหมดที่ใช้ slash commands)? ใช้ได้

สรุป

ผมใช้เวลาพอสมควรในการปรับ KonosubaRPG และ Nibi ให้ประหยัด requests ให้มากที่สุด หรือลดเวลา processor ตอนร้อน หรือลด cold boot ผลลัพธ์คือผมได้ประสิทธิภาพที่ยอดเยี่ยมในเกือบทุกด้าน ต้องบอกว่าผมเริ่มทำให้โปรเจกต์ส่วนใหญ่กลายเป็น cloud (ไม่แน่ใจด้วยซ้ำว่ามีคำนี้ไหม) เพราะผมขี้เกียจมากที่จะโฮสต์พวกมันบน VM ของตัวเองต่อไป จริง ๆ นะ, ผมว่า Github Actions นี่แหละที่ช่วยชีวิตผมไว้ Workers ก็ช่วยเหมือนกัน แต่พอผมเห็นว่าผมสามารถทำ daemons ด้วย Github Actions และ schedules ได้, มันช่วยชีวิตผมได้จริง ๆ

ผมอาจจะเขียนบทความเกี่ยวกับโปรเจกต์ชื่อ email-autoreply, ดังนั้นติดตาม RSS feed เพื่อดูเมื่อมันออกมาเร็ว ๆ นี้ :))

3 สิ่งที่ต้องจำ:

  1. Interaction endpoints = HTTP serverless -- ไม่มี WebSocket, ไม่มีการเชื่อมต่อแบบถาวร Discord POST, คุณตอบกลับ ฟรีบน Cloudflare
  2. Hono คือเครื่องมือที่สมบูรณ์แบบ -- เฟรมเวิร์กน้ำหนักเบา (12KB), multi-runtime, zero dependencies โค้ดเหมือนกันบน Cloudflare, Vercel, Node, ทุกที่
  3. การเรนเดอร์ภาพบน Workers = บ้า -- Satori + Resvg (Wasm) ให้คุณประกอบ UI แบบไดนามิกใน JSX และแปลงเป็น PNG ใน <100ms เกมเต็มรูปแบบสามารถทำงานบนนี้ได้

มันบ้ามาก xD

wrangler deploy
npm run register-commands
npm init -y
npm install hono discord-interactions
npm install -D wrangler typescript

Related Articles