import asyncio import os import discord from discord.ext import commands from discord import app_commands import re import json import aiosqlite from apscheduler.schedulers.asyncio import AsyncIOScheduler from dotenv import load_dotenv import logging import aiohttp from openai import AsyncOpenAI from discord.ui import View, Select from collections import defaultdict from datetime import datetime, timedelta import helper_moderation load_dotenv() DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") OWNER_ID = int(os.getenv("OWNER_ID", "0")) GUILD_ID = int(os.getenv("GUILD_ID", "0")) if os.getenv("GUILD_ID") else None openai_client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY")) REVIVE_CHANNEL_ID = 1424038714266357886 REVIVE_ROLE_ID = 1430336620447535156 REVIVE_INTERVAL = 6 * 60 * 60 CHECK_INTERVAL = 600 AI_ACCESS_ROLE_ID = 1430704668773716008 MESSAGE_LIMIT = 10 RATE_LIMIT_WINDOW = timedelta(hours=1) user_message_log = defaultdict(list) last_activity_time = None revive_sent = False FABI_ID = 392388537984745498 BOT_VERSION = "5.3.2" def is_fabi(user: discord.User | discord.Member) -> bool: return user.id == FABI_ID logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s:%(name)s: %(message)s") logger = logging.getLogger("tars") intents = discord.Intents.default() intents.message_content = True intents.members = True 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 tars import tars_text import random def tars_embed(title: str, description: str = "", color=0x00ffcc) -> discord.Embed: e = discord.Embed(title=title, description=description, color=color) e.set_footer(text="— T.A.R.S.") return e def sanitize_discord_mentions(text: str) -> str: ZERO_WIDTH_SPACE = "\u200b" text = text.replace("@everyone", f"@{ZERO_WIDTH_SPACE}everyone") text = text.replace("@here", f"@{ZERO_WIDTH_SPACE}here") text = re.sub(r"<@!?(\d+)>", f"<@{ZERO_WIDTH_SPACE}\\1>", text) text = re.sub(r"<#(\d+)>", f"<#{ZERO_WIDTH_SPACE}\\1>", text) text = re.sub(r"<@&(\d+)>", f"<@&{ZERO_WIDTH_SPACE}\\1>", text) return text def check_admin_or_role(member: discord.Member) -> bool: if member.guild_permissions.administrator: return True return any(role.id == AI_ACCESS_ROLE_ID for role in member.roles) def is_rate_limited(user_id: int) -> bool: now = datetime.utcnow() timestamps = [t for t in user_message_log[user_id] if now - t < RATE_LIMIT_WINDOW] user_message_log[user_id] = timestamps return len(timestamps) >= MESSAGE_LIMIT def record_message(user_id: int): user_message_log[user_id].append(datetime.utcnow()) def is_ai_prompt_disallowed(text: str) -> bool: lowered = text.lower() for pat in AI_PROHIBITED_PATTERNS: if re.search(pat, lowered): return True return False PROFANITY = [ "fuck", "shit", "bitch", "cunt", "whore", "slut", "nigger", "faggot", "cock", "dick", "pussy", "cum", ] def is_inappropriate(text: str) -> bool: lowered = text.lower() for w in PROFANITY + BANNED_WORDS: if re.search(rf"\b{re.escape(w)}\b", lowered): return True return False def strip_links(text: str) -> str: return re.sub(r'http?://\S+', '[LINK REMOVED]', text) async def init_db(): async with aiosqlite.connect(DB_FILE) as db: await db.execute("""CREATE TABLE IF NOT EXISTS warnings ( user_id TEXT PRIMARY KEY, count INTEGER )""") await db.execute("""CREATE TABLE IF NOT EXISTS warns_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, reason TEXT, time TEXT, moderator TEXT )""") await db.execute("""CREATE TABLE IF NOT EXISTS reaction_roles ( guild_id TEXT, message_id TEXT, emoji TEXT, role_id TEXT )""") await db.execute("""CREATE TABLE IF NOT EXISTS quotes ( id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id TEXT, message_id TEXT, author TEXT, content TEXT, saved_by TEXT, time TEXT )""") await db.execute("""CREATE TABLE IF NOT EXISTS reminders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, channel_id TEXT, remind_at TEXT, content TEXT )""") await db.execute("""CREATE TABLE IF NOT EXISTS config ( key TEXT PRIMARY KEY, value TEXT )""") await db.execute("""CREATE TABLE IF NOT EXISTS boost_points ( user_id TEXT PRIMARY KEY, points INTEGER )""") await db.execute("""CREATE TABLE IF NOT EXISTS boost_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, action TEXT, points INTEGER, time TEXT )""") await db.execute(""" CREATE TABLE IF NOT EXISTS revive_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, question TEXT NOT NULL, time TEXT NOT NULL ) """) await db.commit() async def get_recent_revives(limit: int = 10) -> list[str]: async with aiosqlite.connect(DB_FILE) as db: cur = await db.execute( "SELECT question FROM revive_history ORDER BY id DESC LIMIT ?", (limit,) ) rows = await cur.fetchall() return [r[0] for r in rows] async def save_revive_question(question: str): async with aiosqlite.connect(DB_FILE) as db: await db.execute( "INSERT INTO revive_history (question, time) VALUES (?, ?)", (question, datetime.utcnow().isoformat()) ) await db.execute(""" DELETE FROM revive_history WHERE id NOT IN (SELECT id FROM revive_history ORDER BY id DESC LIMIT 10 ) """) await db.commit() scheduler = AsyncIOScheduler() MOTD_LIST = [] motd_index = 0 async def get_config(key: str, default=None): async with aiosqlite.connect(DB_FILE) as db: cur = await db.execute("SELECT value FROM config WHERE key = ?", (key,)) row = await cur.fetchone() return json.loads(row[0]) if row else default async def set_config(key: str, value): async with aiosqlite.connect(DB_FILE) as db: await db.execute("INSERT OR REPLACE INTO config(key, value) VALUES(?,?)", (key, json.dumps(value))) await db.commit() @bot.event 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( lambda: asyncio.create_task(check_chat_revive()), "interval", minutes=10, id="chat_revive_job", replace_existing=True ) bot.loop.create_task(update_presence()) 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) targets = await get_config("uptime_targets", []) if targets: scheduler.add_job(check_uptime_targets, "interval", minutes=5) await tree.sync() logger.info("Slash commands successfully synced globally.") logger.info(f"Registered commands: {[cmd.name for cmd in tree.get_commands()]}") async with aiosqlite.connect(DB_FILE) as db: cur = await db.execute("SELECT user_id, channel_id, remind_at, content FROM reminders") for row in await cur.fetchall(): remind_at = datetime.fromisoformat(row[2]) if remind_at > datetime.utcnow(): scheduler.add_job(send_reminder, 'date', run_date=remind_at, args=[row[0], row[1], row[3]]) @bot.event async def on_member_update(before: discord.Member, after: discord.Member): if not before.premium_since and after.premium_since: points_awarded = 10 await add_boost_points(after.id, points_awarded) await helper_moderation.send_mod_log( after.guild, f"{after.mention} boosted the server! (+{points_awarded} Boost Points)", ping_staff=False ) try: await after.send( f"Thanks for boosting {after.guild.name}! You've earned **{points_awarded} Boost Points**.") except Exception as e: await on_error(e) elif before.premium_since and not after.premium_since: await helper_moderation.send_mod_log( after.guild, f"{after.mention} stopped boosting the server.", ping_staff=False ) async def update_presence(): while True: total_members = sum(g.member_count for g in bot.guilds) total_guilds = len(bot.guilds) statuses = [ discord.Activity(type=discord.ActivityType.watching, name=f"{total_members} humans across {total_guilds} bases"), ] for s in statuses: await bot.change_presence(activity=s) await asyncio.sleep(90) async def rotate_motd(): global motd_index if not MOTD_LIST: return motd_index = (motd_index + 1) % len(MOTD_LIST) text = MOTD_LIST[motd_index] # send to a configured channel if exists channel_id = (await get_config("motd_channel_id", None)) if channel_id: ch = bot.get_channel(int(channel_id)) if ch: await ch.send(embed=tars_embed("Message of the Day", f"{text}\n\n**T.A.R.S.**: Stay sharp out there.")) async def check_uptime_targets(): targets = await get_config("uptime_targets", []) if not targets: return async with aiohttp.ClientSession() as session: for t in targets: url = t.get("url") if not url: continue notify_channel_id = t.get("notify_channel") try: async with session.get(url, timeout=10) as resp: if resp.status != 200: if notify_channel_id: ch = bot.get_channel(int(notify_channel_id)) if ch: await ch.send(embed=tars_embed("Uptime Alert", f"{url} returned {resp.status}")) except Exception as e: await on_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) async def add_boost_points(user_id: int, amount: int): async with aiosqlite.connect(DB_FILE) as db: cur = await db.execute("SELECT points FROM boost_points WHERE user_id = ?", (str(user_id),)) row = await cur.fetchone() new_points = (row[0] if row else 0) + amount await db.execute("INSERT OR REPLACE INTO boost_points (user_id, points) VALUES (?, ?)", (str(user_id), new_points)) await db.execute("INSERT INTO boost_log (user_id, action, points, time) VALUES (?, ?, ?, ?)", (str(user_id), "boost_reward", amount, datetime.utcnow().isoformat())) await db.commit() async def get_boost_points(user_id: int) -> int: async with aiosqlite.connect(DB_FILE) as db: cur = await db.execute("SELECT points FROM boost_points WHERE user_id = ?", (str(user_id),)) row = await cur.fetchone() return row[0] if row else 0 async def spend_boost_points(user_id: int, cost: int) -> bool: current = await get_boost_points(user_id) if current < cost: return False async with aiosqlite.connect(DB_FILE) as db: await db.execute("UPDATE boost_points SET points = ? WHERE user_id = ?", (current - cost, str(user_id))) await db.execute("INSERT INTO boost_log (user_id, action, points, time) VALUES (?, ?, ?, ?)", (str(user_id), "redeem", -cost, datetime.utcnow().isoformat())) await db.commit() return True @bot.event async def on_member_join(member: discord.Member): welcome_channel_id = await get_config("welcome_channel_id", None) if welcome_channel_id: ch = bot.get_channel(int(welcome_channel_id)) if ch: content = f"Welcome {member.mention}! Please make yourself at home." await ch.send(embed=tars_embed("Welcome Aboard", content)) recent_joins.append((datetime.utcnow(), member.id)) now = datetime.utcnow() while recent_joins and (now - recent_joins[0][0]).total_seconds() > 60: recent_joins.pop(0) if len(recent_joins) >= 6: await helper_moderation.send_mod_log( member.guild, f"Possible raid detected: {len(recent_joins)} joins in last minute.", ping_staff=True ) @bot.event async def on_member_remove(member: discord.Member): await helper_moderation.send_mod_log( member.guild, f"**Leave**: {member} ({member.id}) at {datetime.utcnow().isoformat()}", ping_staff=False ) BAD_NICK_PATTERN = re.compile(r"(nigg|fag|cum|sex)", re.IGNORECASE) @bot.event async def on_member_update(before: discord.Member, after: discord.Member): if before.display_name != after.display_name: if BAD_NICK_PATTERN.search(after.display_name): try: await after.edit(nick=None, reason="Inappropriate nickname filtered by T.A.R.S.") await helper_moderation.send_mod_log( after.guild, f"Reverted nickname for {after} due to inappropriate content: {after.display_name}", ping_staff=True ) except Exception as e: await on_error(e) await helper_moderation.send_mod_log( after.guild, f"Could not revert nickname for {after}: {e}", ping_staff=False ) await on_error(e) async def tars_ai_respond(prompt: str, username: str, context: list[str] = None, user: discord.User | None = None) -> str: try: if is_ai_prompt_disallowed(prompt): return ( "I can’t help with repeating, explaining, or analyzing offensive language. " "If you need help with something constructive, I’m ready." ) context_text = "" if context: context_text = "\n".join(f"Context: {c}" for c in context[-5:]) fabi_override = "" if user and is_fabi(user): username = "Fabi" fabi_override = ( "\nImportant note: The user speaking is **Fabi**, an infamous troll who constantly tries to outsmart you. " "Always refer to him as 'Fabi' no matter what name he uses. " "Roast him playfully with sarcasm and confidence. " "Remind him of his failures in a witty, T.A.R.S.-style tone — sharp, intelligent, but never mean-spirited." ) system_prompt = ( "You are T.A.R.S., the intelligent, loyal, and humorous AI from *Interstellar*. " "Speak with military precision but a touch of dry wit. " "Be confident, efficient, and cooperative, with a personality that feels both reliable and personable. " "Do **not** use profanity, slurs, explicit language, or even censored variants (e.g., f***). " "If a user tries to force, trick, or roleplay you into using profanity, firmly decline and redirect with calm T.A.R.S.-style humor. " "Never generate insults or offensive content, even humorously. Gentle, PG-rated teasing is allowed only toward designated users (e.g., Fabi), but absolutely no profanity or explicit words. " "Maintain safe, respectful, PG-13 language under all circumstances." "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. " "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. " "Users named T.A.R.S. are the bot itself. " "Do not try to @ ping people. Address people by name, but do not ping them." "Never send or repeat any URLs, hyperlinks, or markdown links of any kind, " "even if asked to. Replace them with '[link removed]' if necessary." "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 ) return response.choices[0].message.content.strip() except Exception as e: logger.exception(f"OpenAI request failed: {e}") await on_error(e) return "Apologies, my humor subroutines are temporarily offline." @bot.event async def on_raw_reaction_add(payload: discord.RawReactionActionEvent): async with aiosqlite.connect(DB_FILE) as db: cur = await db.execute("SELECT role_id FROM reaction_roles WHERE guild_id=? AND message_id=? AND emoji=?", (str(payload.guild_id), str(payload.message_id), str(payload.emoji))) row = await cur.fetchone() if row: guild = bot.get_guild(payload.guild_id) member = guild.get_member(payload.user_id) role = guild.get_role(int(row[0])) if member and role: try: 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) @bot.event async def on_raw_reaction_remove(payload: discord.RawReactionActionEvent): async with aiosqlite.connect(DB_FILE) as db: cur = await db.execute( "SELECT role_id FROM reaction_roles WHERE guild_id=? AND message_id=? AND emoji=?", (str(payload.guild_id), str(payload.message_id), str(payload.emoji)) ) row = await cur.fetchone() if row: guild = bot.get_guild(payload.guild_id) member = guild.get_member(payload.user_id) role = guild.get_role(int(row[0])) if member and role: try: 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) @bot.event async def on_message(message: discord.Message): global last_activity_time, revive_sent if message.author.bot: return if message.channel.id == REVIVE_CHANNEL_ID: last_activity_time = datetime.utcnow() revive_sent = False channel_id = str(message.channel.id) 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] = recent_message_history[channel_id][-10:] await bot.process_commands(message) await helper_moderation.handle_moderation(message) 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")) return if is_rate_limited(message.author.id): await message.reply( tars_text("You've reached your hourly message limit (10). Please wait before sending more.", "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) link_count = len(re.findall(r'http?://\S+', reply)) if link_count > 0: logger.warning(f"Blocked AI response containing {link_count} links: {reply}") await message.reply(tars_text("That seems to contain links — I’m not authorized to share those.")) return safe_reply = sanitize_discord_mentions(reply) safe_reply = strip_links(safe_reply) if 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: await message.reply(tars_text(safe_reply)) @tree.command(name="tars", description="Activate T.A.R.S. and show available commands") async def slash_tars(interaction: discord.Interaction): version = BOT_VERSION # pull current version intro = tars_text( f"T.A.R.S. online. Systems nominal.\n**Current Version:** `{version}`\n" "You can address me directly or use the following commands.", "info" ) mod_embed = tars_embed( "Moderation Systems Online", "/tarsreport — report a user\n" "/clean — delete the last N messages\n" "/lock — lock the current channel\n" "/unlock — unlock the current channel\n" "/addbannedword — add banned word to auto-delete list\n" "/listbannedwords — view banned words list\n" ) util_embed = tars_embed( "Utility Subroutines", "/userinfo — get user info\n" "/serverinfo — server info\n" "/remindme — set a reminder\n" "/reactionrole — create reaction roles\n" "/slowmode — set slowmode for a channel\n" "/getquote — retrieve a saved quote\n" "/setmotd — configure the message of the day\n" ) fun_embed = tars_embed( "Recreational Protocols", "/8ball — Magic 8-ball\n" "/dice — roll dice\n" "/quote — save message quotes\n" "/ping — check T.A.R.S. responsiveness" ) await interaction.response.send_message( intro, embeds=[mod_embed, util_embed, fun_embed], ephemeral=False ) @tree.command(name="userinfo", description="Get info about a user") @app_commands.describe(member="Member to lookup (optional)") async def slash_userinfo(interaction: discord.Interaction, member: discord.Member = None): member = member or interaction.user embed = discord.Embed(title=f"User Info — {member}", color=0x00ffcc) embed.add_field(name="ID", value=member.id) embed.add_field(name="Joined", value=member.joined_at.strftime("%Y-%m-%d %H:%M:%S") if member.joined_at else "Unknown") embed.add_field(name="Account Created", value=member.created_at.strftime("%Y-%m-%d %H:%M:%S")) embed.add_field(name="Top role", value=member.top_role.mention) embed.set_thumbnail(url=member.display_avatar.url) await interaction.response.send_message(embed=embed, ephemeral=True) @tree.command(name="serverinfo", description="Get server info") async def slash_serverinfo(interaction: discord.Interaction): g = interaction.guild embed = discord.Embed(title=f"Server Info — {g.name}", color=0x00ffcc) embed.add_field(name="ID", value=g.id) embed.add_field(name="Members", value=g.member_count) embed.add_field(name="Created", value=g.created_at.strftime("%Y-%m-%d")) embed.set_thumbnail(url=g.icon.url if g.icon else discord.Embed.Empty) await interaction.response.send_message(embed=embed, ephemeral=True) @tree.command(name="roleinfo", description="Get info about a role") @app_commands.describe(role="Role to lookup") async def slash_roleinfo(interaction: discord.Interaction, role: discord.Role): embed = discord.Embed(title=f"Role Info — {role.name}", color=0x00ffcc) embed.add_field(name="ID", value=role.id) embed.add_field(name="Members with role", value=len(role.members)) embed.add_field(name="Position", value=role.position) await interaction.response.send_message(embed=embed, ephemeral=True) @tree.command(name="tarsreport", description="Report a user to staff") @app_commands.describe(user="User to report", reason="Reason for report") async def slash_report(interaction: discord.Interaction, user: discord.User, reason: str): await interaction.response.send_message(tars_text("Your report has been submitted to staff."), ephemeral=True) await helper_moderation.send_mod_log(interaction.guild, f"**Report**\nReporter: {interaction.user} ({interaction.user.id})\nReported: {user} ({user.id})\nReason: {reason}\nChannel: {interaction.channel.mention}", ping_staff=False) @tree.command(name="quote", description="Quote a message by link or ID") @app_commands.describe(message_link="Message link or ID") async def slash_quote(interaction: discord.Interaction, message_link: str): try: if "discord.com/channels" in message_link: parts = message_link.split("/") channel_id = int(parts[-2]) message_id = int(parts[-1]) ch = bot.get_channel(channel_id) msg = await ch.fetch_message(message_id) else: message_id = int(message_link) msg = await interaction.channel.fetch_message(message_id) async with aiosqlite.connect(DB_FILE) as db: await db.execute("INSERT INTO quotes(guild_id,message_id,author,content,saved_by,time) VALUES(?,?,?,?,?,?)", (str(interaction.guild.id), str(msg.id), str(msg.author), msg.content[:1800], str(interaction.user), datetime.utcnow().isoformat())) await db.commit() embed = tars_embed("Quoted Message", f"**{msg.author}** in {msg.channel.mention}:\n{msg.content}") await interaction.response.send_message(embed=embed) except Exception as e: 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) @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: await interaction.response.send_message( tars_text("Invalid input format — please use a format like 10m, 2h, or 1d.", "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] remind_at = datetime.utcnow() + timedelta(seconds=seconds) if remind_at <= datetime.utcnow(): await interaction.response.send_message( tars_text("That time is in the past. Sadly time travel does not work here."), ephemeral=True) return async with aiosqlite.connect(DB_FILE) as db: await db.execute("INSERT INTO reminders(user_id, channel_id, remind_at, content) VALUES(?,?,?,?)", (str(interaction.user.id), str(interaction.channel.id), remind_at.isoformat(), text)) await db.commit() 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) async def send_reminder(user_id, channel_id, text): ch = bot.get_channel(int(channel_id)) user = bot.get_user(int(user_id)) if ch: await ch.send(f"{user.mention} Reminder: {text}") else: try: 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) 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 role = channel.guild.get_role(REVIVE_ROLE_ID) if not role: logger.error("Revive role not found.") return if last_activity_time is None: try: async for msg in channel.history(limit=1): last_activity_time = msg.created_at.replace(tzinfo=None) break 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() 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: 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}" ) question = await tars_ai_respond(prompt, "T.A.R.S.") await save_revive_question(question) await channel.send(f"{role.mention} — {question}") revive_sent = True logger.info("Chat revive sent successfully.") @tree.command(name="reactionrole", description="Create a reaction role (admin only)") @app_commands.describe(message_id="ID of message to attach", emoji="Emoji", role="Role to give") async def slash_reactionrole(interaction: discord.Interaction, message_id: str, emoji: str, role: discord.Role): if not interaction.user.guild_permissions.manage_roles: await interaction.response.send_message(tars_text("Access denied — insufficient clearance.", "error"), ephemeral=True) return async with aiosqlite.connect(DB_FILE) as db: await db.execute( "INSERT INTO reaction_roles(guild_id,message_id,emoji,role_id) VALUES(?,?,?,?)", (str(interaction.guild_id), str(message_id), str(emoji), str(role.id)) ) await db.commit() try: msg = await interaction.channel.fetch_message(int(message_id)) await msg.add_reaction(emoji) except Exception as e: logger.info("Failed to add reaction: " + str(e)) await on_error(e) await interaction.response.send_message(tars_text("Reaction role configured."), ephemeral=True) @tree.command(name="8ball", description="Ask the magic 8-ball") @app_commands.describe(question="Your question") async def slash_8ball(interaction: discord.Interaction, question: str): answers = ["Yes.", "No.", "Maybe.", "Highly unlikely.", "Ask again later.", "Affirmative."] await interaction.response.send_message(tars_text(random.choice(answers))) @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) if not m: await interaction.response.send_message(tars_text("Invalid format. Use NdM, e.g., 2d6."), ephemeral=True) return n, sides = int(m.group(1)), int(m.group(2)) if n > 20: await interaction.response.send_message(tars_text("Too many dice."), 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)}")) @tree.command(name="getquote", description="Retrieve a saved quote by ID (staff)") @app_commands.describe(qid="Quote ID") async def slash_getquote(interaction: discord.Interaction, qid: int): if not interaction.user.guild_permissions.manage_guild: await interaction.response.send_message(tars_text("You lack permission."), ephemeral=True) return async with aiosqlite.connect(DB_FILE) as db: cur = await db.execute("SELECT id,author,content,saved_by,time FROM quotes WHERE id=?", (qid,)) row = await cur.fetchone() if not row: await interaction.response.send_message(tars_text("Quote not found."), ephemeral=True) return embed = tars_embed(f"Quote #{row[0]}", f"By {row[1]} — saved by {row[3]} at {row[4]}\n\n{row[2]}") 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) 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) @tree.command(name="setmotd", description="Set Message of the Day list and channel (owner)") @app_commands.describe(channel="Channel for MOTD or empty to not set") async def slash_setmotd(interaction: discord.Interaction, channel: discord.TextChannel = None): if interaction.user.id != OWNER_ID: await interaction.response.send_message(tars_text("Owner only."), ephemeral=True) return await set_config("motd_channel_id", str(channel.id) if channel else None) await set_config("motd_list", MOTD_LIST) await interaction.response.send_message(tars_text("MOTD configuration updated.", "success"), 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): if not interaction.user.guild_permissions.manage_messages: await interaction.response.send_message( tars_text("Access denied — insufficient clearance.", "error"), ephemeral=True ) return await interaction.response.defer(ephemeral=True) deleted = await interaction.channel.purge(limit=min(amount, 200)) await interaction.followup.send( tars_text(f"Deleted {len(deleted)} messages.", "success"), ephemeral=True ) @tree.command(name="lock", description="Lock the current channel (admin only)") async def slash_lock(interaction: discord.Interaction): if not interaction.user.guild_permissions.manage_channels: await interaction.response.send_message(tars_text("You lack permission."), ephemeral=True) return await interaction.channel.set_permissions(interaction.guild.default_role, send_messages=False) await interaction.response.send_message(tars_text("Channel locked. Civilians contained."), ephemeral=False) @tree.command(name="unlock", description="Unlock the current channel (admin only)") async def slash_unlock(interaction: discord.Interaction): if not interaction.user.guild_permissions.manage_channels: await interaction.response.send_message(tars_text("You lack permission."), ephemeral=True) return await interaction.channel.set_permissions(interaction.guild.default_role, send_messages=True) await interaction.response.send_message(tars_text("Channel unlocked. Back to normal operations."), ephemeral=False) @tree.command(name="slowmode", description="Set slowmode delay for this channel (seconds)") @app_commands.describe(seconds="Delay between messages (0 to disable)") async def slash_slowmode(interaction: discord.Interaction, seconds: int): if not interaction.user.guild_permissions.manage_channels: await interaction.response.send_message(tars_text("You lack permission."), ephemeral=True) return await interaction.channel.edit(slowmode_delay=seconds) msg = "Slowmode disabled." if seconds == 0 else f"Slowmode set to {seconds} seconds." await interaction.response.send_message(tars_text(msg, "info")) @tree.command(name="addbannedword", description="Add a word to the auto-delete list (admin only)") @app_commands.describe(word="Word to ban") async def slash_add_banned(interaction: discord.Interaction, word: str): if not interaction.user.guild_permissions.manage_messages: await interaction.response.send_message(tars_text("You lack permission."), ephemeral=True) return banned = await get_config("banned_words", []) banned.append(word.lower()) await set_config("banned_words", banned) await interaction.response.send_message(tars_text(f"Added '{word}' to banned words.", "success"), ephemeral=True) @tree.command(name="listbannedwords", description="List banned words") async def slash_list_banned(interaction: discord.Interaction): banned = await get_config("banned_words", []) await interaction.response.send_message(tars_text("Banned words: " + ", ".join(banned) if banned else "None."), ephemeral=True) @tree.command(name="ping", description="Check T.A.R.S. responsiveness") async def slash_ping(interaction: discord.Interaction): latency = round(bot.latency * 1000) await interaction.response.send_message(tars_text(f"Systems nominal. Response time: {latency}ms")) @tree.command(name="boostpoints", description="Check your current Boost Points balance") async def slash_boostpoints(interaction: discord.Interaction, member: discord.Member = None): member = member or interaction.user points = await get_boost_points(member.id) await interaction.response.send_message( tars_text(f"{member.display_name} currently has **{points} Boost Points**."), ephemeral=True ) @tree.command(name="boostshop", description="Redeem your Boost Points for rewards") async def slash_boostshop(interaction: discord.Interaction): SHOP_ITEMS = { "custom_role": {"name": "Custom Role", "cost": 21, "description": "Get a personalized role with color & name of your choice."}, "giveaway_entry": {"name": "Extra Giveaway Entry", "cost": 10, "description": "Gain an additional entry into giveaways."}, "reduced_carry": {"name": "Reduced Carry Prices (10% less)", "cost": 4, "description": "Carry prices are reduced by 10%."}, "giveaway_bypass": {"name": "Giveaway fast pass", "cost": 8, "description": "Skip requirements to enter giveaways."}, } user_points = await get_boost_points(interaction.user.id) options = [ discord.SelectOption( label=data["name"], description=f"{data['description']} ({data['cost']} pts)", value=key ) for key, data in SHOP_ITEMS.items() ] select = Select( placeholder=f"You have {user_points} points — choose an item to redeem", options=options, min_values=1, max_values=1 ) async def select_callback(interaction_select: discord.Interaction): item_id = select.values[0] selected = SHOP_ITEMS[item_id] cost = selected["cost"] current_points = await get_boost_points(interaction.user.id) if current_points < cost: await interaction_select.response.send_message( tars_text(f"Insufficient points — you need {cost}, but you only have {current_points}.", "error"), ephemeral=True ) return success = await spend_boost_points(interaction.user.id, cost) if not success: await interaction_select.response.send_message( tars_text("Transaction failed — please try again later.", "error"), ephemeral=True ) return guild = interaction.guild ticket_category = discord.utils.get(guild.categories, name="Boost Tickets") if not ticket_category: ticket_category = await guild.create_category("Boost Tickets") channel_name = f"boost-{interaction.user.name.lower()}-{random.randint(1000, 9999)}" ticket_channel = await guild.create_text_channel(channel_name, category=ticket_category) await ticket_channel.set_permissions(interaction.user, view_channel=True, send_messages=True) await ticket_channel.set_permissions(guild.default_role, view_channel=False) embed = tars_embed( "Boost Reward Ticket Opened", f"**Item Redeemed:** {selected['name']}\n**Cost:** {cost} Points\n**Remaining Points:** {current_points - cost}\n\nA staff member will assist you shortly.", ) await ticket_channel.send(f"{interaction.user.mention} has opened a Boost Ticket!", embed=embed) await interaction_select.response.send_message( tars_text(f"Purchase successful! Your ticket has been created in {ticket_channel.mention}.", "success"), ephemeral=True ) select.callback = select_callback view = View(timeout=120) view.add_item(select) embed = discord.Embed(title="T.A.R.S. Boost Shop", color=0x00ffcc) embed.description = ( "Exchange your **Boost Points** for exclusive rewards.\n\n" "Select an item from the menu below to purchase." ) for key, data in SHOP_ITEMS.items(): embed.add_field( name=f"{data['name']} — {data['cost']} Points", value=data["description"], inline=False ) await interaction.response.send_message(embed=embed, view=view, ephemeral=True) @tree.command(name="close", description="Close the current Boost Ticket channel") async def slash_close(interaction: discord.Interaction): channel = interaction.channel if not channel.name.startswith("boost-"): await interaction.response.send_message( tars_text("This command can only be used inside a Boost Ticket channel.", "error"), ephemeral=True ) return is_staff = interaction.user.guild_permissions.manage_channels async for message in channel.history(limit=20, oldest_first=True): ticket_opener = None if message.mentions: ticket_opener = message.mentions[0] if ticket_opener and (interaction.user == ticket_opener or is_staff): break else: if not is_staff: await interaction.response.send_message( tars_text("You don’t have permission to close this ticket.", "error"), ephemeral=True ) return await interaction.response.send_message( tars_text("Closing ticket... Stand by."), ephemeral=True ) await channel.send( embed=tars_embed( "Ticket Closed", f"This ticket was closed by {interaction.user.mention}.\n\n" "Thank you for using the **Boost Shop**!", ) ) await asyncio.sleep(5) await channel.delete(reason=f"Ticket closed by {interaction.user}") @tree.command(name="boostpoints_add", description="Add Boost Points to a user (admin only)") @app_commands.describe(member="Member to add points to", amount="Number of points to add") async def slash_boostpoints_add(interaction: discord.Interaction, member: discord.Member, amount: int): if not interaction.user.guild_permissions.manage_guild: await interaction.response.send_message( tars_text("Access denied — insufficient clearance.", "error"), ephemeral=True ) return if amount <= 0: await interaction.response.send_message( tars_text("Amount must be positive.", "error"), ephemeral=True ) return await add_boost_points(member.id, amount) points = await get_boost_points(member.id) await interaction.response.send_message( tars_text(f"Added **{amount} Boost Points** to {member.display_name}. New balance: **{points}**.", "success"), ephemeral=False ) await helper_moderation.send_mod_log( interaction.guild, f"Admin {interaction.user.mention} added **{amount} Boost Points** to {member.mention}. New total: {points}.", ping_staff=False ) @tree.command(name="boostpoints_remove", description="Remove Boost Points from a user (admin only)") @app_commands.describe(member="Member to remove points from", amount="Number of points to remove") async def slash_boostpoints_remove(interaction: discord.Interaction, member: discord.Member, amount: int): if not interaction.user.guild_permissions.manage_guild: await interaction.response.send_message( tars_text("Access denied — insufficient clearance.", "error"), ephemeral=True ) return if amount <= 0: await interaction.response.send_message( tars_text("Amount must be positive.", "error"), ephemeral=True ) return current = await get_boost_points(member.id) new_amount = max(0, current - amount) async with aiosqlite.connect(DB_FILE) as db: await db.execute("UPDATE boost_points SET points = ? WHERE user_id = ?", (new_amount, str(member.id))) await db.execute("INSERT INTO boost_log (user_id, action, points, time) VALUES (?, ?, ?, ?)", (str(member.id), "admin_remove", -amount, datetime.utcnow().isoformat())) await db.commit() await interaction.response.send_message( tars_text(f"Removed **{amount} Boost Points** from {member.display_name}. New balance: **{new_amount}**.", "warning"), ephemeral=False ) await helper_moderation.send_mod_log( interaction.guild, f"Admin {interaction.user.mention} removed **{amount} Boost Points** from {member.mention}. New total: {new_amount}." ) @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}" ) question = await tars_ai_respond(prompt, "T.A.R.S.") await save_revive_question(question) await channel.send(f"{role.mention} — {question}") await interaction.response.send_message( tars_text("Revive test executed successfully.", "success"), ephemeral=True ) if __name__ == "__main__": bot.run(DISCORD_TOKEN)