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.

JavaScript
const best = args.filter(a => a.length > 3);

if (best.length > 0) {
  reply(best.join(", "));
} else {
  reply("Nothing long enough.");
}
Python
best = args.filter(a => len(a) > 3)

if len(best) > 0:
    reply(best.join(", "))
else:
    reply("Nothing long enough.")

What carries over from Python

WriteMeans
and, or, not&&, ||, !
True, False, Nonetrue, false, null
elifelse 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 > 0 and if (x > 0) are the same.
  • let and const. Assigning to a new name creates it. Use const when 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.

ValueWhat 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

KeyWhat it takes
titleText, up to 256 characters
descriptionText, up to 4096
colorA name, a "#rrggbb", or a number
urlMakes the title a link
thumbnailAn image URL, shown small in the corner
imageAn image URL, shown full width
authorA name, or { name, icon, url }
footerText, or { text, icon }
timestamptrue to stamp it with now
fieldsUp 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.

CallWhat 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.`)
CallWhat 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.

CallGives 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

FunctionWhat 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.

LimitCeiling
Script length20,000 characters
Answer length1,900 characters, then it is cut
Work done200,000 steps
Loop rounds10,000 per loop
Nested calls50 deep
Actions requested10 per run
Embeds shown3 per command
Fields per embed25
Saved reads and writes25 per run
Lookups25 per run
Saved keys2,000 per server
Size of one value2,000 characters
List length5,000 items
Text built up20,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}`)