213 lines
8.9 KiB
Python
213 lines
8.9 KiB
Python
"""Tests for voice mode platform isolation (bug #12542).
|
|
|
|
Voice mode state stored as {chat_id: mode} without a platform namespace
|
|
caused collisions: Telegram chat '123' and Slack chat '123' shared the
|
|
same key. The fix prefixes keys with platform value: 'telegram:123' vs
|
|
'slack:123'.
|
|
"""
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
from gateway.config import Platform
|
|
from gateway.run import GatewayRunner
|
|
|
|
|
|
class TestVoiceKeyHelper:
|
|
"""Test the _voice_key helper method."""
|
|
|
|
|
|
def test_voice_key_different_platforms_same_chat_id(self):
|
|
"""Same chat_id on different platforms yields different keys."""
|
|
runner = _make_runner()
|
|
key_telegram = runner._voice_key(Platform.TELEGRAM, "123")
|
|
key_slack = runner._voice_key(Platform.SLACK, "123")
|
|
key_discord = runner._voice_key(Platform.DISCORD, "123")
|
|
assert key_telegram != key_slack
|
|
assert key_slack != key_discord
|
|
assert key_telegram == "telegram:123"
|
|
assert key_slack == "slack:123"
|
|
assert key_discord == "discord:123"
|
|
|
|
|
|
class TestVoiceModePlatformIsolation:
|
|
"""Test that voice mode state is isolated by platform."""
|
|
|
|
def test_telegram_and_slack_voice_mode_independent(self):
|
|
"""Setting voice mode for Telegram chat '123' does not affect Slack chat '123'."""
|
|
runner = _make_runner()
|
|
|
|
# Enable voice mode for Telegram chat '123'
|
|
runner._voice_mode[runner._voice_key(Platform.TELEGRAM, "123")] = "all"
|
|
# Enable voice mode for Slack chat '123' to a different mode
|
|
runner._voice_mode[runner._voice_key(Platform.SLACK, "123")] = "voice_only"
|
|
|
|
# Verify they are independent
|
|
assert runner._voice_mode.get(runner._voice_key(Platform.TELEGRAM, "123")) == "all"
|
|
assert runner._voice_mode.get(runner._voice_key(Platform.SLACK, "123")) == "voice_only"
|
|
|
|
# Disabling Telegram should not affect Slack
|
|
runner._voice_mode[runner._voice_key(Platform.TELEGRAM, "123")] = "off"
|
|
assert runner._voice_mode.get(runner._voice_key(Platform.TELEGRAM, "123")) == "off"
|
|
assert runner._voice_mode.get(runner._voice_key(Platform.SLACK, "123")) == "voice_only"
|
|
|
|
|
|
class TestLegacyKeyMigration:
|
|
"""Test migration of legacy unprefixed keys in _load_voice_modes."""
|
|
|
|
def test_load_voice_modes_skips_legacy_keys(self):
|
|
"""_load_voice_modes skips keys without ':' prefix and logs a warning."""
|
|
runner = _make_runner()
|
|
|
|
# Simulate legacy persisted data with unprefixed keys
|
|
legacy_data = {
|
|
"123": "all",
|
|
"456": "voice_only",
|
|
# Also includes a properly prefixed key (from after the fix)
|
|
"telegram:789": "off",
|
|
}
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
voice_path = Path(tmpdir) / "gateway_voice_mode.json"
|
|
voice_path.write_text(json.dumps(legacy_data))
|
|
|
|
with patch.object(runner, "_VOICE_MODE_PATH", voice_path):
|
|
with patch("gateway.run.logger") as mock_logger:
|
|
result = runner._load_voice_modes()
|
|
|
|
# Legacy keys without ':' should be skipped
|
|
assert "123" not in result
|
|
assert "456" not in result
|
|
# Prefixed key should be preserved
|
|
assert result.get("telegram:789") == "off"
|
|
# Warning should be logged for each legacy key
|
|
assert mock_logger.warning.called
|
|
warning_calls = [str(call) for call in mock_logger.warning.call_args_list]
|
|
assert any("Skipping legacy unprefixed voice mode key" in str(c) for c in warning_calls)
|
|
|
|
|
|
class TestSyncVoiceModeStateToAdapter:
|
|
"""Test _sync_voice_mode_state_to_adapter filters by platform."""
|
|
|
|
def test_sync_only_includes_platform_chats(self):
|
|
"""Only chats matching the adapter's platform are synced."""
|
|
runner = _make_runner()
|
|
|
|
# Set up voice mode state with multiple platforms
|
|
runner._voice_mode = {
|
|
"telegram:123": "off", # Should sync
|
|
"telegram:456": "all", # Should NOT sync (mode is not "off")
|
|
"slack:123": "off", # Should NOT sync (different platform)
|
|
"discord:789": "off", # Should NOT sync (different platform)
|
|
}
|
|
|
|
# Create a mock Telegram adapter
|
|
mock_adapter = MagicMock()
|
|
mock_adapter.platform = Platform.TELEGRAM
|
|
mock_adapter._auto_tts_disabled_chats = set()
|
|
|
|
runner._sync_voice_mode_state_to_adapter(mock_adapter)
|
|
|
|
# Only telegram:123 should be in disabled_chats (mode="off" for telegram)
|
|
assert mock_adapter._auto_tts_disabled_chats == {"123"}
|
|
|
|
|
|
class TestVoiceModeProfileIsolation:
|
|
"""Two multiplexed bots in one Discord channel keep independent /voice
|
|
state and voice transcripts dispatch through the bot that heard them
|
|
(#75198 voice half)."""
|
|
|
|
@staticmethod
|
|
def _discord_adapter(owner=None):
|
|
from unittest.mock import AsyncMock
|
|
|
|
a = MagicMock()
|
|
a.platform = Platform.DISCORD
|
|
a._owner_profile = owner
|
|
a._voice_text_channels = {111: 123}
|
|
a._voice_sources = {}
|
|
a._voice_input_callback = None
|
|
a._on_voice_disconnect = None
|
|
a._voice_mode_getter = None
|
|
a._auto_tts_enabled_chats = set()
|
|
a._auto_tts_disabled_chats = set()
|
|
a._client = MagicMock()
|
|
a._client.get_channel = MagicMock(return_value=None)
|
|
a.handle_message = AsyncMock()
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_voice_state_and_transcripts_stay_with_the_owning_bot(self, tmp_path):
|
|
from types import SimpleNamespace
|
|
|
|
from gateway.platforms.base import MessageEvent, MessageType, SessionSource
|
|
|
|
runner = _make_runner()
|
|
runner._VOICE_MODE_PATH = tmp_path / "voice.json"
|
|
runner._is_user_authorized = lambda source: True
|
|
default_ad = self._discord_adapter()
|
|
bot2_ad = self._discord_adapter(owner="bot2")
|
|
runner.adapters = {Platform.DISCORD: default_ad}
|
|
runner._profile_adapters = {"bot2": {Platform.DISCORD: bot2_ad}}
|
|
# Inbound event from bot2's transport in channel 123 (same id the
|
|
# default bot also sees).
|
|
src = SessionSource(platform=Platform.DISCORD, chat_id="123", user_id="u1",
|
|
chat_type="channel", profile="bot2")
|
|
src._transport_adapter_ref = lambda: bot2_ad
|
|
|
|
await runner._handle_voice_command(
|
|
MessageEvent(text="/voice tts", message_type=MessageType.TEXT, source=src)
|
|
)
|
|
assert runner._voice_mode == {"bot2:discord:123": "all"}
|
|
assert "123" in bot2_ad._auto_tts_enabled_chats
|
|
assert "123" not in default_ad._auto_tts_enabled_chats
|
|
|
|
# A transcript captured by bot2's adapter runs through bot2, not default.
|
|
runner._bind_voice_input_callback(bot2_ad)
|
|
await bot2_ad._voice_input_callback(guild_id=111, user_id=42, transcript="hi")
|
|
bot2_ad.handle_message.assert_awaited_once()
|
|
default_ad.handle_message.assert_not_awaited()
|
|
assert bot2_ad.handle_message.call_args[0][0].source.profile == "bot2"
|
|
|
|
# Timeout cleanup from bot2's channel disables bot2's auto-TTS only.
|
|
join = MessageEvent(text="/voice channel", message_type=MessageType.TEXT, source=src)
|
|
join.raw_message = SimpleNamespace(guild_id=111, guild=None)
|
|
bot2_ad.join_voice_channel = AsyncMock(return_value=True)
|
|
ch = MagicMock(); ch.name = "General"
|
|
bot2_ad.get_user_voice_channel = AsyncMock(return_value=ch)
|
|
await runner._handle_voice_channel_join(join)
|
|
bot2_ad._on_voice_disconnect("123")
|
|
assert runner._voice_mode["bot2:discord:123"] == "off"
|
|
assert "123" in bot2_ad._auto_tts_disabled_chats
|
|
assert "123" not in default_ad._auto_tts_disabled_chats
|
|
|
|
def test_sync_restores_only_the_owning_profiles_chats(self):
|
|
runner = _make_runner()
|
|
runner._voice_mode = {"discord:1": "all", "bot2:discord:2": "all"}
|
|
default_ad = MagicMock(); default_ad.platform = Platform.DISCORD
|
|
default_ad._owner_profile = None; default_ad._auto_tts_enabled_chats = set()
|
|
bot2_ad = MagicMock(); bot2_ad.platform = Platform.DISCORD
|
|
bot2_ad._owner_profile = "bot2"; bot2_ad._auto_tts_enabled_chats = set()
|
|
runner._sync_voice_mode_state_to_adapter(default_ad)
|
|
runner._sync_voice_mode_state_to_adapter(bot2_ad)
|
|
assert default_ad._auto_tts_enabled_chats == {"1"}
|
|
assert bot2_ad._auto_tts_enabled_chats == {"2"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_runner() -> GatewayRunner:
|
|
"""Create a minimal GatewayRunner for testing."""
|
|
with patch("gateway.run.GatewayRunner._load_voice_modes", return_value={}):
|
|
runner = GatewayRunner.__new__(GatewayRunner)
|
|
runner._voice_mode = {}
|
|
runner.adapters = {}
|
|
return runner
|