Bafflux Docs Developer Portal

Building bots

Interactions

Receive slash commands over the gateway or on your own HTTPS endpoint, and answer them.

When someone uses one of your slash commands, Bafflux creates an interaction and sends it to your bot. Your bot has 3 seconds to answer, or to say it needs longer.

Receiving them

On the gateway (the default): an INTERACTION_CREATE event arrives on your bot's WebSocket. If your bot isn't connected, the person is told it's offline and nothing is sent.

On an HTTP endpoint: set an Interactions Endpoint URL on the portal's General Information page. Bafflux then POSTs each interaction to that URL instead, which suits serverless hosts that can't keep a WebSocket open. See HTTP endpoints below.

The interaction object

json
{
	"type": "command",
	"id": "103745871234568192",
	"token": "6b0f...e91a",
	"applicationId": "103712345678901247",
	"channelId": "103700000000000001",
	"serverId": "103700000000000000",
	"user": {
		"id": "103600000000000000",
		"username": "andre",
		"displayName": "Andre",
		"avatarUrl": null,
		"roles": ["103700000000000123"]
	},
	"data": {
		"id": "103745000000000000",
		"name": "say",
		"options": [
			{ "name": "text", "type": "string", "value": "hello there" },
			{ "name": "times", "type": "integer", "value": 3 }
		]
	}
}

serverId is null when the command was used in a DM with your bot. Options that weren't filled in are left out.

Answering

Post to the interaction's callback URL, using its id and token (no bot token needed):

http
POST /api/interactions/{id}/{token}/callback
Content-Type: application/json

{ "type": "message", "content": "hello there hello there hello there" }
typeWhat happens
messagePosts content (1-2000 characters) and/or embeds in the channel, with "name used /command" above it
deferShows "Bot is thinking..." to the person and gives you 15 minutes to send a follow-up

A message can carry up to 10 embeds with "embeds": [...], with or without content:

json
{ "type": "message", "embeds": [{ "title": "Level 12", "color": 3065014, "fields": [{ "name": "Total XP", "value": "4,210", "inline": true }] }] }

Add "ephemeral": true to a message to show it only to the person who used the command. It isn't stored and disappears when they reload.

Each interaction takes one answer. A second one gets already_answered. Answering after 3 seconds without deferring gets interaction_expired, and the person sees that your bot didn't respond.

Follow-ups

After answering (or deferring), send more messages for 15 minutes:

http
POST /api/interactions/{id}/{token}/followup
Content-Type: application/json

{ "content": "Done! Here's your result.", "ephemeral": false }

Follow-ups take embeds too. After a defer, your first follow-up is the real answer.

Example: slow work

js
if (t === 'INTERACTION_CREATE' && d.data.name === 'report') {
	const url = `https://bafflux.com/api/interactions/${d.id}/${d.token}`;
	const post = (path, body) =>
		fetch(url + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });

	await post('/callback', { type: 'defer' }); // within 3 seconds
	const report = await buildReport(); // can take minutes
	await post('/followup', { content: report });
}

HTTP endpoints

With an Interactions Endpoint URL set, each interaction is POSTed there as JSON (the same object as above). Your endpoint must be public HTTPS and answer within 3 seconds.

Answer in the response. Reply 200 with a callback body ({"type": "message", ...} or {"type": "defer"}) and it's applied directly. You can also reply 200 with an empty body and answer through the callback URL instead.

Saving the URL sends {"type": "ping"} first. Answer {"type": "pong"} or the URL isn't saved.

Verifying requests

Every request is signed with your app's Signing Secret (portal, General Information). Check it, or anyone could send your endpoint fake commands.

HeaderValue
X-Bafflux-TimestampUnix time in seconds when we sent it
X-Bafflux-SignatureHex HMAC-SHA256 of timestamp + "." + body, keyed with the signing secret

Use the raw request body exactly as received, before parsing it. Reject timestamps more than 5 minutes old to stop replays.

js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, timestamp, signature, rawBody) {
	if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
	const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
	return signature?.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
python
import hmac, hashlib, time

def verify(secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

A complete endpoint

A Cloudflare Worker (free tier) that answers /ping:

js
export default {
	async fetch(request, env) {
		const body = await request.text();
		const ok = await verifyWorker(env.SIGNING_SECRET, request.headers.get('x-bafflux-timestamp'), request.headers.get('x-bafflux-signature'), body);
		if (!ok) return new Response('bad signature', { status: 401 });

		const interaction = JSON.parse(body);
		if (interaction.type === 'ping') return Response.json({ type: 'pong' });
		if (interaction.data?.name === 'ping') return Response.json({ type: 'message', content: 'Pong!' });
		return Response.json({ type: 'message', content: "I don't know that one.", ephemeral: true });
	}
};

// Same check as above, with the Web Crypto API that Workers have.
async function verifyWorker(secret, timestamp, signature, body) {
	if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
	const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
	const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${timestamp}.${body}`));
	const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
	return expected === signature;
}

Store the secret with wrangler secret put SIGNING_SECRET, then put the Worker's URL in the portal.