1083 lines
46 KiB
Python
1083 lines
46 KiB
Python
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 = 14400
|
||
CHECK_INTERVAL = 600
|
||
AI_ACCESS_ROLE_ID = 1430704668773716008
|
||
MESSAGE_LIMIT = 10
|
||
RATE_LIMIT_WINDOW = timedelta(hours=1)
|
||
user_message_log = defaultdict(list)
|
||
|
||
FABI_ID = 392388537984745498
|
||
BOT_VERSION = "5.2.8"
|
||
|
||
|
||
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
|
||
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_inappropriate(text: str) -> bool:
|
||
lowered = text.lower()
|
||
for w in 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.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()
|
||
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=True
|
||
)
|
||
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 not isinstance(prompt, str):
|
||
raise ValueError("The 'prompt' must be a string.")
|
||
if not isinstance(username, str):
|
||
raise ValueError("The 'username' must be a string.")
|
||
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. "
|
||
"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. "
|
||
"Playfully banter with users named Yuki or Bacon Man."
|
||
"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."
|
||
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.65
|
||
)
|
||
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):
|
||
if message.author.bot:
|
||
return
|
||
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=True)
|
||
|
||
|
||
@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_channel_activity():
|
||
await bot.wait_until_ready()
|
||
channel = bot.get_channel(REVIVE_CHANNEL_ID)
|
||
if not channel:
|
||
logger.warning("Revive channel not found. Check REVIVE_CHANNEL_ID.")
|
||
return
|
||
while not bot.is_closed():
|
||
try:
|
||
async for message in channel.history(limit=1):
|
||
last_message_time = message.created_at
|
||
now = datetime.utcnow()
|
||
diff = (now - last_message_time).total_seconds()
|
||
if diff >= REVIVE_INTERVAL:
|
||
revive_role = channel.guild.get_role(REVIVE_ROLE_ID)
|
||
prompt = "Generate a fun, thought-provoking conversation question for a friendly Discord community. Keep it short and engaging."
|
||
response = await tars_ai_respond(prompt, "", [])
|
||
question = response.choices[0].message.content.strip()
|
||
ping_text = revive_role.mention if revive_role else "@chat revive"
|
||
await channel.send(f"{ping_text} — {question}")
|
||
logger.info(f"Posted chat revive message: {question}")
|
||
else:
|
||
logger.info(f"Channel active {diff / 60:.1f} minutes ago — skipping.")
|
||
await asyncio.sleep(CHECK_INTERVAL)
|
||
except Exception as e:
|
||
logger.exception(f"Error in chat revive loop: {e}")
|
||
await asyncio.sleep(120)
|
||
|
||
|
||
@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}."
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
bot.run(DISCORD_TOKEN)
|