commit bdf247292cc21013079a9dc27ef956b3cfa2031d Author: Dogo6647 Date: Sat Apr 11 03:46:52 2026 -0700 it starts with one diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91ef479 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +chat.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..19f042a --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# r2chat +Simple and lightweight IM application for multi-user systems (e.g. Windows Server). +Created by Dogo6647 with Python and Tkinter. + +## Usage +Place r2chat folder on C:\ on windows or inside any other location accessible by all users. +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 +or "/var/tmp/r2chat/" on Unix-like. + +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. diff --git a/assets/internal/notif.ogg b/assets/internal/notif.ogg new file mode 100644 index 0000000..f19df5e Binary files /dev/null and b/assets/internal/notif.ogg differ diff --git a/assets/internal/notif.wav b/assets/internal/notif.wav new file mode 100644 index 0000000..17e0e57 Binary files /dev/null and b/assets/internal/notif.wav differ diff --git a/assets/internal/ping.ogg b/assets/internal/ping.ogg new file mode 100644 index 0000000..afd67a7 Binary files /dev/null and b/assets/internal/ping.ogg differ diff --git a/assets/internal/ping.wav b/assets/internal/ping.wav new file mode 100644 index 0000000..07f06b9 Binary files /dev/null and b/assets/internal/ping.wav differ diff --git a/r2chat.py b/r2chat.py new file mode 100644 index 0000000..8e8f6c3 --- /dev/null +++ b/r2chat.py @@ -0,0 +1,323 @@ +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("", lambda e: "break") + + self.entry.bind("", 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()