Chapter 4. Finally, a tutorial.
Suppose we’ll build a bot that tracks volunteers who will bring items to a cookout. As you read, you can follow along in the completed code: Glitch :・゚✧
-
Get a token and user ID for your bot from Discord. This tutorial doesn’t cover the specifics of this, but it’s in the Discord Developer Portal Discord Developer Portal. For identification, we’ll need the bot’s user ID, which is labeled client ID on the application page (as far as I know, it’s all the same ID). We’ll use this to help figure out which messages are directed at the bot. For credentials, we’ll need the bot’s token. We’ll use this to connect to Discord’s API.
We need a suitable place to store these. On Glitch, the .env
file is the place to put secret information, such as the bot token. We’ll also put the client ID there because, while it’s not secret, it’s just nicer not to hard code it. With these, we’ll have this in .env
:
# from https://discord.com/developers/applications
DISCORD_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxx.xxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxx
# from https://discord.com/developers/applications
BOT_USER_ID=111111111111111111
We’ll load the token into our program under a config
object:
const config = {
token: process.env.DISCORD_TOKEN,
};
-
Write up some non-Discord-specific logic. We have a data model and a web service.
The data model we need for this bot is a small list of items we’re tracking and the user ID of who’s bringing each. The whole list is of a small bounded size, so we’ll keep the whole list in memory. When we run a bot on a platform like Glitch with DCC, our process will be started and stopped as needed, so having a copy in memory alone is not sufficient. For persistence across stops and starts, we’ll save changes to disk as they’re made and load them at startup.
const fs = require('fs');
//
// model
//
const assignments = new Map();
function saveItem(name) {
const volunteer = assignments.get(name);
if (!volunteer) {
try {
fs.unlinkSync('.data/' + name);
} catch (e) {
if (e.code === 'ENOENT') {
// no one was bringing it. leave it that way
} else {
throw e;
}
}
} else {
fs.writeFileSync('.data/' + name, volunteer);
}
}
function loadItem(name) {
let volunteer = null;
try {
volunteer = fs.readFileSync('.data/' + name, {encoding: 'utf8'});
} catch (e) {
if (e.code === 'ENOENT') {
// no one's bringing it. this is a valid state
} else {
throw e;
}
}
assignments.set(name, volunteer);
}
loadItem('plates');
loadItem('hamburgers');
loadItem('hot dogs');
loadItem('buns');
loadItem('drinks');
The web service lets a visitor see what items already have volunteers. We’re writing this part in Express. We’ll later need access to an HTTP server that we can hook up to receive dispatch messages, so we won’t use app.listen(...)
. We’ll use http.createServer(app)
and hold on to the result so we can add more handlers to it later.
const http = require('http');
const express = require('express');
//
// web service
//
const app = express();
app.get('/', (req, res) => {
let message = `We have the following covered:
`;
for (const [item, volunteer] of assignments) {
const check = volunteer ? 'x' : ' ';
message += `[${check}] ${item}
`;
}
res.end(message);
});
const server = http.createServer(app);
-
Initialize a bot library. Usually a bot would add a library to communicate with Discord, and that library would help with three things: (a) receiving dispatch messages by connecting to the Discord gateway, (b) caching everything it sees from the gateway for rapid access, and (c) actually doing things on Discord, such as sending messages. When we build a bot with DCC, DCC will take care of (a), and we simply don’t have (b) because we’re trying to avoid having our bot stay awake all the time to watch everything. So we’d still like to have a library take care of (c).
In this tutorial, we’re using Eris. There may be a similar way to set up discord.js or another library. To set up Eris, we’ll do three things out of the ordinary. First, we’ll pass restMode: true
as one of the options in the constructor. This configures Eris to enable routines that fetch data from Discord on-demand (whereas otherwise you would look at data in the cache). Second, in conjunction with the restMode
option, we’ll prefix our token with the word Bot
(Discord accepts two different kinds of tokens, and normally Eris would figure out which one it is by seeing how the gateway reacts to it). And third, we won’t call .connect()
on the bot.
const eris = require('eris');
//
// bot
//
const bot = new eris.Client('Bot ' + config.token, {restMode: true});
bot.on('debug', (message, id) => {
console.log('bot debug', message, id);
});
bot.on('warn', (message, id) => {
console.warn('bot warn', message, id);
});
bot.on('error', (err, id) => {
console.error('bot error', err, id);
});
-
Come up with an alias and client secret for your bot. These are a little like a username and password, used just between your bot and DCC.
For the alias, you might as well use (possibly a simplified version of) your bot’s name. It can contain numbers, letters, and underscores. Or set the alias to something else, if you’d rather not have it publicly identified. The C1 publicly displays the aliases of what bots are using it.
For the client secret, generate a random string. You can use this snippet of shell script to generate one:
echo "admin:$(head -c16 /dev/urandom | base64)"
We’ll add the client secret to .env
, which will now have this:
# from https://discord.com/developers/applications
DISCORD_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxx.xxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxx
# from https://discord.com/developers/applications
BOT_USER_ID=111111111111111111
# echo "admin:$(head -c16 /dev/urandom | base64)"
DCC_SECRET=admin:xxxxxxxxxxxxxxxxxxxxxx==
The alias is meant to stay the same, so we’re putting it directly in the code. We expand the config
object to this:
const config = {
alias: 'sample_cookout',
token: process.env.DISCORD_TOKEN,
clientSecret: process.env.DCC_SECRET,
};
-
Create a DCC client to receive dispatch messages. The client library is now available on NPM https://www.npmjs.com/package/dcc-client. This object sets up a web socket handler to receive dispatch messages from C1 and emits them as dispatch
events. It needs the bot’s alias and client secret (actually the protocol doesn’t currently use the alias, but the parameter is there anyway), as well as a web socket server options object. Here’s a formula that works if you have an HTTP server object and no other web socket handlers on your app: pass that server as the server
field and set path
field to some route for DCC to use for this bot. Or see the ws
package’s documentation if you aren’t adding a web frontend ws - npm or if you need multiple web socket handlers ws - npm.
const dcc = require('dcc-client');
const client = new dcc.Client(config.alias, config.clientSecret, {
path: '/dcc/v1/sample_cookout',
server,
});
client.on('dispatch', (packet) => {
console.log('received packet', JSON.stringify(packet));
});
This is it for setting up listeners on the HTTP server, so now we start listening.
//
// start
//
server.listen(process.env.PORT, () => {
console.log('listening', process.env.PORT);
});
-
Register the client with a C1 deployment. At this point, even though your app is listening, DCC doesn’t know how to talk to it. Now we’ll specify that in the form of a web socket URL. Here we’re using our Glitch project domain along with the path we configured the client to look out for, and all over TLS. We’ll add that URL to the config object:
const config = {
alias: 'sample_cookout',
token: process.env.DISCORD_TOKEN,
dst: 'wss://' + process.env.PROJECT_DOMAIN + '.glitch.me/dcc/v1/sample_cookout',
clientSecret: process.env.DCC_SECRET,
};
Now we use the register
method from the client library to send our information to C1. We’ll schedule this to be done once our HTTP server has started listening. Surprise! We’ve been setting up this config
object to match exactly the options that this method takes. Okay, your program probably doesn’t revolve around DCC, so if you don’t have an object structured like that, just be sure to pass alias
, token
, dst
, and clientSecret
.
server.listen(process.env.PORT, () => {
console.log('listening', process.env.PORT);
dcc.register(config).then(() => {
console.log('register ok');
}).catch((e) => {
console.error('register failed', e);
});
});
-
Check DCC status online. Go to https://daffy-circular-chartreuse.glitch.me/ and click “list relays.” Find your bot in the list by its alias. Here’s ours:
"sample_cookout": {
"enabled": true,
"lastDisableReason": "token and/or intents changed",
"connectRunning": false,
"numQueuedEvents": 0,
"lastWSError": "(not collected)",
"wsReadyState": "(no ws)",
"botShardStatus": "ready"
}
There are a few different things to keep an eye on here. First, make sure your bot has enabled
true. It gets disabled if C1 encounters an unrecoverable error. See the lastDisableReason
for why it’s disabled (the reason will be retained for your reference even after the bot is reenabled, so having a message there doesn’t mean it’s currently experiencing that problem). Re-register your bot to reenable it.
Second, make sure the botShardStatus
reaches ready
. If it doesn’t reach ready
and it doesn’t have connectRunning
true, then C1 might be having trouble logging to the Discord gateway with the token you provided. However, this would be hard to debug, because for privacy purposes, we don’t display Discord-related errors here. You might try connecting to the Discord gateway locally.
Third, make sure numQueuedEvents
doesn’t start growing. If it does, check lastWSError
for why C1 can’t connect to your bot. You may have the dst
or clientSecret
out of sync between your registration and client. Re-register your bot to correct this. If this number reaches 1,000, C1 will throw away all of your queued messages and call your parents at work.
-
Draw the rest of the owl. We have a callback that will log the dispatch messages we get. We have a data model for what we’re tracking. What’s left is to pound out about a dozen if
statements to hook them up. The code for the cookout bot is long, so I won’t dump it here. Go over to the finished code to look at it.
After writing this bot logic, we have a better idea of what dispatch messages we really care about. We only want to see messages being created, and among messages, we only want to see direct messages to our bot, messages that mention our bot, or messages with a certain prefix. We control this with two fields in the registration options: intents
, which cuts broad categories of data before they’re even sent to C1, and criteria
, which filters dispatch messages on a finer level using cold, hard CPU power on C1.
Intents are sent as a number representing a bit field Discord Developer Portal.
Criteria are defined by a language MongoDB’s query selctors https://docs.mongodb.com/manual/reference/operator/query/, as if selecting from a collection of the dispatch messages.
In our bot, we pass these from the config
object, which we expand to this:
const config = {
alias: 'sample_cookout',
token: process.env.DISCORD_TOKEN,
intents: eris.Constants.Intents.guildMessages | eris.Constants.Intents.directMessages,
criteria: {
// corresponds to what we declared in the gateway, but further filters out messages like
// READY, CHANNEL_CREATE, and MESSAGE_UPDATE
t: 'MESSAGE_CREATE',
// ignore messages from self and other bots
$not: {'d.author.bot': true},
$or: [
// DMs
{'d.guild_id': {$exists: false}},
// mentions
{'d.mentions': {$elemMatch: {id: process.env.BOT_USER_ID}}},
// prefix
{'d.content': {$regex: '^cookout\\b'}},
],
},
dst: 'wss://' + process.env.PROJECT_DOMAIN + '.glitch.me/dcc/v1/sample_cookout',
clientSecret: process.env.DCC_SECRET,
};
-
Choose a C1 operator. Note: this step is not covered in the completed sample code. Up until now, we’ve been using a development C1 deployment hosted on Glitch at https://daffy-circular-chartreuse.glitch.me. It comes on line when your bot registers, but it doesn’t stay up all day. You should switch to an always-on C1 deployment to keep your bot online.
To do this, first we’ll stop our program so that it doesn’t re-register while we’re changing this. On Glitch, we’ll comment out the part that registers.
server.listen(process.env.PORT, () => {
console.log('listening', process.env.PORT);
// dcc.register(config).then(() => {
// console.log('register ok');
// }).catch((e) => {
// console.error('register failed', e);
// });
});
We have to deregister with the previous C1 so that it stops sending us dispatch messages. If we didn’t do this, we would get duplicate dispatch messages. We send a deregistration call to the previous C1:
curl -f -X DELETE -u "$DCC_SECRET" https://daffy-circular-chartreuse.glitch.me/relays/sample_cookout
When we switch to a different C1 deployment, it’s safest to generate a new client secret and bot token as well:
# from https://discord.com/developers/applications
DISCORD_TOKEN=yyyyyyyyyyyyyyyyyyyyyyyy.yyyyyy.yyyyyyyyyyyyyyyyyyyyyyyyyyy
# from https://discord.com/developers/applications
BOT_USER_ID=111111111111111111
# echo "admin:$(head -c16 /dev/urandom | base64)"
DCC_SECRET=admin:yyyyyyyyyyyyyyyyyyyyyy==
We add an endpoint
field to the options passed to register
, which for us goes in the config
object:
const config = {
alias: 'sample_cookout',
token: process.env.DISCORD_TOKEN,
intents: eris.Constants.Intents.guildMessages | eris.Constants.Intents.directMessages,
criteria: {
// corresponds to what we declared in the gateway, but further filters out messages like
// READY, CHANNEL_CREATE, and MESSAGE_UPDATE
t: 'MESSAGE_CREATE',
// ignore messages from self and other bots
$not: {'d.author.bot': true},
$or: [
// DMs
{'d.guild_id': {$exists: false}},
// mentions
{'d.mentions': {$elemMatch: {id: process.env.BOT_USER_ID}}},
// prefix
{'d.content': {$regex: '^cookout\\b'}},
],
},
dst: 'wss://' + process.env.PROJECT_DOMAIN + '.glitch.me/dcc/v1/sample_cookout',
clientSecret: process.env.DCC_SECRET,
// don't literally use the dcc.example.com address below, of course.
// scroll down for actual providers' endpoints
endpoint: 'https://dcc.example.com',
};
Then we reenable registration.
server.listen(process.env.PORT, () => {
console.log('listening', process.env.PORT);
dcc.register(config).then(() => {
console.log('register ok');
}).catch((e) => {
console.error('register failed', e);
});
});
I’ve added licenses to the components of this project, so you now formally have permission to use them in your projects.