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):
|
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
@@ -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 = [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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}>"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user