"""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())