Skip to content

fix(slack): harden slash/bang command handling — parse order, session keys, response_url delivery - #69479

Merged
teknium1 merged 13 commits into
mainfrom
slack/c3-commands
Jul 23, 2026
Merged

fix(slack): harden slash/bang command handling — parse order, session keys, response_url delivery#69479
teknium1 merged 13 commits into
mainfrom
slack/c3-commands

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Slack slash/bang command parsing is now robust across the full inbound pipeline (mention strip → bang rewrite → command detect → session key → reply delivery) — commands are no longer mangled by leading whitespace, mention stripping, rich-text enrichment, or DM-thread session-key mismatches, and slash replies are never silently dropped.

Fixes #19688, #55356.

Changes

Credits

Salvaged with authorship preserved: #56718 (@nu476), #30592 (@cypres0099), #43533 (@th3wingman), #39527 (@drafish), #26788 (@soynchux), #55357 (@ooiuuii), #52436 (@srojk34), #66310 (@PavelTajdus).
Supersedes #60907, #47115 (both covered by #30592's earlier mention/bang ordering fix), #59903 (covered by #43533's block-skip). #10052's Slack half was already fixed by #69320; its session_search half is deferred.

Validation

Check Result
scripts/run_tests.sh tests/gateway/ -q -k slack 642 passed, 0 failed
command-dispatch / get_command consumer sweeps 1099 + 940 passed
queue/steer/approval/e2e sweep 869 passed
Re-verified post-rebase by parent session green

Infographic

slack-command-pipeline

@teknium1 teknium1 added the platform/slack Slack app adapter label Jul 22, 2026
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

running on dc5d16f

CI timings

CI timings · View job

Wall time 8m3s vs 10m11s (-20.9%). 10 job(s) slower, 10 faster,

  • Python tests / Run tests slice 7/8: -47.0s
  • Python tests / Run tests slice 5/8: -30.0s
  • Python tests / Run tests slice 8/8: -26.0s
  • Python tests / Run tests slice 2/8: +26.0s
  • OSV scan / Scan lockfiles / osv-scan: -26.0s

@victor-kyriazakos

Copy link
Copy Markdown
Contributor

One correction before merge: when response_url delivery fails, the current fallback drops into chat_postMessage, which turns an ephemeral slash-command reply into a public channel message. That avoids silent loss, but it can expose /model, /status, /whoami, configuration, or future admin-command output that the invoker reasonably expected to remain private.

Suggested implementation: retain the invoking user_id in the stored slash context and fall back to chat_postEphemeral(channel=chat_id, user=user_id, ...). If that private fallback also fails, return a failed SendResult; only use public chat_postMessage behind an explicit policy. Please add a regression asserting that a failed response_url in a channel calls chat_postEphemeral for the invoker and never chat_postMessage. For a failure after some response-url chunks have already succeeded, keep the remainder private rather than replaying the full answer publicly.

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state needs-decision Awaiting maintainer decision before any implementation labels Jul 22, 2026
nu476 and others added 13 commits July 22, 2026 14:54
…ad identity

Slack rich_text blocks mirror the original message text. When bang
commands are rewritten from !model to /model, appending block text makes
the command arguments include a duplicate payload, so the model switcher
sees spaces in the model name and rejects valid commands like:

  !model qwen3.7-plus --provider opencode-go

Skip block extraction for command messages while preserving it for
normal messages. Also preserve Slack thread_ts (top-level or nested in
message/container payload shapes) on native slash-command payloads so
session-scoped commands like /model apply to the intended thread instead
of a channel+user key the next threaded message never matches.

Surgical reapply of PR #43533 (originally against gateway/platforms/slack.py,
now plugins/platforms/slack/adapter.py). Thread-shape widening credit also
to #66310.
…hread

_has_active_session_for_thread() hardcoded chat_type='group', causing
session key mismatch for DM and MPIM threads. DM sessions key as
agent:main:slack:dm:{chat_id}:{thread_ts} but the lookup built
agent:main:slack:group:{chat_id}:{user_id}:{thread_ts}.

Impact: _has_active_session_for_thread always returned False for DM
threads, causing thread context to be prepended on every message. The
prepended context broke slash command detection (get_command() checks
text.startswith('/')), so /cmd and !cmd never worked in DM threads.

Fix: accept event-derived chat_type parameter instead of hardcoding
'group'. Both call sites pass chat_type='dm' if is_dm else 'group',
where is_dm is already computed from channel_type in {'im', 'mpim'}.

This correctly handles:
- IM channels (D-prefix): chat_type='dm'
- MPIM channels (G-prefix): chat_type='dm' (was missed by D-prefix heuristic)
- Channel messages (C-prefix): chat_type='group' (unchanged)

Added regression tests covering DM thread lookup, MPIM thread lookup,
and negative cases verifying the old hardcoded 'group' behavior fails.
_pop_slash_context fell back to a channel-only scan when the
_slash_user_id ContextVar was unset (i.e. send() invoked from a
non-slash code path such as a cron delivery or a normal channel reply).
That scan could steal another user's pending slash reply context: the
normal message got swallowed into an ephemeral response_url POST that
replaces the invoker's ack, and the slash invoker's actual reply then
posted publicly. Remove the fallback — when the ContextVar is unset,
match nothing.

Surgical reapply of PR #26788 (originally against gateway/platforms/slack.py).
…rs (#46762)

Slack's send() caught all exceptions and returned a bare
SendResult(success=False) — never setting retryable=True or extracting
the server's Retry-After header.  When Slack returned a 429 rate-limit
error, the base _send_with_retry() layer saw retryable=False and did
not retry, silently dropping remaining message chunks.

Reuse the existing _is_retryable_upload_error() helper (which already
detects 429, 500+, and connection-type errors) to set retryable=True,
and extract the Retry-After header from the SlackApiError response
when present so the base retry layer honors Slack's backoff schedule
instead of its own default.

Sibling of the Telegram FloodWait fix (PR #46762 / commit 404b06a)
which added the SendResult.retry_after plumbing to the base layer.

Adds five regression tests covering 429 with/without Retry-After,
500 server errors, 403 non-retryable errors, and connection errors.
Commands typed in Slack could be mangled by every enrichment layer the
adapter applies to normal messages:

- Block Kit / unfurl / attachment-notice / text-file injection could
  prepend or append content around a command, moving the command token
  away from character zero or polluting its arguments. Commands are now
  restored from canonical authored input after all enrichment
  (final is_command_text guard before MessageEvent construction).
- @bot /cmd (typed slash behind a mention) was never classified as a
  command; the mention-strip branch now re-probes for both slash and
  bang forms.
- The Slack Agent-view context label ([Slack app context: ...]) was
  prepended to command events too; now command-exempt.
- Native slash payload arguments were strip()ed, destroying meaningful
  spacing inside/after arguments; only the command delimiter is
  nonsemantic now.
- Slash payload thread identity (thread_ts/message_ts, top-level or
  nested in message/container) is preserved onto SessionSource so
  session-scoped commands hit the same thread session.
- /queue and /steer queued fallbacks now propagate channel_context so a
  command that triggered first-entry thread backfill doesn't lose the
  history when re-queued.

Adapted from PR #66310 to the post-#69320 channel_context design (thread
history already rides MessageEvent.channel_context, never text).
Two silent-loss modes in the ephemeral slash reply path:

1. Delivery failure was swallowed: _send_slash_ephemeral returned
   success=True on any POST failure, so the user's actual command reply
   vanished behind the stale 'Running /cmd…' ack. It now returns
   success=False and send() falls back to normal channel delivery.
2. Long replies were truncated to the first ~39k chunk with no notice.
   Replies are now chunked across response_url POSTs (first replaces
   the ack, follow-ups append, all ephemeral), capped at Slack's 5-POST
   response_url budget with an explicit truncation notice when exceeded.

Also updates the #55357 bounded-error-read test for the #26788 precise
context matching (ContextVar must be set) and channel fallback.

Fixes #19688
- test_thread_command_skips_context_prefix: post-#69320 thread context IS
  fetched on first thread entry but rides channel_context; assert the
  command token stays at char zero and the backfill is preserved, instead
  of asserting the fetch never happens.
- test_slack_send_retry.py: main's _get_client() now takes team_id;
  update the lambda stubs.
…r slash replies

Per review (Victor): response_url failure does not mean ephemeral delivery
is impossible — chat.postEphemeral is an independent API path that keeps
the reply private. Public-channel fallback removed entirely; when both
ephemeral paths fail the reply is dropped with a logged error rather than
leaked to the channel. No config knob needed.
@teknium1
teknium1 force-pushed the slack/c3-commands branch from 342b61b to dc5d16f Compare July 22, 2026 21:55
@teknium1

Copy link
Copy Markdown
Contributor Author

Review feedback applied (thanks Victor): the public-channel fallback is gone. New chain when response_url delivery fails:

  1. chat.postEphemeral — independent API path, reply stays "Only visible to you" (the ack isn't replaced, the reply arrives below it)
  2. Both ephemeral paths fail → drop with a logged error + success=False. Never posts publicly. No config knob.

New regression test pins the invariant: test_send_slash_ephemeral_both_paths_fail_never_posts_publicly asserts chat_postMessage is never awaited on the slash-reply path.

@alt-glitch alt-glitch removed the needs-decision Awaiting maintainer decision before any implementation label Jul 22, 2026
@teknium1
teknium1 merged commit 700c8bf into main Jul 23, 2026
36 checks passed
@teknium1
teknium1 deleted the slack/c3-commands branch July 23, 2026 03:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/slack Slack app adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slack slash-command replies can be silently truncated or dropped when response_url delivery fails