mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-05-22 07:15:35 +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:
+7
-1
@@ -434,7 +434,13 @@ category.funfact = fun
|
||||
#category.fortune = fun
|
||||
|
||||
[Scheduled_Messages]
|
||||
# Scheduled message format: <schedule> = channel:message
|
||||
# Scheduled message format:
|
||||
# <schedule> = channel:message
|
||||
# <schedule> = channel:#flood_scope:message (regional flood — middle field must start with #)
|
||||
# The scoped form uses split(':', 2): the message body may contain additional colons.
|
||||
# Examples:
|
||||
# 0 18 * * * = Public:Hello! ...
|
||||
# 0 18 * * * = Public:#sea:Hello! ... (same channel text, sent with #sea flood scope)
|
||||
#
|
||||
# <schedule> is one of:
|
||||
# - 5-field crontab (minute hour day-of-month month day-of-week), e.g.
|
||||
|
||||
@@ -146,3 +146,7 @@ Some configuration can be reloaded without restarting the bot using the **`reloa
|
||||
## Pausing channel responses (remote)
|
||||
|
||||
Admins can DM **`channelpause`** or **`channelresume`** (see `[Admin_ACL]` in `config.ini`) to stop or resume bot reactions on **public channels** only—greeter, keywords, and commands on channels are skipped; DMs still work. The setting is **in memory only** (back to responding on channels after restart). Scheduled channel posts from the scheduler are **not** blocked by this toggle.
|
||||
|
||||
## Scheduled messages (`[Scheduled_Messages]`)
|
||||
|
||||
Each entry is `<schedule_key> = <value>` where the value is normally **`channel:message`** (first colon separates channel from body). For **regional flood scope** on that send only, use **`channel:#scope:message`**: the middle segment must start with `#` (same convention as `flood_scopes` / `outgoing_flood_scope_override`). The message body may contain more colons. Omit the middle field for classic global flood. See `config.ini.example` under `[Scheduled_Messages]` for examples. The **`schedule`** command lists each job with `(#scope)` when set.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -87,22 +87,22 @@ class TestBuildResponseEmpty:
|
||||
class TestBuildResponseWithSchedules:
|
||||
def test_shows_scheduled_count(self, bot):
|
||||
bot.scheduler.scheduled_messages = {
|
||||
"0900": ("general", "Good morning!", "09:00"),
|
||||
"1800": ("general", "Good evening!", "18:00"),
|
||||
"0900": ("general", "Good morning!", "09:00", None),
|
||||
"1800": ("general", "Good evening!", "18:00", None),
|
||||
}
|
||||
cmd = make_cmd(bot)
|
||||
response = cmd._build_response()
|
||||
assert "Scheduled (2)" in response
|
||||
|
||||
def test_formats_time_as_hhmm(self, bot):
|
||||
bot.scheduler.scheduled_messages = {"0930": ("general", "Hello", "09:30")}
|
||||
bot.scheduler.scheduled_messages = {"0930": ("general", "Hello", "09:30", None)}
|
||||
cmd = make_cmd(bot)
|
||||
response = cmd._build_response()
|
||||
assert "09:30" in response
|
||||
|
||||
def test_shows_channel_and_message(self, bot):
|
||||
bot.scheduler.scheduled_messages = {
|
||||
"1200": ("alerts", "Noon check-in", "12:00"),
|
||||
"1200": ("alerts", "Noon check-in", "12:00", None),
|
||||
}
|
||||
cmd = make_cmd(bot)
|
||||
response = cmd._build_response()
|
||||
@@ -111,7 +111,7 @@ class TestBuildResponseWithSchedules:
|
||||
|
||||
def test_long_message_is_truncated(self, bot):
|
||||
long_msg = "A" * 100
|
||||
bot.scheduler.scheduled_messages = {"0800": ("general", long_msg, "08:00")}
|
||||
bot.scheduler.scheduled_messages = {"0800": ("general", long_msg, "08:00", None)}
|
||||
cmd = make_cmd(bot)
|
||||
response = cmd._build_response()
|
||||
# Preview should be ≤43 chars (40 + "...")
|
||||
@@ -124,9 +124,9 @@ class TestBuildResponseWithSchedules:
|
||||
|
||||
def test_entries_sorted_by_time(self, bot):
|
||||
bot.scheduler.scheduled_messages = {
|
||||
"1800": ("general", "Evening", "18:00"),
|
||||
"0600": ("general", "Morning", "06:00"),
|
||||
"1200": ("general", "Noon", "12:00"),
|
||||
"1800": ("general", "Evening", "18:00", None),
|
||||
"0600": ("general", "Morning", "06:00", None),
|
||||
"1200": ("general", "Noon", "12:00", None),
|
||||
}
|
||||
cmd = make_cmd(bot)
|
||||
response = cmd._build_response()
|
||||
@@ -136,16 +136,26 @@ class TestBuildResponseWithSchedules:
|
||||
|
||||
def test_shows_cron_schedule_string(self, bot):
|
||||
bot.scheduler.scheduled_messages = {
|
||||
"0 9 * * *": ("general", "Cron hello", "0 9 * * *"),
|
||||
"0 9 * * *": ("general", "Cron hello", "0 9 * * *", None),
|
||||
}
|
||||
cmd = make_cmd(bot)
|
||||
response = cmd._build_response()
|
||||
assert "0 9 * * *" in response
|
||||
assert "Cron hello" in response
|
||||
|
||||
def test_shows_flood_scope_when_set(self, bot):
|
||||
bot.scheduler.scheduled_messages = {
|
||||
"0 18 * * *": ("Public", "Hello mesh", "0 18 * * *", "#sea"),
|
||||
}
|
||||
cmd = make_cmd(bot)
|
||||
response = cmd._build_response()
|
||||
assert "(#sea)" in response
|
||||
assert "#Public" in response
|
||||
assert "Hello mesh" in response
|
||||
|
||||
def test_shows_at_preset_label(self, bot):
|
||||
bot.scheduler.scheduled_messages = {
|
||||
"@hourly": ("general", "tick", "@hourly"),
|
||||
"@hourly": ("general", "tick", "@hourly", None),
|
||||
}
|
||||
cmd = make_cmd(bot)
|
||||
response = cmd._build_response()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for scheduled_message_cron value parsing."""
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.scheduled_message_cron import parse_scheduled_message_value
|
||||
|
||||
|
||||
class TestParseScheduledMessageValue:
|
||||
def test_legacy_channel_body(self):
|
||||
ch, msg, scope = parse_scheduled_message_value("Public:Hello!")
|
||||
assert ch == "Public"
|
||||
assert msg == "Hello!"
|
||||
assert scope is None
|
||||
|
||||
def test_scoped_channel_scope_body(self):
|
||||
ch, msg, scope = parse_scheduled_message_value("Public:#sea:Hello!")
|
||||
assert ch == "Public"
|
||||
assert scope == "#sea"
|
||||
assert msg == "Hello!"
|
||||
|
||||
def test_scoped_body_may_contain_colons(self):
|
||||
ch, msg, scope = parse_scheduled_message_value("Public:#sea:Hello: with colons")
|
||||
assert ch == "Public"
|
||||
assert scope == "#sea"
|
||||
assert msg == "Hello: with colons"
|
||||
|
||||
def test_three_parts_middle_not_hash_is_legacy(self):
|
||||
ch, msg, scope = parse_scheduled_message_value("Public:Hello: world")
|
||||
assert ch == "Public"
|
||||
assert msg == "Hello: world"
|
||||
assert scope is None
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
ch, msg, scope = parse_scheduled_message_value(" gen : #w : hi ")
|
||||
assert ch == "gen"
|
||||
assert scope == "#w"
|
||||
assert msg == "hi"
|
||||
|
||||
def test_no_colon_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_scheduled_message_value("nocolon")
|
||||
|
||||
def test_only_channel_hash_scope_two_segments_legacy(self):
|
||||
"""Two segments after split(':',2) — middle does not get # form; legacy parse."""
|
||||
ch, msg, scope = parse_scheduled_message_value("Public:#sea")
|
||||
assert ch == "Public"
|
||||
assert msg == "#sea"
|
||||
assert scope is None
|
||||
@@ -140,7 +140,8 @@ class TestSetupScheduledMessages:
|
||||
scheduler.bot.config.set("Scheduled_Messages", "0900", "general: Good morning!")
|
||||
self._setup_and_call(scheduler)
|
||||
assert "0900" in scheduler.scheduled_messages
|
||||
channel, message, label = scheduler.scheduled_messages["0900"]
|
||||
channel, message, label, scope = scheduler.scheduled_messages["0900"]
|
||||
assert scope is None
|
||||
assert channel == "general"
|
||||
assert "Good morning!" in message
|
||||
assert label == "09:00"
|
||||
@@ -163,7 +164,8 @@ class TestSetupScheduledMessages:
|
||||
)
|
||||
self._setup_and_call(scheduler)
|
||||
assert "0 9 * * *" in scheduler.scheduled_messages
|
||||
ch, msg, label = scheduler.scheduled_messages["0 9 * * *"]
|
||||
ch, msg, label, _scope = scheduler.scheduled_messages["0 9 * * *"]
|
||||
assert _scope is None
|
||||
assert ch == "general"
|
||||
assert "Morning cron" in msg
|
||||
assert label == "0 9 * * *"
|
||||
@@ -171,7 +173,27 @@ class TestSetupScheduledMessages:
|
||||
msg_jobs = [
|
||||
j for j in scheduler._apscheduler.get_jobs() if j.id.startswith("schedmsg_")
|
||||
]
|
||||
assert msg_jobs[0].kwargs == {"schedule_key": "0 9 * * *"}
|
||||
assert msg_jobs[0].kwargs == {"schedule_key": "0 9 * * *", "scope": None}
|
||||
self._teardown(scheduler)
|
||||
|
||||
def test_scoped_flood_message_stores_scope_and_job_kwargs(self, scheduler):
|
||||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||||
scheduler.bot.config.set(
|
||||
"Scheduled_Messages",
|
||||
"0 18 * * *",
|
||||
"Public:#sea:Hello! Come send test messages.",
|
||||
)
|
||||
self._setup_and_call(scheduler)
|
||||
assert "0 18 * * *" in scheduler.scheduled_messages
|
||||
ch, msg, label, scope = scheduler.scheduled_messages["0 18 * * *"]
|
||||
assert ch == "Public"
|
||||
assert scope == "#sea"
|
||||
assert msg.startswith("Hello!")
|
||||
msg_jobs = [
|
||||
j for j in scheduler._apscheduler.get_jobs() if j.id.startswith("schedmsg_")
|
||||
]
|
||||
assert len(msg_jobs) == 1
|
||||
assert msg_jobs[0].kwargs == {"schedule_key": "0 18 * * *", "scope": "#sea"}
|
||||
self._teardown(scheduler)
|
||||
|
||||
def test_at_weekly_preset_registered(self, scheduler):
|
||||
@@ -236,7 +258,8 @@ class TestSetupScheduledMessages:
|
||||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||||
scheduler.bot.config.set("Scheduled_Messages", "1000", r"general: Line1\nLine2")
|
||||
self._setup_and_call(scheduler)
|
||||
_, message, label = scheduler.scheduled_messages["1000"]
|
||||
_, message, label, _sc = scheduler.scheduled_messages["1000"]
|
||||
assert _sc is None
|
||||
assert "\n" in message
|
||||
assert label == "10:00"
|
||||
self._teardown(scheduler)
|
||||
@@ -987,7 +1010,7 @@ class TestSendScheduledMessageAsync:
|
||||
scheduler.bot.command_manager.send_channel_message = AsyncMock()
|
||||
asyncio.run(scheduler._send_scheduled_message_async("general", "Hello world"))
|
||||
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
|
||||
"general", "Hello world", skip_user_rate_limit=True
|
||||
"general", "Hello world", skip_user_rate_limit=True, scope=None
|
||||
)
|
||||
|
||||
def test_with_placeholder_calls_get_mesh_info_and_formats(self):
|
||||
@@ -1024,7 +1047,7 @@ class TestSendScheduledMessageAsync:
|
||||
)
|
||||
mock_fmt.assert_called_once()
|
||||
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
|
||||
"general", "Contacts: 42", skip_user_rate_limit=True
|
||||
"general", "Contacts: 42", skip_user_rate_limit=True, scope=None
|
||||
)
|
||||
|
||||
def test_get_mesh_info_exception_sends_message_as_is(self):
|
||||
@@ -1042,7 +1065,7 @@ class TestSendScheduledMessageAsync:
|
||||
)
|
||||
)
|
||||
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
|
||||
"general", "Active: {total_contacts}", skip_user_rate_limit=True
|
||||
"general", "Active: {total_contacts}", skip_user_rate_limit=True, scope=None
|
||||
)
|
||||
|
||||
def test_format_placeholder_exception_sends_message_as_is(self):
|
||||
@@ -1064,7 +1087,19 @@ class TestSendScheduledMessageAsync:
|
||||
)
|
||||
)
|
||||
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
|
||||
"alerts", "Count: {total_contacts}", skip_user_rate_limit=True
|
||||
"alerts", "Count: {total_contacts}", skip_user_rate_limit=True, scope=None
|
||||
)
|
||||
|
||||
def test_passes_scope_to_send_channel_message(self):
|
||||
scheduler = _make_scheduler()
|
||||
scheduler.bot.command_manager.send_channel_message = AsyncMock()
|
||||
asyncio.run(
|
||||
scheduler._send_scheduled_message_async(
|
||||
"Public", "Hi", scope="#sea"
|
||||
)
|
||||
)
|
||||
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
|
||||
"Public", "Hi", skip_user_rate_limit=True, scope="#sea"
|
||||
)
|
||||
|
||||
def test_stagger_invokes_sleep_when_configured(self):
|
||||
@@ -1140,7 +1175,13 @@ class TestSendScheduledMessageWrapper:
|
||||
|
||||
mock_loop = Mock()
|
||||
|
||||
async def _fake_send(channel: str, message: str, *, schedule_key: str = "") -> None:
|
||||
async def _fake_send(
|
||||
channel: str,
|
||||
message: str,
|
||||
*,
|
||||
schedule_key: str = "",
|
||||
scope: str | None = None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
def _run_until_complete(coro):
|
||||
@@ -1156,7 +1197,9 @@ class TestSendScheduledMessageWrapper:
|
||||
|
||||
mock_loop.run_until_complete.assert_called_once()
|
||||
mock_loop.close.assert_called_once()
|
||||
mock_send.assert_called_once_with("general", "test message", schedule_key="")
|
||||
mock_send.assert_called_once_with(
|
||||
"general", "test message", schedule_key="", scope=None
|
||||
)
|
||||
|
||||
def test_suppressed_when_radio_zombie(self):
|
||||
scheduler = _make_scheduler()
|
||||
@@ -1574,7 +1617,7 @@ class TestSendScheduledMessageAsyncTimeout:
|
||||
asyncio.run(sched._send_scheduled_message_async("#general", "hello"))
|
||||
|
||||
sched.bot.command_manager.send_channel_message.assert_awaited_once_with(
|
||||
"#general", "hello", skip_user_rate_limit=True
|
||||
"#general", "hello", skip_user_rate_limit=True, scope=None
|
||||
)
|
||||
|
||||
def test_timeout_raises_asyncio_timeout_error(self, mock_logger):
|
||||
|
||||
Reference in New Issue
Block a user