it starts with one

This commit is contained in:
Diego 2026-04-11 03:46:52 -07:00
commit bdf247292c
7 changed files with 344 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
chat.txt

20
README.md Normal file
View file

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

BIN
assets/internal/notif.ogg Normal file

Binary file not shown.

BIN
assets/internal/notif.wav Normal file

Binary file not shown.

BIN
assets/internal/ping.ogg Normal file

Binary file not shown.

BIN
assets/internal/ping.wav Normal file

Binary file not shown.

323
r2chat.py Normal file
View file

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