From 4787e4d56fc8d9265d4c7d3c0fe5accee86b4078 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:26:11 -0700 Subject: [PATCH] fix(discord): a voice speaker outside the member cache still gets a name guild.get_member() is cache-only, so a speaker who had never typed in the guild (no member intent, or joined voice straight away) rendered as the bare user id while their text turns render a display name: the session-context prompt differed again on every switch. Fall back to the cached global user (global name / username) before giving up; a guild nick still wins when the member is cached. Suggested in the first review, reproduced independently (F3 on the thread). [adapted onto main: #133463 resolves the speaker name inline in _voice_input_source, not through DiscordAdapter.voice_member_display_name, so the fallback is a small helper there. It runs after the /voice join invoker's bound name, which is the nickname their typed turns carry; a cached global name would differ from it. Tests moved to test_discord_turn_prompt_parity.py] (cherry picked from commit 295a01f3ab88f07f3bac22f8939c42bca928c398) --- gateway/run_voice.py | 10 ++++++++++ tests/gateway/test_discord_turn_prompt_parity.py | 12 ++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/gateway/run_voice.py b/gateway/run_voice.py index 5d6fd672db..128dbddde4 100644 --- a/gateway/run_voice.py +++ b/gateway/run_voice.py @@ -237,6 +237,14 @@ class GatewayVoiceMixin: recent_store[key] = (recent + [(now, normalized)])[-5:] return False + @staticmethod + def _cached_user_display_name(client, user_id: int) -> Optional[str]: + """``get_member`` is cache-only; a speaker it misses may still be a cached user, whose display + name (global name or username, no server nickname) beats a bare id.""" + get_user = getattr(client, "get_user", None) + name = getattr(get_user(int(user_id)) if callable(get_user) else None, "display_name", None) + return name if isinstance(name, str) and name else None + @staticmethod def _voice_input_source(adapter, guild_id: int, user_id: int, text_ch_id) -> SessionSource: """Bound text channel's own source when available (voice shares the text conversation's @@ -254,11 +262,13 @@ class GatewayVoiceMixin: # join, never another participant's. if user_name is None and source.user_id == str(user_id): user_name = source.user_name + user_name = user_name or GatewayVoiceMixin._cached_user_display_name(client, user_id) source.user_id, source.user_name = str(user_id), user_name or str(user_id) # The bound source is the `/voice join` message's; its id is not this turn's trigger # (run.py exports it as HERMES_SESSION_MESSAGE_ID for reply anchoring). source.message_id = None else: + user_name = user_name or GatewayVoiceMixin._cached_user_display_name(client, user_id) source = SessionSource( platform=Platform.DISCORD, chat_id=str(text_ch_id), user_id=str(user_id), user_name=user_name or str(user_id), chat_type="channel", diff --git a/tests/gateway/test_discord_turn_prompt_parity.py b/tests/gateway/test_discord_turn_prompt_parity.py index f58a4fe3ba..2a03097ec9 100644 --- a/tests/gateway/test_discord_turn_prompt_parity.py +++ b/tests/gateway/test_discord_turn_prompt_parity.py @@ -137,6 +137,9 @@ async def test_voice_channel_turn_matches_a_typed_turn(monkeypatch: pytest.Monke channel.name, channel.topic = "renamed", "New topic" if case == "speaker-uncached": adapter._client.get_guild = lambda _id: SimpleNamespace(get_member=lambda _uid: None) + # Cached as users only: the joiner's bound nickname still beats their global name. + adapter._client.get_user = {42: SimpleNamespace(display_name="alice_global"), + 43: SimpleNamespace(display_name="Bob")}.get typed = await _typed(adapter, channel) runner = object.__new__(gateway_run.GatewayRunner) runner.adapters = {Platform.DISCORD: adapter} @@ -151,6 +154,11 @@ async def test_voice_channel_turn_matches_a_typed_turn(monkeypatch: pytest.Monke assert spoken is not typed _assert_same_prompt_inputs(typed, spoken) assert spoken.source.message_id is None - # The join-time name belongs to the joiner only; another uncached speaker never borrows it. + # The join-time name belongs to the joiner only; another uncached speaker never borrows it, and + # gets their cached user name (global name, no server nickname) rather than a bare id. other = runner._voice_input_source(adapter, 1, 43, channel.id).user_name - assert other == ("43" if case == "speaker-uncached" else "Alice") + assert other == ("Bob" if case == "speaker-uncached" else "Alice") + if case == "speaker-uncached": + assert runner._voice_input_source(adapter, 1, 44, channel.id).user_name == "44" # cached nowhere + adapter._voice_sources = {} + assert runner._voice_input_source(adapter, 1, 43, channel.id).user_name == "Bob" # no binding