Build

Build a chat bot

Build a dice and trivia bot for originChats: command handling you can test as plain functions, embeds and polls, then wire it to a server.

6 steps · about 20 minutes · uses originchats, random, result

You'll build a bot for an originChats server that rolls dice (!roll 2d6), asks trivia (!trivia, !answer paris), keeps a scoreboard and posts embeds and polls.

Connecting needs a server and a token, so only the last step does. Until then the bot is one function, handle(user, text), that takes a chat line and returns a reply. That's what the try-it tabs call, so you can watch whole conversations here.

What you'll have at the end
ann: !trivia
bot: Trivia: How many sides has a hexagon?
bo: !answer lyon
bot: Not lyon. Try again!
ann: !answer 6
bot: ann got it! That's 1 point(s).
bo: !roll 2d6
bot: 2d6: 1 + 6 = 7
ann: !answer again
bot: No question yet. Try !trivia
bo: !trivia
bot: Trivia: What is the capital of France?
bo: !answer Paris
bot: bo got it! That's 1 point(s).
ann: !scores
bot: ann: 1, bo: 1
Step 1 of 6

Roll a die

std:random gives random.int(low, high), both ends included. random.seed(n) makes the sequence repeat, which is why this page always shows the same rolls. A real bot doesn't seed.

Try changing the seed, or the sides.

Your turn

Write roll

Make roll return a random number from 1 to 6.

main.osl
Ctrl Enter runs
Expected output
true
main.osl
import "std:random"

random.seed(42)
log "you rolled a " ++ random.int(1, 6)
log "you rolled a " ++ random.int(1, 6)
Click the code to edit it
Step 2 of 6

Roll dice like 2d6

People type 2d6 for two six-sided dice. parseDice splits it on d, and returns a result: a good roll, or an error whose message you can show. rollText handles both, so a mistake becomes a polite reply. See errors.

Your turn

A coin

Make coin return heads or tails at random.

main.osl
Ctrl Enter runs
Expected output
heads
import "std:random"
import "std:result"

struct Dice (
  int count
  int sides
)

def parseDice(string text) result<Dice, error> (
  string[] parts = text.trim().toLower().split("d")
  if parts.len != 2 return result.err(error("use the form 2d6"))
  int count = 1
  if parts[1] != "" (
    count = parts[1].tryAs<int> catch return result.err(error("not a number: " ++ parts[1]))
  )
  int sides = parts[2].tryAs<int> catch return result.err(error("not a number: " ++ parts[2]))
  if count < 1 or count > 20 return result.err(error("roll between 1 and 20 dice"))
  if sides < 2 or sides > 1000 return result.err(error("dice have between 2 and 1000 sides"))
  return result.ok(Dice(count, sides))
)

def rollText(string spec) string (
  Dice d = parseDice(spec) catch (e) (
    return "Sorry, " ++ e.message
  )
  int[] rolls = []
  for _ in 1 to d.count (
    rolls.append(random.int(1, d.sides))
  )
  return spec.trim() ++ ": " ++ rolls.join(" + ") ++ " = " ++ rolls.sum()
)
Click the code to edit it

Highlighted lines are new in this step.

Step 3 of 6

Answer chat lines

handle(user, text) splits a line into words and switches on the first one. Everything after it is rest. A line that isn't a command returns "", and the bot stays quiet. The try-it tab replays a little conversation.

Quick check

Why is the bot's logic in handle and not inside the connection code?

Your turn

Shout

Make shout return the text in capitals with an ! at the end.

main.osl
Ctrl Enter runs
Expected output
HELLO BOT!
import "std:random"
import "std:result"

struct Dice (
  int count
  int sides
)

def parseDice(string text) result<Dice, error> (
  string[] parts = text.trim().toLower().split("d")
  if parts.len != 2 return result.err(error("use the form 2d6"))
  int count = 1
  if parts[1] != "" (
    count = parts[1].tryAs<int> catch return result.err(error("not a number: " ++ parts[1]))
  )
  int sides = parts[2].tryAs<int> catch return result.err(error("not a number: " ++ parts[2]))
  if count < 1 or count > 20 return result.err(error("roll between 1 and 20 dice"))
  if sides < 2 or sides > 1000 return result.err(error("dice have between 2 and 1000 sides"))
  return result.ok(Dice(count, sides))
)

def rollText(string spec) string (
  Dice d = parseDice(spec) catch (e) (
    return "Sorry, " ++ e.message
  )
  int[] rolls = []
  for _ in 1 to d.count (
    rolls.append(random.int(1, d.sides))
  )
  return spec.trim() ++ ": " ++ rolls.join(" + ") ++ " = " ++ rolls.sum()
)

def helpText() string (
  return "!roll 2d6, !trivia, !answer <text>, !scores, !help"
)

def handle(string user, string text) string (
  string[] words = text.trim().split(" ")
  string rest = words.trim(2, words.len).join(" ")
  switch words[1] (
    case "!roll"
      return rollText(if rest == "" then "1d6" else rest)
    case "!help"
      return helpText()
    default
      return ""
  )
)
Click the code to edit it

Highlighted lines are new in this step.

Step 4 of 6

Embeds and polls

originchats.newEmbed({...}) starts a rich message and addField and withFooter chain onto it. originchats.newPoll(question, choices) makes a poll. build() returns what the server receives, so you can check it without a connection.

Try changing the card's title or colour.

Your turn

A three-way poll

Make lunchPoll ask Lunch? with the choices pizza, ramen and salad.

main.osl
Ctrl Enter runs
Expected output
{"poll":{"options":[{"text":"pizza"},{"text":"ramen"},{"text":"salad"}],"question":"Lunch?"},"type":"poll"}
import "std:random"
import "std:result"
import "std:originchats"

struct Dice (
  int count
  int sides
)

def parseDice(string text) result<Dice, error> (
  string[] parts = text.trim().toLower().split("d")
  if parts.len != 2 return result.err(error("use the form 2d6"))
  int count = 1
  if parts[1] != "" (
    count = parts[1].tryAs<int> catch return result.err(error("not a number: " ++ parts[1]))
  )
  int sides = parts[2].tryAs<int> catch return result.err(error("not a number: " ++ parts[2]))
  if count < 1 or count > 20 return result.err(error("roll between 1 and 20 dice"))
  if sides < 2 or sides > 1000 return result.err(error("dice have between 2 and 1000 sides"))
  return result.ok(Dice(count, sides))
)

def rollText(string spec) string (
  Dice d = parseDice(spec) catch (e) (
    return "Sorry, " ++ e.message
  )
  int[] rolls = []
  for _ in 1 to d.count (
    rolls.append(random.int(1, d.sides))
  )
  return spec.trim() ++ ": " ++ rolls.join(" + ") ++ " = " ++ rolls.sum()
)

def helpText() string (
  return "!roll 2d6, !trivia, !answer <text>, !scores, !help"
)

def helpCard() *originchats.Embed (
  return originchats.newEmbed({title: "Dice bot", description: helpText(), color: "#5865f2"})
    .addField("!roll 2d6", "roll some dice")
    .addField("!trivia", "ask a question")
    .withFooter("dicebot")
)

def handle(string user, string text) string (
  string[] words = text.trim().split(" ")
  string rest = words.trim(2, words.len).join(" ")
  switch words[1] (
    case "!roll"
      return rollText(if rest == "" then "1d6" else rest)
    case "!help"
      return helpText()
    default
      return ""
  )
)
Click the code to edit it

Highlighted lines are new in this step.

Step 5 of 6

Trivia and scores

Questions are structs. !trivia picks one at random and remembers it, !answer checks the guess (== ignores case), and a map keeps each user's points. Sorting the names keeps the scoreboard in a fixed order. Run the try-it tab to read a whole game.

Your turn

Singular

Say 1 point for one and 3 points otherwise.

main.osl
Ctrl Enter runs
Expected output
1 point
3 points
Quick check

!answer is typed with no question asked. What does the bot do?

import "std:random"
import "std:result"
import "std:originchats"

struct Dice (
  int count
  int sides
)

def parseDice(string text) result<Dice, error> (
  string[] parts = text.trim().toLower().split("d")
  if parts.len != 2 return result.err(error("use the form 2d6"))
  int count = 1
  if parts[1] != "" (
    count = parts[1].tryAs<int> catch return result.err(error("not a number: " ++ parts[1]))
  )
  int sides = parts[2].tryAs<int> catch return result.err(error("not a number: " ++ parts[2]))
  if count < 1 or count > 20 return result.err(error("roll between 1 and 20 dice"))
  if sides < 2 or sides > 1000 return result.err(error("dice have between 2 and 1000 sides"))
  return result.ok(Dice(count, sides))
)

def rollText(string spec) string (
  Dice d = parseDice(spec) catch (e) (
    return "Sorry, " ++ e.message
  )
  int[] rolls = []
  for _ in 1 to d.count (
    rolls.append(random.int(1, d.sides))
  )
  return spec.trim() ++ ": " ++ rolls.join(" + ") ++ " = " ++ rolls.sum()
)

def helpText() string (
  return "!roll 2d6, !trivia, !answer <text>, !scores, !help"
)

def helpCard() *originchats.Embed (
  return originchats.newEmbed({title: "Dice bot", description: helpText(), color: "#5865f2"})
    .addField("!roll 2d6", "roll some dice")
    .addField("!trivia", "ask a question")
    .withFooter("dicebot")
)

struct Question (
  string text
  string answer
)

Question[] questions = [
  Question("What is the capital of France?", "paris"),
  Question("How many sides has a hexagon?", "6"),
  Question("Which planet is the Red Planet?", "mars"),
]

Question? current = null
string[int] scores = {}

def ask() string (
  Question q = random.item(questions)
  current = q
  return "Trivia: " ++ q.text
)

def answer(string user, string guess) string (
  if current == null return "No question yet. Try !trivia"
  Question q = current
  if guess.trim() == q.answer (
    current = null
    scores[user] = scores.get(user, 0) + 1
    return user ++ " got it! That's " ++ scores[user] ++ " point(s)."
  )
  return "Not " ++ guess.trim() ++ ". Try again!"
)

def scoreboard() string (
  if scores.len == 0 return "No scores yet."
  string[] lines = []
  for name in scores.getKeys().sort() (
    lines.append(name ++ ": " ++ scores[name])
  )
  return lines.join(", ")
)

def handle(string user, string text) string (
  string[] words = text.trim().split(" ")
  string rest = words.trim(2, words.len).join(" ")
  switch words[1] (
    case "!roll"
      return rollText(if rest == "" then "1d6" else rest)
    case "!trivia"
      return ask()
    case "!answer"
      return answer(user, rest)
    case "!scores"
      return scoreboard()
    case "!help"
      return helpText()
    default
      return ""
  )
)
Click the code to edit it

Highlighted lines are new in this step.

Step 6 of 6

Connect it

originchats.new(url, {token}) makes a client, and nothing happens until client.run(). client.command(prefix, fn) calls fn with a message when a line starts with the prefix. msg.content() is what comes after the prefix, and msg.reply(text) answers. Every handler just calls handle, and /roll is a slash command shown in the chat's command picker. The token comes from the environment: don't put it in the source.

Run it with ROTUR_TOKEN=... osl run bot.osl. Next: originchats, or a web API.

main.osl
import "std:random"
import "std:result"
import "std:originchats"
import "std:env"

struct Dice (
  int count
  int sides
)

def parseDice(string text) result<Dice, error> (
  string[] parts = text.trim().toLower().split("d")
  if parts.len != 2 return result.err(error("use the form 2d6"))
  int count = 1
  if parts[1] != "" (
    count = parts[1].tryAs<int> catch return result.err(error("not a number: " ++ parts[1]))
  )
  int sides = parts[2].tryAs<int> catch return result.err(error("not a number: " ++ parts[2]))
  if count < 1 or count > 20 return result.err(error("roll between 1 and 20 dice"))
  if sides < 2 or sides > 1000 return result.err(error("dice have between 2 and 1000 sides"))
  return result.ok(Dice(count, sides))
)

def rollText(string spec) string (
  Dice d = parseDice(spec) catch (e) (
    return "Sorry, " ++ e.message
  )
  int[] rolls = []
  for _ in 1 to d.count (
    rolls.append(random.int(1, d.sides))
  )
  return spec.trim() ++ ": " ++ rolls.join(" + ") ++ " = " ++ rolls.sum()
)

def helpText() string (
  return "!roll 2d6, !trivia, !answer <text>, !scores, !help"
)

def helpCard() *originchats.Embed (
  return originchats.newEmbed({title: "Dice bot", description: helpText(), color: "#5865f2"})
    .addField("!roll 2d6", "roll some dice")
    .addField("!trivia", "ask a question")
    .withFooter("dicebot")
)

struct Question (
  string text
  string answer
)

Question[] questions = [
  Question("What is the capital of France?", "paris"),
  Question("How many sides has a hexagon?", "6"),
  Question("Which planet is the Red Planet?", "mars"),
]

Question? current = null
string[int] scores = {}

def ask() string (
  Question q = random.item(questions)
  current = q
  return "Trivia: " ++ q.text
)

def answer(string user, string guess) string (
  if current == null return "No question yet. Try !trivia"
  Question q = current
  if guess.trim() == q.answer (
    current = null
    scores[user] = scores.get(user, 0) + 1
    return user ++ " got it! That's " ++ scores[user] ++ " point(s)."
  )
  return "Not " ++ guess.trim() ++ ". Try again!"
)

def scoreboard() string (
  if scores.len == 0 return "No scores yet."
  string[] lines = []
  for name in scores.getKeys().sort() (
    lines.append(name ++ ": " ++ scores[name])
  )
  return lines.join(", ")
)

def handle(string user, string text) string (
  string[] words = text.trim().split(" ")
  string rest = words.trim(2, words.len).join(" ")
  switch words[1] (
    case "!roll"
      return rollText(if rest == "" then "1d6" else rest)
    case "!trivia"
      return ask()
    case "!answer"
      return answer(user, rest)
    case "!scores"
      return scoreboard()
    case "!help"
      return helpText()
    default
      return ""
  )
)

string token = env.get("ROTUR_TOKEN")
*originchats.Client client = originchats.new("wss://chats.mistium.com", {token: token})

client.on("ready", def(*originchats.Client c) (
  log "ready as " ++ (c.username() ?? "?")
))

client.command("!help", def(*originchats.Message msg) (
  msg.reply("", {embeds: [helpCard()]}).unwrap()
))

client.command("!roll", def(*originchats.Message msg) (
  msg.reply(handle(msg.user(), "!roll " ++ msg.content())).unwrap()
))

client.command("!trivia", def(*originchats.Message msg) (
  msg.reply(handle(msg.user(), "!trivia")).unwrap()
))

client.command("!answer", def(*originchats.Message msg) (
  msg.reply(handle(msg.user(), "!answer " ++ msg.content())).unwrap()
))

client.command("!scores", def(*originchats.Message msg) (
  msg.reply(handle(msg.user(), "!scores")).unwrap()
))

client.command("!poll", def(*originchats.Message msg) (
  msg.target().send("", {embeds: [originchats.newPoll(msg.content(), ["yes", "no"])]}).unwrap()
))

client.slashCommand("/roll")
  .description("roll some dice")
  .addString("dice", {description: "for example 2d6", required: false})
  .fn(def(*originchats.Slash call) (
    call.respond(handle(call.user(), "!roll " ++ call.argStr("dice"))).unwrap()
  ))

client.run().unwrap()
Click the code to edit it

Highlighted lines are new in this step.