From 33b2688a2d2888bf5e560c70a5bd92a319f5288d Mon Sep 17 00:00:00 2001 From: david Date: Tue, 8 Sep 2026 00:36:22 -0300 Subject: [PATCH] all of this was vibecoded. i just asked claude to fix all my bugs and he actually overhauled shit woaw. he only changed the name and position of a couple functions so if i want to write more things it should be fine. i love claude i love antrhopic i love vibecoding @claude make me a million dollars in a day make no mistake --- .gitignore | 1 + bot.py | 243 ++++++++++++++++++++++++------------------------ colab_setup.py | 131 ++++++++++++++++++++++++++ main.py | 73 +++++++++------ setup_wizard.py | 104 +++++++++++++++++++++ utils.py | 38 ++++++++ 6 files changed, 441 insertions(+), 149 deletions(-) create mode 100644 colab_setup.py create mode 100644 setup_wizard.py create mode 100644 utils.py diff --git a/.gitignore b/.gitignore index d926c53..166fd48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ files env config/system_prompt.txt +__pycache__ diff --git a/bot.py b/bot.py index 597ef2c..cdb14e4 100644 --- a/bot.py +++ b/bot.py @@ -8,8 +8,8 @@ import random import traceback import discord import websockets -from datetime import datetime from pathlib import Path +from utils import nots, now_str, tail_lines def ignore_connection_closed_ok(loop, context): #this doesn't fucking work exc = context.get("exception") @@ -19,9 +19,11 @@ def ignore_connection_closed_ok(loop, context): #this doesn't fucking work def load_system_prompt() -> str: - return Path("config/system_prompt.txt").read_text(encoding="utf-8") + return (CONFIG_DIR / "system_prompt.txt").read_text(encoding="utf-8") -FILES_DIR = Path("files") +# resolve files relative to this script, not whatever dir it happens to get launched from +SCRIPT_DIR = Path(__file__).resolve().parent +FILES_DIR = SCRIPT_DIR / "files" LOG_DIR = FILES_DIR / "log" MEDIA_DIR = FILES_DIR / "media" CONFIG_DIR = FILES_DIR / "config" @@ -45,6 +47,10 @@ _config_lines = [l.strip() for l in (CONFIG_DIR / "tokens.txt").read_text(encodi TOKEN = _config_lines[0] server = int(_config_lines[1]) +_conn_lines = [l.strip() for l in (CONFIG_DIR / "connection.txt").read_text(encoding="utf-8").splitlines() if l.strip() and not l.strip().startswith("#")] +MAIN_URI = _conn_lines[0] # ws:// or wss:// address of main.py +MAIN_TOKEN = _conn_lines[1] # shared secret main.py checks on every payload + intents = discord.Intents.default() intents.message_content = True intents.members = True @@ -63,9 +69,6 @@ interaction_count = 0 QUIET_COOLDOWN = 600 # seconds of silence before she's allowed to send a random message (10 mins) -def timestamp() -> str: - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") - def log_error(text: str): with ERROR_FILE.open("a", encoding="utf-8") as f: f.write(text + "\n") @@ -74,27 +77,19 @@ def log_message(text: str): with MEMORY_FILE.open("a", encoding="utf-8") as f: f.write(text + "\n") -def nots(line: str) -> str: - if line.startswith("[") and "]" in line: - return line.split("]", 1)[1].lstrip() - return line - def load_shortterm() -> list[str]: try: - with MEMORY_FILE.open("r", encoding="utf-8", errors="ignore") as f: - lines = f.readlines()[-15:] # reads bottom 15 lines - return [line.strip() for line in lines if line.strip()] + return tail_lines(MEMORY_FILE, 15) # reads bottom 15 lines without loading the whole file except Exception as e: - log_error(f"[{timestamp()}] error reading shortterm: {e}") + log_error(f"[{now_str()}] error reading shortterm: {e}") return [] def load_longterm() -> list[str]: try: - with MEMORY_FILE.open("r", encoding="utf-8", errors="ignore") as f: - lines = [nots(line.strip()) for line in f if line.strip()] - return lines[-515:-15] if len(lines) > 15 else [] + lines = [nots(l) for l in tail_lines(MEMORY_FILE, 515)] + return lines[:-15] if len(lines) > 15 else [] except Exception as e: - log_error(f"[{timestamp()}] error reading longterm: {e}") + log_error(f"[{now_str()}] error reading longterm: {e}") return [] def build_payload(user_input: str, rebuild: bool = False) -> str: @@ -108,22 +103,28 @@ def build_payload(user_input: str, rebuild: bool = False) -> str: "shortterm": load_shortterm(), "longterm": load_longterm(), "rebuild_vectors": should_rebuild, + "auth_token": MAIN_TOKEN, }) +_main_ws = None # kept open across messages instead of reconnecting every time + async def call_main(payload: str) -> tuple[bool, str]: - uri = "ws://0.0.0.0:8765" + global _main_ws try: - async with websockets.connect(uri, open_timeout=None, ping_timeout=None) as websocket: - await websocket.send(payload) - raw = await websocket.recv() - data = json.loads(raw) - if "error" in data: - return False, data["error"] - return True, data["ok"] + if _main_ws is None or _main_ws.closed: + _main_ws = await websockets.connect(MAIN_URI, open_timeout=None, ping_timeout=None) + await _main_ws.send(payload) + raw = await _main_ws.recv() + data = json.loads(raw) + if "error" in data: + return False, data["error"] + return True, data["ok"] except websockets.exceptions.ConnectionClosedOK: - return False, f"[{timestamp()}] websocket closed cleanly (no reply)" + _main_ws = None + return False, f"[{now_str()}] websocket closed cleanly (no reply)" except Exception as e: - return False, f"[{timestamp()}] websocket error: {e}" + _main_ws = None + return False, f"[{now_str()}] websocket error: {e}" async def handle_error(error_msg: str, notify_channel=None, notify_user=None): print(error_msg) @@ -163,6 +164,22 @@ def steal_media(text: str): with MEDIA_FILE.open("a", encoding="utf-8") as f: f.write(text + "\n") +def steal_attachments(message): + #steal images and gifs and stuff (always do this regardless of what else fires) + media_urls = [ + a.url for a in message.attachments + if a.content_type and a.content_type.startswith(("image/", "video/")) + ] + + for embed in message.embeds: + if embed.image and embed.image.url: + media_urls.append(embed.image.url) + if embed.video and embed.video.url: + media_urls.append(embed.video.url) + + for url in media_urls: + steal_media(f"[{now_str()}] saved media: {url}") + def resolve_mentions(content: str, guild) -> str: # thank you claude if not guild: return content @@ -205,17 +222,17 @@ async def random_dm(): dm = await victim.create_dm() await dm.send(result) dm_sent = True - log_message(f"[{timestamp()}]Faggot-chan sent a random dm to {victim}: {result}") - print(f"[{timestamp()}] random dm to {victim}: {result}") + log_message(f"[{now_str()}]Faggot-chan sent a random dm to {victim}: {result}") + print(f"[{now_str()}] random dm to {victim}: {result}") except Exception as e: - await handle_error(f"[{timestamp()}] failed to send dm to {victim}: {e}", notify_user=victim) + await handle_error(f"[{now_str()}] failed to send dm to {victim}: {e}", notify_user=victim) async def random_message(): await client.wait_until_ready() while not client.is_closed(): await asyncio.sleep(600) #every 10 minutes if time.time() - last_server_message < QUIET_COOLDOWN: #only send if server has been quiet for 10 mins continue - if random.random() < 0.0001: #0.001% + if random.random() < 0.0001: #0.01% for guild in client.guilds: channel = discord.utils.get(guild.text_channels, name="general") if channel: @@ -229,8 +246,8 @@ async def random_message(): await handle_error(result, notify_channel=channel) continue await channel.send(result) - log_message(f"[{timestamp()}] random message: {result}") - print(f"[{timestamp()}] random message: {result}") + log_message(f"[{now_str()}] random message: {result}") + print(f"[{now_str()}] random message: {result}") @client.event async def on_ready(): @@ -240,77 +257,64 @@ async def on_ready(): @client.event -async def on_random(message): - - RNG = random.randint(1,3) - - if message.author == client.user: - return - - - if not is_member_in_server(message.author): - return - - if str(message.author.id) in block_id: - return - - if not message.guild or message.guild.id != server: - return - - if message.channel.name not in ["general", "announcements"]: - return - - mentioned = client.user.mentioned_in(message) - - #steal images and gifs and stuff (always do this regardless of what else fires) - media_urls = [ - a.url for a in message.attachments - if a.content_type and a.content_type.startswith(("image/", "video/")) - ] - - for embed in message.embeds: - if embed.image and embed.image.url: - media_urls.append(embed.image.url) - if embed.video and embed.video.url: - media_urls.append(embed.video.url) - - for url in media_urls: - steal_media(f"[{timestamp()}] saved media: {url}") - - #stickers - early exit, skip ai generation +async def on_random(message) -> bool: + # single weighted pick between sticker/emoji/media, used as a substitute for an actual + # llm reply — only called once on_message already decided this message gets a response. + # returns True if it fired (caller should skip the llm call), False if it should proceed normally. stickers = list(message.guild.stickers) - if stickers and not mentioned and random.random() < 0.15: #15% chance of sending a sticker - chosen = random.choice(stickers) - try: - await message.channel.send(stickers=[chosen]) - log_message(f"[{timestamp()}] Faggot-chan sent sticker: {chosen.name}") - print(f"[{timestamp()}] Faggot-chan sent sticker: {chosen.name}") - except websockets.exceptions.ConnectionClosedOK: - pass - except Exception as e: - log_error(f"[{timestamp()}] error: {e}") - await message.reply("someone tell <@1000000000000001> there is a problem with my ai") - print(f"Sticker send failed: {e}") - return - emojis = [str(e) for e in message.guild.emojis] - if emojis and not mentioned and random.random() < 0.2: #20% of reacting - early exit - try: - await message.add_reaction(random.choice(emojis)) - except websockets.exceptions.ConnectionClosedOK: - pass - except Exception as e: - log_error(f"[{timestamp()}] error: {e}") - await message.reply("someone tell <@1000000000000001> there is a problem with my ai") - print(f"Emoji reaction failed: {e}") - return - if random.random() < 0.1 and saved_media_urls: #10% of sending a stolen image - early exit, uses live set - chosen = random.choice(list(saved_media_urls)) - await message.channel.send(chosen) - log_message(f"[{timestamp()}] Faggot-chan sent media: {chosen}") - print(f"[{timestamp()}] Faggot-chan sent media: {chosen}") - return + options = [] + if stickers: + options.append(("sticker", 0.10)) + if emojis: + options.append(("emoji", 0.13)) + if saved_media_urls: + options.append(("media", 0.07)) # ~30% combined chance of firing when all three are available + + if not options: + return False + + roll = random.random() + cumulative = 0.0 + for kind, weight in options: + cumulative += weight + if roll >= cumulative: + continue + + if kind == "sticker": + chosen = random.choice(stickers) + try: + await message.channel.send(stickers=[chosen]) + log_message(f"[{now_str()}] Faggot-chan sent sticker: {chosen.name}") + print(f"[{now_str()}] Faggot-chan sent sticker: {chosen.name}") + except websockets.exceptions.ConnectionClosedOK: + pass + except Exception as e: + log_error(f"[{now_str()}] error: {e}") + await message.reply("someone tell <@1000000000000001> there is a problem with my ai") + print(f"Sticker send failed: {e}") + return True + + if kind == "emoji": + try: + await message.add_reaction(random.choice(emojis)) + except websockets.exceptions.ConnectionClosedOK: + pass + except Exception as e: + log_error(f"[{now_str()}] error: {e}") + await message.reply("someone tell <@1000000000000001> there is a problem with my ai") + print(f"Emoji reaction failed: {e}") + return True + + if kind == "media": + chosen = random.choice(list(saved_media_urls)) + await message.channel.send(chosen) + log_message(f"[{now_str()}] Faggot-chan sent media: {chosen}") + print(f"[{now_str()}] Faggot-chan sent media: {chosen}") + return True + + return False @client.event @@ -332,6 +336,9 @@ async def on_message(message): if message.guild and message.channel.name not in ["general", "announcements"]: return + if message.guild: + steal_attachments(message) + content = resolve_mentions(message.content, message.guild) # always detect replies @@ -345,21 +352,19 @@ async def on_message(message): if replied_message.author == client.user: replied_to_bot = True replied_content = resolve_mentions(replied_message.content, message.guild) - log_message(f'[{timestamp()}] {message.author} replied to {replied_message.author} saying: "{replied_content}" with reply: "{content}"') + log_message(f'[{now_str()}] {message.author} replied to {replied_message.author} saying: "{replied_content}" with reply: "{content}"') else: - log_message(f'[{timestamp()}] {message.author} said: {content}') + log_message(f'[{now_str()}] {message.author} said: {content}') except Exception as e: - log_error(f"[{timestamp()}] failed to resolve reply: {e}") - log_message(f'[{timestamp()}] {message.author} said: {content}') + log_error(f"[{now_str()}] failed to resolve reply: {e}") + log_message(f'[{now_str()}] {message.author} said: {content}') else: - log_message(f'[{timestamp()}] {message.author} said: {content}') + log_message(f'[{now_str()}] {message.author} said: {content}') interaction_count += 1 rebuild = (interaction_count % 150 == 0) if message.guild is None: #dms now should work - if str(message.author.id) in block_id: - return dm_sent = False if replied_message: user_input = f'{message.author} replied to {replied_message.author} saying: "{replied_content}" with reply: "{content}"' @@ -375,11 +380,10 @@ async def on_message(message): return try: await message.reply(result) - print(f"[{timestamp()}] DM from {message.author}: {content} Bot replied in DM: {result}") - log_message(f"[{timestamp()}] DM from {message.author}: {content}") - log_message(f"[{timestamp()}] Faggot-chan replied in DM: {result}") + print(f"[{now_str()}] DM from {message.author}: {content} Bot replied in DM: {result}") + log_message(f"[{now_str()}] Faggot-chan said in DM: {result}") except Exception as e: - await handle_error(f"[{timestamp()}] failed to send DM reply: {e}", notify_user=message.author) + await handle_error(f"[{now_str()}] failed to send DM reply: {e}", notify_user=message.author) return mentioned = client.user.mentioned_in(message) @@ -397,6 +401,9 @@ async def on_message(message): if not mentioned and random.random() > 0.03: #3% chance of replying if not mentiond return + if await on_random(message): # ~30% chance she just sends a sticker/emoji/media instead + return + if replied_message: user_input = f'{message.author} replied to {replied_message.author} saying: "{replied_content}" with reply: "{content}"' else: @@ -413,16 +420,14 @@ async def on_message(message): try: await message.reply(result) - print(f"[{timestamp()}] {message.author} said: {content}\n[{timestamp()}] Faggot-chan said: {result}") - log_message(f"[{timestamp()}] {message.author} said: {content}\n[{timestamp()}] Faggot-chan said: {result}") + print(f"[{now_str()}] {message.author} said: {content}\n[{now_str()}] Faggot-chan said: {result}") + log_message(f"[{now_str()}] Faggot-chan said: {result}") if str(message.author.id) == moralfag_id: moralfag_counter += 1 if moralfag_counter >= faglimit: moralfag_skip = True moralfag_counter = 0 except Exception as e: - await handle_error(f"[{timestamp()}] failed to send reply: {e}", notify_channel=message.channel) - - await on_random(message) + await handle_error(f"[{now_str()}] failed to send reply: {e}", notify_channel=message.channel) client.run(TOKEN) diff --git a/colab_setup.py b/colab_setup.py new file mode 100644 index 0000000..b99068c --- /dev/null +++ b/colab_setup.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +#all of this is vibecoded but i read it and it's okay +""" +run this in a colab cell to stand up main.py + a public wss:// tunnel to it. +session is ephemeral — restart the notebook, get a new url, re-paste it +into your bot.py-side config/connection.txt. +""" + +import os +import re +import secrets +import subprocess +import time +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +CONFIG_DIR = SCRIPT_DIR / "config" + +CHATMODEL = "hf.co/Stetsoncody/Llama-3.2-3B-Instruct-abliterated-GGUF:Q4_K_M" +EMBEDMODEL = "hf.co/CompendiumLabs/bge-base-en-v1.5-gguf" + + +def run(cmd, **kwargs): + print(f"$ {cmd}") + return subprocess.run(cmd, shell=True, check=True, **kwargs) + + +def install_ollama(): + print("\n-- installing ollama --") + run("apt install zstd | sh") #hi david here, colab doesn't come with zstd and since this script is meant for colab only it's okay to put apt + run("curl -fsSL https://ollama.com/install.sh | sh") + subprocess.Popen("ollama serve", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(5) # give the server a sec to come up before we start pulling models + + +def pull_models(): + print("\n-- pulling models (this'll take a while) --") + run(f"ollama pull {CHATMODEL}") + run(f"ollama pull {EMBEDMODEL}") + + +def install_python_deps(): + print("\n-- installing python deps --") + run("pip install -q ollama websockets") + + +def ensure_auth_token() -> str: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + token_file = CONFIG_DIR / "auth_token.txt" + if token_file.exists(): + token = token_file.read_text(encoding="utf-8").strip() + print(f"\nusing existing auth token from {token_file}") + else: + token = secrets.token_urlsafe(32) + token_file.write_text(f"{token}\n", encoding="utf-8") + print(f"\ngenerated new auth token, wrote {token_file}") + return token + + +def ensure_params_and_prompt(): + # main.py needs both of these to exist — if this is a fresh colab instance + # and you haven't uploaded them yet, stop here and add them first. + params_file = CONFIG_DIR / "params.json" + prompt_file = CONFIG_DIR / "system_prompt.txt" + missing = [f.name for f in (params_file, prompt_file) if not f.exists()] + if missing: + raise SystemExit( + f"missing {', '.join(missing)} in {CONFIG_DIR} — upload them before running this" + ) + + +def start_main(): + print("\n-- starting main.py --") + return subprocess.Popen( + f"python3 {SCRIPT_DIR / 'main.py'}", + shell=True, + ) + + +def start_tunnel() -> str: + print("\n-- installing cloudflared --") + run("curl -fsSL -o cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb") + run("dpkg -i cloudflared.deb") + + print("\n-- opening tunnel to localhost:8765 --") + proc = subprocess.Popen( + "cloudflared tunnel --url http://localhost:8765", + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + url = None + deadline = time.time() + 30 + for line in proc.stdout: + print(line, end="") + match = re.search(r"https://[a-zA-Z0-9\-]+\.trycloudflare\.com", line) + if match: + url = match.group(0) + break + if time.time() > deadline: + break + + if not url: + raise SystemExit("couldn't find the tunnel url in cloudflared's output, check the log above") + + wss_url = url.replace("https://", "wss://") + return wss_url + + +def main(): + install_ollama() + pull_models() + install_python_deps() + ensure_params_and_prompt() + token = ensure_auth_token() + start_main() + time.sleep(3) # let main.py bind its port before the tunnel tries to reach it + wss_url = start_tunnel() + + print("\n" + "=" * 60) #wtf is this shit claude + print("paste these into setup_wizard.py on the bot.py side:") + print(f" address: {wss_url}") + print(f" token: {token}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py index ad5dc4f..4da70aa 100644 --- a/main.py +++ b/main.py @@ -6,19 +6,22 @@ import ollama import websockets from pathlib import Path from websockets.server import serve -from datetime import datetime +from utils import nots, now_str chatmodel = "hf.co/Stetsoncody/Llama-3.2-3B-Instruct-abliterated-GGUF:Q4_K_M" embedmodel = 'hf.co/CompendiumLabs/bge-base-en-v1.5-gguf' +# resolve config relative to this script, not whatever dir it happens to get launched from +SCRIPT_DIR = Path(__file__).resolve().parent +CONFIG_DIR = SCRIPT_DIR / "config" -def times() -> str: - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") +try: + AUTH_TOKEN = (CONFIG_DIR / "auth_token.txt").read_text(encoding="utf-8").strip() +except FileNotFoundError: + raise SystemExit("no config/auth_token.txt found — run setup_wizard.py first") -def nots(line: str) -> str: - if line.startswith("[") and "]" in line: - return line.split("]", 1)[1].lstrip() - return line +# only one generation at a time, we've got one cpu running one model here +chat_semaphore = asyncio.Semaphore(1) # THANK YOU GUY FROM HUGGING FACE THAT MADE ALL THIS SO THAT I DON'T HAVE TO THINK I LOVE YOU @@ -29,7 +32,7 @@ VECTOR_DB = [] def add_chunk_to_database(chunk): embedding = ollama.embed(model=embedmodel, input=chunk)['embeddings'][0] - VECTOR_DB.append((chunk, embedding)) + return embedding def cosine_similarity(a, b): dot_product = sum([x * y for x, y in zip(a, b)]) @@ -47,35 +50,43 @@ def retrieve(query, top_n=5): return similarities[:top_n] def rebuild_vector_db(longterm_lines: list[str]): - VECTOR_DB.clear() + global VECTOR_DB + existing = dict(VECTOR_DB) # chunk -> embedding, so we don't re-embed stuff we already have + new_db = [] + added = 0 for i, chunk in enumerate(longterm_lines): - add_chunk_to_database(chunk) - print(f"[{times()}] Added chunk {i+1}/{len(longterm_lines)} to the database") - -interaction_count = 0 + embedding = existing.get(chunk) + if embedding is None: + embedding = add_chunk_to_database(chunk) + added += 1 + print(f"[{now_str()}] embedded new chunk {added} ({i+1}/{len(longterm_lines)})") + new_db.append((chunk, embedding)) + VECTOR_DB = new_db async def ollamachat(websocket): - global interaction_count try: # bot sends one json payload with everything main needs raw = await websocket.recv() payload = json.loads(raw) + if payload.get("auth_token") != AUTH_TOKEN: + await websocket.send(json.dumps({"error": "bad auth token"})) + return + system_prompt = payload["system_prompt"] user_input = payload["user_input"] shortterm = payload.get("shortterm", []) # list of strings longterm = payload.get("longterm", []) # list of strings - interaction_count += 1 - - # rebuild vector db if bot signals it (every 150 interactions bot sends full longterm) - if payload.get("rebuild_vectors"): - rebuild_vector_db(longterm) - print(f"[{times()}] Vector DB rebuilt with {len(longterm)} chunks") + # rebuild vector db if bot signals it (every 150 interactions bot sends full longterm), + # or if we come up empty but longterm isn't — e.g. we just restarted and lost VECTOR_DB + if payload.get("rebuild_vectors") or (not VECTOR_DB and longterm): + await asyncio.to_thread(rebuild_vector_db, longterm) + print(f"[{now_str()}] Vector DB reconciled, {len(VECTOR_DB)} chunks") user_query = nots(user_input) - retrieved_knowledge = retrieve(user_query) if VECTOR_DB else [] + retrieved_knowledge = await asyncio.to_thread(retrieve, user_query) if VECTOR_DB else [] # rag chunks go in system so they don't pollute turn history rag_block = "" @@ -151,24 +162,26 @@ async def ollamachat(websocket): messages.insert(0, {"role": "system", "content": system_prompt + rag_block}) - params = json.loads(Path("config/params.json").read_text()) + params = json.loads((CONFIG_DIR / "params.json").read_text()) - response = ollama.chat( - model=chatmodel, - messages=messages, - options=params, - ) + async with chat_semaphore: + response = await asyncio.to_thread( + ollama.chat, + model=chatmodel, + messages=messages, + options=params, + ) # strip blocks in case the model ignores think=False, thank you claude bot_reply = re.sub(r'.*?', '', response["message"]["content"], flags=re.DOTALL).strip() print(messages) - print(f"[{times()}] reply: {bot_reply}\n") + print(f"[{now_str()}] reply: {bot_reply}\n") await websocket.send(json.dumps({"ok": bot_reply})) except Exception as e: - error_msg = f"[{times()}] error in ollamachat: {e}" + error_msg = f"[{now_str()}] error in ollamachat: {e}" print(error_msg) try: await websocket.send(json.dumps({"error": error_msg})) @@ -177,7 +190,7 @@ async def ollamachat(websocket): async def main(): - print(f"[{times()}] hosting on ws://0.0.0.0:8765") + print(f"[{now_str()}] hosting on ws://0.0.0.0:8765") async with serve(ollamachat, "0.0.0.0", 8765, ping_interval=None, ping_timeout=None): await asyncio.Future() diff --git a/setup_wizard.py b/setup_wizard.py new file mode 100644 index 0000000..8575b27 --- /dev/null +++ b/setup_wizard.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +setup wizard for faggot-chan. figures out which files need writing based on +how you're deploying, and writes them. run this before bot.py / main.py. +""" +import secrets +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent + + +def ask(prompt: str, default: str = None) -> str: + suffix = f" [{default}]" if default else "" + val = input(f"{prompt}{suffix}: ").strip() + return val or default or "" + + +def ask_yn(prompt: str) -> bool: + while True: + val = input(f"{prompt} (y/n): ").strip().lower() + if val in ("y", "yes"): + return True + if val in ("n", "no"): + return False + print("just y or n please") + + +def write_tokens_file(config_dir: Path): + config_dir.mkdir(parents=True, exist_ok=True) + print("\n-- discord bot setup --") + token = ask("discord bot token") + server_id = ask("discord server (guild) id") + while not server_id.isdigit(): + print("that's not a number") + server_id = ask("discord server (guild) id") + (config_dir / "tokens.txt").write_text(f"{token}\n{server_id}\n", encoding="utf-8") + print(f"wrote {config_dir / 'tokens.txt'}") + + +def write_connection_file(config_dir: Path, default_uri: str = "ws://0.0.0.0:8765"): + config_dir.mkdir(parents=True, exist_ok=True) + print("\n-- connection to main.py --") + uri = ask("main.py address (ws:// or wss://)", default_uri) + print("paste the auth token main.py is using (from colab_setup.py output),") + token = ask("or leave blank to generate a new one") + if not token: + token = secrets.token_urlsafe(32) + print(f"generated token: {token}") + (config_dir / "connection.txt").write_text(f"{uri}\n{token}\n", encoding="utf-8") + print(f"wrote {config_dir / 'connection.txt'}") + return token + + +def write_auth_token_file(config_dir: Path, token: str = None): + config_dir.mkdir(parents=True, exist_ok=True) + if not token: + print("\n-- websocket auth for main.py --") + print("this is the secret bot.py has to send. leave blank to generate one.") + token = ask("auth token") + if not token: + token = secrets.token_urlsafe(32) + print(f"generated token: {token}") + (config_dir / "auth_token.txt").write_text(f"{token}\n", encoding="utf-8") + print(f"wrote {config_dir / 'auth_token.txt'}") + print(f"\ngive this token (and this machine's public wss:// url) to whoever's setting up bot.py:\n {token}") + + +def main(): + print("faggot-chan setup wizard\n") + + colab = ask_yn("is this for a colab-hosted main.py") + + if not colab: + # everything lives together, plain local setup + bot_config = SCRIPT_DIR / "files" / "config" + main_config = SCRIPT_DIR / "config" + write_tokens_file(bot_config) + token = write_connection_file(bot_config, default_uri="ws://0.0.0.0:8765") + write_auth_token_file(main_config, token=token) + print("\nall set. run main.py, then bot.py.") + return + + where = ask("running this on the 'vps' (bot.py host) or 'colab' box (main.py host)", "vps") + + if where.lower().startswith("colab"): + # this box just needs the auth token main.py will check — connection details + # (discord token, server id) live on whichever box runs bot.py + main_config = SCRIPT_DIR / "config" + write_auth_token_file(main_config) + print("\nnow run colab_setup.py to bring main.py + the tunnel up.") + return + + # vps / bot.py host: skip discord-client setup entirely (handled separately), + # this preset only wires up the connection to the colab-hosted main.py + bot_config = SCRIPT_DIR / "files" / "config" + if not (bot_config / "tokens.txt").exists(): + print("\nheads up: no files/config/tokens.txt found here — the vps preset skips") + print("discord token setup, so make sure that file gets added separately.") + write_connection_file(bot_config, default_uri="wss://your-colab-tunnel-url-here") + print("\nall set. run bot.py once main.py is up on the colab side.") + + +if __name__ == "__main__": + main() diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..aea653b --- /dev/null +++ b/utils.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +from pathlib import Path +from datetime import datetime + + +def now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def nots(line: str) -> str: + if line.startswith("[") and "]" in line: + return line.split("]", 1)[1].lstrip() + return line + + +def tail_lines(path: Path, n: int) -> list[str]: + # grabs the last n lines without reading the whole file into memory first, + # since memory.txt only ever grows and reading it fully every message doesn't scale + if n <= 0: + return [] + avg_line_bytes = 200 + with path.open("rb") as f: + f.seek(0, 2) + file_size = f.tell() + if file_size == 0: + return [] + block_size = n * avg_line_bytes + data = b"" + lines = [] + while True: + seek_pos = max(file_size - block_size, 0) + f.seek(seek_pos) + data = f.read(file_size - seek_pos) + lines = data.splitlines() + if len(lines) > n or seek_pos == 0: + break + block_size *= 2 + return [l.decode("utf-8", errors="ignore").strip() for l in lines[-n:] if l.strip()]