Compare commits

...

2 commits

Author SHA1 Message Date
c8b8870139 add multiple channel support
Reviewed-on: #1
2026-04-11 12:55:49 -05:00
b6501b9719 replace i think it does 2026-04-11 12:18:43 -05:00

665
r2chat.py
View file

@ -1,323 +1,342 @@
import tkinter as tk import tkinter as tk
from tkinter import scrolledtext, messagebox from tkinter import scrolledtext, messagebox
from tkinter import ttk, colorchooser, font as tkfont from tkinter import ttk, colorchooser, font as tkfont
import os, sys, json import os, sys, json
from pathlib import Path from pathlib import Path
import time import time
import getpass import getpass
from plyer import notification from plyer import notification
import pygame as media import pygame as media
import webbrowser import webbrowser
import re import re
import subprocess
if sys.platform == "win32":
ROOT = Path("C:/Users/Public") if sys.platform == "win32":
else: ROOT = Path("C:/Users/Public")
ROOT = Path("/var/tmp") else:
CHAT_FILE = ROOT / "r2chat" / "chat.txt" ROOT = Path("/var/tmp")
CONFIG_FILE = Path.home() / "r2chat.config.json"
os.makedirs(os.path.dirname(CHAT_FILE), exist_ok=True) try:
if sys.argv[1]!='chat':
REFRESH_INTERVAL = 1000 CHANCHAN = sys.argv[1].replace(' ','_').replace('.','-')
COLOR_PATTERN = re.compile(r"\(\((#[0-9a-fA-F]{6})\s*\|\s*([^)]+)\)\)") CHANNEL = 'chat_'+CHANCHAN
status = "online" else:
CHANNEL = 'chat'
username = getpass.getuser() CHANCHAN = 'chat'
media.mixer.init() except:
CHANNEL = 'chat'
DEFAULT_CONFIG = { CHANCHAN = 'chat'
"font_family": "Lucida Console",
"font_size": 11, CHAT_FILE = ROOT / "r2chat" / f"{CHANNEL}.txt"
"border_width": 2, CONFIG_FILE = Path.home() / "r2chat.config.json"
"bg_color": "#f0f0f0", os.makedirs(os.path.dirname(CHAT_FILE), exist_ok=True)
"fg_color": "#1e1e1e",
"user_color": "#0000ff", REFRESH_INTERVAL = 1000
"status_join": "{username} is now online.", COLOR_PATTERN = re.compile(r"\(\((#[0-9a-fA-F]{6})\s*\|\s*([^)]+)\)\)")
"status_leave": "{username} is now offline.", status = "online"
"status_inactive": "{username} will be right back.",
"status_busy": "{username} is busy." username = getpass.getuser()
} media.mixer.init()
def load_config(): DEFAULT_CONFIG = {
if CONFIG_FILE.exists(): "font_family": "Lucida Console",
try: "font_size": 11,
with open(CONFIG_FILE, "r", encoding="utf-8") as f: "border_width": 2,
user_cfg = json.load(f) "bg_color": "#f0f0f0",
return {**DEFAULT_CONFIG, **user_cfg} # user values override defaults "fg_color": "#1e1e1e",
except Exception as e: "user_color": "#0000ff",
print(f":o config load failed, using defaults instead. error: {e}") "status_join": "{username} is now online.",
else: "status_leave": "{username} is now offline.",
with open(CONFIG_FILE, "w", encoding="utf-8") as f: "status_inactive": "{username} will be right back.",
json.dump(DEFAULT_CONFIG, f, indent=2) "status_busy": "{username} is busy."
print(f":) created default config at {CONFIG_FILE}") }
return DEFAULT_CONFIG.copy()
def load_config():
class r2chat: if CONFIG_FILE.exists():
def __init__(self, root): try:
self.root = root with open(CONFIG_FILE, "r", encoding="utf-8") as f:
self.cfg = load_config() user_cfg = json.load(f)
self.root.title(f"r2chat :: {username}") return {**DEFAULT_CONFIG, **user_cfg} # user values override defaults
except Exception as e:
self.last_size = 0 print(f":o config load failed, using defaults instead. error: {e}")
else:
# configs with open(CONFIG_FILE, "w", encoding="utf-8") as f:
font = (self.cfg["font_family"], self.cfg["font_size"]) json.dump(DEFAULT_CONFIG, f, indent=2)
bg_color = self.cfg["bg_color"] print(f":) created default config at {CONFIG_FILE}")
fg_color = self.cfg["fg_color"] return DEFAULT_CONFIG.copy()
# menubar
self.menubar = tk.Menu(root)
self.root.config(menu=self.menubar) class r2chat:
def __init__(self, root):
## chat menu self.root = root
self.chatmenu = tk.Menu(self.menubar, tearoff=0) self.cfg = load_config()
self.menubar.add_cascade(label="Chat", menu=self.chatmenu) self.root.title(f"r2chat :: {username} / #{CHANCHAN}")
self.chatmenu.add_command(label="Registered users", command=lambda: print(""))
self.chatmenu.add_command(label="Leave", command=lambda: self.on_close()) self.last_size = 0
## insert menu # configs
self.chatmenu = tk.Menu(self.menubar, tearoff=0) font = (self.cfg["font_family"], self.cfg["font_size"])
self.menubar.add_cascade(label="Insert", menu=self.chatmenu) bg_color = self.cfg["bg_color"]
self.chatmenu.add_command(label="Emoticon", command=lambda: print("")) fg_color = self.cfg["fg_color"]
self.chatmenu.add_command(label="GIF", command=lambda: print(""))
# menubar
## status menu self.menubar = tk.Menu(root)
self.chatmenu = tk.Menu(self.menubar, tearoff=0) self.root.config(menu=self.menubar)
self.menubar.add_cascade(label="Status", menu=self.chatmenu)
self.chatmenu.add_command(label="Online (All notifs)", command=lambda: self.setstatus("online")) ## chat menu
self.chatmenu.add_command(label="Inactive (Pings only)", command=lambda: self.setstatus("inactive")) self.chatmenu = tk.Menu(self.menubar, tearoff=0)
self.chatmenu.add_command(label="Busy (No notifs)", command=lambda: self.setstatus("busy")) self.menubar.add_cascade(label="Chat", menu=self.chatmenu)
self.chatmenu.add_command(label="Appear offline", command=lambda: self.setstatus("offline")) self.chatmenu.add_command(label="Registered users", command=lambda: print(""))
self.chatmenu.add_command(label="Leave", command=lambda: self.on_close())
## menu buttons
self.menubar.add_command(label="Preferences", command=lambda: self.openprefs()) ## insert menu
self.menubar.add_command(label="Help", command=lambda: webbrowser.open_new_tab("https://forgejo.nishi.boats/Dogo6647/r2chat")) self.chatmenu = tk.Menu(self.menubar, tearoff=0)
self.menubar.add_cascade(label="Insert", menu=self.chatmenu)
# MAIN MENU STUFFS self.chatmenu.add_command(label="Emoticon", command=lambda: print(""))
self.entry = tk.Entry(root, relief='sunken', borderwidth=self.cfg["border_width"]) self.chatmenu.add_command(label="GIF", command=lambda: print(""))
self.entry.pack(padx=10, pady=(0, 10), fill=tk.X, side=tk.BOTTOM)
## status menu
self.chat_area = scrolledtext.ScrolledText(root, wrap='word', font=font, bg=bg_color, fg=fg_color, relief='ridge', borderwidth=self.cfg["border_width"]) self.chatmenu = tk.Menu(self.menubar, tearoff=0)
self.chat_area.pack(padx=10, pady=10, fill=tk.BOTH, expand=True) self.menubar.add_cascade(label="Status", menu=self.chatmenu)
self.chat_area.bind("<Key>", lambda e: "break") self.chatmenu.add_command(label="Online (All notifs)", command=lambda: self.setstatus("online"))
self.chatmenu.add_command(label="Inactive (Pings only)", command=lambda: self.setstatus("inactive"))
self.entry.bind("<Return>", self.send_message) self.chatmenu.add_command(label="Busy (No notifs)", command=lambda: self.setstatus("busy"))
self.chatmenu.add_command(label="Appear offline", command=lambda: self.setstatus("offline"))
if not os.path.exists(CHAT_FILE):
open(CHAT_FILE, "a").close() ## menu buttons
self.menubar.add_command(label="Preferences", command=lambda: self.openprefs())
self.update_chat() self.menubar.add_command(label="Help", command=lambda: webbrowser.open_new_tab("https://forgejo.nishi.boats/Dogo6647/r2chat"))
self.write_system_message(self.cfg["status_join"])
# MAIN MENU STUFFS
def on_close(self): self.entry = tk.Entry(root, relief='sunken', borderwidth=self.cfg["border_width"])
self.write_system_message(self.cfg["status_leave"]) self.entry.pack(padx=10, pady=(0, 10), fill=tk.X, side=tk.BOTTOM)
self.root.destroy()
self.chat_area = scrolledtext.ScrolledText(root, wrap='word', font=font, bg=bg_color, fg=fg_color, relief='ridge', borderwidth=self.cfg["border_width"])
def write_system_message(self, msg): self.chat_area.pack(padx=10, pady=10, fill=tk.BOTH, expand=True)
line = f"---- {msg.format(username=username)} ----\n" self.chat_area.bind("<Key>", lambda e: "break")
with open(CHAT_FILE, "a", encoding="utf-8") as f:
f.write(line) self.entry.bind("<Return>", self.send_message)
def send_message(self, event=None): if not os.path.exists(CHAT_FILE):
msg = self.entry.get().strip() open(CHAT_FILE, "a").close()
if msg:
timestamp = time.strftime("%y/%m/%d %H:%M") self.update_chat()
line = f"[{timestamp}] (({self.cfg['user_color']} | {username})): {msg}\n" self.write_system_message(self.cfg["status_join"])
try:
with open(CHAT_FILE, "a", encoding="utf-8") as f: def on_close(self):
f.write(line) self.write_system_message(self.cfg["status_leave"])
except Exception as e: self.root.destroy()
print("oh noes! ", e)
def write_system_message(self, msg):
self.entry.delete(0, tk.END) line = f"---- {msg.format(username=username)} ----\n"
with open(CHAT_FILE, "a", encoding="utf-8") as f:
def setstatus(self, new_status): f.write(line)
global status
def send_message(self, event=None):
if status == new_status: msg = self.entry.get().strip()
return if msg and msg.startswith("/join #"):
subprocess.Popen(f'pythonw "{os.path.abspath(__file__)}" "{msg[7:len(msg)]}"', shell=True)
status = new_status msg = ''
self.entry.delete(0, tk.END)
if status == "online": if msg:
self.write_system_message(msg=self.cfg["status_join"]) timestamp = time.strftime("%y/%m/%d %H:%M")
elif status == "offline": line = f"[{timestamp}] (({self.cfg['user_color']} | {username})): {msg}\n"
self.write_system_message(msg=self.cfg["status_leave"]) try:
elif status == "inactive": with open(CHAT_FILE, "a", encoding="utf-8") as f:
self.write_system_message(msg=self.cfg["status_inactive"]) f.write(line)
elif status == "busy": except Exception as e:
self.write_system_message(msg=self.cfg["status_busy"]) print("oh noes! ", e)
def openprefs(self): self.entry.delete(0, tk.END)
PreferencesWindow(self.root, self.cfg, self.save_prefs)
def setstatus(self, new_status):
def save_prefs(self, new_cfg): global status
self.cfg = new_cfg
with open(CONFIG_FILE, "w", encoding="utf-8") as f: if status == new_status:
json.dump(new_cfg, f, indent=2) return
font = (new_cfg["font_family"], new_cfg["font_size"])
self.chat_area.config(font=font, bg=new_cfg["bg_color"], fg=new_cfg["fg_color"], borderwidth=new_cfg["border_width"]) status = new_status
self.entry.config(borderwidth=new_cfg["border_width"])
if status == "online":
def render_text(self, text): self.write_system_message(msg=self.cfg["status_join"])
pos = 0 elif status == "offline":
self.write_system_message(msg=self.cfg["status_leave"])
for match in COLOR_PATTERN.finditer(text): elif status == "inactive":
start, end = match.span() self.write_system_message(msg=self.cfg["status_inactive"])
color = match.group(1) elif status == "busy":
content = match.group(2) self.write_system_message(msg=self.cfg["status_busy"])
if start > pos: def openprefs(self):
self.chat_area.insert(tk.END, text[pos:start]) PreferencesWindow(self.root, self.cfg, self.save_prefs)
tag_name = f"color_{color}" def save_prefs(self, new_cfg):
if not tag_name in self.chat_area.tag_names(): self.cfg = new_cfg
self.chat_area.tag_config(tag_name, foreground=color) with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(new_cfg, f, indent=2)
self.chat_area.insert(tk.END, content, tag_name) font = (new_cfg["font_family"], new_cfg["font_size"])
pos = end self.chat_area.config(font=font, bg=new_cfg["bg_color"], fg=new_cfg["fg_color"], borderwidth=new_cfg["border_width"])
self.entry.config(borderwidth=new_cfg["border_width"])
if pos < len(text):
self.chat_area.insert(tk.END, text[pos:]) def render_text(self, text):
pos = 0
def update_chat(self):
try: for match in COLOR_PATTERN.finditer(text):
current_size = os.path.getsize(CHAT_FILE) start, end = match.span()
color = match.group(1)
if current_size > self.last_size: content = match.group(2)
with open(CHAT_FILE, "r", encoding="utf-8") as f:
f.seek(self.last_size) if start > pos:
new_data = f.read() self.chat_area.insert(tk.END, text[pos:start])
self.last_size = current_size
tag_name = f"color_{color}"
self.chat_area.config(state='normal') if not tag_name in self.chat_area.tag_names():
if f"@{username} " in new_data and not "---" in new_data and not status == "busy" and not status == "offline": self.chat_area.tag_config(tag_name, foreground=color)
media.mixer.Sound("assets/internal/ping.ogg").play()
messagebox.showinfo("Important message!", new_data) self.chat_area.insert(tk.END, content, tag_name)
if self.root.wm_state() == 'iconic': pos = end
if not f"@{username} " in new_data and not status == "busy" and not status == "inactive":
notification.notify(title="r2chat", message=new_data) if pos < len(text):
media.mixer.Sound("assets/internal/notif.ogg").play() self.chat_area.insert(tk.END, text[pos:])
self.render_text(new_data)
self.chat_area.config(state='disabled') def update_chat(self):
self.chat_area.yview(tk.END) try:
current_size = os.path.getsize(CHAT_FILE)
except Exception as e:
print("oh noes! ", e) if current_size > self.last_size:
with open(CHAT_FILE, "r", encoding="utf-8") as f:
self.root.after(REFRESH_INTERVAL, self.update_chat) f.seek(self.last_size)
new_data = f.read()
class PreferencesWindow: self.last_size = current_size
def __init__(self, parent, cfg, on_save):
self.parent = parent self.chat_area.config(state='normal')
self.cfg = cfg.copy() if f"@{username} " in new_data and not "---" in new_data and not status == "busy" and not status == "offline":
self.on_save = on_save media.mixer.Sound("assets/internal/ping.ogg").play()
messagebox.showinfo("Important message!", new_data)
self.win = tk.Toplevel(parent) if self.root.wm_state() == 'iconic':
self.win.title("r2chat prefs") if not f"@{username} " in new_data and not status == "busy" and not status == "inactive":
self.win.geometry("420x520") notification.notify(title="r2chat", message=new_data)
self.win.resizable(False, False) media.mixer.Sound("assets/internal/notif.ogg").play()
self.win.transient(parent) self.render_text(new_data)
self.win.grab_set() self.chat_area.config(state='disabled')
self.chat_area.yview(tk.END)
self.vars = {}
self._build() except Exception as e:
print("oh noes! ", e)
def _build(self):
notebook = ttk.Notebook(self.win) self.root.after(REFRESH_INTERVAL, self.update_chat)
notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
class PreferencesWindow:
self._build_appearance_tab(notebook) def __init__(self, parent, cfg, on_save):
self._build_status_messages_tab(notebook) self.parent = parent
self.cfg = cfg.copy()
btn_frame = tk.Frame(self.win) self.on_save = on_save
btn_frame.pack(fill=tk.X, padx=10, pady=(0, 10))
self.win = tk.Toplevel(parent)
tk.Button(btn_frame, text="Save", width=10, command=self._save).pack(side=tk.RIGHT, padx=(4, 0)) self.win.title("r2chat prefs")
tk.Button(btn_frame, text="Cancel", width=10, command=self.win.destroy).pack(side=tk.RIGHT) self.win.geometry("420x520")
tk.Button(btn_frame, text="Back to defaults", command=self._reset).pack(side=tk.LEFT) self.win.resizable(False, False)
self.win.transient(parent)
def _build_appearance_tab(self, notebook): self.win.grab_set()
frame = tk.Frame(notebook)
notebook.add(frame, text="Interface") self.vars = {}
self._build()
# font family
self._row(frame, 0, "Font family") def _build(self):
font_var = tk.StringVar(value=self.cfg["font_family"]) notebook = ttk.Notebook(self.win)
self.vars["font_family"] = font_var notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
families = sorted(tkfont.families())
font_combo = ttk.Combobox(frame, textvariable=font_var, values=families, state="readonly", width=24) self._build_appearance_tab(notebook)
font_combo.grid(row=0, column=1, padx=10, pady=6, sticky=tk.W) self._build_status_messages_tab(notebook)
# font size btn_frame = tk.Frame(self.win)
self._row(frame, 1, "Font size") btn_frame.pack(fill=tk.X, padx=10, pady=(0, 10))
size_var = tk.IntVar(value=self.cfg["font_size"])
self.vars["font_size"] = size_var tk.Button(btn_frame, text="Save", width=10, command=self._save).pack(side=tk.RIGHT, padx=(4, 0))
tk.Spinbox(frame, from_=7, to=24, textvariable=size_var, width=6).grid(row=1, column=1, padx=10, pady=6, sticky=tk.W) tk.Button(btn_frame, text="Cancel", width=10, command=self.win.destroy).pack(side=tk.RIGHT)
tk.Button(btn_frame, text="Back to defaults", command=self._reset).pack(side=tk.LEFT)
# border width
self._row(frame, 2, "Frame thickness") def _build_appearance_tab(self, notebook):
bw_var = tk.IntVar(value=self.cfg["border_width"]) frame = tk.Frame(notebook)
self.vars["border_width"] = bw_var notebook.add(frame, text="Interface")
tk.Spinbox(frame, from_=0, to=6, textvariable=bw_var, width=6).grid(row=2, column=1, padx=10, pady=6, sticky=tk.W)
# font family
# color pickers self._row(frame, 0, "Font family")
for i, (key, label) in enumerate([ font_var = tk.StringVar(value=self.cfg["font_family"])
("bg_color", "Background color"), self.vars["font_family"] = font_var
("fg_color", "Text color"), families = sorted(tkfont.families())
("user_color", "Username color"), font_combo = ttk.Combobox(frame, textvariable=font_var, values=families, state="readonly", width=24)
], start=3): font_combo.grid(row=0, column=1, padx=10, pady=6, sticky=tk.W)
self._row(frame, i, label)
self._color_picker_row(frame, i, key) # font size
self._row(frame, 1, "Font size")
def _build_status_messages_tab(self, notebook): size_var = tk.IntVar(value=self.cfg["font_size"])
frame = tk.Frame(notebook) self.vars["font_size"] = size_var
notebook.add(frame, text="Status messages") tk.Spinbox(frame, from_=7, to=24, textvariable=size_var, width=6).grid(row=1, column=1, padx=10, pady=6, sticky=tk.W)
tk.Label(frame, text="Use {username} where you want your username to be.", fg="gray", font=("Lucida Console", 9)).grid( # border width
row=0, column=0, columnspan=2, padx=10, pady=(10, 4), sticky=tk.W) self._row(frame, 2, "Frame thickness")
bw_var = tk.IntVar(value=self.cfg["border_width"])
for i, (key, label) in enumerate([("status_join", "Join message"),("status_leave", "Leave message"),("status_inactive", "Inactive message"),("status_busy", "Busy message")], start=1): self.vars["border_width"] = bw_var
self._row(frame, i, label) tk.Spinbox(frame, from_=0, to=6, textvariable=bw_var, width=6).grid(row=2, column=1, padx=10, pady=6, sticky=tk.W)
var = tk.StringVar(value=self.cfg[key])
self.vars[key] = var # color pickers
tk.Entry(frame, textvariable=var, width=32).grid(row=i, column=1, padx=10, pady=6, sticky=tk.W) for i, (key, label) in enumerate([
("bg_color", "Background color"),
def _row(self, frame, row, text): ("fg_color", "Text color"),
tk.Label(frame, text=text, anchor=tk.W, width=18).grid(row=row, column=0, padx=10, pady=6, sticky=tk.W) ("user_color", "Username color"),
], start=3):
def _color_picker_row(self, frame, row, key): self._row(frame, i, label)
color_var = tk.StringVar(value=self.cfg[key]) self._color_picker_row(frame, i, key)
self.vars[key] = color_var
def _build_status_messages_tab(self, notebook):
preview = tk.Label(frame, bg=self.cfg[key], width=4, relief="sunken", borderwidth=1) frame = tk.Frame(notebook)
preview.grid(row=row, column=1, padx=(10, 4), pady=6, sticky=tk.W) notebook.add(frame, text="Status messages")
def pick(v=color_var, p=preview, k=key): tk.Label(frame, text="Use {username} where you want your username to be.", fg="gray", font=("Lucida Console", 9)).grid(
result = colorchooser.askcolor(color=v.get(), title=k) row=0, column=0, columnspan=2, padx=10, pady=(10, 4), sticky=tk.W)
if result[1]:
v.set(result[1]) for i, (key, label) in enumerate([("status_join", "Join message"),("status_leave", "Leave message"),("status_inactive", "Inactive message"),("status_busy", "Busy message")], start=1):
p.config(bg=result[1]) self._row(frame, i, label)
var = tk.StringVar(value=self.cfg[key])
tk.Button(frame, text="Choose…", command=pick, width=8).grid(row=row, column=1, padx=(44, 0), pady=6, sticky=tk.W) self.vars[key] = var
tk.Entry(frame, textvariable=var, width=32).grid(row=i, column=1, padx=10, pady=6, sticky=tk.W)
def _save(self):
for key, var in self.vars.items(): def _row(self, frame, row, text):
self.cfg[key] = var.get() tk.Label(frame, text=text, anchor=tk.W, width=18).grid(row=row, column=0, padx=10, pady=6, sticky=tk.W)
self.on_save(self.cfg)
self.win.destroy() def _color_picker_row(self, frame, row, key):
color_var = tk.StringVar(value=self.cfg[key])
def _reset(self): self.vars[key] = color_var
from __main__ import DEFAULT_CONFIG
for key, var in self.vars.items(): preview = tk.Label(frame, bg=self.cfg[key], width=4, relief="sunken", borderwidth=1)
if key in DEFAULT_CONFIG: preview.grid(row=row, column=1, padx=(10, 4), pady=6, sticky=tk.W)
var.set(DEFAULT_CONFIG[key])
def pick(v=color_var, p=preview, k=key):
if __name__ == "__main__": result = colorchooser.askcolor(color=v.get(), title=k)
root = tk.Tk() if result[1]:
app = r2chat(root) v.set(result[1])
root.geometry("500x520") p.config(bg=result[1])
root.minsize(360, 360)
root.attributes('-topmost', True) tk.Button(frame, text="Choose…", command=pick, width=8).grid(row=row, column=1, padx=(44, 0), pady=6, sticky=tk.W)
root.protocol("WM_DELETE_WINDOW", app.on_close)
root.mainloop() def _save(self):
for key, var in self.vars.items():
self.cfg[key] = var.get()
self.on_save(self.cfg)
self.win.destroy()
def _reset(self):
from __main__ import DEFAULT_CONFIG
for key, var in self.vars.items():
if key in DEFAULT_CONFIG:
var.set(DEFAULT_CONFIG[key])
if __name__ == "__main__":
root = tk.Tk()
app = r2chat(root)
root.geometry("500x520")
root.minsize(360, 360)
root.attributes('-topmost', True)
root.protocol("WM_DELETE_WINDOW", app.on_close)
root.mainloop()