Add deadman switch cog
This commit is contained in:
@@ -8,6 +8,9 @@ Homepage: https://homepage.kingstons-scrapyard.net/
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
**Deadman**
|
||||||
|
- [Deadman](#deadman)
|
||||||
|
|
||||||
**AutoRoom**
|
**AutoRoom**
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [AutoRoom](#autoroom)
|
- [AutoRoom](#autoroom)
|
||||||
@@ -49,6 +52,35 @@ Homepage: https://homepage.kingstons-scrapyard.net/
|
|||||||
[p]load autoroom
|
[p]load autoroom
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Deadman (bot owner only):
|
||||||
|
|
||||||
|
```
|
||||||
|
[p]cog install scrapyard deadman
|
||||||
|
[p]load deadman
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deadman
|
||||||
|
|
||||||
|
A member of the Scrapyard Cogworks package. A deadman switch that alerts listed users if not disabled before it expires.
|
||||||
|
|
||||||
|
- **Owner only**: Only the bot owner can arm, disable, or check status.
|
||||||
|
- **Arm**: `[p]deadman arm <duration> @user1 @user2 [channel]`
|
||||||
|
- **Disable**: `[p]deadman disable`
|
||||||
|
- **Status**: `[p]deadman status`
|
||||||
|
|
||||||
|
**Duration format** (flexible string):
|
||||||
|
- `30s` = 30 seconds
|
||||||
|
- `6h` = 6 hours
|
||||||
|
- `2h30m` = 2 hours 30 minutes
|
||||||
|
- `1d` = 1 day
|
||||||
|
- `1d12h` = 1 day 12 hours
|
||||||
|
|
||||||
|
Supported units: `s`, `m`, `h`, `d`, `w` (and their full names).
|
||||||
|
|
||||||
|
When the switch is armed, the bot stores the expiry time and alerts users by DM. If an optional channel is provided, it also posts the alert there. If `[p]deadman disable` is not run before expiry, the listed users are DM'd and pinged that "the deadman switch has been TRIGGERED".
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## AutoRoom
|
## AutoRoom
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Package for Deadman cog."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from redbot.core.bot import Red
|
||||||
|
|
||||||
|
from .deadman import Deadman
|
||||||
|
|
||||||
|
with Path(__file__).parent.joinpath("info.json").open() as fp:
|
||||||
|
__red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"]
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot: Red) -> None:
|
||||||
|
"""Load Deadman cog."""
|
||||||
|
await bot.add_cog(Deadman(bot))
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from redbot.core import Config, commands
|
||||||
|
|
||||||
|
|
||||||
|
def parse_duration(text: str) -> Optional[timedelta]:
|
||||||
|
"""Parse a flexible duration string like ``6h``, ``2h30m``, ``90s`` or ``1d``."""
|
||||||
|
text = text.strip().lower()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total = timedelta()
|
||||||
|
num = ""
|
||||||
|
unit = ""
|
||||||
|
seen_unit = False
|
||||||
|
|
||||||
|
for char in text:
|
||||||
|
if char.isdigit():
|
||||||
|
if seen_unit:
|
||||||
|
# A digit after a unit means a new component, e.g. "1d12h"
|
||||||
|
delta = _unit_to_timedelta(int(num), unit)
|
||||||
|
if delta is None:
|
||||||
|
return None
|
||||||
|
total += delta
|
||||||
|
num = char
|
||||||
|
unit = ""
|
||||||
|
seen_unit = False
|
||||||
|
else:
|
||||||
|
num += char
|
||||||
|
else:
|
||||||
|
if not num:
|
||||||
|
return None
|
||||||
|
unit += char
|
||||||
|
seen_unit = True
|
||||||
|
|
||||||
|
if unit:
|
||||||
|
delta = _unit_to_timedelta(int(num), unit)
|
||||||
|
if delta is None:
|
||||||
|
return None
|
||||||
|
total += delta
|
||||||
|
elif num:
|
||||||
|
# Trailing digits with no unit, e.g. "5m30" - invalid.
|
||||||
|
return None
|
||||||
|
|
||||||
|
if total <= timedelta(0):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_to_timedelta(amount: int, unit: str) -> Optional[timedelta]:
|
||||||
|
if not amount or not isinstance(amount, int):
|
||||||
|
return None
|
||||||
|
if unit and unit.isdigit():
|
||||||
|
return None
|
||||||
|
if unit in ("s", "sec", "secs", "second", "seconds"):
|
||||||
|
return timedelta(seconds=amount)
|
||||||
|
if unit in ("m", "min", "mins", "minute", "minutes"):
|
||||||
|
return timedelta(minutes=amount)
|
||||||
|
if unit in ("h", "hr", "hrs", "hour", "hours"):
|
||||||
|
return timedelta(hours=amount)
|
||||||
|
if unit in ("d", "day", "days"):
|
||||||
|
return timedelta(days=amount)
|
||||||
|
if unit in ("w", "week", "weeks"):
|
||||||
|
return timedelta(weeks=amount)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Deadman(commands.Cog):
|
||||||
|
"""Deadman switch - pings listed users if [p]deadman disable isn't run before expiration."""
|
||||||
|
|
||||||
|
def __init__(self, bot):
|
||||||
|
self.bot = bot
|
||||||
|
self.config = Config.get_conf(self, identifier=918273645)
|
||||||
|
self.config.register_global(
|
||||||
|
armed=False,
|
||||||
|
expires_at=None,
|
||||||
|
users=[],
|
||||||
|
trigger_channel=None,
|
||||||
|
)
|
||||||
|
self._task = None
|
||||||
|
self._triggered = False
|
||||||
|
|
||||||
|
async def red_delete_data_for_user(self, **kwargs):
|
||||||
|
return
|
||||||
|
|
||||||
|
def cog_unload(self):
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
|
||||||
|
async def cog_load(self):
|
||||||
|
self._task = asyncio.create_task(self._monitor())
|
||||||
|
|
||||||
|
async def _monitor(self):
|
||||||
|
"""Background task that checks every 30 seconds whether the switch has expired."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
await self._check_expiry()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _check_expiry(self):
|
||||||
|
if not await self.config.armed():
|
||||||
|
self._triggered = False
|
||||||
|
return
|
||||||
|
|
||||||
|
expires_at = await self.config.expires_at()
|
||||||
|
if expires_at is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if datetime.utcnow() >= datetime.fromisoformat(expires_at):
|
||||||
|
if not self._triggered:
|
||||||
|
await self._trigger()
|
||||||
|
# Keep armed/stale so repeated pings don't spam; disable clears state.
|
||||||
|
|
||||||
|
async def _trigger(self):
|
||||||
|
self._triggered = True
|
||||||
|
users = await self.config.users()
|
||||||
|
channel_id = await self.config.trigger_channel()
|
||||||
|
|
||||||
|
victims: list[discord.User] = []
|
||||||
|
for uid in users:
|
||||||
|
user = self.bot.get_user(uid)
|
||||||
|
if user is None:
|
||||||
|
try:
|
||||||
|
user = await self.bot.fetch_user(uid)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if user is not None:
|
||||||
|
victims.append(user)
|
||||||
|
|
||||||
|
for user in victims:
|
||||||
|
try:
|
||||||
|
await user.send(
|
||||||
|
"**GENERAL ALERT**\nThe deadman switch has been TRIGGERED. "
|
||||||
|
"Please contact the operator immediately."
|
||||||
|
)
|
||||||
|
except discord.Forbidden:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if channel_id:
|
||||||
|
channel = self.bot.get_channel(channel_id)
|
||||||
|
if channel:
|
||||||
|
mentions = " ".join(
|
||||||
|
f"<@{uid}>" for uid in users if self.bot.get_user(uid)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await channel.send(
|
||||||
|
f"**GENERAL ALERT**\nThe deadman switch has been TRIGGERED. "
|
||||||
|
f"{mentions} Please contact the operator immediately."
|
||||||
|
)
|
||||||
|
except discord.Forbidden:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@commands.group()
|
||||||
|
@commands.is_owner()
|
||||||
|
async def deadman(self, ctx):
|
||||||
|
"""Deadman switch control."""
|
||||||
|
|
||||||
|
@deadman.command(name="arm")
|
||||||
|
async def deadman_arm(self, ctx, duration: str, users: commands.Greedy[discord.User], trigger_channel: Optional[discord.TextChannel] = None):
|
||||||
|
"""Arm the deadman switch for a duration, pinging listed users if not disabled in time.
|
||||||
|
|
||||||
|
Duration format: a flexible string like ``6h``, ``2h30m``, ``90s``, ``1d``, or a combination.
|
||||||
|
Users: mention the users to alert (e.g. ``@user1 @user2``).
|
||||||
|
Optional: a channel where the alert should also be posted.
|
||||||
|
"""
|
||||||
|
if not users:
|
||||||
|
await ctx.send("You must provide at least one user to alert.")
|
||||||
|
return
|
||||||
|
|
||||||
|
duration_td = parse_duration(duration)
|
||||||
|
if duration_td is None:
|
||||||
|
await ctx.send(
|
||||||
|
"Invalid duration. Examples: `6h`, `2h30m`, `90s`, `1d`, `1d12h`."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
expires_at = datetime.utcnow() + duration_td
|
||||||
|
await self.config.armed.set(True)
|
||||||
|
await self.config.expires_at.set(expires_at.isoformat())
|
||||||
|
await self.config.users.set([u.id for u in users])
|
||||||
|
await self.config.trigger_channel.set(trigger_channel.id if trigger_channel else None)
|
||||||
|
self._triggered = False
|
||||||
|
|
||||||
|
expires_str = expires_at.strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
await ctx.send(
|
||||||
|
f"Deadman switch ARMED.\n"
|
||||||
|
f"Expires: **{expires_str}** (in {duration_td})\n"
|
||||||
|
f"Users notified: {', '.join(u.mention for u in users)}\n"
|
||||||
|
+ (f"Alert channel: {trigger_channel.mention}\n" if trigger_channel else "\n")
|
||||||
|
+ "Run `[p]deadman disable` before that time to cancel."
|
||||||
|
)
|
||||||
|
|
||||||
|
@deadman.command(name="disable")
|
||||||
|
async def deadman_disable(self, ctx):
|
||||||
|
"""Disable the currently armed deadman switch."""
|
||||||
|
armed = await self.config.armed()
|
||||||
|
if not armed:
|
||||||
|
await ctx.send("The deadman switch is not currently armed.")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.config.armed.set(False)
|
||||||
|
await self.config.expires_at.set(None)
|
||||||
|
await self.config.users.set([])
|
||||||
|
await self.config.trigger_channel.set(None)
|
||||||
|
self._triggered = False
|
||||||
|
await ctx.send("Deadman switch disabled.")
|
||||||
|
|
||||||
|
@deadman.command(name="status")
|
||||||
|
async def deadman_status(self, ctx):
|
||||||
|
"""Show the current deadman switch status."""
|
||||||
|
armed = await self.config.armed()
|
||||||
|
if not armed:
|
||||||
|
await ctx.send("The deadman switch is currently **disarmed**.")
|
||||||
|
return
|
||||||
|
|
||||||
|
expires_at = await self.config.expires_at()
|
||||||
|
users = await self.config.users()
|
||||||
|
expires_dt = datetime.fromisoformat(expires_at)
|
||||||
|
remaining = expires_dt - datetime.utcnow()
|
||||||
|
mentions = ", ".join(f"<@{uid}>" for uid in users) if users else "None"
|
||||||
|
await ctx.send(
|
||||||
|
f"Deadman switch is **ARMED**.\n"
|
||||||
|
f"Expires at: {expires_dt.strftime('%Y-%m-%d %H:%M UTC')}\n"
|
||||||
|
f"Time remaining: {remaining}\n"
|
||||||
|
f"Users to alert: {mentions}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "Deadman",
|
||||||
|
"author": [
|
||||||
|
"Scrapyard Cogworks"
|
||||||
|
],
|
||||||
|
"short": "Deadman switch that pings listed users if not disabled in time.",
|
||||||
|
"description": "Arm a deadman switch with a duration. If [p]deadman disable is not run before it expires, the bot DMs and pings all listed users that the switch has been triggered. Bot owner only.",
|
||||||
|
"install_msg": "Thanks for installing Deadman from Scrapyard Cogworks! Visit us at https://homepage.kingstons-scrapyard.net/",
|
||||||
|
"requirements": [],
|
||||||
|
"tags": [
|
||||||
|
"deadman",
|
||||||
|
"switch",
|
||||||
|
"alert",
|
||||||
|
"safety",
|
||||||
|
"ping"
|
||||||
|
],
|
||||||
|
"min_bot_version": "3.5.0",
|
||||||
|
"min_python_version": [
|
||||||
|
3,
|
||||||
|
11,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"end_user_data_statement": "This cog stores Discord user and channel IDs of configured participants. It does not store message content or profane data."
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user