aboutsummaryrefslogtreecommitdiff
path: root/bot.py
diff options
context:
space:
mode:
Diffstat (limited to 'bot.py')
-rw-r--r--bot.py281
1 files changed, 193 insertions, 88 deletions
diff --git a/bot.py b/bot.py
index 7485ef2..3f5b2f5 100644
--- a/bot.py
+++ b/bot.py
@@ -1,14 +1,17 @@
#!/usr/bin/env python3
-import os
import re # thank you claude
+import base64
+import hashlib
import asyncio
import json
import time
import random
import traceback
+import aiohttp # already installed, discord.py needs it
import discord
import websockets
from pathlib import Path
+from urllib.parse import urlparse
from utils import nots, now_str, tail_lines
def ignore_connection_closed_ok(loop, context): #this doesn't fucking work
@@ -19,7 +22,7 @@ def ignore_connection_closed_ok(loop, context): #this doesn't fucking work
def load_system_prompt() -> str:
- return (CONFIG_DIR / "system_prompt.txt").read_text(encoding="utf-8")
+ return PROMPT_FILE.read_text(encoding="utf-8")
# resolve files relative to this script, not whatever dir it happens to get launched from
SCRIPT_DIR = Path(__file__).resolve().parent
@@ -27,19 +30,23 @@ FILES_DIR = SCRIPT_DIR / "files"
LOG_DIR = FILES_DIR / "log"
MEDIA_DIR = FILES_DIR / "media"
CONFIG_DIR = FILES_DIR / "config"
+DM_DIR = LOG_DIR / "dm" # one memory file per person so dms never end up in the shared memory
ERROR_FILE = LOG_DIR / "errorlog.txt"
MEMORY_FILE = LOG_DIR / "memory.txt"
MEDIA_FILE = MEDIA_DIR / "media.txt"
THREADS_FILE = CONFIG_DIR / "threads.txt"
+PROMPT_FILE = CONFIG_DIR / "system_prompt.txt"
def init_files():
- for d in (LOG_DIR, MEDIA_DIR, CONFIG_DIR):
+ for d in (LOG_DIR, MEDIA_DIR, CONFIG_DIR, DM_DIR):
d.mkdir(parents=True, exist_ok=True)
for f in (ERROR_FILE, MEMORY_FILE, MEDIA_FILE):
f.touch(exist_ok=True)
if not THREADS_FILE.exists():
THREADS_FILE.write_text("6\n")
+ if not PROMPT_FILE.exists(): # otherwise every single message dies on a missing file
+ PROMPT_FILE.write_text("you are faggot-chan, a chaotic discord bot. edit files/config/system_prompt.txt\n", encoding="utf-8")
init_files()
@@ -69,17 +76,23 @@ interaction_count = 0
QUIET_COOLDOWN = 600 # seconds of silence before she's allowed to send a random message (10 mins)
+MEDIA_MIMES = ("image/png", "image/jpeg", "image/webp", "image/heic", "image/heif",
+ "audio/", "video/", "application/pdf", "text/plain") # what gemini can actually read
+MEDIA_MAX_BYTES = 10 * 1024 * 1024 # total per message, gemini caps inline request size (~20mb and base64 adds a third)
+MEDIA_SAVE_MAX_BYTES = 8 * 1024 * 1024 # per file we keep, stays under discord's 10mb upload limit so she can send it back
+
def log_error(text: str):
with ERROR_FILE.open("a", encoding="utf-8") as f:
f.write(text + "\n")
-def log_message(text: str):
- with MEMORY_FILE.open("a", encoding="utf-8") as f:
+def log_message(text: str, file: Path = MEMORY_FILE):
+ text = " ".join(text.splitlines()) # one message = one line, otherwise multiline messages break the history parsing
+ with file.open("a", encoding="utf-8") as f:
f.write(text + "\n")
-def load_shortterm() -> list[str]:
+def load_shortterm(file: Path = MEMORY_FILE) -> list[str]:
try:
- return tail_lines(MEMORY_FILE, 15) # reads bottom 15 lines without loading the whole file
+ return tail_lines(file, 15) # reads bottom 15 lines without loading the whole file
except Exception as e:
log_error(f"[{now_str()}] error reading shortterm: {e}")
return []
@@ -92,39 +105,52 @@ def load_longterm() -> list[str]:
log_error(f"[{now_str()}] error reading longterm: {e}")
return []
-def build_payload(user_input: str, rebuild: bool = False) -> str:
+def build_payload(user_input: str, rebuild: bool = False, media: list = None, logged: bool = False, dm_file: Path = None) -> str:
global vector_db_ready
- should_rebuild = rebuild or not vector_db_ready # always rebuild on first ever message
- if should_rebuild:
- vector_db_ready = True
+ shortterm = load_shortterm(dm_file or MEMORY_FILE)
+ if logged and shortterm:
+ shortterm = shortterm[:-1] # the message she's answering is already the last line, it goes in the final turn instead
+ if dm_file:
+ longterm = [] # dms stay out of the shared rag so private stuff can't come up in a public channel
+ should_rebuild = False
+ else:
+ longterm = load_longterm()
+ should_rebuild = rebuild or not vector_db_ready # always rebuild on first ever message
+ if should_rebuild:
+ vector_db_ready = True
return json.dumps({
"system_prompt": load_system_prompt(),
"user_input": user_input,
- "shortterm": load_shortterm(),
- "longterm": load_longterm(),
+ "shortterm": shortterm,
+ "longterm": longterm,
"rebuild_vectors": should_rebuild,
+ "media": media or [], # [{"mime_type", "data" (base64)}], gemini.py reads everything, main.py only the images
"auth_token": MAIN_TOKEN,
})
_main_ws = None # kept open across messages instead of reconnecting every time
+_main_lock = None # one send/recv at a time on the shared socket, made on first use so it lives in discord's loop
async def call_main(payload: str) -> tuple[bool, str]:
- global _main_ws
- for attempt in range(2): # retry once with a fresh connection if the cached one turned out dead
- try:
- if _main_ws is None:
- _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 Exception as e:
- _main_ws = None
- if attempt == 0:
- continue # one retry with a fresh connection before giving up
- return False, f"[{now_str()}] websocket error: {e}"
+ global _main_ws, _main_lock
+ if _main_lock is None:
+ _main_lock = asyncio.Lock()
+ async with _main_lock:
+ for attempt in range(2): # retry once with a fresh connection if the cached one turned out dead
+ try:
+ if _main_ws is None:
+ _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 Exception as e:
+ _main_ws = None
+ if attempt == 0:
+ continue # one retry with a fresh connection before giving up
+ return False, f"[{now_str()}] websocket error: {e}"
async def handle_error(error_msg: str, notify_channel=None, notify_user=None):
print(error_msg)
@@ -144,41 +170,103 @@ async def keep_typing(channel, stop_event):
await channel.typing()
await asyncio.sleep(8) # refresh every 8s before discord's 10s timeout
+def dm_file_for(user) -> Path:
+ f = DM_DIR / f"{user.id}.txt"
+ f.touch(exist_ok=True)
+ return f
+
+def split_reply(text: str, limit: int = 2000) -> list[str]:
+ # discord rejects anything over 2000 chars (and empty messages), split on a newline/space when possible
+ chunks = []
+ text = text.strip()
+ while len(text) > limit:
+ cut = max(text.rfind("\n", 0, limit), text.rfind(" ", 0, limit))
+ if cut <= 0:
+ cut = limit
+ chunks.append(text[:cut].strip())
+ text = text[cut:].strip()
+ if text:
+ chunks.append(text)
+ return chunks
+
+async def send_reply(message, text: str):
+ # first chunk is a real reply, the rest just follow in the channel
+ chunks = split_reply(text)
+ await message.reply(chunks[0])
+ for chunk in chunks[1:]:
+ await message.channel.send(chunk)
+
vector_db_ready = False # gets set to True after first rebuild so we don't keep rebuilding
-saved_media_urls = set()
+saved_media = set() # file names inside MEDIA_DIR
with MEDIA_FILE.open("r", encoding="utf-8") as f:
for line in f:
if "saved media:" in line:
- url = line.split("saved media:", 1)[1].strip()
- saved_media_urls.add(url)
+ name = line.split("saved media:", 1)[1].strip()
+ if (MEDIA_DIR / name).is_file(): # old entries were urls (those expire), they don't match a file so they get dropped
+ saved_media.add(name)
-def steal_media(text: str):
- if "saved media:" not in text:
+async def steal_media(url: str, name: str):
+ # discord cdn links are signed and die after a day or so, so we keep our own copy
+ if name in saved_media:
+ return
+ saved_media.add(name) # claim it first so two messages can't download the same thing at once
+ try:
+ data = b""
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url) as r:
+ r.raise_for_status()
+ async for chunk in r.content.iter_chunked(65536):
+ data += chunk
+ if len(data) > MEDIA_SAVE_MAX_BYTES:
+ break
+ if len(data) > MEDIA_SAVE_MAX_BYTES: # too big for her to send back anyway
+ saved_media.discard(name)
+ return
+ (MEDIA_DIR / name).write_bytes(data)
+ except Exception as e:
+ saved_media.discard(name)
+ log_error(f"[{now_str()}] failed to save media {url}: {e}")
return
- url = text.split("saved media:", 1)[1].strip()
- if url in saved_media_urls:
- return
- saved_media_urls.add(url)
with MEDIA_FILE.open("a", encoding="utf-8") as f:
- f.write(text + "\n")
+ f.write(f"[{now_str()}] saved media: {name}\n")
-def steal_attachments(message):
+async 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/"))
+ media = [
+ (a.url, f"{a.id}_{Path(a.filename).name}") for a in message.attachments
+ if a.content_type and a.content_type.startswith(("image/", "video/")) and a.size <= MEDIA_SAVE_MAX_BYTES
]
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 part in (embed.image, embed.video):
+ if part and part.url:
+ ext = Path(urlparse(part.url).path).suffix # no extension, no preview on discord, not worth keeping
+ if ext:
+ media.append((part.url, hashlib.sha1(part.url.encode()).hexdigest()[:16] + ext))
- for url in media_urls:
- steal_media(f"[{now_str()}] saved media: {url}")
+ for url, name in media:
+ await steal_media(url, name)
+
+async def grab_media(message) -> list[dict]:
+ # downloads supported attachments to send to gemini, only call this when the bot was directly mentioned
+ media = []
+ total = 0
+ for a in message.attachments:
+ mime = (a.content_type or "").split(";")[0].strip().lower()
+ if not mime.startswith(MEDIA_MIMES):
+ continue
+ if total + a.size > MEDIA_MAX_BYTES:
+ continue
+ try:
+ data = await a.read()
+ except Exception as e:
+ log_error(f"[{now_str()}] failed to download attachment {a.filename}: {e}")
+ continue
+ total += len(data)
+ media.append({"mime_type": mime, "data": base64.b64encode(data).decode("ascii")})
+ return media
def resolve_mentions(content: str, guild) -> str: # thank you claude
if not guild:
@@ -208,24 +296,28 @@ async def random_dm():
victim = random.choice(members)
payload = build_payload(
f"System: you are sending a dm to {victim}, write a long, playful story or an over-the-top pun monologue about anything. "
- "It should sound like something faggot-chan would post when she's bored — chaotic, funny, and full of personality (at least 20 words)."
- "keep in mind this is a dm so make it a bit personal"
+ "It should sound like something faggot-chan would post when she's bored — chaotic, funny, and full of personality (at least 20 words). "
+ "keep in mind this is a dm so make it a bit personal",
+ dm_file=dm_file_for(victim)
)
stop_typing = asyncio.Event()
asyncio.create_task(keep_typing(victim.dm_channel or await victim.create_dm(), stop_typing))
ok, result = await call_main(payload)
stop_typing.set()
if not ok:
- await handle_error(result, notify_user=victim)
+ await handle_error(result) # just log it, the victim has nothing to do with this
+ continue
+ if not result.strip():
continue
try: #sends the dm
dm = await victim.create_dm()
- await dm.send(result)
+ for chunk in split_reply(result):
+ await dm.send(chunk)
dm_sent = True
- log_message(f"[{now_str()}]Faggot-chan sent a random dm to {victim}: {result}")
+ log_message(f"[{now_str()}] Faggot-chan said in DM: {result}", dm_file_for(victim))
print(f"[{now_str()}] random dm to {victim}: {result}")
except Exception as e:
- await handle_error(f"[{now_str()}] failed to send dm to {victim}: {e}", notify_user=victim)
+ await handle_error(f"[{now_str()}] failed to send dm to {victim}: {e}")
async def random_message():
await client.wait_until_ready()
while not client.is_closed():
@@ -237,40 +329,43 @@ async def random_message():
channel = discord.utils.get(guild.text_channels, name="general")
if channel:
payload = build_payload(
- "System: write a long, playful story or an over-the-top pun monologue about cheese. or anything random"
- "it should sound like something faggot-chan would post when she's bored."
+ "System: write a short, playful story or an over-the-top pun monologue about cheese. or anything random "
+ "it should sound like something faggot-chan would post when she's bored. "
"chaotic, funny, and full of personality (at least 15 words and no more than 20)"
)
ok, result = await call_main(payload)
if not ok:
await handle_error(result, notify_channel=channel)
continue
- await channel.send(result)
+ if not result.strip():
+ continue
+ for chunk in split_reply(result):
+ await channel.send(chunk)
log_message(f"[{now_str()}] random message: {result}")
print(f"[{now_str()}] random message: {result}")
@client.event
async def on_ready():
+ asyncio.get_running_loop().set_exception_handler(ignore_connection_closed_ok)
client.loop.create_task(random_message())
client.loop.create_task(random_dm())
print(f"faggot-chan is active as {client.user}")
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.
- # skip locked/role-restricted ones, discord rejects those (50081 / 10014)
- stickers = [s for s in message.guild.stickers if s.available]
- emojis = [str(e) for e in message.guild.emojis if e.available and not e.roles]
+ stickers = list(message.guild.stickers)
+ emojis = [str(e) for e in message.guild.emojis]
+ media_files = [n for n in saved_media if (MEDIA_DIR / n).is_file()] # in case something got deleted by hand
options = []
if stickers:
options.append(("sticker", 0.10))
if emojis:
options.append(("emoji", 0.13))
- if saved_media_urls:
+ if media_files:
options.append(("media", 0.07)) # ~30% combined chance of firing when all three are available
if not options:
@@ -289,8 +384,6 @@ async def on_random(message) -> bool:
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")
@@ -300,8 +393,6 @@ async def on_random(message) -> bool:
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")
@@ -309,10 +400,15 @@ async def on_random(message) -> bool:
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}")
+ chosen = random.choice(media_files)
+ try:
+ await message.channel.send(file=discord.File(MEDIA_DIR / chosen))
+ log_message(f"[{now_str()}] Faggot-chan sent media: {chosen}")
+ print(f"[{now_str()}] Faggot-chan sent media: {chosen}")
+ 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"Media send failed: {e}")
return True
return False
@@ -321,7 +417,7 @@ async def on_random(message) -> bool:
@client.event
async def on_message(message):
global dm_sent, interaction_count, last_server_message
- if message.author == client.user:
+ if message.author == client.user or message.author.bot: # other bots too, otherwise two bots can loop forever
return
if not is_member_in_server(message.author):
@@ -338,9 +434,10 @@ async def on_message(message):
return
if message.guild:
- steal_attachments(message)
+ await steal_attachments(message)
content = resolve_mentions(message.content, message.guild)
+ mem_file = MEMORY_FILE if message.guild else dm_file_for(message.author) # dms get their own memory
# always detect replies
replied_message = None
@@ -348,19 +445,22 @@ async def on_message(message):
replied_content = ""
if message.reference:
try:
- replied_message = message.reference.resolved or await message.channel.fetch_message(message.reference.message_id)
+ replied_message = message.reference.resolved
+ if not isinstance(replied_message, discord.Message): # deleted or not cached, resolved is a placeholder with no author
+ replied_message = await message.channel.fetch_message(message.reference.message_id)
if replied_message:
if replied_message.author == client.user:
replied_to_bot = True
replied_content = resolve_mentions(replied_message.content, message.guild)
- log_message(f'[{now_str()}] {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}"', mem_file)
else:
- log_message(f'[{now_str()}] {message.author} said: {content}')
+ log_message(f'[{now_str()}] {message.author} said: {content}', mem_file)
except Exception as e:
+ replied_message = None # fetch failed (deleted), treat it like a normal message
log_error(f"[{now_str()}] failed to resolve reply: {e}")
- log_message(f'[{now_str()}] {message.author} said: {content}')
+ log_message(f'[{now_str()}] {message.author} said: {content}', mem_file)
else:
- log_message(f'[{now_str()}] {message.author} said: {content}')
+ log_message(f'[{now_str()}] {message.author} said: {content}', mem_file)
interaction_count += 1
rebuild = (interaction_count % 150 == 0)
@@ -371,7 +471,7 @@ async def on_message(message):
user_input = f'{message.author} replied to {replied_message.author} saying: "{replied_content}" with reply: "{content}"'
else:
user_input = f"{message.author} said in DM: {content}"
- payload = build_payload(user_input, rebuild)
+ payload = build_payload(user_input, rebuild, await grab_media(message), True, mem_file) # a dm is always aimed at her
stop_typing = asyncio.Event()
asyncio.create_task(keep_typing(message.channel, stop_typing))
ok, result = await call_main(payload)
@@ -379,29 +479,31 @@ async def on_message(message):
if not ok:
await handle_error(result, notify_user=message.author)
return
+ if not result.strip():
+ return
try:
- await message.reply(result)
+ await send_reply(message, 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}")
+ log_message(f"[{now_str()}] Faggot-chan said in DM: {result}", mem_file)
except Exception as e:
await handle_error(f"[{now_str()}] failed to send DM reply: {e}", notify_user=message.author)
return
- mentioned = client.user.mentioned_in(message)
+ mentioned = client.user in message.mentions # mentioned_in() also fires on @everyone/@here
#if it replied to bot, treat it as a mention automatically
if replied_to_bot:
mentioned = True
- if str(message.author.id) == moralfag_id:
+ if not mentioned and random.random() > 0.03: #3% chance of replying if not mentiond
+ return
+
+ if str(message.author.id) == moralfag_id: # only checked once she was actually going to reply, otherwise the skip gets eaten by chatter
global moralfag_skip, moralfag_counter
if moralfag_skip:
moralfag_skip = False
return
- 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
@@ -410,7 +512,8 @@ async def on_message(message):
else:
user_input = f"{message.author} said: {content}"
- payload = build_payload(user_input, rebuild)
+ media = await grab_media(message) if client.user in message.mentions else [] # only direct @mentions, not replies or random 3% ones
+ payload = build_payload(user_input, rebuild, media, True)
stop_typing = asyncio.Event()
asyncio.create_task(keep_typing(message.channel, stop_typing))
ok, result = await call_main(payload)
@@ -418,9 +521,11 @@ async def on_message(message):
if not ok:
await handle_error(result, notify_channel=message.channel)
return
+ if not result.strip():
+ return
try:
- await message.reply(result)
+ await send_reply(message, 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: