dsql f1f2ecf74a fix: mass-ping leak, CDN link mangling, tz-naive timestamp, prompt limit drift
Fixes from the dpy4 audit report (REPORT_dpy4_audit.md, dpy_commons section):

- dpycommons-1 (HIGH): safe_send/_send applied kwargs to the first chunk only,
  so allowed_mentions/silent/suppress_embeds dropped off every chunk after
  the first, letting a suppressed @everyone/@here fire live on later chunks
  of a mass-ping. Mention-control kwargs now apply to every chunk; file/
  reference/view/etc. still ride the first message only.
- dpycommons-4 (MED): _DISCORD_HOST_RE missed discordapp.net (Discord's own
  media/image CDN), causing wrap_bare_links to <>-wrap preview links Discord
  itself emits; dropped the nonexistent discord.media host.
- dpycommons-8 (MED): discord_timestamp stamped naive datetimes as UTC,
  diverging from discord.py's own naive-datetime handling (local via
  astimezone()) and rendering the wrong wall time on non-UTC hosts. Now
  matches discord.py's behavior.
- dpycommons-9: prompts.py hardcoded the 5-button/80-char/100-char limits
  inline instead of sourcing from limits.py; moved them to limits.py
  (BUTTON_ROW_MAX, BUTTON_LABEL_MAX, SELECT_OPTION_LABEL_MAX,
  SELECT_PLACEHOLDER_MAX, SELECT_MAX_OPTIONS) and corrected the placeholder
  cap from 100 to Discord's actual 150; choose() now raises ValueError above
  25 options instead of failing inside discord.py's select builder.
- dpycommons-3: choose()'s select-label path now truncates labels to
  SELECT_OPTION_LABEL_MAX so a >80-char key routed to the select can no
  longer build a >100-char option label.
- dpycommons-7: safe_send's empty-input fallback now sends content=None
  instead of content='' (Discord rejects an explicit empty string).
- redundant except: dropped discord.NotFound from parsing.py's attachment
  read except tuple (it subclasses HTTPException, already caught).
- doc-only: softened chunk_text's "no content is lost" overclaim,
  documented extract_message_links as guild-only (DM @me links unmatched).

Version 0.1.0 -> 0.1.1.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 15:33:21 -04:00

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.0

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).

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.

Notes / deviations

  • parse_message is async. 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 (not discord.py-self).

Versioning

Tagged vX.Y.Z; pin a tag in your install line.

S
Description
Utility toolkit for discord.py
Readme
92 KiB
Languages
Python 100%