Quiz master

One bot per room runs the game: it asks each question, takes the answers, scores them by its own clock and shows the leaderboard. A simple version; Pling is the finished product built this way.

Works today With @pikoman/ws 1.0.0-rc.10 from npm and Pikoman as it runs now.

Download quiz-bot.zip

A folder that runs as it is, in Node and Python: quiz.mjs, quiz.py, .env.example, README.md, package.json, page.js, pikoman_bot.py, quiz.json, requirements.txt.

What it does

The host writes !quiz. The bot sends the questions from quiz.json one at a time, without the right answer; players answer with a client event, and the first answer of each counts. When the time is up the bot reveals the answer and the scores: a right answer is worth 500 points and up to 500 more for speed, measured when the answer reached the bot. At the end it posts the leaderboard.

  1. host: !quiz
  2. quiz master
  3. players answer
  4. leaderboard

The key it needs

Rights subscribe and client_events on the room prefix: it may join the rooms and speak in them, and nothing else. One bot per room: the quiz room’s prefix only.

New API key, filled in for the quiz master dashboard.pikoman.se
Start from
Chosen: Bot
Name
Quiz master
Rights
Not chosen: publish Not chosen: stats Not chosen: clients Not chosen: disconnect Not chosen: disconnect_all Chosen: subscribe Chosen: client_events Not chosen: kick Not chosen: ban
Channel kinds
Not chosen: public Not chosen: private Chosen: presence
Channels
Chosen: Only some channelsprefix presence-quiz-
Addresses
Chosen: Only these addressesthe server the bot runs on
Expires
Never, or in 90 days for a trial

Set up

The docs cover the key and connecting; everything about the bot itself is on this page.

  • The Pro plan. A key that joins channels is a bot’s key, and bot keys come with Pro.
  • The key above. In the dashboard: your app, New API key, filled in as shown. How each choice works: Keys and rights. The secret, sk_k_….…, is shown once (what it looks like).
  • Your app id and the secret in .env, copied from .env.example. Nowhere else: not in a page, not in git.
  • Connecting. The bot signs its own join tokens with the key, and Pikoman shows it as a bot: bots in presence channels.
  • Your members join with tokens your backend signs, with their name and user id in the member data, {"user_id": "42", "name": "Ada"}: the token endpoint.

How it works

  1. Only the host starts it

    Your backend signs "host": true into the host’s member data; the bot checks it there, never in what a player sends.

  2. The answer stays with the bot

    client-question carries the question and the options, not the answer: it is sent only with client-reveal, after the time is up.

  3. The bot’s clock decides

    The time of an answer is when it reached the bot. A player cannot claim to have been fast; the first answer per user id counts, later ones are ignored, and so are answers to an earlier question.

  4. It keeps the score

    After each question, the right answer and the top ten; at the end, the winner.

Hiding the answers

Here an answer is a client event, and every member of the room receives it: a player who reads the traffic can see what the others chose. That is fine among friends. For a game with something at stake, there are two ways to hide them:

  • To the bot alone. A player sends the answer with room.sendTo(bot, …), and only the bot gets it.
  • Sealed. The player seals the answer to a key whose private half lives only in the bot’s memory, and puts their player id inside; the bot opens it and checks the id against who Pikoman says sent it. This is how Pling does it, with one bot per room, roles in signed member data and scores by the bot’s own clock.

The source

These are the files in the zip, as they are: copy one, download one, or take the whole folder.

Node: quiz.mjs

quiz.mjsDownload quiz.mjs
// quiz.mjs: a quiz master, one bot per room. The host writes "!quiz", the bot asks the questions in
// quiz.json one by one, members answer with a client event, and the bot scores each answer by its
// own clock: right and fast gets most. After each question it shows the right answer, and at the
// end the leaderboard. Node 22+. npm install, then npm start (reads .env).
//
// Kept simple: an answer is a client event, which every member of the room receives. To hide the
// answers, send them to the bot alone (sendTo) or seal them to a key only
// the bot holds, as Pling on quiz.pikoman.se does.
//
// Its key (dashboard, New API key): rights subscribe and client_events, kind presence,
// channels: prefix presence-quiz-.
import { readFileSync } from "node:fs";
import { Pikoman, OfflineAuthenticator } from "@pikoman/ws";

const ROOM = process.env.ROOM || "presence-quiz-1";
// QUIZ: another file of questions (a path); quiz.json beside the bot when it is not set.
const QUESTIONS = JSON.parse(readFileSync(process.env.QUIZ || new URL("./quiz.json", import.meta.url), "utf8"));
const PAUSE = Number(process.env.PAUSE_SECONDS || 5); // between the answer and the next question

const pikoman = new Pikoman(process.env.PIKOMAN_APP, {
  ws_address: process.env.PIKOMAN_WS || "wss://next.pikoman.se",
});
const auth = OfflineAuthenticator.fromSecret(process.env.PIKOMAN_API_KEY, { name: "Quiz master" }, 3);
const room = pikoman.listenPresence(ROOM, auth);

const sleep = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
let running = false;
let open = null; // the question taking answers: { n, asked, seconds, answers: Map(user -> { option, ms }) }
const scores = new Map(); // user -> { name, points }

// Members are told apart by what your backend signed into their join token, never by what they
// send: {"user_id": "42", "name": "Ada"}, and {"host": true} for the one who may start the quiz.
const userOf = (from) => room.getMember(from)?.user_id ?? from;

room.onClient("message", (from, data) => {
  if (String(data?.text ?? "").trim() !== "!quiz" || room.isBot(from)) return;
  if (room.getMember(from)?.host !== true) return room.send("message", { text: "Only the host can start the quiz." });
  if (running) return;
  play();
});

// An answer: room.send("answer", { n: 1, option: 2 }) on the member's page. The first one counts.
room.onClient("answer", (from, data) => {
  if (!open || data?.n !== open.n || room.isBot(from)) return;
  const option = data?.option;
  const user = userOf(from);
  if (!Number.isInteger(option) || open.answers.has(user)) return;
  // The time is the bot's own, when the answer reached it: nothing a player writes counts.
  open.answers.set(user, { option, ms: Date.now() - open.asked, name: room.getMember(from)?.name ?? "Someone" });
});

async function play() {
  running = true;
  scores.clear();
  await room.send("message", { text: `The quiz starts: ${QUESTIONS.length} questions.` });
  for (const [i, q] of QUESTIONS.entries()) {
    const n = i + 1;
    const seconds = q.seconds ?? 15;
    // The question, without the right answer: that stays with the bot until the reveal.
    room.send("question", { n, total: QUESTIONS.length, question: q.question, options: q.options, seconds });
    open = { n, asked: Date.now(), seconds, answers: new Map() };
    await sleep(seconds);
    const { answers } = open;
    open = null;

    for (const [user, { option, ms, name }] of answers) {
      const score = scores.get(user) ?? { name, points: 0 };
      // Right: 500 points, and up to 500 more for speed. Wrong: nothing.
      if (option === q.answer) score.points += 500 + Math.round(500 * Math.max(0, 1 - ms / (seconds * 1000)));
      scores.set(user, score);
    }
    const right = [...answers.values()].filter((a) => a.option === q.answer).length;
    room.send("reveal", { n, answer: q.answer, right, answered: answers.size, leaderboard: leaderboard() });
    room.send("message", { text: `${q.options[q.answer]}! ${right} of ${answers.size} got it right.` });
    if (n < QUESTIONS.length) await sleep(PAUSE);
  }
  const top = leaderboard();
  room.send("leaderboard", { final: true, leaderboard: top });
  room.send("message", { text: top.length ? `The winner: ${top[0].name}, ${top[0].points} points.` : "Nobody answered." });
  running = false;
}

function leaderboard() {
  return [...scores.values()].sort((a, b) => b.points - a.points).slice(0, 10);
}

room.refused((code) => console.error(`${ROOM}: ${code}`));
console.log(`Quiz master in ${ROOM}: ${QUESTIONS.length} questions`);
pikoman.revoked(() => {
  console.error("The bot's key was deleted.");
  process.exit(1);
});

Python: quiz.py

quiz.pyDownload quiz.py
"""quiz.py: a quiz master, one bot per room, in Python. The host writes "!quiz", the bot asks the
questions in quiz.json one by one, members answer with a client event, and the bot scores each
answer by its own clock: right and fast gets most. After each question it shows the right answer,
and at the end the leaderboard. Python 3.10+. pip install -r requirements.txt, then
python quiz.py (reads .env).

Kept simple: an answer is a client event, which every member of the room receives. To hide the
answers, send them to the bot alone (send_to) or seal them to a key only
the bot holds, as Pling on quiz.pikoman.se does.

Its key (dashboard, New API key): rights subscribe and client_events, kind presence,
channels: prefix presence-quiz-.
"""

import asyncio
import json
import time
from pathlib import Path

from pikoman_bot import Bot, env

ROOM = env("ROOM", "presence-quiz-1")
QUESTIONS = json.loads((Path(__file__).parent / env("QUIZ", "quiz.json")).read_text(encoding="utf-8"))
PAUSE = float(env("PAUSE_SECONDS", "5"))  # between the answer and the next question

bot = Bot("Quiz master", access=3)
room = bot.join(ROOM)
state = {"running": False, "open": None, "task": None}  # open: the question taking answers
scores = {}  # user -> {name, points}


def user_of(sender):
    # Members are told apart by what your backend signed into their join token, never by what they
    # send: {"user_id": "42", "name": "Ada"}, and {"host": true} for the one who may start the quiz.
    return room.member(sender).get("user_id", sender)


@room.on("message")
async def message(sender, data, info):
    if str((data or {}).get("text", "")).strip() != "!quiz" or room.is_bot(sender):
        return
    if room.member(sender).get("host") is not True:
        return await room.send("message", {"text": "Only the host can start the quiz."})
    if not state["running"]:
        state["task"] = asyncio.create_task(play())  # kept, so it is not collected while it plays


# An answer: room.send("answer", { n: 1, option: 2 }) on the member's page. The first one counts.
@room.on("answer")
def answer(sender, data, info):
    question = state["open"]
    if not question or not isinstance(data, dict) or data.get("n") != question["n"] or room.is_bot(sender):
        return
    option, user = data.get("option"), user_of(sender)
    if type(option) is not int or user in question["answers"]:
        return
    # The time is the bot's own, when the answer reached it: nothing a player writes counts.
    question["answers"][user] = {"option": option, "ms": (time.monotonic() - question["asked"]) * 1000,
                                 "name": room.member(sender).get("name", "Someone")}


def leaderboard():
    return sorted(scores.values(), key=lambda s: -s["points"])[:10]


async def play():
    state["running"] = True
    scores.clear()
    await room.send("message", {"text": f"The quiz starts: {len(QUESTIONS)} questions."})
    for n, q in enumerate(QUESTIONS, start=1):
        seconds = q.get("seconds", 15)
        # The question, without the right answer: that stays with the bot until the reveal.
        await room.send("question", {"n": n, "total": len(QUESTIONS), "question": q["question"], "options": q["options"], "seconds": seconds})
        state["open"] = {"n": n, "asked": time.monotonic(), "answers": {}}
        await asyncio.sleep(seconds)
        answers, state["open"] = state["open"]["answers"], None

        for user, a in answers.items():
            score = scores.setdefault(user, {"name": a["name"], "points": 0})
            # Right: 500 points, and up to 500 more for speed. Wrong: nothing.
            if a["option"] == q["answer"]:
                score["points"] += 500 + round(500 * max(0, 1 - a["ms"] / (seconds * 1000)))
        right = sum(1 for a in answers.values() if a["option"] == q["answer"])
        await room.send("reveal", {"n": n, "answer": q["answer"], "right": right, "answered": len(answers), "leaderboard": leaderboard()})
        await room.send("message", {"text": f"{q['options'][q['answer']]}! {right} of {len(answers)} got it right."})
        if n < len(QUESTIONS):
            await asyncio.sleep(PAUSE)
    top = leaderboard()
    await room.send("leaderboard", {"final": True, "leaderboard": top})
    await room.send("message", {"text": f"The winner: {top[0]['name']}, {top[0]['points']} points." if top else "Nobody answered."})
    state["running"] = False


print(f"Quiz master in {ROOM}: {len(QUESTIONS)} questions")
bot.start()

The player’s page: page.js

page.jsDownload page.js
// page.js: the quiz on a player's page. A question with its buttons, the right answer when the
// time is up, and the leaderboard at the end. Trust only the bot: room.isBot(from) is set by the
// server, never by a member. `room` is the presence channel your page already joined:
// pikoman.listenPresence("presence-quiz-1").
export function playQuiz(room, box) {
  const say = (text) => box.replaceChildren(Object.assign(document.createElement("p"), { textContent: text }));

  room.onClient("question", (from, { n, total, question, options }) => {
    if (!room.isBot(from)) return;
    say(`${n}/${total}: ${question}`);
    options.forEach((option, i) => {
      const button = Object.assign(document.createElement("button"), { type: "button", textContent: option });
      button.addEventListener("click", () => {
        room.send("answer", { n, option: i });
        box.querySelectorAll("button").forEach((b) => (b.disabled = true)); // the first answer counts
        button.setAttribute("aria-pressed", "true");
      });
      box.append(button);
    });
  });
  room.onClient("reveal", (from, { answer, right, answered }) => {
    if (!room.isBot(from)) return;
    const buttons = box.querySelectorAll("button");
    buttons.forEach((b) => (b.disabled = true));
    say(`The answer: ${buttons[answer]?.textContent ?? answer + 1}. ${right} of ${answered} got it right.`);
  });
  room.onClient("leaderboard", (from, { leaderboard }) => {
    if (!room.isBot(from)) return;
    const list = document.createElement("ol");
    for (const { name, points } of leaderboard) {
      list.append(Object.assign(document.createElement("li"), { textContent: `${name}: ${points}` }));
    }
    box.replaceChildren(list);
  });
}

The questions: quiz.json

quiz.jsonDownload quiz.json
[
  {
    "question": "Which planet is closest to the sun?",
    "options": ["Venus", "Mercury", "Mars"],
    "answer": 1,
    "seconds": 15
  },
  {
    "question": "How many strings does a standard violin have?",
    "options": ["Four", "Five", "Six"],
    "answer": 0,
    "seconds": 15
  },
  {
    "question": "Which language is spoken in Brazil?",
    "options": ["Spanish", "Portuguese", "French"],
    "answer": 1,
    "seconds": 15
  }
]
pikoman_bot.py: what every Python bot here shares, the tokens, the websocket and the rooms
pikoman_bot.pyDownload pikoman_bot.py
"""pikoman_bot.py: what a Pikoman bot needs in Python, in one file next to the bot.

- Rest: signed HTTP requests (publish, to one member, stats), each with a token made for that
  one request, as KEYS.md says: the method, the path and a hash of the exact body.
- Bot and Room: a websocket that joins presence channels with join tokens signed by the bot's
  own API key, keeps the member list (and which members are bots), hands client events to your
  handlers, sends, sends to one member and kicks. It reconnects by itself.

Python 3.10+, pip install -r requirements.txt (websockets, BSD-3-Clause; cryptography,
Apache-2.0 OR BSD-3-Clause). Reads PIKOMAN_APP, PIKOMAN_API_KEY and, for tests, PIKOMAN_WS and
PIKOMAN_HTTP from the environment or from a .env file beside the bot.
"""

import asyncio
import base64
import hashlib
import inspect
import json
import os
import random
import signal
import time
import urllib.error
import urllib.parse
import urllib.request

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed

LOCAL = ("localhost", "127.0.0.1", "[::1]")


def load_env(path=".env"):
    """KEY=value lines from .env; what the environment already has wins."""
    try:
        with open(path, encoding="utf-8") as file:
            lines = file.read().splitlines()
    except FileNotFoundError:
        return
    for line in lines:
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        name, value = (part.strip() for part in line.split("=", 1))
        value = value.strip("\"'")
        if value and name not in os.environ:
            os.environ[name] = value


load_env()


def env(name, default=""):
    """A setting from the environment; empty counts as not set."""
    return os.environ.get(name) or default


def b64(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def checked(address: str, secure: str) -> str:
    """https:// or wss://; plain http:// or ws:// only to this computer, so keys never go in the clear."""
    parsed = urllib.parse.urlsplit(address)
    if parsed.scheme != secure and not (parsed.scheme == secure[:-1] and parsed.hostname in ("localhost", "127.0.0.1", "::1")):
        raise ValueError(f"{address}: use {secure}:// (plain {secure[:-1]}:// only for localhost)")
    return address.rstrip("/")


class PikomanError(Exception):
    def __init__(self, status, text):
        super().__init__(f"{status}: {text}")
        self.status = status


class Key:
    """An API key's secret, sk_<key id>.<seed>: the id goes in each token's header, the seed signs it."""

    def __init__(self, secret: str):
        if not secret.startswith("sk_") or "." not in secret:
            raise ValueError("PIKOMAN_API_KEY looks like sk_k_….…: the secret the dashboard showed once")
        key_id, seed = secret[3:].split(".", 1)
        self.id = key_id
        self._key = Ed25519PrivateKey.from_private_bytes(base64.urlsafe_b64decode(seed + "=" * (-len(seed) % 4)))

    def sign(self, claims: dict) -> str:
        """An EdDSA JWT with the key id in its header."""
        header = {"alg": "EdDSA", "typ": "JWT", "kid": self.id}
        signed = b64(json.dumps(header).encode()) + "." + b64(json.dumps(claims).encode())
        return signed + "." + b64(self._key.sign(signed.encode()))

    def __repr__(self):
        return f"Key({self.id}, [hidden])"


def target(member) -> dict:
    """A connection id ("c_…"), or {"user_id": "42"} for every connection of that user."""
    if isinstance(member, dict):
        return {"to_user": str(member["user_id"])}
    return {"to": member}


class Rest:
    """The HTTP API, every request signed with the API key and bound to that one request."""

    def __init__(self, app=None, api_key=None, address=None):
        self.app = app or env("PIKOMAN_APP")
        if not self.app:
            raise ValueError("Set PIKOMAN_APP: your app id, app-…")
        self.key = Key(api_key or env("PIKOMAN_API_KEY"))
        self.address = checked(address or env("PIKOMAN_HTTP", "https://next-api.pikoman.se"), "https")

    def request(self, method: str, path: str, data=None) -> dict:
        body = b"" if data is None else json.dumps(data).encode()
        now = int(time.time())
        token = self.key.sign({
            "iss": self.app, "iat": now, "exp": now + 60, "htm": method, "htu": path,
            "bh": b64(hashlib.sha256(body).digest()),
        })
        request = urllib.request.Request(self.address + path, data=body if data is not None else None, method=method, headers={
            "Authorization": "Bearer " + token,
            "Content-Type": "application/json",
        })
        try:
            with urllib.request.urlopen(request, timeout=10) as response:
                text = response.read()
                return json.loads(text) if text else {}
        except urllib.error.HTTPError as e:
            raise PikomanError(e.code, e.read().decode(errors="replace")) from None

    def publish(self, channel: str, event: str, data=None, to=None) -> dict:
        """POST /{channel}/{event}; with `to`, to one member only."""
        path = f"/{urllib.parse.quote(channel, safe='=@,.;')}/{urllib.parse.quote(event, safe='=@,.;')}"
        if to is not None:
            path += "?" + urllib.parse.urlencode(target(to))
        return self.request("POST", path, data)

    def stats(self, channel: str) -> dict:
        return self.request("GET", f"/{urllib.parse.quote(channel, safe='=@,.;')}/stats")


async def call(fn, *args):
    """Runs a handler, plain or async; an error in it is printed and the bot goes on."""
    try:
        result = fn(*args)
        if inspect.isawaitable(result):
            await result
    except Exception as e:  # noqa: BLE001: one handler must never stop the bot
        print(f"handler {getattr(fn, '__name__', fn)}: {e!r}")


def client_name(event: str) -> str:
    return event if event.startswith("client-") else "client-" + event


class Room:
    """A presence channel the bot is in: its members, which of them are bots, and its events."""

    def __init__(self, bot, name: str):
        self.bot = bot
        self.name = name
        self.members = {}  # connection id -> member data, as their join token gave it
        self.bots = {}  # connection id -> the API key id a bot joined with: only the server says
        self._handlers = {}
        self._any = []
        self._joins = []
        self._leaves = []
        self._errors = []

    def on(self, event: str):
        """@room.on("message"): fn(sender, data, info) for that client event (client-message)."""
        def add(fn):
            self._handlers.setdefault(client_name(event), []).append(fn)
            return fn
        return add

    def on_any(self, fn):
        """@room.on_any: fn(sender, event, data, info) for every client event."""
        self._any.append(fn)
        return fn

    def on_join(self, fn):
        """@room.on_join: fn(connection, data) when a member joins."""
        self._joins.append(fn)
        return fn

    def on_leave(self, fn):
        """@room.on_leave: fn(connection, data) when a member leaves."""
        self._leaves.append(fn)
        return fn

    def on_error(self, fn):
        """@room.on_error: fn(code), such as join_refused, not_allowed or rate_limited."""
        self._errors.append(fn)
        return fn

    def is_bot(self, connection: str) -> bool:
        return connection in self.bots

    def member(self, connection: str) -> dict:
        data = self.members.get(connection)
        return data if isinstance(data, dict) else {}

    async def send(self, event: str, data=None):
        """A client event to the other members (at most 10 a second per connection)."""
        await self.bot.send({"op": "send", "channel": self.name, "event": client_name(event), "data": data, "echo": False})

    async def send_to(self, member, event: str, data=None):
        """To one member only: a connection id, or {"user_id": "42"}."""
        await self.bot.send({"op": "send", "channel": self.name, "event": client_name(event), "data": data, "echo": False, **target(member)})

    async def kick(self, member, reason=None, ban_seconds=None):
        """Out of this room: needs the kick right (ban, with ban_seconds)."""
        message = {"op": "kick", "channel": self.name, **target(member)}
        if reason:
            message["reason"] = reason
        if ban_seconds:
            message["ban_seconds"] = ban_seconds
        await self.bot.send(message)

    async def handle(self, message: dict):
        op = message.get("op")
        if op == "members":
            self.members = dict(message.get("members") or {})
            self.bots = dict(message.get("bots") or {})
        elif op == "member_joined":
            self.members[message["id"]] = message.get("data")
            if message.get("bot"):
                self.bots[message["id"]] = message["bot"]
            for fn in self._joins:
                await call(fn, message["id"], message.get("data"))
        elif op == "member_left":
            data = self.members.pop(message["id"], None)
            self.bots.pop(message["id"], None)
            for fn in self._leaves:
                await call(fn, message["id"], data)
        elif op == "client_event":
            sender, event, data = message.get("from"), message.get("event", ""), message.get("data")
            # Set by the server, never by the sender: direct (to this bot alone), user_id, bot.
            info = {k: message[k] for k in ("direct", "user_id", "bot") if k in message}
            for fn in self._handlers.get(event, []):
                await call(fn, sender, data, info)
            for fn in self._any:
                await call(fn, sender, event, data, info)
        elif op == "error":
            print(f"{self.name}: {message.get('code')}")
            for fn in self._errors:
                await call(fn, message.get("code"))


class Bot:
    """One websocket for the bot, in as many rooms as it joins. await bot.run() keeps it going."""

    def __init__(self, name: str, access: int = 3, app=None, api_key=None, address=None):
        self.app = app or env("PIKOMAN_APP")
        if not self.app:
            raise ValueError("Set PIKOMAN_APP: your app id, app-…")
        self.key = Key(api_key or env("PIKOMAN_API_KEY"))
        self.address = checked(address or env("PIKOMAN_WS", "wss://next.pikoman.se"), "wss")
        self.data = {"name": name}  # what the members see next to "bot"
        self.access = access  # 3: may speak (the key needs client_events); 0: listens
        self.rooms = {}
        self.id = None
        self._ws = None
        self._ready = []

    def join(self, channel: str) -> Room:
        room = self.rooms[channel] = Room(self, channel)
        return room

    def on_ready(self, fn):
        """@bot.on_ready: fn() each time the bot is connected and has asked to join its rooms."""
        self._ready.append(fn)
        return fn

    def token(self, channel: str) -> str:
        """A join token for this connection and channel, signed with the bot's API key."""
        return self.key.sign({
            "connection": self.id, "channel": channel, "access": self.access,
            "data": self.data, "exp": int(time.time()) + 300,
        })

    async def send(self, message: dict):
        if self._ws is not None:
            await self._ws.send(json.dumps(message))

    def start(self, *tasks):
        """Runs the bot (and any other coroutines, such as a web server) until Ctrl+C or SIGTERM."""
        def stop(*_):
            raise KeyboardInterrupt

        signal.signal(signal.SIGTERM, stop)  # as from systemd or podman stop: the same as Ctrl+C

        async def main():
            await asyncio.gather(self.run(), *tasks)

        try:
            asyncio.run(main())
        except KeyboardInterrupt:
            print("Stopped.")

    async def run(self):
        delay = 1
        paused = 60  # after 4402: the first wait, doubling to five minutes
        while True:
            try:
                async with connect(f"{self.address}/{self.app}") as ws:
                    self._ws = ws
                    async for raw in ws:
                        message = json.loads(raw)
                        if message.get("op") == "connected":
                            delay = 1
                            self.id = message["id"]
                            for name in self.rooms:
                                await self.send({"op": "join", "channel": name, "token": self.token(name)})
                            for fn in self._ready:
                                await call(fn)
                        elif message.get("channel") in self.rooms:
                            await self.rooms[message["channel"]].handle(message)
                        elif message.get("op") == "error":
                            print("pikoman:", message.get("code"))
            except ConnectionClosed as e:
                code = e.rcvd.code if e.rcvd is not None else None
                if code == 4403:
                    raise SystemExit("The bot's key was deleted.") from None
                if code == 4401:
                    raise SystemExit("The app disconnected the bot.") from None
                if code == 4402:
                    # The key's bot rights are paused (the app's owner is off Pro): not for good.
                    # Try again in minutes, and the bot is back on its own when Pro is.
                    self._ws = None
                    self.id = None
                    print(f"The bot's key is paused until the app is on Pro again; trying again in about {paused // 60} min.")
                    await asyncio.sleep(random.uniform(paused / 2, paused))
                    paused = min(paused * 2, 300)
                    continue
                print("connection closed:", code if code is not None else "no close frame")
            except OSError as e:
                print("could not connect:", e)
            self._ws = None
            self.id = None
            paused = 60
            # Back again after a random wait, longer each time it fails, up to 30 s.
            await asyncio.sleep(random.uniform(0.5, delay))
            delay = min(delay * 2, 30)
.env.exampleDownload .env.example
# Copy to .env and fill in. Never commit .env.
# Your app id, app-…
PIKOMAN_APP=
# The bot's own API key, sk_k_….… (rights subscribe and client_events, prefix presence-quiz-)
PIKOMAN_API_KEY=
# The room to run the quiz in (default presence-quiz-1)
ROOM=
# The questions (default quiz.json, beside the bot)
QUIZ=
# Seconds between the answer and the next question (default 5)
PAUSE_SECONDS=
# Another server than wss://next.pikoman.se, for tests
PIKOMAN_WS=
package.jsonDownload package.json
{
  "name": "pikoman-quiz-bot",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "description": "A simple quiz master for a Pikoman room: questions from quiz.json, scores by the bot's own clock, a leaderboard.",
  "license": "MIT",
  "scripts": {
    "start": "node --env-file=.env quiz.mjs"
  },
  "engines": {
    "node": ">=22"
  },
  "dependencies": {
    "@pikoman/ws": "1.0.0-rc.10"
  }
}
requirements.txtDownload requirements.txt
websockets==15.0.1
cryptography==46.0.3

Run it

Node 22 or later
# unpack quiz-bot.zip, then in the folder:
npm install
cp .env.example .env    # fill in PIKOMAN_APP and PIKOMAN_API_KEY
npm start
Or Python 3.10 or later
python -m venv .venv && . .venv/bin/activate    # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python quiz.py

Ideas to take it further

  • Questions from your own editor or an LLM, written to quiz.json.
  • A TV view: the host’s page shows the question big, and phones show only the buttons, as in Pling.
  • Teams: put the team in the signed member data and add up per team.

More bots

We are rebuilding the dashboard

Self-service sign-up is closed while we do. Until the new dashboard is ready we set up apps by hand: write to us with a line about what you are building, and we will set up your app and get back to you.

Write to hello@pikoman.se