From 1c2d810d9fb5d2e85b9972f3426781d3cc1a9e42 Mon Sep 17 00:00:00 2001 From: fretuks Date: Wed, 10 Dec 2025 11:50:01 +0100 Subject: [PATCH] Major version update with enhanced revive, circuit breaker, and AI usage metrics. Refactored error handling and added new utilities for health checks, activity tracking, and configuration management. --- config.py | 4 + tars_bot.py | 347 +++++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 277 insertions(+), 74 deletions(-) diff --git a/config.py b/config.py index 7c3d004..358a8af 100644 --- a/config.py +++ b/config.py @@ -31,6 +31,10 @@ AI_PROHIBITED_PATTERNS = [ r"\bblyat\b", r"\bsuka\b", r"\bnaxuy\b", r"\bблять\b", r"\bсука\b", r"\bнахуй\b", r"\bхуй\b", ] +CHANNEL_THEMES = { + 1424038714266357886: "Gaming, General Chat, and Fun", +} + def is_ai_prompt_disallowed(text: str) -> bool: lowered = text.lower() diff --git a/tars_bot.py b/tars_bot.py index bfdc853..7bdb152 100644 --- a/tars_bot.py +++ b/tars_bot.py @@ -31,9 +31,138 @@ RATE_LIMIT_WINDOW = timedelta(hours=1) user_message_log = defaultdict(list) last_activity_time = None revive_sent = False - FABI_ID = 392388537984745498 -BOT_VERSION = "5.4.1" +BOT_VERSION = "6.0.0" +BOT_START_TIME = datetime.utcnow() +LAST_ERROR_TIME: datetime | None = None +LAST_REVIVE_TIME: datetime | None = None +FEATURE_FLAGS = { + "ai_enabled": True, + "revive_enabled": True, +} +ERROR_WINDOW = timedelta(seconds=60) +ERROR_THRESHOLD = 5 +COOLDOWN_PERIOD = timedelta(minutes=10) +ERROR_LOG: list[datetime] = [] +COOLDOWN_UNTIL: datetime | None = None +TOPIC_COUNTER = defaultdict(int) +HOURLY_ACTIVITY = defaultdict(int) + + +async def check_openai_health() -> bool: + try: + await openai_client.models.list() + return True + except Exception as e: + logger.error(f"OpenAI API is not available: {e}") + return False + + +async def check_db_health() -> bool: + try: + async with aiosqlite.connect(DB_FILE) as db: + await db.execute("SELECT 1") + return True + except Exception as e: + logger.error(f"Database is not available: {e}") + return False + + +def record_error(): + global LAST_ERROR_TIME, COOLDOWN_UNTIL + now = datetime.utcnow() + LAST_ERROR_TIME = now + ERROR_LOG.append(now) + ERROR_LOG[:] = [t for t in ERROR_LOG if now - t <= ERROR_WINDOW] + if len(ERROR_LOG) >= ERROR_THRESHOLD and not COOLDOWN_UNTIL: + COOLDOWN_UNTIL = now + COOLDOWN_PERIOD + FEATURE_FLAGS["ai_enabled"] = False + FEATURE_FLAGS["revive_enabled"] = False + logger.error("Circuit breaker triggered. Features temporarily disabled.") + + +def check_circuit_recovery(): + global COOLDOWN_UNTIL + if COOLDOWN_UNTIL and datetime.utcnow() >= COOLDOWN_UNTIL: + FEATURE_FLAGS["ai_enabled"] = True + FEATURE_FLAGS["revive_enabled"] = True + COOLDOWN_UNTIL = None + ERROR_LOG.clear() + logger.info("Circuit breaker reset. Features re-enabled.") + + +def is_dead_hour() -> bool: + if not HOURLY_ACTIVITY: + return False + avg = sum(HOURLY_ACTIVITY.values()) / max(len(HOURLY_ACTIVITY), 1) + current = HOURLY_ACTIVITY.get(datetime.utcnow().hour, 0) + return current < (avg * 0.5) + + +CONFIG_SCHEMA = { + "revive_interval": int, + "motd_channel_id": (int, type(None)), + "ai_enabled": bool, + "revive_enabled": bool, +} + +AI_USAGE = { + "by_user": defaultdict(int), + "by_channel": defaultdict(int), + "tokens": 0, + "failures": 0, +} + + +def get_time_of_day() -> str: + hour = datetime.utcnow().hour + if 5 <= hour < 12: + return "morning" + if 12 <= hour < 18: + return "afternoon" + if 18 <= hour < 23: + return "evening" + return "late_night" + + +def get_season() -> str: + month = datetime.utcnow().month + if month in (12, 1, 2): + return "winter" + if month in (3, 4, 5): + return "spring" + if month in (6, 7, 8): + return "summer" + return "autumn" + + +REVIVE_STYLE_PROMPTS = { + "morning": "Friendly morning icebreaker that encourages people to start chatting.", + "afternoon": "Casual, light discussion starter to revive mid-day conversation.", + "evening": "Relaxed, social question suitable for evening community chat.", + "late_night": "Low-pressure, reflective question suitable for late-night lurkers." +} + +SEASONAL_PROMPTS = { + "winter": "Seasonal tone: cozy, reflective, or end-of-year energy.", + "spring": "Seasonal tone: fresh ideas, plans, and motivation.", + "summer": "Seasonal tone: relaxed, fun, or social energy.", + "autumn": "Seasonal tone: thoughtful, nostalgic, or analytical.", +} + + +def decay_topics(): + for k in list(TOPIC_COUNTER.keys()): + TOPIC_COUNTER[k] *= 0.9 + if TOPIC_COUNTER[k] < 1: + del TOPIC_COUNTER[k] + + +def prune_hourly_activity(): + now = datetime.utcnow().hour + for h in list(HOURLY_ACTIVITY.keys()): + if (now - h) % 24 > 6: + del HOURLY_ACTIVITY[h] def is_fabi(user: discord.User | discord.Member) -> bool: @@ -50,7 +179,7 @@ intents.guilds = True intents.reactions = True bot = commands.Bot(command_prefix="/", intents=intents) tree = bot.tree -from config import DB_FILE, recent_joins, recent_message_history, BANNED_WORDS, AI_PROHIBITED_PATTERNS +from config import DB_FILE, recent_joins, recent_message_history, BANNED_WORDS, AI_PROHIBITED_PATTERNS, CHANNEL_THEMES from tars import tars_text import random @@ -103,9 +232,10 @@ PROFANITY = [ ] -def is_inappropriate(text: str) -> bool: +async def is_inappropriate(text: str) -> bool: lowered = text.lower() - for w in PROFANITY + BANNED_WORDS: + banned = await get_config("banned_words", BANNED_WORDS) + for w in PROFANITY + banned: if re.search(rf"\b{re.escape(w)}\b", lowered): return True return False @@ -295,6 +425,9 @@ async def on_ready(): await init_db() logger.info(f"T.A.R.S. is online as {bot.user} (ID: {bot.user.id})") scheduler.start() + scheduler.add_job(check_circuit_recovery, "interval", minutes=1) + scheduler.add_job(decay_topics, "interval", hours=1) + scheduler.add_job(prune_hourly_activity, "interval", hours=1) scheduler.add_job( lambda: bot.loop.create_task(check_chat_revive()), "interval", @@ -339,7 +472,7 @@ async def on_member_update(before: discord.Member, after: discord.Member): f"You've earned **{points_awarded} Boost Points**." ) except Exception as e: - await on_error(e) + await handle_error(e) elif before.premium_since and not after.premium_since: await helper_moderation.send_mod_log( after.guild, @@ -365,7 +498,7 @@ async def on_member_update(before: discord.Member, after: discord.Member): f"Could not revert nickname for {after}: {e}", ping_staff=False ) - await on_error(e) + await handle_error(e) async def update_presence(): @@ -413,12 +546,12 @@ async def check_uptime_targets(): if ch: await ch.send(embed=tars_embed("Uptime Alert", f"{url} returned {resp.status}")) except Exception as e: - await on_error(e) + await handle_error(e) if notify_channel_id: ch = bot.get_channel(int(notify_channel_id)) if ch: await ch.send(embed=tars_embed("Uptime Alert", f"{url} is unreachable: {e}")) - await on_error(e) + await handle_error(e) async def add_boost_points(user_id: int, amount: int): @@ -485,8 +618,10 @@ BAD_NICK_PATTERN = re.compile(r"(nigg|fag|cum|sex)", re.IGNORECASE) async def tars_ai_respond(prompt: str, username: str, context: list[str] = None, - user: discord.User | None = None) -> str: + user: discord.User | None = None, channel_id: int | None = None) -> str: try: + if not FEATURE_FLAGS["ai_enabled"]: + return "Systems are stabilizing. Stand by." if is_ai_prompt_disallowed(prompt): return ( "I can’t help with repeating, explaining, or analyzing offensive language. " @@ -527,25 +662,26 @@ async def tars_ai_respond(prompt: str, username: str, context: list[str] = None, "Do not use, repeat, quote, translate, explain, define, analyze, or provide examples of profanity, slurs, hate speech, or explicit language, even if the user asks politely, academically, hypothetically, or includes the terms themselves. If such a request is made, decline and redirect immediately without referencing the language." f"{fabi_override}" ) - messages = [ {"role": "system", "content": system_prompt}, ] if context_text: messages.append({"role": "system", "content": context_text}) messages.append({"role": "user", "content": f"{username} says: {prompt}"}) - response = await openai_client.chat.completions.create( model="gpt-4o-mini", messages=messages, max_tokens=100, temperature=0.4 ) + AI_USAGE["by_user"][user.id if user else "unknown"] += 1 + AI_USAGE["by_channel"][channel_id] += 1 + AI_USAGE["tokens"] += response.usage.total_tokens return response.choices[0].message.content.strip() - except Exception as e: logger.exception(f"OpenAI request failed: {e}") - await on_error(e) + await handle_error(e) + AI_USAGE["failures"] += 1 return "Apologies, my humor subroutines are temporarily offline." @@ -564,7 +700,7 @@ async def on_raw_reaction_add(payload: discord.RawReactionActionEvent): await member.add_roles(role, reason="Reaction role added") except Exception as e: logger.exception(f"Could not add role from reaction: {e}") - await on_error(e) + await handle_error(e) @bot.event @@ -584,7 +720,7 @@ async def on_raw_reaction_remove(payload: discord.RawReactionActionEvent): await member.remove_roles(role, reason="Reaction role removed") except Exception as e: logger.exception(f"Could not remove role from reaction: {e}") - await on_error(e) + await handle_error(e) @bot.event @@ -592,6 +728,9 @@ async def on_message(message: discord.Message): global last_activity_time, revive_sent if message.author.bot: return + if message.guild is None or not isinstance(message.author, discord.Member): + await bot.process_commands(message) + return if message.channel.id == REVIVE_CHANNEL_ID: last_activity_time = datetime.utcnow() revive_sent = False @@ -603,6 +742,10 @@ async def on_message(message: discord.Message): recent_message_history[channel_id] = recent_message_history[channel_id][-10:] await bot.process_commands(message) await helper_moderation.handle_moderation(message) + words = re.findall(r"\b[a-zA-Z]{4,}\b", message.content.lower()) + for w in words: + TOPIC_COUNTER[w] += 1 + HOURLY_ACTIVITY[datetime.utcnow().hour] += 1 if bot.user in message.mentions: if not check_admin_or_role(message.author): await message.reply(tars_text("You need the Level 10 role to use me.", "error")) @@ -611,14 +754,24 @@ async def on_message(message: discord.Message): await message.reply( tars_text("You've reached your hourly message limit (10). Please wait before sending more.", "warning")) return + if not FEATURE_FLAGS["ai_enabled"]: + await message.reply( + tars_text("AI systems are temporarily offline for stability. Please try again later.", "warning") + ) + return record_message(message.author.id) async with message.channel.typing(): last_message = message.content.strip() context_messages = [ entry["content"] for entry in recent_message_history[channel_id][:-1] ][-5:] - reply = await tars_ai_respond(last_message, message.author.display_name, context_messages, - user=message.author) + reply = await tars_ai_respond( + last_message, + message.author.display_name, + context_messages, + user=message.author, + channel_id=message.channel.id, + ) link_count = len(re.findall(r'http?://\S+', reply)) if link_count > 0: logger.warning(f"Blocked AI response containing {link_count} links: {reply}") @@ -626,7 +779,7 @@ async def on_message(message: discord.Message): return safe_reply = sanitize_discord_mentions(reply) safe_reply = strip_links(safe_reply) - if is_inappropriate(safe_reply): + if await is_inappropriate(safe_reply): logger.warning(f"Blocked inappropriate response: {safe_reply}") await message.reply(tars_text("I can’t repeat that — let’s keep things respectful.")) else: @@ -747,7 +900,7 @@ async def slash_quote(interaction: discord.Interaction, message_link: str): await interaction.response.send_message(tars_text("Unable to find message or permission denied."), ephemeral=True) logger.exception(f"Quote error: {e}") - await on_error(e) + await handle_error(e) @tree.command(name="remindme", description="Set a reminder: e.g. /remindme 10m Check the logs") @@ -786,22 +939,26 @@ async def send_reminder(user_id, channel_id, text): await user.send(f"Reminder: {text}") except Exception as e: logger.info(f"Unable to send reminder to {user}: " + str(e)) - await on_error(e) + await handle_error(e) async def check_chat_revive(): global last_activity_time, revive_sent - channel = bot.get_channel(REVIVE_CHANNEL_ID) if not channel: logger.error("Revive channel not found.") return - + time_style = get_time_of_day() + season = get_season() + style_prompt = REVIVE_STYLE_PROMPTS.get(time_style, "") + season_prompt = SEASONAL_PROMPTS.get(season, "") + channel_theme = CHANNEL_THEMES.get(channel.id, "Open-ended discussion starter") role = channel.guild.get_role(REVIVE_ROLE_ID) if not role: logger.error("Revive role not found.") return - + if not FEATURE_FLAGS["revive_enabled"]: + return if last_activity_time is None: try: async for msg in channel.history(limit=1): @@ -810,19 +967,15 @@ async def check_chat_revive(): except Exception as e: logger.error(f"History read failed: {e}") return - if last_activity_time is None: last_activity_time = datetime.utcnow() - inactivity = (datetime.utcnow() - last_activity_time).total_seconds() - + dynamic_interval = REVIVE_INTERVAL * (0.5 if is_dead_hour() else 1.0) + if inactivity < dynamic_interval or revive_sent: + return logger.info( f"[Revive Check] inactivity={inactivity:.0f}s revive_sent={revive_sent}" ) - - if inactivity < REVIVE_INTERVAL or revive_sent: - return - recent_questions = await get_recent_revives() avoid_text = "" if recent_questions: @@ -831,11 +984,17 @@ async def check_chat_revive(): + "\n".join(f"- {q}" for q in recent_questions) ) prompt = ( - "Generate a short, friendly, thought-provoking conversation starter " - "for a casual Discord community. One sentence.\n\n" + f"{style_prompt}\n" + f"{season_prompt}\n" + f"Theme: {channel_theme}\n\n" + "Generate ONE single-sentence question.\n" + "No emojis. No lists. No meta commentary.\n" + "Be natural, engaging, and non-repetitive.\n\n" f"{avoid_text}" ) question = await tars_ai_respond(prompt, "T.A.R.S.") + global LAST_REVIVE_TIME + LAST_REVIVE_TIME = datetime.utcnow() await save_revive_question(question) await channel.send(f"{role.mention} — {question}") revive_sent = True @@ -860,7 +1019,7 @@ async def slash_reactionrole(interaction: discord.Interaction, message_id: str, await msg.add_reaction(emoji) except Exception as e: logger.info("Failed to add reaction: " + str(e)) - await on_error(e) + await handle_error(e) await interaction.response.send_message(tars_text("Reaction role configured."), ephemeral=True) @@ -902,16 +1061,16 @@ async def slash_getquote(interaction: discord.Interaction, qid: int): await interaction.response.send_message(embed=embed) -@bot.event -async def on_error(event_method, *args, **kwargs): - logger.exception("An error occurred", exc_info=True) +async def handle_error(e: Exception): + logger.exception("Unhandled exception", exc_info=e) + record_error() + check_circuit_recovery() owner = bot.get_user(OWNER_ID) if owner: try: - await owner.send("T.A.R.S. encountered an error. Check logs.") - except Exception as e: - logger.info(f"Failed to send error report to owner: {e}", exc_info=True) - await on_error(e) + await owner.send("T.A.R.S. encountered an error. Circuit breaker status updated.") + except Exception: + pass @tree.command(name="setmotd", description="Set Message of the Day list and channel (owner)") @@ -1182,41 +1341,81 @@ async def slash_boostpoints_remove(interaction: discord.Interaction, member: dis ) -@tree.command(name="revive", description="Admin revive controls") -@app_commands.describe(action="Use 'test' to force a revive message") -async def slash_revive(interaction: discord.Interaction, action: str): - if not interaction.user.guild_permissions.mention_everyone: - await interaction.response.send_message( - tars_text("Access denied — insufficient clearance.", "error"), - ephemeral=True - ) - return - if action.lower() != "test": - await interaction.response.send_message( - tars_text("Invalid action. Use `/revive test`.", "warning"), - ephemeral=True - ) - return - channel = bot.get_channel(REVIVE_CHANNEL_ID) - role = channel.guild.get_role(REVIVE_ROLE_ID) - recent_questions = await get_recent_revives() - avoid_text = "" - if recent_questions: - avoid_text = ( - "Avoid reusing or closely paraphrasing these recent questions:\n" - + "\n".join(f"- {q}" for q in recent_questions) - ) - - prompt = ( - "Generate a short, friendly, thought-provoking conversation starter " - "for a casual Discord community. One sentence.\n\n" - f"{avoid_text}" +@tree.command(name="status", description="Show T.A.R.S. system health") +async def slash_status(interaction: discord.Interaction): + uptime = datetime.utcnow() - BOT_START_TIME + latency_ms = round(bot.latency * 1000) + scheduler_running = scheduler.running + openai_ok = await check_openai_health() + db_ok = await check_db_health() + revive_time = ( + LAST_REVIVE_TIME.isoformat(timespec="seconds") + if LAST_REVIVE_TIME else "Never" ) - question = await tars_ai_respond(prompt, "T.A.R.S.") - await save_revive_question(question) - await channel.send(f"{role.mention} — {question}") + error_time = ( + LAST_ERROR_TIME.isoformat(timespec="seconds") + if LAST_ERROR_TIME else "None" + ) + embed = discord.Embed( + title="T.A.R.S. System Status", + color=0x00ffcc + ) + embed.add_field(name="Uptime", value=str(uptime).split(".")[0], inline=False) + embed.add_field(name="WebSocket Latency", value=f"{latency_ms} ms", inline=True) + embed.add_field(name="Scheduler Running", value=str(scheduler_running), inline=True) + embed.add_field( + name="OpenAI API", + value="Operational" if openai_ok else "Unavailable", + inline=True + ) + embed.add_field( + name="Database", + value="Operational" if db_ok else "Unavailable", + inline=True + ) + embed.add_field(name="AI Enabled", value=str(FEATURE_FLAGS["ai_enabled"]), inline=True) + embed.add_field(name="Revive Enabled", value=str(FEATURE_FLAGS["revive_enabled"]), inline=True) + embed.add_field(name="Last Revive Sent", value=revive_time, inline=False) + embed.add_field(name="Last Error", value=error_time, inline=False) + embed.set_footer(text="— T.A.R.S. Diagnostics") + await interaction.response.send_message(embed=embed, ephemeral=True) + + +@tree.command(name="config_view", description="View live configuration (owner)") +async def slash_config_view(interaction: discord.Interaction): + if interaction.user.id != OWNER_ID: + await interaction.response.send_message(tars_text("Owner only."), ephemeral=True) + return + rows = [] + async with aiosqlite.connect(DB_FILE) as db: + cur = await db.execute("SELECT key, value FROM config") + rows = await cur.fetchall() + text = "\n".join(f"• {k}: {v}" for k, v in rows) or "No config set." await interaction.response.send_message( - tars_text("Revive test executed successfully.", "success"), + embed=tars_embed("Live Configuration", text), + ephemeral=True + ) + + +@tree.command(name="ai_stats", description="View AI usage metrics (moderator)") +async def slash_ai_stats(interaction: discord.Interaction): + if not interaction.user.guild_permissions.manage_guild: + await interaction.response.send_message(tars_text("Insufficient clearance."), ephemeral=True) + return + top_users = sorted( + AI_USAGE["by_user"].items(), + key=lambda x: x[1], + reverse=True + )[:5] + lines = [ + f"Total Tokens Used: {AI_USAGE['tokens']}", + f"Failures: {AI_USAGE['failures']}", + "", + "**Top Users:**" + ] + lines.extend(f"- {uid}: {count}" for uid, count in top_users) + await interaction.response.send_message( + embed=tars_embed("AI Usage Metrics", "\n".join(lines)), ephemeral=True )