// Lumora Bot SDK for JavaScript (Node.js ≥ 18) — peer-to-peer bots, no central API. // // import { Bot } from "./lumora-bot.mjs"; // const bot = new Bot({ name: "Echo Bot", dataDir: "./echo-bot" }); // bot.command("start", "Bắt đầu", (ctx) => // ctx.reply("Xin chào!", [[{ text: "👍 Thích", data: "like" }]])); // bot.onCallback((ctx) => ctx.reply(`Bạn bấm: ${ctx.data}`)); // bot.onMessage((ctx) => ctx.reply(`Bạn nói: ${ctx.text}`)); // await bot.run(); // // Requires the `lumorad` binary (env LUMORAD or `binary` option). ~30 MB RAM per bot. import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; import { mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs"; import { join } from "node:path"; // Permissions: new Bot({ ..., admins: ["", "@owner·k7q2xz"] }) // bot.command("ban", "Chặn", fn, { adminOnly: true }) / { role: "mod" } // bot.roles.grant(peer, "mod"); ctx.isAdmin; ctx.hasRole("mod") // Storage: bot.storage.get/set (JSON file in dataDir) // Broadcast: await bot.broadcast("Tin mới!") // Limits: no central quota; receivers accept ≤20 msg/s per sender (burst 200), // the SDK paces sends (15/s overall, burst 5 then 1/s per chat). class Storage { constructor(path) { this.path = path; try { this.data = JSON.parse(readFileSync(path, "utf8")); } catch { this.data = {}; } } get(k, d) { return k in this.data ? this.data[k] : d; } set(k, v) { this.data[k] = v; this.save(); } delete(k) { delete this.data[k]; this.save(); } save() { writeFileSync(this.path + ".tmp", JSON.stringify(this.data)); renameSync(this.path + ".tmp", this.path); } } class Roles { constructor(storage) { this.s = storage; if (!this.s.get("__roles__")) this.s.set("__roles__", {}); } grant(peer, role) { const r = this.s.get("__roles__"); (r[peer] ||= []).includes(role) || r[peer].push(role); this.s.set("__roles__", r); } revoke(peer, role) { const r = this.s.get("__roles__"); r[peer] = (r[peer] || []).filter((x) => x !== role); this.s.set("__roles__", r); } of(peer) { return new Set(this.s.get("__roles__")[peer] || []); } } const normButtons = (rows = []) => rows.map((row) => row.map((b) => Array.isArray(b) ? /^https?:\/\//.test(b[1]) ? { text: b[0], data: "", url: b[1] } : { text: b[0], data: String(b[1]), url: "" } : { text: b.text ?? "", data: b.data ?? "", url: b.url ?? "" } ) ); class Context { constructor(bot, ev) { this.bot = bot; this.event = ev; this.conv = ev.conv || ev.msg_conv || ""; this.sender = ev.author || ev.from || ""; this.text = ev.body?.text ?? ""; this.data = ev.data ?? ""; this.msgId = ev.id || ev.msg_id || ""; const [first, ...rest] = this.text.trim().split(/\s+/); this.command = first?.startsWith("/") ? first.slice(1).split("@")[0] : null; this.args = rest.join(" "); } get isAdmin() { return this.bot.adminPeers.has(this.sender); } hasRole(role) { return this.isAdmin || this.bot.roles.of(this.sender).has(role); } reply(text, buttons) { return this.bot.send(this.conv, text, buttons); } sendFile(path) { return this.bot.call("send_file", { conv: this.conv, path }); } } export class Bot { constructor({ name = "", token, dataDir = "./lumora-bot", binary, about = "", lan = true, publicDirectory = true, extraArgs = [], admins = [], ratePerSec = 15 }) { Object.assign(this, { name, dataDir, about }); this.token = token || process.env.LUMORA_BOT_TOKEN; if (!name && !this.token) throw new Error("give the bot a name, or a token created in the app (BotFather)"); mkdirSync(dataDir, { recursive: true }); this.storage = new Storage(join(dataDir, "bot-storage.json")); this.roles = new Roles(this.storage); this.adminSpecs = admins; this.adminPeers = new Set(); this.ratePerSec = ratePerSec; this.sendTimes = new Map(); this.binary = binary || process.env.LUMORAD || "lumorad"; this.args = [...extraArgs, ...(lan ? [] : ["--no-lan"]), ...(publicDirectory ? [] : ["--no-directory"])]; this.commands = new Map(); this.msgHandlers = []; this.cbHandlers = []; this.pending = new Map(); this.nextId = 1; this.me = null; } command(name, description, fn, { adminOnly = false, role = null } = {}) { const guarded = (ctx) => { if ((adminOnly && !ctx.isAdmin) || (role && !ctx.hasRole(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); }; this.commands.set(name.replace(/^\//, ""), { fn: guarded, description, hidden: adminOnly || !!role }); return this; } onMessage(fn) { this.msgHandlers.push(fn); return this; } onCallback(fn) { this.cbHandlers.push(fn); return this; } call(cmd, args = {}, timeoutMs = 60000) { const id = this.nextId++; this.proc.stdin.write(JSON.stringify({ ...args, id, cmd }) + "\n"); return new Promise((resolve, reject) => { const t = setTimeout(() => { this.pending.delete(id); reject(new Error(`${cmd} timed out`)); }, timeoutMs); this.pending.set(id, (r) => { clearTimeout(t); r.ok ? resolve(r.data) : reject(new Error(r.error)); }); }); } async #pace(conv) { const now = Date.now(); const chat = (this.sendTimes.get(conv) || []).filter((t) => now - t < 5000); const all = (this.sendTimes.get("__all__") || []).filter((t) => now - t < 1000); let wait = 0; if (chat.length >= 5) wait = Math.max(wait, 1000 - (now - chat[chat.length - 1])); if (all.length >= this.ratePerSec) wait = Math.max(wait, 1000 - (now - all[0])); if (wait > 0) await new Promise((r) => setTimeout(r, wait)); const t = Date.now(); this.sendTimes.set(conv, [...chat, t].slice(-10)); this.sendTimes.set("__all__", [...all, t]); } async send(conv, text, buttons) { await this.#pace(conv); return this.call("send_message", { conv, text, buttons: normButtons(buttons) }); } async users() { return (await this.call("contacts")).filter((c) => c.status === 3).map((c) => c.peer); } async broadcast(text, buttons) { let n = 0; for (const p of await this.users()) { try { await this.send("d:" + p, text, buttons); n++; } catch (e) { console.error("[lumora-bot] broadcast failed:", e.message); } } return n; } async start() { mkdirSync(this.dataDir, { recursive: true }); const args = ["--data", this.dataDir, "--stdio", ...this.args]; if (!this.token) args.splice(2, 0, "--name", this.name); this.proc = spawn(this.binary, args, { stdio: ["pipe", "pipe", "ignore"], env: this.token ? { ...process.env, LUMORA_BOT_TOKEN: this.token } : process.env, }); createInterface({ input: this.proc.stdout }).on("line", (line) => { let v; try { v = JSON.parse(line); } catch { return; } if (v.event) this.#dispatch(v).catch((e) => console.error("[lumora-bot] handler error:", e)); else if (v.id && this.pending.has(v.id)) { this.pending.get(v.id)(v); this.pending.delete(v.id); } }); this.me = await this.call("me"); for (const a of this.adminSpecs) { if (a.startsWith("@")) { try { const found = await this.call("resolve_username", { handle: a }); if (found.length === 1) this.adminPeers.add(found[0].peer); else console.error(`[lumora-bot] admin ${a}: ${found.length} matches — use the full @name·tag`); } catch (e) { console.error(`[lumora-bot] admin ${a}: ${e.message}`); } } else this.adminPeers.add(a); } const commands = [...this.commands].filter(([, c]) => !c.hidden).map(([command, { description }]) => ({ command, description })); await this.call("set_bot", { commands, about: this.about, auto_accept: true }); return this; } async run() { await this.start(); console.log(`🤖 ${this.me.handle || this.me.name || this.name} is online — ID ${this.me.short_id} (${this.me.peer})`); await new Promise((r) => this.proc.on("exit", r)); } stop() { this.proc?.stdin.end(); } async #dispatch(ev) { if (ev.event === "message" && !ev.mine && ev.kind === "text") { const ctx = new Context(this, ev); const c = ctx.command && this.commands.get(ctx.command); if (c) return c.fn(ctx); for (const fn of this.msgHandlers) await fn(ctx); } else if (ev.event === "callback") { const ctx = new Context(this, ev); for (const fn of this.cbHandlers) await fn(ctx); } } }