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>
This commit is contained in:
2026-07-03 15:33:21 -04:00
parent 056516dee9
commit f1f2ecf74a
8 changed files with 84 additions and 30 deletions
+20 -7
View File
@@ -30,7 +30,9 @@ await dc.safe_send(channel, content=long_text, embeds=many_embeds)
for piece in dc.chunk_text(blob): for piece in dc.chunk_text(blob):
await channel.send(piece) await channel.send(piece)
# a live, timezone-local timestamp rendered by the Discord client # 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>" dc.discord_timestamp(dt, "R") # "<t:1751500000:R>"
``` ```
@@ -55,7 +57,9 @@ action = await dc.choose(ctx, "Pick:", {
interaction). Both scope to a user (a stranger's click gets an ephemeral "not for you" and the 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 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` 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. 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 ## What's inside
@@ -67,16 +71,25 @@ auto-switches to a select dropdown for more than 5 options or long labels.
| Prompts | `confirm`, `choose` | | Prompts | `confirm`, `choose` |
| Send | `safe_send` | | Send | `safe_send` |
All Discord hard limits live as module constants (`MSG_LIMIT`, `EMBED_TOTAL`, …) — the single All Discord hard limits live as module constants (`MSG_LIMIT`, `EMBED_TOTAL`,
source of truth; nothing hardcodes a limit. `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 ## Contract
Config-free (functions take the discord objects they act on, never a global). Fail-loud: 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, `format_table` raises `ValueError` on ragged rows, `discord_timestamp` on a bad style,
`choose` on empty options; `safe_send` and the prompts propagate Discord perms/HTTP errors `choose` on empty options or more than 25 options; `safe_send` and the prompts propagate
(a prompt **timeout** is a normal `None`, not an error). The one tolerated swallow is a single Discord perms/HTTP errors (a prompt **timeout** is a normal `None`, not an error). The one
bad attachment in `parse_message` (warn + skip) — pass `strict=True` to raise instead. 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 ## Notes / deviations
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "dpy_commons" name = "dpy_commons"
version = "0.1.0" version = "0.1.1"
description = "Shared discord.py utilities — message/embed parsing, limit-fitting, link extraction, chunking, timestamps, await-prompts, limit-safe send. Config-free, installable." description = "Shared discord.py utilities — message/embed parsing, limit-fitting, link extraction, chunking, timestamps, await-prompts, limit-safe send. Config-free, installable."
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+1 -1
View File
@@ -29,7 +29,7 @@ from .text import (
wrap_bare_links, wrap_bare_links,
) )
__version__ = "0.1.0" __version__ = "0.1.1"
__all__ = [ __all__ = [
"parse_message", "parse_message",
+5
View File
@@ -11,3 +11,8 @@ EMBED_AUTHOR = 256
EMBED_TOTAL = 6000 EMBED_TOTAL = 6000
EMBED_MAX_FIELDS = 25 EMBED_MAX_FIELDS = 25
MSG_MAX_EMBEDS = 10 MSG_MAX_EMBEDS = 10
BUTTON_ROW_MAX = 5
BUTTON_LABEL_MAX = 80
SELECT_OPTION_LABEL_MAX = 100
SELECT_PLACEHOLDER_MAX = 150
SELECT_MAX_OPTIONS = 25
+3 -2
View File
@@ -32,7 +32,8 @@ def extract_message_links(
text_or_message: "Union[str, discord.Message]", text_or_message: "Union[str, discord.Message]",
) -> "list[tuple[int, int, int]]": ) -> "list[tuple[int, int, int]]":
"""pull discord message jump-links out of text or a Message's content, returning """pull discord message jump-links out of text or a Message's content, returning
(guild_id, channel_id, message_id) tuples""" (guild_id, channel_id, message_id) tuples; guild-channel links only — a DM jump-link
(.../channels/@me/{channel}/{message}) has no guild segment and is not matched"""
text = text_or_message.content if isinstance(text_or_message, discord.Message) else text_or_message text = text_or_message.content if isinstance(text_or_message, discord.Message) else text_or_message
return [(int(g), int(c), int(m)) for g, c, m in _MESSAGE_LINK_RE.findall(text or "")] return [(int(g), int(c), int(m)) for g, c, m in _MESSAGE_LINK_RE.findall(text or "")]
@@ -75,7 +76,7 @@ async def _parse_attachments(message: discord.Message, *, strict: bool) -> "list
for attachment in message.attachments: for attachment in message.attachments:
try: try:
files.append(await attachment.to_file()) files.append(await attachment.to_file())
except (discord.HTTPException, discord.NotFound, OSError) as exc: except (discord.HTTPException, OSError) as exc:
if strict: if strict:
raise raise
log.warning("dpy_commons: skipping attachment %s: %s", attachment.filename, exc) log.warning("dpy_commons: skipping attachment %s: %s", attachment.filename, exc)
+18 -5
View File
@@ -14,9 +14,17 @@ from typing import Any, Optional, Union
import discord import discord
from .limits import (
BUTTON_LABEL_MAX,
BUTTON_ROW_MAX,
SELECT_MAX_OPTIONS,
SELECT_OPTION_LABEL_MAX,
SELECT_PLACEHOLDER_MAX,
)
from .text import truncate
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_MAX_BUTTONS = 5
_NOT_FOR_YOU = "this prompt isn't for you" _NOT_FOR_YOU = "this prompt isn't for you"
Destination = Union[discord.abc.Messageable, discord.Interaction] Destination = Union[discord.abc.Messageable, discord.Interaction]
@@ -163,22 +171,27 @@ async def choose(
) -> "Optional[Any]": ) -> "Optional[Any]":
"""render a button per option (or a select dropdown for >5 options or long labels), await """render a button per option (or a select dropdown for >5 options or long labels), await
the click, and return the chosen option's VALUE or None(timeout); options maps an emoji the click, and return the chosen option's VALUE or None(timeout); options maps an emoji
(unicode / '<:name:id>' / Emoji) or a label to a return value""" (unicode / '<:name:id>' / Emoji) or a label to a return value; raises ValueError on empty
options or more than 25 (discord's per-select option cap)"""
if not options: if not options:
raise ValueError("choose requires at least one option") raise ValueError("choose requires at least one option")
if len(options) > SELECT_MAX_OPTIONS:
raise ValueError(f"choose supports at most {SELECT_MAX_OPTIONS} options, got {len(options)}")
scoped = _infer_user(destination, user) scoped = _infer_user(destination, user)
view = _PromptView(user=scoped, timeout=timeout) view = _PromptView(user=scoped, timeout=timeout)
keys = list(options.keys()) keys = list(options.keys())
use_select = len(keys) > _MAX_BUTTONS or any(_is_long_label(k) for k in keys) use_select = len(keys) > BUTTON_ROW_MAX or any(_is_long_label(k) for k in keys)
if use_select: if use_select:
mapping: "dict[str, Any]" = {} mapping: "dict[str, Any]" = {}
select = _ValueSelect(view, mapping, placeholder=prompt[:100]) select = _ValueSelect(view, mapping, placeholder=truncate(prompt, SELECT_PLACEHOLDER_MAX))
for i, key in enumerate(keys): for i, key in enumerate(keys):
token = str(i) token = str(i)
mapping[token] = options[key] mapping[token] = options[key]
label, emoji = _split_key(key) label, emoji = _split_key(key)
if label is not None:
label = truncate(label, SELECT_OPTION_LABEL_MAX)
select.add_option(label=label, value=token, emoji=emoji) select.add_option(label=label, value=token, emoji=emoji)
view.add_item(select) view.add_item(select)
else: else:
@@ -195,7 +208,7 @@ async def choose(
def _is_long_label(key: Any) -> bool: def _is_long_label(key: Any) -> bool:
"""true when a plain-text label is too long for a button (>80 chars)""" """true when a plain-text label is too long for a button (>80 chars)"""
if isinstance(key, str) and not (key.startswith("<") and key.endswith(">")): if isinstance(key, str) and not (key.startswith("<") and key.endswith(">")):
return len(key) > 80 return len(key) > BUTTON_LABEL_MAX
return False return False
+27 -8
View File
@@ -10,6 +10,12 @@ from .embeds import fit_embed, split_embeds
from .text import chunk_text from .text import chunk_text
_PER_CHUNK_KWARGS = ("allowed_mentions", "silent", "suppress_embeds", "tts")
_ONCE_ONLY_KWARGS = (
"file", "files", "stickers", "nonce", "reference", "mention_author", "view", "poll", "delete_after",
)
async def safe_send( async def safe_send(
destination: discord.abc.Messageable, destination: discord.abc.Messageable,
content: "Optional[str]" = None, content: "Optional[str]" = None,
@@ -22,11 +28,21 @@ async def safe_send(
needed; returns the sent Messages, fails loud on perms/HTTP errors (only limit-handling is needed; returns the sent Messages, fails loud on perms/HTTP errors (only limit-handling is
automatic) automatic)
extra kwargs ride only on the FIRST message so per-send options (files, reference, etc.) mention-control kwargs (`allowed_mentions`, `silent`, `suppress_embeds`, `tts`) apply to
aren't duplicated across the split""" EVERY chunk 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`) ride only on the FIRST message so they aren't duplicated
across the split
with no content and no embeds, one message is still sent (content=None) so a bare
safe_send(destination) or a files/view-only call goes out as a single message rather than
silently doing nothing"""
content_chunks = chunk_text(content) if content else [] content_chunks = chunk_text(content) if content else []
embed_groups = split_embeds([fit_embed(embed) for embed in embeds]) if embeds else [] embed_groups = split_embeds([fit_embed(embed) for embed in embeds]) if embeds else []
per_chunk = {k: v for k, v in kwargs.items() if k in _PER_CHUNK_KWARGS}
once_only = {k: v for k, v in kwargs.items() if k in _ONCE_ONLY_KWARGS}
messages: "list[discord.Message]" = [] messages: "list[discord.Message]" = []
first = True first = True
@@ -34,22 +50,25 @@ async def safe_send(
# attach the first embed group to the last content message so a single content+embeds # attach the first embed group to the last content message so a single content+embeds
# call collapses to one message when it fits # call collapses to one message when it fits
group = embed_groups.pop(0) if (i == len(content_chunks) - 1 and embed_groups) else None group = embed_groups.pop(0) if (i == len(content_chunks) - 1 and embed_groups) else None
messages.append(await _send(destination, chunk, group, first, kwargs)) messages.append(await _send(destination, chunk, group, first, per_chunk, once_only))
first = False first = False
for group in embed_groups: for group in embed_groups:
messages.append(await _send(destination, None, group, first, kwargs)) messages.append(await _send(destination, None, group, first, per_chunk, once_only))
first = False first = False
if not messages: if not messages:
messages.append(await _send(destination, content or "", None, True, kwargs)) messages.append(await _send(destination, content, None, True, per_chunk, once_only))
return messages return messages
async def _send(destination, content, embeds, first, kwargs) -> discord.Message: async def _send(destination, content, embeds, first, per_chunk, once_only) -> discord.Message:
"""send one message; first-message kwargs are applied once then dropped""" """send one message; per-chunk kwargs apply every time, once-only kwargs apply on the
extra = dict(kwargs) if first else {} first message only"""
extra = dict(per_chunk)
if first:
extra.update(once_only)
if embeds: if embeds:
extra["embeds"] = embeds extra["embeds"] = embeds
return await destination.send(content=content, **extra) return await destination.send(content=content, **extra)
+9 -6
View File
@@ -4,7 +4,7 @@ primitives the embed helpers reuse"""
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import datetime, timezone from datetime import datetime
from typing import Optional, Sequence from typing import Optional, Sequence
from .limits import MSG_LIMIT from .limits import MSG_LIMIT
@@ -14,7 +14,7 @@ _TIMESTAMP_STYLES = ("t", "T", "d", "D", "f", "F", "R")
_URL_RE = re.compile(r"https?://[^\s<>]+", re.IGNORECASE) _URL_RE = re.compile(r"https?://[^\s<>]+", re.IGNORECASE)
_DISCORD_HOST_RE = re.compile( _DISCORD_HOST_RE = re.compile(
r"^https?://(?:[a-z0-9-]+\.)*discord(?:app)?\.(?:com|gg|media)\b", re.IGNORECASE r"^https?://(?:[a-z0-9-]+\.)*discord(?:app)?\.(?:com|gg|net)\b", re.IGNORECASE
) )
@@ -48,7 +48,9 @@ def wrap_bare_links(text: str) -> str:
def chunk_text(text: str, limit: int = MSG_LIMIT) -> list[str]: def chunk_text(text: str, limit: int = MSG_LIMIT) -> list[str]:
"""split text into pieces each <= limit, breaking on newline boundaries where possible, """split text into pieces each <= limit, breaking on newline boundaries where possible,
then word boundaries, only mid-word as a last resort; no content is lost""" then word boundaries, only mid-word as a last resort; the boundary char itself (the
newline/space split on) is dropped from the output, so joining the chunks back together
does not exactly reproduce the input"""
if limit < 1: if limit < 1:
raise ValueError(f"limit must be >= 1, got {limit}") raise ValueError(f"limit must be >= 1, got {limit}")
if len(text) <= limit: if len(text) <= limit:
@@ -99,12 +101,13 @@ def format_table(rows: "Sequence[Sequence]", headers: "Optional[Sequence]" = Non
def discord_timestamp(dt: datetime, style: str = "f") -> str: def discord_timestamp(dt: datetime, style: str = "f") -> str:
"""return discord's dynamic timestamp markup <t:unix:style> for a datetime; raises """return discord's dynamic timestamp markup <t:unix:style> for a datetime; a naive dt is
ValueError on an invalid style (one of t/T/d/D/f/F/R)""" treated as local time (matching discord.py's own naive-datetime handling), so pass an
aware dt if the source is UTC; raises ValueError on an invalid style (one of t/T/d/D/f/F/R)"""
if style not in _TIMESTAMP_STYLES: if style not in _TIMESTAMP_STYLES:
raise ValueError(f"invalid timestamp style '{style}'; expected one of {_TIMESTAMP_STYLES}") raise ValueError(f"invalid timestamp style '{style}'; expected one of {_TIMESTAMP_STYLES}")
if dt.tzinfo is None: if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc) dt = dt.astimezone()
return f"<t:{int(dt.timestamp())}:{style}>" return f"<t:{int(dt.timestamp())}:{style}>"