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:
@@ -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") # "<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
|
||||
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
|
||||
|
||||
|
||||
+1
-1
@@ -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 = [
|
||||
|
||||
@@ -29,7 +29,7 @@ from .text import (
|
||||
wrap_bare_links,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.1"
|
||||
|
||||
__all__ = [
|
||||
"parse_message",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+27
-8
@@ -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)
|
||||
|
||||
@@ -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 <t:unix:style> for a datetime; raises
|
||||
ValueError on an invalid style (one of t/T/d/D/f/F/R)"""
|
||||
"""return discord's dynamic timestamp markup <t:unix:style> 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"<t:{int(dt.timestamp())}:{style}>"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user