From f1f2ecf74a0f3c1442182b6af2ef2c56c0beb632 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Fri, 3 Jul 2026 15:33:21 -0400 Subject: [PATCH] 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 --- README.md | 27 ++++++++++++++++++++------- pyproject.toml | 2 +- src/dpy_commons/__init__.py | 2 +- src/dpy_commons/limits.py | 5 +++++ src/dpy_commons/parsing.py | 5 +++-- src/dpy_commons/prompts.py | 23 ++++++++++++++++++----- src/dpy_commons/send.py | 35 +++++++++++++++++++++++++++-------- src/dpy_commons/text.py | 15 +++++++++------ 8 files changed, 84 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 1026850..f8ad483 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ await dc.safe_send(channel, content=long_text, embeds=many_embeds) for piece in dc.chunk_text(blob): 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") # "" ``` @@ -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 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. +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 @@ -67,16 +71,25 @@ auto-switches to a select dropdown for more than 5 options or long labels. | Prompts | `confirm`, `choose` | | Send | `safe_send` | -All Discord hard limits live as module constants (`MSG_LIMIT`, `EMBED_TOTAL`, …) — the single -source of truth; nothing hardcodes a limit. +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; `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. +`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 diff --git a/pyproject.toml b/pyproject.toml index 61710f5..8bf649a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] 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." requires-python = ">=3.10" dependencies = [ diff --git a/src/dpy_commons/__init__.py b/src/dpy_commons/__init__.py index be59a84..403b82b 100644 --- a/src/dpy_commons/__init__.py +++ b/src/dpy_commons/__init__.py @@ -29,7 +29,7 @@ from .text import ( wrap_bare_links, ) -__version__ = "0.1.0" +__version__ = "0.1.1" __all__ = [ "parse_message", diff --git a/src/dpy_commons/limits.py b/src/dpy_commons/limits.py index 492bce0..3e16a3d 100644 --- a/src/dpy_commons/limits.py +++ b/src/dpy_commons/limits.py @@ -11,3 +11,8 @@ EMBED_AUTHOR = 256 EMBED_TOTAL = 6000 EMBED_MAX_FIELDS = 25 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 diff --git a/src/dpy_commons/parsing.py b/src/dpy_commons/parsing.py index 8cef46f..65fb813 100644 --- a/src/dpy_commons/parsing.py +++ b/src/dpy_commons/parsing.py @@ -32,7 +32,8 @@ def extract_message_links( text_or_message: "Union[str, discord.Message]", ) -> "list[tuple[int, int, int]]": """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 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: try: files.append(await attachment.to_file()) - except (discord.HTTPException, discord.NotFound, OSError) as exc: + except (discord.HTTPException, OSError) as exc: if strict: raise log.warning("dpy_commons: skipping attachment %s: %s", attachment.filename, exc) diff --git a/src/dpy_commons/prompts.py b/src/dpy_commons/prompts.py index 3fda4e8..15c09e8 100644 --- a/src/dpy_commons/prompts.py +++ b/src/dpy_commons/prompts.py @@ -14,9 +14,17 @@ from typing import Any, Optional, Union 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__) -_MAX_BUTTONS = 5 _NOT_FOR_YOU = "this prompt isn't for you" Destination = Union[discord.abc.Messageable, discord.Interaction] @@ -163,22 +171,27 @@ async def choose( ) -> "Optional[Any]": """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 - (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: 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) view = _PromptView(user=scoped, timeout=timeout) 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: 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): token = str(i) mapping[token] = options[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) view.add_item(select) else: @@ -195,7 +208,7 @@ async def choose( def _is_long_label(key: Any) -> bool: """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(">")): - return len(key) > 80 + return len(key) > BUTTON_LABEL_MAX return False diff --git a/src/dpy_commons/send.py b/src/dpy_commons/send.py index aa1cbf3..bd6f1f3 100644 --- a/src/dpy_commons/send.py +++ b/src/dpy_commons/send.py @@ -10,6 +10,12 @@ from .embeds import fit_embed, split_embeds 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( destination: discord.abc.Messageable, 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 automatic) - extra kwargs ride only on the FIRST message so per-send options (files, reference, etc.) - aren't duplicated across the split""" + mention-control kwargs (`allowed_mentions`, `silent`, `suppress_embeds`, `tts`) apply to + 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 [] 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]" = [] 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 # call collapses to one message when it fits 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 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 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 -async def _send(destination, content, embeds, first, kwargs) -> discord.Message: - """send one message; first-message kwargs are applied once then dropped""" - extra = dict(kwargs) if first else {} +async def _send(destination, content, embeds, first, per_chunk, once_only) -> discord.Message: + """send one message; per-chunk kwargs apply every time, once-only kwargs apply on the + first message only""" + extra = dict(per_chunk) + if first: + extra.update(once_only) if embeds: extra["embeds"] = embeds return await destination.send(content=content, **extra) diff --git a/src/dpy_commons/text.py b/src/dpy_commons/text.py index 1b56e79..218ce63 100644 --- a/src/dpy_commons/text.py +++ b/src/dpy_commons/text.py @@ -4,7 +4,7 @@ primitives the embed helpers reuse""" from __future__ import annotations import re -from datetime import datetime, timezone +from datetime import datetime from typing import Optional, Sequence 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) _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]: """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: raise ValueError(f"limit must be >= 1, got {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: - """return discord's dynamic timestamp markup for a datetime; raises - ValueError on an invalid style (one of t/T/d/D/f/F/R)""" + """return discord's dynamic timestamp markup for a datetime; a naive dt is + 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: raise ValueError(f"invalid timestamp style '{style}'; expected one of {_TIMESTAMP_STYLES}") if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) + dt = dt.astimezone() return f""