refactor(adapters/discord): 11250->7733; _HermesView base for 6 component views (1074-line factory -> 634), _send_prompt/_resolve_channel/_send_url_media/_send_local_file unification, opus loading and setup helpers extracted, dead _discord_allow_any_attachment removed
This commit is contained in:
+1807
-5289
File diff suppressed because it is too large
Load Diff
@@ -28,16 +28,13 @@ def resolve_ffmpeg_executable() -> str:
|
||||
explicit = os.getenv("FFMPEG_PATH")
|
||||
if explicit and explicit.strip():
|
||||
return os.path.expandvars(os.path.expanduser(explicit.strip()))
|
||||
|
||||
discovered = _shared_find_ffmpeg()
|
||||
if discovered:
|
||||
return discovered
|
||||
|
||||
local_appdata = os.getenv("LOCALAPPDATA")
|
||||
if local_appdata:
|
||||
packages_dir = Path(local_appdata) / "Microsoft" / "WinGet" / "Packages"
|
||||
candidates = sorted(packages_dir.glob("Gyan.FFmpeg_*/*/bin/ffmpeg.exe"))
|
||||
if candidates:
|
||||
return str(candidates[-1])
|
||||
|
||||
return "ffmpeg"
|
||||
|
||||
@@ -54,7 +54,6 @@ class DiscordRecoveryStore:
|
||||
|
||||
def _initialize(self, conn: sqlite3.Connection) -> None:
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
|
||||
apply_wal_with_fallback(conn, db_label="discord_recovery.db")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS discord_messages (
|
||||
@@ -97,16 +96,11 @@ class DiscordRecoveryStore:
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
cutoff = (
|
||||
dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=_RETENTION_DAYS)
|
||||
).isoformat()
|
||||
cutoff = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=_RETENTION_DAYS)).isoformat()
|
||||
conn.execute("DELETE FROM discord_messages WHERE updated_at < ?", (cutoff,))
|
||||
conn.execute(
|
||||
"DELETE FROM discord_recovery_scans "
|
||||
"WHERE COALESCE(completed_at, started_at) < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM discord_recovery_cursors WHERE updated_at < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
conn.execute("DELETE FROM discord_recovery_cursors WHERE updated_at < ?", (cutoff,))
|
||||
|
||||
@@ -95,14 +95,8 @@ class MixerChild:
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
pcm: bytes,
|
||||
*,
|
||||
loop: bool = False,
|
||||
gain: float = 1.0,
|
||||
is_speech: bool = False,
|
||||
fade_in_ms: int = 0,
|
||||
self, name: str, pcm: bytes, *, loop: bool = False, gain: float = 1.0,
|
||||
is_speech: bool = False, fade_in_ms: int = 0,
|
||||
):
|
||||
# Pad to a whole number of frames so looping is seamless and the final
|
||||
# partial frame doesn't click.
|
||||
@@ -134,20 +128,16 @@ class MixerChild:
|
||||
else:
|
||||
self._finished = True
|
||||
return None
|
||||
|
||||
np = _require_numpy()
|
||||
chunk = self._pcm[self._pos:self._pos + FRAME_SIZE]
|
||||
self._pos += FRAME_SIZE
|
||||
if len(chunk) < FRAME_SIZE:
|
||||
chunk = chunk + b"\x00" * (FRAME_SIZE - len(chunk))
|
||||
|
||||
samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32)
|
||||
|
||||
gain = self.gain
|
||||
if self.fade_frames and self._fade_done < self.fade_frames:
|
||||
self._fade_done += 1
|
||||
gain *= self._fade_done / self.fade_frames
|
||||
|
||||
if gain != 1.0:
|
||||
samples = samples * gain
|
||||
return samples
|
||||
@@ -167,11 +157,7 @@ class VoiceMixer(discord.AudioSource):
|
||||
return False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ambient_gain: float = 0.18,
|
||||
duck_gain: float = 0.06,
|
||||
speech_gain: float = 1.0,
|
||||
self, *, ambient_gain: float = 0.18, duck_gain: float = 0.06, speech_gain: float = 1.0,
|
||||
duck_release_ms: int = 400,
|
||||
):
|
||||
self._lock = threading.Lock()
|
||||
@@ -202,8 +188,7 @@ class VoiceMixer(discord.AudioSource):
|
||||
self._ambient = None
|
||||
return
|
||||
self._ambient = MixerChild(
|
||||
"ambient", pcm, loop=True,
|
||||
gain=self._effective_ambient_gain(), fade_in_ms=200,
|
||||
"ambient", pcm, loop=True, gain=self._effective_ambient_gain(), fade_in_ms=200,
|
||||
)
|
||||
|
||||
def _effective_ambient_gain(self) -> float:
|
||||
@@ -220,8 +205,7 @@ class VoiceMixer(discord.AudioSource):
|
||||
return
|
||||
with self._lock:
|
||||
child = MixerChild(
|
||||
"speech", pcm, loop=False,
|
||||
gain=self._speech_gain if gain is None else float(gain),
|
||||
"speech", pcm, loop=False, gain=self._speech_gain if gain is None else float(gain),
|
||||
is_speech=True, fade_in_ms=fade_in_ms,
|
||||
)
|
||||
self._speech.append(child)
|
||||
@@ -259,10 +243,8 @@ class VoiceMixer(discord.AudioSource):
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
return SILENCE_FRAME
|
||||
|
||||
np = _require_numpy()
|
||||
acc: "Optional[np.ndarray]" = None
|
||||
|
||||
# Speech children (drop exhausted ones; release duck when last ends)
|
||||
if self._speech:
|
||||
still_live: List[MixerChild] = []
|
||||
@@ -275,7 +257,6 @@ class VoiceMixer(discord.AudioSource):
|
||||
self._speech = still_live
|
||||
if not self._speech and self._speech_active:
|
||||
self._begin_duck_release_locked()
|
||||
|
||||
# Ambient bed — ramp gain back up during duck-release.
|
||||
if self._ambient is not None:
|
||||
if self._duck_release_left > 0 and not self._speech_active:
|
||||
@@ -290,10 +271,8 @@ class VoiceMixer(discord.AudioSource):
|
||||
amb = self._ambient.read_frame()
|
||||
if amb is not None:
|
||||
acc = amb if acc is None else acc + amb
|
||||
|
||||
if acc is None:
|
||||
return SILENCE_FRAME
|
||||
|
||||
np.clip(acc, -32768, 32767, out=acc)
|
||||
return acc.astype(np.int16).tobytes()
|
||||
|
||||
@@ -315,16 +294,11 @@ def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]:
|
||||
requirement of the voice path (see ``VoiceReceiver.pcm_to_wav``).
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
resolve_ffmpeg_executable(), "-y", "-loglevel", "error",
|
||||
"-i", path,
|
||||
"-f", "s16le",
|
||||
"-ar", str(SAMPLE_RATE),
|
||||
"-ac", str(CHANNELS),
|
||||
"pipe:1",
|
||||
resolve_ffmpeg_executable(), "-y", "-loglevel", "error", "-i", path, "-f", "s16le",
|
||||
"-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS), "pipe:1",
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
@@ -353,35 +327,26 @@ def synth_ambient_pcm(seconds: float = 4.0) -> bytes:
|
||||
np = _require_numpy()
|
||||
n = int(SAMPLE_RATE * seconds)
|
||||
t = np.arange(n, dtype=np.float64) / SAMPLE_RATE
|
||||
|
||||
# Choose base frequencies that complete whole cycles over the loop so the
|
||||
# wrap point is click-free.
|
||||
def _whole_cycle_freq(target: float) -> float:
|
||||
cycles = max(1, round(target * seconds))
|
||||
return cycles / seconds
|
||||
|
||||
f1 = _whole_cycle_freq(110.0)
|
||||
f2 = _whole_cycle_freq(110.5)
|
||||
trem = _whole_cycle_freq(0.5) # ~0.5 Hz tremolo
|
||||
|
||||
pad = (
|
||||
0.55 * np.sin(2 * np.pi * f1 * t)
|
||||
+ 0.45 * np.sin(2 * np.pi * f2 * t)
|
||||
)
|
||||
pad = (0.55 * np.sin(2 * np.pi * f1 * t) + 0.45 * np.sin(2 * np.pi * f2 * t))
|
||||
tremolo = 0.6 + 0.4 * (0.5 * (1 + np.sin(2 * np.pi * trem * t)))
|
||||
signal = pad * tremolo
|
||||
|
||||
# Smooth filtered noise for air, kept very low.
|
||||
rng = np.random.default_rng(7)
|
||||
noise = rng.standard_normal(n)
|
||||
kernel = np.ones(64) / 64.0
|
||||
noise = np.convolve(noise, kernel, mode="same")
|
||||
signal = signal + 0.08 * noise
|
||||
|
||||
# Normalise to a modest peak (mixer applies the real ambient gain on top).
|
||||
peak = float(np.max(np.abs(signal))) or 1.0
|
||||
signal = (signal / peak) * 0.5
|
||||
|
||||
mono16 = (signal * 32767.0).astype(np.int16)
|
||||
stereo16 = np.repeat(mono16[:, None], CHANNELS, axis=1).reshape(-1)
|
||||
return stereo16.tobytes()
|
||||
|
||||
@@ -9,8 +9,8 @@ class TestOpusFindLibrary:
|
||||
|
||||
def test_uses_find_library_first(self):
|
||||
"""find_library must be the primary lookup strategy."""
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
source = inspect.getsource(DiscordAdapter.connect)
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter, _load_opus_codec
|
||||
source = inspect.getsource(DiscordAdapter.connect) + inspect.getsource(_load_opus_codec)
|
||||
assert "find_library" in source, \
|
||||
"Opus loading must use ctypes.util.find_library"
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Parity tests for the shared ``_HermesView`` base behind the Discord component views.
|
||||
|
||||
Every view must keep its own user-visible rejection strings and the shared
|
||||
timeout behaviour (buttons disabled, embed greyed with the expiry footer).
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.platforms.discord.adapter import ( # noqa: E402
|
||||
ChoicePickerView,
|
||||
ClarifyChoiceView,
|
||||
ExecApprovalView,
|
||||
ModelPickerView,
|
||||
SlashConfirmView,
|
||||
UpdatePromptView,
|
||||
)
|
||||
|
||||
|
||||
def _interaction(user_id=1):
|
||||
return SimpleNamespace(
|
||||
user=SimpleNamespace(id=user_id, display_name="alice", roles=[]),
|
||||
response=SimpleNamespace(send_message=AsyncMock(), edit_message=AsyncMock(), defer=AsyncMock()),
|
||||
message=SimpleNamespace(embeds=[]),
|
||||
data={"values": ["x"]},
|
||||
channel_id=5,
|
||||
)
|
||||
|
||||
|
||||
async def _noop(*_a, **_k):
|
||||
return ""
|
||||
|
||||
|
||||
def _views():
|
||||
return {
|
||||
"exec": ExecApprovalView(session_key="s", allowed_user_ids=set()),
|
||||
"slash": SlashConfirmView(session_key="s", confirm_id="c", allowed_user_ids=set()),
|
||||
"update": UpdatePromptView(session_key="s", allowed_user_ids=set()),
|
||||
"clarify": ClarifyChoiceView(choices=["a"], clarify_id="c", allowed_user_ids=set()),
|
||||
"model": ModelPickerView(
|
||||
providers=[], current_model="m", current_provider="p", session_key="s",
|
||||
on_model_selected=_noop, allowed_user_ids=set(),
|
||||
),
|
||||
"choice": ChoicePickerView(choices=[{"value": "v"}], on_choice_selected=_noop, allowed_user_ids=set()),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"name,call,expected",
|
||||
[
|
||||
("exec", lambda v, i: v._resolve(i, "once", None, "x"), "You're not authorized to approve commands~"),
|
||||
("slash", lambda v, i: v._resolve(i, "once", None, "x"), "You're not authorized to answer this prompt~"),
|
||||
("update", lambda v, i: v._respond(i, "y", None, "x"), "You're not authorized~"),
|
||||
("clarify", lambda v, i: v._resolve_choice(i, 0, "a"), "You're not authorized to answer this prompt~"),
|
||||
("clarify", lambda v, i: v._on_other(i), "You're not authorized to answer this prompt~"),
|
||||
("model", lambda v, i: v._on_provider_selected(i), "You're not authorized~"),
|
||||
("model", lambda v, i: v._on_back(i), "You're not authorized~"),
|
||||
("choice", lambda v, i: v._on_select(i), "⛔ You are not authorized to change this setting."),
|
||||
],
|
||||
)
|
||||
async def test_unauthorized_click_strings_preserved(monkeypatch, name, call, expected):
|
||||
monkeypatch.delenv("DISCORD_ALLOW_ALL_USERS", raising=False)
|
||||
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
|
||||
monkeypatch.delenv("GATEWAY_ALLOWED_USERS", raising=False)
|
||||
view = _views()[name]
|
||||
interaction = _interaction()
|
||||
await call(view, interaction)
|
||||
interaction.response.send_message.assert_awaited_once_with(expected, ephemeral=True)
|
||||
interaction.response.edit_message.assert_not_called()
|
||||
assert view.resolved is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"name,call,expected",
|
||||
[
|
||||
("exec", lambda v, i: v._resolve(i, "once", None, "x"), "This approval has already been resolved~"),
|
||||
("slash", lambda v, i: v._resolve(i, "once", None, "x"), "This prompt has already been resolved~"),
|
||||
("update", lambda v, i: v._respond(i, "y", None, "x"), "Already answered~"),
|
||||
("clarify", lambda v, i: v._resolve_choice(i, 0, "a"), "This prompt has already been answered~"),
|
||||
("model", lambda v, i: v._on_model_selected(i), "Already resolved~"),
|
||||
],
|
||||
)
|
||||
async def test_already_resolved_strings_preserved(monkeypatch, name, call, expected):
|
||||
monkeypatch.setenv("DISCORD_ALLOW_ALL_USERS", "true")
|
||||
view = _views()[name]
|
||||
view.resolved = True
|
||||
interaction = _interaction()
|
||||
await call(view, interaction)
|
||||
interaction.response.send_message.assert_awaited_once_with(expected, ephemeral=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("name", ["exec", "slash", "update", "clarify"])
|
||||
async def test_on_timeout_disables_and_greys_embed(name):
|
||||
view = _views()[name]
|
||||
embed = SimpleNamespace(color=None, set_footer=lambda *, text: setattr(embed, "footer", text))
|
||||
msg = SimpleNamespace(embeds=[embed], edit=AsyncMock())
|
||||
view._message = msg
|
||||
await view.on_timeout()
|
||||
assert view.resolved is True
|
||||
assert all(child.disabled for child in view.children)
|
||||
assert embed.footer == "⏱ Prompt expired — no action taken"
|
||||
msg.edit.assert_awaited_once_with(embed=embed, view=view)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_timeout_without_message_is_safe():
|
||||
for view in _views().values():
|
||||
await view.on_timeout()
|
||||
Reference in New Issue
Block a user