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
|
## 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
|
## 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`
|
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
|
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
|
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
|
## 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`,
|
stays suppressed across the whole split. Once-only kwargs (`file`, `files`, `stickers`,
|
||||||
`nonce`, `reference`, `mention_author`, `view`, `poll`, `delete_after`) still ride the first
|
`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
|
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
|
## Notes / deviations
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "dpy_commons"
|
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."
|
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 = [
|
||||||
|
|||||||
@@ -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,
|
limit-fitting, link extraction, text chunking, timestamp helpers, interactive await-prompts,
|
||||||
and a limit-safe send. see each submodule's docstring for the contract.
|
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 .embeds import fit_embed, sanitize_embed, split_embeds
|
||||||
from .limits import (
|
from .limits import (
|
||||||
|
BUTTON_LABEL_MAX,
|
||||||
|
BUTTON_ROW_MAX,
|
||||||
EMBED_AUTHOR,
|
EMBED_AUTHOR,
|
||||||
EMBED_DESC,
|
EMBED_DESC,
|
||||||
EMBED_FIELD_NAME,
|
EMBED_FIELD_NAME,
|
||||||
@@ -16,6 +20,9 @@ from .limits import (
|
|||||||
EMBED_TOTAL,
|
EMBED_TOTAL,
|
||||||
MSG_LIMIT,
|
MSG_LIMIT,
|
||||||
MSG_MAX_EMBEDS,
|
MSG_MAX_EMBEDS,
|
||||||
|
SELECT_MAX_OPTIONS,
|
||||||
|
SELECT_OPTION_LABEL_MAX,
|
||||||
|
SELECT_PLACEHOLDER_MAX,
|
||||||
)
|
)
|
||||||
from .parsing import extract_message_links, parse_message, sanitize_mentions
|
from .parsing import extract_message_links, parse_message, sanitize_mentions
|
||||||
from .prompts import choose, confirm
|
from .prompts import choose, confirm
|
||||||
@@ -29,7 +36,10 @@ from .text import (
|
|||||||
wrap_bare_links,
|
wrap_bare_links,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "0.1.1"
|
try:
|
||||||
|
__version__ = version("dpy_commons")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
__version__ = "0.0.0+unknown"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"parse_message",
|
"parse_message",
|
||||||
@@ -57,5 +67,10 @@ __all__ = [
|
|||||||
"EMBED_TOTAL",
|
"EMBED_TOTAL",
|
||||||
"EMBED_MAX_FIELDS",
|
"EMBED_MAX_FIELDS",
|
||||||
"MSG_MAX_EMBEDS",
|
"MSG_MAX_EMBEDS",
|
||||||
|
"BUTTON_ROW_MAX",
|
||||||
|
"BUTTON_LABEL_MAX",
|
||||||
|
"SELECT_OPTION_LABEL_MAX",
|
||||||
|
"SELECT_PLACEHOLDER_MAX",
|
||||||
|
"SELECT_MAX_OPTIONS",
|
||||||
"__version__",
|
"__version__",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
and split a list into sendable groups; none of these mutate their input"""
|
and split a list into sendable groups; none of these mutate their input"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
@@ -25,8 +26,12 @@ _ZWSP = ""
|
|||||||
|
|
||||||
|
|
||||||
def _copy(embed: discord.Embed) -> discord.Embed:
|
def _copy(embed: discord.Embed) -> discord.Embed:
|
||||||
"""deep copy via dict round-trip so the input is never mutated"""
|
"""deep copy via dict round-trip so the input is never mutated
|
||||||
return discord.Embed.from_dict(embed.to_dict())
|
|
||||||
|
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:
|
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]]":
|
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
|
"""chunk embeds into sendable groups bounded by BOTH the 10-per-message count and the
|
||||||
message"""
|
6000-char combined total (EMBED_TOTAL); a new group starts before either cap is exceeded
|
||||||
return [embeds[i:i + MSG_MAX_EMBEDS] for i in range(0, len(embeds), MSG_MAX_EMBEDS)]
|
|
||||||
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import unicodedata
|
||||||
from typing import Any, Optional, Union
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
import discord
|
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
|
"""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; raises ValueError on empty
|
(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:
|
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:
|
if len(options) > SELECT_MAX_OPTIONS:
|
||||||
@@ -190,8 +193,7 @@ async def choose(
|
|||||||
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 or str(key) or " ", SELECT_OPTION_LABEL_MAX)
|
||||||
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:
|
||||||
@@ -226,6 +228,43 @@ def _split_key(key: Any) -> "tuple[Optional[str], Optional[Union[str, discord.Pa
|
|||||||
return str(key), None
|
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:
|
def _looks_unicode_emoji(value: str) -> bool:
|
||||||
"""heuristic: a short non-ascii token is treated as a unicode emoji key"""
|
"""whether value is a unicode emoji key rather than a text label
|
||||||
return bool(value) and len(value) <= 4 and not value.isascii()
|
|
||||||
|
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
|
`view`, `poll`, `delete_after`) ride only on the FIRST message so they aren't duplicated
|
||||||
across the split
|
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
|
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
|
safe_send(destination) or a files/view-only call goes out as a single message rather than
|
||||||
silently doing nothing"""
|
silently doing nothing"""
|
||||||
content_chunks = chunk_text(content) if content else []
|
embed = kwargs.pop("embed", None)
|
||||||
embed_groups = split_embeds([fit_embed(embed) for embed in embeds]) if embeds else []
|
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}
|
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}
|
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]" = []
|
messages: "list[discord.Message]" = []
|
||||||
first = True
|
first = True
|
||||||
@@ -58,7 +72,10 @@ async def safe_send(
|
|||||||
first = False
|
first = False
|
||||||
|
|
||||||
if not messages:
|
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
|
return messages
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ def chunk_text(text: str, limit: int = MSG_LIMIT) -> list[str]:
|
|||||||
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:
|
||||||
return [text] if text else []
|
return [text] if text.strip() else []
|
||||||
|
|
||||||
chunks: list[str] = []
|
chunks: list[str] = []
|
||||||
remaining = text
|
remaining = text
|
||||||
@@ -65,11 +65,13 @@ def chunk_text(text: str, limit: int = MSG_LIMIT) -> list[str]:
|
|||||||
split = window.rfind(" ")
|
split = window.rfind(" ")
|
||||||
if split <= 0:
|
if split <= 0:
|
||||||
split = limit
|
split = limit
|
||||||
chunks.append(remaining[:split])
|
piece = remaining[:split]
|
||||||
|
if piece.strip():
|
||||||
|
chunks.append(piece)
|
||||||
remaining = remaining[split:]
|
remaining = remaining[split:]
|
||||||
if remaining.startswith(("\n", " ")):
|
if remaining.startswith(("\n", " ")):
|
||||||
remaining = remaining[1:]
|
remaining = remaining[1:]
|
||||||
if remaining:
|
if remaining.strip():
|
||||||
chunks.append(remaining)
|
chunks.append(remaining)
|
||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user