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)
This commit is contained in:
teknium1
2026-10-03 14:26:11 -07:00
committed by Mark S.
parent 33599cf4c0
commit 4787e4d56f
2 changed files with 20 additions and 2 deletions

View File

@@ -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",

View File

@@ -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