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.
53 lines
2.2 KiB
Python
53 lines
2.2 KiB
Python
"""Voice-transcript dedup belongs to the bot that heard the utterance and the conversation it is bound to.
|
|
|
|
The runner keeps one dedup store for every Discord bot it serves. Keyed by guild and speaker alone, a
|
|
second bot (another profile) in the same guild dropped its own copy of an utterance because the first
|
|
bot had just recorded it, and a rebind to another text channel inherited the old binding's history.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
import gateway.run as gateway_run
|
|
from gateway.config import Platform
|
|
from gateway.session import SessionSource
|
|
|
|
_GUILD, _USER = 1, 42
|
|
|
|
|
|
class _Bot:
|
|
"""What the voice callback reads from a Discord adapter (weakref-able)."""
|
|
|
|
def __init__(self, profile: str | None) -> None:
|
|
self._owner_profile = profile
|
|
self._voice_text_channels, self._voice_sources = {_GUILD: 700}, {}
|
|
self._client = SimpleNamespace(get_channel=lambda _id: None, get_guild=lambda _id: None)
|
|
self.handle_message = AsyncMock()
|
|
|
|
def build_source(self, **fields) -> SessionSource:
|
|
return SessionSource(platform=Platform.DISCORD, profile=self._owner_profile, **fields)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dedup_is_scoped_to_the_receiving_bot_and_its_binding() -> None:
|
|
bot_a, bot_b = _Bot(None), _Bot("bot-b")
|
|
runner = object.__new__(gateway_run.GatewayRunner)
|
|
runner.adapters = {Platform.DISCORD: bot_a}
|
|
runner._voice_mode, runner._session_db, runner.session_store = {}, None, MagicMock()
|
|
runner._canonicalize = lambda source, **_kw: object()
|
|
runner._is_user_authorized = lambda source: True
|
|
|
|
async def say(bot: _Bot, text: str = "what broke on the ingest box") -> None:
|
|
await runner._handle_voice_channel_input(_GUILD, _USER, text, adapter=bot)
|
|
|
|
await say(bot_a)
|
|
await say(bot_b)
|
|
await say(bot_a) # the same capture emitted twice: still suppressed
|
|
assert (bot_a.handle_message.await_count, bot_b.handle_message.await_count) == (1, 1)
|
|
bot_a._voice_text_channels[_GUILD] = 800 # /voice join from another text channel
|
|
await say(bot_a)
|
|
assert bot_a.handle_message.await_count == 2
|