Add 7 new pure-stdlib plugins: whois (raw TCP port 43), portcheck (async TCP connect scan with internal-net guard), httpcheck (HTTP status/redirects/timing), tlscheck (TLS version/cipher/cert inspect), blacklist (parallel DNSBL check against 10 RBLs), rand (password/hex/ uuid/bytes/int/coin/dice), and timer (async countdown notifications). Add --cprofile flag to CLI for profiling bot runtime. Update all docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
141 lines
4.4 KiB
Python
141 lines
4.4 KiB
Python
"""Plugin: countdown timer with async notifications (pure stdlib)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
|
|
from derp.plugin import command
|
|
|
|
# In-memory timer storage: {channel_or_nick: {label: task}}
|
|
_timers: dict[str, dict[str, asyncio.Task]] = {}
|
|
_MAX_TIMERS = 10
|
|
_MAX_DURATION = 86400 # 24h
|
|
_DURATION_RE = re.compile(
|
|
r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$",
|
|
)
|
|
|
|
|
|
def _parse_duration(spec: str) -> int | None:
|
|
"""Parse a duration like '5m', '1h30m', '90s', or raw seconds."""
|
|
# Try raw integer seconds
|
|
try:
|
|
secs = int(spec)
|
|
return secs if 0 < secs <= _MAX_DURATION else None
|
|
except ValueError:
|
|
pass
|
|
|
|
m = _DURATION_RE.match(spec.lower())
|
|
if not m or not any(m.groups()):
|
|
return None
|
|
|
|
hours = int(m.group(1) or 0)
|
|
mins = int(m.group(2) or 0)
|
|
secs = int(m.group(3) or 0)
|
|
total = hours * 3600 + mins * 60 + secs
|
|
return total if 0 < total <= _MAX_DURATION else None
|
|
|
|
|
|
def _format_duration(secs: int) -> str:
|
|
"""Format seconds into compact duration."""
|
|
parts = []
|
|
if secs >= 3600:
|
|
parts.append(f"{secs // 3600}h")
|
|
secs %= 3600
|
|
if secs >= 60:
|
|
parts.append(f"{secs // 60}m")
|
|
secs %= 60
|
|
if secs or not parts:
|
|
parts.append(f"{secs}s")
|
|
return "".join(parts)
|
|
|
|
|
|
async def _timer_task(bot, target: str, label: str, duration: int, nick: str) -> None:
|
|
"""Background task that waits and then notifies."""
|
|
try:
|
|
await asyncio.sleep(duration)
|
|
await bot.send(target, f"{nick}: timer '{label}' ({_format_duration(duration)}) finished")
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
# Clean up
|
|
if target in _timers:
|
|
_timers[target].pop(label, None)
|
|
if not _timers[target]:
|
|
del _timers[target]
|
|
|
|
|
|
@command("timer", help="Timer: !timer <duration> [label] | !timer list | !timer cancel <label>")
|
|
async def cmd_timer(bot, message):
|
|
"""Set countdown timers with async notification.
|
|
|
|
Usage:
|
|
!timer 5m -- 5 minute timer
|
|
!timer 1h30m deploy -- named timer
|
|
!timer 90 -- 90 seconds
|
|
!timer list -- show active timers
|
|
!timer cancel deploy -- cancel a timer
|
|
"""
|
|
parts = message.text.split(None, 3)
|
|
if len(parts) < 2:
|
|
await bot.reply(message, "Usage: !timer <duration> [label] | list | cancel <label>")
|
|
return
|
|
|
|
target = message.target if message.is_channel else message.nick
|
|
sub = parts[1].lower()
|
|
|
|
# List timers
|
|
if sub == "list":
|
|
active = _timers.get(target, {})
|
|
if not active:
|
|
await bot.reply(message, "No active timers")
|
|
return
|
|
items = []
|
|
for label, task in sorted(active.items()):
|
|
# We can't easily get remaining time from asyncio.Task,
|
|
# so just show the label and status
|
|
status = "running" if not task.done() else "done"
|
|
items.append(f"{label} ({status})")
|
|
await bot.reply(message, f"Timers: {', '.join(items)}")
|
|
return
|
|
|
|
# Cancel timer
|
|
if sub == "cancel":
|
|
if len(parts) < 3:
|
|
await bot.reply(message, "Usage: !timer cancel <label>")
|
|
return
|
|
label = parts[2]
|
|
active = _timers.get(target, {})
|
|
task = active.get(label)
|
|
if task and not task.done():
|
|
task.cancel()
|
|
await bot.reply(message, f"Cancelled timer: {label}")
|
|
else:
|
|
await bot.reply(message, f"No active timer: {label}")
|
|
return
|
|
|
|
# Set timer
|
|
duration = _parse_duration(parts[1])
|
|
if duration is None:
|
|
await bot.reply(message, "Invalid duration (use: 5m, 1h30m, 90s, max 24h)")
|
|
return
|
|
|
|
label = parts[2] if len(parts) > 2 else _format_duration(duration)
|
|
|
|
if target not in _timers:
|
|
_timers[target] = {}
|
|
|
|
if len(_timers[target]) >= _MAX_TIMERS:
|
|
await bot.reply(message, f"Too many timers (max {_MAX_TIMERS}), cancel some first")
|
|
return
|
|
|
|
if label in _timers[target] and not _timers[target][label].done():
|
|
await bot.reply(message, f"Timer '{label}' already running, cancel it first")
|
|
return
|
|
|
|
task = asyncio.create_task(
|
|
_timer_task(bot, target, label, duration, message.nick),
|
|
)
|
|
_timers[target][label] = task
|
|
await bot.reply(message, f"Timer set: '{label}' ({_format_duration(duration)})")
|