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.
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
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).
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 404b06ac4)
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.
_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).
_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.
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.
Removes Homebrew and PyPI wheel/sdist as Hermes distribution paths while
preserving the supported source, Docker, and Nix workflows.
Changes:
- Removes the Homebrew formula, PyPI publish workflow, sdist manifest
(MANIFEST.in), and wheel/sdist release-attachment logic from scripts/release.py.
- Keeps setuptools metadata and entry points required by editable installs
and Docker/Nix builds, but adds a setup.py guard that rejects wheel/sdist
builds outside a sealed Nix derivation (HERMES_NIX_BUILD=1).
- Removes pip/Homebrew install detection, PyPI update checks, the pip
self-update path, the deprecation-banner state, the postinstall subcommand,
wheel data-directory fallbacks in agent/i18n.py and hermes_constants.py,
and the ACP Registry manifest/version-lockstep release logic.
- Adds /nix/store/ path detection so `nix run` / `nix profile install`
installs (which don't set HERMES_MANAGED) are correctly identified as
"nix" rather than falling through to "git"/"unknown".
- Retired install-method values ("pip", "homebrew") in existing
.install_method stamps (both code-scoped and home-scoped) are ignored by
the allowlist reader and fall through to "unknown" instead of resurrecting
a retired enum value.
- Updates Nix packaging to ship bare runtime data (locales, optional-mcps)
through store symlinks and wrapper env vars instead of wheel data-files.
- Removes the ACP Registry manifest/icon and their version-lockstep tests.
- Deletes or rewrites packaging, pip-update, Homebrew, and ACP Registry
tests; adds parametrized coverage for the packaging build guard covering
BOTH sdist and wheel paths (the guards live in separate cmdclass entries
— a passing sdist test proves nothing about the wheel path).
- Updates installation/platform documentation and related user-facing copy.
- Adjusts the supply-chain scan so deleted install-hook files do not trigger
a finding, while additions or modifications still require the existing
ci-reviewed label gate.
Supported installation paths (unchanged):
- git installer (install.sh)
- Docker
- Nix/NixOS
- editable development installs (uv sync, uv pip install -e ., pip install -e .)
Slack delivers user mentions as opaque IDs (<@U123>). The agent had no
way to tell one participant from another — or from itself — so it could
misread a mention of a human as a self-mention and answer messages
addressed to that person (the "bot thinks it's @someone-else" bug).
Two cooperating fixes:
- _humanize_user_mentions rewrites remaining <@UID> tokens (the bot's
own mention is stripped earlier) to @DisplayName in the trigger text
and reply_to_text — the Slack equivalent of Discord's clean_content.
Handles the labelled <@UID|handle> form; unresolvable IDs fall back
to the raw ID.
- _build_identity_prompt injects an ephemeral per-turn system-prompt
line via the channel_prompt seam (applied at API-call time, never
persisted — prompt caching preserved) naming the bot's own workspace
handle (per-team in multi-workspace installs) so the agent has a
positive "that's me" anchor.
Salvaged from #55340 by @benbarclay, rebased over the workspace-scoped
user-name cache (team_id-aware resolution) on main.
Two mention-tracking gaps around thread parents (#24848):
1. When a thread PARENT @-mentioned the bot (e.g. '<@bot> check this
and ask me before running'), a later bare reply like 'run' fell
through every wake check if the mention event predated this process
(restart) — _mentioned_threads is in-memory only. Add a 5th wake
check that fetches the parent text (with the bot mention preserved
via strip_bot_mention=False) and wakes when the parent addressed the
bot, registering the thread so later replies skip the fetch.
2. A TOP-LEVEL @mention starts a thread (session keying falls back to
the message ts), but only the raw event thread_ts was registered in
_mentioned_threads — so replies to a top-level mention did not
auto-trigger. Register the session-scoped thread_ts instead.
_fetch_thread_parent_text reuses the shared thread-context cache (raw
payloads) so the parent check costs at most one conversations.replies
call per thread; _register_mentioned_thread centralizes the bounded-set
eviction.
Salvaged from #24848 by @kaiyisg, rebased onto the extracted
_should_wake_on_unmentioned_message helper.
The un-mentioned wake decision relied on three checks: thread root in
_bot_message_ts (populated only by the adapter's own send() path),
_mentioned_threads (populated on @mention), and an existing session.
Two gaps (#63530):
- Gap A: bot messages posted OUTSIDE gateway send() — skills/scripts
calling chat.postMessage directly, cron/API posts — never enter
_bot_message_ts, so human replies in those threads were silently
dropped.
- Gap B: _bot_message_ts is process memory; after a gateway restart the
bot stopped waking on replies to threads it started before the
restart.
Fix: add a 4th, API-derived check — _bot_authored_thread_root — which
resolves the thread root's author via conversations.replies (cached in
_thread_context_cache via the new parent_user_id field, TTL-bounded).
Root authorship comes from Slack itself, so it covers outside-send
posts and survives restarts, unlike in-memory ts tracking. The wake
decision is extracted into _should_wake_on_unmentioned_message for
direct unit testing; the legacy checks remain first (cheap, additive).
Fixes#63530.
Salvaged from #64067 by @knoal (author metadata normalized to their
GitHub identity), rebased over the thread-context formatter split and
extended with per-team bot-id resolution for multi-workspace installs.
Persistent sessions survive gateway restarts, but thread replies posted
while the gateway was DOWN never reached the session — and the adapter
had no way to notice, so the conversation silently resumed with a hole
in it.
On the first ordinary reply per thread after a restart (tracked by a
fresh-process _thread_rehydration_checked set), fetch the thread delta
past the persisted per-session watermark and inject any missed messages
as part of the new turn via channel_context. Exactly-once per thread
per process; when the watermark is empty (pre-feature sessions) the
check is a no-op. Steady-state replies keep advancing the watermark so
rehydration never re-injects messages the session already carries as
ordinary turns. Prior history is never rewritten (prompt caching safe).
Builds on the persisted watermark introduced for #23918.
Salvaged from #33215 by @vexclawx31, reworked from a repeated
full-thread injection guard into a watermark-delta injection so
rehydration adds only what the session actually missed.
Once a thread has an active session, a later reply that explicitly
@mentions the bot did not re-fetch Slack thread context, so the agent
missed messages added to the thread after the initial hydrate (e.g.
other bots/integrations replying in multi-agent workflows). The
explicit mention is a fresh intent signal and now triggers a refresh.
Mechanics:
- SessionEntry gains a small persisted metadata dict, with
SessionStore.get/set_session_metadata accessors (survives gateway
restarts via the routing index).
- The adapter stores a per-thread consumption watermark
(slack_thread_watermark:<channel>:<thread>) recording the last
thread ts the session consumed.
- On explicit mention in an active thread, _fetch_thread_context runs
with force_refresh=True (bypassing the TTL cache) and after_ts=<the
watermark>, so only NOT-yet-seen messages are injected — as part of
the new turn via channel_context. Prior conversation history is
never rewritten, preserving prompt caching.
- _fetch_thread_context caches raw conversations.replies payloads so
watermark-scoped re-formatting needs no extra API call; formatting
is split into _format_thread_context.
- Thread session keys are built once in _build_thread_session_key
(shared by the wake gate and the watermark accessors), still via
build_session_key().
Fixes#23918. Supersedes #62299 (keyword-triggered refresh limited to
'investigate' prompts — the mention signal is the general fix).
Salvaged from #23927 by @heathley, rebased onto the plugin adapter
layout and rerouted through channel_context instead of text-prepend.
Prepending cold-start thread backfill directly onto the message text
moved a recognized command (e.g. a bang-normalized "!queue ...") away
from character zero, so downstream command routing misclassified it as
conversational text and the command silently didn't run.
Route the backfill through MessageEvent.channel_context instead —
gateway.run already prepends channel_context after command dispatch
("[New message]" framing), so commands keep their COMMAND type while
the recovered history stays available to the agent.
Supersedes #68020, which dropped the fetched context entirely for
commands instead of preserving it out-of-band.
Salvaged from #66069 by @LevSky22.
_fetch_thread_context unconditionally filtered out the bot's own prior
replies, so cold-start sessions (bot posts a thread root, user replies
later, no active session) lost every assistant turn and the agent could
not reconstruct the prior conversation.
The circular-context concern the filter guarded against does not apply
here: the call site is gated by _has_active_session_for_thread, so this
method only runs when there is no session history to duplicate.
Self-bot replies are now kept and labelled with an explicit [assistant]
prefix (skipping user-name resolution — the label already communicates
authorship). Third-party bot posts and the bot-authored thread parent
keep their existing treatment.
Fixes#38861.
Salvaged from #38936 by @temalo, rebased from the pre-plugin
gateway/platforms/slack.py layout onto plugins/platforms/slack/adapter.py
(preserving the [unverified] trust-tag handling added on main since).
A session key that exists in the store but would be rolled to a fresh
session by the reset policy (daily/idle/suspended) is not an active
session. Treating it as active suppressed the first-turn Slack
thread-history reseed after reset (#55239).
_has_active_session_for_thread() now consults SessionStore._should_reset
so a stale entry gates like a missing one, letting _fetch_thread_context
reseed the fresh session with recent thread history.
Fixes#55239.
Salvaged from #55240 by @ooiuuii.
Slack buffers un-acked Socket Mode events and replays them when the
websocket reconnects; the replay can arrive several minutes later —
past the 300s default dedup TTL — producing a duplicate bot reply.
Default the Slack dedup window to 1 hour (memory stays bounded by the
deduplicator's max_size LRU pruning) and allow overriding via
SLACK_DEDUP_TTL_SECONDS.
Salvaged from PR #40064 by @MrAbsaroka (reapplied onto the
plugin-migrated adapter path). Fixes#4777.
The Socket Mode watchdog only reconnects when is_connected() returns False
or the receiver task dies. When the underlying aiohttp ClientSession is
closed (e.g. after a network blip), slack_sdk gets stuck retrying
"Session is closed" while is_connected() can still report healthy and the
receiver task stays alive — so the watchdog never fires and the process is
alive but deaf to Slack indefinitely.
Add a ping/pong staleness probe: Slack sends a ping roughly every
ping_interval seconds even on an idle socket, so a stale/missing
last_ping_pong_time (past a first-ping grace window) is a reliable signal
the transport is wedged. The watchdog now also reconnects on staleness,
which rebuilds the handler with a fresh session. Guards non-numeric
attributes so a mocked/partial client never triggers a spurious reconnect.
7 new tests; full test_slack.py (216) green.
SocketModeClient.connect() is a "while True" retry loop that never checks
the client's closed flag, so anything still inside it when the shared
aiohttp session is closed keeps retrying against a session that can never
work again. That is the "Failed to connect (error: Session is closed);
Retrying..." spam in #46990, at a steady ping_interval cadence that only
a process restart clears.
_stop_socket_mode_handler closed the handler first and cancelled
afterwards, which loses the race. close_async() closes that shared
session, and three things can be inside connect() when it does: our own
start_async task, monitor_current_session() (on staleness) and
receive_messages() (on a CLOSE frame), the latter two reaching it
independently through connect_to_new_endpoint(). connect() also rebinds
current_session_monitor and message_receiver to fresh tasks when it
succeeds, so the set of live tasks changes across the awaits inside
close(). Cancelling from a snapshot taken partway through races a moving
target rather than closing the window.
So cancel all four before close_async() instead. With nothing left alive
to enter connect(), no rebinding can happen during teardown and the
window cannot open at all. The client's task attributes are read with
getattr so a rename inside the SDK degrades to a no-op instead of raising
during shutdown, and the wait is asyncio.wait with a timeout rather than
an unbounded await, so a task wedged in a network call cannot hold up
shutdown.
The underlying SDK defect is tracked at slackapi/python-slack-sdk#1913.
This replaces the earlier version of this change, which added a closed
session check when building a handler and another in the watchdog. Both
are unnecessary once teardown stops leaking tasks: each
AsyncSocketModeHandler builds its own SocketModeClient with a fresh
ClientSession, so a new handler cannot inherit a closed session, and the
current handler's session is only closed by the teardown path itself.
Dropping the watchdog check also keeps this off the ping/pong staleness
trigger proposed in #52923, which addresses a wedged live connection
rather than a leaked one.
Fixes#46990
A platform with a missing dependency or missing credentials can never
succeed on retry, but connect() returned bare False, so the gateway
treated the failure as transient and queued it for background
reconnection — looping forever at the backoff cap. Set
_set_fatal_error(..., retryable=False) for missing-dependency and
missing-credential failures in the Slack, Telegram, and Discord
adapters so the reconnect watcher drops them from the retry queue.
Salvaged from PR #31057 by @dskwe (reapplied onto the plugin-migrated
adapter paths). Fixes#31049.
Missing SLACK_BOT_TOKEN / SLACK_APP_TOKEN is a permanent configuration
error, not a transient outage. Without a fatal-error marker the gateway
queued Slack for background reconnection and looped forever (#66696).
Set _set_fatal_error(..., retryable=False) so the reconnect watcher
drops it from the retry queue, and point the log/error text at
`hermes gateway setup` / the profile's ~/.hermes/.env.
Salvaged from PR #66720 by @x7peeps. Fixes#66696.
Slack now overrides send_clarify to render multi-choice clarify prompts
as native Block Kit buttons (one per choice + a final '✏️ Other…'
free-text button), mirroring the Telegram/Discord adapters and the
existing Slack approval-button pattern.
- Unique hermes_clarify_choice_<idx> action_ids (Slack rejects
duplicate action_ids within one actions block); dispatch via a
compiled-regex action matcher plus hermes_clarify_other.
- Chunks elements across actions blocks in groups of 5 so a larger
choice list degrades gracefully instead of 400ing (invalid_blocks).
- Choice taps resolve through tools.clarify_gateway
.resolve_gateway_clarify with the canonical registered choice text —
the same applier the typed-reply path uses — then edit the message
to show the outcome and drop the buttons.
- 'Other' flips the entry into text-capture via mark_awaiting_text
(only on tap, never at send time) so the gateway text-intercept
captures the next typed message.
- Auth-gated via _is_interactive_user_authorized; atomic-pop
double-click guard mirrors _approval_resolved; late taps on evicted
entries surface an honest expiry notice instead of a false ✓.
- Open-ended prompts delegate to the base plain-text render.
Salvaged from PR #61943 by @100yenadmin. Earliest implementation of
this feature was PR #28885 by @cypres0099; sibling implementations
#66606 (@jaaro-ai) and #51547 (@Mongol-Jimmi) are superseded.
Closes#52369
Consolidated defensive layer for the invalid_blocks / msg_too_long bug
class (#56615, #62054, #53693): one malformed or oversized block fails
the ENTIRE chat.postMessage / chat.update call, so approval cards never
update and messages silently drop.
New block_kit.sanitize_blocks() is applied at every call site that
attaches blocks (send/edit via _maybe_blocks, send_exec_approval,
send_slash_confirm, and the approval/slash-confirm chat.update button
handlers). It:
- truncates section/context text to the 3000-char cap with an ellipsis
(covers interaction payloads where Slack's HTML-escaping of < > &
inflates text past the limit budgeted at send time)
- truncates header text to its 150-char cap
- drops empty blocks (no text / elements / rows)
- replaces null table column_settings entries with {} and trims
default trailing entries (Slack requires every entry be an object)
- caps the payload at Slack's 50-block maximum
- returns None when nothing valid remains so callers fall back to the
plain-text payload; never raises
Also registers contributor mappings for the salvaged PRs in this
cluster (tw0316, kamonspecial, sowork-skills).
Slack's chat.update enforces the same ~40k character limit as
chat.postMessage, but edit_message sent the formatted text unchunked —
an oversized edit failed outright with msg_too_long and the message
was never updated. Unlike send() we cannot split an edit into multiple
messages, so truncate to MAX_MESSAGE_LENGTH via the shared chunker
(which preserves code-block boundaries) and keep the first chunk.
Reapplied from PR #33224 (targeted the pre-plugin gateway/platforms/slack.py
path) onto plugins/platforms/slack/adapter.py.
Slack API returns `no_text` error when `chat.postMessage` is called
with empty or whitespace-only text. This happens when cron delivery
posts a response before the agent produces content (e.g. malformed
turn before [SILENT]).
Add early-return guards in both code paths:
- `_standalone_send()`: out-of-process cron delivery via Web API
- `send()`: in-process gateway adapter
Both paths now skip the API call and return success when the formatted
message is empty or whitespace-only.
Fixes#52663
Slack re-escapes HTML entities in the interaction payload
(< -> <, > -> >, & -> &), inflating the section text
past the 3000-char Block Kit limit when the button handler
rebuilds updated_blocks for chat.update.
The send path already budget-truncates, but the click handlers
used the echoed original_text verbatim. Cap it to 3000 chars
in both _handle_approval_action and _handle_slash_confirm_action.
Fixes#53693
assert statements are stripped when Python runs with -O flag. Replace
the assert cur is not None in _rich_text_list_block() with an explicit
if/continue guard. The assert is technically unreachable (first loop
iteration always sets cur), but using assert for invariant checking in
production code is fragile under optimization.
Slack rejects a rich_text_section / rich_text_preformatted / rich_text_quote
whose elements list is empty or contains a zero-length text element, and a
header whose plain_text is empty. The Block Kit renderer emits exactly those
shapes for common inputs: a markdown table with a blank cell or a ragged
(short) row, an empty fenced code block, a blank quote line, an empty list
item, and an emphasis-only header ("# ***"). Any one of them poisons the
whole payload — chat.postMessage fails with invalid_blocks ("missing element"
/ "must be more than 0 characters") and the message loses its rich rendering
entirely.
Route every rich_text builder's child elements through _nonempty_elements
(drop zero-length text elements; substitute a single space when nothing
remains) and skip headers that reduce to empty after markdown-marker
stripping. Observed in production via live chat.postMessage rejections;
regression tests cover each case plus a well-formed-content control.
Complementary to #56618 (column_settings hardening + no-blocks retry): that
change makes Block Kit rejections recoverable, this one makes the common
empty-content cases render correctly in the first place. No overlapping hunks.
Bot-posted alerts (Honeycomb, PagerDuty, Datadog, GitHub bot, etc.) carry
their actionable content — section text, button URLs — in Block Kit
blocks, while the plain text field holds only the alert title.
_fetch_thread_context and _fetch_thread_parent_text only read
msg.get('text'), so that content never reached the agent.
Add a _render_message_text helper that merges top-level text with
readable block content, section/header/context text, actionable URLs,
and (folded in from #61261 during conflict resolution) legacy
attachment fields, and use it for thread-context and parent-text
rendering.
Salvaged from #29541.
`_fetch_thread_context` and `_fetch_thread_parent_text` only read each
message's plain `text` field, so messages posted by apps (Alertmanager,
Grafana, PagerDuty, CI bots) — which carry their content in legacy
`attachments` or Block Kit `blocks` with an empty `text` — were dropped
entirely. When such a message *starts* a thread (e.g. an alert), a bot
mentioned mid-thread to investigate sees an empty thread and can only ask
"what should I investigate?".
Fall back to the existing `_extract_text_from_slack_blocks` and a new
`_extract_text_from_slack_attachments` helper when `text` is empty, so
app-posted alerts and notifications are visible in fetched thread history.
Adds TestThreadContextAppMessages (attachment-only, blocks-only, and
empty-message cases).
Closes#52387
Slack messages can carry the bot @mention only inside Block Kit `blocks`
(a `rich_text` section with a `user` element), with the flat top-level
`text` containing just a fallback string. Both mention gates read only the
flat `text`:
- the `allow_bots: mentions` bot-message filter, and
- the `is_mentioned` channel router (`routing_text = original_text`)
so such messages were silently dropped — `allow_bots: mentions` was
effectively non-functional for Block-Kit senders, and the same blind spot
hit `require_mention` / `strict_mention`.
Add `_collect_slack_block_mentions()` (walks blocks, recovers `<@UID>`
tokens from non-quoted rich-text `user` elements) and
`_slack_mention_detection_text()` (flat text + recovered mentions). Both
gates now use the merged detection text. Mentions nested in
`rich_text_quote` are deliberately ignored, preserving the existing
contract that quoted/forwarded content can't trick the bot. Also emit a
debug line when a bot message is dropped so silent drops are diagnosable.
Tests: 4 new cases in tests/gateway/test_slack_mention.py (Block-Kit
mention recovered, flat-text passthrough, no-mention, quoted-mention
ignored). 275 slack tests pass.
- Update test_observed_group_context_preserves_slash_command_text_for_dispatch
to assert user_id is preserved for COMMAND messages (new correct behavior)
- Add _coerce_allow_set helper to handle both list and comma-separated
string allowlist inputs (prevents character-by-character iteration bug)
- Include 'channel' in chat_type checks for group-scoped authorization
- Add _telegram_extra fallback for group_allowed_chats (consistent with
group_allow_from fallback)
- Add AUTHOR_MAP entry for nyaruko@hermes -> tsuk1nose
- authz_mixin: add config.extra fallback for group_allowed_chats
when observe-unmentioned mode strips user_id from env-var check
- authz_mixin: check adapter allow_from/group_allow_from for
user authorization from config.yaml without env vars
- telegram/adapter: separate group_allow_from for group chats
vs allow_from for DMs
- telegram/adapter: preserve sender source for command messages
so admin-only slash commands work in groups
- telegram/adapter: add _telegram_extra fallback for
group_allow_from config reading
Widen the allow_session tier from Matrix to every adapter the gateway
notifies: Telegram, Discord, Slack, Feishu, and Teams gate their Session
button on it; WhatsApp Cloud and qqbot accept the kwarg (no session tier
in their button sets). Also thread allow_session through the plugin-
escalation gate, the execute_code guard payload, and the plain-text
fallback so every notify path carries the same capability flags.
Adds an allow_session flag to the gateway approval payload so adapters
can render the session tier independently of the permanent tier. Matrix
gains a session reaction (🌀) and a reaction legend; pure-tirith prompts
now offer once/session/deny instead of collapsing to once/deny.
Salvaged from PR #67312, adapted to the allow_permanent semantics that
landed in #68597 (Always offered when any dangerous-pattern warning is
persistable; pure-tirith prompts stay session-max).
Three related messaging-approval fixes:
1. approvals.timeout default 60 -> 300. PR #63501 collapsed the gateway
wait onto the canonical approvals.timeout (previously
gateway_timeout=300), silently shrinking messaging approval windows
to 60s. Push-notification approvals routinely arrive later than a
minute; taps landed after the wait had already failed closed.
2. Stale-tap honesty: adapters resolved the approval AFTER rendering
'<checkmark> Approved by <user>' (Telegram/Discord/Slack), or ignored a zero
resolve count (WhatsApp Cloud/Feishu). A tap on an expired prompt
claimed approval while the command had already been denied. All
button paths now resolve first and render 'Approval expired -
command was not run' when nothing was waiting.
3. Mixed-warning prompts (dangerous pattern + tirith finding) now offer
Always: the persistence layer already permanently allowlists the
pattern key and downgrades the tirith key to session scope, but the
UI hid Always whenever ANY tirith warning was present. Pure-tirith
prompts still withhold Always (content findings are session-max by
design), and Smart-DENY overrides remain once-only.
The wedged-recovery heartbeat watchdog (line 2526) calls
_notify_fatal_error() directly from the heartbeat task. disconnect()
cancels _polling_heartbeat_task unconditionally (no current_task guard,
unlike _polling_error_task). Same bug class as #68406: the child
disconnect cancels the heartbeat parent before the runner can queue
reconnect.
Widen _handoff_polling_fatal_error() to also clear
_polling_heartbeat_task when it is the current task, and route the
heartbeat watchdog call site through the handoff helper.
Co-authored-by: Imgaojp <6065749+Imgaojp@users.noreply.github.com>
Release the current polling-recovery task's ownership before invoking
the fatal-error handler. The runner bounds adapter cleanup in a child
task; disconnect() cancels the tracked polling-recovery task, so
retaining the current notifier in _polling_error_task would cancel the
fatal callback before the runner can finish its reconnect-queue or
shutdown decision.
The new _handoff_polling_fatal_error() helper clears
_polling_error_task only when it is the current notifier. Other
recovery tasks remain tracked and are still cancelled and awaited
during teardown.
Covers both network retry exhaustion and polling-conflict exhaustion.
Replaces the misleading "Restarting gateway" message with "Escalating
to gateway recovery".
Fixes#68406.
Text-batching tests (and any tooling) build MatrixAdapter via
object.__new__ without running __init__; moving _split_threshold from a
class constant to an instance attribute made _flush_text_batch die with
AttributeError, silently dropping the flush. Restore class-level
defaults (max_message_length, _split_threshold) that __init__ overrides,
and derive the near-limit test payload from adapter._split_threshold
instead of the old hardcoded 3950.
Raise the Matrix adapter default chunk size from 4,000 to 16,000
characters and allow overrides via config.yaml or MATRIX_MAX_MESSAGE_LENGTH.
Fixes#53026
Transplant of PR #26848 onto the plugin adapter path
(plugins/platforms/feishu/adapter.py — the original PR targeted the
since-removed gateway/platforms/feishu.py).
``send`` classifies each chunk independently, so chunk 1 of a long
markdown reply (often plain prose) went out as msg_type=text while
later chunks rendered as post — literal **bold**/## heading markers
in the Feishu client. Lock the decision at the whole-message level:
compute prefer_post once from the full formatted message and pass it
to _build_outbound_payload per chunk.
The original PR's per-chunk table exemption is intentionally dropped:
tables now route through post/md (issue #52786 cluster fix), so the
exemption would reintroduce the raw-table downgrade.
Co-authored-by: Hermes Agent <hermes@nousresearch.com>
Resolves issue #52786 (duplicate of #23938):
The `_build_outbound_payload` shortcut forced any message containing a
pipe table to ``msg_type=text``. Feishu readers then rendered the raw
pipe-and-dash source instead of a table. Empirically current Feishu
clients render markdown tables inside ``post``-type ``md`` elements
natively, so the downgrade branch had to go.
Two changes:
1. ``_MARKDOWN_HINT_RE`` now also matches a pipe-table header+separator
pair, so a table-only message is recognised as "has markdown" and
takes the ``post`` path. All previously recognised hints (headings,
lists, code, bold/italic/strike/underline, links, blockquotes, hr)
still match — verified by the existing 205 test_feishu.py cases plus
the new regression tests below.
2. ``_build_outbound_payload`` no longer special-cases `_MARKDOWN_TABLE_RE`
before the hint check. The hint check now routes table content to
`_build_markdown_post_payload`, which is the same path any other
markdown structure takes.
``_MARKDOWN_TABLE_RE`` itself is retained as a module-level constant for
external callers (import-path-sensitive tests, third-party consumers of
the adapter module) and continues to work for its existing uses.
Tests
-----
New: ``tests/gateway/test_feishu_table_markdown.py`` — four regression
tests:
- ``test_markdown_table_uses_post_not_text`` — pure-table content
reaches ``post`` (issue #52786 scenario).
- ``test_table_combined_with_other_markdown_does_not_downgrade`` —
prose + table + prose message keeps its surrounding markdown.
- ``test_existing_markdown_heading_still_uses_post`` — sanity guard:
the heading path is unchanged.
- ``test_plain_text_without_markdown_still_uses_text`` — negative
control: pure prose still goes to ``text``.
Verification
------------
``pytest tests/gateway/test_feishu.py
tests/gateway/test_feishu_table_markdown.py`` passes 209/209 (205
existing + 4 new), three consecutive runs.
Rollback
--------
``git reset --hard 44ddc552f5``
restores upstream main without the new test file.
The DingTalk docs offer gateway.platforms.dingtalk.extra.allowed_users
as the config.yaml alternative to DINGTALK_ALLOWED_USERS. The adapter
honors it (_load_allowed_users reads PlatformConfig.extra), but gateway
authorization (_is_user_authorized in gateway/authz_mixin.py) only
consults the env var, and load_gateway_config() bridged the allowlist
to the env var only from a top-level dingtalk: block. A nested-only
allowlist therefore passed the adapter and was then denied at the
gateway - listed users fell through to pairing/default-deny in DMs.
Extend the DingTalk YAML->env bridge to fall back to the merged nested
platform config (gateway.platforms / platforms), mirroring the existing
platforms.discord.extra.allow_from precedent. Precedence is unchanged:
an explicit DINGTALK_ALLOWED_USERS env var still wins, then the
top-level dingtalk: block, then the nested extra.
Also correct the docs' claim that the two allowlists are "merged" when
both are set - that behavior never existed (the doc line came from a
docs-only sweep); the effective result is the intersection of the two
gates, so the docs now recommend configuring one or the other.
Repro (before): config.yaml containing only the nested allowlist ->
adapter._is_user_allowed("user-id-1") is True but
runner._is_user_authorized(...) is False. After: both True; unlisted
users are still denied.
The Telegram gateway could go silently deaf for hours: the reconnect ladder
stalled mid-way (e.g. "attempt 4/10, reconnecting in 40s" then nothing) while
the process stayed active(running), so Restart=always never fired.
Root class: every recovery path — the ladder's re-entry
(_schedule_polling_recovery), the pending-update probe (_probe_pending_updates),
and PTB's error callback — gates new recovery on _polling_error_task.done(). If
that single task wedges on any hung await, all recovery returns early forever
and nothing retries.
The heartbeat loop is a separate task, so make it an independent, cause-agnostic
watchdog: if the same recovery task stays in-flight past
_POLLING_ERROR_TASK_STUCK_TIMEOUT (300s — well beyond a healthy ladder attempt's
bounded stop+drain+start+backoff), force a retryable-fatal so the background
reconnector rebuilds the adapter instead of relying on the frozen ladder. This
guarantees progress regardless of *where* the stall is (issue direction #1),
tracked locally so no task-assignment site needs to change.
Also salvages @koduri-mahesh-bhushan-chowdary's #66492 (drain-await timeout),
which closes the one concrete wedge vector documented in the incident
(_drain_polling_connections' unbounded shutdown()/initialize() on a wedged
CLOSE-WAIT pool). The watchdog covers the rest of the class.
Co-authored-by: Koduri Mahesh Bhushan Chowdary <mkoduri73@gmail.com>
_drain_polling_connections() awaited polling_req.shutdown() and
.initialize() without a timeout. When the getUpdates httpx connection is
wedged on a stale CLOSE-WAIT socket, that close can block forever, hanging
_handle_polling_network_error (the tracked _polling_error_task). The task
never completes, so every escalation path — _schedule_polling_recovery,
_probe_pending_updates, the heartbeat verifier — stays gated behind its
in-flight guard, the ladder freezes mid-way, _set_fatal_error is never
reached, and Restart=always never fires: the gateway is alive but silently
dead.
Wrap both drain awaits in asyncio.wait_for with a new module-level
_DRAIN_TIMEOUT (15.0s, matching _UPDATER_STOP_TIMEOUT), mirroring the
existing bounded stop()/start_polling() sites. On timeout the drain logs and
continues, so the handler task completes and the ladder always advances
toward the fatal-restart escalation.
Adds test_reconnect_continues_if_drain_hangs, which wedges the drain and
asserts the handler still reaches start_polling within a hard bound.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>