fix(discord): forum channel media + polish

Extend forum support from PR #10145:

- REST path (_send_discord): forum thread creation now uploads media
  files as multipart attachments on the starter message in a single
  call. Previously media files were silently dropped on the forum
  path.
- Websocket media paths (_send_file_attachment, send_voice, send_image,
  send_animation — covers send_image_file, send_video, send_document
  transitively): forum channels now go through a new _forum_post_file
  helper that creates a thread with the file as starter content,
  instead of failing via channel.send(file=...) which forums reject.
- _send_to_forum chunk follow-up failures are collected into
  raw_response['warnings'] so partial-send outcomes surface.
- Process-local probe cache (_DISCORD_CHANNEL_TYPE_PROBE_CACHE) avoids
  GET /channels/{id} on every uncached send after the first.
- Dedup of TestSendDiscordMedia that the PR merge-resolution left
  behind.
- Docs: Forum Channels section under website/docs/user-guide/messaging/discord.md.

Tests: 117 passed (22 new for forum+media, probe cache, warnings).
This commit is contained in:
Teknium 2026-04-17 19:36:19 -07:00 committed by Teknium
parent e5333e793c
commit 607be54a24
5 changed files with 540 additions and 186 deletions

View file

@ -276,3 +276,113 @@ async def test_send_to_forum_create_thread_failure():
assert result.success is False
assert "rate limited" in result.error
# ---------------------------------------------------------------------------
# Forum follow-up chunk failure reporting + media on forum paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_to_forum_follow_up_chunk_failures_collected_as_warnings():
"""Partial-send chunk failures surface in raw_response['warnings']."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
adapter.MAX_MESSAGE_LENGTH = 20
chunk_msg_1 = SimpleNamespace(id=500)
# Every follow-up chunk fails — we should collect a warning per failure
thread_ch = SimpleNamespace(
id=555,
send=AsyncMock(side_effect=Exception("rate limited")),
)
thread = SimpleNamespace(id=555, message=chunk_msg_1, thread=thread_ch)
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 999
forum_channel.name = "ideas"
forum_channel.create_thread = AsyncMock(return_value=thread)
adapter._client = SimpleNamespace(
get_channel=lambda _chat_id: forum_channel,
fetch_channel=AsyncMock(),
)
# Long enough to produce multiple chunks
result = await adapter.send("999", "A" * 60)
# Starter message (first chunk) was delivered via create_thread, so send is
# successful overall — but follow-up chunks all failed and are reported.
assert result.success is True
assert result.message_id == "500"
warnings = (result.raw_response or {}).get("warnings") or []
assert len(warnings) >= 1
assert all("rate limited" in w for w in warnings)
@pytest.mark.asyncio
async def test_forum_post_file_creates_thread_with_attachment():
"""_forum_post_file routes file-bearing sends to create_thread with file kwarg."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
thread_ch = SimpleNamespace(id=777, send=AsyncMock())
thread = SimpleNamespace(id=777, message=SimpleNamespace(id=800), thread=thread_ch)
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 999
forum_channel.name = "ideas"
forum_channel.create_thread = AsyncMock(return_value=thread)
# discord.File is a real class; build a MagicMock that looks like one
fake_file = SimpleNamespace(filename="photo.png")
result = await adapter._forum_post_file(
forum_channel,
content="here is a photo",
file=fake_file,
)
assert result.success is True
assert result.message_id == "800"
forum_channel.create_thread.assert_awaited_once()
call_kwargs = forum_channel.create_thread.await_args.kwargs
assert call_kwargs["file"] is fake_file
assert call_kwargs["content"] == "here is a photo"
# Thread name derived from content's first line
assert call_kwargs["name"] == "here is a photo"
@pytest.mark.asyncio
async def test_forum_post_file_uses_filename_when_no_content():
"""Thread name falls back to file.filename when no content is provided."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
thread = SimpleNamespace(id=1, message=SimpleNamespace(id=2), thread=SimpleNamespace(id=1, send=AsyncMock()))
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 10
forum_channel.name = "forum"
forum_channel.create_thread = AsyncMock(return_value=thread)
fake_file = SimpleNamespace(filename="voice-message.ogg")
result = await adapter._forum_post_file(forum_channel, content="", file=fake_file)
assert result.success is True
call_kwargs = forum_channel.create_thread.await_args.kwargs
# Content was empty → thread name derived from filename
assert call_kwargs["name"] == "voice-message.ogg"
@pytest.mark.asyncio
async def test_forum_post_file_creation_failure():
"""_forum_post_file returns a failed SendResult when create_thread raises."""
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***"))
forum_channel = _discord_mod.ForumChannel()
forum_channel.id = 999
forum_channel.create_thread = AsyncMock(side_effect=Exception("missing perms"))
result = await adapter._forum_post_file(
forum_channel,
content="hi",
file=SimpleNamespace(filename="x.png"),
)
assert result.success is False
assert "missing perms" in (result.error or "")