aboutsummaryrefslogtreecommitdiff
path: root/bot.py
diff options
context:
space:
mode:
authordavid <[email protected]>2026-10-14 20:45:52 -0300
committerdavid <[email protected]>2026-10-14 20:45:52 -0300
commit9f82a0a2231ce732cb647ae67d3ac0cd6312c1a4 (patch)
treef4fa7ba446205453c410519dcb76b01d419cd3e8 /bot.py
parentb8baac169d3716fac55272b7127f00f9adb92ee6 (diff)
downloadfaggot-chan-9f82a0a2231ce732cb647ae67d3ac0cd6312c1a4.tar.gz
faggot-chan-9f82a0a2231ce732cb647ae67d3ac0cd6312c1a4.zip
hello
Diffstat (limited to 'bot.py')
-rw-r--r--bot.py262
1 files changed, 165 insertions, 97 deletions
diff --git a/bot.py b/bot.py
index 3f5b2f5..dc2003b 100644
--- a/bot.py
+++ b/bot.py
@@ -37,6 +37,11 @@ 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"
+BLOCKED_FILE = CONFIG_DIR / "blocked.txt" # edited with admin.py, re-read on every message
+SETTINGS_FILE = CONFIG_DIR / "settings.json"
+REACTIONS_FILE = CONFIG_DIR / "reactions.json" # sticker/emoji/media descriptions she picks from
+
+DEFAULT_SETTINGS = {"admin_id": "1000000000000001", "moralfag_id": "1000000000000002", "faglimit": 2} # faglimit: ignore every x reply
def init_files():
for d in (LOG_DIR, MEDIA_DIR, CONFIG_DIR, DM_DIR):
@@ -47,6 +52,10 @@ def init_files():
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")
+ if not BLOCKED_FILE.exists():
+ BLOCKED_FILE.write_text("# one discord user id per line, edit with admin.py edit blocked\n1000000000000003 # fuck these two in particular\n1000000000000004\n", encoding="utf-8")
+ if not SETTINGS_FILE.exists():
+ SETTINGS_FILE.write_text(json.dumps(DEFAULT_SETTINGS, indent=2) + "\n", encoding="utf-8")
init_files()
@@ -57,23 +66,27 @@ 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
+FALLBACK_URI = _conn_lines[2] if len(_conn_lines) > 2 else None # optional, local main.py (ollama) that takes over when gemini runs out of quota, same token
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
client = discord.Client(intents=intents)
-block_id = ["1000000000000003", "1000000000000004"] #fuck these two in particular
-
-moralfag_id = "1000000000000002"
moralfag_counter = 0
moralfag_skip = False
-faglimit = 2 #ignore every x reply
last_server_message = 0.0 # unix timestamp of last message anyone sent in the server
dm_sent = False
interaction_count = 0
+QUOTA_MSG = "usage limit reached, see you in a month"
+QUOTA_COOLDOWN = 3600 # after gemini says it's out of quota go straight to the fallback for this long (seconds), then try it again
+quota_until = 0.0 # unix timestamp
+
+MAX_REACTION_OPTIONS = 25 # per kind (stickers/emojis/media), random sample so the prompt stays small
+REACTION_TAG = re.compile(r'\[(sticker|emoji|media):\s*([^\]]+?)\s*\]', re.I)
+
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",
@@ -85,10 +98,27 @@ def log_error(text: str):
with ERROR_FILE.open("a", encoding="utf-8") as f:
f.write(text + "\n")
+def load_blocked() -> set[str]:
+ # re-read on every message so admin.py edits apply without restarting
+ try:
+ lines = [l.split("#")[0].strip() for l in BLOCKED_FILE.read_text(encoding="utf-8").splitlines()]
+ return {l for l in lines if l}
+ except Exception as e:
+ log_error(f"[{now_str()}] error reading blocked list: {e}")
+ return set()
+
+def load_settings() -> dict:
+ try:
+ return {**DEFAULT_SETTINGS, **json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))}
+ except Exception as e:
+ log_error(f"[{now_str()}] error reading settings: {e}")
+ return dict(DEFAULT_SETTINGS)
+
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")
+ return text.strip() # what shortterm will read back for this line, build_payload uses it to find the message she's answering
def load_shortterm(file: Path = MEMORY_FILE) -> list[str]:
try:
@@ -105,21 +135,23 @@ def load_longterm() -> list[str]:
log_error(f"[{now_str()}] error reading longterm: {e}")
return []
-def build_payload(user_input: str, rebuild: bool = False, media: list = None, logged: bool = False, dm_file: Path = None) -> str:
+def build_payload(user_input: str, rebuild: bool = False, media: list = None, logged: str = None, dm_file: Path = None, tools: str = "") -> str:
global vector_db_ready
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 logged and logged in shortterm:
+ # cut at the message she's answering (it goes in the final turn instead), found by content and not by position:
+ # people keep talking while she's busy so it's not always the last line, and whatever got logged after it isn't part of this reply
+ shortterm = shortterm[:len(shortterm) - 1 - shortterm[::-1].index(logged)]
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
+ longterm = load_longterm() if should_rebuild else [] # only sent when it's needed, the backend asks for it (need_longterm) if it lost it
if should_rebuild:
vector_db_ready = True
return json.dumps({
- "system_prompt": load_system_prompt(),
+ "system_prompt": load_system_prompt() + tools,
"user_input": user_input,
"shortterm": shortterm,
"longterm": longterm,
@@ -128,34 +160,56 @@ def build_payload(user_input: str, rebuild: bool = False, media: list = None, lo
"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
+_main_ws = {} # one connection per backend uri (gemini / fallback), kept open across messages instead of reconnecting every time
+_main_lock = None # one send/recv at a time on the shared sockets, made on first use so it lives in discord's loop
+
+async def call_backend(uri: str, payload: str) -> tuple[bool, str, bool]:
+ # last bool is True when the backend said it's out of quota
+ resent = False
+ for attempt in range(2): # retry once with a fresh connection if the cached one turned out dead
+ try:
+ ws = _main_ws.get(uri)
+ if ws is None:
+ ws = _main_ws[uri] = await websockets.connect(uri, open_timeout=None, ping_timeout=None)
+ await ws.send(payload)
+ data = json.loads(await ws.recv())
+ if data.get("need_longterm") and not resent: # backend has no vector db (restarted / first time as fallback), send it the longterm once
+ resent = True
+ p = json.loads(payload)
+ p["longterm"] = load_longterm()
+ p["rebuild_vectors"] = True
+ payload = json.dumps(p)
+ await ws.send(payload)
+ data = json.loads(await ws.recv())
+ if "error" in data:
+ return False, data["error"], bool(data.get("quota"))
+ return True, data["ok"], False
+ except Exception as e:
+ _main_ws.pop(uri, None)
+ if attempt == 0:
+ continue # one retry with a fresh connection before giving up
+ return False, f"[{now_str()}] websocket error: {e}", False
async def call_main(payload: str) -> tuple[bool, str]:
- global _main_ws, _main_lock
+ global _main_lock, quota_until
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}"
+ if not (FALLBACK_URI and time.time() < quota_until): # skip gemini while it's known to be out of quota
+ ok, result, quota = await call_backend(MAIN_URI, payload)
+ if not quota:
+ return ok, result
+ quota_until = time.time() + QUOTA_COOLDOWN
+ if not FALLBACK_URI:
+ return False, QUOTA_MSG
+ print(f"[{now_str()}] gemini is out of quota, using the fallback for the next {QUOTA_COOLDOWN // 60} min")
+ ok, result, _ = await call_backend(FALLBACK_URI, payload)
+ return ok, result
async def handle_error(error_msg: str, notify_channel=None, notify_user=None):
print(error_msg)
log_error(error_msg)
- notice = "someone tell <@1000000000000001> there is a problem with my ai"
+ notice = QUOTA_MSG if error_msg == QUOTA_MSG else f"someone tell <@{load_settings()['admin_id']}> there is a problem with my ai"
try:
if notify_channel:
await notify_channel.send(notice)
@@ -347,71 +401,83 @@ async def random_message():
@client.event
async def on_ready():
asyncio.get_running_loop().set_exception_handler(ignore_connection_closed_ok)
+ for guild in client.guilds:
+ sync_reactions(guild)
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.
- 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 media_files:
- options.append(("media", 0.07)) # ~30% combined chance of firing when all three are available
+def load_reactions() -> dict:
+ # re-read every time so admin.py edit reactions applies without a restart
+ try:
+ data = json.loads(REACTIONS_FILE.read_text(encoding="utf-8"))
+ except (FileNotFoundError, json.JSONDecodeError):
+ data = {}
+ for k in ("stickers", "emojis", "media"):
+ data.setdefault(k, {}) # name -> short description
+ return data
- if not options:
- return False
+def sync_reactions(guild):
+ # anything new gets added with an empty description, fill them in with admin.py edit reactions
+ data = load_reactions()
+ changed = False
+ for key, names in (("stickers", [s.name for s in guild.stickers]), ("emojis", [e.name for e in guild.emojis]), ("media", sorted(saved_media))):
+ for n in names:
+ if n not in data[key]:
+ data[key][n] = ""
+ changed = True
+ if changed:
+ REACTIONS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
- roll = random.random()
- cumulative = 0.0
- for kind, weight in options:
- cumulative += weight
- if roll >= cumulative:
+def reaction_tool_prompt(guild) -> str:
+ # the "tool": instead of (or along with) words she can write a [sticker: name] / [emoji: name] / [media: name] tag and
+ # split_reactions picks it back out of the reply. same call as the reply, so no extra latency or gemini quota.
+ sync_reactions(guild)
+ data = load_reactions()
+ have = {
+ "stickers": {s.name for s in guild.stickers},
+ "emojis": {e.name for e in guild.emojis},
+ "media": {n for n in saved_media if (MEDIA_DIR / n).is_file()},
+ }
+ sections = []
+ for key in ("stickers", "emojis", "media"):
+ options = [(n, d) for n, d in data[key].items() if n in have[key] and (d or key != "media")] # media without a description tells her nothing
+ if not options:
continue
+ options = random.sample(options, min(len(options), MAX_REACTION_OPTIONS))
+ sections.append(f"{key}:\n" + "\n".join(f"- {n}: {d}" if d else f"- {n}" for n, d in options))
+ if not sections:
+ return ""
+ return ("\n\nREACTIONS: you can send a sticker, an emoji reaction or a media file instead of (or along with) words, but only when it really fits the moment, most of the time just reply normally. "
+ "to do it put ONE tag at the end of your message: [sticker: name], [emoji: name] or [media: name], using exactly one of the names below. "
+ "the tag alone is fine if you have nothing to say. never invent names.\n" + "\n".join(sections))
+def split_reactions(text: str) -> tuple[str, list]:
+ return REACTION_TAG.sub("", text).strip(), [(k.lower(), n) for k, n in REACTION_TAG.findall(text)]
+
+async def send_reaction(message, kind: str, name: str):
+ # looks the name up for real, a made up name just does nothing
+ name = name.strip().strip(":").lower()
+ try:
if kind == "sticker":
- chosen = random.choice(stickers)
- try:
+ chosen = next((s for s in message.guild.stickers if s.name.lower() == name), None)
+ if chosen:
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 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 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(media_files)
- try:
+ elif kind == "emoji":
+ chosen = next((e for e in message.guild.emojis if e.name.lower() == name), None)
+ if chosen:
+ await message.add_reaction(chosen)
+ elif kind == "media":
+ chosen = next((n for n in saved_media if n.lower() == name and (MEDIA_DIR / n).is_file()), None) # only names we saved ourselves
+ if chosen:
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
+ except Exception as e:
+ await handle_error(f"[{now_str()}] failed to send {kind} {name}: {e}", notify_channel=message.channel)
@client.event
@@ -426,7 +492,7 @@ async def on_message(message):
# update last activity so random_message knows the server isn't quiet
last_server_message = time.time()
- if str(message.author.id) in block_id:
+ if str(message.author.id) in load_blocked():
return
# drop guild messages outside allowed channels before doing anything else, thank you claude
@@ -452,15 +518,15 @@ 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'[{now_str()}] {message.author} replied to {replied_message.author} saying: "{replied_content}" with reply: "{content}"', mem_file)
+ logged_line = 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}', mem_file)
+ logged_line = 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}', mem_file)
+ logged_line = log_message(f'[{now_str()}] {message.author} said: {content}', mem_file)
else:
- log_message(f'[{now_str()}] {message.author} said: {content}', mem_file)
+ logged_line = log_message(f'[{now_str()}] {message.author} said: {content}', mem_file)
interaction_count += 1
rebuild = (interaction_count % 150 == 0)
@@ -471,7 +537,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, await grab_media(message), True, mem_file) # a dm is always aimed at her
+ payload = build_payload(user_input, rebuild, await grab_media(message), logged_line, 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)
@@ -498,22 +564,20 @@ async def on_message(message):
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
+ settings = load_settings()
+ if str(message.author.id) == settings["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 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:
user_input = f"{message.author} said: {content}"
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)
+ payload = build_payload(user_input, rebuild, media, logged_line, tools=reaction_tool_prompt(message.guild))
stop_typing = asyncio.Event()
asyncio.create_task(keep_typing(message.channel, stop_typing))
ok, result = await call_main(payload)
@@ -521,18 +585,22 @@ async def on_message(message):
if not ok:
await handle_error(result, notify_channel=message.channel)
return
- if not result.strip():
+ result, reactions = split_reactions(result) # she can answer with a [sticker: x] style tag instead of words
+ if not result.strip() and not reactions:
return
try:
- 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:
- moralfag_counter += 1
- if moralfag_counter >= faglimit:
- moralfag_skip = True
- moralfag_counter = 0
+ if result.strip():
+ 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) == settings["moralfag_id"]:
+ moralfag_counter += 1
+ if moralfag_counter >= settings["faglimit"]:
+ moralfag_skip = True
+ moralfag_counter = 0
+ for kind, name in reactions[:1]: # one is enough
+ await send_reaction(message, kind, name)
except Exception as e:
await handle_error(f"[{now_str()}] failed to send reply: {e}", notify_channel=message.channel)