feat(channel): add proxy support for Discord channel

- Add proxy, proxy_username, proxy_password fields to DiscordConfig
- Pass proxy and proxy_auth to discord.Client
- Add aiohttp.BasicAuth when credentials are provided
- Add tests for proxy configuration scenarios
This commit is contained in:
Jonas
2026-04-09 23:54:11 +08:00
committed by Xubin Ren
parent 0e6331b66d
commit 7506af7104
2 changed files with 157 additions and 19 deletions
+110 -10
View File
@@ -5,11 +5,17 @@ from pathlib import Path
from types import SimpleNamespace
import pytest
discord = pytest.importorskip("discord")
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.discord import MAX_MESSAGE_LEN, DiscordBotClient, DiscordChannel, DiscordConfig
from nanobot.channels.discord import (
MAX_MESSAGE_LEN,
DiscordBotClient,
DiscordChannel,
DiscordConfig,
)
from nanobot.command.builtin import build_help_text
@@ -18,9 +24,11 @@ class _FakeDiscordClient:
instances: list["_FakeDiscordClient"] = []
start_error: Exception | None = None
def __init__(self, owner, *, intents) -> None:
def __init__(self, owner, *, intents, proxy=None, proxy_auth=None) -> None:
self.owner = owner
self.intents = intents
self.proxy = proxy
self.proxy_auth = proxy_auth
self.closed = False
self.ready = True
self.channels: dict[int, object] = {}
@@ -53,7 +61,9 @@ class _FakeDiscordClient:
class _FakeAttachment:
# Attachment double that can simulate successful or failing save() calls.
def __init__(self, attachment_id: int, filename: str, *, size: int = 1, fail: bool = False) -> None:
def __init__(
self, attachment_id: int, filename: str, *, size: int = 1, fail: bool = False
) -> None:
self.id = attachment_id
self.filename = filename
self.size = size
@@ -211,7 +221,7 @@ async def test_start_handles_client_construction_failure(monkeypatch) -> None:
MessageBus(),
)
def _boom(owner, *, intents):
def _boom(owner, *, intents, proxy=None, proxy_auth=None):
raise RuntimeError("bad client")
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _boom)
@@ -514,9 +524,7 @@ async def test_slash_new_forwards_when_user_is_allowlisted() -> None:
assert new_cmd is not None
await new_cmd.callback(interaction)
assert interaction.response.messages == [
{"content": "Processing /new...", "ephemeral": True}
]
assert interaction.response.messages == [{"content": "Processing /new...", "ephemeral": True}]
assert len(handled) == 1
assert handled[0]["content"] == "/new"
assert handled[0]["sender_id"] == "123"
@@ -590,9 +598,7 @@ async def test_slash_help_returns_ephemeral_help_text() -> None:
assert help_cmd is not None
await help_cmd.callback(interaction)
assert interaction.response.messages == [
{"content": build_help_text(), "ephemeral": True}
]
assert interaction.response.messages == [{"content": build_help_text(), "ephemeral": True}]
assert handled == []
@@ -727,11 +733,13 @@ async def test_start_typing_uses_typing_context_when_trigger_typing_missing() ->
def typing(self):
async def _waiter():
await release.wait()
# Hold the loop so task remains active until explicitly stopped.
class _Ctx(_TypingCtx):
async def __aenter__(self):
await super().__aenter__()
await _waiter()
return _Ctx()
typing_channel = _NoTriggerChannel(channel_id=123)
@@ -745,3 +753,95 @@ async def test_start_typing_uses_typing_context_when_trigger_typing_missing() ->
await asyncio.sleep(0)
assert channel._typing_tasks == {}
def test_config_accepts_proxy_fields() -> None:
config = DiscordConfig(
enabled=True,
token="token",
allow_from=["*"],
proxy="http://127.0.0.1:7890",
proxy_username="user",
proxy_password="pass",
)
assert config.proxy == "http://127.0.0.1:7890"
assert config.proxy_username == "user"
assert config.proxy_password == "pass"
def test_config_proxy_defaults_to_none() -> None:
config = DiscordConfig(enabled=True, token="token", allow_from=["*"])
assert config.proxy is None
assert config.proxy_username is None
assert config.proxy_password is None
@pytest.mark.asyncio
async def test_start_passes_proxy_to_client(monkeypatch) -> None:
_FakeDiscordClient.instances.clear()
channel = DiscordChannel(
DiscordConfig(
enabled=True,
token="token",
allow_from=["*"],
proxy="http://127.0.0.1:7890",
),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
await channel.start()
assert channel.is_running is False
assert len(_FakeDiscordClient.instances) == 1
assert _FakeDiscordClient.instances[0].proxy == "http://127.0.0.1:7890"
assert _FakeDiscordClient.instances[0].proxy_auth is None
@pytest.mark.asyncio
async def test_start_passes_proxy_auth_when_credentials_provided(monkeypatch) -> None:
aiohttp = pytest.importorskip("aiohttp")
_FakeDiscordClient.instances.clear()
channel = DiscordChannel(
DiscordConfig(
enabled=True,
token="token",
allow_from=["*"],
proxy="http://127.0.0.1:7890",
proxy_username="user",
proxy_password="pass",
),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
await channel.start()
assert channel.is_running is False
assert len(_FakeDiscordClient.instances) == 1
assert _FakeDiscordClient.instances[0].proxy == "http://127.0.0.1:7890"
assert _FakeDiscordClient.instances[0].proxy_auth is not None
assert isinstance(_FakeDiscordClient.instances[0].proxy_auth, aiohttp.BasicAuth)
assert _FakeDiscordClient.instances[0].proxy_auth.login == "user"
assert _FakeDiscordClient.instances[0].proxy_auth.password == "pass"
@pytest.mark.asyncio
async def test_start_no_proxy_auth_when_only_username(monkeypatch) -> None:
_FakeDiscordClient.instances.clear()
channel = DiscordChannel(
DiscordConfig(
enabled=True,
token="token",
allow_from=["*"],
proxy="http://127.0.0.1:7890",
proxy_username="user",
),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
await channel.start()
assert channel.is_running is False
assert _FakeDiscordClient.instances[0].proxy_auth is None