mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-22 22:11:15 +00:00
feat(scheduler): optional flood scope for scheduled channel messages
Parse channel:#scope:body in [Scheduled_Messages], pass scope to send_channel_message; extend schedule list and docs.
This commit is contained in:
1 parent
d4eba9ad11
commit
4a0b989f5a
8 files changed
+229
-48
No files matched your search
@@ -68,8 +68,9 @@ class ScheduleCommand(BaseCommand):
|
||||
scheduled = self._get_scheduled_messages()
|
||||
if scheduled:
|
||||
lines.append(f"Scheduled ({len(scheduled)}):")
|
||||
for sched_display, channel, preview in scheduled:
|
||||
lines.append(f" {sched_display} #{channel}: {preview}")
|
||||
for sched_display, channel, preview, scope in scheduled:
|
||||
scope_part = f" ({scope})" if scope else ""
|
||||
lines.append(f" {sched_display} #{channel}{scope_part}: {preview}")
|
||||
else:
|
||||
lines.append("No scheduled messages configured.")
|
||||
|
||||
@@ -80,19 +81,28 @@ class ScheduleCommand(BaseCommand):
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_scheduled_messages(self) -> list[tuple[str, str, str]]:
|
||||
"""Return sorted list of (schedule_display, channel, preview) tuples."""
|
||||
def _get_scheduled_messages(self) -> list[tuple[str, str, str, str | None]]:
|
||||
"""Return sorted list of (schedule_display, channel, preview, scope) tuples."""
|
||||
scheduler = getattr(self.bot, "scheduler", None)
|
||||
if scheduler is None:
|
||||
return []
|
||||
|
||||
scheduled = getattr(scheduler, "scheduled_messages", {})
|
||||
rows: list[tuple[str, str, str, str]] = []
|
||||
rows: list[tuple[str, str, str, str, str | None]] = []
|
||||
for schedule_key, payload in scheduled.items():
|
||||
if len(payload) == 3:
|
||||
if len(payload) >= 4:
|
||||
channel, message, display_label, scope = (
|
||||
payload[0],
|
||||
payload[1],
|
||||
payload[2],
|
||||
payload[3],
|
||||
)
|
||||
elif len(payload) == 3:
|
||||
channel, message, display_label = payload
|
||||
scope = None
|
||||
else:
|
||||
channel, message = payload[0], payload[1]
|
||||
scope = None
|
||||
sk = schedule_key
|
||||
display_label = (
|
||||
f"{sk[:2]}:{sk[2:]}"
|
||||
@@ -107,9 +117,9 @@ class ScheduleCommand(BaseCommand):
|
||||
preview = (
|
||||
safe_message if len(safe_message) <= 40 else safe_message[:37] + "..."
|
||||
)
|
||||
rows.append((display_label, schedule_key, channel, preview))
|
||||
rows.append((display_label, schedule_key, channel, preview, scope))
|
||||
rows.sort(key=lambda r: (r[0].lower(), r[1]))
|
||||
return [(r[0], r[2], r[3]) for r in rows]
|
||||
return [(r[0], r[2], r[3], r[4]) for r in rows]
|
||||
|
||||
def _get_advert_info(self) -> Optional[str]:
|
||||
"""Return a one-line advert interval summary, or None if disabled."""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parse [Scheduled_Messages] option keys into APScheduler CronTrigger instances.
|
||||
Parse ``[Scheduled_Messages]`` option keys into APScheduler CronTrigger instances,
|
||||
and option values into ``(channel, message, scope)`` for optional regional flood scope.
|
||||
|
||||
Supports:
|
||||
Supports (schedule keys):
|
||||
- Standard 5-field crontab: minute hour day-of-month month day-of-week
|
||||
- Preset aliases: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly
|
||||
- Deprecated legacy HHMM (24-hour, no colon) for daily firing at that clock time
|
||||
@@ -15,6 +16,38 @@ from typing import Optional
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
|
||||
def parse_scheduled_message_value(raw: str) -> tuple[str, str, str | None]:
|
||||
"""Parse a ``[Scheduled_Messages]`` option value into ``(channel, message, scope)``.
|
||||
|
||||
**Legacy (unscoped):** ``channel:body`` — split on the first ``:`` only; ``scope`` is
|
||||
``None`` (global flood).
|
||||
|
||||
**Scoped:** ``channel:#region:body`` — exactly three segments from ``split(':', 2)``
|
||||
where the middle segment starts with ``#`` after strip. The message body may contain
|
||||
further colons. Scope must not contain ``:``.
|
||||
|
||||
Args:
|
||||
raw: Config value, e.g. ``Public:Hello`` or ``Public:#sea:Hello: more``.
|
||||
|
||||
Returns:
|
||||
``(channel, message, scope)`` with ``scope`` set only for the scoped form.
|
||||
|
||||
Raises:
|
||||
ValueError: If there is no ``:`` (cannot separate channel from body).
|
||||
"""
|
||||
s = (raw or "").strip()
|
||||
if ":" not in s:
|
||||
raise ValueError("scheduled message value must be channel:message")
|
||||
parts = s.split(":", 2)
|
||||
if len(parts) == 3 and parts[1].strip().startswith("#"):
|
||||
channel = parts[0].strip()
|
||||
scope = parts[1].strip()
|
||||
message = parts[2].strip()
|
||||
return channel, message, scope
|
||||
channel, message = s.split(":", 1)
|
||||
return channel.strip(), message.strip(), None
|
||||
|
||||
# Maps @preset (lowercase) -> 5-field crontab (APScheduler does not accept @syntax in from_crontab).
|
||||
_SPECIAL_PRESET_TO_CRON: dict[str, str] = {
|
||||
"@yearly": "0 0 1 1 *",
|
||||
|
||||
+43
-16
@@ -20,12 +20,15 @@ from apscheduler.triggers.date import DateTrigger
|
||||
from meshcore.events import EventType
|
||||
|
||||
from .maintenance import MaintenanceRunner
|
||||
from .scheduled_message_cron import is_valid_legacy_hhmm, parse_schedule_key
|
||||
from .scheduled_message_cron import (
|
||||
is_valid_legacy_hhmm,
|
||||
parse_schedule_key,
|
||||
parse_scheduled_message_value,
|
||||
)
|
||||
from .security_utils import validate_external_url
|
||||
from .utils import decode_escape_sequences, format_keyword_response_with_placeholders, get_config_timezone
|
||||
|
||||
|
||||
|
||||
class MessageScheduler:
|
||||
"""Manages scheduled messages and timing"""
|
||||
|
||||
@@ -94,25 +97,30 @@ class MessageScheduler:
|
||||
cron_suggestion,
|
||||
)
|
||||
|
||||
channel, message = message_info.split(':', 1)
|
||||
channel = channel.strip()
|
||||
message = decode_escape_sequences(message.strip())
|
||||
channel, message, scope = parse_scheduled_message_value(message_info)
|
||||
message = decode_escape_sequences(message)
|
||||
|
||||
job_id = "schedmsg_" + hashlib.sha256(
|
||||
f"{schedule_key}\0{channel}\0{message}".encode()
|
||||
f"{schedule_key}\0{channel}\0{scope or ''}\0{message}".encode()
|
||||
).hexdigest()[:24]
|
||||
|
||||
self._apscheduler.add_job(
|
||||
self.send_scheduled_message,
|
||||
parsed.trigger,
|
||||
args=[channel, message],
|
||||
kwargs={"schedule_key": schedule_key},
|
||||
kwargs={"schedule_key": schedule_key, "scope": scope},
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
)
|
||||
self.scheduled_messages[schedule_key] = (channel, message, parsed.display_label)
|
||||
self.scheduled_messages[schedule_key] = (
|
||||
channel,
|
||||
message,
|
||||
parsed.display_label,
|
||||
scope,
|
||||
)
|
||||
scope_note = f" scope={scope}" if scope else ""
|
||||
self.logger.info(
|
||||
f"Scheduled message: {parsed.display_label} -> {channel}: {message}"
|
||||
f"Scheduled message: {parsed.display_label} -> {channel}{scope_note}: {message}"
|
||||
)
|
||||
except ValueError:
|
||||
self.logger.warning(f"Invalid scheduled message format: {message_info}")
|
||||
@@ -234,7 +242,13 @@ class MessageScheduler:
|
||||
slot = int.from_bytes(digest[:4], "big") / (2**32)
|
||||
return float(slot * max_s)
|
||||
|
||||
def send_scheduled_message(self, channel: str, message: str, schedule_key: str = ""):
|
||||
def send_scheduled_message(
|
||||
self,
|
||||
channel: str,
|
||||
message: str,
|
||||
schedule_key: str = "",
|
||||
scope: str | None = None,
|
||||
):
|
||||
"""Send a scheduled message (synchronous wrapper for schedule library)"""
|
||||
if self.bot.is_radio_zombie:
|
||||
self.logger.warning("send_scheduled_message suppressed — radio is in zombie state")
|
||||
@@ -244,7 +258,11 @@ class MessageScheduler:
|
||||
return
|
||||
|
||||
current_time = self.get_current_time()
|
||||
self.logger.info(f"📅 Sending scheduled message at {current_time.strftime('%H:%M:%S')} to {channel}: {message}")
|
||||
scope_note = f" [{scope}]" if scope else ""
|
||||
self.logger.info(
|
||||
f"📅 Sending scheduled message at {current_time.strftime('%H:%M:%S')} "
|
||||
f"to {channel}{scope_note}: {message}"
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
@@ -253,8 +271,10 @@ class MessageScheduler:
|
||||
if hasattr(self.bot, 'main_event_loop') and self.bot.main_event_loop and self.bot.main_event_loop.is_running():
|
||||
# Schedule coroutine in the running main event loop
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self._send_scheduled_message_async(channel, message, schedule_key=schedule_key),
|
||||
self.bot.main_event_loop
|
||||
self._send_scheduled_message_async(
|
||||
channel, message, schedule_key=schedule_key, scope=scope
|
||||
),
|
||||
self.bot.main_event_loop,
|
||||
)
|
||||
# Wait for completion (with timeout to prevent indefinite blocking)
|
||||
try:
|
||||
@@ -270,7 +290,9 @@ class MessageScheduler:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
self._send_scheduled_message_async(channel, message, schedule_key=schedule_key)
|
||||
self._send_scheduled_message_async(
|
||||
channel, message, schedule_key=schedule_key, scope=scope
|
||||
)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
@@ -432,7 +454,12 @@ class MessageScheduler:
|
||||
return any(placeholder in message for placeholder in placeholders)
|
||||
|
||||
async def _send_scheduled_message_async(
|
||||
self, channel: str, message: str, *, schedule_key: str = ""
|
||||
self,
|
||||
channel: str,
|
||||
message: str,
|
||||
*,
|
||||
schedule_key: str = "",
|
||||
scope: str | None = None,
|
||||
):
|
||||
"""Send a scheduled message (async implementation)"""
|
||||
stagger = self._scheduled_message_stagger_seconds(schedule_key)
|
||||
@@ -464,7 +491,7 @@ class MessageScheduler:
|
||||
send_timeout = self.bot.config.getint('Bot', 'send_timeout_seconds', fallback=30)
|
||||
await _asyncio.wait_for(
|
||||
self.bot.command_manager.send_channel_message(
|
||||
channel, message, skip_user_rate_limit=True
|
||||
channel, message, skip_user_rate_limit=True, scope=scope
|
||||
),
|
||||
timeout=send_timeout,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user