bot client

This commit is contained in:
Diego 2026-04-11 14:36:49 -07:00
commit 8eaa8e3adf
4 changed files with 307 additions and 5 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
chat.txt
__pycache__/

View file

@ -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")
]
```

205
botclient.py Normal file
View file

@ -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

26
examples/botexample.py Normal file
View file

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