Translator
A room where everyone writes in their own language: each message is translated by a model on your own server, and the translation shows under it for everyone.
Works today With @pikoman/ws 1.0.0-rc.10 from npm and Pikoman as it runs now. The model runs on your own server.
A folder that runs as it is, in Node and Python: translator.mjs, translator.py, .env.example, README.md, package.json, page.js, pikoman_bot.py, requirements.txt.
What it does
The bot sends each chat message to your model server over an OpenAI-compatible chat API (Ollama, llama.cpp, vLLM; we use Gemma 4 with Ollama) and posts the translation in the room. Two questions to the model at a time. A connection may send 10 client events a second, so the bot sends at most 8: when the room is busy, several translations go out in one event.
- room
- translator
- your model
- room
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. The rooms that want translating get their own prefix.
- Start from
- Chosen: Bot
- Name
- Translator
- Rights
- Not chosen:
publishNot chosen:statsNot chosen:clientsNot chosen:disconnectNot chosen:disconnect_allChosen:subscribeChosen:client_eventsNot chosen:kickNot chosen:ban - Channel kinds
- Not chosen: public Not chosen: private Chosen: presence
- Channels
- Chosen: Only some channelsprefix
presence-intl- - 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.
How it works
It queues each message
Every chat message from a member that is not a bot, up to 1 000 characters, with the sender’s signed name.
It asks your model
POST {AI_URL}/chat/completionswithAI_MODEL, at temperature 0 and a short instruction: the translation only.AI_KEY, if set, goes as a bearer token. Nothing leaves your server but the call to it.It batches when busy
Translations wait per room. Every 125 ms the room that has waited longest gets one
client-translationsevent with all it has, up to 20: one at a time when it is quiet, several when it is busy, and never more than 8 events a second.The page shows them
page.jstakes them from a bot only, and shows them as text.
The source
These are the files in the zip, as they are: copy one, download one, or take the whole folder.
Node: translator.mjs
// translator.mjs: translates each chat message with a language model on your own server and posts
// the translation in the room, so people can talk across languages. Any server with an
// OpenAI-compatible chat API works: Ollama, llama.cpp, vLLM. We use Gemma 4 with Ollama.
// A connection may send 10 client events a second, so when the room is busy the translations go
// out together, a few in each event. Node 22+. npm install, then npm start (reads .env).
//
// Its key (dashboard, New API key): rights subscribe and client_events, kind presence,
// channels: prefix presence-intl-.
import { Pikoman, OfflineAuthenticator } from "@pikoman/ws";
const ROOMS = (process.env.ROOMS || "presence-intl-lobby").split(",");
const LANGUAGE = process.env.LANGUAGE || "English";
const AI_URL = process.env.AI_URL || "http://127.0.0.1:11434/v1"; // Ollama's OpenAI-compatible API
const AI_MODEL = process.env.AI_MODEL || "gemma4:12b";
const AT_ONCE = 2; // questions to the model at the same time
const SEND_EVERY = 125; // ms between two events from the bot: 8 a second, under the limit of 10
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: "Translator" }, 3);
/** One message through the model: the translation, or null if it could not. */
async function translate(text) {
const response = await fetch(`${AI_URL}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.AI_KEY ? { Authorization: `Bearer ${process.env.AI_KEY}` } : {}),
},
body: JSON.stringify({
model: AI_MODEL,
temperature: 0,
messages: [
{ role: "system", content: `Translate the user's chat message into ${LANGUAGE}. Answer with the translation only. If it is already in ${LANGUAGE}, answer with it unchanged.` },
{ role: "user", content: text },
],
}),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`${AI_URL}: ${response.status}`);
const answer = (await response.json()).choices?.[0]?.message?.content?.trim();
return answer || null;
}
// What waits: messages for the model, and translations for the room.
const toTranslate = []; // { room, from, name, text }
const toSend = new Map(); // room -> [{ from, name, text, original }]
let asking = 0;
async function work() {
while (asking < AT_ONCE && toTranslate.length) {
const job = toTranslate.shift();
asking++;
translate(job.text)
.then((text) => {
if (text && text !== job.text) {
const list = toSend.get(job.room) ?? [];
list.push({ from: job.from, name: job.name, text, original: job.text });
toSend.set(job.room, list);
}
})
.catch((e) => console.error("translate:", e.message))
.finally(() => {
asking--;
work();
});
}
}
// One event every SEND_EVERY ms, to the room that has waited longest, with all it has: one
// translation when it is quiet, a batch when it is busy. Never more than 10 a second.
const rooms = new Map();
setInterval(() => {
const [name, items] = toSend.entries().next().value ?? [];
if (!name) return;
toSend.delete(name);
rooms.get(name).send("translations", { language: LANGUAGE, items: items.slice(0, 20) });
if (items.length > 20) toSend.set(name, items.slice(20)); // the rest next time (12 KiB per event)
}, SEND_EVERY);
for (const name of ROOMS) {
const room = pikoman.listenPresence(name, auth);
rooms.set(name, room);
room.onClient("message", (from, data) => {
const text = String(data?.text ?? "").trim().slice(0, 1000);
if (!text || room.isBot(from)) return;
if (toTranslate.length > 200) return console.error(`${name}: too much to translate, a message skipped`);
toTranslate.push({ room: name, from, name: room.getMember(from)?.name ?? "Someone", text });
work();
});
room.refused((code) => console.error(`${name}: ${code}`));
}
console.log(`Translating ${ROOMS.join(", ")} into ${LANGUAGE} with ${AI_MODEL}`);
pikoman.revoked(() => {
console.error("The bot's key was deleted.");
process.exit(1);
});Python: translator.py
"""translator.py: translates each chat message with a language model on your own server and posts
the translation in the room, in Python. Any server with an OpenAI-compatible chat API works:
Ollama, llama.cpp, vLLM. We use Gemma 4 with Ollama. A connection may send 10 client events a
second, so when the room is busy the translations go out together, a few in each event.
Python 3.10+. pip install -r requirements.txt, then python translator.py (reads .env).
Its key (dashboard, New API key): rights subscribe and client_events, kind presence,
channels: prefix presence-intl-.
"""
import asyncio
import json
import urllib.request
from pikoman_bot import Bot, env
ROOMS = env("ROOMS", "presence-intl-lobby").split(",")
LANGUAGE = env("LANGUAGE", "English")
AI_URL = env("AI_URL", "http://127.0.0.1:11434/v1") # Ollama's OpenAI-compatible API
AI_MODEL = env("AI_MODEL", "gemma4:12b")
AT_ONCE = 2 # questions to the model at the same time
SEND_EVERY = 0.125 # seconds between two events from the bot: 8 a second, under the limit of 10
bot = Bot("Translator", access=3)
asking = asyncio.Semaphore(AT_ONCE)
to_send = {} # room name -> [{from, name, text, original}], oldest room first
def translate(text):
"""One message through the model (in a thread: urllib waits): the translation, or None."""
body = json.dumps({
"model": AI_MODEL,
"temperature": 0,
"messages": [
{"role": "system", "content": f"Translate the user's chat message into {LANGUAGE}. Answer with the translation only. If it is already in {LANGUAGE}, answer with it unchanged."},
{"role": "user", "content": text},
],
}).encode()
headers = {"Content-Type": "application/json"}
if env("AI_KEY"):
headers["Authorization"] = "Bearer " + env("AI_KEY")
request = urllib.request.Request(f"{AI_URL}/chat/completions", data=body, headers=headers, method="POST")
with urllib.request.urlopen(request, timeout=30) as response:
answer = json.load(response)["choices"][0]["message"]["content"].strip()
return answer or None
def translator(room):
pending = set() # tasks still with the model, kept so they are not collected
@room.on("message")
def message(sender, data, info):
text = str((data or {}).get("text", "")).strip()[:1000]
if not text or room.is_bot(sender):
return
if len(pending) > 200:
return print(f"{room.name}: too much to translate, a message skipped")
name = room.member(sender).get("name", "Someone")
task = asyncio.create_task(job(sender, name, text))
pending.add(task)
task.add_done_callback(pending.discard)
async def job(sender, name, text):
async with asking:
try:
translated = await asyncio.to_thread(translate, text)
except Exception as e: # noqa: BLE001: the next message may go better
return print("translate:", e)
if translated and translated != text:
to_send.setdefault(room.name, []).append({"from": sender, "name": name, "text": translated, "original": text})
async def sender():
"""One event every SEND_EVERY seconds, to the room that has waited longest, with all it has."""
while True:
await asyncio.sleep(SEND_EVERY)
if not to_send:
continue
name = next(iter(to_send))
items = to_send.pop(name)
await bot.rooms[name].send("translations", {"language": LANGUAGE, "items": items[:20]})
if len(items) > 20:
to_send[name] = items[20:] # the rest next time (12 KiB per event)
for name in ROOMS:
translator(bot.join(name))
print(f"Translating {', '.join(ROOMS)} into {LANGUAGE} with {AI_MODEL}")
bot.start(sender())On your page: page.js
// page.js: the translations under each message, on a member's page. 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-intl-lobby").
export function showTranslations(room, list) {
room.onClient("translations", (from, { language, items }) => {
if (!room.isBot(from)) return;
for (const { name, text } of items) {
const line = document.createElement("li");
line.textContent = `${name} (${language}): ${text}`; // text, never HTML
list.append(line);
}
});
}pikoman_bot.py: what every Python bot here shares, the tokens, the websocket and the rooms
"""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)# 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-intl-)
PIKOMAN_API_KEY=
# The rooms to translate, comma-separated (default presence-intl-lobby)
ROOMS=
# The language to translate into (default English)
LANGUAGE=
# Your model server's OpenAI-compatible API (default http://127.0.0.1:11434/v1, Ollama) and model (default gemma4:12b)
AI_URL=
AI_MODEL=
# A key for the model server, if it wants one (sent as Authorization: Bearer)
AI_KEY=
# Another server than wss://next.pikoman.se, for tests
PIKOMAN_WS={
"name": "pikoman-translator-bot",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Translates each message in a Pikoman chat room with a language model on your own server.",
"license": "MIT",
"scripts": {
"start": "node --env-file=.env translator.mjs"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"@pikoman/ws": "1.0.0-rc.10"
}
}websockets==15.0.1
cryptography==46.0.3Run it
# unpack translator-bot.zip, then in the folder:
npm install
cp .env.example .env # fill in PIKOMAN_APP and PIKOMAN_API_KEY
npm startpython -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python translator.pyThe model, once: ollama pull gemma4:12b (Apache-2.0). Check the licence of any other model you choose: some do not allow commercial use. We checked both examples against Gemma 4 in Ollama: “Hej allihopa, vi ses vid lunch!” became “Hi everyone, see you at lunch!”.
Ideas to take it further
- A language per member: put it in the signed member data, and send each translation to them alone with
sendTo. - Skip messages already in the target language, with a cheap check before the model.
- Summarise a long room for someone who just came back, with the same model.