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