Getting started
KirzenScript takes its syntax from JavaScript and from Python, and accepts either. Whichever of the two you already write, you can write it here without looking anything up.
It is not JavaScript, though, and the difference matters. Kirzen reads your script and runs it itself rather than handing it to the engine underneath. That is why a script cannot reach the file system, the network, the bot token or the database, and why a mistake in one command can never take the bot down.
const name = user.nick
if (args.length > 0) {
reply(`Hi ${name}, you said ${input}`)
} else {
reply(`Hi ${name}!`)
}
Write scripts in Servers → your server → Commands → New
command → A script. A custom command is triggered by what
somebody types, so you give it a prefix like !roll.
What the editor does for you
- Eight ready-made commands. Start from a dice roll, an economy, a profile card or a leaderboard and change it, rather than starting from nothing.
- It checks as you type. A moment after you stop, the line with the mistake is marked in the gutter and the reason is spelled out underneath.
- Try it. Runs the script against made-up values and shows what would be said and what would be done. Nothing is sent and nothing is saved.
- It behaves like a code editor. Tab indents, brackets and quotes close themselves, and Enter keeps your place.
Two ways to write it
These two commands are the same command. Braces are the default and what the rest of this page uses, but a colon and an indent do the same job wherever you prefer them. The style is decided per block, so you can mix them freely.
const best = args.filter(a => a.length > 3);
if (best.length > 0) {
reply(best.join(", "));
} else {
reply("Nothing long enough.");
}
best = args.filter(a => len(a) > 3)
if len(best) > 0:
reply(best.join(", "))
else:
reply("Nothing long enough.")
What carries over from Python
| Write | Means |
|---|---|
and, or, not | &&, ||, ! |
True, False, None | true, false, null |
elif | else if |
def name(): | function name() {} |
for x in list: | for (const x of list) {} |
# comment | // comment |
len(x) | x.length |
str(x), int(x) | String(x), parseInt(x) |
What you can leave out
- Semicolons. Never needed, in either style.
-
Parentheses round a condition.
if x > 0andif (x > 0)are the same. -
let and const. Assigning to a new name creates
it. Use
constwhen you want the name locked. - Braces. A colon and an indent do the same job.
The one rule when using colons: the lines inside a block have to be indented further than the line that opened it. Kirzen says so plainly if they are not.
What a script knows
Six values are waiting for you. They are copies, so changing one changes nothing on Discord.
| Value | What it holds |
|---|---|
user |
id, name, nick,
mention, avatar,
joinedAt, createdAt,
roles, topRole,
isBooster
|
user.roles |
A list, highest first. Each has id,
name, color, mention.
topRole is the first of them.
|
server |
id, name, members,
icon, boosts,
createdAt, roleCount,
channelCount
|
channel |
id, name, mention,
topic, isNsfw
|
args |
What came after the command, split on spaces |
input |
The same thing as one string |
command |
The name of the command being run |
reply(`${user.name} is in ${server.name}, which has ${server.members} members.`)
if (hasRole("123456789012345678")) {
reply(" And they are a moderator.")
}
joinedAt and createdAt are seconds, ready
to hand to timestamp().
Answering
reply() builds the command's answer. Call it as many
times as you like; the pieces are joined together.
reply("Hello ");
reply(user.name);
// The command answers "Hello Terra"
send() is different: it posts a separate message in the
channel. Use it when you want a second message rather than a longer
first one. dm() sends privately to whoever ran the
command.
Embeds and colour
embed() attaches an embed to the command's answer. Call
it up to three times for three embeds, and use it alongside
reply() if you want text above them.
embed({
title: `Profile of ${user.nick}`,
description: "Everything Kirzen knows about you.",
color: user.topRole.color,
thumbnail: user.avatar,
fields: [
{ name: "Top role", value: user.topRole.mention, inline: true },
{ name: "Joined", value: timestamp(user.joinedAt, "R"), inline: true },
{ name: "Roles", value: str(len(user.roles)), inline: true }
],
footer: { text: server.name },
timestamp: true
})
Every part of an embed
| Key | What it takes |
|---|---|
title | Text, up to 256 characters |
description | Text, up to 4096 |
color | A name, a "#rrggbb", or a number |
url | Makes the title a link |
thumbnail | An image URL, shown small in the corner |
image | An image URL, shown full width |
author | A name, or { name, icon, url } |
footer | Text, or { text, icon } |
timestamp | true to stamp it with now |
fields | Up to 25 of { name, value, inline } |
Colours by name
blurple green yellow
red fuchsia white
black grey gold
orange aqua purple
pink navy dark
embed({ title: "Warning", color: "red" })
embed({ title: "Done", color: "#57f287" })
embed({ title: "Match", color: user.topRole.color })
Every URL is checked before it is used. Anything that is not http or https is dropped, so a script cannot smuggle something odd into a message.
Sending one somewhere else
sendEmbed({ title: "Logged", description: input }, "123456789012345678")
sendTo("123456789012345678", "A plain message, in another channel.")
Leave the channel off and it goes to the channel the command was used in. If Kirzen cannot post in the channel you name, the send is refused and the command says so rather than posting somewhere else.
Doing things
A script never touches Discord itself. It records what it would like done, and Kirzen checks each request against its real permissions before carrying any of it out. If Kirzen cannot manage a role, the script is not told a lie: the action is simply refused and reported.
| Call | What it asks for |
|---|---|
addRole(id, who?) | Give a role. Leave the second part out and it means whoever ran it |
removeRole(id, who?) | Take a role away |
setNickname(name, who?) | Rename somebody. Empty clears the nickname |
timeout(secs, who?, why?) | Time somebody out. Zero seconds lifts it |
react(emoji) | React to the message that triggered it |
deleteMessage() | Delete that message |
pin() | Pin that message |
createThread(name) | Start a thread on it |
send(text) | Post a separate message |
sendTo(id, text) | Post it in another channel |
sendEmbed(obj) | Post an embed as its own message |
dm(text) | Send a direct message |
react(), pin(),
deleteMessage() and createThread() act on
the message somebody typed to trigger the command.
Kicking and banning are deliberately absent. They cannot be undone,
a script is easy to get wrong, and a moderator who needs them has
/ban and /kick already.
timeout() is here instead: it does the job and it wears
off.
Remembering
db is a small store that belongs to your server and
survives between runs. It is what turns a command that answers into
a command that keeps score: an economy, a counter, a profile, a
leaderboard.
db.add(`coins:${user.id}`, 10)
reply(`You now have ${db.get(`coins:${user.id}`)} coins.`)
| Call | What it does |
|---|---|
db.get(key, fallback) | Reads it, or the fallback when nothing is there |
db.set(key, value) | Writes a number, some text, a list, or an object |
db.add(key, n) | Adds to a number and gives you the new total |
db.has(key) | Whether anything is stored under it |
db.delete(key) | Removes it |
db.top(n, prefix) | The n highest numbers, for a leaderboard |
db.keys(prefix) | The keys that start with something |
db.count() | How many keys the server is using |
Naming keys
A key is just text, so put what it is about in it. A prefix is what
makes db.top and db.keys useful, because
both work on one.
db.set(`coins:${user.id}`, 500) // one per member
db.set("event:name", "Winter cup") // one for the server
db.set(`profile:${user.id}`, { class: "Mage", level: 3 })
A leaderboard
let text = ""
let place = 1
for (const entry of db.top(5, "coins:")) {
const id = entry.key.replace("coins:", "")
text += `${place}. ${mentionUser(id)}, ${numberFormat(entry.value)}\n`
place++
}
embed({ title: "Richest members", description: text, color: "gold" })
What it will not do
- It is per server. Nothing you save is visible to another server, and nothing another server saved is visible here.
- It is small on purpose. 2,000 keys per server, 2,000 characters per value, and 25 reads or writes per run. It is a memory for commands, not a database.
- A run that stops halfway keeps what it already wrote. There is no undo, so add before you spend rather than the other way round.
Looking at it by hand
Servers → your server → Commands shows a meter of how full the namespace is as soon as anything is saved, and Saved data opens the panel behind it.
- See what is there. Every key with its type, its value and its size, filtered by prefix.
- Edit a value. Written as JSON, so a number stays a number and an object stays an object. The next run reads what you leave.
- Rename a key. Moves the value across. It refuses to land on a key that already exists rather than overwrite it.
-
Delete one, a prefix, or everything. Filtering by
coins:and deleting clears that one command's data without touching the rest.
The ring turns amber past 70% and red past 90%, so a namespace filling up is visible before a script starts being refused.
Other people
A script starts out knowing about whoever ran it. These read anybody else on the server.
| Call | Gives back |
|---|---|
getMember(id) |
The same shape as user: id,
name, nick, mention,
avatar, joinedAt,
isBooster, roles. Null if they are
not here.
|
getRole(id) |
id, name, color, mention, position |
roleCount(id) | How many members hold that role |
getXp(who?) |
Their levelling: xp, level,
rank, messages,
percent. Leave the id out for whoever ran it.
|
mentioned() | The first id the command was given, mention or raw |
mentions() | All of them, as a list |
const target = getMember(mentioned())
if (!target) {
reply("Mention somebody first.")
} else {
const level = getXp(target.id)
embed({
title: target.nick,
thumbnail: target.avatar,
color: target.roles.length ? target.roles[0].color : "blurple",
fields: [
{ name: "Level", value: str(level.level), inline: true },
{ name: "Rank", value: `#${level.rank}`, inline: true },
{ name: "Joined", value: timestamp(target.joinedAt, "R"), inline: true }
]
})
}
Looking things up is capped at 25 per run, the same way saving is. Reading a whole server in a loop is not what this is for.
The language
Variables
let count = 0 // can change
const name = "Kirzen" // locked, cannot be changed
total = 0 // no keyword at all also works
count += 5
count++
Text
const a = "double quotes";
const b = 'single quotes';
const c = `a template with ${user.name} in it`;
const d = "joined " + "with plus";
Conditions
if (server.members > 100) {
reply("Busy server")
} else if (server.members > 10) {
reply("Getting there")
} else {
reply("Cosy")
}
const label = args.length ? "with arguments" : "without"
&&, || and ?? work as
you would expect, including short-circuiting.
=== and == both compare by value.
Loops
for (const word of args) {
reply(word.toUpperCase() + " ")
}
for (let i = 0; i < 5; i++) {
if (i === 2) continue
reply(i)
}
let n = 3
while (n > 0) {
reply(n)
n--
}
Lists and objects
const colours = ["red", "green", "blue"];
reply(colours[1]); // green
reply(colours.length); // 3
const profile = { name: user.name, level: 7 };
reply(`${profile.name} is level ${profile.level}`);
Functions
function double(n) {
return n * 2
}
const shout = (text) => text.toUpperCase() + "!"
reply(double(21))
reply(shout("hello"))
Comments
// a line
# also a line
/* or several
lines */
Built-in functions
| Function | What it does |
|---|---|
len(x) | How long a piece of text or a list is |
random(a, b) | A whole number from a to b, both included |
pick(list) | One item from a list, at random |
range(a, b) | A list of numbers from a up to but not including b |
keys(object) | The names in an object |
sum(list) | Adds a list of numbers up |
unique(list) | Drops repeats |
shuffle(list) | The same items in a random order |
json(value) | Turns anything into text, useful while working |
numberFormat(n) | 1234567 becomes 1,234,567 |
date() | year month day hour minute weekday |
values(object) | The values in an object |
mentionUser(id) | Turns an id into a mention |
mentionRole(id) | The same for a role |
mentionChannel(id) | The same for a channel |
Number(v), String(v), Boolean(v) | Conversions |
parseInt(v), parseFloat(v) | Reads a number out of text |
str(v), int(v) | The Python spelling of the same two |
hasRole(id) | Whether the person who ran it has that role |
color(name) | Turns a colour name into a number |
now() | The time now, in seconds |
timestamp(s, style) |
A time Discord renders in each reader's own zone. Styles:
t T d D f F R
|
truncate(text, n) | Cuts text and adds an ellipsis |
bold, italic, underline,
strike, spoiler, quote,
code, codeBlock, link
|
Discord's markup, without remembering the punctuation |
Math.… |
floor, ceil, round,
abs, min, max,
pow, sqrt, random
|
Methods on values
Text
length toUpperCase toLowerCase
trim includes startsWith
endsWith indexOf charAt
slice split replace
replaceAll repeat padStart
padEnd
Lists
length join includes
indexOf slice concat
push reverse map
filter find some
every reduce sort
Numbers
toFixed toString
const names = args.map(a => a.toLowerCase()).filter(a => a.length > 2);
reply(names.sort().join(", "));
Limits
Every script runs against a budget. Hitting one of these is not a crash: the command answers with a note and Kirzen carries on.
| Limit | Ceiling |
|---|---|
| Script length | 20,000 characters |
| Answer length | 1,900 characters, then it is cut |
| Work done | 200,000 steps |
| Loop rounds | 10,000 per loop |
| Nested calls | 50 deep |
| Actions requested | 10 per run |
| Embeds shown | 3 per command |
| Fields per embed | 25 |
| Saved reads and writes | 25 per run |
| Lookups | 25 per run |
| Saved keys | 2,000 per server |
| Size of one value | 2,000 characters |
| List length | 5,000 items |
| Text built up | 20,000 characters |
What is not there
These exist in JavaScript and not here. Each is left out either because it would be a way out of the sandbox or a way to hang the bot, so none of them will be added.
eval Function require
import process globalThis
setTimeout fetch new
class this async
await try __proto__
constructor prototype
Reading .constructor or .__proto__ gives
nothing rather than an error, and writing to either is refused.
Recipes
A dice roll
const sides = int(args[0]) || 6
reply(`${user.mention} rolled a ${random(1, sides)} on a d${sides}.`)
A self-assign role
addRole("123456789012345678")
reply("Done, you have the role.")
Pick one at random
if (args.length < 2) {
reply("Give me at least two things to choose between.")
} else {
reply(`I pick **${pick(args)}**.`)
}
A tidy list
const items = args
.map(a => a.trim())
.filter(a => a.length > 0)
.sort();
for (let i = 0; i < items.length; i++) {
reply(`${i + 1}. ${items[i]}\n`);
}
A profile card
embed({
title: user.nick,
color: user.topRole ? user.topRole.color : "blurple",
thumbnail: user.avatar,
fields: [
{ name: "Joined", value: timestamp(user.joinedAt, "R"), inline: true },
{ name: "Account made", value: timestamp(user.createdAt, "D"), inline: true },
{ name: "Roles", value: user.roles.map(r => r.mention).join(" ") || "none" }
],
footer: { text: `${server.name} · ${server.members} members` }
})
A daily reward
const key = `daily:${user.id}`
const last = db.get(key, 0)
const wait = 86400 - (now() - last)
if (wait > 0) {
reply(`Come back ${timestamp(last + 86400, "R")}.`)
} else {
const prize = random(50, 200)
const total = db.add(`coins:${user.id}`, prize)
db.set(key, now())
reply(`You picked up ${prize} coins. You now have ${numberFormat(total)}.`)
}
React and clean up
react("✅")
deleteMessage()
send(`${user.mention} said: ${input}`)