diff --git a/cron/scheduler.py b/cron/scheduler.py index 97d0567300..0eccd458ff 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -360,12 +360,52 @@ def _normalize_deliver_value(deliver) -> str: return str(deliver) +# Routing intent tokens — resolved at fire time, not create time, so a +# job created before Telegram was wired up will pick up Telegram once it +# comes online. ``all`` expands into the set of connected platforms +# (those with a configured home chat_id) in _expand_routing_tokens. +_ROUTING_TOKENS = frozenset({"all"}) + + +def _expand_routing_tokens(part: str) -> List[str]: + """Expand a routing-intent token to concrete platform names. + + ``all`` expands to every platform in ``_iter_home_target_platforms()`` + that has a configured home chat_id right now. Unknown / non-token + values pass through unchanged as a single-element list, so the caller + can treat every token uniformly. + """ + token = part.lower() + if token not in _ROUTING_TOKENS: + return [part] + expanded: List[str] = [] + for platform_name in _iter_home_target_platforms(): + if _get_home_target_chat_id(platform_name): + expanded.append(platform_name) + return expanded + + def _resolve_delivery_targets(job: dict) -> List[dict]: - """Resolve all concrete auto-delivery targets for a cron job (supports comma-separated deliver).""" + """Resolve all concrete auto-delivery targets for a cron job. + + Accepts the legacy comma-separated ``deliver`` string plus the + ``all`` routing-intent token, which expands to every platform with + a configured home channel. Tokens may be combined with explicit + targets: ``origin,all`` and ``all,telegram:-100:17`` both work. + Duplicate (platform, chat_id, thread_id) tuples are collapsed by the + existing dedup pass. + """ deliver = _normalize_deliver_value(job.get("deliver", "local")) if deliver == "local": return [] - parts = [p.strip() for p in deliver.split(",") if p.strip()] + + raw_parts = [p.strip() for p in deliver.split(",") if p.strip()] + + # Expand routing intents. + parts: List[str] = [] + for raw in raw_parts: + parts.extend(_expand_routing_tokens(raw)) + seen = set() targets = [] for part in parts: diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 2182a1b17d..ce213a9f39 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -351,6 +351,95 @@ class TestResolveDeliveryTarget: assert _resolve_delivery_targets({"deliver": []}) == [] +class TestRoutingIntents: + """``all`` routing intent expands at fire time.""" + + def test_all_expands_to_every_connected_home_channel(self, monkeypatch): + """deliver='all' fans out to every platform with a configured home channel.""" + from cron.scheduler import _resolve_delivery_targets + + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111") + monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222") + monkeypatch.setenv("SLACK_HOME_CHANNEL", "C333") + # Sanity: platforms without the env var must NOT appear in the expansion. + monkeypatch.delenv("SIGNAL_HOME_CHANNEL", raising=False) + monkeypatch.delenv("MATRIX_HOME_ROOM", raising=False) + + targets = _resolve_delivery_targets({"deliver": "all", "origin": None}) + platforms = sorted(t["platform"] for t in targets) + + assert "telegram" in platforms + assert "discord" in platforms + assert "slack" in platforms + assert "signal" not in platforms + assert "matrix" not in platforms + + def test_all_combines_with_explicit_target_and_dedups(self, monkeypatch): + """'telegram:-999,all' yields every home channel + the explicit target without dupes.""" + from cron.scheduler import _resolve_delivery_targets + + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111") + monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222") + + # Explicit telegram target precedes 'all'. Expansion adds discord; + # the dedup pass collapses any (platform, chat_id, thread_id) repeats. + job = {"deliver": "telegram:-999,all", "origin": None} + targets = _resolve_delivery_targets(job) + + platforms = sorted(t["platform"].lower() for t in targets) + assert "telegram" in platforms + assert "discord" in platforms + # Every target is unique on (platform, chat_id, thread_id). + keys = [(t["platform"].lower(), str(t["chat_id"]), t.get("thread_id")) for t in targets] + assert len(keys) == len(set(keys)) + + def test_all_with_no_connected_channels_returns_empty(self, monkeypatch): + """deliver='all' with nothing connected returns [] — delivery is recorded as failed upstream.""" + from cron.scheduler import _resolve_delivery_targets + + for var in ("TELEGRAM_HOME_CHANNEL", "DISCORD_HOME_CHANNEL", "SLACK_HOME_CHANNEL", + "SIGNAL_HOME_CHANNEL", "MATRIX_HOME_ROOM", "MATTERMOST_HOME_CHANNEL", + "SMS_HOME_CHANNEL", "EMAIL_HOME_ADDRESS", "DINGTALK_HOME_CHANNEL", + "FEISHU_HOME_CHANNEL", "WECOM_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL", + "BLUEBUBBLES_HOME_CHANNEL", "QQBOT_HOME_CHANNEL", "QQ_HOME_CHANNEL"): + monkeypatch.delenv(var, raising=False) + + assert _resolve_delivery_targets({"deliver": "all", "origin": None}) == [] + + def test_origin_comma_all_preserves_origin_first(self, monkeypatch): + """'origin,all' delivers to the origin platform plus every other home channel.""" + from cron.scheduler import _resolve_delivery_targets + + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111") + monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222") + + job = { + "deliver": "origin,all", + "origin": {"platform": "discord", "chat_id": "888"}, + } + targets = _resolve_delivery_targets(job) + platforms = sorted(t["platform"].lower() for t in targets) + assert "telegram" in platforms + assert "discord" in platforms + + # The origin's explicit chat_id (888) wins the dedup race over the + # discord home channel (-222) because origin is resolved first. + discord = next(t for t in targets if t["platform"].lower() == "discord") + assert discord["chat_id"] == "888" + + def test_all_token_case_insensitive(self, monkeypatch): + """'ALL' / 'All' / 'all' are all recognized.""" + from cron.scheduler import _resolve_delivery_targets + + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "-111") + monkeypatch.setenv("DISCORD_HOME_CHANNEL", "-222") + + for token in ("ALL", "All", "all"): + targets = _resolve_delivery_targets({"deliver": token, "origin": None}) + platforms = sorted(t["platform"].lower() for t in targets) + assert platforms == ["discord", "telegram"], f"token={token!r} -> {platforms}" + + class TestDeliverResultWrapping: """Verify that cron deliveries are wrapped with header/footer and no longer mirrored.""" diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 5e9ffa51ea..b4cc4f69ec 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -541,7 +541,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "deliver": { "type": "string", - "description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), or platform:chat_id:thread_id for a specific destination. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering', 'sms:+15551234567'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting." + "description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), 'all' (fan out to every connected home channel), or platform:chat_id:thread_id for a specific destination. Combine with comma: 'origin,all' delivers to the origin plus every other connected channel. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering', 'sms:+15551234567', 'all'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting. 'all' resolves at fire time, so a job created before a channel was wired up will pick it up automatically once connected." }, "skills": { "type": "array", diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index f02b13934f..c2c67df8a2 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -240,9 +240,20 @@ When scheduling jobs, you specify where the output goes: | `"weixin"` | Weixin (WeChat) | | | `"bluebubbles"` | BlueBubbles (iMessage) | | | `"qqbot"` | QQ Bot (Tencent QQ) | | +| `"all"` | Fan out to every connected home channel | Resolved at fire time | +| `"telegram,discord"` | Fan out to a specific set of channels | Comma-separated list | +| `"origin,all"` | Deliver to the origin **plus** every other connected channel | Combine any tokens | The agent's final response is automatically delivered. You do not need to call `send_message` in the cron prompt. +### Routing intent (`all`) + +`all` lets you ship one cron job to every messaging channel you have configured, without having to enumerate them by name. It is **resolved at fire time**, so a job created before you wired up Telegram will pick up Telegram on the next tick after you set `TELEGRAM_HOME_CHANNEL`. + +Semantics: `all` expands to every platform with a configured home channel. Zero is fine; the job simply produces no delivery targets and is recorded as a delivery failure upstream. + +`all` composes with explicit targets. `origin,all` delivers to the origin chat *plus* every other connected home channel, de-duplicating by `(platform, chat_id, thread_id)`. + ### Response wrapping By default, delivered cron output is wrapped with a header and footer so the recipient knows it came from a scheduled task: