Skip to content

Examples

Two complete, minimal bots, one in JavaScript and one in Python. Both do the same thing:

  1. fetch the gateway URL with GET /gateway/bot,
  2. connect, wait for Hello, then Identify (or Resume after a reconnect),
  3. heartbeat on the interval from Hello, answer heartbeat requests, and detect a dead connection,
  4. remember session_id and the bot's user ID from READY,
  5. answer every !ping message with a pong reply,
  6. reconnect and resume when the connection drops, and stop on a fatal close code.

Everything they use is described in Gateway connection and Responding to messages.

Before you start

  1. Create a bot and copy its token.
  2. Add it to a guild with at least View Channel, Send Messages and Read Message History (permissions=68608).
  3. Put the token in an environment variable, never in the source file:
export TEBHOS_BOT_TOKEN="1290847135893127168.q3JxY2V0...W5k"
$env:TEBHOS_BOT_TOKEN = "1290847135893127168.q3JxY2V0...W5k"

The bot

Requires Node.js 18 or newer (for the built-in fetch) and the ws package.

npm install ws

Save as bot.mjs and run with node bot.mjs.

// bot.mjs - minimal TEBHOS bot that answers "!ping" with "pong".
import WebSocket from "ws";

const TOKEN = process.env.TEBHOS_BOT_TOKEN; // "{application_id}.{secret}"
const API = process.env.TEBHOS_API ?? "https://tebhos.com/api/v1";

// Close codes after which reconnecting cannot help.
const FATAL_CLOSE_CODES = new Set([4004, 4010, 4011, 4012]);
// Close codes after which the old session cannot be resumed.
const FRESH_SESSION_CLOSE_CODES = new Set([4001, 4002, 4003, 4005, 4007]);

let gatewayUrl = null;
let sessionId = null;
let seq = null;
let botUserId = null;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// REST helper: bot auth header, JSON body, waits and retries on 429.
async function api(method, path, body) {
  for (;;) {
    const res = await fetch(`${API}${path}`, {
      method,
      headers: { Authorization: `Bot ${TOKEN}`, "Content-Type": "application/json" },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
    if (res.status === 429) {
      const data = await res.json().catch(() => ({}));
      const seconds = Number(data.retry_after ?? res.headers.get("retry-after") ?? 1);
      await sleep(seconds * 1000);
      continue;
    }
    if (!res.ok) {
      throw new Error(`${method} ${path} failed: ${res.status} ${await res.text()}`);
    }
    return res.status === 204 ? null : res.json();
  }
}

async function handleMessage(message) {
  // Never answer yourself or other bots.
  if (message.author?.id === botUserId || message.author?.bot) return;
  if (message.content.trim() !== "!ping") return;
  await api("POST", `/channels/${message.channel_id}/messages`, {
    content: "pong",
    message_reference: { message_id: message.id },
  });
}

function handleDispatch(type, data) {
  switch (type) {
    case "READY":
      sessionId = data.session_id;
      botUserId = data.user.id;
      console.log(`Ready as ${data.user.username} (${botUserId})`);
      break;
    case "RESUMED":
      console.log("Session resumed");
      break;
    case "GUILD_CREATE":
      console.log(`Guild available: ${data.id}`);
      break;
    case "MESSAGE_CREATE":
      handleMessage(data).catch((error) => console.error(error));
      break;
  }
}

function connect() {
  const ws = new WebSocket(`${gatewayUrl}?v=1&encoding=json`);
  let heartbeatTimer = null;
  let firstBeatTimer = null;
  let acked = true;

  const send = (op, d) => ws.send(JSON.stringify({ op, d }));

  const beat = () => {
    if (!acked) {
      // No ACK since the last heartbeat: the connection is dead. Drop it and resume.
      ws.terminate();
      return;
    }
    acked = false;
    send(1, seq);
  };

  ws.on("message", (raw) => {
    const { op, d, s, t } = JSON.parse(raw.toString());
    if (s != null) seq = s;

    switch (op) {
      case 10: { // Hello
        const interval = d.heartbeat_interval;
        firstBeatTimer = setTimeout(() => {
          beat();
          heartbeatTimer = setInterval(beat, interval);
        }, interval * Math.random());

        if (sessionId && seq != null) {
          send(6, { token: TOKEN, session_id: sessionId, seq }); // Resume
        } else {
          send(2, { // Identify: the bare token, no "Bot " prefix
            token: TOKEN,
            properties: { os: process.platform, browser: "example-bot", device: "example-bot" },
          });
        }
        break;
      }
      case 11: // Heartbeat ACK
        acked = true;
        break;
      case 1: // The gateway asks for a heartbeat now
        send(1, seq);
        break;
      case 7: // Reconnect: drop this connection and resume on a new one
        ws.terminate();
        break;
      case 9: // Invalid Session: start over with a fresh Identify
        sessionId = null;
        seq = null;
        setTimeout(() => ws.terminate(), 1000 + Math.random() * 4000);
        break;
      case 0: // Dispatch
        handleDispatch(t, d);
        break;
    }
  });

  ws.on("close", (code) => {
    clearTimeout(firstBeatTimer);
    clearInterval(heartbeatTimer);
    if (FATAL_CLOSE_CODES.has(code)) {
      console.error(`Gateway closed with ${code}; not reconnecting.`);
      process.exit(1);
    }
    if (FRESH_SESSION_CLOSE_CODES.has(code)) {
      sessionId = null;
      seq = null;
    }
    console.log(`Disconnected (${code}); reconnecting in 2 s`);
    setTimeout(connect, 2000);
  });

  ws.on("error", (error) => console.error("Gateway error:", error.message));
}

if (!TOKEN) {
  console.error("Set TEBHOS_BOT_TOKEN first.");
  process.exit(1);
}
({ url: gatewayUrl } = await api("GET", "/gateway/bot"));
connect();

Requires Python 3.10 or newer with aiohttp and websockets.

pip install aiohttp websockets

Save as bot.py and run with python bot.py.

"""Minimal TEBHOS bot that answers "!ping" with "pong"."""
import asyncio
import json
import os
import random
import sys

import aiohttp
import websockets

TOKEN = os.environ.get("TEBHOS_BOT_TOKEN")  # "{application_id}.{secret}"
API = os.environ.get("TEBHOS_API", "https://tebhos.com/api/v1")

# Close codes after which reconnecting cannot help.
FATAL_CLOSE_CODES = {4004, 4010, 4011, 4012}
# Close codes after which the old session cannot be resumed.
FRESH_SESSION_CLOSE_CODES = {4001, 4002, 4003, 4005, 4007}


class Bot:
    def __init__(self, http: aiohttp.ClientSession):
        self.http = http
        self.session_id = None
        self.seq = None
        self.user_id = None
        self.acked = True
        self.tasks = set()

    async def api(self, method, path, body=None):
        """REST helper: bot auth header, JSON body, waits and retries on 429."""
        headers = {"Authorization": f"Bot {TOKEN}"}
        while True:
            async with self.http.request(method, API + path, json=body, headers=headers) as res:
                if res.status == 429:
                    try:
                        data = await res.json(content_type=None)
                        seconds = float(data.get("retry_after", 1))
                    except (ValueError, TypeError, AttributeError):
                        seconds = float(res.headers.get("Retry-After", 1))
                    await asyncio.sleep(seconds)
                    continue
                if res.status >= 400:
                    raise RuntimeError(f"{method} {path} failed: {res.status} {await res.text()}")
                return None if res.status == 204 else await res.json()

    async def send(self, ws, op, d):
        await ws.send(json.dumps({"op": op, "d": d}))

    async def heartbeat(self, ws, interval):
        await asyncio.sleep(interval * random.random())
        while True:
            if not self.acked:
                # No ACK since the last heartbeat: the connection is dead. Drop it and resume.
                await ws.close(code=4000)
                return
            self.acked = False
            await self.send(ws, 1, self.seq)
            await asyncio.sleep(interval)

    async def handle_message(self, message):
        author = message.get("author") or {}
        # Never answer yourself or other bots.
        if author.get("id") == self.user_id or author.get("bot"):
            return
        if message.get("content", "").strip() != "!ping":
            return
        await self.api("POST", f"/channels/{message['channel_id']}/messages", {
            "content": "pong",
            "message_reference": {"message_id": message["id"]},
        })

    def spawn(self, coro):
        task = asyncio.create_task(coro)
        self.tasks.add(task)
        task.add_done_callback(self.tasks.discard)

    def handle_dispatch(self, event, data):
        if event == "READY":
            self.session_id = data["session_id"]
            self.user_id = data["user"]["id"]
            print(f"Ready as {data['user']['username']} ({self.user_id})")
        elif event == "RESUMED":
            print("Session resumed")
        elif event == "GUILD_CREATE":
            print(f"Guild available: {data['id']}")
        elif event == "MESSAGE_CREATE":
            self.spawn(self.handle_message(data))

    async def connect_once(self, url):
        """Runs one gateway connection. Returns the close code, if any."""
        heartbeat_task = None
        try:
            async with websockets.connect(url, max_size=None) as ws:
                async for raw in ws:
                    payload = json.loads(raw)
                    op, d = payload["op"], payload.get("d")
                    if payload.get("s") is not None:
                        self.seq = payload["s"]

                    if op == 10:  # Hello
                        self.acked = True
                        heartbeat_task = asyncio.create_task(
                            self.heartbeat(ws, d["heartbeat_interval"] / 1000))
                        if self.session_id and self.seq is not None:
                            await self.send(ws, 6, {  # Resume
                                "token": TOKEN, "session_id": self.session_id, "seq": self.seq})
                        else:
                            await self.send(ws, 2, {  # Identify: the bare token, no "Bot " prefix
                                "token": TOKEN,
                                "properties": {"os": sys.platform, "browser": "example-bot",
                                               "device": "example-bot"},
                            })
                    elif op == 11:  # Heartbeat ACK
                        self.acked = True
                    elif op == 1:  # The gateway asks for a heartbeat now
                        await self.send(ws, 1, self.seq)
                    elif op == 7:  # Reconnect: drop this connection and resume on a new one
                        await ws.close(code=4000)
                    elif op == 9:  # Invalid Session: start over with a fresh Identify
                        self.session_id, self.seq = None, None
                        await asyncio.sleep(random.uniform(1, 5))
                        await ws.close(code=4000)
                    elif op == 0:  # Dispatch
                        self.handle_dispatch(payload.get("t"), d)
                return ws.close_code
        except websockets.ConnectionClosed as closed:
            return closed.rcvd.code if closed.rcvd else None
        except OSError as error:
            print(f"Connection failed: {error}")
            return None
        finally:
            if heartbeat_task:
                heartbeat_task.cancel()

    async def run(self):
        gateway = await self.api("GET", "/gateway/bot")
        url = gateway["url"] + "?v=1&encoding=json"
        while True:
            code = await self.connect_once(url)
            if code in FATAL_CLOSE_CODES:
                sys.exit(f"Gateway closed with {code}; not reconnecting.")
            if code in FRESH_SESSION_CLOSE_CODES:
                self.session_id, self.seq = None, None
            print(f"Disconnected ({code}); reconnecting in 2 s")
            await asyncio.sleep(2)


async def main():
    if not TOKEN:
        sys.exit("Set TEBHOS_BOT_TOKEN first.")
    async with aiohttp.ClientSession() as http:
        await Bot(http).run()


if __name__ == "__main__":
    asyncio.run(main())

Start the bot, type !ping in a channel it can see, and it replies pong.

How the pieces map to the protocol

Code Protocol step Reference
GET /gateway/bot Find the WebSocket URL Get the gateway URL
?v=1&encoding=json Required gateway version and JSON frames Connect
op 10 handler Start heartbeating, then Identify or Resume Receive Hello
op 2 with properties Authenticate with the bare token Identify
acked flag Detect a connection that stopped answering Heartbeat
READY handler Keep session_id and the bot's user ID Receive READY
op 6 after a drop Resume and receive missed events Resume
MESSAGE_CREATE handler Prefix command and reply Responding to messages
429 loop in api Wait retry_after seconds, then retry Rate limits

Adding more commands

Replace the single !ping check with a lookup table:

const commands = {
  ping: async (message) => ({ content: "pong" }),
  echo: async (message, args) => ({ content: args.join(" ") || "Nothing to echo." }),
};

async function handleMessage(message) {
  if (message.author?.id === botUserId || message.author?.bot) return;
  const content = message.content.trim();
  if (!content.startsWith("!")) return;
  const [name, ...args] = content.slice(1).split(/\s+/);
  const command = commands[name.toLowerCase()];
  if (!command) return;
  const reply = await command(message, args);
  await api("POST", `/channels/${message.channel_id}/messages`, {
    ...reply,
    message_reference: { message_id: message.id },
  });
}
async def cmd_ping(message, args):
    return {"content": "pong"}

async def cmd_echo(message, args):
    return {"content": " ".join(args) or "Nothing to echo."}

COMMANDS = {"ping": cmd_ping, "echo": cmd_echo}

# inside class Bot:
async def handle_message(self, message):
    author = message.get("author") or {}
    if author.get("id") == self.user_id or author.get("bot"):
        return
    content = message.get("content", "").strip()
    if not content.startswith("!"):
        return
    name, *args = content[1:].split() or [""]
    command = COMMANDS.get(name.lower())
    if command is None:
        return
    reply = await command(message, args)
    reply["message_reference"] = {"message_id": message["id"]}
    await self.api("POST", f"/channels/{message['channel_id']}/messages", reply)

Before you run it for real

  • Keep the token out of source control and logs. If it leaks, regenerate it; running bots are disconnected and must restart with the new token.
  • Send only the events you need to your code: pass ignored_events in Identify to drop the rest at the gateway, see Event filtering.
  • Handle 403 on sends gracefully: a guild can remove the bot's permissions in any channel at any time.
  • Stay under the per-channel message limit (20 per 10 seconds); queue replies instead of sending bursts.