From 8eaa8e3adf955c4d84546771ae81513c89edc09d Mon Sep 17 00:00:00 2001 From: Dogo6647 Date: Sat, 11 Apr 2026 14:36:49 -0700 Subject: [PATCH] bot client --- .gitignore | 1 + README.md | 80 +++++++++++++++- botclient.py | 205 +++++++++++++++++++++++++++++++++++++++++ examples/botexample.py | 26 ++++++ 4 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 botclient.py create mode 100644 examples/botexample.py diff --git a/.gitignore b/.gitignore index 91ef479..ae468d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ chat.txt +__pycache__/ diff --git a/README.md b/README.md index 19f042a..17210b0 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,82 @@ Make sure you're running the script from within the folder it's in. python r2chat.py ``` -Chat history is saved as `chat.txt` inside either "C:\Users\Public\r2chat\" on Windows +- Chat history is saved as `chat_CHANNEL.txt` inside either "C:\Users\Public\r2chat\" on Windows or "/var/tmp/r2chat/" on Unix-like. - -Preferences are stored as a json in your home directory and can be changed +- Preferences are stored as a json in your home directory and can be changed directly from the GUI by clicking "Preferences" on the menu bar. +- You can insert color text into the chat by using the following syntax: `((#XXXXXX | yourtext))` +- The "insert" menu is not functional yet. -You can insert color text into the chat by using the following syntax: `((#XXXXXX | yourtext))` +## Bot client usage +You can look at `examples/botexample.py` for an example working setup you can use as a template. -The "insert" menu is not functional yet. +Unlike regular r2chat, the bot client doesn't require any dependencies and its config file +is stored as `r2bot.config.json` where you're running the script from, so it's important +to separate bots between directories to prevent them from overwriting each other's files. + +### Bot client API +#### Init: +``` +bot = r2bot(channel, username) +``` + +#### Sending a message: +``` +bot.send(msg) +``` + +#### Sending a system message: +``` +bot.system_message(msg) +``` + +#### Receiving messages +``` +messages = bot.poll() +``` +Returns a list of new lines and triggers event callbacks, +which can be used like this: + +``` +def handlemsg(msg): + print("got message:", msg) + +# Register event +bot.on("on_message", handlemsg) +``` + +List of possible on-event types: +- "on_message" +- "on_mention" +- "on_system" + +#### Statuses +``` +bot.set_status(status) +``` + +List of standard statuses: +- "online" +- "offline" +- "inactive" +- "busy" + +#### Leave signal +``` +bot.leave_channel() +``` + +#### Parse text blocks +The bot client can split normal text and colored text +into an array; +``` +segments = bot.parse_colors(text) +``` +Would output something like: +``` +[ + ("text", "normal text"), + ("color", "#ff0000", "This is red text") +] +``` diff --git a/botclient.py b/botclient.py new file mode 100644 index 0000000..5aef79b --- /dev/null +++ b/botclient.py @@ -0,0 +1,205 @@ +# //////////////////////////////////////////////// +# //// R2BOT - Bot client library for r2chat //// +# ////////////////////////////////////////////// + +import os, sys, json, time, re, getpass +from pathlib import Path + +# --- Setup/default config --- +if sys.platform == "win32": + ROOT = Path("C:/Users/Public") +else: + ROOT = Path("/var/tmp") + +CHANNELS_DIR = ROOT / "r2chat" +INSTANCE_DIR = CHANNELS_DIR / "instance" +CONFIG_FILE = Path("r2bot.config.json") + +os.makedirs(INSTANCE_DIR, exist_ok=True) +os.makedirs(CHANNELS_DIR, exist_ok=True) + +COLOR_PATTERN = re.compile(r"\(\((#[0-9a-fA-F]{6})\s*\|\s*([^)]+)\)\)") + +DEFAULT_CONFIG = { + "user_color": "#0000ff", + "status_join": "{username} is now online.", + "status_leave": "{username} is now offline.", + "status_inactive": "{username} will be right back.", + "status_busy": "{username} is busy." +} + + +# --- Engine --- +class r2bot: + def __init__(self, channel="chat", username=None): + self.username = username or f"{getpass.getuser()}'s-bot" + self.botname = f"{self.username}#BOT" + self.channel = self._normalize_channel(channel) + print(f"[r2bot] Logged in as {self.username} in {self.channel}.") + self.chat_file = CHANNELS_DIR / f"{self.channel}.txt" + self.user_file = INSTANCE_DIR / f"{self.username}.json" + + self.cfg = self.load_config() + self.last_size = os.path.getsize(self.chat_file) + self.status = "online" + + self.callbacks = { + "on_message": None, + "on_mention": None, + "on_system": None + } + + self._ensure_files() + self.user_data = self.load_userdata() + self._join_channel() + + def _normalize_channel(self, name): + if name == "chat": + return name + name = name.replace(" ", "_").replace(".", "-") + return f"chat_{name}" + + def _ensure_files(self): + if not self.chat_file.exists(): + self.chat_file.touch() + + def load_config(self): + if CONFIG_FILE.exists(): + try: + return {**DEFAULT_CONFIG, **json.load(open(CONFIG_FILE))} + except: + pass + return DEFAULT_CONFIG.copy() + + def load_userdata(self): + if self.user_file.exists(): + try: + return json.load(open(self.user_file)) + except: + pass + + return { + "username": self.username, + "status": "online", + "channels": [], + "last_seen": time.time() + } + + def save_userdata(self): + self.user_data["last_seen"] = time.time() + json.dump(self.user_data, open(self.user_file, "w"), indent=2) + + def _join_channel(self): + if self.channel not in self.user_data["channels"]: + self.user_data["channels"].append(self.channel) + + self.user_data["status"] = self.status + self.save_userdata() + + self.system_message(self.cfg["status_join"]) + + def leave_channel(self): + self.system_message(self.cfg["status_leave"]) + + if self.channel in self.user_data["channels"]: + self.user_data["channels"].remove(self.channel) + + if not self.user_data["channels"]: + self.status = "offline" + + self.user_data["status"] = self.status + self.save_userdata() + + def send(self, msg): + if not msg.strip(): + return + + timestamp = time.strftime("%y/%m/%d %H:%M") + line = f"[{timestamp}] (({self.cfg['user_color']} | {self.botname})): {msg}\n" + + with open(self.chat_file, "a", encoding="utf-8") as f: + f.write(line) + + def system_message(self, msg): + line = f"//// {msg.format(username=self.botname)} ////\n" + with open(self.chat_file, "a", encoding="utf-8") as f: + f.write(line) + + if self.callbacks["on_system"]: + self.callbacks["on_system"](line) + + def set_status(self, new_status): + if new_status == self.status: + return + + self.status = new_status + + mapping = { + "online": self.cfg["status_join"], + "offline": self.cfg["status_leave"], + "inactive": self.cfg["status_inactive"], + "busy": self.cfg["status_busy"] + } + + if new_status in mapping: + self.system_message(mapping[new_status]) + + self.user_data["status"] = self.status + self.save_userdata() + + def poll(self): + # (reads new messages) + current_size = os.path.getsize(self.chat_file) + + if current_size <= self.last_size: + return [] + + with open(self.chat_file, "r", encoding="utf-8") as f: + f.seek(self.last_size) + new_data = f.read() + + self.last_size = current_size + lines = new_data.splitlines() + + for line in lines: + self._handle_line(line) + + return lines + + def _handle_line(self, line): + if line.startswith("----"): + if self.callbacks["on_system"]: + self.callbacks["on_system"](line) + return + + if f"@{self.username}" in line: + if self.callbacks["on_mention"]: + self.callbacks["on_mention"](line) + + if self.callbacks["on_message"]: + self.callbacks["on_message"](line) + + def on(self, event, func): + if event in self.callbacks: + self.callbacks[event] = func + + def parse_colors(self, text): + # (return text segments with color info) + segments = [] + pos = 0 + + for match in COLOR_PATTERN.finditer(text): + start, end = match.span() + color = match.group(1) + content = match.group(2) + + if start > pos: + segments.append(("text", text[pos:start])) + + segments.append(("color", color, content)) + pos = end + + if pos < len(text): + segments.append(("text", text[pos:])) + + return segments diff --git a/examples/botexample.py b/examples/botexample.py new file mode 100644 index 0000000..cc94352 --- /dev/null +++ b/examples/botexample.py @@ -0,0 +1,26 @@ +import os, sys +sys.path.append(os.path.abspath("../")) +from botclient import r2bot +import time + +bot = r2bot(channel="chat", username="genericbot") + +def mainhandler(msg): + if "/test" in msg: + bot.send("hello world") + +def mentionhandler(msg): + bot.send("mentioned") + +bot.on("on_message", mainhandler) +bot.on("on_mention", mentionhandler) + +try: + print("Bot is running.") + while True: + bot.poll() + time.sleep(1) + +except KeyboardInterrupt: + print("Stopping bot.") + bot.leave_channel()