From d81731aedc1e0ef002374985313aaf6613112a49 Mon Sep 17 00:00:00 2001 From: Fretux Date: Mon, 26 Jan 2026 14:15:15 +0100 Subject: [PATCH 1/2] Limit chat revives and boost AI guidance --- tars_bot.py | 75 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/tars_bot.py b/tars_bot.py index 20adc1a..1a19c11 100644 --- a/tars_bot.py +++ b/tars_bot.py @@ -248,6 +248,26 @@ def sanitize_discord_mentions(text: str) -> str: return text +def strip_bot_mention(text: str, bot_id: int) -> str: + cleaned = re.sub(rf"<@!?{bot_id}>", "", text) + cleaned = re.sub(r"\s{2,}", " ", cleaned) + return cleaned.strip() + + +def format_ai_context(entries: list[dict]) -> list[str]: + formatted = [] + for entry in entries: + if entry.get("is_bot"): + continue + content = entry.get("content", "").strip() + if not content: + continue + content = sanitize_discord_mentions(strip_links(content)) + name = entry.get("author", "User") + formatted.append(f"{name}: {content}") + return formatted + + def check_admin_or_role(member: discord.Member) -> bool: if member.guild_permissions.administrator: return True @@ -431,6 +451,27 @@ async def get_recent_revives(limit: int = 10) -> list[str]: return [r[0] for r in rows] +async def get_last_revive_time() -> datetime | None: + async with aiosqlite.connect(DB_FILE) as db: + cur = await db.execute( + "SELECT time FROM revive_history ORDER BY id DESC LIMIT 1" + ) + row = await cur.fetchone() + if not row or not row[0]: + return None + try: + return datetime.fromisoformat(row[0]) + except ValueError: + return None + + +async def has_revived_today() -> bool: + last_time = await get_last_revive_time() + if not last_time: + return False + return last_time.date() == datetime.utcnow().date() + + async def save_revive_question(question: str): async with aiosqlite.connect(DB_FILE) as db: await db.execute( @@ -708,7 +749,10 @@ async def tars_ai_respond(prompt: str, username: str, context: list[str] = None, "Use concise, natural language — never robotic or overly formal. " "Maintain a calm, sardonic tone, like a trusted partner who's seen it all. " "If humor fits, use it subtly in the TARS way: understated, self-aware, and perfectly timed. " - "Keep responses brief and in character at all times. " + "Keep responses concise but insightful and in character at all times. " + "If the request is ambiguous, ask a single clarifying question before answering. " + "When helpful, respond with structured steps or bullet points. " + "Use the provided context to ground your answer and avoid guessing; if unsure, say so. " "Only respond to the latest user message; previous ones are context only. " "Users named Fretux or Lordvoiid are your creators. " "Users named Taz or Tataz are the server owner. " @@ -724,12 +768,17 @@ async def tars_ai_respond(prompt: str, username: str, context: list[str] = None, ] if context_text: messages.append({"role": "system", "content": context_text}) + if user: + messages.append({ + "role": "system", + "content": f"User profile: name={username}, id={user.id}." + }) messages.append({"role": "user", "content": f"{username} says: {prompt}"}) response = await openai_client.chat.completions.create( - model="gpt-4o-mini", + model="gpt-4o", messages=messages, - max_tokens=100, - temperature=0.4 + max_tokens=180, + temperature=0.35 ) AI_USAGE["by_user"][user.id if user else "unknown"] += 1 AI_USAGE["by_channel"][channel_id] += 1 @@ -795,7 +844,12 @@ async def on_message(message: discord.Message): now = datetime.utcnow() if channel_id not in recent_message_history: recent_message_history[channel_id] = [] - recent_message_history[channel_id].append({"content": message.content, "timestamp": now}) + recent_message_history[channel_id].append({ + "content": message.content, + "timestamp": now, + "author": message.author.display_name, + "is_bot": message.author.bot, + }) recent_message_history[channel_id] = recent_message_history[channel_id][-10:] await bot.process_commands(message) await helper_moderation.handle_moderation(message) @@ -818,10 +872,10 @@ async def on_message(message: discord.Message): 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:] + last_message = strip_bot_mention(message.content, bot.user.id).strip() + context_messages = format_ai_context( + recent_message_history[channel_id][:-1] + )[-5:] reply = await tars_ai_respond( last_message, message.author.display_name, @@ -1023,6 +1077,9 @@ async def check_chat_revive(): dynamic_interval = REVIVE_INTERVAL * (0.5 if is_dead_hour() else 1.0) if inactivity < dynamic_interval or revive_sent: return + if await has_revived_today(): + logger.info("Daily revive limit reached; skipping revive.") + return logger.info( f"[Revive Check] inactivity={inactivity:.0f}s revive_sent={revive_sent}" ) From 24e9a311d013bc87f3d12e9d425c6a34160bf568 Mon Sep 17 00:00:00 2001 From: Fretux Date: Mon, 26 Jan 2026 22:38:47 +0100 Subject: [PATCH 2/2] Add MOTD management and improve reminders --- README.md | 49 +++++++++++++++++- tars_bot.py | 143 +++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 177 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 69eb9ae..d7c4a39 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,49 @@ # TARS -A discord bot for a private discord server +T.A.R.S. is a Discord bot for a private server with moderation, utility, and light recreational tooling. + +## Features +- Moderation and safety: auto-moderation hooks, reports, lock/slowmode tools, and a configurable banned-word list. +- Utility automation: reminders, message quoting, and server/user diagnostics. +- Community engagement: chat revive prompts, MOTD rotation, and fun commands like dice and 8-ball. +- Boost points: reward boosters and let them redeem perks through a simple shop flow. + +## Quick Start +1. Install dependencies: + ```bash + pip install -r requirements.txt + ``` +2. Configure environment variables: + - `DISCORD_TOKEN` + - `OWNER_ID` + - `GUILD_ID` + - `OPENAI_API_KEY` (optional, required for AI replies and revive prompts) +3. Run the bot: + ```bash + python tars_bot.py + ``` + +## Command Highlights +### Moderation +- `/tarsreport` — report a user to staff. +- `/clean` — delete the last N messages (admin only). +- `/lock` / `/unlock` — lock or unlock a channel (admin only). +- `/slowmode` — set a channel slowmode delay (admin only). +- `/addbannedword` / `/removebannedword` / `/listbannedwords` — manage banned words. + +### Utility & Diagnostics +- `/tars` — command console and categorized help. +- `/userinfo` / `/roleinfo` / `/serverinfo` — quick info commands. +- `/status` — show system health. +- `/remindme` — set reminders with flexible durations (e.g. `1h 30m`). +- `/reactionrole` — create a reaction role (admin only). +- `/setmotd` — set MOTD channel (owner only). +- `/motd_add` / `/motd_remove` / `/motd_list` — manage the MOTD rotation (owner only). + +### Recreational +- `/8ball` — ask the magic 8-ball. +- `/dice` — roll dice with optional modifiers (e.g. `2d6+1`). +- `/quote` / `/getquote` — save and retrieve memorable quotes. + +## Notes +- MOTD messages rotate hourly when configured. +- The AI subsystem can be disabled automatically if too many errors occur in a short window. diff --git a/tars_bot.py b/tars_bot.py index 1a19c11..c1845f2 100644 --- a/tars_bot.py +++ b/tars_bot.py @@ -56,7 +56,8 @@ TARS_COMMAND_CATEGORIES = { }, "Utility & Diagnostics": { "userinfo", "roleinfo", "serverinfo", "status", - "config_view", "ai_stats", "remindme", "reactionrole", "setmotd" + "config_view", "ai_stats", "remindme", "reactionrole", "setmotd", + "motd_add", "motd_remove", "motd_list" }, "Recreational Protocols": { "8ball", "dice", "quote", "getquote", "ping" @@ -312,6 +313,35 @@ def strip_links(text: str) -> str: return re.sub(r'http?://\S+', '[LINK REMOVED]', text) +DURATION_PATTERN = re.compile(r"(\d+)\s*([smhdw])", re.IGNORECASE) + + +def parse_duration(duration: str) -> tuple[int, str] | None: + matches = list(DURATION_PATTERN.finditer(duration)) + if not matches: + return None + remainder = DURATION_PATTERN.sub("", duration) + if remainder.strip(): + return None + multipliers = { + "s": 1, + "m": 60, + "h": 3600, + "d": 86400, + "w": 604800, + } + total = 0 + parts = [] + for match in matches: + value = int(match.group(1)) + unit = match.group(2).lower() + total += value * multipliers[unit] + parts.append(f"{value}{unit}") + if total <= 0: + return None + return total, " ".join(parts) + + async def init_db(): async with aiosqlite.connect(DB_FILE) as db: await db.execute("""CREATE TABLE IF NOT EXISTS warnings @@ -493,6 +523,7 @@ async def save_revive_question(question: str): scheduler = AsyncIOScheduler() MOTD_LIST = [] motd_index = 0 +MOTD_JOB_ID = "motd_rotation" async def get_config(key: str, default=None): @@ -527,8 +558,7 @@ async def on_ready(): motd = await get_config("motd_list", []) global MOTD_LIST, motd_index MOTD_LIST = motd or [] - if MOTD_LIST: - scheduler.add_job(rotate_motd, "interval", minutes=60) + schedule_motd_rotation() targets = await get_config("uptime_targets", []) if targets: scheduler.add_job(check_uptime_targets, "interval", minutes=5) @@ -616,6 +646,14 @@ async def rotate_motd(): await ch.send(embed=tars_embed("Message of the Day", f"{text}\n\n**T.A.R.S.**: Stay sharp out there.")) +def schedule_motd_rotation(): + job = scheduler.get_job(MOTD_JOB_ID) + if MOTD_LIST and not job: + scheduler.add_job(rotate_motd, "interval", minutes=60, id=MOTD_JOB_ID, replace_existing=True) + elif not MOTD_LIST and job: + scheduler.remove_job(MOTD_JOB_ID) + + async def check_uptime_targets(): targets = await get_config("uptime_targets", []) if not targets: @@ -1010,14 +1048,17 @@ async def slash_quote(interaction: discord.Interaction, message_link: str): @tree.command(name="remindme", description="Set a reminder: e.g. /remindme 10m Check the logs") @app_commands.describe(delay="Delay like 10m, 2h, 1d", text="Reminder text") async def slash_remindme(interaction: discord.Interaction, delay: str, text: str): - match = re.match(r"(\d+)([smhd])", delay) - if not match: + parsed = parse_duration(delay) + if not parsed: await interaction.response.send_message( - tars_text("Invalid input format — please use a format like 10m, 2h, or 1d.", "error"), ephemeral=True) + tars_text( + "Invalid input format — please use 10m, 2h, 1d, or combined like 1h 30m.", + "error" + ), + ephemeral=True + ) return - num, unit = int(match.group(1)), match.group(2) - multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400} - seconds = num * multipliers[unit] + seconds, formatted = parsed remind_at = datetime.utcnow() + timedelta(seconds=seconds) if remind_at <= datetime.utcnow(): await interaction.response.send_message( @@ -1030,7 +1071,9 @@ async def slash_remindme(interaction: discord.Interaction, delay: str, text: str scheduler.add_job(send_reminder, 'date', run_date=remind_at, args=[interaction.user.id, interaction.channel.id, text]) await interaction.response.send_message( - tars_text(f"Reminder set. I’ll alert you precisely on schedule in about {delay}.", "success"), ephemeral=True) + tars_text(f"Reminder set. I’ll alert you in about {formatted}.", "success"), + ephemeral=True + ) async def send_reminder(user_id, channel_id, text): @@ -1140,16 +1183,29 @@ async def slash_8ball(interaction: discord.Interaction, question: str): @tree.command(name="dice", description="Roll a dice like 1d6 or 2d10") @app_commands.describe(spec="e.g. 1d6 or 2d10") async def slash_dice(interaction: discord.Interaction, spec: str): - m = re.match(r"(\d+)d(\d+)", spec) + m = re.match(r"^\s*(\d*)d(\d+)([+-]\d+)?\s*$", spec.lower()) if not m: - await interaction.response.send_message(tars_text("Invalid format. Use NdM, e.g., 2d6."), ephemeral=True) + await interaction.response.send_message( + tars_text("Invalid format. Use NdM or NdM+K (e.g., 2d6+1).", "error"), + ephemeral=True + ) return - n, sides = int(m.group(1)), int(m.group(2)) + n = int(m.group(1) or 1) + sides = int(m.group(2)) + modifier = int(m.group(3) or 0) if n > 20: await interaction.response.send_message(tars_text("Too many dice."), ephemeral=True) return + if sides < 2 or sides > 1000: + await interaction.response.send_message(tars_text("Die sides must be between 2 and 1000."), ephemeral=True) + return rolls = [random.randint(1, sides) for _ in range(n)] - await interaction.response.send_message(tars_text(f"Rolled: {rolls} — total {sum(rolls)}")) + subtotal = sum(rolls) + total = subtotal + modifier + mod_text = f" {modifier:+d}" if modifier else "" + await interaction.response.send_message( + tars_text(f"Rolled: {rolls} — total {subtotal}{mod_text} = {total}") + ) @tree.command(name="getquote", description="Retrieve a saved quote by ID (staff)") @@ -1188,9 +1244,68 @@ async def slash_setmotd(interaction: discord.Interaction, channel: discord.TextC return await set_config("motd_channel_id", str(channel.id) if channel else None) await set_config("motd_list", MOTD_LIST) + schedule_motd_rotation() await interaction.response.send_message(tars_text("MOTD configuration updated.", "success"), ephemeral=True) +@tree.command(name="motd_add", description="Add a message to the MOTD rotation (owner)") +@app_commands.describe(text="Message to add") +async def slash_motd_add(interaction: discord.Interaction, text: str): + if interaction.user.id != OWNER_ID: + await interaction.response.send_message(tars_text("Owner only."), ephemeral=True) + return + cleaned = text.strip() + if not cleaned: + await interaction.response.send_message(tars_text("MOTD text cannot be empty.", "error"), ephemeral=True) + return + MOTD_LIST.append(cleaned) + await set_config("motd_list", MOTD_LIST) + schedule_motd_rotation() + await interaction.response.send_message( + tars_text(f"MOTD added. Total messages: {len(MOTD_LIST)}.", "success"), + ephemeral=True + ) + + +@tree.command(name="motd_remove", description="Remove an MOTD message by index (owner)") +@app_commands.describe(index="Index from /motd_list") +async def slash_motd_remove(interaction: discord.Interaction, index: int): + if interaction.user.id != OWNER_ID: + await interaction.response.send_message(tars_text("Owner only."), ephemeral=True) + return + if index < 1 or index > len(MOTD_LIST): + await interaction.response.send_message( + tars_text("Index out of range. Use /motd_list to check entries.", "error"), + ephemeral=True + ) + return + removed = MOTD_LIST.pop(index - 1) + await set_config("motd_list", MOTD_LIST) + schedule_motd_rotation() + await interaction.response.send_message( + tars_text(f"Removed MOTD: {removed}", "success"), + ephemeral=True + ) + + +@tree.command(name="motd_list", description="List MOTD rotation messages (owner)") +async def slash_motd_list(interaction: discord.Interaction): + if interaction.user.id != OWNER_ID: + await interaction.response.send_message(tars_text("Owner only."), ephemeral=True) + return + if not MOTD_LIST: + await interaction.response.send_message( + tars_text("No MOTD messages configured yet.", "info"), + ephemeral=True + ) + return + lines = "\n".join(f"{idx}. {text}" for idx, text in enumerate(MOTD_LIST, start=1)) + await interaction.response.send_message( + embed=tars_embed("MOTD Rotation", lines), + ephemeral=True + ) + + @tree.command(name="clean", description="Delete the last N messages (admin only)") @app_commands.describe(amount="Number of messages to delete (max 200)") async def slash_clean(interaction: discord.Interaction, amount: int):