280 lines
9.3 KiB
Python
280 lines
9.3 KiB
Python
import asyncio
|
|
import os
|
|
import sys
|
|
import wave
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
|
|
import discord
|
|
|
|
import bot
|
|
import voice
|
|
|
|
|
|
class FakeChannel:
|
|
def __init__(self, failures_before_success=0, channel_id=999):
|
|
self.failures_before_success = failures_before_success
|
|
self.messages = []
|
|
self.attempts = 0
|
|
self.id = channel_id
|
|
|
|
async def send(self, text):
|
|
self.attempts += 1
|
|
if self.attempts <= self.failures_before_success:
|
|
response = SimpleNamespace(status=403, reason="Forbidden")
|
|
raise discord.Forbidden(response, {"message": "Missing Permissions", "code": 50013})
|
|
self.messages.append(text)
|
|
|
|
|
|
def test_safe_send_chunked_falls_back_to_secondary_channel():
|
|
async def run():
|
|
primary = FakeChannel(failures_before_success=1, channel_id=100)
|
|
fallback = FakeChannel(channel_id=200)
|
|
delivered = await bot.safe_send_chunked(
|
|
primary,
|
|
"hello world",
|
|
fallback_channels=[fallback],
|
|
purpose="test notification",
|
|
)
|
|
return delivered, fallback.messages
|
|
|
|
delivered, messages = asyncio.run(run())
|
|
assert messages == ["hello world"]
|
|
assert delivered is not None
|
|
|
|
|
|
def test_safe_send_chunked_returns_none_when_all_channels_fail():
|
|
async def run():
|
|
primary = FakeChannel(failures_before_success=10, channel_id=100)
|
|
fallback = FakeChannel(failures_before_success=10, channel_id=200)
|
|
return await bot.safe_send_chunked(
|
|
primary,
|
|
"hello world",
|
|
fallback_channels=[fallback],
|
|
purpose="test notification",
|
|
)
|
|
|
|
assert asyncio.run(run()) is None
|
|
|
|
|
|
def test_build_retry_notice_mentions_reason_and_path():
|
|
message = bot.build_retry_notice(
|
|
file_path=str(Path("recordings") / "1" / "session" / "meeting.wav"),
|
|
reason="OpenRouter 502",
|
|
)
|
|
|
|
assert "OpenRouter 502" in message
|
|
assert "meeting.wav" in message
|
|
assert "`/retry`" in message
|
|
|
|
|
|
def test_meeting_recorder_wraps_meeting_sink_with_silence_generator(tmp_path, monkeypatch):
|
|
created = {}
|
|
|
|
class FakeMeetingSink:
|
|
def __init__(self, output_path, tracks_dir):
|
|
self.output_path = output_path
|
|
self.tracks_dir = tracks_dir
|
|
created["meeting"] = self
|
|
|
|
class FakeSilenceGeneratorSink:
|
|
def __init__(self, destination):
|
|
self.destination = destination
|
|
created["silence"] = self
|
|
|
|
class FakeVoiceClient:
|
|
def __init__(self):
|
|
self.listened_sink = None
|
|
|
|
def is_listening(self):
|
|
return False
|
|
|
|
def listen(self, sink, after):
|
|
self.listened_sink = sink
|
|
created["after"] = after
|
|
|
|
monkeypatch.setattr(voice, "MeetingSink", FakeMeetingSink)
|
|
monkeypatch.setattr(voice.voice_recv, "SilenceGeneratorSink", FakeSilenceGeneratorSink)
|
|
|
|
vc = FakeVoiceClient()
|
|
output_path = tmp_path / "meeting.wav"
|
|
recorder = voice.MeetingRecorder(vc, str(output_path))
|
|
|
|
asyncio.run(recorder.start(lambda error: None))
|
|
|
|
assert created["meeting"].output_path == str(output_path)
|
|
assert created["meeting"].tracks_dir == str(tmp_path / "tracks")
|
|
assert created["silence"].destination is created["meeting"]
|
|
assert vc.listened_sink is created["silence"]
|
|
assert recorder.recording is True
|
|
|
|
|
|
def test_wait_for_file_ready_rejects_stable_but_invalid_wav(tmp_path):
|
|
broken = tmp_path / "meeting.wav"
|
|
broken.write_bytes(b"not a real wav file")
|
|
|
|
ready = asyncio.run(bot.wait_for_file_ready(str(broken), attempts=3, delay=0.001))
|
|
|
|
assert ready is False
|
|
|
|
|
|
def test_wait_for_file_ready_accepts_valid_wav(tmp_path):
|
|
valid = tmp_path / "meeting.wav"
|
|
with wave.open(str(valid), "wb") as wav_file:
|
|
wav_file.setnchannels(1)
|
|
wav_file.setsampwidth(2)
|
|
wav_file.setframerate(16000)
|
|
wav_file.writeframes(b"\x00\x00" * 160)
|
|
|
|
ready = asyncio.run(bot.wait_for_file_ready(str(valid), attempts=3, delay=0.001))
|
|
|
|
assert ready is True
|
|
|
|
|
|
def test_wait_for_recording_artifacts_ready_accepts_tracks_created_during_wait(tmp_path):
|
|
meeting = tmp_path / "meeting.wav"
|
|
meeting.write_bytes(b"not a finalized mixed wav")
|
|
tracks_dir = tmp_path / "tracks"
|
|
tracks_dir.mkdir()
|
|
|
|
async def create_tracks_later():
|
|
await asyncio.sleep(0.003)
|
|
alice = tracks_dir / "track-111.wav"
|
|
alice.write_bytes(b"alice audio")
|
|
(tracks_dir / "tracks.json").write_text(
|
|
'{"tracks":[{"display_name":"Alice","path":"' + str(alice) + '"}]}'
|
|
)
|
|
|
|
async def run_wait():
|
|
writer = asyncio.create_task(create_tracks_later())
|
|
try:
|
|
return await bot.wait_for_recording_artifacts_ready(str(meeting), attempts=20, delay=0.001)
|
|
finally:
|
|
await writer
|
|
|
|
assert asyncio.run(run_wait()) == "tracks"
|
|
|
|
|
|
def test_load_speaker_tracks_resolves_manifest_tracks_next_to_recording(tmp_path):
|
|
meeting = tmp_path / "meeting.wav"
|
|
meeting.write_bytes(b"RIFFdemo")
|
|
tracks_dir = tmp_path / "tracks"
|
|
tracks_dir.mkdir()
|
|
alice = tracks_dir / "track-111.wav"
|
|
alice.write_bytes(b"alice")
|
|
missing = tracks_dir / "track-222.wav"
|
|
(tracks_dir / "tracks.json").write_text(
|
|
'{"tracks":[{"display_name":"Alice","path":"missing-prefix/track-111.wav"},'
|
|
'{"display_name":"Bob","path":"' + str(missing) + '"}]}'
|
|
)
|
|
|
|
tracks = bot._load_speaker_tracks(str(meeting))
|
|
|
|
assert tracks == [{"display_name": "Alice", "path": str(alice)}]
|
|
|
|
|
|
def test_transcribe_recording_uses_speaker_tracks_when_manifest_exists(tmp_path, monkeypatch):
|
|
meeting = tmp_path / "meeting.wav"
|
|
meeting.write_bytes(b"RIFFdemo")
|
|
tracks_dir = tmp_path / "tracks"
|
|
tracks_dir.mkdir()
|
|
alice = tracks_dir / "track-111.wav"
|
|
alice.write_bytes(b"alice")
|
|
(tracks_dir / "tracks.json").write_text(
|
|
'{"tracks":[{"display_name":"Alice","path":"' + str(alice) + '"}]}'
|
|
)
|
|
calls = []
|
|
|
|
async def fake_transcribe_tracks(tracks):
|
|
calls.append(("tracks", tracks))
|
|
return "[Alice]\nhello"
|
|
|
|
async def fake_transcribe(path):
|
|
calls.append(("single", path))
|
|
return "single transcript"
|
|
|
|
monkeypatch.setattr(bot, "transcribe_tracks", fake_transcribe_tracks)
|
|
monkeypatch.setattr(bot, "transcribe", fake_transcribe)
|
|
|
|
result = asyncio.run(bot._transcribe_recording(str(meeting), guild_id=123))
|
|
|
|
assert result == "[Alice]\nhello"
|
|
assert calls == [("tracks", [{"display_name": "Alice", "path": str(alice)}])]
|
|
|
|
|
|
def test_transcribe_recording_falls_back_to_single_file_for_old_recordings(tmp_path, monkeypatch):
|
|
meeting = tmp_path / "meeting.wav"
|
|
meeting.write_bytes(b"RIFFdemo")
|
|
calls = []
|
|
|
|
async def fake_transcribe_tracks(tracks):
|
|
calls.append(("tracks", tracks))
|
|
return "should not be used"
|
|
|
|
async def fake_transcribe(path):
|
|
calls.append(("single", path))
|
|
return "single transcript"
|
|
|
|
monkeypatch.setattr(bot, "transcribe_tracks", fake_transcribe_tracks)
|
|
monkeypatch.setattr(bot, "transcribe", fake_transcribe)
|
|
|
|
result = asyncio.run(bot._transcribe_recording(str(meeting), guild_id=123))
|
|
|
|
assert result == "single transcript"
|
|
assert calls == [("single", str(meeting))]
|
|
|
|
|
|
def test_process_recording_skips_ready_wait_when_tracks_exist(tmp_path, monkeypatch):
|
|
"""When speaker tracks exist, process_recording should not call wait_for_file_ready."""
|
|
meeting = tmp_path / "meeting.wav"
|
|
meeting.write_bytes(b"RIFFdemo")
|
|
tracks_dir = tmp_path / "tracks"
|
|
tracks_dir.mkdir()
|
|
alice = tracks_dir / "track-111.wav"
|
|
alice.write_bytes(b"alice audio")
|
|
(tracks_dir / "tracks.json").write_text(
|
|
'{"tracks":[{"display_name":"Alice","path":"' + str(alice) + '"}]}'
|
|
)
|
|
calls = []
|
|
|
|
async def fake_transcribe_recording(path, guild_id):
|
|
calls.append(("transcribe", path))
|
|
return "[Alice]\nhello"
|
|
|
|
async def fake_summarize(transcript):
|
|
calls.append(("summarize", transcript))
|
|
return "Summary text."
|
|
|
|
async def fake_get_output_channel(guild_id):
|
|
return 999
|
|
|
|
async def fake_clear_retry_state(_guild_id):
|
|
pass
|
|
|
|
async def fake_resolve_text_channel(_channel_id):
|
|
return SimpleNamespace(id=999, send=lambda t: None)
|
|
|
|
async def fake_safe_send_chunked(_channel, text, **kw):
|
|
calls.append(("delivered", text[:30]))
|
|
return True
|
|
|
|
monkeypatch.setattr(bot, "_transcribe_recording", fake_transcribe_recording)
|
|
monkeypatch.setattr(bot, "summarize", fake_summarize)
|
|
monkeypatch.setattr(bot, "wait_for_file_ready", lambda *a, **kw: calls.append(("ready",)) or True)
|
|
monkeypatch.setattr(bot, "safe_send_chunked", fake_safe_send_chunked)
|
|
monkeypatch.setattr(bot, "config", SimpleNamespace(
|
|
get_output_channel=fake_get_output_channel,
|
|
clear_retry_state=fake_clear_retry_state,
|
|
set_retry_state=lambda g, p, c, r: None,
|
|
))
|
|
monkeypatch.setattr(bot, "resolve_text_channel", fake_resolve_text_channel)
|
|
|
|
asyncio.run(bot.process_recording(str(meeting), 123, 456, None))
|
|
|
|
# wait_for_file_ready should NOT have been called
|
|
assert ("ready",) not in calls, "wait_for_file_ready was called despite tracks existing"
|
|
assert calls[:2] == [("transcribe", str(meeting)),
|
|
("summarize", "[Alice]\nhello")]
|