Add MOTD management and improve reminders

This commit is contained in:
2026-01-26 22:38:47 +01:00
parent 97c0ebc6f2
commit 24e9a311d0
2 changed files with 177 additions and 15 deletions
+48 -1
View File
@@ -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
View File
@@ -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):