Files
derp/plugins/timer.py
user 530f33be76 feat: add wave 2 plugins and --cprofile CLI flag
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>
2026-02-15 01:58:47 +01:00

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)})")