Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 162 additions & 1 deletion cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,102 @@ def _seed_cron_thread_session(
)


def _seed_cron_channel_session(
job: dict,
adapter,
platform_name: str,
chat_id: str,
mirror_text: str,
*,
is_dm: bool,
user_id: Optional[str],
chat_name: Optional[str] = None,
) -> bool:
"""Seed the FLAT (thread_id=None) session for an ``in_channel`` cron delivery.

The ``in_channel`` surface (D1/D2) delivers the brief flat into the channel
with no thread, so the continuation surface is the whole-channel /
whole-DM session keyed ``thread_id=None`` — the same bucket
``reply_in_thread: false`` routes an inbound plain reply to.

Unlike the thread path, the shipped delivery-mirror alone is NOT sufficient
here: ``mirror_to_session`` only APPENDS to a session that already EXISTS
(``_find_session_id`` → no-op when none matches), and a flat channel
``(…, None)`` row is only created when a human posts a top-level message the
bot processes — a ``chat_postMessage`` cron delivery never goes through the
inbound handler, so the row is usually absent and the mirror silently drops
the brief (verified live: the brief never landed, the reply had no context).
So we CREATE the flat session row first, exactly like
``_seed_cron_thread_session`` does for threads, then mirror into it.

The session KEY must match what the user's later inbound reply resolves to
(``build_session_key``):
- **Channel** (``chat_type="group"``): key is
``…:group:<chat_id>:<user_id>`` — user-isolated — so the seed MUST carry
the **origin's real ``user_id``** (the member who scheduled the job), NOT
a synthetic ``system:cron`` id, or the reply keys to a different session.
- **1:1 DM** (``chat_type="dm"``): the key is ``…:dm:<chat_id>`` and does
NOT embed ``user_id``, so any ``user_id`` resolves to the same session.
``chat_type`` mirrors the inbound handler's own choice
(``"dm" if is_dm else "group"``, ``adapter.py``), so the seeded key is
byte-identical to the reply's key.

Returns True if a seed row was created and the brief mirrored, else False
(caller falls back to the plain mirror). Best-effort — a delivery that
already succeeded is never failed by a seeding problem.
"""
text = (mirror_text or "").strip()
if not text:
return False
try:
from gateway.config import Platform
from gateway.session import SessionSource

chat_type = "dm" if is_dm else "group"
session_store = getattr(adapter, "_session_store", None)
if session_store is not None:
try:
platform_enum = Platform(platform_name.lower())
except (ValueError, KeyError):
platform_enum = None
if platform_enum is not None:
dest_source = SessionSource(
platform=platform_enum,
chat_id=str(chat_id),
chat_name=chat_name,
chat_type=chat_type,
user_id=str(user_id) if user_id else None,
thread_id=None, # flat — the whole-channel/DM session
)
# Create the flat session row so the mirror has a target and the
# user's later plain reply joins the SAME session.
session_store.get_or_create_session(dest_source)

from gateway.mirror import mirror_to_session

ok = mirror_to_session(
platform_name,
str(chat_id),
f"[Cron delivery: {job.get('name') or job.get('id', 'cron')}]\n{text}",
source_label="cron",
thread_id=None,
user_id=str(user_id) if user_id else None,
role="user",
)
if ok:
logger.info(
"Job '%s': seeded flat in_channel session on %s:%s (chat_type=%s)",
job.get("id", "?"), platform_name, chat_id, chat_type,
)
return bool(ok)
except Exception as e:
logger.debug(
"Job '%s': seeding in_channel session failed for %s:%s: %s",
job.get("id", "?"), platform_name, chat_id, e,
)
return False


def _cron_job_origin_log_suffix(job: dict) -> str:
"""Return safe provenance details for security warnings about a cron job.

Expand Down Expand Up @@ -1205,6 +1301,50 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
delivered = False
target_errors = []

# Continuable cron surface (D1/D2/D6): resolve the delivery surface for
# this platform generically from its config ``extra``. Default "thread"
# (today's behaviour, byte-identical). "in_channel" delivers the brief
# FLAT into the channel (no dedicated thread) so a plain channel reply
# continues the job in-context via the shared-channel session
# ``(platform, chat_id, None)`` — the same bucket ``reply_in_thread:
# false`` routes inbound channel messages to. The key is read
# generically here (any platform); the ``in_channel`` branch is gated on
# the adapter capability flag ``supports_inchannel_continuable`` so an
# unsupported platform fails SAFE to "thread" (Slack is the first
# consumer; "first consumer ≠ definition").
surface_mode = "thread"
try:
surface_raw = (pconfig.extra or {}).get("cron_continuable_surface")
if surface_raw is not None and str(surface_raw).strip().lower() == "in_channel":
surface_mode = "in_channel"
except Exception:
surface_mode = "thread"
in_channel_surface = surface_mode == "in_channel"
if in_channel_surface and runtime_adapter is not None and not getattr(
runtime_adapter, "supports_inchannel_continuable", False
):
# Fail safe (D6): platform has no in_channel continuation primitive.
logger.debug(
"Job '%s': cron_continuable_surface=in_channel not supported on "
"%s, using thread",
job.get("id", "?"), platform_name,
)
in_channel_surface = False

# For an in_channel delivery the flat continuation session is created
# explicitly below (the shipped mirror only APPENDS to an existing
# session, and the flat channel row is otherwise absent for a
# chat_postMessage delivery). ``is_dm`` selects the session chat_type so
# the seeded key matches the inbound reply's key: a 1:1 DM keys as
# ``dm`` (Slack DM channel ids start with "D"; or the origin says so),
# everything else as ``group`` (shared channel). ``inchannel_seeded``
# suppresses the generic mirror below so the brief is not double-written.
origin_chat_type = str(origin.get("chat_type") or "").lower()
is_dm_target = origin_chat_type == "dm" or (
not origin_chat_type and str(chat_id).startswith("D")
)
inchannel_seeded = False

# Continuable cron (thread-preferred): when mirroring is enabled for the
# origin target and the gateway is live, try to open a DEDICATED thread
# for this job and deliver the brief into it. On thread-capable
Expand All @@ -1213,10 +1353,20 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
# continues with full context. On DM-only platforms (WhatsApp/Signal)
# create_handoff_thread returns None and we fall back to mirroring into
# the origin DM session (handled after delivery). Cf. _process_handoff.
#
# in_channel surface (D2): SKIP thread creation entirely — leave
# thread_id=None so the delivery posts flat, then
# ``_seed_cron_channel_session`` (below) CREATES the shared-channel
# session and mirrors the brief into it. The shipped mirror alone is
# NOT enough here: ``mirror_to_session`` only APPENDS to an existing
# session and a flat ``(platform, chat_id, None)`` row is otherwise
# absent for a ``chat_postMessage`` delivery, so the seed must create
# the row first (F5).
thread_seeded = False
opened_thread_id: Optional[str] = None
if (
mirror_this_target
and not in_channel_surface
and runtime_adapter is not None
and loop is not None
and not thread_id # never override an explicit origin thread/topic
Expand Down Expand Up @@ -1454,10 +1604,21 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
chat_name=origin.get("chat_name"),
)
thread_seeded = True
# in_channel surface: CREATE + seed the flat channel/DM
# session (the shipped mirror only appends to an existing
# session — the flat row is otherwise absent for a
# chat_postMessage delivery, so the brief would be lost).
if in_channel_surface and mirror_this_target and not thread_seeded:
inchannel_seeded = _seed_cron_channel_session(
job, runtime_adapter, platform_name, chat_id,
mirror_text, is_dm=is_dm_target,
user_id=origin_user_id,
chat_name=origin.get("chat_name"),
)
_maybe_mirror_cron_delivery(
job, platform_name, chat_id, mirror_text,
thread_id=thread_id, user_id=origin_user_id,
enabled=mirror_this_target and not thread_seeded,
enabled=mirror_this_target and not thread_seeded and not inchannel_seeded,
)
except Exception as e:
err_msg = f"live adapter delivery to {platform_name}:{chat_id} failed: {e}"
Expand Down
2 changes: 2 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,8 @@ def _merge_platform_map(source_platforms: Any) -> None:
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
if "reply_in_thread" in platform_cfg:
bridged["reply_in_thread"] = platform_cfg["reply_in_thread"]
if "cron_continuable_surface" in platform_cfg:
bridged["cron_continuable_surface"] = platform_cfg["cron_continuable_surface"]
if "require_mention" in platform_cfg:
bridged["require_mention"] = platform_cfg["require_mention"]
if plat == Platform.TELEGRAM and "allowed_chats" in platform_cfg:
Expand Down
15 changes: 15 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2307,6 +2307,21 @@ class BasePlatformAdapter(ABC):
# "typed_command_prefix", "/"); no per-platform branching at call sites.
typed_command_prefix: str = "/"

# Whether this adapter supports the ``in_channel`` continuable-cron surface
# (``platforms.<p>.extra.cron_continuable_surface: in_channel``): a
# continuable cron job delivered FLAT into a channel (no dedicated thread),
# with the user's plain channel reply continuing the job in-context via the
# shared-channel session. Only coherent on a platform that has BOTH a
# flat-reply outbound gate AND a whole-channel inbound session bucket keyed
# ``(platform, chat_id, None)`` — today that is Slack (``reply_in_thread:
# false``). Default False: an unsupported platform fails SAFE, treating
# ``in_channel`` as ``thread`` (a threaded continuation ≈ today's
# behaviour), never a dropped continuation. Read generically by the cron
# scheduler via ``getattr(adapter, "supports_inchannel_continuable",
# False)`` — no per-platform branching at the call site (the key stays a
# generic seam; Slack is merely the first consumer).
supports_inchannel_continuable: bool = False

def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
Expand Down
73 changes: 69 additions & 4 deletions plugins/platforms/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,14 @@ class SlackAdapter(BasePlatformAdapter):
# the prefix that works everywhere — instruction text must show it.
typed_command_prefix = "!"

# Slack has both halves the ``in_channel`` continuable-cron surface needs:
# a flat-reply outbound gate (``reply_in_thread: false`` → ``_resolve_thread_ts``
# returns None for top-level channel messages) AND a whole-channel inbound
# session bucket keyed ``(platform, channel_id, None)`` (the same
# ``reply_in_thread: false`` path in ``_handle_slack_message``). So a
# continuable cron delivered flat here continues in-context on a plain reply.
supports_inchannel_continuable = True

def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SLACK)
self._app: Optional[Any] = None
Expand Down Expand Up @@ -1073,6 +1081,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:

self._warn_if_missing_group_dm_scopes(auth_response, team_name)
self._warn_if_not_bot_token(auth_response, team_name)
self._warn_if_inchannel_without_flat_reply(team_name)

# Register message event handler
@self._app.event("message")
Expand Down Expand Up @@ -1562,6 +1571,62 @@ def _dm_top_level_threads_as_sessions(self) -> bool:
return True # default: each DM thread is its own session
return str(raw).strip().lower() in {"1", "true", "yes", "on"}

def _cron_continuable_surface(self) -> str:
"""Resolve the continuable-cron delivery surface for this platform.

Values: ``"thread"`` (default — today's behaviour: a continuable cron
job opens a dedicated hidden thread and seeds it) or ``"in_channel"``
(deliver FLAT into the channel timeline; the shared-channel session
``(slack, channel_id, None)`` is the continuation surface). Set
``platforms.slack.extra.cron_continuable_surface: in_channel`` in
config.yaml. Pair with ``reply_in_thread: false`` so the user's reply
is answered flat in the channel and keyed to the same shared session —
see ``_warn_if_inchannel_without_flat_reply``. Any unrecognised value
coerces to ``"thread"`` (fail safe).
"""
raw = self.config.extra.get("cron_continuable_surface")
if raw is None:
return "thread"
val = str(raw).strip().lower()
return "in_channel" if val == "in_channel" else "thread"

def _warn_if_inchannel_without_flat_reply(self, team_name: str) -> None:
"""Warn when ``in_channel`` is set without the required ``reply_in_thread: false`` pairing.

The two knobs are orthogonal (D4/D5): ``cron_continuable_surface:
in_channel`` skips thread creation on delivery, and ``reply_in_thread:
false`` makes the bot answer inbound channel messages flat and key them
to the whole-channel session ``(slack, channel_id, None)``. For a
continuable in-channel cron to actually continue on a plain reply, BOTH
must hold: the seed lands in the shared-channel session, and the reply
must resolve to (and be answered in) that same flat session.

Enforcement is WARN, not hard-require (D5): the misconfiguration fails
SAFE — ``in_channel`` without ``reply_in_thread: false`` yields a
threaded continuation (≈ today's behaviour), never a dropped/orphaned
session — so a config-load rejection would be heavier than warranted
and would make the two knobs non-orthogonal. Mirrors the existing
connect-time warning pattern (``_warn_if_missing_group_dm_scopes``,
``_warn_if_not_bot_token``).
"""
try:
if self._cron_continuable_surface() != "in_channel":
return
# reply_in_thread defaults True (legacy: reply in a thread).
if self.config.extra.get("reply_in_thread", True):
logger.warning(
"[Slack] %s: cron_continuable_surface=in_channel is set "
"WITHOUT reply_in_thread=false. A continuable in-channel "
"cron job will deliver flat, but the bot will still reply "
"to your continuation in a thread — so it falls back to a "
"threaded continuation (\u2248 default behaviour), not the "
"flat channel session you asked for. Set "
"platforms.slack.extra.reply_in_thread: false to pair them.",
team_name,
)
except Exception:
pass
Comment on lines +1593 to +1628

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 No warning when DM in_channel cron requires dm_top_level_threads_as_sessions=false (bug)

The _warn_if_inchannel_without_flat_reply method at plugins/platforms/slack/adapter.py line 1593 emits a connect-time warning when cron_continuable_surface=in_channel is set without reply_in_thread=false (channel-level config). However, it does not warn about the DM-level requirement: DM continuation with in_channel cron requires dm_top_level_threads_as_sessions=false. Under the default (true), a DM reply to a flat cron seed creates a per-message session that diverges from the flat seed session key, silently breaking the continuation. The e2e test at tests/manual/cron_inchannel_dm_e2e.py confirms this divergence.

💡 Suggestion: Extend _warn_if_inchannel_without_flat_reply to also check dm_top_level_threads_as_sessions when cron_continuable_surface=in_channel, emitting an additional warning when it defaults to True.

📋 Prompt for AI Agents

In plugins/platforms/slack/adapter.py, method _warn_if_inchannel_without_flat_reply (around line 1612-1627): after the existing reply_in_thread check, add a second check: examine self.config.extra.get('dm_top_level_threads_as_sessions', True) and if True, emit a logger.warning that DM continuations with cron_continuable_surface=in_channel require dm_top_level_threads_as_sessions: false. Direct users to set platforms.slack.extra.dm_top_level_threads_as_sessions: false in config.yaml.


def _resolve_thread_ts(
self,
reply_to: Optional[str] = None,
Expand Down Expand Up @@ -2097,10 +2162,10 @@ async def send_image(

async def _ssrf_redirect_guard(response):
"""Re-check redirect targets so public URLs cannot bounce into private IPs."""
if response.is_redirect and response.next_request:
redirect_url = str(response.next_request.url)
if not is_safe_url(redirect_url):
raise ValueError("Blocked redirect to private/internal address")
from tools.url_safety import redirect_target_from_response
redirect_url = redirect_target_from_response(response)
if redirect_url and not is_safe_url(redirect_url):
raise ValueError("Blocked redirect to private/internal address")
Comment on lines 2163 to +2168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 redirect_target_from_response imported but never defined -- breaks Slack send_image redirect guard (bug)

In plugins/platforms/slack/adapter.py line 2165, the PR replaces the inline redirect-guard logic with from tools.url_safety import redirect_target_from_response. The function does not exist in tools/url_safety.py or anywhere else. The old code correctly checked response.is_redirect and response.next_request before accessing response.next_request.url. The new code will raise ImportError every time the httpx response event hook fires during image download in Slack, crashing the image send and falling through to text-only fallback -- simultaneously disabling SSRF redirect validation. The same missing function is also imported in gateway/platforms/base.py line 549 (pre-existing bug, not introduced here), but this PR introduces a NEW broken call site that replaces previously-working code.

💡 Suggestion: Revert the slack adapter _ssrf_redirect_guard to the original inline redirect-checking code that works with httpx response objects directly: check response.is_redirect and response.next_request, then extract str(response.next_request.url). This restores the working SSRF redirect guard that was replaced by a call to a non-existent function.

Suggested change
async def _ssrf_redirect_guard(response):
"""Re-check redirect targets so public URLs cannot bounce into private IPs."""
if response.is_redirect and response.next_request:
redirect_url = str(response.next_request.url)
if not is_safe_url(redirect_url):
raise ValueError("Blocked redirect to private/internal address")
from tools.url_safety import redirect_target_from_response
redirect_url = redirect_target_from_response(response)
if redirect_url and not is_safe_url(redirect_url):
raise ValueError("Blocked redirect to private/internal address")
async def _ssrf_redirect_guard(response):
"""Re-check redirect targets so public URLs cannot bounce into private IPs."""
if response.is_redirect and response.next_request:
redirect_url = str(response.next_request.url)
if not is_safe_url(redirect_url):
raise ValueError("Blocked redirect to private/internal address")
📋 Prompt for AI Agents

In plugins/platforms/slack/adapter.py lines 2163-2168, revert the _ssrf_redirect_guard inner function to the original inline redirect-checking code. Replace the import of redirect_target_from_response (which does not exist) with direct httpx response attribute checks: check response.is_redirect and response.next_request, then extract str(response.next_request.url). This restores the SSRF redirect validation that was broken by the refactor.


# Download the image first
async with httpx.AsyncClient(
Expand Down
Loading
Loading