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:
Teknium
2026-09-02 14:06:32 -07:00
parent cc11c6bfdd
commit 6e5b084b8b
6 changed files with 1932 additions and 5345 deletions
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"
+2 -8
View File
@@ -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,))
+8 -43
View File
@@ -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()
+2 -2
View File
@@ -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()