Merge pull request #2 from Fretuks/implement-commands-from-readme
Add MOTD management, flexible reminders, and enhanced dice command
This commit is contained in:
@@ -1,2 +1,49 @@
|
||||
# TARS
|
||||
A discord bot for a private discord server
|
||||
T.A.R.S. is a Discord bot for a private server with moderation, utility, and light recreational tooling.
|
||||
|
||||
## Features
|
||||
- Moderation and safety: auto-moderation hooks, reports, lock/slowmode tools, and a configurable banned-word list.
|
||||
- Utility automation: reminders, message quoting, and server/user diagnostics.
|
||||
- Community engagement: chat revive prompts, MOTD rotation, and fun commands like dice and 8-ball.
|
||||
- Boost points: reward boosters and let them redeem perks through a simple shop flow.
|
||||
|
||||
## Quick Start
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
2. Configure environment variables:
|
||||
- `DISCORD_TOKEN`
|
||||
- `OWNER_ID`
|
||||
- `GUILD_ID`
|
||||
- `OPENAI_API_KEY` (optional, required for AI replies and revive prompts)
|
||||
3. Run the bot:
|
||||
```bash
|
||||
python tars_bot.py
|
||||
```
|
||||
|
||||
## Command Highlights
|
||||
### Moderation
|
||||
- `/tarsreport` — report a user to staff.
|
||||
- `/clean` — delete the last N messages (admin only).
|
||||
- `/lock` / `/unlock` — lock or unlock a channel (admin only).
|
||||
- `/slowmode` — set a channel slowmode delay (admin only).
|
||||
- `/addbannedword` / `/removebannedword` / `/listbannedwords` — manage banned words.
|
||||
|
||||
### Utility & Diagnostics
|
||||
- `/tars` — command console and categorized help.
|
||||
- `/userinfo` / `/roleinfo` / `/serverinfo` — quick info commands.
|
||||
- `/status` — show system health.
|
||||
- `/remindme` — set reminders with flexible durations (e.g. `1h 30m`).
|
||||
- `/reactionrole` — create a reaction role (admin only).
|
||||
- `/setmotd` — set MOTD channel (owner only).
|
||||
- `/motd_add` / `/motd_remove` / `/motd_list` — manage the MOTD rotation (owner only).
|
||||
|
||||
### Recreational
|
||||
- `/8ball` — ask the magic 8-ball.
|
||||
- `/dice` — roll dice with optional modifiers (e.g. `2d6+1`).
|
||||
- `/quote` / `/getquote` — save and retrieve memorable quotes.
|
||||
|
||||
## Notes
|
||||
- MOTD messages rotate hourly when configured.
|
||||
- The AI subsystem can be disabled automatically if too many errors occur in a short window.
|
||||
|
||||
+129
-14
@@ -56,7 +56,8 @@ TARS_COMMAND_CATEGORIES = {
|
||||
},
|
||||
"Utility & Diagnostics": {
|
||||
"userinfo", "roleinfo", "serverinfo", "status",
|
||||
"config_view", "ai_stats", "remindme", "reactionrole", "setmotd"
|
||||
"config_view", "ai_stats", "remindme", "reactionrole", "setmotd",
|
||||
"motd_add", "motd_remove", "motd_list"
|
||||
},
|
||||
"Recreational Protocols": {
|
||||
"8ball", "dice", "quote", "getquote", "ping"
|
||||
@@ -312,6 +313,35 @@ def strip_links(text: str) -> str:
|
||||
return re.sub(r'http?://\S+', '[LINK REMOVED]', text)
|
||||
|
||||
|
||||
DURATION_PATTERN = re.compile(r"(\d+)\s*([smhdw])", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_duration(duration: str) -> tuple[int, str] | None:
|
||||
matches = list(DURATION_PATTERN.finditer(duration))
|
||||
if not matches:
|
||||
return None
|
||||
remainder = DURATION_PATTERN.sub("", duration)
|
||||
if remainder.strip():
|
||||
return None
|
||||
multipliers = {
|
||||
"s": 1,
|
||||
"m": 60,
|
||||
"h": 3600,
|
||||
"d": 86400,
|
||||
"w": 604800,
|
||||
}
|
||||
total = 0
|
||||
parts = []
|
||||
for match in matches:
|
||||
value = int(match.group(1))
|
||||
unit = match.group(2).lower()
|
||||
total += value * multipliers[unit]
|
||||
parts.append(f"{value}{unit}")
|
||||
if total <= 0:
|
||||
return None
|
||||
return total, " ".join(parts)
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with aiosqlite.connect(DB_FILE) as db:
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS warnings
|
||||
@@ -493,6 +523,7 @@ async def save_revive_question(question: str):
|
||||
scheduler = AsyncIOScheduler()
|
||||
MOTD_LIST = []
|
||||
motd_index = 0
|
||||
MOTD_JOB_ID = "motd_rotation"
|
||||
|
||||
|
||||
async def get_config(key: str, default=None):
|
||||
@@ -527,8 +558,7 @@ async def on_ready():
|
||||
motd = await get_config("motd_list", [])
|
||||
global MOTD_LIST, motd_index
|
||||
MOTD_LIST = motd or []
|
||||
if MOTD_LIST:
|
||||
scheduler.add_job(rotate_motd, "interval", minutes=60)
|
||||
schedule_motd_rotation()
|
||||
targets = await get_config("uptime_targets", [])
|
||||
if targets:
|
||||
scheduler.add_job(check_uptime_targets, "interval", minutes=5)
|
||||
@@ -616,6 +646,14 @@ async def rotate_motd():
|
||||
await ch.send(embed=tars_embed("Message of the Day", f"{text}\n\n**T.A.R.S.**: Stay sharp out there."))
|
||||
|
||||
|
||||
def schedule_motd_rotation():
|
||||
job = scheduler.get_job(MOTD_JOB_ID)
|
||||
if MOTD_LIST and not job:
|
||||
scheduler.add_job(rotate_motd, "interval", minutes=60, id=MOTD_JOB_ID, replace_existing=True)
|
||||
elif not MOTD_LIST and job:
|
||||
scheduler.remove_job(MOTD_JOB_ID)
|
||||
|
||||
|
||||
async def check_uptime_targets():
|
||||
targets = await get_config("uptime_targets", [])
|
||||
if not targets:
|
||||
@@ -1010,14 +1048,17 @@ async def slash_quote(interaction: discord.Interaction, message_link: str):
|
||||
@tree.command(name="remindme", description="Set a reminder: e.g. /remindme 10m Check the logs")
|
||||
@app_commands.describe(delay="Delay like 10m, 2h, 1d", text="Reminder text")
|
||||
async def slash_remindme(interaction: discord.Interaction, delay: str, text: str):
|
||||
match = re.match(r"(\d+)([smhd])", delay)
|
||||
if not match:
|
||||
parsed = parse_duration(delay)
|
||||
if not parsed:
|
||||
await interaction.response.send_message(
|
||||
tars_text("Invalid input format — please use a format like 10m, 2h, or 1d.", "error"), ephemeral=True)
|
||||
tars_text(
|
||||
"Invalid input format — please use 10m, 2h, 1d, or combined like 1h 30m.",
|
||||
"error"
|
||||
),
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
num, unit = int(match.group(1)), match.group(2)
|
||||
multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
||||
seconds = num * multipliers[unit]
|
||||
seconds, formatted = parsed
|
||||
remind_at = datetime.utcnow() + timedelta(seconds=seconds)
|
||||
if remind_at <= datetime.utcnow():
|
||||
await interaction.response.send_message(
|
||||
@@ -1030,7 +1071,9 @@ async def slash_remindme(interaction: discord.Interaction, delay: str, text: str
|
||||
scheduler.add_job(send_reminder, 'date', run_date=remind_at,
|
||||
args=[interaction.user.id, interaction.channel.id, text])
|
||||
await interaction.response.send_message(
|
||||
tars_text(f"Reminder set. I’ll alert you precisely on schedule in about {delay}.", "success"), ephemeral=True)
|
||||
tars_text(f"Reminder set. I’ll alert you in about {formatted}.", "success"),
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
async def send_reminder(user_id, channel_id, text):
|
||||
@@ -1140,16 +1183,29 @@ async def slash_8ball(interaction: discord.Interaction, question: str):
|
||||
@tree.command(name="dice", description="Roll a dice like 1d6 or 2d10")
|
||||
@app_commands.describe(spec="e.g. 1d6 or 2d10")
|
||||
async def slash_dice(interaction: discord.Interaction, spec: str):
|
||||
m = re.match(r"(\d+)d(\d+)", spec)
|
||||
m = re.match(r"^\s*(\d*)d(\d+)([+-]\d+)?\s*$", spec.lower())
|
||||
if not m:
|
||||
await interaction.response.send_message(tars_text("Invalid format. Use NdM, e.g., 2d6."), ephemeral=True)
|
||||
await interaction.response.send_message(
|
||||
tars_text("Invalid format. Use NdM or NdM+K (e.g., 2d6+1).", "error"),
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
n, sides = int(m.group(1)), int(m.group(2))
|
||||
n = int(m.group(1) or 1)
|
||||
sides = int(m.group(2))
|
||||
modifier = int(m.group(3) or 0)
|
||||
if n > 20:
|
||||
await interaction.response.send_message(tars_text("Too many dice."), ephemeral=True)
|
||||
return
|
||||
if sides < 2 or sides > 1000:
|
||||
await interaction.response.send_message(tars_text("Die sides must be between 2 and 1000."), ephemeral=True)
|
||||
return
|
||||
rolls = [random.randint(1, sides) for _ in range(n)]
|
||||
await interaction.response.send_message(tars_text(f"Rolled: {rolls} — total {sum(rolls)}"))
|
||||
subtotal = sum(rolls)
|
||||
total = subtotal + modifier
|
||||
mod_text = f" {modifier:+d}" if modifier else ""
|
||||
await interaction.response.send_message(
|
||||
tars_text(f"Rolled: {rolls} — total {subtotal}{mod_text} = {total}")
|
||||
)
|
||||
|
||||
|
||||
@tree.command(name="getquote", description="Retrieve a saved quote by ID (staff)")
|
||||
@@ -1188,9 +1244,68 @@ async def slash_setmotd(interaction: discord.Interaction, channel: discord.TextC
|
||||
return
|
||||
await set_config("motd_channel_id", str(channel.id) if channel else None)
|
||||
await set_config("motd_list", MOTD_LIST)
|
||||
schedule_motd_rotation()
|
||||
await interaction.response.send_message(tars_text("MOTD configuration updated.", "success"), ephemeral=True)
|
||||
|
||||
|
||||
@tree.command(name="motd_add", description="Add a message to the MOTD rotation (owner)")
|
||||
@app_commands.describe(text="Message to add")
|
||||
async def slash_motd_add(interaction: discord.Interaction, text: str):
|
||||
if interaction.user.id != OWNER_ID:
|
||||
await interaction.response.send_message(tars_text("Owner only."), ephemeral=True)
|
||||
return
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
await interaction.response.send_message(tars_text("MOTD text cannot be empty.", "error"), ephemeral=True)
|
||||
return
|
||||
MOTD_LIST.append(cleaned)
|
||||
await set_config("motd_list", MOTD_LIST)
|
||||
schedule_motd_rotation()
|
||||
await interaction.response.send_message(
|
||||
tars_text(f"MOTD added. Total messages: {len(MOTD_LIST)}.", "success"),
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
@tree.command(name="motd_remove", description="Remove an MOTD message by index (owner)")
|
||||
@app_commands.describe(index="Index from /motd_list")
|
||||
async def slash_motd_remove(interaction: discord.Interaction, index: int):
|
||||
if interaction.user.id != OWNER_ID:
|
||||
await interaction.response.send_message(tars_text("Owner only."), ephemeral=True)
|
||||
return
|
||||
if index < 1 or index > len(MOTD_LIST):
|
||||
await interaction.response.send_message(
|
||||
tars_text("Index out of range. Use /motd_list to check entries.", "error"),
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
removed = MOTD_LIST.pop(index - 1)
|
||||
await set_config("motd_list", MOTD_LIST)
|
||||
schedule_motd_rotation()
|
||||
await interaction.response.send_message(
|
||||
tars_text(f"Removed MOTD: {removed}", "success"),
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
@tree.command(name="motd_list", description="List MOTD rotation messages (owner)")
|
||||
async def slash_motd_list(interaction: discord.Interaction):
|
||||
if interaction.user.id != OWNER_ID:
|
||||
await interaction.response.send_message(tars_text("Owner only."), ephemeral=True)
|
||||
return
|
||||
if not MOTD_LIST:
|
||||
await interaction.response.send_message(
|
||||
tars_text("No MOTD messages configured yet.", "info"),
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
lines = "\n".join(f"{idx}. {text}" for idx, text in enumerate(MOTD_LIST, start=1))
|
||||
await interaction.response.send_message(
|
||||
embed=tars_embed("MOTD Rotation", lines),
|
||||
ephemeral=True
|
||||
)
|
||||
|
||||
|
||||
@tree.command(name="clean", description="Delete the last N messages (admin only)")
|
||||
@app_commands.describe(amount="Number of messages to delete (max 200)")
|
||||
async def slash_clean(interaction: discord.Interaction, amount: int):
|
||||
|
||||
Reference in New Issue
Block a user