# 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@v1.0.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") # "" ``` ### 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`. 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 - **`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.