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