GitHub avatar

Fox's Blog

Discord Bot 100% Serverless: Hono + Cloudflare Workers

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

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

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

WebSocket connection always open. The bot reconnects by itself at 3am. The bot crashes because I look at sheep the wrong way. The bill piles up.

One day I discovered: why keep a connection open? Discord can just POST you the events you care about. You reply, done.

Since 2021, Discord offers interaction endpoints.

It's just HTTP. You work with no WebSocket, no persistent state. You get a request, you send JSON, it's over. The next request arrives 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 make a Discord bot with no WebSocket using Hono (an 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 complete Discord game with real-time image rendering).

WebSocket vs. Interaction Endpoints: Why It Was a Bad Idea

Imagine a Minecraft game where you have to keep the connection open even when you're not playing.

And the server auto-reconnects every time it crashes. You have to handle timeouts, exponential reconnections, all the crap boilerplate we hate. 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 Is It Exactly?

Discord POSTs a request to your endpoint.

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

It's pure HTTP, with a minimal readable stack.

Hono + Cloudflare Workers: The Thrifty Combo

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

Cloudflare Workers is edge compute. Your requests land on the closest 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 full bot:

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

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Grab 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. Check 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 by 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;

30 lines is enough for a working bot.

You forget bot.login(), event emitters, and callback hell. You keep only HTTP.

To deploy on Cloudflare:

npm install -D wrangler
npx wrangler deploy

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

You put 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? It'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,         // x-signature-ed25519 header
  timestamp,         // x-signature-timestamp header
  publicKey          // from Discord Dev Portal
);

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

Verify first. Parse after. That's the order that 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 → greeting 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 is nicely weird. 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: 'Generate 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)
        }
      };
    }
  }
};

Crazy: you call an external API, upload a file to Discord, send a message with the file. All with no WebSocket, just HTTP.

Persistence With Supabase

Nibi uses Supabase as a key-value store. To check whether 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);
}

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 is wild.

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

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

The Render Architecture

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

All in under a second. Wild.

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. It becomes SVG. SVG becomes PNG. <100ms on a Cloudflare Worker.

You get the power? Just beautiful.

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 stats in the DB. 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 (gray)
      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 compute damage, update the DB, regenerate the image, send it.

A full turn-based game with zero persistent connection. Just stateless HTTP. Completely broken.

Supabase: The DB Made for Workers

Classic databases (PostgreSQL, MySQL, MongoDB) are designed for persistent TCP connections. You open a socket, keep the connection open, send queries. Problem: Cloudflare Workers doesn't support persistent TCP connections. Every request is an ephemeral process. As soon as you answer the client, the Worker disappears.

You can't do this:

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

And even native PostgreSQL drivers like pg or postgres.js use TCP connections. On Workers, they crash.

Supabase solves all of that.

Supabase is a REST API on top of PostgreSQL. You make plain HTTP requests. Each call is independent, no persistent connection, no state to manage. It's perfectly suited to 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 connection only, 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 HTTP REST API

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

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

For a serverless Discord bot, Supabase is the simplest, most reliable choice. You configure no driver, maintain no connection, manage no timeout. You just send HTTP requests.

If you want a real example, look at Nibi above: the persistence code is literally readJson() and writeJson() on Supabase. You start with no migrations, no complex schema, no heavy config. It works out of the box. And if your bot gets big, you migrate to real SQL queries without switching 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 fix: 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();

Basic hack, but it works.

Toward an npm Package: hono-discord-interactions

By hand, making a bot is 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. That would cut Nibi in half easily.

Idea for later.

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: $0.50/million.

Spoiler: you'll never burn the 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 Cloudflare AND Vercel. Useful for redundancy or testing before picking one.

Quick Checklist

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

Advantages vs Limitations

Advantages

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

Limitations

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

For 90% of bots (all slash-command based)? Good.

To Conclude

I spent quite a while optimizing KonosubaRPG and Nibi to save either as many requests as possible, or to cut hot CPU time, or to cut cold boot. Result: I've got solid perf on pretty much everything. Fact is I started cloudifying (no idea if that's even a word) most of my projects because I had monumental laziness about keeping hosting them on my own VM. Really, I think GitHub Actions saved my skin. Workers too, but actually when I saw I could run daemons with GitHub Actions and schedules, that really saved me for real.

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

3 key points:

  1. Interaction endpoints = serverless HTTP -- 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. Identical code on Cloudflare, Vercel, Node, everywhere.
  3. Image render on Workers = wild -- Satori + Resvg (Wasm) let you compose dynamic UIs in JSX and convert them to PNG in <100ms. A full game can run on that.

Wild.

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 événements qui t'intéressent. Tu réponds, c'est bon.

Depuis 2021, Discord propose les interaction endpoints.

C'est juste HTTP. Tu travailles sans WebSocket, sans é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).

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, avec une stack minimale et lisible.

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;

30 lignes suffisent pour un bot fonctionnel.

Tu oublies bot.login(), les event emitters et le callback hell. Tu gardes uniquement HTTP.

Pour deploy sur Cloudflare :

npm install -D wrangler
npx wrangler deploy

Boom. Tu as une URL comme 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. Tu évites les spritesheets pré-rendus. 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.

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

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. Tu configures aucun driver, tu maintiens aucune connexion, tu gères aucun timeout. Tu envoies 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. Tu démarres sans migrations, sans schéma complexe, sans config lourde. Ç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.

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

  • Coût minimal (gratuit jusqu'à 100k req/jour)
  • Scalable (sans gestion de connexion)
  • Simple (sans boilerplate WebSocket)
  • Rapide (Cloudflare = serveurs au edge)
  • Portable (code Hono = plusieurs hosts)

Limitations

  • Tu reçois uniquement les interactions (slash commands, buttons, modals), sans les événements serveur en temps réel (membre rejoint, rôle ajouté, message supprimé, etc.)
  • Timeout de 3 secondes pour répondre -- sinon Discord affiche "Application did not respond"
  • Si tu as besoin de vraies events -- il 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 points à retenir :

  1. Interaction endpoints = HTTP serverless -- sans WebSocket, sans 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.

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

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

我怎么把每月 50 欧的 Discord 机器人换成零元,interaction endpoint、Hono、Workers、实时图片渲染、不用 WebSocket 的完整游戏。

Discord 机器人 100% 无服务器:Hono + Cloudflare Workers = 💸 零元

我之前在自己机器上养了几个月经典 Discord 机器人。

WebSocket 长连接一直开着。凌晨 3 点自己重连。看我不顺眼就 crash。账单一直涨。

有一天我发现:为什么要维持连接?Discord 可以只把感兴趣的事件 POST 给你。你回包,完事。

2021 年起 Discord 有 interaction endpoint。

就是纯 HTTP。不用 WebSocket,不用持久状态。收到请求,回 JSON,结束。下个请求自己来。

最爽的:Cloudflare Workers 每天 10 万请求内免费。90% 的机器人,每月 0 元。

这篇教你用 Hono(超轻 web 框架)加 Cloudflare Workers 做无 WebSocket Discord 机器人。带两个真实项目:Nibi(学日语机器人,TTS,好玩)和 Konosuba-RPG(完整 Discord 游戏,实时图片渲染)。

WebSocket vs Interaction Endpoint:为什么原来是馊主意

想象一个 Minecraft 游戏,不玩的时候也要一直连着。

服务器一 crash 就自动重连。超时、指数退避、一堆你讨厌的 boilerplate 全要自己写。就为了收交互。

interaction endpoint 反着来。Discord POST 到你 URL。你回包。完事。

服务器挂了?Discord 重试两三次就过了。零 drama。

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

之后成本:Cloudflare 上每月 0 元,每天 10 万请求内。

架构:到底是什么?

Discord POST 一个请求到你的 endpoint。

Discord:"喂!用户点了 /ping!"
      ↓
   你的 URL(Cloudflare Worker)
      ↓
 你验明确实是 Discord(验签名)
      ↓
 你解析交互类型
      ↓
 你跑 handler
      ↓
 你回 JSON
      ↓
Discord:"好,我显示给用户"

纯 HTTP,栈又小又清楚。

Hono + Cloudflare Workers:省钱组合

Hono 是 12KB 的 web 框架。到处跑: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) {
    // slash 命令
    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

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 拿的
);

重要坑:签名依赖精确 body。你解析 JSON 再 stringify,或者 log 一下 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 生成语音,在 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、装饰品、升级。每场战斗实时生成一张图片。不用预渲染 sprite。图片按玩家属性、怪、战斗状态动态合成。

图在 Cloudflare Workers 上 <500ms 生成。字面意思。

渲染架构

Discord(你点"攻击")
    ↓
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。

get 到威力了吗?美得很。

游戏状态与成长

玩家数据在 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' 的交互。handler 路由:

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);
  }
}

Boom,算伤害,更新库,重渲图,发出去。

回合制完整游戏,不维持任何连接。纯无状态 HTTP。烂得漂亮。

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 这类原生 driver 也走 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 原生。零配置,零 driver,零持久连接。

数据库 兼容 Workers? 为什么
Supabase ✅ 行 无状态 REST API,纯 HTTP
PlanetScale(MySQL) ⚠️ 半行 只能 HTTPS,没长事务
Neon ⚠️ 半行 serverless 分支,但 driver 要 TCP
Turso(libSQL) ⚠️ 半行 HTTP 可行但受限
Prisma/Prisma Postgres ❌ 不行 要持久 TCP
MongoDB Atlas ❌ 不行 TCP driver,无原生 REST API
Redis(Upstash) ✅ 行 REST API 走 HTTP

Supabase 真正强的还不只是库,是整套 edge-first 生态:

  • Auth:REST API 管 session,无状态
  • Storage:文件上传下载走 HTTP
  • Realtime:WebSocket 可选,不想用就 REST 轮询
  • Row Level Security:安全规则写库里,不写后端

serverless Discord 机器人,Supabase 是最简单最稳的选择。不配 driver,不养连接,不管超时。只发 HTTP 请求。

看上面 Nibi 的例子就懂:持久化就是 readJson() 加 writeJson()。不用 migration,不用复杂 schema,不用重配置。直接跑。机器人做大了再迁真 SQL 查询,provider 都不用换。

Polyfill:Node 要在 Workers 上跑

有些包默认你在 Node。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

手写机器人 boilerplate 很多:

  • 验 Discord 签名
  • 路由交互类型
  • 管命令、组件、modal
  • 回合法 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;

Boum。200 行变 20 行。Nibi 直接砍一半。

以后再说的想法。

部署

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 万请求内免费。超出:100 万次 0.5 美元。

剧透:用户不到一万活跃,你花不完 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. 注册 slash 命令(一次):
  6. 部署:
  7. 把 URL 填进 Discord(Developer Portal → 应用 → Interactions Endpoint URL)
  8. Discord 测试连接(你要回 PING)
  9. 拉机器人进服务器
  10. 搞定

优点 vs 局限

优点

  • 成本极小(每天 10 万请求内免费)
  • 可扩展(不用管连接)
  • 简单(无 WebSocket boilerplate)
  • 快(Cloudflare = 边缘服务器)
  • 可移植(Hono 代码到处跑)

局限

  • 只能收交互(slash 命令、按钮、modal),收不到服务器实时事件(成员加入、身份组变更、消息删除等)
  • 3 秒超时回包,不然 Discord 显示"应用无响应"
  • 真要实时事件,得另加 HTTP webhook 或辅助 WebSocket 连接

90% 的机器人(全 slash 命令)?够了。

结语

我在 KonosubaRPG 和 Nibi 上抠了很久性能,要么省请求数,要么压热 CPU 时间,要么压冷启动。结果各项性能都很顶。 说实话,我一开始把大部分项目云化(这词对吗)是因为懒得在自己 VPS 上养它们。真的,我觉得是 GitHub Actions 救了我的命。Workers 也是,但当我发现能用 GitHub Actions 加 schedule 跑 daemon,我是真松了口气。

我应该会给 email-autoreply 写篇文章,订阅 RSS 等更新吧 :))。

三句话总结:

  1. Interaction endpoint = HTTP 无服务器,无 WebSocket,无持久连接。Discord POST,你回。Cloudflare 免费。
  2. Hono 是完美工具,12KB 轻框架,多运行时,零依赖。Cloudflare、Vercel、Node,到处同一份代码。
  3. Workers 上图片渲染很疯,Satori + Resvg(Wasm)在 JSX 里拼动态 UI,转 PNG 不到 100ms。完整游戏跑得动。

疯得很。

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

Discordボット完全サーバーレス:HonoとCloudflare Workers

月50ユーロかかってたDiscordボットをゼロ円に置き換えた方法。interaction endpoints、Hono、Workers、リアルタイム画像レンダー、WebSocketなしの完全ゲーム。

Discordボット完全サーバーレス:HonoとCloudflare Workersでお金ゼロ

昔ながらのDiscordボットの保守に数か月かけた。自分のマシンでね。

WebSocket接続は開きっぱなし。ボットは朝3時に1人で再接続する。変な見方で羊を数えたせいでボットはクラッシュする。請求は増える。

ある日気づいた。なんで接続を維持するのって。Discordは興味あるイベントだけPOSTしてくれる。返せば終わりだ。

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

ただのHTTPだ。WebSocketなし、永続状態なしで働く。リクエスト受けて、JSON返して、終わりだ。次のリクエストは1人で来る。

最高なのは、Cloudflare Workersが1日10万リクエストまで無料なことだ。9割のボットは月0円だ。

この記事ではHono(超軽量ウェブフレームワーク)とCloudflare WorkersでWebSocketなしDiscordボットを作る方法を示す。実在2プロジェクトを見せる。Nibi(日本語学習ボット、TTS、かっこいい)とKonosuba-RPG(リアルタイム画像レンダー付き完全なDiscordゲーム)だ。

WebSocket対Interaction Endpoints:なぜ悪い考えだったか

遊んでないときも接続を開いたままにしなきゃいけないMinecraftゲームを想像して。

サーバーはクラッシュするたび自動再接続する。タイムアウトも指数バックオフ再接続も、大嫌いな定型ボイラープレート全部を管理しなきゃいけない。ただやり取りを受け取るためだけにね。

interaction endpointsは逆だ。Discordが君のURLにPOSTする。返せば終わりだ。

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

前のコスト:Nodeプロセスを生かしとくだけでHerokuに月50ユーロだ。

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

アーキテクチャ:結局何なの

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未満だ。コストは1日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()もイベントエミッタもコールバック地獄も忘れる。HTTPだけ残す。

Cloudflareへのデプロイはこうだ。

npm install -D wrangler
npx wrangler deploy

はい。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から
);

大事な罠:署名は正確なbodyに依存する。JSONパースして再stringifyしたり、bodyをログに出したりしたら署名が壊れる。

先に検証する。後にパースする。この順序が大事だ。

ケース1:Nibi(日本語学習ボット)

Nibiは日本語学習用Discordボットだ。単純コマンドだ。

  • /dictionary kanji → 定義を表示
  • /pronounce テキスト → TTS(text-to-speech)を生成
  • /hello → あいさつメッセージ

各コマンドはTypeScriptファイル1つだ。

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、ひらがな、漢字、何でも)。ボットはひらがなに変換して、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. romajiからひらがなへ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: 'Erreur : impossible de générer l\'audio 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稼いで、装飾品付けて、レベル上げる。各バトルが画像をリアルタイム生成する。事前レンダーしたスプライトシートは避ける。画像はプレイヤー stats とモブと戦闘状態から動的合成される。

画像生成は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未満だ。

強さ分かる?ただ美しい。

ゲーム状態と進行

プレイヤーデータは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の stats を更新する。次に新しい stats で画像を作り直す。

やり取り:ゲームプレイのボタン

ゲームは戦闘行動にボタンやり取りを使う。

{
  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だ。完全にどうかしてる。

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ネイティブだ。設定ゼロ、ドライバーゼロ、永続接続ゼロだ。

データベース 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クエリに移行できる。

ポリフィル: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

手でボットを作ると定型が大量だ。

  • 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は半分くらい減りそうだ。

後日のアイデアだ。

デプロイ

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万リクエストまで無料だ。超過は100万件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でアプリケーションを作る
  2. PUBLIC_KEY、BOT_TOKEN、APP_IDをコピーする
  3. プロジェクトを作る:
  4. index.tsを書く(署名検証と振り分け)
  5. スラッシュコマンドを登録する(1回):
  6. デプロイ:
  7. URLをDiscordに入れる(Developer PortalからApplicationからInteractions Endpoint URL)
  8. Discordが接続をテストする(PINGに返さなきゃいけない)
  9. ボットをサーバーに招く
  10. 終わり

利点対制限

利点

  • 最小コスト(1日10万リクエストまで無料)
  • スケーラブル(接続管理なし)
  • 単純(WebSocket定型なし)
  • 高速(Cloudflareはエッジサーバー)
  • 可搬(Honoコードは複数ホスト)

制限

  • 受け取れるのはやり取りだけだ(スラッシュコマンド、ボタン、モーダル)。サーバーのリアルタイムイベントはなし(参加、役職追加、メッセージ削除など)
  • 応答タイムアウト3秒。超えたらDiscordは「Application did not respond」を出す
  • 本物イベントが要るなら、別のHTTPウェブフックか補助WebSocket接続が要る

9割のボット(全部スラッシュコマンド基準)にはこれでいい。

結びに

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変換できる。完全ゲームがこれで回る。

やばい。

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

Discord 봇 100퍼센트 서버리스: Hono + Cloudflare Workers

월 50유로 들던 Discord 봇을 0유로로 바꾼 방법. 인터랙션 엔드포인트, Hono, Workers, 실시간 이미지 렌더링, WebSocket 없는 완전한 게임.

Discord 봇 100퍼센트 서버리스: Hono + Cloudflare Workers = 💸 0원

제 기기에서 고전 Discord 봇들을 몇 달 유지했어요.

항상 열린 WebSocket 연결. 새벽 3시에 혼자 다시 연결하는 봇. 제가 양을 삐딱하게 본다고 죽는 봇. 청구서가 올라가요.

어느 날 깨달았어요. 왜 연결을 유지해야 하지? Discord가 관심 있는 이벤트만 POST로 보내줄 수 있잖아요. 답장하면 끝이에요.

2021년부터 Discord는 인터랙션 엔드포인트를 제공해요.

그냥 HTTP예요. WebSocket 없이, 영속 상태 없이 일해요. 요청을 받고 JSON을 보내면 끝이에요. 다음 요청은 혼자 와요.

그리고 최고예요. Cloudflare Workers는 하루 10만 요청까지 무료예요. 90퍼센트의 봇에게는 월 0유로예요.

이 글에서는 Hono(초경량 웹 프레임워크)와 Cloudflare Workers로 WebSocket 없는 Discord 봇을 만드는 법을 보여줘요. 실제 프로젝트 둘을 보여드릴게요. Nibi(일본어 학습 봇, TTS, 멋져요)와 Konosuba-RPG(실시간 이미지 렌더링이 있는 완전한 Discord 게임)예요.

WebSocket 대 인터랙션 엔드포인트: 왜 나쁜 생각이었는지

놀지 않을 때도 연결을 열어 두어야 하는 Minecraft 게임을 상상해 보세요.

그리고 서버는 죽을 때마다 자동으로 다시 연결돼요. 타임아웃과 지수 재접속, 우리가 싫어하는 온갖 상용구 코드를 다뤄야 해요. 인터랙션을 받는 것만 위해서요.

인터랙션 엔드포인트는 반대예요. Discord가 여러분 URL에 POST해요. 답장해요. 끝이에요.

서버가 죽으면요? Discord가 2-3번 재시도하고 넘어가요. 드라마 제로예요.

전 비용: Node 프로세스 하나를 살려 두려고 Heroku에 월 50유로.

후 비용: 하루 10만 요청까지 Cloudflare에서 월 0유로.

아키텍처: 정확히 뭔가요?

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 미만. 비용은 하루 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()과 이벤트 이미터, 콜백 지옥은 잊어요. HTTP만 남겨요.

Cloudflare에 배포하려면요.

npm install -D wrangler
npx wrangler deploy

쾅. 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을 파싱했다가 다시 문자열로 만들거나 바디를 로그하면 서명이 깨져요.

먼저 확인해요. 나중에 파싱해요. 순서가 중요해요.

사례 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: 'Erreur : impossible de générer l\'audio xD',
          flags: 64  // ephemeral (비공개 메시지)
        }
      };
    }
  }
};

미쳤어요. 외부 API를 부르고, 파일을 Discord에 올리고, 파일이 있는 메시지를 보내요. 전부 WebSocket 없이 그냥 HTTP로요.

Supabase로 영속화

Nibi는 Supabase를 키-값 저장소로 써요. 사용자가 등록되어 있는지 확인하려면요.

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 (이미지 렌더링 게임)

좋아요, 이건 미쳤어요.

Konosuba-RPG는 Discord 위의 완전한 게임이에요. 몹과 싸우고, XP를 얻고, 액세서리를 장착하고, 레벨을 올려요. 매 전투가 실시간 이미지를 만들어내요. 미리 렌더링된 스프라이트시트를 피하고요. 이미지는 플레이어 스탯과 몹, 전투 상태에서 동적으로 합성돼요.

그리고 이미지가 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 미만이에요.

힘이 느껴지시죠? 그냥 아름다워요.

게임 상태와 진행

플레이어 자료는 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) {
  // 컴포넌트 인터랙션 (버튼 클릭 등)
  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. 완전히 맛이 갔어요.

Supabase: Workers용 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에 네이티브예요. 설정 제로, 드라이버 제로, 영속 연결 제로.

데이터베이스 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만이 아니에요. 생태계 전체가 엣지 우선으로 생각됐어요.

  • 인증: 세션 관리용 REST API, 무상태로 동작
  • 스토리지: HTTP로 파일 올리기와 내려받기
  • 실시간: WebSocket 선택 사항이지만 REST로 폴링도 가능
  • 행 수준 보안: 보안 규칙이 백엔드가 아니라 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

손으로 봇을 만들면 상용구가 많아요.

  • 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를 쉽게 절반으로 줄여요.

나중을 위한 생각이에요.

배포

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만 요청을 절대 쓰지 않아요.

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. Discord에 URL 넣기 (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord가 연결을 테스트해요 (PING에 답해야 해요)
  9. 서버에 봇 초대하기
  10. 됐어요

이점 대 한계

이점

  • 최소 비용 (하루 10만 요청까지 무료)
  • 확장 가능 (연결 관리 없이)
  • 단순 (WebSocket 상용구 없이)
  • 빠름 (Cloudflare = 엣지 서버)
  • 이식 가능 (Hono 코드 = 여러 호스트)

한계

  • 인터랙션(슬래시 명령어, 버튼, 모달)만 받아요. 실시간 서버 이벤트(멤버 참가, 역할 추가, 메시지 삭제 등)는 없어요
  • 답장 타임아웃 3초. 넘기면 Discord가 "Application did not respond"를 보여줘요
  • 진짜 이벤트가 필요하면 별도 HTTP 웹훅이나 보조 WebSocket 연결이 필요해요

90퍼센트 봇(전부 슬래시 명령어 기반)에는요? 충분해요.

마무리하자면

KonosubaRPG와 Nibi를 최적화하느라 꽤 많은 시간을 썼어요. 요청을 최대한 아끼거나, 핫 프로세서 시간을 줄이거나, 콜드 부팅을 줄이려고요. 결과적으로 거의 모든 것에서 상당한 성능을 얻었어요. 제가 대부분의 프로젝트를 클라우드화하기(그렇게 말하는지도 모르겠어요) 시작한 것은 제 VM에 호스팅을 계속하는 게 monumentally 귀찮았기 때문이라는 것을 알아두세요. 정말 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로 바꿔요. 완전한 게임이 그 위에서 돌 수 있어요.

미쳤어요.

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 euroyla nasıl

%100 sunucusuz Discord botu: Hono + Cloudflare Workers = 💸 sıfır

Klasik Discord botlarını kendi makinemde bakımlı tutarak birkaç ay geçirdim.

Sürekli açık WebSocket bağlantısı. Bot sabah 3te kendi kendine yeniden bağlanır. Koyunlara ters baktığım için bot çöker. Fatura yükselir.

Bir gün şunu keşfettim: bağlantıyı neden sürdüreyim? Discord ilgilendiğin olayları sana POST ile gönderebilir. Yanıtlarsın, biter.

2021den beri Discord interaction endpointleri sunar.

Bu sadece HTTP. WebSocketsiz, kalıcı durumsuz çalışırsın. Bir istek alırsın, JSON gönderirsin, biter. Sonraki istek kendi kendine gelir.

Ve en iyisi: Cloudflare Workers günde 100 bin isteğe kadar ücretsizdir. Botların %90ı için bu 0 €/aydır.

Bu makale Hono (aşırı hafif web çerçevesi) ve Cloudflare Workers kullanarak WebSocketsiz bir Discord botunun nasıl yapılacağını gösterir. İki gerçek proje göstereceğim: Nibi (Japonca öğrenme botu, TTS, havalı) ve Konosuba-RPG (gerçek zamanlı görsel renderlı tam bir Discord oyunu).

WebSocket ve Interaction Endpointleri: neden kötü bir fikirdi

Oynamadığında bile bağlantıyı açık tutman gereken bir Minecraft oyunu hayal et.

Ve sunucu her çöktüğünde otomatik yeniden bağlanır. Timeoutları, üstel yeniden bağlanmaları, nefret ettiğimiz tüm o saçma boilerplatei yönetmek zorundasın. Sadece etkileşimleri almak için.

Interaction endpointleri bunun tersidir. Discord URLne POST yapar. Yanıtlarsın. Biter.

Sunucun çökerse? Discord 2-3 kez yeniden dener ve yoluna devam eder. Sıfır drama.

Önceki maliyet: bir Node sürecini canlı tutmak için sırf Heroku üzerinde 50 €/ay.

Sonraki maliyet: günde 100 bin isteğe kadar Cloudflare üzerinde 0 €/ay.

Mimari: bu tam olarak nedir?

Discord endpointine bir istek POSTlar.

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"

Bu minimal ve okunabilir yığınla saf HTTP.

Hono + Cloudflare Workers: tutumlu ikili

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

Cloudflare Workers uçta işlemdir. İsteklerin en yakın sunucuya ulaşır. Yanıt süresi: <100 ms. Maliyet: günde 100 bin isteğe kadar ücretsiz.

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

İşte tam bir botun minimal kodu:

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

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Başlıkları 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 olduğunu doğrula (spam değil)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Gönderdiğini ayrıştır
  const interaction = JSON.parse(body);

  // 4. Türe göre yanıtla
  if (interaction.type === 1) {
    // Discord testi (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // Bu bir slash komutu
    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;

İşlevsel bir bot için 30 satır yeter.

bot.login(), olay yayıcılar ve callback cehennemini unutursun. Sadece HTTPyi tutarsın.

Cloudflare üzerine dağıtmak için:

npm install -D wrangler
npx wrangler deploy

Bum. https://mon-bot.workers.dev/interactions gibi bir URLn olur.

Bunu Discord Developer Portal içinde "INTERACTIONS ENDPOINT URL" altına koyarsın ve Discord etkileşimlerini oraya göndermeye başlar.

İmzayı doğrulamak: sahte istek yok

Discord her isteği bir açık anahtarla imzalar. Kötü imzalı bir istek alırsan? O spamdir. Görmezden gel ve devam et.

discord-interactions paketi işi yapar:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // tam ham metin (ayrıştırılmış JSON değil!)
  signature,         // x-signature-ed25519 başlığı
  timestamp,         // x-signature-timestamp başlığı
  publicKey          // Discord Dev Portalden
);

Önemli tuzak: imza tam gövdeye bağlıdır. JSON ayrıştırıp yeniden dizgeleĢtirirsen ya da gövdeyi loglarsan imzayı bozarsın.

Önce doğrula. Sonra ayrıştır. Önemli olan sıradır.

Durum 1: Nibi (Japonca öğrenme botu)

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

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

Her komut bir TypeScript dosyasıdır:

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

Bir komut bu arayüzü uygular:

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

/pronounce komutu: botu konuşturmak

İyice tuhaf olan bu. Metin gönderirsin (romaji, hiragana, kanji, her şey), bot bunu hiraganaya çevirir, VOICEVOX ya da Google TTS üzerinden TTS üretir ve Discordda bir sesli mesaj gönderir.

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. Kuroshiro ile romaji → hiragana çevir
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. TTS sesini üret
      const audioBuffer = await generateTTS(hiragana);

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

      // 4. Sesli mesajı sesle birlikte gönder
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // saniye cinsinden süre
        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 (özel mesaj)
        }
      };
    }
  }
};

Çılgınca: harici bir API çağırırsın, bir dosyayı Discorda yüklersin, dosyayla birlikte bir mesaj gönderirsin. Hepsi WebSocketsiz, sadece HTTP.

Supabase ile kalıcılık

Nibi key-value deposu olarak Supabase kullanır. Bir 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 temel (gerçek SQL sorguları yok, sadece JSON) ama çalışıyor. Küçük botlar için mükemmel.

Durum 2: Konosuba-RPG (görsel renderlı Discord oyunu)

Tamam bu çılgınca.

Konosuba-RPG Discordda tam bir oyundur. Moblarla savaşırsın, XP kazanırsın, aksesuar kuşanırsın, seviye atlarsın. Her savaş gerçek zamanlı bir görsel üretir. Önceden renderlanmış spritesheetlerden kaçınırsın. Görsel oyuncu istatistiklerinden, mobdan ve savaş durumundan dinamik oluşturulur.

Ve görsel Cloudflare Workers’ta <500 msde üretilir. Kelimenin tam anlamıyla.

Render mimarisi

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

Hepsi bir saniyeden kısa sürede. Çılgınca.

Workers tarafında görsel render

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

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

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

  // 1. Arayüz 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. SVGye dönüşür. SVG PNGye dönüşür. Bir Cloudflare Worker üzerinde <100 ms.

Gücü görüyor musun? Bu sadece güzel.

Oyun durumu ve ilerleme

Oyuncu verileri Supabasededir:

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 aksiyon (saldırı, savunma, iyileşme) veritabanında istatistikleri günceller. Ve sonra görseli yeni istatistiklerle yeniden üretirsin.

Etkileşimler: oynanış düğmeleri

Oyun savaşta aksiyonlar için düğme etkileşimleri kullanır:

{
  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" düğmesine tıkladığında Discord custom_id: 'battle_attack' ile bir etkileşim POSTlar. Handler bunu yönlendirir:

if (interaction.type === 3) {
  // Bileşen etkileşimi (düğme tıklaması vs.)
  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 bum, hasarı hesaplarsın, veritabanını güncellersin, görseli yeniden üretirsin, gönderirsin.

Bağlantı kalıcılığı olmadan tam bir sıra tabanlı oyun. Sadece stateless HTTP. Tamamen kırık.

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

Klasik veritabanları (PostgreSQL, MySQL, MongoDB) kalıcı TCP bağlantıları için tasarlanmıştır. Bir soket açarsın, bağlantıyı açık 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 verir vermez Worker kaybolur.

Bunu yapamazsın:

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

Ve pg ya da postgres.js gibi yerel PostgreSQL sürücüleri bile TCP bağlantıları kullanır. Worker üzerinde çökerler.

Supabase tüm bunları çözer.

Supabase PostgreSQL üstünde bir REST APIdir. Normal HTTP istekleri yaparsın. Her çağrı bağımsızdır, kalıcı bağlantı yok, yönetilecek durum yok. Sunucusuz modele mükemmel uyar.

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

Supabase istemcisi (@supabase/supabase-js) kaputun altında fetch kullanır. Ve fetch Worker üzerinde yereldir. 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ısmi Yalnızca HTTPS bağlantısı, uzun işlem yok
Neon ⚠️ Kısmi Sunucusuz dallar ama TCP sürücü gerekli
Turso (libSQL) ⚠️ Kısmi HTTP mümkün ama sınırlı
Prisma/Prisma Postgres ❌ Hayır Kalıcı TCP gerekli
MongoDB Atlas ❌ Hayır TCP sürücü, yerel REST API yok
Redis (Upstash) ✅ Evet HTTP REST API

Supabasin gerçek avantajı sadece veritabanı değil: tüm ekosistem uç öncelikli düşünülmüş:

  • Auth: oturumları yönetmek için REST API, durumsuz çalışır
  • Storage: HTTP üzerinden dosya yükleme/indirme
  • Realtime: WebSocket opsiyonel ama REST üzerinden yoklama da yapabilirsin
  • Row Level Security: güvenlik kuralları backendinde değil, veritabanında

Sunucusuz bir Discord botu için Supabase en basit ve en güvenilir seçimdir. Sürücü yapılandırmazsın, bağlantı sürdürmezsin, timeout yönetmezsin. Sadece HTTP istekleri gönderirsin.

Gerçek bir örnek istersen yukarıda Nibiye bak: kalıcılık kodu kelimenin tam anlamıyla Supabase üzerinde readJson() ve writeJson()dur. Migrasyonsuz, karmaşık şemasız, ağır yapılandırmasız başlarsın. Doğrudan çalışır. Ve botun büyürse sağlayıcı değiştirmeden gerçek SQL sorgularına geçersin.

Polyfilller: Node Worker üzerinde çalışmak isteyince

Bazı paketler Node APIleri bekler. Kuromoji (kanji ayrıştırıcı) XMLHttpRequest kullanır. Workerlarda fetch vardır, XMLHttpRequest yoktur.

Basit çözüm: index.ts en üstüne bir polyfill eklemek:

// kuromoji için XMLHttpRequest Polyfilli
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Minimal saplamа
  } as any;
}

Ya da adanmış bir modül yapmak:

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

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

Bu temel bir hack ama çalışıyor.

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

Elle bot yapmak çok boilerplate demektir:

  • Discord imzasını doğrula
  • Etkileşim türlerini yönlendir
  • Komutları, bileşenleri, modalları yönet
  • Geçerli JSON döndür

Bütün bunlar bir npm paketinde soyutlanabilir. Şöyle bir şey:

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. 200 yerine 20 satır. Nibiyi kolayca yarıya indirirdi.

Sonraya fikir.

Dağıtım

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 100 bin isteğe kadar ücretsiz. Ötesi: 0,50 $/milyon.

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

Vercel

npm run vercel:deploy

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

Aynısı, ücretsiz.

İkisi birden

Hono her yerde çalışır. Aynı kodu hem Cloudflare hem Vercelde dağıtabilirsin. Yedeklilik ya da seçmeden önce test için kullanışlı.

Hızlı kontrol listesi

  1. Discord Developer Portal üzerinde bir Uygulama oluştur
  2. PUBLIC_KEY, BOT_TOKEN, APP_ID kopyala
  3. Projeyi oluştur:
  4. index.ts yaz (imza doğrulama + yönlendirme)
  5. Slash komutlarını kaydet (bir kez):
  6. Dağıt:
  7. URLyi Discorda koy (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord bağlantıyı test eder (PINGe yanıt vermelisin)
  9. Botu bir sunucuya davet et
  10. Tamamdır

Avantajlar ve Sınırlamalar

Avantajlar

  • Minimal maliyet (günde 100 bin isteğe kadar ücretsiz)
  • Ölçeklenebilir (bağlantı yönetimi olmadan)
  • Basit (WebSocket boilerplatei olmadan)
  • Hızlı (Cloudflare = uçta sunucular)
  • Taşınabilir (Hono kodu = birden çok host)

Sınırlamalar

  • Yalnızca etkileşimleri alırsın (slash komutları, düğmeler, modallar), gerçek zamanlı sunucu olayları olmadan (üye katıldı, rol eklendi, mesaj silindi vs.)
  • Yanıtlamak için 3 saniyelik timeout: yoksa Discord "Application did not respond" gösterir
  • Gerçek olaylar gerekirse: ayrı bir HTTP webhook ya da yardımcı bir WebSocket bağlantısı gerekir

Botların %90ı için (hepsi slash komut tabanlı)? Bu tamamdır.

Sonuç olarak

KonosubaRPG ve Nibiyi ya olası en çok isteği tasarruf etmek, ya sıcak işlemci süresini azaltmak ya da soğuk başlatmayı kısaltmak için optimize ederek epey zaman geçirdim. Sonuç, hemen her şeyde sağlam performanslarım var. Bil ki çoğu projemi bulutlaştırmaya başlamıştım (bunun denip denmediğini bile bilmiyorum) çünkü kendi VMimde barındırmaya devam etmeye anıtsal bir üşengeçliğim vardı. Gerçekten sanırım kıçımı kurtaran Github Actions oldu. Workerlar da ama aslında Github Actions ve zamanlamalarla daemonlar yapabileceğimi gördüğümde bu beni gerçekten kurtardı cidden.

Yakında email-autoreply adlı bir proje hakkında bir makale yazacağım, o yüzden yakında çıktığını görmek için RSS akışına abone olun :)).

Akılda tutulacak 3 nokta:

  1. Interaction endpointleri = sunucusuz HTTP: WebSocketsiz, kalıcı bağlantısız. Discord POSTlar, yanıtlarsın. Cloudflare üzerinde ücretsiz.
  2. Hono mükemmel araçtır: Hafif çerçeve (12 KB), çok çalışma ortamlı, sıfır bağımlılık. Cloudflare, Vercel, Node üzerinde aynı kod, her yerde.
  3. Worker üzerinde görsel render = çılgınca: Satori + Resvg (Wasm) JSX ile dinamik arayüzler besteleyip <100 msde PNGye çevirmene izin verir. Tam bir oyun bunun üstünde dönebilir.

Bu hasta.

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 euro: interaction endpoint, Hono, Worker, rendering di immagini in tempo reale, e un gioco completo senza WebSocket.

Bot Discord 100% serverless: Hono + Cloudflare Workers = zero soldi

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 mattina. Il bot crasha perché guardo le pecore di traverso. La fattura sale.

Un giorno ho scoperto: perché mantenere una connessione? Discord può farti POST solo gli eventi che ti interessano. Rispondi, ed è fatta.

Dal 2021, Discord propone gli interaction endpoint.

È solo HTTP. Lavori senza WebSocket, senza stato persistente. Ricevi una richiesta, invii JSON, è finita. La richiesta successiva arriva da sola.

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

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

WebSocket contro Interaction Endpoint: perché era una cattiva 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 timeout, riconnessioni esponenziali, tutto il boilerplate di merda che detestiamo. Solo per ricevere interazioni.

Gli interaction endpoint sono l'inverso. Discord fa POST sul tuo URL. Rispondi. È finita.

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

Costo prima: 50€/mese su Heroku solo perché un processo Node resti vivo.

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

L'architettura: cos'è di preciso?

Discord fa POST di una richiesta sul tuo endpoint.

Discord: "Ehi! L'utente ha cliccato su /ping!"
      ↓
   Il tuo URL (Cloudflare Worker)
      ↓
 Verifichi che sia davvero Discord (controllo firma)
      ↓
 Analizzi il tipo di interazione
      ↓
 Esegui l'handler
      ↓
 Restituisci JSON
      ↓
Discord: "Figo, lo mostro all'utente"

È HTTP puro, con uno stack minimo e leggibile.

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

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

Il combo Hono + Cloudflare è il match perfetto per un bot Discord.

Ecco il codice minimo di un bot completo:

// bot Discord minimo: verifica firma + routing comandi
import { Hono } from 'hono';
import { verifyKey } from 'discord-interactions';

const app = new Hono();

app.post('/interactions', async (c) => {
  // 1. Recupera 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 (non spam)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. Analizza ciò che ha inviato
  const interaction = JSON.parse(body);

  // 4. Rispondi secondo il 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;

30 righe bastano per un bot funzionante.

Dimentichi bot.login(), gli event emitter e il callback hell. Tieni solo HTTP.

Per deployare su Cloudflare:

npm install -D wrangler
npx wrangler deploy

Boom. Hai un URL come https://mio-bot.workers.dev/interactions.

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

Verificare la firma: niente richieste fake

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:

// verifica la firma Ed25519 di Discord sul body grezzo
import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // testo grezzo 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-stringify, o se logghi il body, rompi la firma.

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

Caso 1: Nibi (bot apprendimento del 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

È quello ben strano. Invii testo (romaji, hiragana, kanji, qualsiasi cosa), il bot lo converte in hiragana, genera TTS via VOICEVOX o Google TTS, e invia un messaggio audio su Discord.

// comando TTS: romaji → hiragana → audio → 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. Converti romaji → hiragana con Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

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

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

      // 4. Invia 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)
        }
      };
    }
  }
};

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

Persistenza con Supabase

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

// lettura/scrittura JSON su Supabase
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) {
  // Aggiungi 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 piccoli bot è perfetto.

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

Okay questa è folle.

Konosuba-RPG è un gioco completo su Discord. Combatti mob, guadagni XP, equipaggi accessori, sali di livello. Ogni battaglia genera un'immagine in tempo reale. Eviti gli spritesheet pre-renderizzati. L'immagine è composta dinamicamente dalle stat 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)
    ↓
Carica l'immagine su Discord
    ↓
Invia il messaggio con l'immagine

Tutto in meno di un secondo. È pazzesco.

Rendering di immagini sui Worker

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

// JSX → SVG → PNG sul Worker
import { Satori } from 'satori';
import initWasm from '@cf-wasm/resvg';

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

  // 1. Crea 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.

Cogli la potenza? È solo bello.

Game state e progressione

I dati del giocatore sono in Supabase:

// legge e aggiorna il giocatore in 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 stat nel DB. E poi rigeneri l'immagine con le nuove stat.

Interazioni: i pulsanti del gameplay

Il gioco usa interazioni pulsante per le azioni in combattimento:

// riga di pulsanti attacco/difesa
{
  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 fa POST di un'interazione con custom_id: 'battle_attack'. L'handler la instrada così:

// instrada i clic sui pulsanti battaglia
if (interaction.type === 3) {
  // Interazione componente (clic pulsante, 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 DB, rigeneri l'immagine, invii.

È un gioco a turni completo senza alcuna persistenza di connessione. Solo HTTP stateless. Completamente rotto.

Supabase: il DB fatto per i Worker

I database classici (PostgreSQL, MySQL, MongoDB) sono pensati 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. Appena rispondi al client, il Worker sparisce.

Non puoi fare questo:

// NON funziona sui Worker: connessione TCP persistente
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // connessione TCP persistente = morto

E anche i driver PostgreSQL nativi come pg o postgres.js usano connessioni TCP. Sui Worker, si piantano.

Supabase risolve tutto questo.

Supabase è un'API REST sopra PostgreSQL. Fai normali richieste HTTP. Ogni chiamata è indipendente, niente connessione persistente, niente stato da gestire. È perfettamente adatto al modello serverless.

// Funziona PERFETTAMENTE sui Worker: solo fetch HTTP
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 sui Worker. Zero configurazione, zero driver, zero connessione persistente.

Database Compatibile Worker? Perché
Supabase Sì REST API senza stato, HTTP puro
PlanetScale (MySQL) Parziale Connessione solo HTTPS, niente transazioni lunghe
Neon Parziale Branch serverless ma serve driver TCP
Turso (libSQL) Parziale HTTP possibile ma limitato
Prisma/Prisma Postgres No Richiede TCP persistente
MongoDB Atlas No Driver TCP, niente REST API nativa
Redis (Upstash) Sì REST API HTTP

Il vero vantaggio di Supabase non è solo il DB: è tutto l'ecosistema pensato edge-first:

  • Auth: API REST per gestire le sessioni, funziona senza stato
  • Storage: Upload/download di file via HTTP
  • Realtime: WebSocket opzionale, ma puoi anche fare poll via REST
  • Row Level Security: le regole di sicurezza sono nel DB, non nel tuo backend

Per un bot Discord serverless, Supabase è la scelta più semplice e affidabile. Non configuri alcun driver, non mantieni alcuna connessione, non gestisci alcun timeout. Invii solo richieste HTTP.

Se vuoi un vero esempio, guarda Nibi più sopra: il codice di persistenza è letteralmente readJson() e writeJson() su Supabase. Parti senza migration, senza schema complesso, senza config pesante. Funziona subito. E se il tuo bot diventa grosso, migri verso vere query SQL senza cambiare provider.

Polyfill: quando Node vuole girare sui Worker

Alcuni package si aspettano API Node. Kuromoji (parser kanji) usa XMLHttpRequest. I Worker hanno fetch, non XMLHttpRequest.

Soluzione semplice: aggiungi un polyfill in cima a index.ts:

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

O fai 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();

È hack basico, ma funziona.

Verso un pacchetto npm: hono-discord-interactions

A mano, fare un bot è tanto boilerplate:

  • Verifica la firma Discord
  • Instrada i tipi di interazione
  • Gestisce comandi, componenti, modali
  • Restituisce JSON valido

Si potrebbe astrarre tutto questo in un pacchetto npm. Tipo:

// handler dichiarativo per comandi slash
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: `Ciao ${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.

Deploy

Cloudflare Workers

npm install -D wrangler

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

# Secret
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://mio-bot.workers.dev/interactions

Costo: gratis fino a 100k richieste/giorno. Oltre: $0.50/milione.

Spoiler: non spenderai mai le 100k richieste a meno di avere 10 000 utenti attivi.

Vercel

npm run vercel:deploy

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

Uguale, gratis.

Entrambi insieme

Hono gira ovunque. Puoi deployare lo stesso codice su Cloudflare E Vercel. Utile per ridondanza o per testare prima di scegliere.

Checklist rapida

  1. Crea un'Applicazione sul Discord Developer Portal
  2. Copia PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Crea il progetto:
  4. Scrivi index.ts (verifica firma + routing)
  5. Registra le slash command (una volta):
  6. Deploy:
  7. Metti l'URL in Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord testa la connessione (devi rispondere al PING)
  9. Invita il bot su un server
  10. Fatto

Vantaggi contro limitazioni

Vantaggi

  • Costo minimo (gratis fino a 100k req/giorno)
  • Scalabile (senza gestione di connessione)
  • Semplice (senza boilerplate WebSocket)
  • Rapido (Cloudflare = server all'edge)
  • Portabile (codice Hono = più host)

Limitazioni

  • Ricevi solo le interazioni (slash command, pulsanti, modali), senza gli eventi server in tempo reale (membro entra, ruolo aggiunto, messaggio cancellato, ecc.)
  • 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 command)? Va bene.

Per concludere

Ho passato parecchio tempo a ottimizzare KonosubaRPG e Nibi per risparmiare o più richieste possibili, o per ridurre il tempo di processore a caldo, o per ridurre il boot cold. Risultato, ho performance pazzesche su quasi tutto. Bisogna sapere che avevo iniziato a cloudificare (non so nemmeno se si dice) la maggior parte dei miei progetti perché avevo una pigrizia monumentale a continuare a ospitarli sulla mia VM. Davvero, credo che siano le Github Action che mi hanno salvato la pelle. I worker anche, ma in realtà quando ho visto che potevo fare demoni con le Github Action e gli schedule, mi ha davvero salvato.

Scriverò senza dubbio un articolo su un progetto chiamato email-autoreply, quindi iscrivetevi al feed RSS per vederlo uscire presto :)).

I 3 punti da ricordare:

  1. Interaction endpoint = HTTP serverless: senza WebSocket, senza connessione persistente. Discord fa POST, tu rispondi. Gratis su Cloudflare.
  2. Hono è lo strumento perfetto: framework leggero (12KB), multi-runtime, zero dipendenze. Codice identico su Cloudflare, Vercel, Node, ovunque.
  3. Rendering immagini sui Worker = folle: Satori + Resvg (Wasm) ti lascia comporre UI dinamiche in JSX e convertirle in PNG in <100ms. Un gioco completo può girare su questo.

È malato.

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

Discord-Bot 100 % serverless: Hono + Cloudflare Workers = 💸 null

Ich habe ein paar Monate klassische Discord-Bots auf meiner eigenen Maschine gepflegt.

Immer offene WebSocket-Verbindung. Der Bot reconnectet allein um 3 Uhr morgens. Der Bot crasht, weil ich die Schafe schief anschaue. Die Rechnung steigt.

Eines Tages habe ich entdeckt: Warum eine Verbindung pflegen? Discord kann dir einfach die Events POSTen, die dich interessieren. Du antwortest, fertig.

Seit 2021 bietet Discord Interaction Endpoints an.

Das ist nur HTTP. Du arbeitest ohne WebSocket, ohne persistenten Zustand. Du empfängst eine Anfrage, du schickst JSON, fertig. Die nächste Anfrage kommt von allein.

Und das Beste: Cloudflare Workers ist gratis bis 100k Anfragen/Tag. Für 90 % der Bots sind das 0€/Monat.

Dieser Artikel zeigt dir, wie man einen Discord-Bot ohne WebSocket macht, mit Hono (ultraleichtes Webframework) und Cloudflare Workers. Ich zeige dir zwei echte Projekte: Nibi (Bot zum Japanischlernen, TTS, cool) und Konosuba-RPG (ein komplettes Discord-Spiel mit Echtzeit-Bildrendering).

WebSocket vs. Interaction Endpoints: warum das eine schlechte Idee war

Stell dir ein Minecraft-Spiel vor, in dem du die Verbindung offen halten musst, selbst wenn du nicht spielst.

Und der Server reconnectet automatisch jedes Mal, wenn er crasht. Du musst Timeouts handhaben, exponentielle Reconnects, den ganzen scheiß Boilerplate, den man hasst. Nur um Interaktionen zu empfangen.

Die Interaction Endpoints sind das Gegenteil. Discord POSTet auf deine URL. Du antwortest. Fertig.

Wenn dein Server crasht? Discord retryt 2-3 Mal und geht weiter. Zero 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: was ist das eigentlich?

Discord POSTet eine Anfrage auf deinen 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"

Das ist pures HTTP, mit minimalem lesbarem Stack.

Hono + Cloudflare Workers: die sparsame Combo

Hono ist ein Webframework, das 12KB wiegt. Es läuft überall: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... derselbe Code überall.

Cloudflare Workers ist Edge-Compute. Deine Anfragen kommen beim nächsten Server an. Antwortzeit: <100ms. Kosten: gratis bis 100k Anfragen/Tag.

Die Hono + Cloudflare-Combo ist das perfekte Match für einen Discord-Bot.

Hier der Minimalcode eines kompletten Bots:

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, dass es 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 es geschickt hat
  const interaction = JSON.parse(body);

  // 4. Je nach Typ antworten
  if (interaction.type === 1) {
    // Discord-Test (PING)
    return c.json({ type: 1 });
  }

  if (interaction.type === 2) {
    // Das ist ein 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 Zeilen reichen für einen funktionsfähigen Bot.

Du vergisst bot.login(), die Event Emitter und die Callback-Hölle. Du behältst nur HTTP.

Fürs Deploy auf Cloudflare:

npm install -D wrangler
npx wrangler deploy

Boom. Du hast eine URL wie https://mon-bot.workers.dev/interactions.

Du trägst das im Discord Developer Portal unter "INTERACTIONS ENDPOINT URL" ein, und Discord fängt an, deine Interaktionen dorthin zu schicken.

Die Signatur prüfen: keine Fake-Requests

Discord signiert jede Anfrage mit einem öffentlichen Schlüssel. Wenn du eine Anfrage mit schlechter Signatur empfängst? Das ist Spam. Ignorieren und weitermachen.

Das Paket discord-interactions macht den Job:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // exakter Rohtext (kein geparstes JSON !)
  signature,         // Header x-signature-ed25519
  timestamp,         // Header x-signature-timestamp
  publicKey          // aus Discord Dev Portal
);

Wichtige Falle: Die Signatur hängt vom exakten Body ab. Wenn du JSON parst und neu stringifizierst oder den Body loggst, brichst du die Signatur.

Zuerst prüfen. Danach parsen. Das ist die Reihenfolge, die zählt.

Fall 1: Nibi (Bot zum Japanischlernen)

Nibi ist ein Discord-Bot zum Japanischlernen. Simple Commands:

  • /dictionary kanji → zeigt Definitionen an
  • /pronounce テキスト → generiert TTS (Text-to-Speech)
  • /hello → Begrüßungsnachricht

Jeder Command ist eine 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

Der ist richtig schräg. Du schickst Text (Romaji, Hiragana, Kanji, egal was), der Bot konvertiert das in Hiragana, generiert TTS via VOICEVOX oder Google TTS und schickt eine Audionachricht auf Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Generiert TTS für japanischen Text',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Auszusprechender Text',
        required: true
      }
    ]
  },

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

    try {
      // 1. Romaji → Hiragana konvertieren mit Kuroshiro
      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 schicken
      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 : Audio konnte nicht generiert werden xD',
          flags: 64  // ephemeral (private Nachricht)
        }
      };
    }
  }
};

Das ist verrückt: Du rufst eine externe API auf, lädst eine Datei zu Discord hoch, schickst eine 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 Nutzer 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) {
  // Nutzer hinzufügen
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

Das ist sehr simpel (keine echten SQL-Queries, nur JSON), aber es funktioniert. Für kleine Bots perfekt.

Fall 2: Konosuba-RPG (Discord-Spiel mit Bildrendering)

Okay, das hier ist verrückt.

Konosuba-RPG ist ein komplettes Spiel auf Discord. Du bekämpfst Mobs, gewinnst XP, rüstest Accessoires aus, steigst Level auf. Jeder Battle generiert ein Bild in Echtzeit. Du vermeidest vorgerenderte Spritesheets. Das Bild wird dynamisch aus Spielerstats, Mob und Kampfzustand komponiert.

Und das Bild generiert sich in <500ms auf Cloudflare Workers. Buchstäblich.

Die Rendering-Architektur

Discord (du klickst "Attack")
    ↓
Cloudflare Worker empfängt die Interaktion
    ↓
Game-State-Update (XP, HP usw.)
    ↓
JSX mit Satori generieren
    ↓
SVG → PNG konvertieren mit Resvg (Wasm)
    ↓
Bild zu Discord hochladen
    ↓
Nachricht mit Bild schicken

Alles in weniger als einer Sekunde. Das ist dingue.

Bildrendering auf Workers-Seite

Konosuba nutzt 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 die 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 SVG. SVG wird PNG. <100ms auf einem Cloudflare Worker.

Du kapierst die Power? Das ist einfach schön.

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) aktualisiert die Stats in der DB. Und danach regenerierst du das Bild mit den neuen Stats.

Interaktionen: die Gameplay-Buttons

Das Spiel nutzt Button Interactions für 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 Interaktion mit custom_id: 'battle_attack'. Der Handler routet das:

if (interaction.type === 3) {
  // Component-Interaktion (Button-Klick usw.)
  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 DB, regenerierst das Bild, schickst.

Das ist ein komplettes Turn-based-Spiel ohne jegliche Connection-Persistenz. Nur stateless HTTP. Komplett kaputt.

Supabase: die DB gemacht für Workers

Klassische Datenbanken (PostgreSQL, MySQL, MongoDB) sind für persistente TCP-Verbindungen entworfen. Du öffnest einen Socket, hältst die Verbindung offen, schickst Queries. Problem: Cloudflare Workers unterstützt keine persistenten TCP-Verbindungen. Jede Anfrage ist ein ephemerer Prozess. Sobald du dem Client antwortest, verschwindet der Worker.

Du kannst das nicht machen:

// Das geht NICHT auf Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // persistente TCP-Verbindung = tot

Und selbst native PostgreSQL-Driver wie pg oder postgres.js nutzen TCP-Verbindungen. Auf Workers crashen sie.

Supabase löst das alles.

Supabase ist eine REST-API über PostgreSQL. Du machst normale HTTP-Anfragen. Jeder Call ist unabhängig, keine persistente Verbindung, kein zu verwaltender Zustand. Das passt perfekt zum Serverless-Modell.

// Das geht PERFEKT auf Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Der Supabase-Client (@supabase/supabase-js) nutzt fetch unter der Haube. Und fetch ist nativ auf Workers. Null Konfiguration, null Driver, null persistente Verbindung.

Datenbank Workers-kompatibel? Warum
Supabase ✅ Ja Zustandslose REST-API, pures HTTP
PlanetScale (MySQL) ⚠️ Teilweise Nur HTTPS-Verbindung, keine langen Transaktionen
Neon ⚠️ Teilweise Serverless-Branches aber TCP-Driver nötig
Turso (libSQL) ⚠️ Teilweise HTTP möglich aber limitiert
Prisma/Prisma Postgres ❌ Nein Braucht persistentes TCP
MongoDB Atlas ❌ Nein TCP-Driver, keine native REST-API
Redis (Upstash) ✅ Ja HTTP-REST-API

Der echte Vorteil von Supabase ist nicht nur die DB, es ist das ganze Ökosystem, das edge-first gedacht ist:

  • Auth: REST-API zum Verwalten der Sessions, funktioniert zustandslos
  • Storage: Datei-Upload/Download via HTTP
  • Realtime: Optionales WebSocket, aber du kannst auch via REST pollen
  • Row Level Security: Die Sicherheitsregeln liegen in der DB, nicht in deinem Backend

Für einen serverless Discord-Bot ist Supabase die simpelste und verlässlichste Wahl. Du konfigurierst keinen Driver, du pflegst keine Verbindung, du handhabst keinen Timeout. Du schickst nur HTTP-Anfragen.

Wenn du ein echtes Beispiel willst, schau dir Nibi oben an: Der Persistenzcode ist buchstäblich readJson() und writeJson() auf Supabase. Du startest ohne Migrationen, ohne komplexes Schema, ohne schwere Config. Das funktioniert direkt. Und wenn dein Bot groß wird, migrierst du zu echten SQL-Queries, ohne den Provider zu wechseln.

Polyfills: wenn Node auf Workers laufen will

Manche Pakete erwarten Node-APIs. Kuromoji (Kanji-Parser) nutzt XMLHttpRequest. Workers haben fetch, kein XMLHttpRequest.

Simpler Workaround: Ein Polyfill oben in der index.ts hinzufügen:

// XMLHttpRequest-Polyfill für kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Minimaler Stub
  } as any;
}

Oder ein dediziertes Modul machen:

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

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

Das ist simpler Hack, aber er funktioniert.

Zu einem npm-Paket: hono-discord-interactions

Von Hand ist einen Bot machen viel Boilerplate:

  • Discord-Signatur prüfen
  • Interaktionstypen routen
  • Commands, Components, Modals handhaben
  • Gültiges JSON zurückgeben

Man könnte das alles in einem npm-Paket abstrahieren. So in der Art:

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 Zeilen statt 200. Das würde Nibi locker halbieren.

Idee für später.

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

Resultierende URL: https://mon-bot.workers.dev/interactions

Kosten: gratis bis 100k Anfragen/Tag. Darüber: $0.50/Million.

Spoiler: Du wirst die 100k Anfragen nie ausgeben, außer du hast 10.000 aktive Nutzer.

Vercel

npm run vercel:deploy

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

Gleich, gratis.

Beide zugleich

Hono läuft überall. Du kannst denselben Code auf Cloudflare UND Vercel deployen. Nützlich für Redundanz oder zum Testen vor der Wahl.

Schnelle Checkliste

  1. Eine Application auf Discord Developer Portal erstellen
  2. PUBLIC_KEY, BOT_TOKEN, APP_ID kopieren
  3. Das Projekt erstellen:
  4. index.ts schreiben (Signaturprüfung + Routing)
  5. Die Slash Commands registrieren (einmal):
  6. Deployen:
  7. Die URL in Discord eintragen (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord testet die Verbindung (du musst auf PING antworten)
  9. Den Bot auf einen Server einladen
  10. Fertig

Vorteile vs. Grenzen

Vorteile

  • Minimale Kosten (gratis bis 100k Req/Tag)
  • Skalierbar (ohne Verbindungsverwaltung)
  • Simpel (ohne WebSocket-Boilerplate)
  • Schnell (Cloudflare = Edge-Server)
  • Portabel (Hono-Code = mehrere Hosts)

Grenzen

  • Du empfängst nur Interaktionen (Slash Commands, Buttons, Modals), ohne Server-Events in Echtzeit (Member joint, Rolle hinzugefügt, Nachricht gelöscht usw.)
  • 3 Sekunden Timeout zum Antworten, sonst zeigt Discord "Application did not respond"
  • Wenn du echte Events brauchst, braucht es einen separaten HTTP-Webhook oder eine auxiliare WebSocket-Verbindung

Für 90 % der Bots (alles basierend auf Slash Commands)? Passt.

Zum Abschluss

Ich habe ziemlich viel Zeit damit verbracht, KonosubaRPG und Nibi zu optimieren, um entweder so viele Anfragen wie möglich zu sparen oder die Hot-CPU-Zeit zu reduzieren oder den Cold Boot zu reduzieren. Ergebnis: Ich habe ziemlich überall saubere Perfs. Man muss wissen, dass ich angefangen hatte, die meisten meiner Projekte zu cloudifizieren (ich weiß nicht mal, ob man das sagt), weil ich monumental faul war, sie weiter auf meiner eigenen VM zu hosten. Wirklich, ich glaube, die Github Actions haben mir den Arsch gerettet. Die Workers auch, aber eigentlich, als ich sah, dass ich mit Github Actions und Schedules Daemons machen konnte, hat mich das wirklich gerettet, ehrlich.

Ich werde wohl einen Artikel über ein Projekt namens email-autoreply schreiben, also abonniert den RSS-Feed, um ihn bald erscheinen zu sehen :)).

Die 3 Punkte zum Mitnehmen :

  1. Interaction Endpoints = HTTP serverless, ohne WebSocket, ohne persistente Connection. Discord POSTet, du antwortest. Gratis 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) lässt dich dynamische UIs in JSX komponieren und in <100ms in PNG konvertieren. Ein komplettes Spiel kann darauf laufen.

Das ist krank.

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

Discord-бот 100% serverless : Hono + Cloudflare Workers

Как я заменил Discord-бота, стоившего мне 50€/мес, на

Discord-бот 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-игра с рендером картинок в реальном времени).

WebSocket против Interaction Endpoints : почему это была плохая идея

Представь игру Minecraft, где надо держать соединение открытым, даже когда не играешь.

А сервер автоматически переподключается каждый раз, когда крашится. Ты должен управлять таймаутами, экспоненциальными переподключениями, всем дерьмовым boilerplate, который ненавидим. Только ради получения взаимодействий.

Interaction endpoints это наоборот. Discord POSTит на твой URL. Ты отвечаешь. Конец.

Если твой сервер крашнулся? Discord ретраит 2-3 раза и идёт дальше. Zero drama.

Цена до : 50€/мес на Heroku только за то, чтобы процесс Node оставался жив.

Цена после : 0€/мес на Cloudflare до 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 это вычисления на 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,         // заголовок x-signature-ed25519
  timestamp,         // заголовок x-signature-timestamp
  publicKey          // из Discord Dev Portal
);

Важная ловушка : подпись зависит от точного body. Если парсишь JSON и снова stringify, или если логируешь 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 : заставить бота говорить

Она-то и прикольная. Шлёшь текст (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 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 получает взаимодействие
    ↓
Обновление 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.

Сечёшь мощь? Это просто красиво.

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);

Каждое действие (атака, защита, лечение) обновляет статы в базе. А потом регенерируешь картинку с новыми статами.

Взаимодействия : кнопки геймплея

Игра использует 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'. Хендлер роутит это :

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);
  }
}

И бум, считаешь урон, апдейтишь базу, регенерируешь картинку, отправляешь.

Это целая пошаговая игра без какого-либо персистентного соединения. Только stateless HTTP. Совершенно сломано.

Supabase : БД, сделанная для Workers

Классические базы данных (PostgreSQL, MySQL, MongoDB) спроектированы под персистентные TCP-соединения. Открываешь сокет, держишь соединение открытым, шлёшь запросы. Проблема : Cloudflare Workers не поддерживают персистентные TCP-соединения. Каждый запрос эфемерный процесс. Как только ответил клиенту, Worker исчезает.

Так делать нельзя :

// Это НЕ работает на Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // персистентное TCP-соединение = dead

А нативные драйверы 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 опционально, но можно и poll через REST
  • Row Level Security : правила безопасности в БД, а не в твоём бэкенде

Для serverless Discord-бота Supabase самый простой и надёжный выбор. Не настраиваешь драйвер, не держишь соединение, не управляешь таймаутами. Просто шлёшь HTTP-запросы.

Если хочешь настоящий пример, смотри Nibi выше : код персистентности это буквально readJson() и writeJson() на Supabase. Стартуешь без миграций, без сложной схемы, без тяжёлой конфигурации. Работает сразу. А если бот вырастет, мигрируешь на настоящие SQL-запросы, не меняя провайдера.

Полифиллы : когда Node хочет крутиться на Workers

Некоторые пакеты ожидают Node API. Kuromoji (парсер kanji) использует 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 вдвое легко.

Идея на потом.

Деплой

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. Создать Application в 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. Готово

Плюсы против лимитов

Плюсы

  • Минимальная цена (бесплатно до 100k req/день)
  • Масштабируемо (без управления соединениями)
  • Просто (без WebSocket boilerplate)
  • Быстро (Cloudflare = серверы на edge)
  • Портативно (код Hono = несколько хостов)

Лимиты

  • Ты получаешь только взаимодействия (slash commands, buttons, modals), без серверных событий реального времени (участник зашёл, роль добавлена, сообщение удалено и т.д.)
  • Таймаут 3 секунды на ответ -- иначе Discord показывает "Application did not respond"
  • Если нужны настоящие events -- нужен отдельный HTTP-webhook или вспомогательное WebSocket-соединение

Для 90 % ботов (всё на слеш-командах)? Годится.

В заключение

Я провёл немало времени, оптимизируя KonosubaRPG и Nibi, чтобы экономить то максимум запросов, то горячее процессорное время, то холодный старт. Итог : у меня приличные перфы почти во всём. Надо знать, что я начал клаудифицировать (даже не знаю, говорится ли так) большинство проектов, потому что было монументально лень продолжать хостить их на собственной VM. Реально, думаю, меня спасли Github Actions. Воркеры тоже, но вообще когда я увидел, что могу делать демонов через Github Actions и schedules, это меня реально спасло.

Я, наверное, напишу статью про проект под названием 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. Целая игра может на этом крутиться.

Это больно.

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

Cómo reemplacé un bot Discord que me costaba 50€/mes por cero euros: interaction endpoints, Hono, Workers, render de imagen en tiempo real, y un juego completo sin WebSocket.

Bot Discord 100% serverless: Hono + Cloudflare Workers = 💸 cero

Pasé unos meses manteniendo bots Discord clásicos en mi propia máquina.

Conexión WebSocket siempre abierta. El bot reconecta solo a las 3 de la mañana. El bot crashea porque miro las ovejas mal de reojo. La factura sube.

Un día descubrí: ¿por qué mantener una conexión? Discord puede simplemente postearte los eventos que te interesan. Respondes, listo.

Desde 2021, Discord ofrece los interaction endpoints.

Es solo HTTP. Trabajas sin WebSocket, sin estado persistente. Recibes una petición, envías JSON, se acabó. La petición siguiente llega sola.

Y lo mejor: Cloudflare Workers es gratis hasta 100k peticiones/día. Para el 90% de los bots, son 0€/mes.

Este artículo te muestra cómo hacer un bot Discord sin WebSocket usando Hono (framework web ultraligero) y Cloudflare Workers. Te voy a mostrar dos proyectos reales: Nibi (bot para aprender japonés, TTS, guay) y Konosuba-RPG (un juego Discord completo con render de imagen en tiempo real).

WebSocket vs. Interaction Endpoints: por qué era mala idea

Imagina un juego Minecraft donde tienes que mantener la conexión abierta incluso cuando no juegas.

Y el servidor se reconecta automáticamente cada vez que crashea. Tienes que gestionar los timeouts, las reconexiones exponenciales, todo el boilerplate de mierda que odiamos. Solo para recibir interacciones.

Los interaction endpoints son lo inverso. Discord hace POST a tu URL. Respondes. Se acabó.

¿Si tu servidor crashea? Discord reintenta 2-3 veces y pasa a otra cosa. Cero drama.

Coste antes: 50€/mes en Heroku solo para que un proceso Node siga vivo.

Coste después: 0€/mes en Cloudflare hasta 100k peticiones/día.

La arquitectura: ¿qué es al justo?

Discord hace POST de una petición a tu endpoint.

Discord: "¡Eh! ¡El usuario hizo clic en /ping!"
      ↓
   Tu URL (Cloudflare Worker)
      ↓
 Compruebas que es de verdad Discord (chequeo de firma)
      ↓
 Parseas el tipo de interacción
      ↓
 Ejecutas el handler
      ↓
 Devuelves JSON
      ↓
Discord: "Guay, voy a mostrarle eso al usuario"

Es HTTP puro, con una stack mínima y legible.

Hono + Cloudflare Workers: el combo económico

Hono es un framework web que pesa 12KB. Corre en todas partes: Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... el mismo código en todas partes.

Cloudflare Workers es cómputo en el edge. Tus peticiones llegan al servidor más cercano. Tiempo de respuesta: <100ms. Coste: gratis hasta 100k peticiones/día.

El combo Hono + Cloudflare es el match perfecto para un bot Discord.

Aquí 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. Recupera las cabeceras
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();

  // 2. Comprueba que es de verdad Discord (no 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 un 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 líneas bastan para un bot funcional.

Te olvidas de bot.login(), los event emitters y el callback hell. Solo guardas HTTP.

Para desplegar en Cloudflare:

npm install -D wrangler
npx wrangler deploy

Boom. Tienes una URL como https://mon-bot.workers.dev/interactions.

La metes en el Discord Developer Portal bajo "INTERACTIONS ENDPOINT URL", y Discord empieza a enviar tus interacciones ahí.

Verificar la firma: nada de fake requests

Discord firma cada petición con una clave pública. ¿Si recibes una petición con mala firma? Es spam. Ignora y sigue.

El paquete discord-interactions hace el trabajo:

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // texto en 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 re-stringify, o si logueas el body, rompes la firma.

Verifica primero. Parsea después. Ese es el orden que cuenta.

Caso 1: Nibi (bot aprendizaje del japonés)

Nibi es un bot 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

Es el que es bien raro. Envías texto (romaji, hiragana, kanji, lo que sea), el bot lo convierte a hiragana, genera TTS vía VOICEVOX o Google TTS, y envía un mensaje 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: imposible generar el audio xD',
          flags: 64  // ephemeral (mensaje privado)
        }
      };
    }
  }
};

Es una locura: llamas una API externa, subes un archivo a Discord, envías un mensaje con el archivo. Todo eso sin WebSocket, solo HTTP.

Persistencia con Supabase

Nibi usa Supabase como key-value store. Para comprobar 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 (sin verdaderas consultas SQL, solo JSON), pero funciona. Para los bots pequeños es perfecto.

Caso 2: Konosuba-RPG (juego Discord con render de imagen)

Okay esta es una locura.

Konosuba-RPG es un juego completo en Discord. Combates mobs, ganas XP, equipas accesorios, subes de nivel. Cada battle genera una imagen en tiempo real. Evitas los spritesheets prerenderizados. 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 render

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 eso en menos de un segundo. Es una locura.

Render 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 vuelve SVG. SVG se vuelve PNG. <100ms en un Cloudflare Worker.

¿Captas la potencia? Es simplemente hermoso.

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 base. 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 la rutea:

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);
  }
}

Y boom, calculas los daños, actualizas la base, regeneras la imagen, envías.

Es un juego turn-based completo sin ninguna persistencia de conexión. Solo HTTP stateless. Completamente roto.

Supabase: la DB hecha para los Workers

Las bases de datos clásicas (PostgreSQL, MySQL, MongoDB) están diseñadas para conexiones TCP persistentes. Abres un socket, mantienes la conexión abierta, envías peticiones. Problema: Cloudflare Workers no soporta las 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 = dead

E incluso los drivers PostgreSQL nativos como pg o postgres.js usan conexiones TCP. En Workers, petan.

Supabase resuelve todo eso.

Supabase es una API REST sobre PostgreSQL. Haces peticiones HTTP normales. Cada llamada es independiente, sin conexión persistente, sin estado que gestionar. Está perfectamente adaptado al modelo serverless.

// Esto funciona PERFECTO en Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

El cliente Supabase (@supabase/supabase-js) usa fetch bajo el capó. Y fetch es nativo en Workers. Cero configuración, cero driver, cero conexión persistente.

Base de datos ¿Compatible Workers? Por qué
Supabase ✅ Sí REST API sin estado, HTTP puro
PlanetScale (MySQL) ⚠️ Parcial Conexión HTTPS solo, sin transacciones largas
Neon ⚠️ Parcial Ramas serverless pero driver TCP necesario
Turso (libSQL) ⚠️ Parcial HTTP posible pero limitado
Prisma/Prisma Postgres ❌ No Necesita TCP persistente
MongoDB Atlas ❌ No Driver TCP, sin REST API nativa
Redis (Upstash) ✅ Sí REST API HTTP

La verdadera ventaja de Supabase no es solo la DB: es todo el ecosistema pensado edge-first:

  • Auth: API REST para gestionar las sesiones, funciona sin estado
  • Storage: Subida/descarga de archivos vía HTTP
  • Realtime: WebSocket opcional, pero también puedes sondear vía REST
  • Row Level Security: las reglas de seguridad están en la DB, no en tu backend

Para un bot Discord serverless, Supabase es la elección más simple y fiable. No configuras ningún driver, no mantienes ninguna conexión, no gestionas ningún timeout. Solo envías peticiones HTTP.

Si quieres un verdadero ejemplo, mira Nibi arriba: el código de persistencia es literalmente readJson() y writeJson() sobre Supabase. Arrancas sin migraciones, sin esquema complejo, sin config pesada. Funciona directo. Y si tu bot se vuelve grande, migras hacia verdaderas consultas SQL sin cambiar de provider.

Polyfills: cuando Node quiere correr en Workers

Algunos paquetes esperan APIs Node. Kuromoji (parser kanji) usa XMLHttpRequest. Los Workers tienen fetch, no XMLHttpRequest.

Solución simple: añadir un polyfill arriba 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 hack básico, pero funciona.

Hacia un paquete npm: hono-discord-interactions

A mano, hacer un bot es mucho boilerplate:

  • Comprueba la firma Discord
  • Rutea los tipos de interacción
  • Gestiona los comandos, components, modals
  • Devuelve JSON válido

Se podría abstraer todo eso en un paquete 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;

Boum. 20 líneas en vez de 200. Eso reduciría Nibi a la mitad fácil.

Idea para más tarde.

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

Coste: gratis hasta 100k peticiones/día. Más allá: $0.50/millón.

Spoiler: nunca gastarás las 100k peticiones 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 corre en todas partes. Puedes desplegar el mismo código en Cloudflare Y Vercel. Útil para la redundancia o probar antes de elegir.

Checklist rápida

  1. Crear una Aplicación en el Discord Developer Portal
  2. Copiar PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Crear el proyecto:
  4. Escribir index.ts (verif firma + routing)
  5. Registrar los slash commands (una vez):
  6. Deploy:
  7. Meter la URL en Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord prueba la conexión (tienes que responder al PING)
  9. Invitar el bot a un servidor
  10. Listo

Ventajas vs Limitaciones

Ventajas

  • Coste mínimo (gratis hasta 100k req/día)
  • Escalable (sin gestión de conexión)
  • Simple (sin boilerplate WebSocket)
  • Rápido (Cloudflare = servidores en el edge)
  • Portable (código Hono = varios hosts)

Limitaciones

  • Solo recibes las interacciones (slash commands, buttons, modals), sin los eventos servidor en tiempo real (miembro se une, rol añadido, mensaje borrado, etc.)
  • Timeout de 3 segundos para responder: si no Discord muestra "Application did not respond"
  • Si necesitas verdaderos events: hace falta 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 o bien el máximo de peticiones posibles, o bien para reducir el tiempo de procesador en caliente, o bien para reducir el boot cold. Resultado, tengo unos perfs de locos en casi todo. Hay que saber que había empezado a cloudificar (ni siquiera sé si eso se dice) la mayoría de mis proyectos porque tenía una pereza monumental de seguir alojándolos en mi propia VM. De verdad, creo que son las Github Actions las que me salvaron el pellejo. Los workers también, pero en realidad cuando vi que podía hacer daemons con las Github Actions y los schedules, eso me salvó de verdad.

Seguro escribiré un artículo sobre un proyecto llamado email-autoreply, ¡así que suscribíos al feed RSS para verlo salir próximamente :))!

Los 3 puntos a recordar:

  1. Interaction endpoints = HTTP serverless: sin WebSocket, sin conexión persistente. Discord hace POST, respondes. Gratis en Cloudflare.
  2. Hono es la herramienta perfecta: Framework ligero (12KB), multiruntime, cero dependencias. Código idéntico en Cloudflare, Vercel, Node, en todas partes.
  3. Render de imagen en Workers = locura: Satori + Resvg (Wasm) te deja componer UIs dinámicas en JSX y convertirlas en PNG en <100ms. Un juego completo puede correr sobre eso.

Es una locura.

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

Bot Discord 100% serverless : Hono + Cloudflare Workers

Bagaimana saya mengganti bot Discord yang menghabiskan 50€/bulan dengan

Bot Discord 100% serverless : Hono + Cloudflare Workers = 💸 nol

Saya menghabiskan beberapa bulan memelihara bot Discord klasik di mesin saya sendiri.

Koneksi WebSocket selalu terbuka. Bot menyambung ulang sendiri jam 3 pagi. Bot crash karena saya memandang domba dengan cara yang salah. Tagihannya naik.

Suatu hari saya menemukan : kenapa mempertahankan koneksi ? Discord bisa POST saja event yang menarik buatmu. Kamu menjawab, selesai.

Sejak 2021, Discord menawarkan interaction endpoints.

Ini cuma HTTP. Kamu bekerja tanpa WebSocket, tanpa keadaan persisten. Kamu menerima permintaan, kamu mengirim JSON, selesai. Permintaan berikutnya datang sendiri.

Dan yang terbaik : Cloudflare Workers gratis hingga 100k permintaan/hari. Untuk 90% bot, itu 0€/bulan.

Artikel ini menunjukkan cara membuat bot Discord tanpa WebSocket memakai Hono (framework web ultra ringan) dan Cloudflare Workers. Saya akan menunjukkan dua proyek nyata : Nibi (bot untuk belajar bahasa Jepang, TTS, keren) dan Konosuba-RPG (permainan Discord lengkap dengan render gambar waktu nyata).

WebSocket vs. Interaction Endpoints : kenapa itu ide buruk

Bayangkan permainan Minecraft di mana kamu harus menjaga koneksi tetap terbuka bahkan ketika tidak bermain.

Dan server menyambung ulang otomatis setiap kali crash. Kamu harus menangani timeout, reconnect eksponensial, semua boilerplate menyebalkan yang kita benci. Hanya untuk menerima interaksi.

Interaction endpoints kebalikannya. Discord POST ke URL-mu. Kamu menjawab. Selesai.

Kalau servermu crash? Discord retry 2-3 kali lalu lanjut. Zero drama.

Biaya sebelumnya : 50€/bulan di Heroku hanya supaya proses Node tetap hidup.

Biaya sesudahnya : 0€/bulan di Cloudflare hingga 100k permintaan/hari.

Arsitekturnya : sebenarnya apa?

Discord POST permintaan ke endpoint-mu.

Discord: "Eh! Pengguna mengklik /ping!"
      ↓
    URL-mu (Cloudflare Worker)
      ↓
 Kamu memverifikasi bahwa ini benar-benar Discord (pemeriksaan tanda tangan)
      ↓
 Kamu memparsing tipe interaksi
      ↓
 Kamu mengeksekusi handler
      ↓
 Kamu mengembalikan JSON
      ↓
Discord: "Oke, saya tampilkan ke pengguna"

Ini HTTP murni, dengan stack minimal dan mudah dibaca.

Hono + Cloudflare Workers : kombo hemat

Hono adalah framework web seberat 12KB. Ia berjalan di mana-mana : Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... kode yang sama di mana-mana.

Cloudflare Workers adalah komputasi di edge. Permintaanmu tiba di server terdekat. Waktu respons : <100ms. Biaya : gratis hingga 100k permintaan/hari.

Kombo Hono + Cloudflare adalah pasangan sempurna untuk bot Discord.

Inilah kode minimal bot lengkap :

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

const app = new Hono();

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

  // 2. Memverifikasi bahwa ini 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 ia kirim
  const interaction = JSON.parse(body);

  // 4. Menjawab menurut 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;

30 baris cukup untuk bot fungsional.

Kamu melupakan bot.login(), event emitter dan callback hell. Kamu hanya menyimpan HTTP.

Untuk deploy ke Cloudflare :

npm install -D wrangler
npx wrangler deploy

Boom. Kamu punya URL seperti https://mon-bot.workers.dev/interactions.

Kamu menaruh itu di Discord Developer Portal di bawah "INTERACTIONS ENDPOINT URL", dan Discord mulai mengirim interaksimu ke sana.

Memverifikasi tanda tangan : tanpa permintaan palsu

Discord menandatangani setiap permintaan dengan kunci publik. Kalau kamu menerima permintaan dengan tanda tangan buruk? Itu spam. Abaikan dan lanjut.

Paket discord-interactions mengerjakan tugasnya :

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // teks mentah yang persis (bukan JSON ter parsing !)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // dari Discord Dev Portal
);

Jebakan penting : tanda tangan bergantung pada body yang persis. Kalau kamu memparsing JSON dan me re-stringify, atau kalau kamu log body, kamu merusak tanda tangan.

Verifikasi dulu. Parsing setelahnya. Urutan itulah yang penting.

Kasus 1 : Nibi (bot belajar bahasa Jepang)

Nibi adalah bot Discord untuk belajar bahasa Jepang. Perintah sederhana :

  • /dictionary kanji → menampilkan definisi
  • /pronounce テキスト → membangkitkan TTS (text-to-speech)
  • /hello → pesan sambutan

Setiap perintah adalah satu berkas TypeScript :

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

Satu perintah mengimplementasikan antarmuka ini :

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

Perintah /pronounce : membuat bot bicara

Inilah yang aneh banget. Kamu mengirim teks (romaji, hiragana, kanji, apa saja), bot mengubahnya menjadi hiragana, membangkitkan TTS lewat VOICEVOX atau Google TTS, dan mengirim pesan audio di Discord.

const pronounce: Command = {
  data: {
    name: 'pronounce',
    description: 'Membangkitkan TTS untuk teks Jepang',
    options: [
      {
        type: 3,  // STRING
        name: 'text',
        description: 'Texte à prononcer',
        required: true
      }
    ]
  },

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

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

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

      // 3. Mengunggah berkas ke Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Mengirim pesan dengan audio
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // durée en secondes
        env.BOT_TOKEN
      );

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

Ini gila : kamu memanggil API eksternal, mengunggah berkas ke Discord, mengirim pesan dengan berkasnya. Semua itu tanpa WebSocket, hanya HTTP.

Persistensi dengan Supabase

Nibi memakai Supabase sebagai key-value store. Untuk memeriksa apakah pengguna 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) {
  // Menambahkan pengguna
  users.push({ id: interaction.member.user.id, verified: true });
  await DatabaseUtils.writeJson('users', users);
}

Ini sangat dasar (tanpa query SQL sungguhan, hanya JSON), tapi berhasil. Untuk bot kecil ini sempurna.

Kasus 2 : Konosuba-RPG (permainan Discord dengan render gambar)

Oke yang ini gila.

Konosuba-RPG adalah permainan lengkap di Discord. Kamu melawan mob, mendapatkan XP, melengkapi aksesori, naik level. Setiap battle membangkitkan gambar waktu nyata. Kamu menghindari spritesheet pra-render. Gambar disusun dinamis dari stat pemain, mob, dan keadaan tempur.

Dan gambar dibangkitkan dalam <500ms di Cloudflare Workers. Secara harfiah.

Arsitektur render

Discord (tu cliques "Attack")
    ↓
Cloudflare Worker reçoit l'interaction
    ↓
Pembaruan game state (XP, HP, dll.)
    ↓
Membangkitkan JSX dengan Satori
    ↓
Mengonversi SVG → PNG dengan Resvg (Wasm)
    ↓
Mengunggah gambar ke Discord
    ↓
Mengirim pesan dengan gambar

Semua itu dalam kurang dari sedetik. Gila.

Render gambar di sisi Workers

Konosuba memakai 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. Membuat 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 menulis JSX normal. Itu menjadi SVG. SVG menjadi PNG. <100ms di Cloudflare Worker.

Kamu menangkap kekuatannya? Ini indah banget.

Game state dan progresi

Data pemain ada 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, sembuh) memperbarui stat di basis data. Lalu kamu membangkitkan ulang gambar dengan stat baru.

Interaksi : tombol gameplay

Permainan memakai interaksi tombol untuk aksi tempur :

{
  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'
    }
  ]
}

Ketika kamu mengklik "Attack", Discord POST interaksi dengan custom_id: 'battle_attack'. Handler me route itu :

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);
  }
}

Dan boom, kamu menghitung damage, memperbarui basis data, membangkitkan ulang gambar, mengirim.

Ini permainan turn-based lengkap tanpa persistensi koneksi apa pun. Hanya HTTP stateless. Benar-benar rusak.

Supabase : DB yang dibuat untuk Workers

Basis data klasik (PostgreSQL, MySQL, MongoDB) dirancang untuk koneksi TCP persisten. Kamu membuka socket, menjaga koneksi tetap terbuka, mengirim query. Masalah : Cloudflare Workers tidak mendukung koneksi TCP persisten. Setiap permintaan adalah proses fana. Begitu kamu menjawab klien, Worker menghilang.

Kamu tidak bisa melakukan ini :

// Ini TIDAK jalan di Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // connexion TCP persistante = dead

Dan bahkan driver PostgreSQL native seperti pg atau postgres.js memakai koneksi TCP. Di Workers, mereka crash.

Supabase menyelesaikan semua itu.

Supabase adalah API REST di atas PostgreSQL. Kamu membuat permintaan HTTP normal. Setiap panggilan independen, tanpa koneksi persisten, tanpa keadaan yang dikelola. Ini sangat cocok dengan model serverless.

// Ini jalan SEMPURNA di Workers
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Klien Supabase (@supabase/supabase-js) memakai fetch di balik kapnya. Dan fetch native di Workers. Nol konfigurasi, nol driver, nol koneksi persisten.

Basis data Kompatibel Workers? Kenapa
Supabase ✅ Ya REST API tanpa keadaan, HTTP murni
PlanetScale (MySQL) ⚠️ Sebagian Koneksi HTTPS saja, tanpa transaksi panjang
Neon ⚠️ Sebagian Branch serverless tapi driver TCP diperlukan
Turso (libSQL) ⚠️ Sebagian HTTP mungkin tapi dibatasi
Prisma/Prisma Postgres ❌ Tidak Membutuhkan TCP persisten
MongoDB Atlas ❌ Tidak Driver TCP, tanpa REST API native
Redis (Upstash) ✅ Ya REST API HTTP

Keunggulan nyata Supabase bukan sekadar DB -- melainkan seluruh ekosistem yang dirancang edge-first :

  • Auth : API REST untuk mengelola sesi, berfungsi tanpa keadaan
  • Storage : Upload/download berkas lewat HTTP
  • Realtime : WebSocket opsional, tapi kamu juga bisa polling lewat REST
  • Row Level Security : aturan keamanan ada di DB, bukan di backend-mu

Untuk bot Discord serverless, Supabase adalah pilihan paling sederhana dan paling andal. Kamu tidak mengonfigurasi driver apa pun, tidak memelihara koneksi apa pun, tidak mengelola timeout apa pun. Kamu hanya mengirim permintaan HTTP.

Kalau kamu mau contoh nyata, lihat Nibi di atas : kode persistensinya secara harfiah readJson() dan writeJson() di Supabase. Kamu mulai tanpa migrasi, tanpa skema rumit, tanpa config berat. Langsung jalan. Dan kalau botmu jadi besar, kamu migrasi ke query SQL sungguhan tanpa ganti provider.

Polyfill : ketika Node ingin berjalan di Workers

Beberapa paket mengharapkan API Node. Kuromoji (parser kanji) memakai XMLHttpRequest. Workers punya fetch, bukan XMLHttpRequest.

Solusi sederhana : tambahkan polyfill di atas index.ts :

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

Atau buat modul khusus :

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

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

Ini hack dasar, tapi berhasil.

Dengan tangan, membuat bot itu banyak boilerplate :

  • Memverifikasi tanda tangan Discord
  • Me route tipe interaksi
  • Mengelola perintah, komponen, modal
  • Mengembalikan JSON valid

Kita bisa mengabstraksi semua itu dalam paket npm. Semisal :

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 baris alih-alih 200. Itu akan memangkas Nibi setengahnya dengan mudah.

Ide untuk nanti.

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 hasilnya : https://mon-bot.workers.dev/interactions

Biaya : gratis hingga 100k permintaan/hari. Sesudahnya : $0.50/juta.

Bocoran : kamu tidak akan pernah menghabiskan 100k permintaan kecuali punya 10 000 pengguna aktif.

Vercel

npm run vercel:deploy

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

Sama, gratis.

Keduanya sekaligus

Hono berjalan di mana-mana. Kamu bisa mendeploy kode yang sama di Cloudflare DAN Vercel. Berguna untuk redundansi atau menguji sebelum memilih.

Checklist cepat

  1. Membuat Aplikasi di Discord Developer Portal
  2. Menyalin PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Membuat proyek :
  4. Menulis index.ts (verif tanda tangan + routing)
  5. Mendaftarkan slash command (sekali) :
  6. Deploy :
  7. Menaruh URL di Discord (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord menguji koneksi (kamu harus menjawab PING)
  9. Mengundang bot ke server
  10. Selesai

Kelebihan vs Keterbatasan

Kelebihan

  • Biaya minimal (gratis hingga 100k req/hari)
  • Skalabel (tanpa manajemen koneksi)
  • Sederhana (tanpa boilerplate WebSocket)
  • Cepat (Cloudflare = server di edge)
  • Portabel (kode Hono = banyak host)

Keterbatasan

  • Kamu hanya menerima interaksi (slash command, tombol, modal), tanpa event server waktu nyata (anggota bergabung, peran ditambahkan, pesan dihapus, dll.)
  • Timeout 3 detik untuk menjawab -- kalau tidak Discord menampilkan "Application did not respond"
  • Kalau kamu butuh event sungguhan -- perlu webhook HTTP terpisah atau koneksi WebSocket tambahan

Untuk 90% bot (semua berbasis slash command)? Ini cukup.

Untuk menyimpulkan

Saya menghabiskan cukup banyak waktu mengoptimalkan KonosubaRPG dan Nibi untuk menghemat entah permintaan sebanyak mungkin, atau mengurangi waktu prosesor panas, atau mengurangi boot dingin. Hasilnya, saya punya performa gila di hampir semuanya. Perlu tahu bahwa saya mulai meng cloudifikasi (entah itu kata yang benar) sebagian besar proyek saya karena saya malas banget terus menghostingnya di VM saya sendiri. Sungguh, saya rasa Github Actions-lah yang menyelamatkan kulit pantat saya. Workers juga, tapi sebenarnya ketika saya melihat bahwa saya bisa membuat daemon dengan Github Actions dan schedule, itu benar-benar menyelamatkan saya.

Saya mungkin akan menulis artikel tentang proyek bernama email-autoreply, jadi berlanggananlah ke feed RSS untuk melihatnya keluar segera :)).

3 poin yang perlu diingat :

  1. Interaction endpoints = HTTP serverless -- tanpa WebSocket, tanpa koneksi persisten. Discord POST, kamu menjawab. Gratis di Cloudflare.
  2. Hono alat yang sempurna -- Framework ringan (12KB), multi-runtime, nol dependensi. Kode identik di Cloudflare, Vercel, Node, di mana-mana.
  3. Render gambar di Workers = gila -- Satori + Resvg (Wasm) membiarkanmu menyusun UI dinamis dalam JSX dan mengubahnya menjadi PNG dalam <100ms. Permainan lengkap bisa berjalan di atas itu.

Ini sakit.

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

Discord बॉट 100% serverless : Hono + Cloudflare Workers

मैंने 50€/महीना वाला Discord बॉट शून्य euro से कैसे बदला, interaction endpoint, Hono, Workers, वास्तविक समय छवि रेंडर, और बिना WebSocket पूरा गेम।

Discord बॉट 100% serverless : Hono + Cloudflare Workers = 💸 शून्य

मैंने अपनी मशीन पर क्लासिक Discord बॉट बनाए रखने में कुछ महीने बिताए।

हमेशा खुला WebSocket कनेक्शन। बॉट रात 3 बजे खुद फिर से जुड़ता है। बॉट crash करता है क्योंकि मैं भेड़ों को टेढ़ा देखता हूँ। बिल बढ़ता है।

एक दिन मैंने खोजा : कनेक्शन बनाए क्यों रखना ? Discord तुम्हें सिर्फ रुचि की घटनाएँ POST कर सकता है। तुम जवाब देते हो, हो गया।

2021 से, Discord interaction endpoint देता है।

यह बस HTTP है। तुम बिना WebSocket, बिना स्थायी स्थिति काम करते हो। तुम अनुरोध पाते हो, तुम JSON भेजते हो, खत्म। अगला अनुरोध अपने आप आता है।

और सबसे अच्छा : Cloudflare Workers 100k अनुरोध/दिन तक मुफ्त है। 90% बॉट के लिए, यह 0€/महीना है।

यह लेख दिखाता है कि Hono (अति हल्का वेब framework) और Cloudflare Workers इस्तेमाल करके बिना WebSocket Discord बॉट कैसे बनाया जाए। मैं तुम्हें दो असली प्रोजेक्ट दिखाऊँगा : Nibi (जापानी सीखने का बॉट, TTS, cool) और Konosuba-RPG (वास्तविक समय छवि रेंडर वाला पूरा Discord गेम)।

WebSocket बनाम Interaction Endpoint : यह बुरा विचार क्यों था

Minecraft खेल कल्पना करो जहाँ तुम्हें कनेक्शन खुला रखना पड़ता है जब तुम खेल भी नहीं रहे।

और सर्वर हर crash पर अपने आप फिर से जुड़ता है। तुम्हें timeout, घातांकीय पुनः जुड़ाव, सारा घटिया boilerplate संभालना पड़ता है जिससे हम नफरत करते हैं। सिर्फ interaction पाने के लिए।

interaction endpoint इसका उल्टा है। Discord तुम्हारे URL पर POST करता है। तुम जवाब देते हो। खत्म।

अगर तुम्हारा सर्वर crash करता है ? Discord 2-3 बार retry करता है और आगे बढ़ता है। Zero drama।

पहले लागत : सिर्फ Node प्रक्रिया जीवित रखने के लिए Heroku पर 50€/महीना।

बाद में लागत : 100k अनुरोध/दिन तक Cloudflare पर 0€/महीना।

आर्किटेक्चर : यह ठीक क्या है ?

Discord तुम्हारे endpoint पर अनुरोध POST करता है।

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"

यह शुद्ध HTTP है, न्यूनतम और पठनीय stack के साथ।

Hono + Cloudflare Workers : किफायती combo

Hono एक वेब framework है जो 12KB का है। वह हर जगह चलता है : Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... हर जगह वही कोड।

Cloudflare Workers edge पर compute है। तुम्हारे अनुरोध निकटतम सर्वर पर पहुँचते हैं। उत्तर समय : <100ms। लागत : 100k अनुरोध/दिन तक मुफ्त।

Hono + Cloudflare combo Discord बॉट के लिए सही match है।

पूरे बॉट का न्यूनतम कोड यहाँ है :

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 है (spam नहीं)
  const isValid = verifyKey(
    body,
    signature,
    timestamp,
    c.env.PUBLIC_KEY
  );
  if (!isValid) return c.text('Invalid request', 401);

  // 3. उसने क्या भेजा parse करता है
  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 रखते हो।

Cloudflare पर deploy करने के लिए :

npm install -D wrangler
npx wrangler deploy

धड़ाम। तुम्हें https://mon-bot.workers.dev/interactions जैसा URL मिलता है।

तुम इसे Discord Developer Portal में "INTERACTIONS ENDPOINT URL" के नीचे रखते हो, और Discord वहाँ तुम्हारे interaction भेजना शुरू करता है।

हस्ताक्षर जाँचना : कोई नकली अनुरोध नहीं

Discord हर अनुरोध सार्वजनिक कुंजी से हस्ताक्षर करता है। अगर तुम्हें गलत हस्ताक्षर वाला अनुरोध मिलता है ? यह spam है। नजरअंदाज करो और आगे बढ़ो।

discord-interactions पैकेज काम करता है :

import { verifyKey } from 'discord-interactions';

const isValid = verifyKey(
  rawBody,           // सटीक कच्चा पाठ (pars किया JSON नहीं !)
  signature,         // header x-signature-ed25519
  timestamp,         // header x-signature-timestamp
  publicKey          // Discord Dev Portal से
);

महत्वपूर्ण जाल : हस्ताक्षर सटीक body पर निर्भर करता है। अगर तुम JSON parse करके फिर से stringify करते हो, या body log करते हो, तो तुम हस्ताक्षर तोड़ते हो।

पहले जाँचो। बाद में parse करो। यही क्रम मायने रखता है।

मामला 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 कमांड : बॉट को बुलवाना

यह वह है जो अच्छी तरह अजीब है। तुम पाठ भेजते हो (romaji, hiragana, kanji, कुछ भी), बॉट उसे hiragana में बदलता है, 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 से romaji → hiragana बदलना
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. TTS ऑडियो बनाना
      const audioBuffer = await generateTTS(hiragana);

      // 3. फाइल Discord पर upload करना
      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: 'Erreur : impossible de générer l\'audio xD',
          flags: 64  // ephemeral (निजी संदेश)
        }
      };
    }
  }
};

यह पागल है : तुम बाहरी API बुलाते हो, तुम Discord पर फाइल upload करते हो, तुम फाइल के साथ संदेश भेजते हो। सब बिना 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 query नहीं, सिर्फ JSON), लेकिन काम करता है। छोटे बॉट के लिए एकदम सही है।

मामला 2 : Konosuba-RPG (छवि रेंडर वाला Discord गेम)

अच्छा यह पागल है।

Konosuba-RPG Discord पर पूरा गेम है। तुम mob से लड़ते हो, तुम XP जीतते हो, तुम सहायक वस्तुएँ सजाते हो, तुम स्तर बढ़ाते हो। हर battle छवि वास्तविक समय बनाती है। तुम पूर्व रेंडर spritesheet से बचते हो। छवि खिलाड़ी stats, mob और युद्ध स्थिति से गतिशील बनती है।

और छवि Cloudflare Workers पर <500ms में बनती है। सचमुच।

रेंडर आर्किटेक्चर

Discord (तुम "Attack" क्लिक करते हो)
    ↓
Cloudflare Worker interaction पाता है
    ↓
गेम स्थिति अद्यतन (XP, HP, आदि)
    ↓
Satori से JSX बनाता है
    ↓
Resvg (Wasm) से SVG → PNG बदलता है
    ↓
छवि Discord पर upload करता है
    ↓
छवि के साथ संदेश भेजता है

सब एक सेकंड से कम में। यह पागलपन है।

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।

शक्ति समझ रहे हो ? यह बस सुंदर है।

गेम स्थिति और प्रगति

खिलाड़ी डेटा 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 के साथ छवि फिर से बनाते हो।

Interaction : gameplay बटन

खेल युद्ध में क्रियाओं के लिए 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 करता है। 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 गेम है। सिर्फ stateless HTTP। पूरी तरह टूटा हुआ।

Supabase : Workers के लिए बनी DB

क्लासिक डेटाबेस (PostgreSQL, MySQL, MongoDB) स्थायी TCP कनेक्शन के लिए डिजाइन किए हैं। तुम socket खोलते हो, तुम कनेक्शन खुला रखते हो, तुम query भेजते हो। समस्या : Cloudflare Workers स्थायी TCP कनेक्शन समर्थन नहीं करते। हर अनुरोध अल्पकालिक प्रक्रिया है। जैसे ही तुम client को जवाब देते हो, Worker गायब हो जाता है।

तुम यह नहीं कर सकते :

// यह Workers पर काम नहीं करता
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // स्थायी TCP कनेक्शन = dead

और pg या postgres.js जैसे नेटिव PostgreSQL driver भी TCP कनेक्शन इस्तेमाल करते हैं। Workers पर, वे crash करते हैं।

Supabase यह सब सुलझाता है।

Supabase PostgreSQL के ऊपर REST API है। तुम सामान्य HTTP अनुरोध करते हो। हर कॉल स्वतंत्र है, कोई स्थायी कनेक्शन नहीं, प्रबंधित करने को कोई स्थिति नहीं। यह serverless मॉडल के लिए पूरी तरह अनुकूल है।

// यह Workers पर एकदम सही काम करता है
const { data, error } = await supabase
  .from('players')
  .select('*')
  .eq('discord_id', userId)
  .single();

Supabase client (@supabase/supabase-js) हुड के नीचे fetch इस्तेमाल करता है। और fetch Workers पर नेटिव है। शून्य कॉन्फ़िगरेशन, शून्य driver, शून्य स्थायी कनेक्शन।

डेटाबेस Workers संगत ? क्यों
Supabase ✅ हाँ बिना स्थिति REST API, शुद्ध HTTP
PlanetScale (MySQL) ⚠️ आंशिक सिर्फ HTTPS कनेक्शन, लंबे लेनदेन नहीं
Neon ⚠️ आंशिक Serverless शाखाएँ लेकिन जरूरी TCP driver
Turso (libSQL) ⚠️ आंशिक HTTP संभव लेकिन सीमित
Prisma/Prisma Postgres ❌ नहीं स्थायी TCP जरूरी
MongoDB Atlas ❌ नहीं TCP driver, कोई नेटिव REST API नहीं
Redis (Upstash) ✅ हाँ HTTP REST API

Supabase का असली फायदा सिर्फ DB नहीं है, यह पूरा पारिस्थितिकी तंत्र है जो edge-first सोचा गया है :

  • Auth : सत्र प्रबंधित करने के लिए REST API, बिना स्थिति काम करता है
  • Storage : HTTP से फाइल Upload/download
  • Realtime : वैकल्पिक WebSocket, लेकिन तुम REST से poll भी कर सकते हो
  • Row Level Security : सुरक्षा नियम DB में हैं, तुम्हारे backend में नहीं

serverless Discord बॉट के लिए, Supabase सबसे सरल और सबसे विश्वसनीय चुनाव है। तुम कोई driver कॉन्फ़िगर नहीं करते, तुम कोई कनेक्शन नहीं रखते, तुम कोई timeout नहीं संभालते। तुम बस HTTP अनुरोध भेजते हो।

अगर तुम्हें असली उदाहरण चाहिए, तो ऊपर Nibi देखो : स्थायित्व कोड सचमुच Supabase पर readJson() और writeJson() है। तुम बिना migration, बिना जटिल schema, बिना भारी config शुरू करते हो। यह सीधे काम करता है। और अगर तुम्हारा बॉट बड़ा होता है, तो तुम provider बदले बिना असली SQL query की ओर migrate करते हो।

Polyfill : जब Node Workers पर चलना चाहता है

कुछ पैकेज Node API अपेक्षा करते हैं। Kuromoji (kanji parser) 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

हाथ से, बॉट बनाना बहुत boilerplate है :

  • Discord हस्ताक्षर जाँचना
  • interaction प्रकार route करना
  • कमांड, component, modal संभालना
  • वैध 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 को आसानी से आधा घटा देगा।

बाद के लिए विचार।

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 अनुरोध/दिन तक मुफ्त। उसके बाद : $0.50/million।

स्पॉइलर : तुम 100k अनुरोध कभी खर्च नहीं करोगे जब तक 10 000 सक्रिय उपयोगकर्ता न हों।

Vercel

npm run vercel:deploy

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

वही, मुफ्त।

दोनों एक साथ

Hono हर जगह चलता है। तुम वही कोड Cloudflare और Vercel पर डिप्लॉय कर सकते हो। चुनने से पहले अतिरेक या परीक्षण के लिए उपयोगी।

त्वरित checklist

  1. Discord Developer Portal पर Application बनाना
  2. PUBLIC_KEY, BOT_TOKEN, APP_ID कॉपी करना
  3. प्रोजेक्ट बनाना :
  4. index.ts लिखना (हस्ताक्षर जाँच + routing)
  5. slash command दर्ज करना (एक बार) :
  6. Deploy :
  7. Discord में URL रखना (Developer Portal → Application → Interactions Endpoint URL)
  8. Discord कनेक्शन परखता है (तुम्हें PING का जवाब देना होगा)
  9. सर्वर पर बॉट आमंत्रित करना
  10. हो गया

फायदे बनाम सीमाएँ

फायदे

  • न्यूनतम लागत (100k req/दिन तक मुफ्त)
  • Scalable (बिना कनेक्शन प्रबंधन)
  • सरल (बिना WebSocket boilerplate)
  • तेज (Cloudflare = edge सर्वर)
  • Portable (Hono कोड = कई host)

सीमाएँ

  • तुम्हें सिर्फ interaction मिलते हैं (slash command, button, modal), बिना वास्तविक समय सर्वर घटनाओं (सदस्य जुड़ा, भूमिका जुड़ी, संदेश हटाया, आदि) के
  • जवाब देने के लिए 3 सेकंड timeout, वरना Discord "Application did not respond" दिखाता है
  • अगर तुम्हें असली event चाहिए, अलग HTTP webhook या सहायक WebSocket कनेक्शन चाहिए

90% बॉट (सब slash command आधारित) के लिए ? यह अच्छा है।

समाप्त करने के लिए

मैंने KonosubaRPG और Nibi अनुकूलित करने में काफी समय बिताया ताकि या तो ज्यादा से ज्यादा अनुरोध बचाए जाएँ, या गर्म प्रोसेसर समय घटाया जाए, या cold boot घटाया जाए। परिणाम, मुझे लगभग सब पर शानदार perf मिली। जानना चाहिए कि मैंने अपने ज्यादातर प्रोजेक्ट cloudify करना (पता नहीं यह कहा भी जाता है) शुरू किया था क्योंकि मुझे उन्हें अपनी VM पर होस्ट करते रहने की बहुत आलस थी। सच में, मुझे लगता है कि Github Actions ने ही मेरी जान बचाई है। Workers भी, लेकिन असल में जब मैंने देखा कि मैं Github Actions और schedule से daemon बना सकता हूँ, तो इसने सच में मुझे बचाया।

मैं email-autoreply नामक प्रोजेक्ट पर लेख लिखने वाला हूँ, इसलिए उसे जल्द निकलता देखने के लिए RSS feed subscribe करो :))।

याद रखने के 3 बिंदु :

  1. Interaction endpoint = HTTP serverless , बिना WebSocket, बिना स्थायी connection के। Discord POST करता है, तुम जवाब देते हो। Cloudflare पर मुफ्त।
  2. Hono सही उपकरण है , हल्का Framework (12KB), multi-runtime, शून्य निर्भरताएँ। Cloudflare, Vercel, Node, हर जगह समान कोड।
  3. Workers पर Renderimage = पागल , Satori + Resvg (Wasm) तुम्हें JSX में गतिशील UI बनाने और उन्हें <100ms में PNG में बदलने देता है। पूरा गेम इस पर चल सकता है।

यह बीमार है।

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 نقاط التفاعل.

إنها مجرد HTTP. تعمل دون WebSocket ودون حالة دائمة. تستلم طلبا، وترسل JSON، انتهى. يصل الطلب التالي وحده.

والأفضل : Cloudflare Workers مجاني حتى 100k طلب/يوم. لـ90% من البوتات، هذا 0€/شهر.

يريك هذا المقال كيف تصنع بوت Discord دون WebSocket باستخدام Hono (إطار ويب خفيف للغاية) وCloudflare Workers. سأريك مشروعين حقيقيين : Nibi (بوت لتعلم اليابانية وTTS ورائع) وKonosuba-RPG (لعبة Discord كاملة مع عرض صور في الوقت الحقيقي).

WebSocket ضد نقاط التفاعل : لماذا كانت فكرة سيئة

تخيل لعبة Minecraft حيث يجب إبقاء الاتصال مفتوحا حتى عندما لا تلعب.

ويعيد الخادم الاتصال تلقائيا كلما انهار. يجب أن تدير المهلات وإعادات الاتصال الأسّية، وكل boilerplate اللعين الذي نكرهه. فقط لاستلام تفاعلات.

نقاط التفاعل هي العكس. يرسل Discord POST على رابطك. ترد. انتهى.

إذا انهار خادمك ؟ يعيد Discord المحاولة 2-3 مرات ويمضي لشيء آخر. صفر دراما.

الkost قبل : 50€/شهر على Heroku فقط ليبقى عملية Node حية.

الkost بعد : 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. الـkost : مجاني حتى 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 (لا spam)
  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() وباعثات الأحداث وجحيم callbacks. تحتفظ فقط بـHTTP.

للنشر على Cloudflare :

npm install -D wrangler
npx wrangler deploy

بوم. لديك رابط مثل https://mon-bot.workers.dev/interactions.

تضع ذلك في Discord Developer Portal تحت "INTERACTIONS ENDPOINT URL"، ويبدأ Discord إرسال تفاعلاتك هناك.

التحقق من التوقيع : لا طلبات مزيفة

يوقّع Discord كل طلب بمفتاح عام. إذا استلمت طلبا بتوقيع سيئ ؟ إنه spam. تجاهل وواصل.

تقوم حزمة 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 وأعدت تحويله لسلسلة، أو سجلت الجسم، تكسر التوقيع.

تحقق أولا. حلل بعدها. هذا هو الترتيب المهم.

الحالة 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، وتجهز إكسسوارات، وتصعد مستوى. كل معركة تولد صورة في الوقت الحقيقي. تتجنب spritesheets المُصيَّرة مسبقا. تُركَّب الصورة ديناميكيا من إحصاءات اللاعب والوحش وحالة القتال.

وتُولَّد الصورة في <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.

تستوعب القوة ؟ إنها جميلة فقط.

حالة اللعبة والتقدم

بيانات اللاعب في 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) {
  // تفاعل مكون (نقرة زر، إلخ.)
  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 عديم الحالة. معطوبة تماما.

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 : قواعد الأمان في قاعدة البيانات، لا في خلفيتك

لبوت Discord بلا خادم، 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: `مرحبا ${interaction.member.user.username}!` }
      })
    }
  ]
});

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

بوم. 20 سطرا بدلا من 200. سيقلّص ذلك Nibi إلى النصف بسهولة.

فكرة لاحقا.

النشر

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

الkost : مجاني حتى 100k طلب/يوم. بعدها : $0.50/مليون.

تشويق : لن تنفق أبدا 100k طلب إلا إذا كان لديك 10 000 مستخدم نشط.

Vercel

npm run vercel:deploy

الرابط : 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. تسجيل slash commands (مرة) :
  6. النشر :
  7. وضع الرابط في Discord (Developer Portal ← Application ← Interactions Endpoint URL)
  8. يختبر Discord الاتصال (يجب أن ترد على PING)
  9. دعوة البوت على خادم
  10. انتهى الأمر

مزايا ضد حدود

مزايا

  • kost دنيا (مجاني حتى 100k طلب/يوم)
  • قابل للتوسع (دون إدارة اتصال)
  • بسيط (دون boilerplate خاص بـWebSocket)
  • سريع (Cloudflare = خوادم عند الحافة)
  • محمول (كود Hono = مضيفون متعددون)

حدود

  • تستلم فقط التفاعلات (slash commands وأزرار ونوافذ)، دون أحداث الخادم في الوقت الحقيقي (انضمام عضو وإضافة دور وحذف رسالة، إلخ.)
  • مهلة 3 ثوانٍ للرد، وإلا يعرض Discord "Application did not respond"
  • إذا احتجت أحداثا حقيقية، يلزم webhook HTTP منفصل أو اتصال WebSocket مساعد

لـ90% من البوتات (كلها قائمة على slash commands) ؟ إنه جيد.

للختم

قضيت وقتا طويلا في تحسين KonosubaRPG وNibi لتوفير إما أكبر عدد طلبات ممكن، أو لتقليل زمن المعالج الساخن، أو لتقليل الإقلاع البارد. النتيجة، لدي أداء رائع في كل شيء تقريبا. اعلم أنني بدأت في جعل معظم مشاريعي سحابية (لا أعرف حتى إن قيل ذلك) لأن كسلا هائلا منعني من مواصلة استضافتها على VM خاصتي. فعلا، أظن أن Github Actions هي التي أنقذت جلدي. Workers أيضا، لكن في الواقع عندما رأيت أنني أستطيع صنع daemons مع Github Actions والجداول، أنقذني ذلك فعلا.

سأكتب غالبا مقالا عن مشروع اسمه email-autoreply، إذن اشتركوا في تدفق RSS لرؤيته يصدر قريبا :)).

النقاط الثلاث للتذكر :

  1. نقاط التفاعل = HTTP بلا خادم، دون WebSocket ودون اتصال دائم. يرسل Discord POST، وترد. مجاني على Cloudflare.
  2. Hono هو الأداة المثالية، إطار خفيف (12KB) ومتعدد runtime وصفر تبعيات. كود مطابق على Cloudflare وVercel وNode وفي كل مكان.
  3. عرض الصور على Workers = مجنون، يتيح لك Satori + Resvg (Wasm) تركيب واجهات ديناميكية في JSX وتحويلها إلى PNG في <100ms. يمكن للعبة كاملة العمل على ذلك.

إنه مريض.

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

Cách tôi thay bot Discord tốn 50€/tháng bằng

Bot Discord 100% serverless : Hono + Cloudflare Workers = 💸 0 đồng

Tôi từng dành vài tháng duy trì bot Discord cổ điển trên máy của mình.

Kết nối WebSocket luôn mở. Bot tự reconnect lúc 3h sáng. Bot crash vì tôi nhìn cừu chằm chằm sai cách. Hóa đơn leo thang.

Một ngày tôi phát hiện : vì sao phải duy trì kết nối? Discord có thể POST đúng sự kiện bạn quan tâm. Bạn trả lời, xong.

Từ 2021, Discord cung cấp interaction endpoint.

Đó chỉ là HTTP. Bạn làm việc không WebSocket, không trạng thái bền vững. Bạn nhận yêu cầu, gửi JSON, xong. Yêu cầu tiếp theo tự tới.

Và hay nhất : Cloudflare Workers miễn phí tới 100k yêu cầu/ngày. Với 90% bot, là 0€/tháng.

Bài này chỉ bạn cách làm bot Discord không WebSocket bằng Hono (framework web siêu nhẹ) và Cloudflare Workers. Tôi sẽ cho bạn thấy hai dự án thật : Nibi (bot học tiếng Nhật, TTS, ngầu) và Konosuba-RPG (game Discord đầy đủ với render ảnh thời gian thực).

WebSocket so với Interaction Endpoint : vì sao ý cũ tệ

Tưởng tượng game Minecraft nơi bạn phải giữ kết nối mở ngay cả khi không chơi.

Và máy chủ tự reconnect mỗi lần crash. Bạn phải quản lý timeout, reconnect lũy thừa, cả đống boilerplate rác ta ghét. Chỉ để nhận tương tác.

Interaction endpoint thì ngược lại. Discord POST lên URL của bạn. Bạn trả lời. Xong.

Nếu máy chủ của bạn crash? Discord retry 2-3 lần rồi bỏ qua. Không drama.

Chi phí trước : 50€/tháng trên Heroku chỉ để một tiến trình Node sống.

Chi phí sau : 0€/tháng trên Cloudflare tới 100k yêu cầu/ngày.

Kiến trúc : thực chất là gì?

Discord POST yêu cầu lên endpoint của bạn.

Discord: "Ê! Người dùng vừa bấm /ping!"
      ↓
   URL của bạn (Cloudflare Worker)
      ↓
 Bạn kiểm tra đúng là Discord (kiểm tra chữ ký)
      ↓
 Bạn phân tích kiểu tương tác
      ↓
 Bạn chạy handler
      ↓
 Bạn trả về JSON
      ↓
Discord: "Hay, tôi sẽ hiện nó cho người dùng"

Đó là HTTP thuần, với stack tối thiểu và dễ đọc.

Hono + Cloudflare Workers : combo tiết kiệm

Hono là framework web nặng 12KB. Nó chạy khắp nơi : Cloudflare Workers, Vercel, AWS Lambda, Deno, Bun... cùng mã khắp nơi.

Cloudflare Workers là compute ở biên. Yêu cầu của bạn tới máy chủ gần nhất. Thời gian phản hồi : <100ms. Chi phí : miễn phí tới 100k yêu cầu/ngày.

Combo Hono + Cloudflare là cặp hoàn hảo cho bot Discord.

Đây là mã tối thiểu của một bot đầy đủ :

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

const app = new Hono();

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

  // 2. Kiểm tra đúng 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. Phân tích những gì nó gửi
  const interaction = JSON.parse(body);

  // 4. Trả lời theo kiểu
  if (interaction.type === 1) {
    // Discord kiểm tra (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;

30 dòng đủ cho bot chạy được.

Bạn quên bot.login(), event emitter và callback hell. Bạn chỉ giữ HTTP.

Để deploy lên Cloudflare :

npm install -D wrangler
npx wrangler deploy

Bùm. Bạn có URL kiểu https://mon-bot.workers.dev/interactions.

Bạn đặt nó vào Discord Developer Portal dưới "INTERACTIONS ENDPOINT URL", và Discord bắt đầu gửi tương tác của bạn tới đó.

Kiểm tra chữ ký : không yêu cầu giả mạo

Discord ký mỗi yêu cầu bằng khóa công khai. Nếu bạn nhận yêu cầu chữ ký sai? Đó là spam. Bỏ qua và tiếp tục.

Gói discord-interactions làm việc này :

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 body chính xác. Nếu bạn parse JSON rồi stringify lại, hay log body, bạn phá chữ ký.

Kiểm tra trước. Parse sau. Thứ tự mới là điều quan trọng.

Trường hợp 1 : Nibi (bot học tiếng Nhật)

Nibi là bot Discord học tiếng Nhật. Lệnh đơn giản :

  • /dictionary kanji → hiện định nghĩa
  • /pronounce テキスト → tạo TTS (text-to-speech)
  • /hello → tin nhắn chào

Mỗi lệnh là một tệp TypeScript :

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

Một lệnh triển khai 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

Đây là cái hay ho. Bạn gửi văn bản (romaji, hiragana, kanji, bất cứ gì), bot đổi nó sang hiragana, tạo TTS qua VOICEVOX hay Google TTS, và gửi tin nhắn thoại trê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. Đổi romaji → hiragana bằng Kuroshiro
      const hiragana = await kuroshiro.convert(text, { to: 'hiragana' });

      // 2. Tạo âm thanh TTS
      const audioBuffer = await generateTTS(hiragana);

      // 3. Tải tệp lên Discord
      const uploadFilename = await uploadToDiscord(
        audioBuffer,
        interaction.channel.id,
        env.BOT_TOKEN
      );

      // 4. Gửi tin nhắn kèm âm thanh
      await sendVoiceMessage(
        interaction.channel.id,
        uploadFilename,
        audioBuffer.length / 16000,  // độ dài 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 tạo được âm thanh xD',
          flags: 64  // ephemeral (tin nhắn riêng tư)
        }
      };
    }
  }
};

Thật điên : bạn gọi API ngoài, tải tệp lên Discord, gửi tin nhắn kèm tệp. Tất cả không WebSocket, chỉ HTTP.

Bền vững với Supabase

Nibi dùng Supabase làm 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 truy vấn SQL thật, chỉ JSON), nhưng chạy được. Với bot nhỏ là hoàn hảo.

Trường hợp 2 : Konosuba-RPG (game Discord render ảnh)

OK cái này điên thật.

Konosuba-RPG là game đầy đủ trên Discord. Bạn đánh mob, nhận XP, trang bị phụ kiện, lên cấp. Mỗi trận tạo một ảnh thời gian thực. Bạn tránh spritesheet render trước. Ảnh được dựng động từ stat người chơi, mob, và trạng thái trận.

Và ảnh sinh ra trong <500ms trên Cloudflare Workers. Theo nghĩa đen.

Kiến trúc render

Discord (bạn bấm "Attack")
    ↓
Cloudflare Worker nhận tương tác
    ↓
Cập nhật trạng thái game (XP, HP, v.v.)
    ↓
Tạo JSX bằng Satori
    ↓
Đổi SVG → PNG bằng Resvg (Wasm)
    ↓
Tải ảnh lên Discord
    ↓
Gửi tin nhắn kèm ảnh

Tất cả dưới một giây. Thật kinh ngạc.

Render ảnh phía Workers

Konosuba 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 nắm sức mạnh chưa? Đẹp đơn thuần.

Trạng thái game và tiến trình

Dữ liệu người chơi nằm 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 stat trong DB. Rồi bạn tạo lại ảnh với stat mới.

Tương tác : nút bấm gameplay

Game dùng tương tác nút bấm cho hành động trận :

{
  type: 1,  // ActionRow
  components: [
    {
      type: 2,  // Button
      style: 1,  // Primary (xanh)
      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 tương tác với custom_id: 'battle_attack'. Handler định tuyến nó :

if (interaction.type === 3) {
  // Tương tác thành phần (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à bùm, bạn tính sát thương, cập nhật DB, tạo lại ảnh, gửi đi.

Đó là game theo lượt đầy đủ không cần chút bền vững kết nối nào. Chỉ HTTP stateless. Hoàn toàn điên.

Supabase : DB làm cho Workers

Cơ sở dữ liệu cổ điển (PostgreSQL, MySQL, MongoDB) thiết kế cho kết nối TCP bền vững. Bạn mở socket, giữ kết nối mở, gửi truy vấn. Vấn đề : Cloudflare Workers không hỗ trợ kết nối TCP bền vững. Mỗi yêu cầu là tiến trình phù du. Hễ bạn trả lời client, Worker biến mất.

Bạn không thể làm vầy :

// Việc này KHÔNG chạy trên Workers
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();  // kết nối TCP bền vững = 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 sập.

Supabase giải quyết tất cả.

Supabase là API REST trên PostgreSQL. Bạn gửi yêu cầu HTTP bình thường. Mỗi gọi độc lập, không kết nối bền vững, không trạng thái cần quản lý. Hoàn toàn hợp mô hình serverless.

// Việc 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) dùng fetch bên dưới. Và fetch gốc trên Workers. Không cấu hình, không driver, không kết nối bền vững.

Cơ sở dữ liệu Tương thích Workers? Vì sao
Supabase ✅ Có API REST không trạng thái, HTTP thuần
PlanetScale (MySQL) ⚠️ Một phần Chỉ kết nối HTTPS, không giao dịch dài
Neon ⚠️ Một phần Nhánh 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 Đòi TCP bền vững
MongoDB Atlas ❌ Không Driver TCP, không API REST gốc
Redis (Upstash) ✅ Có API REST HTTP

Lợi thế thật của Supabase không chỉ là DB -- mà là cả hệ sinh thái nghĩ edge-first :

  • Auth : API REST quản lý phiên, chạy không trạng thái
  • Storage : Tải lên/tải xuống tệp qua HTTP
  • Realtime : WebSocket tùy chọn, nhưng bạn cũng có thể poll qua REST
  • Row Level Security : quy tắc bảo mật nằm trong DB, không trong backend của bạn

Với bot Discord serverless, Supabase là lựa chọn đơn giản và đáng tin nhất. Bạn không cấu hình driver, không duy trì kết nối, không quản lý timeout. Bạn chỉ gửi yêu cầu HTTP.

Nếu bạn muốn ví dụ thật, xem Nibi ở trên : mã bền vững đúng là readJson() và writeJson() trên Supabase. Bạn khởi động không migration, không schema phức tạp, không config nặng. Chạy ngay. Và nếu bot của bạn lớn lên, bạn chuyển sang truy vấn SQL thật mà không đổi provider.

Polyfill : khi Node muốn chạy trên Workers

Vài gói mong đợi API Node. Kuromoji (trình phân tích kanji) dùng XMLHttpRequest. Workers có fetch, không có XMLHttpRequest.

Giải pháp đơn giản : thêm polyfill lên đầu index.ts :

// Polyfill XMLHttpRequest cho kuromoji
if (!globalThis.XMLHttpRequest) {
  globalThis.XMLHttpRequest = class {
    // Stub tối thiểu
  } as any;
}

Hay làm module chuyên :

// 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 chạy.

Hướng tới gói npm : hono-discord-interactions

Làm tay, bot là nhiều boilerplate :

  • Kiểm tra chữ ký Discord
  • Định tuyến kiểu tương tác
  • Quản lý lệnh, component, modal
  • Trả về JSON hợp lệ

Ta có thể trừu tượng tất cả vào gói 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. Việc đó giảm Nibi một nửa dễ dàng.

Ý tưởng để sau.

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í tới 100k yêu cầu/ngày. Vượt : $0.50/triệu.

Tiết lộ : bạn sẽ không bao giờ xài hết 100k yêu cầu trừ khi có 10 000 người dùng hoạt động.

Vercel

npm run vercel:deploy

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

Cũng vậy, miễn phí.

Cả hai cùng lúc

Hono chạy khắp nơi. Bạn có thể deploy cùng mã lên Cloudflare VÀ Vercel. Hữu ích cho dự phòng hay thử trước khi chọn.

Checklist nhanh

  1. Tạo Application trên Discord Developer Portal
  2. Chép PUBLIC_KEY, BOT_TOKEN, APP_ID
  3. Tạo dự án :
  4. Viết index.ts (kiểm tra chữ ký + định tuyến)
  5. Đăng ký slash command (một lần) :
  6. Deploy :
  7. Đặt URL vào 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 lên máy chủ
  10. Xong

Ưu điểm so với Giới hạn

Ưu điểm

  • Chi phí tối thiểu (miễn phí tới 100k req/ngày)
  • Mở rộng được (không quản lý kết nối)
  • Đơn giản (không boilerplate WebSocket)
  • Nhanh (Cloudflare = máy chủ ở biên)
  • Di động (mã Hono = nhiều host)

Giới hạn

  • Bạn chỉ nhận tương tác (slash command, nút, modal), không có sự kiện máy chủ thời gian thực (thành viên tham gia, vai trò thêm, tin nhắn xóa, v.v.)
  • Timeout 3 giây để trả lời -- nếu không Discord hiện "Application did not respond"
  • Nếu bạn cần event thật -- cần webhook HTTP riêng hay kết nối WebSocket phụ

Với 90% bot (mọi thứ dựa trên slash command)? Ổn.

Kết luận

Tôi đã dành khá nhiều thời gian tối ưu KonosubaRPG và Nibi để tiết kiệm hoặc nhiều yêu cầu nhất có thể, hoặc giảm thời gian xử lý nóng, hoặc giảm khởi động lạnh. Kết quả, tôi có hiệu năng khá ngon trên gần như mọi thứ. Phải biết là tôi đã bắt đầu cloud hóa (tôi thậm chí không biết có từ này không) hầu hết dự án của mình vì tôi lười khủng khiếp việc tiếp tục host chúng trên VM của mình. Thật lòng, tôi nghĩ chính Github Actions đã cứu mạng tôi. Workers cũng vậy, nhưng thực ra khi tôi thấy mình có thể làm daemon bằng Github Actions và schedule, việc đó thực sự cứu tôi.

Tôi có lẽ sẽ viết bài về dự án tên email-autoreply, nên hãy đăng ký feed RSS để thấy nó ra mắt sớm :)).

3 điểm cần nhớ :

  1. Interaction endpoint = HTTP serverless -- không WebSocket, không kết nối bền vững. 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, không phụ thuộc. Mã giống hệt trên Cloudflare, Vercel, Node, khắp nơi.
  3. Render ảnh trên Workers = điên -- Satori + Resvg (Wasm) cho bạn dựng UI động bằng JSX và đổi sang PNG trong <100ms. Game đầy đủ có thể chạy trên đó.

Điên thật.

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