Examples¶
Two complete, minimal bots, one in JavaScript and one in Python. Both do the same thing:
- fetch the gateway URL with
GET /gateway/bot, - connect, wait for Hello, then Identify (or Resume after a reconnect),
- heartbeat on the interval from Hello, answer heartbeat requests, and detect a dead connection,
- remember
session_idand the bot's user ID fromREADY, - answer every
!pingmessage with apongreply, - 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¶
- Create a bot and copy its token.
- Add it to a guild with at least View Channel, Send Messages and Read Message History (
permissions=68608). - Put the token in an environment variable, never in the source file:
The bot¶
Requires Node.js 18 or newer (for the built-in fetch) and the ws package.
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.
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_eventsin 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.