split_embeds chunked only by the 10-per-message count, so several individually-fitted embeds whose combined length exceeded 6000 still 400'd (BASE_TYPE_MAX_LENGTH), breaking the 'never fails on a discord limit' contract; it now starts a new group before either the count OR the 6000 total is exceeded. chunk_text could emit a whitespace-only piece (e.g. '\n') when the only split boundary sat at index 1, which safe_send then sent as empty content discord rejects (50006); whitespace-only pieces are now skipped. (live 400/50006 need discord; the split/chunk logic is verified offline.) Signed-off-by: disqualifier <dev@disqualifier.me>
dpy_commons
Shared discord.py utilities — the discord-side sibling of commons. A module of functions
grouped by concern: message/embed parsing, embed sanitizing + limit-fitting, link extraction,
text chunking, timestamp helpers, interactive await-prompts, and a limit-safe send.
Install
dpy_commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_commons.git@v0.1.3
Usage
import dpy_commons as dc
# structured parse of a rich message (async — it reads attachments from the CDN)
payload = await dc.parse_message(message)
payload["mentions"]["users"] # [123, ...]
payload["poll"] # {"question": ..., "options": [...]} or None
# sanitize an embed to be safe-to-send (links wrapped, color normalized, within limits)
safe = dc.sanitize_embed(raw_embed)
# send that never trips a Discord limit: chunks content, fits + splits embeds
await dc.safe_send(channel, content=long_text, embeds=many_embeds)
# split a 5000-char blob into <=2000 pieces on clean boundaries
for piece in dc.chunk_text(blob):
await channel.send(piece)
# a live, timezone-local timestamp rendered by the Discord client; a naive dt is treated
# as local time (matching discord.py's own naive-datetime handling) — pass an aware dt
# if the source is UTC
dc.discord_timestamp(dt, "R") # "<t:1751500000:R>"
Interactive await-prompts
Throw a prompt, await it, get the answer back right there — no listener, no view subclass,
no state plumbing:
if await dc.confirm(ctx, "Delete 500 messages?"):
await purge()
action = await dc.choose(ctx, "Pick:", {
"✅": "approve",
"❌": "deny",
"<:escalate:123456789>": "escalate",
})
# action -> "approve" | "deny" | "escalate" | None (timeout)
confirm returns True/False/None; choose returns the mapped value (never the raw
interaction). Both scope to a user (a stranger's click gets an ephemeral "not for you" and the
prompt stays live), disable their components after resolve/timeout, accept custom emojis
anywhere an emoji goes, and take cleanup=True to delete the prompt afterward. choose
auto-switches to a select dropdown for more than 5 options or long labels, truncates select
option labels and the placeholder to Discord's caps, and raises ValueError for more than 25
options (Discord's per-select cap). An emoji-only key is label-less on the button path (a
button may be emoji-only), but on the select path it gets a non-empty fallback label (the
key's own text form) alongside its emoji, since Discord rejects a select option with no label.
What's inside
| Concern | Functions |
|---|---|
| Parsing | parse_message (async), extract_message_links, sanitize_mentions |
| Embeds | fit_embed, sanitize_embed, split_embeds |
| Text | chunk_text, format_table, discord_timestamp, humanize_delta |
| Prompts | confirm, choose |
| Send | safe_send |
All Discord hard limits live as module constants (MSG_LIMIT, EMBED_TOTAL,
BUTTON_ROW_MAX, BUTTON_LABEL_MAX, SELECT_OPTION_LABEL_MAX, SELECT_PLACEHOLDER_MAX,
SELECT_MAX_OPTIONS, …) — the single source of truth; nothing hardcodes a limit.
Contract
Config-free (functions take the discord objects they act on, never a global). Fail-loud:
format_table raises ValueError on ragged rows, discord_timestamp on a bad style,
choose on empty options or more than 25 options; safe_send and the prompts propagate
Discord perms/HTTP errors (a prompt timeout is a normal None, not an error). The one
tolerated swallow is a single bad attachment in parse_message (warn + skip) — pass
strict=True to raise instead.
safe_send's mention-control kwargs (allowed_mentions, silent, suppress_embeds, tts)
apply to every chunked message, not just the first, so a suppressed @everyone/@here
stays suppressed across the whole split. Once-only kwargs (file, files, stickers,
nonce, reference, mention_author, view, poll, delete_after) still ride the first
message only. A bare safe_send(destination) with no content/embeds sends a single message
with content=None. A singular embed= kwarg is folded into the embeds pipeline (fit +
split like any other embed); passing both embed and embeds raises TypeError, matching
discord.py's own Messageable.send rule. Any kwarg safe_send doesn't recognize also raises
TypeError naming it, rather than being silently dropped.
Notes / deviations
parse_messageisasync. The spec wrote it sync, but attachments are read from the CDN (network I/O), which cannot be synchronous. Await it.- Targets
discord.py>=2.2(notdiscord.py-self).
Versioning
Tagged vX.Y.Z; pin a tag in your install line.