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>
105 lines
4.4 KiB
Markdown
105 lines
4.4 KiB
Markdown
# dpy_commons
|
|
|
|
Shared discord.py utilities — the discord-side sibling of `commons`. A module of functions
|
|
grouped by concern: message/embed parsing, embed sanitizing + limit-fitting, link extraction,
|
|
text chunking, timestamp helpers, interactive await-prompts, and a limit-safe send.
|
|
|
|
## Install
|
|
|
|
```
|
|
dpy_commons @ git+ssh://git@git.rethinkstudios.io/rethink-public/dpy_commons.git@v0.1.0
|
|
```
|
|
|
|
## Usage
|
|
|
|
```python
|
|
import dpy_commons as dc
|
|
|
|
# structured parse of a rich message (async — it reads attachments from the CDN)
|
|
payload = await dc.parse_message(message)
|
|
payload["mentions"]["users"] # [123, ...]
|
|
payload["poll"] # {"question": ..., "options": [...]} or None
|
|
|
|
# sanitize an embed to be safe-to-send (links wrapped, color normalized, within limits)
|
|
safe = dc.sanitize_embed(raw_embed)
|
|
|
|
# send that never trips a Discord limit: chunks content, fits + splits embeds
|
|
await dc.safe_send(channel, content=long_text, embeds=many_embeds)
|
|
|
|
# split a 5000-char blob into <=2000 pieces on clean boundaries
|
|
for piece in dc.chunk_text(blob):
|
|
await channel.send(piece)
|
|
|
|
# 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>"
|
|
```
|
|
|
|
### Interactive await-prompts
|
|
|
|
Throw a prompt, `await` it, get the answer back right there — no listener, no view subclass,
|
|
no state plumbing:
|
|
|
|
```python
|
|
if await dc.confirm(ctx, "Delete 500 messages?"):
|
|
await purge()
|
|
|
|
action = await dc.choose(ctx, "Pick:", {
|
|
"✅": "approve",
|
|
"❌": "deny",
|
|
"<:escalate:123456789>": "escalate",
|
|
})
|
|
# action -> "approve" | "deny" | "escalate" | None (timeout)
|
|
```
|
|
|
|
`confirm` returns `True`/`False`/`None`; `choose` returns the mapped **value** (never the raw
|
|
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, truncates select
|
|
option labels and the placeholder to Discord's caps, and raises `ValueError` for more than 25
|
|
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
|
|
|
|
| Concern | Functions |
|
|
|---|---|
|
|
| Parsing | `parse_message` (async), `extract_message_links`, `sanitize_mentions` |
|
|
| Embeds | `fit_embed`, `sanitize_embed`, `split_embeds` |
|
|
| Text | `chunk_text`, `format_table`, `discord_timestamp`, `humanize_delta` |
|
|
| Prompts | `confirm`, `choose` |
|
|
| Send | `safe_send` |
|
|
|
|
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 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
|
|
|
|
- **`parse_message` is `async`.** The spec wrote it sync, but attachments are read from the
|
|
CDN (network I/O), which cannot be synchronous. Await it.
|
|
- Targets **`discord.py>=2.2`** (not `discord.py-self`).
|
|
|
|
## Versioning
|
|
|
|
Tagged `vX.Y.Z`; pin a tag in your install line.
|