"""Lumora Bot SDK for Python — peer-to-peer bots, no central API. A bot is a normal Lumora identity running headless (`lumorad`). Users add it by its 9-digit ID like any friend; messages travel directly and end-to-end encrypted. from lumora_bot import Bot bot = Bot("Thời tiết Bot", data_dir="./weather-bot") @bot.command("start", "Bắt đầu") def start(ctx): ctx.reply("Xin chào! Chọn thành phố:", buttons=[[("Hà Nội", "hn"), ("TP.HCM", "hcm")]]) @bot.on_callback def city(ctx): ctx.reply(f"Bạn chọn: {ctx.data}") @bot.on_message def echo(ctx): ctx.reply(f"Bạn nói: {ctx.text}") bot.run() Permissions: bot = Bot("Shop Bot", admins=["", "@chu_shop·k7q2xz"]) @bot.command("ban", "Chặn người dùng", admin_only=True) def ban(ctx): ... bot.roles.grant("", "mod"); ctx.has_role("mod"); ctx.is_admin Persistent data: `bot.storage["key"] = value` (JSON file in data_dir, saved automatically). Broadcast: `bot.broadcast("Khuyến mãi hôm nay!")` sends to every user, paced. Limits: there is no central quota. Each recipient's device accepts up to 20 messages/s (burst 200) from one sender to stop spam; the SDK paces sends (default 15/s overall, 1/s per chat burst 5) so you never hit it. Requirements: Python ≥ 3.8 and the `lumorad` binary (set LUMORAD=/path/to/lumorad or pass `binary=`). Resource use: one process per bot, ~30 MB RAM, ~0 % CPU when idle. Handlers run on a small thread pool so a slow handler never blocks the network. """ from __future__ import annotations import json import os import queue import shutil import subprocess import threading import time from concurrent.futures import ThreadPoolExecutor from typing import Callable, Dict, List, Optional, Sequence, Tuple, Union ButtonSpec = Union[Tuple[str, str], Dict[str, str]] def _buttons(rows: Optional[Sequence[Sequence[ButtonSpec]]]): out = [] for row in rows or []: r = [] for b in row: if isinstance(b, dict): r.append({"text": b.get("text", ""), "data": b.get("data", ""), "url": b.get("url", "")}) else: text, data = b if str(data).startswith(("http://", "https://")): r.append({"text": text, "data": "", "url": data}) else: r.append({"text": text, "data": str(data), "url": ""}) out.append(r) return out class Storage(dict): """dict persisted as JSON in the bot's data dir (atomic writes, saved on change).""" def __init__(self, path: str): super().__init__() self._path = path self._lock = threading.Lock() try: with open(path, encoding="utf-8") as f: super().update(json.load(f)) except (OSError, ValueError): pass def save(self): with self._lock: tmp = self._path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(dict(self), f, ensure_ascii=False) os.replace(tmp, self._path) def __setitem__(self, k, v): super().__setitem__(k, v) self.save() def __delitem__(self, k): super().__delitem__(k) self.save() class Roles: """Simple role table: peer → set of roles, persisted in storage.""" def __init__(self, storage: Storage): self._s = storage self._s.setdefault("__roles__", {}) def grant(self, peer: str, role: str): r = self._s["__roles__"] r.setdefault(peer, []) if role not in r[peer]: r[peer].append(role) self._s.save() def revoke(self, peer: str, role: str): r = self._s["__roles__"] if role in r.get(peer, []): r[peer].remove(role) self._s.save() def of(self, peer: str): return set(self._s["__roles__"].get(peer, [])) class Context: """Passed to every handler.""" def __init__(self, bot: "Bot", event: dict): self.bot = bot self.event = event self.conv: str = event.get("conv") or event.get("msg_conv") or "" self.sender: str = event.get("author") or event.get("from") or "" body = event.get("body") or {} self.text: str = body.get("text", "") if isinstance(body, dict) else "" self.data: str = event.get("data", "") self.msg_id: str = event.get("id") or event.get("msg_id") or "" parts = self.text.strip().split(maxsplit=1) self.command: Optional[str] = parts[0][1:].split("@")[0] if parts and parts[0].startswith("/") else None self.args: str = parts[1] if len(parts) > 1 else "" @property def is_admin(self) -> bool: return self.sender in self.bot.admin_peers def has_role(self, role: str) -> bool: return self.is_admin or role in self.bot.roles.of(self.sender) def reply(self, text: str, buttons=None, reply: bool = False) -> dict: return self.bot.send(self.conv, text, buttons=buttons, reply_to=self.msg_id if reply else None) def send_file(self, path: str) -> dict: return self.bot.call("send_file", conv=self.conv, path=path) class Bot: def __init__(self, name: str = "", data_dir: str = "./lumora-bot", binary: Optional[str] = None, token: Optional[str] = None, about: str = "", lan: bool = True, public_directory: bool = True, workers: int = 4, extra_args: Optional[List[str]] = None, admins: Optional[List[str]] = None, rate_per_sec: float = 15.0): # token from the in-app BotFather (recommended): name/@username come from it self.token = token or os.environ.get("LUMORA_BOT_TOKEN") if not name and not self.token: raise ValueError("give the bot a name, or a token created in the app (BotFather)") self.name = name self.data_dir = data_dir self.binary = binary or os.environ.get("LUMORAD") or shutil.which("lumorad") or "lumorad" self.about = about self.extra = list(extra_args or []) if not lan: self.extra.append("--no-lan") if not public_directory: self.extra.append("--no-directory") self._commands: Dict[str, Tuple[Callable, str, bool]] = {} self._admin_specs = list(admins or []) self.admin_peers: set = set() os.makedirs(data_dir, exist_ok=True) self.storage = Storage(os.path.join(data_dir, "bot-storage.json")) self.roles = Roles(self.storage) self._rate = rate_per_sec self._out: "queue.Queue" = queue.Queue(maxsize=10000) self._last_chat: Dict[str, List[float]] = {} self._on_message: List[Callable] = [] self._on_callback: List[Callable] = [] self._on_user: List[Callable] = [] self._pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="lumora-bot") self._lock = threading.Lock() self._cv = threading.Condition() self._resp: Dict[int, dict] = {} self._next = 1 self._proc: Optional[subprocess.Popen] = None self.me: dict = {} # ------------------------------------------------------------ decorators def command(self, name: str, description: str = "", admin_only: bool = False, role: Optional[str] = None): """Register /name. admin_only / role restrict who may use it.""" def deco(fn): def guarded(ctx): if admin_only and not ctx.is_admin: return ctx.reply("⛔ Bạn không có quyền dùng lệnh này. / You are not allowed to use this command.") if role and not ctx.has_role(role): return ctx.reply("⛔ Bạn không có quyền dùng lệnh này. / You are not allowed to use this command.") return fn(ctx) self._commands[name.lstrip("/")] = (guarded, description, admin_only or bool(role)) return fn return deco def on_message(self, fn): self._on_message.append(fn) return fn def on_callback(self, fn): self._on_callback.append(fn) return fn def on_new_user(self, fn): self._on_user.append(fn) return fn # ------------------------------------------------------------ API def call(self, cmd: str, timeout: float = 60, **args) -> dict: with self._lock: rid = self._next self._next += 1 args.update(id=rid, cmd=cmd) assert self._proc and self._proc.stdin with self._lock: self._proc.stdin.write(json.dumps(args, ensure_ascii=False) + "\n") self._proc.stdin.flush() with self._cv: if not self._cv.wait_for(lambda: rid in self._resp, timeout): raise TimeoutError(cmd) r = self._resp.pop(rid) if not r.get("ok"): raise RuntimeError(r.get("error")) return r.get("data") def send(self, conv: str, text: str, buttons=None, reply_to: Optional[str] = None) -> dict: """Send now, paced: ≤ rate_per_sec overall and a small per-chat burst (never trips receivers' anti-spam).""" self._pace(conv) args = {"conv": conv, "text": text, "buttons": _buttons(buttons)} if reply_to: args["reply_to"] = reply_to return self.call("send_message", **args) def _pace(self, conv: str): with self._lock: now = time.monotonic() hist = [t for t in self._last_chat.get(conv, []) if now - t < 5.0] wait = 0.0 if len(hist) >= 5: # per chat: burst 5, then 1/s wait = max(wait, 1.0 - (now - hist[-1])) g = self._last_chat.setdefault("__all__", []) g[:] = [t for t in g if now - t < 1.0] if len(g) >= self._rate: # overall rate wait = max(wait, 1.0 - (now - g[0])) if wait > 0: time.sleep(wait) with self._lock: t = time.monotonic() self._last_chat.setdefault(conv, []).append(t) self._last_chat[conv] = self._last_chat[conv][-10:] self._last_chat.setdefault("__all__", []).append(t) def users(self) -> List[str]: """Everyone who started a chat with the bot.""" return [c["peer"] for c in self.call("contacts") if c.get("status") == 3] def broadcast(self, text: str, buttons=None) -> int: """Send `text` to every user (paced). Returns the number of recipients.""" n = 0 for p in self.users(): try: self.send("d:" + p, text, buttons=buttons) n += 1 except Exception as e: print(f"[lumora-bot] broadcast to {p[:10]}… failed: {e}", flush=True) return n # ------------------------------------------------------------ runtime def start(self): os.makedirs(self.data_dir, exist_ok=True) args = [self.binary, "--data", self.data_dir, "--stdio", *self.extra] env = dict(os.environ) if self.token: env["LUMORA_BOT_TOKEN"] = self.token # via environment, never on the command line else: args[3:3] = ["--name", self.name] self._proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1, env=env) threading.Thread(target=self._reader, daemon=True).start() self.me = self.call("me") for a in self._admin_specs: if a.startswith("@"): try: found = self.call("resolve_username", handle=a) if len(found) == 1: self.admin_peers.add(found[0]["peer"]) else: print(f"[lumora-bot] admin {a}: {len(found)} matches — use the full @name·tag", flush=True) except Exception as e: print(f"[lumora-bot] admin {a}: {e}", flush=True) else: self.admin_peers.add(a) # admin-only commands are hidden from the public command menu cmds = [{"command": c, "description": d} for c, (_, d, hidden) in self._commands.items() if not hidden] self.call("set_bot", commands=cmds, about=self.about, auto_accept=True) return self def run(self): """Start and block forever (Ctrl+C to stop).""" self.start() label = self.me.get("handle") or self.me.get("name") or self.name print(f"🤖 {label} is online — ID {self.me['short_id']} ({self.me['peer']})", flush=True) try: self._proc.wait() except KeyboardInterrupt: pass finally: self.stop() def stop(self): if self._proc: try: self._proc.stdin.close() self._proc.wait(timeout=5) except Exception: self._proc.kill() self._pool.shutdown(wait=False) def _reader(self): assert self._proc and self._proc.stdout for line in self._proc.stdout: try: v = json.loads(line) except ValueError: continue if "event" in v: self._pool.submit(self._dispatch, v) elif "id" in v: with self._cv: self._resp[v["id"]] = v self._cv.notify_all() def _dispatch(self, ev: dict): try: kind = ev.get("event") if kind == "message" and not ev.get("mine") and ev.get("kind") == "text": ctx = Context(self, ev) if ctx.command is not None and ctx.command in self._commands: self._commands[ctx.command][0](ctx) elif ctx.command is not None and "help" not in self._commands and ctx.command == "help": lines = [f"/{c} — {d}" for c, (_, d, hidden) in self._commands.items() if not hidden or ctx.is_admin] ctx.reply("\n".join(lines) or "No commands") else: for fn in self._on_message: fn(ctx) elif kind == "callback": ctx = Context(self, ev) for fn in self._on_callback: fn(ctx) elif kind == "bot_user": for fn in self._on_user: fn(Context(self, {"conv": "d:" + ev["peer"], "from": ev["peer"]})) except Exception as e: # never let a handler crash the bot print(f"[lumora-bot] handler error: {e!r}")