Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20e6a4af86 | ||
|
|
bd27ef58f1 | ||
|
|
79c2c7286f | ||
|
|
65fa11316e | ||
|
|
444e08b7db | ||
|
|
bd9502c62d | ||
|
|
5eb9ed2be0 | ||
|
|
265b9ab447 | ||
|
|
40429b0afe | ||
|
|
b530affd6e |
@@ -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@v0.1.3
|
||||
```
|
||||
|
||||
## 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
@@ -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 = [
|
||||
|
||||
@@ -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__",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user