Add MOTD management and improve reminders
This commit is contained in:
@@ -1,2 +1,49 @@
|
|||||||
# TARS
|
# 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.
|
||||||
|
|||||||
+129
-14
@@ -56,7 +56,8 @@ TARS_COMMAND_CATEGORIES = {
|
|||||||
},
|
},
|
||||||
"Utility & Diagnostics": {
|
"Utility & Diagnostics": {
|
||||||
"userinfo", "roleinfo", "serverinfo", "status",
|
"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": {
|
"Recreational Protocols": {
|
||||||
"8ball", "dice", "quote", "getquote", "ping"
|
"8ball", "dice", "quote", "getquote", "ping"
|
||||||
@@ -312,6 +313,35 @@ def strip_links(text: str) -> str:
|
|||||||
return re.sub(r'http?://\S+', '[LINK REMOVED]', text)
|
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 def init_db():
|
||||||
async with aiosqlite.connect(DB_FILE) as db:
|
async with aiosqlite.connect(DB_FILE) as db:
|
||||||
await db.execute("""CREATE TABLE IF NOT EXISTS warnings
|
await db.execute("""CREATE TABLE IF NOT EXISTS warnings
|
||||||
@@ -493,6 +523,7 @@ async def save_revive_question(question: str):
|
|||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
MOTD_LIST = []
|
MOTD_LIST = []
|
||||||
motd_index = 0
|
motd_index = 0
|
||||||
|
MOTD_JOB_ID = "motd_rotation"
|
||||||
|
|
||||||
|
|
||||||
async def get_config(key: str, default=None):
|
async def get_config(key: str, default=None):
|
||||||
@@ -527,8 +558,7 @@ async def on_ready():
|
|||||||
motd = await get_config("motd_list", [])
|
motd = await get_config("motd_list", [])
|
||||||
global MOTD_LIST, motd_index
|
global MOTD_LIST, motd_index
|
||||||
MOTD_LIST = motd or []
|
MOTD_LIST = motd or []
|
||||||
if MOTD_LIST:
|
schedule_motd_rotation()
|
||||||
scheduler.add_job(rotate_motd, "interval", minutes=60)
|
|
||||||
targets = await get_config("uptime_targets", [])
|
targets = await get_config("uptime_targets", [])
|
||||||
if targets:
|
if targets:
|
||||||
scheduler.add_job(check_uptime_targets, "interval", minutes=5)
|
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."))
|
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():
|
async def check_uptime_targets():
|
||||||
targets = await get_config("uptime_targets", [])
|
targets = await get_config("uptime_targets", [])
|
||||||
if not 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")
|
@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")
|
@app_commands.describe(delay="Delay like 10m, 2h, 1d", text="Reminder text")
|
||||||
async def slash_remindme(interaction: discord.Interaction, delay: str, text: str):
|
async def slash_remindme(interaction: discord.Interaction, delay: str, text: str):
|
||||||
match = re.match(r"(\d+)([smhd])", delay)
|
parsed = parse_duration(delay)
|
||||||
if not match:
|
if not parsed:
|
||||||
await interaction.response.send_message(
|
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
|
return
|
||||||
num, unit = int(match.group(1)), match.group(2)
|
seconds, formatted = parsed
|
||||||
multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
||||||
seconds = num * multipliers[unit]
|
|
||||||
remind_at = datetime.utcnow() + timedelta(seconds=seconds)
|
remind_at = datetime.utcnow() + timedelta(seconds=seconds)
|
||||||
if remind_at <= datetime.utcnow():
|
if remind_at <= datetime.utcnow():
|
||||||
await interaction.response.send_message(
|
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,
|
scheduler.add_job(send_reminder, 'date', run_date=remind_at,
|
||||||
args=[interaction.user.id, interaction.channel.id, text])
|
args=[interaction.user.id, interaction.channel.id, text])
|
||||||
await interaction.response.send_message(
|
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):
|
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")
|
@tree.command(name="dice", description="Roll a dice like 1d6 or 2d10")
|
||||||
@app_commands.describe(spec="e.g. 1d6 or 2d10")
|
@app_commands.describe(spec="e.g. 1d6 or 2d10")
|
||||||
async def slash_dice(interaction: discord.Interaction, spec: str):
|
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:
|
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
|
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:
|
if n > 20:
|
||||||
await interaction.response.send_message(tars_text("Too many dice."), ephemeral=True)
|
await interaction.response.send_message(tars_text("Too many dice."), ephemeral=True)
|
||||||
return
|
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)]
|
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)")
|
@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
|
return
|
||||||
await set_config("motd_channel_id", str(channel.id) if channel else None)
|
await set_config("motd_channel_id", str(channel.id) if channel else None)
|
||||||
await set_config("motd_list", MOTD_LIST)
|
await set_config("motd_list", MOTD_LIST)
|
||||||
|
schedule_motd_rotation()
|
||||||
await interaction.response.send_message(tars_text("MOTD configuration updated.", "success"), ephemeral=True)
|
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)")
|
@tree.command(name="clean", description="Delete the last N messages (admin only)")
|
||||||
@app_commands.describe(amount="Number of messages to delete (max 200)")
|
@app_commands.describe(amount="Number of messages to delete (max 200)")
|
||||||
async def slash_clean(interaction: discord.Interaction, amount: int):
|
async def slash_clean(interaction: discord.Interaction, amount: int):
|
||||||
|
|||||||
Reference in New Issue
Block a user