π§ Context
Discord rejects any message whose content is longer than 2000 characters β the API returns an HTTP 400 and discord.py raises discord.HTTPException. The bot builds one content string in render_answer_content (src/apps/discord_bot.py) β the answer text plus a **Sources:** block of one URL per line β and sends it in two places:
- the
@mention handler: await message.reply(content)
- the
/ask slash command: await interaction.followup.send(content)
Both sends happen outside the try/except that wraps the ask() call, so when an answer runs past 2000 characters the send raises and nothing catches it. The user gets no answer at all β and for the slash command the deferred interaction is never completed, so Discord shows "the application did not respond." RAG answers plus a multi-URL sources block cross 2000 characters routinely, so this is a real failure, not an edge case.
The fix is to split the content into pieces of at most 2000 characters and send them in order.
Files this ticket owns:
src/apps/discord_bot.py
tests/apps/test_discord_bot.py
π Implementation Plan
Note: Approach suggested here may not be the best way as you work through it. If you find a better way to avoid long responses from splitting across messages instead of erroring out, feel free to suggest that!
1. A pure splitter helper
Add a module-level constant for the limit (e.g. MAX_MESSAGE_LENGTH = 2000, or a little under 2000) and a pure function split_message(content: str, limit: int = MAX_MESSAGE_LENGTH) -> list[str] that returns the content broken into pieces, each no longer than limit. Keep it free of any Discord objects so it can be unit-tested directly, exactly like the existing strip_bot_mention / question_assembler_helper tests.
Split on line boundaries, not mid-line: accumulate whole lines into a piece and start a new piece before adding a line would exceed the limit. This keeps the answer's paragraphs and the one-URL-per-line sources block intact across the split rather than cutting a URL or a markdown span in half. Handle the one case line-splitting can't: a single line longer than limit (e.g. one very long URL) must be hard-sliced into limit-sized pieces so the function can always satisfy the length guarantee.
Guarantees to hold: every returned piece is <= limit, and every character of the original ends up in some piece, in order. Two things the tests must account for: a line-boundary split consumes the boundary newline (it becomes the message break β it isn't re-emitted inside a piece), and an over-long single line is hard-sliced mid-character. Because of those, the pieces do not reconstruct the input via a plain "".join or "\n".join, so don't assert exact reconstruction (see the test notes below). Content at or under the limit returns a single piece (so short answers behave exactly as today).
2. Use it at both send sites
Replace each single send with a sequential loop over split_message(content), awaiting each send so order is preserved:
- Slash command:
interaction.followup.send can be called multiple times, so loop it over the pieces.
- Mention handler: send the first piece as
message.reply(...) (keeps the reply context) and the remaining pieces as plain message.channel.send(...) follow-ons, so the user isn't reply-pinged once per piece.
A short answer produces one piece, so this is identical to current behaviour in the common case.
3. Guard the sends against other failures
This is a small addition slightly beyond the core length fix, but worth doing while this code is being touched. Today both sends sit outside any try/except, so a send that fails for reasons other than length β the bot lacking permission to post in the channel (discord.Forbidden), a transient Discord/network error β crashes the handler with nothing catching it, exactly like the length case does now. The split solves the length cause; it doesn't cover these.
Wrap the send loop in a try/except discord.HTTPException (the base class those failures raise) at both sites. On failure, log it and post the same simple error the ask() failure path already uses β "β I couldn't process this request, please try again later." This is best-effort: if even that error message can't be sent (e.g. no permission at all), just let the log stand. Since that error string now appears in more than one place, consider lifting it to a module-level constant.
4. Tests β tests/apps/test_discord_bot.py
split_message is a pure function; test it with no Discord mocking, matching the existing style in this file:
- content under the limit β exactly one piece, equal to the input;
- content well over the limit spanning many lines β multiple pieces, each
<= 2000, with the answer text and every source URL still present across the pieces. Assert substring / URL presence and the length cap β not exact "".join / "\n".join reconstruction, since boundary newlines are consumed by the split;
- a single line longer than the limit β hard-sliced into
<= 2000 pieces. Here no newline is involved, so "".join(pieces) == that_line is a valid assertion for this specific case;
- content exactly at the limit β a single piece.
π Notes
- Don't change
render_answer_content or how sources are formatted β split the string it already produces. The Sources block staying whole is a nice-to-have, not required; splitting on line boundaries keeps each source line intact regardless.
- The mention and slash paths both need the change; a fix to only one leaves the other crashing on long answers.
- The send-error guard is intentionally simple and slightly beyond the core length fix β one message for any non-length send failure. It's not trying to distinguish or recover from specific failures, just to stop them going unhandled the way they do today.
β
Acceptance Criteria
- A pure
split_message helper returns pieces each <= 2000 characters, with every character preserved across the pieces in order (a line-boundary split turns the boundary newline into the message break); content at or under 2000 returns a single unchanged piece.
- Both the
@mention reply and the /ask followup send long answers as multiple in-order messages instead of raising β no answer is dropped and the slash interaction always completes.
- Both send sites are wrapped so a non-length send failure (e.g. missing channel permission, transient error) is logged and surfaces the simple error message instead of an unhandled exception.
- Tests cover under-limit, multi-line over-limit, a single over-limit line, and the exact-limit boundary, with no Discord mocking.
make test and make lint pass.
π§ Context
Discord rejects any message whose content is longer than 2000 characters β the API returns an HTTP 400 and discord.py raises
discord.HTTPException. The bot builds one content string inrender_answer_content(src/apps/discord_bot.py) β the answer text plus a**Sources:**block of one URL per line β and sends it in two places:@mentionhandler:await message.reply(content)/askslash command:await interaction.followup.send(content)Both sends happen outside the
try/exceptthat wraps theask()call, so when an answer runs past 2000 characters the send raises and nothing catches it. The user gets no answer at all β and for the slash command the deferred interaction is never completed, so Discord shows "the application did not respond." RAG answers plus a multi-URL sources block cross 2000 characters routinely, so this is a real failure, not an edge case.The fix is to split the content into pieces of at most 2000 characters and send them in order.
Files this ticket owns:
src/apps/discord_bot.pytests/apps/test_discord_bot.pyπ Implementation Plan
Note: Approach suggested here may not be the best way as you work through it. If you find a better way to avoid long responses from splitting across messages instead of erroring out, feel free to suggest that!
1. A pure splitter helper
Add a module-level constant for the limit (e.g.
MAX_MESSAGE_LENGTH = 2000, or a little under 2000) and a pure functionsplit_message(content: str, limit: int = MAX_MESSAGE_LENGTH) -> list[str]that returns the content broken into pieces, each no longer thanlimit. Keep it free of any Discord objects so it can be unit-tested directly, exactly like the existingstrip_bot_mention/question_assembler_helpertests.Split on line boundaries, not mid-line: accumulate whole lines into a piece and start a new piece before adding a line would exceed the limit. This keeps the answer's paragraphs and the one-URL-per-line sources block intact across the split rather than cutting a URL or a markdown span in half. Handle the one case line-splitting can't: a single line longer than
limit(e.g. one very long URL) must be hard-sliced intolimit-sized pieces so the function can always satisfy the length guarantee.Guarantees to hold: every returned piece is
<= limit, and every character of the original ends up in some piece, in order. Two things the tests must account for: a line-boundary split consumes the boundary newline (it becomes the message break β it isn't re-emitted inside a piece), and an over-long single line is hard-sliced mid-character. Because of those, the pieces do not reconstruct the input via a plain"".joinor"\n".join, so don't assert exact reconstruction (see the test notes below). Content at or under the limit returns a single piece (so short answers behave exactly as today).2. Use it at both send sites
Replace each single send with a sequential loop over
split_message(content), awaiting each send so order is preserved:interaction.followup.sendcan be called multiple times, so loop it over the pieces.message.reply(...)(keeps the reply context) and the remaining pieces as plainmessage.channel.send(...)follow-ons, so the user isn't reply-pinged once per piece.A short answer produces one piece, so this is identical to current behaviour in the common case.
3. Guard the sends against other failures
This is a small addition slightly beyond the core length fix, but worth doing while this code is being touched. Today both sends sit outside any
try/except, so a send that fails for reasons other than length β the bot lacking permission to post in the channel (discord.Forbidden), a transient Discord/network error β crashes the handler with nothing catching it, exactly like the length case does now. The split solves the length cause; it doesn't cover these.Wrap the send loop in a
try/except discord.HTTPException(the base class those failures raise) at both sites. On failure, log it and post the same simple error theask()failure path already uses β "β I couldn't process this request, please try again later." This is best-effort: if even that error message can't be sent (e.g. no permission at all), just let the log stand. Since that error string now appears in more than one place, consider lifting it to a module-level constant.4. Tests β
tests/apps/test_discord_bot.pysplit_messageis a pure function; test it with no Discord mocking, matching the existing style in this file:<= 2000, with the answer text and every source URL still present across the pieces. Assert substring / URL presence and the length cap β not exact"".join/"\n".joinreconstruction, since boundary newlines are consumed by the split;<= 2000pieces. Here no newline is involved, so"".join(pieces) == that_lineis a valid assertion for this specific case;π Notes
render_answer_contentor how sources are formatted β split the string it already produces. The Sources block staying whole is a nice-to-have, not required; splitting on line boundaries keeps each source line intact regardless.β Acceptance Criteria
split_messagehelper returns pieces each<= 2000characters, with every character preserved across the pieces in order (a line-boundary split turns the boundary newline into the message break); content at or under 2000 returns a single unchanged piece.@mentionreply and the/askfollowup send long answers as multiple in-order messages instead of raising β no answer is dropped and the slash interaction always completes.make testandmake lintpass.