Compare commits

...
10 Commits
Author SHA1 Message Date
dsql 4c1244de88 release: 1.0.0
first stable release. pre-1.0.0 verification complete: all surviving MED regressions and
gaps resolved and independently re-fired, tree audited clean across the suite.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-09 18:53:15 -04:00
dsql bd27ef58f1 fix: classify choose() emoji keys by unicode properties, not length+ascii
_looks_unicode_emoji used 'len<=4 and not isascii', so a short non-ascii text label
(Sí/да/はい/café/确定) was misread as a unicode emoji and sent as a labelless component
emoji, which Discord 400s. It now treats a key as an emoji only when every codepoint is
emoji-composition material (So/Sk symbols, ZWJ, variation selectors, regional indicators,
keycap combiner) with at least one pictographic symbol - so a compound emoji (ZWJ family,
flag, skin-tone, keycap) stays one emoji while an i18n label is a label. The terminal
Discord 400 remains a documented live-gap (needs a gateway).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 19:59:56 -04:00
dsql 79c2c7286f docs: re-export the button/select limit constants the README advertises
README lists BUTTON_ROW_MAX/BUTTON_LABEL_MAX/SELECT_OPTION_LABEL_MAX/SELECT_PLACEHOLDER_MAX/
SELECT_MAX_OPTIONS as flat module constants, but __init__ re-exported only the message/embed
limits, so dc.BUTTON_ROW_MAX was an AttributeError - re-export all five to match the doc.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 19:38:30 -04:00
dsql 65fa11316e fix: safe_send empty-fallback no longer re-injects whitespace-only content
When chunk_text dropped a whitespace-only content to [] and there were no embeds, the
empty-messages fallback re-sent the raw original content - the exact whitespace 444e08b
set out to eliminate, which Discord 50006s. The fallback now sends content=None so a
files/view-only call still goes out as one message, and whitespace-only content is never
forwarded raw; a genuinely empty send remains the caller's fail-loud error.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 19:29:55 -04:00
dsql 444e08b7db fix: split_embeds bounds groups by the 6000-char total too; chunk_text drops whitespace-only chunks
split_embeds chunked only by the 10-per-message count, so several individually-fitted
embeds whose combined length exceeded 6000 still 400'd (BASE_TYPE_MAX_LENGTH), breaking the
'never fails on a discord limit' contract; it now starts a new group before either the
count OR the 6000 total is exceeded. chunk_text could emit a whitespace-only piece (e.g.
'\n') when the only split boundary sat at index 1, which safe_send then sent as empty
content discord rejects (50006); whitespace-only pieces are now skipped. (live 400/50006
need discord; the split/chunk logic is verified offline.)

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 16:46:37 -04:00
dsql bd9502c62d fix: safe_send routes embed= + fails loud on unknown kwargs; embed helpers deep-copy (no input mutation)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 19:03:05 -04:00
dsql 5eb9ed2be0 refactor: derive __version__ from package metadata (single source)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 17:00:52 -04:00
dsql 265b9ab447 fix: sync __version__ to 0.1.2 (drifted behind pyproject)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:25:18 -04:00
dsql 40429b0afe docs: fix stale install pin, v0.1.0 -> v0.1.2
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:24:02 -04:00
dsql b530affd6e fix: choose() select path emits null label for emoji-only keys
add_option(label=label, ...) passed label=None straight through for an
emoji-only key (_split_key returns (None, emoji)), serializing
{"label": null, ...} which Discord's select-option schema rejects
(1-100 char label required). The <=5-option button path is unaffected
(a button may be emoji-only). Fall back to the key's own text form
(or a single space) as the label when _split_key yields none, so the
emoji is still shown via emoji= but the option always carries a
non-empty label.

Bump 0.1.1 -> 0.1.2.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:15:01 -04:00
7 changed files with 120 additions and 21 deletions
+8 -3
View File
@@ -7,7 +7,7 @@ text chunking, timestamp helpers, interactive await-prompts, and a limit-safe se
## Install
```
dpy_commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_commons.git@v0.1.0
dpy_commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_commons.git@v1.0.0
```
## Usage
@@ -59,7 +59,9 @@ prompt stays live), disable their components after resolve/timeout, accept custo
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).
options (Discord's per-select cap). An emoji-only key is label-less on the button path (a
button may be emoji-only), but on the select path it gets a non-empty fallback label (the
key's own text form) alongside its emoji, since Discord rejects a select option with no label.
## What's inside
@@ -89,7 +91,10 @@ apply to **every** chunked message, not just the first, so a suppressed `@everyo
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`.
with `content=None`. A singular `embed=` kwarg is folded into the `embeds` pipeline (fit +
split like any other embed); passing both `embed` and `embeds` raises `TypeError`, matching
discord.py's own `Messageable.send` rule. Any kwarg `safe_send` doesn't recognize also raises
`TypeError` naming it, rather than being silently dropped.
## Notes / deviations
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "dpy_commons"
version = "0.1.1"
version = "1.0.0"
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 = [
+16 -1
View File
@@ -4,8 +4,12 @@ a module of functions grouped by concern: message/embed parsing, embed sanitizin
limit-fitting, link extraction, text chunking, timestamp helpers, interactive await-prompts,
and a limit-safe send. see each submodule's docstring for the contract.
"""
from importlib.metadata import PackageNotFoundError, version
from .embeds import fit_embed, sanitize_embed, split_embeds
from .limits import (
BUTTON_LABEL_MAX,
BUTTON_ROW_MAX,
EMBED_AUTHOR,
EMBED_DESC,
EMBED_FIELD_NAME,
@@ -16,6 +20,9 @@ from .limits import (
EMBED_TOTAL,
MSG_LIMIT,
MSG_MAX_EMBEDS,
SELECT_MAX_OPTIONS,
SELECT_OPTION_LABEL_MAX,
SELECT_PLACEHOLDER_MAX,
)
from .parsing import extract_message_links, parse_message, sanitize_mentions
from .prompts import choose, confirm
@@ -29,7 +36,10 @@ from .text import (
wrap_bare_links,
)
__version__ = "0.1.1"
try:
__version__ = version("dpy_commons")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
__all__ = [
"parse_message",
@@ -57,5 +67,10 @@ __all__ = [
"EMBED_TOTAL",
"EMBED_MAX_FIELDS",
"MSG_MAX_EMBEDS",
"BUTTON_ROW_MAX",
"BUTTON_LABEL_MAX",
"SELECT_OPTION_LABEL_MAX",
"SELECT_PLACEHOLDER_MAX",
"SELECT_MAX_OPTIONS",
"__version__",
]
+26 -5
View File
@@ -2,6 +2,7 @@
and split a list into sendable groups; none of these mutate their input"""
from __future__ import annotations
import copy
import logging
import discord
@@ -25,8 +26,12 @@ _ZWSP = ""
def _copy(embed: discord.Embed) -> discord.Embed:
"""deep copy via dict round-trip so the input is never mutated"""
return discord.Embed.from_dict(embed.to_dict())
"""deep copy via dict round-trip so the input is never mutated
discord.py 2.7.1's to_dict() reuses the embed's own _fields list rather than copying
it, so from_dict(embed.to_dict()) would still share that list with the input; the
explicit deepcopy breaks that aliasing"""
return discord.Embed.from_dict(copy.deepcopy(embed.to_dict()))
def fit_embed(embed: discord.Embed) -> discord.Embed:
@@ -130,6 +135,22 @@ def _normalize_colour(value: object) -> "discord.Colour | None":
def split_embeds(embeds: "list[discord.Embed]") -> "list[list[discord.Embed]]":
"""chunk a list of embeds into groups of <= 10 (MSG_MAX_EMBEDS) so each group sends in one
message"""
return [embeds[i:i + MSG_MAX_EMBEDS] for i in range(0, len(embeds), MSG_MAX_EMBEDS)]
"""chunk embeds into sendable groups bounded by BOTH the 10-per-message count and the
6000-char combined total (EMBED_TOTAL); a new group starts before either cap is exceeded
each embed is assumed already within its own limits (run fit_embed first); a single
embed over EMBED_TOTAL still lands alone in its own group"""
groups: "list[list[discord.Embed]]" = []
current: "list[discord.Embed]" = []
running = 0
for embed in embeds:
size = len(embed)
if current and (len(current) >= MSG_MAX_EMBEDS or running + size > EMBED_TOTAL):
groups.append(current)
current = []
running = 0
current.append(embed)
running += size
if current:
groups.append(current)
return groups
+44 -5
View File
@@ -10,6 +10,7 @@ treating a timeout as a normal None return.
from __future__ import annotations
import logging
import unicodedata
from typing import Any, Optional, Union
import discord
@@ -172,7 +173,9 @@ async def choose(
"""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; raises ValueError on empty
options or more than 25 (discord's per-select option cap)"""
options or more than 25 (discord's per-select option cap); on the select path an
emoji-only key gets a non-empty fallback label (the key's text form) alongside its emoji,
since discord's select options require a label but a button may be emoji-only"""
if not options:
raise ValueError("choose requires at least one option")
if len(options) > SELECT_MAX_OPTIONS:
@@ -190,8 +193,7 @@ async def choose(
token = str(i)
mapping[token] = options[key]
label, emoji = _split_key(key)
if label is not None:
label = truncate(label, SELECT_OPTION_LABEL_MAX)
label = truncate(label or str(key) or " ", SELECT_OPTION_LABEL_MAX)
select.add_option(label=label, value=token, emoji=emoji)
view.add_item(select)
else:
@@ -226,6 +228,43 @@ def _split_key(key: Any) -> "tuple[Optional[str], Optional[Union[str, discord.Pa
return str(key), None
def _is_emoji_codepoint(ch: str) -> bool:
"""whether a single character is emoji-composition material
covers the pictographic symbols themselves (category So), skin-tone modifiers (Sk),
and the glue that binds a compound emoji into one grapheme: ZWJ (U+200D), variation
selectors (U+FE00-FE0F), regional-indicator letters (flags, U+1F1E6-1F1FF), and the
keycap combiner (U+20E3). a plain letter/digit/space is none of these.
"""
code = ord(ch)
if ch == "" or ch == "":
return True
if 0xFE00 <= code <= 0xFE0F:
return True
if 0x1F1E6 <= code <= 0x1F1FF:
return True
return unicodedata.category(ch) in ("So", "Sk")
def _looks_unicode_emoji(value: str) -> bool:
"""heuristic: a short non-ascii token is treated as a unicode emoji key"""
return bool(value) and len(value) <= 4 and not value.isascii()
"""whether value is a unicode emoji key rather than a text label
true only when EVERY codepoint is emoji-composition material (see _is_emoji_codepoint)
and at least one is a pictographic symbol - so a compound emoji (ZWJ family, flag,
skin-tone) classifies as one emoji, while a short non-ascii text label (Sí/да/はい/確定/
café) is a label because its letters are not emoji codepoints. does not cap length:
a long emoji-only run is still an emoji, a run mixing letters and emoji is a label.
"""
if not value:
return False
# a keycap sequence is [0-9#*] + optional VS16 + U+20E3 - its base is an ascii digit/
# symbol (not So), so treat the whole run as an emoji when the keycap combiner is present
if value.endswith("") and all(c in "0123456789#*️⃣" for c in value):
return True
has_symbol = False
for ch in value:
if not _is_emoji_codepoint(ch):
return False
if unicodedata.category(ch) == "So":
has_symbol = True
return has_symbol
+20 -3
View File
@@ -34,14 +34,28 @@ async def safe_send(
`view`, `poll`, `delete_after`) ride only on the FIRST message so they aren't duplicated
across the split
a singular `embed=` kwarg is folded into the `embeds` pipeline (fit + split like any
other embed); passing both `embed` and `embeds` raises `TypeError`, matching
discord.py's own `Messageable.send` rule. Any other kwarg not recognized by either
whitelist above also raises `TypeError` naming it, instead of being silently dropped
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 []
embed = kwargs.pop("embed", None)
if embed is not None:
if embeds is not None:
raise TypeError("safe_send() cannot mix embed and embeds")
embeds = [embed]
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}
unexpected = [k for k in kwargs if k not in _PER_CHUNK_KWARGS and k not in _ONCE_ONLY_KWARGS]
if unexpected:
raise TypeError(f"safe_send() got unexpected keyword argument(s): {', '.join(unexpected)}")
content_chunks = chunk_text(content) if content else []
embed_groups = split_embeds([fit_embed(e) for e in embeds]) if embeds else []
messages: "list[discord.Message]" = []
first = True
@@ -58,7 +72,10 @@ async def safe_send(
first = False
if not messages:
messages.append(await _send(destination, content, None, True, per_chunk, once_only))
# nothing chunked out (no content, or whitespace-only that chunk_text dropped) and no
# embeds: send content=None so a files/view-only call still goes out as one message,
# WITHOUT re-injecting the raw whitespace chunk_text discarded (which Discord 50006s)
messages.append(await _send(destination, None, None, True, per_chunk, once_only))
return messages
+5 -3
View File
@@ -54,7 +54,7 @@ def chunk_text(text: str, limit: int = MSG_LIMIT) -> list[str]:
if limit < 1:
raise ValueError(f"limit must be >= 1, got {limit}")
if len(text) <= limit:
return [text] if text else []
return [text] if text.strip() else []
chunks: list[str] = []
remaining = text
@@ -65,11 +65,13 @@ def chunk_text(text: str, limit: int = MSG_LIMIT) -> list[str]:
split = window.rfind(" ")
if split <= 0:
split = limit
chunks.append(remaining[:split])
piece = remaining[:split]
if piece.strip():
chunks.append(piece)
remaining = remaining[split:]
if remaining.startswith(("\n", " ")):
remaining = remaining[1:]
if remaining:
if remaining.strip():
chunks.append(remaining)
return chunks