Commit Graph

1461 Commits

Author SHA1 Message Date
Maciej Lisiewski
c57fbf4584 ci: fix remaining ASYNCXXX linter errors 2026-10-10 15:20:14 +02:00
Maciej Lisiewski
bda3aea496 ci: remove unused variables (linter) 2026-10-10 15:20:14 +02:00
Maciej Lisiewski
0006a5e229 ci: fix PLW1509, PLW1510, PLW2101 linter errors 2026-10-10 15:20:13 +02:00
Teknium
a274d3388f fix: core modules import again on Python 3.11-3.13 (re-quote eager forward references)
#135348 ran ruff's UP037 autofix with ruff.strict.toml's target-version = "py314". On 3.14
annotations are lazy (PEP 649), so stripping quotes is safe there; on 3.11-3.13 a function or
variable annotation is evaluated when the def or statement runs. Every unquoted annotation that
names the enclosing class (`def clone_for_agent(self) -> ContextEngine`), a class bound later in
the module, or a TYPE_CHECKING-only import now raises NameError on import: 300 modules on main
(gateway.config, run_agent, hermes_state, cron.jobs, the Telegram adapter, ...) fail to import
under 3.11, the update path crashes mid-`hermes update` on any install whose interpreter is
older than 3.14, and the e2e-upgrade suite's retry leg fails with
`NameError: name 'ContextEngine' is not defined`.

- Re-quote exactly the 79 annotations (36 files) that name something unbound when they are
  evaluated (including tui_gateway/server.py's `_TurnScopes`, which only reaches that module's
  globals through register() after import); no other text changes.
- agent/turn_scripted_prelude.py: `Generator[...]` gets its third argument back. d76cce4f11
  applied UP043 (default type args, valid only from 3.13) to a module-level alias, so
  `Generator[Y, S]` raises TypeError on 3.11/3.12 and takes gateway.run, agent.conversation_loop
  and tui_gateway.{server,ws,entry} down with it.
- ruff.strict.toml target-version = "py311", the requires-python floor. With it UP037 leaves
  these quotes alone and only strips the two that are genuinely redundant, which this commit
  strips; any future version-gated fix stays valid on every supported Python.
- tests/ci/test_strict_ruff_target.py pins the target to the requires-python floor (red on main).
2026-10-09 14:25:07 -07:00
Maciej Lisiewski
2ff58849f4 ci: ruff UP037 fixes 2026-10-09 17:34:38 +02:00
Maciej Lisiewski
9eab688b42 ci: ruff UP017 fixes 2026-10-09 17:34:36 +02:00
Maciej Lisiewski
aff64c3eeb ci: UP007, UP022, UP028, UP031, UP041 linter fixes 2026-10-09 17:31:52 +02:00
Maciej Lisiewski
32b70e9733 ci: FURB188, FURB192 linter fixes 2026-10-09 17:31:52 +02:00
Maciej Lisiewski
4278b6aa24 ci: RUF013, RUF015 2026-10-08 21:29:48 +02:00
Maciej Lisiewski
b4e436c115 ci: don't cast int to int 2026-10-08 19:28:23 +02:00
Maciej Lisiewski
aa2d120cba ci: prefix unused variables with underscore (RUF059) 2026-10-08 19:22:03 +02:00
Maciej Lisiewski
d76cce4f11 ci: apply all automatic linter fixes available 2026-10-08 17:22:54 +02:00
kshitijk4poor
b278d6528f fix(line): a non-string channel secret or token reads as unset
_credentials coerced every value with str(), so YAML channel_secret: true
keyed the HMAC with the guessable "True" (likewise 123, lists, maps).
Read a value only when it is a string, matching the webhook adapter's
_is_usable_secret policy; scoped-env values stay first. (Lead fold: Sol
r4/r5 + quality lens, converged; probed: true/0 -> "" on head.)
2026-10-07 13:19:35 +05:30
kshitijk4poor
d28c513904 fix(raft): a null or blank bridge token is unset, not a guessable inbound token
`bridge_token:` with an empty YAML value became str(None) == "None" (and a
whitespace-only value stayed "   "). Both are truthy, so connect() skipped
the auto-generated token and _authorized() accepted any wake/activity POST
carrying that header. Same class as the null-secret fixes for LINE and
WhatsApp Cloud in this stack.
2026-10-07 13:19:35 +05:30
kshitijk4poor
adf4a4b48c fix(sms): a non-string or blank standalone-send key fails closed instead of raising
PlatformConfig keeps a numeric YAML api_key as an int, so the .strip() this
stack added to _standalone_send raised AttributeError where origin/main
returned an error dict. Coerce through str(... or "") like the LINE reader,
and pin both the int key and a whitespace-only key (which also gives the
standalone strip a test that fails without it).
2026-10-07 13:19:35 +05:30
kshitijk4poor
ae083f8a22 fix(line): read the standalone-send token through _credentials and pin both strips
_standalone_send still read the access token raw, so a blank or newline-padded token went out as the Bearer header; reuse the hardened _credentials. Pin the token half of the null/blank guard and the env presence strip (both reverted green before). Gate r4 Lows.
2026-10-07 13:19:35 +05:30
kshitijk4poor
62df46fdc4 test(sms): pin the stripped Twilio token in the requirements check
Reverting only the check_sms_requirements strip left the suite green; also drop the dead 'or ""' after reads that already pass a default. Gate r3 Lows.
2026-10-07 13:19:35 +05:30
kshitijk4poor
c5caff330d fix(line): keep a null channel secret unset instead of keying HMAC with "None"
c92ed7e000 wrapped the extra fallback in str(), so a present-but-null config value (an empty YAML key) became the guessable literal "None" and connect passed. Fall through to "" before str(); strip the env presence check too so check_requirements agrees with connect. Gate r3 High (2ab, 2c, quality converge).
2026-10-07 13:19:35 +05:30
kshitijk4poor
84cb790e64 fix(sms): fail closed on a whitespace-only Twilio auth token
Gate r2 finding 2 (same class as the webhook blank-secret fix):
TWILIO_AUTH_TOKEN was read unstripped, so a whitespace-only token
passed check_sms_requirements and keyed the X-Twilio-Signature HMAC
with blanks, letting anyone forge inbound SMS. Strip the token at
every read (adapter init, standalone send, requirements check).

Stripping alone would leave an empty key, and HMAC with an empty key
is equally public (verified by probe: a signature forged with b''
was accepted). So _check_signature now refuses outright when no
auth token is set. This goes one line past the reviewer's FIX, which
only asked for the strip.
2026-10-07 13:19:35 +05:30
kshitijk4poor
a560ba0b08 fix(line): fail closed on a whitespace-only channel secret
Gate r2 finding 1 (same class as the webhook blank-secret fix):
_credentials returned LINE_CHANNEL_SECRET / extra['channel_secret']
unstripped, so a whitespace-only secret passed the `not channel_secret`
check at connect and in verify_line_signature, and the webhook HMAC
was keyed with blanks, making signatures forgeable. Strip both values
in _credentials (matching whatsapp_cloud) so a blank secret reads as
unset and connect refuses with config_missing.
2026-10-07 13:19:35 +05:30
kshitijk4poor
149f39fc9f fix(photon): log the sent-text lookup failure instead of swallowing it
The code-health ratchet (BLE001) rejects a blind except Exception that
neither re-raises nor logs the traceback. The record path already logs
at debug with exc_info; give the lookup path the same.
2026-10-07 13:18:15 +05:30
kshitijk4poor
cb71296ebd fix(photon): a threaded reply to our bubble stays "own" after a restart
Cloud spectrum targets carry no direction and _sent_message_ids is
in-memory, so after a gateway restart a swipe-reply to one of our
bubbles hydrated the quoted text from the sent-text index but was
marked as not ours: the agent saw [Replying to: "..."] instead of
[Replying to your previous message: "..."]. Only our sends are indexed,
so an index hit is itself proof the target was ours.
2026-10-07 13:18:15 +05:30
David Uche
aba1f72ef2 fix(photon): record sent text at the _post_send choke point
Polls (clarify), the plain-text fallback resend and standalone attachment
sends reached the sidecar without recording what they sent, so a threaded
reply to those bubbles lost its quoted context. Record once in _post_send
(plus the standalone attachment leg, which has its own HTTP client) and
reuse _normalize_chat_key instead of a duplicate regex.
2026-10-07 13:18:15 +05:30
David Uche
348a68bb4e fix(photon): keep the user's words when they reply in an iMessage thread (#100663)
spectrum-ts 12.x delivers a threaded (swipe) reply as
{type: 'reply', content, target}. The sidecar had no branch for it, so
the agent received '[Photon content type not handled: reply]' and the
message text was lost.

- sidecar: normalize 'reply' to its inner content plus the target's id,
  direction and text
- adapter: unwrap it and set reply_to_message_id / reply_to_text /
  reply_to_is_own_message on the MessageEvent
- adapter: record outbound text (send, attachments, standalone send) in
  gateway.rich_sent_store and use it when spectrum can't hydrate the
  quoted text, which is the usual case for our own bubbles. DM GUIDs
  and bare E.164 share one key.
2026-10-07 13:18:15 +05:30
beardthelion
8e85a0fd8c fix(plugins/telegram): clamp negative inline-query offsets to page zero
Telegram echoes back the client-controlled inline_query.offset string on
every inline query. build_inline_results passed it through int() and
used it directly as a slice start, so a negative offset indexed the
catalog from the tail: small catalogs served their last few entries with
negative-prefixed result ids, and larger catalogs could return an empty
page with a misleading next_offset that skipped most of the catalog.

Clamp the parsed offset to >= 0. Malformed offsets still fall back to
page zero via the existing except path, and valid pagination is
unchanged.
2026-10-06 22:52:16 -05:00
alt-glitch
1b8bd66b7f fix(lint): stdin guard catches calls whose arguments start on the next line (review)
Greptile 4198572811: the guard now finds subprocess/os/asyncio calls as ast.Call nodes, so multi-line calls are checked, and the 27 calls it newly flagged (pixel-art ffmpeg and 26 others) pass stdin=DEVNULL.
2026-10-07 01:03:03 +05:30
kshitijk4poor
3c5c402de1 refactor(telegram): import platform_ssl_context at module top in telegram_network
Gate finding C02-2 (Low): the function-local import in
discover_fallback_ips did not match the adapter, which already imports it
at module top. agent.ssl_verify imports only stdlib at module level, so
there is no cycle or import cost.
2026-10-06 12:25:01 +05:30
kshitijk4poor
cdd58da229 refactor(telegram): import platform_ssl_context at module top in the adapter
adapter.py is over the 2000-line target and frozen at its main size by the
code-health ratchet; the local import + named context variable grew it by 3
lines. A module-level import and calling the cached accessor at each use site
(it returns the same identity) keeps the change net-zero, and the test now
patches the name where production reads it.

Co-authored-by: luwen <142317085+LuwenOosten@users.noreply.github.com>
2026-10-06 12:25:01 +05:30
luwen
785cf8a3de fix(telegram): reuse the shared platform TLS context for every Telegram client
Every Telegram httpx client (DoH fallback discovery, both PTB HTTPXRequest
pools, and each lazily rebuilt TelegramFallbackTransport pool) was built with
verify=True, so httpx ran ssl.create_default_context() and a full CA load on
the gateway event loop per client, per reconnect. A slow trust store stalled
the loop for most of the connect deadline on every attempt.

agent.ssl_verify already caches one platform-trust client context
(_shared_context(None)); expose it as platform_ssl_context() and pass it as
verify= to every Telegram client and transport. Verification is unchanged
(CERT_REQUIRED + check_hostname via the truststore-injected context), proxies
still flow through httpx's proxy/env handling, and the first build is ~3 ms
because the platform verifier loads no CA file.

This is the minimal slice of #133340: the PR's single-flight Future, worker
thread and to_thread client construction are unnecessary once the context is
cached and shared.

Fixes #133339
Salvaged from #133340.
2026-10-06 12:25:01 +05:30
kshitijk4poor
7748c7f484 fix(gateway): do not hand an installed reconnect adapter's hold queue back to its predecessor
Gate finding C01.r1.2c [High]: the hand-back added for failed candidates
also ran in the post-install exception/cancel branches. After a successful
secondary install (or a primary publish) the predecessor already forwards
to the new adapter; if the task was cancelled during redelivery (gateway
teardown cancels secondary reconnect tasks) the hand-back linked the new
adapter back to the predecessor, and any hold - e.g. the disconnect()
teardown salvage - ping-ponged between the two until RecursionError.

Break the cycle at both ends: adopt_held_inbound clears the adopter's own
successor link (it owns the queue again), and the runner skips the
hand-back when the adapter is the one installed in its slot. Covered by a
cancel-after-install row on the secondary hand-back test.
2026-10-06 11:24:04 +05:30
unsupportedpastels
a036b13793 fix(discord): a programmatic voice join binds the requested text channel on every path
join_voice_channel(channel, text_channel_id=..., source=...) is the programmatic join API, and it
kept the requested binding only on a cold join: the already-connected paths (same voice channel, or a
move) returned before writing it, so a successful join to another text channel kept transcribing
into the old one. All three paths now apply the binding through one helper. Moving to another text
channel drops speech still buffered for the old one, as /voice join does, and the old channel's bound
source unless a new one is given: its chat would keep routing turns into the old conversation.

With no bound source, a voice turn built a synthetic one with chat_type "channel". A typed message in
a guild text channel is "group" (or "thread" under its parent), so the voice turn keyed another
session. It now goes through build_source as a typed message does, with the guild, thread and parent.

/voice join, the only in-repo caller, already writes its binding itself after joining, so this is
latent until something calls join_voice_channel with a text channel (the open auto-join PRs would).

Reported by @andrexibiza as F7 in #132474.
2026-10-06 00:08:57 -04:00
kshitijk4poor
c3f3589efb refactor(discord): move _component_check_auth into adapter_component_auth to keep adapter.py under its size cap
Code-health ratchet: plugins/platforms/discord/adapter.py was 7389 lines
against a cap of 7370 after this stack added the live-auth confirmation to
the component click check. Move the shared component authorization helper
(byte-identical body) into a topical sibling; the view's _check_auth
late-imports it, and the direct-helper tests import it from its new home.
2026-10-06 09:37:09 +05:30
kshitijk4poor
d29d0d07aa fix(discord): confirm a snapshot wildcard with the live check too
A '*' in the connect-time DISCORD_ALLOWED_USERS snapshot still
short-circuited component auth, so removing the wildcard from .env after
connect left every button open until a restart. The gateway's live check
already honours '*' itself, so route the wildcard through it like a
listed ID. Also drop the redundant _authorization_check guard
(_is_sender_authorized already returns None when no check is wired) and
trim the docstring.
2026-10-06 09:37:09 +05:30
pierrenode
502df01d54 fix(discord): buttons stop accepting a user revoked after connect
Component views authorized clicks against the adapter's connect-time
_allowed_user_ids, so a user removed by `hermes pairing revoke` (CLI or
dashboard, another process) or a .env edit kept approving exec commands
until a gateway restart. A user found only in that snapshot is now
confirmed with the gateway's per-call authorization check, as Matrix
approval reactions are since #130920; role, allow-all and pairing grants
are unchanged, and an unwired adapter keeps the snapshot.

(cherry picked from commit a6ced15a6000247b9736cede3cbf4c6ed0c11265)
2026-10-06 09:37:09 +05:30
unsupportedpastels
56f7986418 refactor(discord): move the slash gate verdict to a sibling mixin; adapt the thread-starter test
_evaluate_slash_authorization moves unchanged into adapter_slash_auth.py beside the new _slash_role_grant, so adapter.py stays within its code-health file cap. The thread-starter test passes a thread, as _dispatch_thread_session now takes one, and the voice role-grant test gains type hints.
2026-10-05 20:48:50 -04:00
John Paul Soliva
39f0da8b2f fix(discord): a native slash or /thread starter stamps the role grant for its own actor only
The salvaged #125620 commits stamp role_authorized on _build_slash_event
and _dispatch_thread_session from bool(_allowed_role_ids), which is only
right because today's two callers run _check_slash_authorization first.
Neither builder checks its actor itself, so any later caller that builds
an event before (or without) the gate would mint a grant for a member the
gate refuses.

The grant is now re-evaluated from the interaction in both builders
(_slash_role_grant): a role allowlist exists AND this actor passes the
slash gate, the meaning _discord_message_admission gives the flag. A
role-only member still reaches the agent, and an unrelated-role actor's
event is refused by the gateway even when built directly. The two
salvaged tests fold into one parametrized invariant over both builders
with the refusal row.

Recomputing the verdict from the actor follows #118966 by KoNit-K.
Reported by andrexibiza (F4, native half) on #128788 and #130303.

Co-authored-by: KoNit-K <konit.block@protonmail.com>
(cherry picked from commit 357e77d09263d25d7779d42f0eda3d352b3032a9)
2026-10-05 20:48:50 -04:00
Christopher
c314053823 docs(discord): state what the slash role flag means
Review feedback: the comment read as if the stamped role_authorized
value were a per-user role match. It is the same adapter-level verdict
the message path stamps: a role allowlist exists and the user passed
the slash authorization gate.

(cherry picked from commit e4165fc2ed7f65b9356ac75f90cab02aec85d70c)
2026-10-05 20:48:50 -04:00
Christopher
f5ef17a321 fix(discord): carry the role verdict into slash command events
Fixes #118958

(cherry picked from commit f5f538ef4b86a1d179e76292894372cfece8d2f3)
2026-10-05 20:48:50 -04:00
unsupportedpastels
932210ec2a fix(discord): a /voice join to another text channel drops speech the receiver still holds for the old one
The receiver buffers PCM per speaker with no conversation identity, and a same-voice-channel /voice join keeps that receiver. The poll and the leave flush then read the binding after draining, so audio captured for channel A was stamped with B and passed the post-STT check. The gateway join handler now discards the receiver's unemitted audio, under its lock, before moving the binding to a different text channel; a first join or same-channel rejoin keeps it. Nothing awaits between the discard and the binding write.

Reported by andrexibiza (F1) on #133471 and #130312.
2026-10-05 20:06:54 -04:00
unsupportedpastels
d5d234b377 refactor(discord): move voice-channel info helpers to a sibling mixin; annotate the stale-binding test
get_voice_channel_info and get_voice_channel_context move unchanged into adapter_voice_info.py, and the new comments and docstring are folded, so adapter.py stays within its code-health file cap. Add type hints to the new test.
2026-10-05 20:06:54 -04:00
John Paul Soliva
fb49ef18f5 fix(discord): capture the voice binding when a poll batch is collected
The listen loop transcribes a check_silence() batch serially, so a later
utterance read its binding after a /voice join landed during an earlier
utterance's STT and was posted to the new channel. Capture the binding
once per batch (and per leave flush) and pass it in.

(cherry picked from commit e4b020a0fd21833a89fc1fa7f904db489d797d9c)
2026-10-05 20:06:54 -04:00
John Paul Soliva
118a008f9b fix(discord): an utterance transcribed across a /voice join rebind is not posted to the new channel
_process_voice_input awaits WAV conversion and STT, then calls the
gateway callback with only guild, user and transcript, and the callback
routes by the guild's binding as it is at that moment. A `/voice join`
from another text channel while the bot is already connected rewrites
_voice_text_channels and _voice_sources, so an utterance captured for
the old conversation became a turn in the new one, with its channel
prompt, bound skills, transcript echo and replies.

The bound text channel is captured when the utterance is accepted and
compared after STT; if the binding moved, the stale work is dropped and
logged. Rejoining from the same channel keeps the conversation and is
dispatched as before. Nothing awaits between that check and the
callback's own read of the binding.

Reported by andrexibiza (F6) on #128788.

(cherry picked from commit 47d515eb4e1633945212e42d6cf77aba41906ea8)
2026-10-05 20:06:54 -04:00
Teknium
c93c632c93 fix(whatsapp): keep transient and restart-needed aiohttp installs retryable
ensure_aiohttp flattened every PM failure into the non-retryable
whatsapp_aiohttp_missing. That covered PM's post-install
"sms installed; restart Hermes to activate it" (sync succeeded, process
could not adopt the generation), a busy dependency lock and a failed
download. With WhatsApp as the only platform the gateway exited 78 and
systemd's RestartPreventExitStatus=78 kept it down.

ensure_aiohttp now returns (why, retryable). Final only when PM can never
install here (lazy installs disabled, or a foreign interpreter not on the
install's selected or older generation), when aiohttp is present but
broken, or when it is still absent after a successful install. A plain
shadowing aiohttp.py (no __path__) is reported as an incomplete install
instead of escaping as AttributeError.
2026-10-05 15:35:42 -07:00
Teknium
2dda207409 fix(whatsapp): install a missing aiohttp through PM and name every failed connect
A gateway whose dependency environment lacks aiohttp (a sealed env from
`hermes update` with a partial messaging extra, #126358) spawned the bridge,
then every /health poll raised ModuleNotFoundError inside
`_poll_bridge_health`'s blanket except. connect() returned False with no
fatal code, so the reconnect watcher retried forever while the bridge
answered /health 200, and telemetry counted each attempt as error_class
"other".

- connect() now obtains aiohttp through PM (`ensure_import("sms")`, the
  extra that is exactly aiohttp) before starting the bridge, the same
  lazy-install path Slack/Teams use. When PM cannot install it (lazy
  installs disabled, foreign interpreter, install failure) or aiohttp is a
  half-removed namespace shell (#71308), it fails with the named,
  non-retryable `whatsapp_aiohttp_missing` and never spawns the bridge.
- The bridge-died / "did not start in 15s" paths set a retryable fatal code
  (`whatsapp_bridge_exited` / `whatsapp_bridge_timeout`), so reconnect
  status and connect telemetry name the cause instead of "other".
- Folds the salvaged test file into test_whatsapp_connect.py as two
  invariant tests.
2026-10-05 15:35:42 -07:00
liuhao1024
f5e21913cc fix(whatsapp): name a missing aiohttp in the bridge preflight (salvage of #126557)
A sealed env built by `hermes update` can ship a partial messaging extra
without aiohttp. check_whatsapp_requirements only verified Node.js, so
connect() spawned the bridge and every health poll raised
ModuleNotFoundError — swallowed by `except Exception: continue` — and
the gateway looped forever on the misleading "Bridge HTTP server did not
start in 15s" (#126358).

Add _aiohttp_available() and wire it into _preflight() as a named,
non-retryable whatsapp_aiohttp_missing fatal error with install guidance,
so the failure surfaces before any bridge process is spawned.

Salvaged from #126557 (Finn763), whose lazy-install route through
tools/lazy_deps no longer exists on main (the module is now updater
shims); this keeps the fail-fast core and adapts it to the current
_preflight() structure.

Co-authored-by: Finn763 <165816600+Finn763@users.noreply.github.com>
2026-10-05 15:35:42 -07:00
SmokeDev
0e73d1d3b6 fix(discord): track live voice credentials across re-keys instead of snapshotting at start()
VoiceReceiver.start() captured conn.secret_key (by value) and
conn.dave_session (by reference) exactly once. Both go stale in normal
operation:

- op 4 SESSION_DESCRIPTION rotates the transport secret_key on every
  voice (re)connect — the snapshot then fails NaCl decrypt forever.
- voice_state.reinit_dave_session() REPLACES conn.dave_session with a
  NEW DaveSession when none existed yet, so a receiver that starts
  before DAVE finishes negotiating keeps None forever and treats E2EE
  as off — every frame reaches opus as ciphertext for the whole
  session.

The failure is silent by construction: decrypt failures warn for the
first 10 packets then go dark, and shredded opus output raises nothing.
The operator-visible symptom is "the bot ignores me."

Fix — resolve, don't copy:

- Credentials live in ONE tuple (secret_key, dave_session,
  dave_protocol_version, dave_downgraded), re-resolved from the live
  connection and swapped in a single reference store; the receive
  thread reads one consistent generation per packet, so a refresh can
  never pair a new key with an old session.
- Refresh triggers: the voice-ws hook on op 4 and op 24 (the hook runs
  AFTER received_message dispatches the op, so it reads the new
  values); membership changes in the bot's channel (DAVE epoch bump)
  via the existing on_voice_state_update handler; and a
  25-consecutive-NaCl-failure streak as the catch-all, retried every
  streak window while the failure persists.
- Unmapped-SSRC hole closed without recreating it elsewhere: with E2EE
  actively on (protocol_version > 0 and outside a passthrough window),
  frames for an SSRC with no user mapping are dropped (after the
  existing sole-member inference) instead of being fed to opus as
  ciphertext.  A non-null session alone is NOT the predicate — the
  session object survives protocol downgrades to 0 and passthrough
  transitions, where plaintext is legitimate.  Passthrough windows
  mirror discord.py's own set_passthrough_mode calls: op 21
  pending-downgrade opens 120s; op 22 opens a 10s grace ONLY on the
  dave_downgraded True -> False edge (upgrade-after-downgrade), with
  replace-semantics so the short grace supersedes a residual long
  window; a receiver starting mid-pending-downgrade seeds the window
  from conn.dave_pending_transitions.
- Silence made visible: decrypt failures keep emitting one warning per
  250 after the first 10, and stop() logs decode-health counters
  (ok / decrypt_failed / dave_unmapped_dropped).

Tests: tests/gateway/test_discord_voice_rekey.py (24) — credential
resolution/rotation, the late-DAVE-session upgrade, failure-streak
refresh + reset, op 4/24/5 hook behavior, original-hook passthrough,
the DAVE-state drop predicate (downgraded-plaintext decodes, op-21
window decodes, expired window drops, upgrade edge replaces the long
window, same-version and duplicate op 22 open nothing,
mid-pending-downgrade startup seeds), unmapped-drop vs mapped flow,
sole-member rescue, tuple-generation consistency, teardown health
line.  Adjacent voice_command / voice_mixer / send / opus suites green
under the diff (111 passed).

Fixes #77968
2026-10-05 16:42:08 -05:00
unsupportedpastels
c53536929e test(discord): annotate the turn prompt parity test; keep adapter.py within its cap
Add type hints to the parity test and fold two new adapter comments into one line each, so adapter.py stays at its size on main (code-health file cap). The channel-label helper lives beside the thread-name formatter in adapter_thread_titles.py.
2026-10-05 15:15:44 -04:00
John Paul Soliva
04a6eb4835 fix(discord): a spoken turn reads its thread's parent bindings and the channel's current labels
A spoken turn rebuilds its source from the copy `/voice join` stored, and
three of its prompt inputs still differed from a typed message in the same
conversation:

- voice joined from a thread looked up the channel prompt and bound skills
  for the thread alone, while a typed message there falls back to the
  parent channel's entry, so a parent-only binding never reached speech;
- the chat name and topic stayed at their join-time values, so after a
  rename or topic edit every typed/spoken switch re-rendered the pinned
  context prompt and back;
- `guild.get_member()` is cache-only, and on a miss the speaker was named
  by numeric id even when they were the member who ran `/voice join`.

The resolvers now get the stored parent id, the name and topic come from
the bound channel through the same formatters a message uses (the stored
values stay as the fallback when the channel is not cached), and an
uncached joiner keeps the name bound at join; any other uncached speaker
still gets their id, never the joiner's name.

Reported by andrexibiza (F1, F2) and teknium1 (get_member) on #128788.

Co-authored-by: Matt McFarland <mattttmcfarland@gmail.com>
(cherry picked from commit b993d20b240b34a61cf9c54dbd52d9050680f497)
2026-10-05 15:15:44 -04:00
John Paul Soliva
7c3a3bfc06 fix(discord): slash, /thread and voice turns carry a message turn's prompt inputs
Native slash commands (/skill, /plan, /queue), the /thread starter and
voice-channel input are human turns, so each re-pins the session-context
prompt and the channel prompt. They built their event without the fields
on_message sets:

- a DM slash turn had no chat name, so the "origin" line rendered the
  channel id instead of the user's name;
- a thread slash turn and the /thread starter dropped the parent channel
  from the thread label, and the starter took the parent channel's topic;
- a spoken turn named the speaker by numeric id, not display name;
- none carried the channel's bound skill, so a session opened by one of
  them never loaded it.

Each switch between one of these turns and a typed message re-rendered
the pinned context prompt and back again, a prompt-cache miss each way.
They now use the same formatters (_format_thread_chat_name,
_get_effective_topic on the thread, the member display name) and
_resolve_channel_skills as the message path.

(cherry picked from commit c2727ba500f22249dd729aba6365af3bb3c5e970)
2026-10-05 15:15:44 -04:00
unsupportedpastels
455e229ef9 test(discord): annotate the auto-thread topic parity test and keep it to the topic
Add type hints, drop the channel-prompt and skill-binding assertions (they guard behaviour the fix does not change), and fold the new adapter comment into one line so adapter.py stays within its code-health cap.
2026-10-05 14:05:04 -04:00