discomposer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
1
+ """discomposer: validate, fetch and send Discord message templates with discord.py."""
2
+
3
+ from discomposer.editor import (
4
+ DEFAULT_URL,
5
+ EditorClient,
6
+ EditorError,
7
+ components_to_blocks,
8
+ emoji_text,
9
+ guild_context,
10
+ parse_code,
11
+ )
12
+ from discomposer.format import (
13
+ TemplateError,
14
+ chosen_options,
15
+ clean_emoji,
16
+ find_select,
17
+ selects_for,
18
+ validate,
19
+ validate_design,
20
+ )
21
+ from discomposer.media import MediaError, MediaStore, image_extension
22
+ from discomposer.render import (
23
+ button_emoji,
24
+ button_style,
25
+ outside_text,
26
+ render,
27
+ render_message,
28
+ select_rows,
29
+ )
30
+ from discomposer.tickets import (
31
+ category_roles,
32
+ chosen_category,
33
+ missing_roles,
34
+ ticket_categories,
35
+ ticket_overwrites,
36
+ transcript_markdown,
37
+ )
38
+ from discomposer.variables import VARIABLES, fill_variables
39
+
40
+ __all__ = [
41
+ "DEFAULT_URL",
42
+ "VARIABLES",
43
+ "EditorClient",
44
+ "EditorError",
45
+ "MediaError",
46
+ "MediaStore",
47
+ "TemplateError",
48
+ "button_emoji",
49
+ "button_style",
50
+ "category_roles",
51
+ "chosen_category",
52
+ "chosen_options",
53
+ "clean_emoji",
54
+ "components_to_blocks",
55
+ "emoji_text",
56
+ "fill_variables",
57
+ "find_select",
58
+ "guild_context",
59
+ "image_extension",
60
+ "missing_roles",
61
+ "parse_code",
62
+ "outside_text",
63
+ "render",
64
+ "render_message",
65
+ "select_rows",
66
+ "selects_for",
67
+ "ticket_categories",
68
+ "ticket_overwrites",
69
+ "transcript_markdown",
70
+ "validate",
71
+ "validate_design",
72
+ ]
discomposer/editor.py ADDED
@@ -0,0 +1,266 @@
1
+ import copy
2
+ import json
3
+ import re
4
+ from pathlib import PurePosixPath
5
+ from urllib.parse import urlsplit
6
+
7
+ import aiohttp
8
+
9
+ from discomposer.format import validate, validate_design
10
+ from discomposer.media import MAX_IMAGE, MediaError
11
+
12
+ DEFAULT_URL = "https://editor.caneabi.dev"
13
+ # Only the editor and Discord's CDN are fetched, so templates can't reach internal URLs.
14
+ DISCORD_MEDIA = frozenset({"cdn.discordapp.com", "media.discordapp.net"})
15
+ CODE = re.compile(r"[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}")
16
+ MAX_RESPONSE = 70 * 1024
17
+ OFFLINE = "The editor is unavailable right now. Please try again later."
18
+
19
+
20
+ class EditorError(Exception):
21
+ """Talking to the editor failed. The message is meant for end users."""
22
+
23
+
24
+ async def read_all(response, limit):
25
+ # response.content.read(n) returns whatever has arrived so far, which truncates large images.
26
+ data = bytearray()
27
+ async for chunk in response.content.iter_chunked(64 * 1024):
28
+ data.extend(chunk)
29
+ if len(data) > limit:
30
+ raise EditorError("The response is too large.")
31
+ return bytes(data)
32
+
33
+
34
+ def parse_code(value):
35
+ """Return the code from a code or an editor link."""
36
+ code = str(value).strip().rstrip("/").rsplit("/", 1)[-1].upper()
37
+ if not CODE.fullmatch(code):
38
+ raise EditorError("That is not a valid code. It looks like this: K7F3M-9QX2A")
39
+ return code
40
+
41
+
42
+ def components_to_blocks(items, on_row_item=None):
43
+ """Convert sent Components V2 back into blocks.
44
+
45
+ on_row_item(child, blocks) may turn your own buttons or menus into blocks and returns True.
46
+ Returns (blocks, color, has_actions); has_actions means interactive components were left over.
47
+ """
48
+ blocks, color, has_actions = [], None, False
49
+
50
+ def walk(parts):
51
+ nonlocal color, has_actions
52
+ for part in parts:
53
+ kind = part.get("type")
54
+ if kind == 17:
55
+ color = part.get("accent_color", color)
56
+ walk(part.get("components", []))
57
+ elif kind == 9:
58
+ accessory = part.get("accessory", {})
59
+ if accessory.get("type") == 11:
60
+ blocks.append({"image": accessory.get("media", {}).get("url")})
61
+ elif accessory.get("type") == 2:
62
+ walk([{"type": 1, "components": [accessory]}])
63
+ walk(part.get("components", []))
64
+ elif kind == 10:
65
+ blocks.append({"text": part.get("content", "")})
66
+ elif kind == 12:
67
+ blocks.extend(
68
+ {"gallery": item.get("media", {}).get("url")} for item in part.get("items", [])
69
+ )
70
+ elif kind == 14:
71
+ blocks.append({"separator": True})
72
+ elif kind == 1:
73
+ for child in part.get("components", []):
74
+ if on_row_item and on_row_item(child, blocks):
75
+ continue
76
+ if child.get("type") == 2 and child.get("style") == 5:
77
+ link = {"label": child.get("label") or "Link", "url": child.get("url")}
78
+ if emoji_text(child.get("emoji")):
79
+ link["emoji"] = emoji_text(child.get("emoji"))
80
+ blocks.append({"link": link})
81
+ else:
82
+ has_actions = True
83
+
84
+ walk(items)
85
+ return blocks, color, has_actions
86
+
87
+
88
+ def emoji_text(emoji):
89
+ """Emoji of a component in template notation."""
90
+ if emoji and emoji.get("id"):
91
+ prefix = "a" if emoji.get("animated") else ""
92
+ return f"<{prefix}:{emoji.get('name')}:{emoji['id']}>"
93
+ return emoji.get("name") if emoji else None
94
+
95
+
96
+ def guild_context(guild):
97
+ """Channels, roles and emojis for the editor. Codes aren't secret, so only public ones."""
98
+ everyone = guild.default_role
99
+ channels = [
100
+ channel for channel in guild.text_channels if channel.permissions_for(everyone).view_channel
101
+ ]
102
+ roles = [r for r in guild.roles if not r.is_default() and not r.managed]
103
+ emojis = [e for e in guild.emojis if e.available]
104
+ return {
105
+ "channels": [{"id": str(c.id), "name": c.name} for c in channels[:200]],
106
+ "roles": [{"id": str(r.id), "name": r.name} for r in reversed(roles[-200:])],
107
+ "emojis": [{"id": str(e.id), "name": e.name, "animated": e.animated} for e in emojis[:200]],
108
+ }
109
+
110
+
111
+ class EditorClient:
112
+ """Client for the template editor. Pass a MediaStore to keep copies of images."""
113
+
114
+ def __init__(self, base_url=DEFAULT_URL, media=None, *, timeout=30):
115
+ self.base_url = base_url.rstrip("/")
116
+ self.media = media
117
+ self.timeout = timeout
118
+ self.image_url = re.compile(re.escape(self.base_url) + r"/api/images/[0-9a-f]{64}")
119
+
120
+ async def _request(self, method, path, *, limit=MAX_RESPONSE, parse=True, **kwargs):
121
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
122
+ try:
123
+ async with (
124
+ aiohttp.ClientSession(timeout=timeout) as session,
125
+ session.request(
126
+ method, self.base_url + path, allow_redirects=False, **kwargs
127
+ ) as response,
128
+ ):
129
+ raw = await read_all(response, limit)
130
+ return response.status, json.loads(raw) if parse else raw
131
+ except (aiohttp.ClientError, TimeoutError, ValueError):
132
+ raise EditorError(OFFLINE) from None
133
+
134
+ async def open(self, document):
135
+ """Store a document and return the link to edit it."""
136
+ document = validate(document)
137
+ status, body = await self._request("POST", "/api/templates", json=document)
138
+ if status != 201 or not isinstance(body, dict) or not isinstance(body.get("code"), str):
139
+ raise EditorError((body or {}).get("error") if isinstance(body, dict) else OFFLINE)
140
+ return f"{self.base_url}/e/{body['code']}"
141
+
142
+ async def upload(self, raw):
143
+ """Upload an image and return its URL."""
144
+ status, body = await self._request(
145
+ "POST", "/api/images", data=raw, headers={"content-type": "application/octet-stream"}
146
+ )
147
+ if status != 201 or not isinstance(body, dict) or not isinstance(body.get("path"), str):
148
+ raise EditorError((body or {}).get("error") if isinstance(body, dict) else OFFLINE)
149
+ return self.base_url + body["path"]
150
+
151
+ async def fetch_document(self, code):
152
+ """Fetch and validate a document by code or link."""
153
+ status, body = await self._request("GET", f"/api/templates/{parse_code(code)}")
154
+ if status == 404:
155
+ raise EditorError("This code doesn't exist or has expired.")
156
+ if status != 200 or not isinstance(body, dict):
157
+ raise EditorError(OFFLINE)
158
+ return validate(body)
159
+
160
+ async def fetch(self, code, *, adopt_images=True):
161
+ """Fetch the design of a template; images are adopted if a MediaStore is set."""
162
+ design = (await self.fetch_document(code))["design"]
163
+ if adopt_images and self.media is not None:
164
+ design = await self.adopt_images(design)
165
+ return design
166
+
167
+ async def _download(self, url):
168
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
169
+ try:
170
+ async with (
171
+ aiohttp.ClientSession(timeout=timeout) as session,
172
+ session.get(url, allow_redirects=False) as response,
173
+ ):
174
+ if response.status != 200:
175
+ raise EditorError(
176
+ "A Discord image has expired. Please upload it again in the editor."
177
+ )
178
+ return await read_all(response, MAX_IMAGE)
179
+ except (aiohttp.ClientError, TimeoutError):
180
+ raise EditorError(
181
+ "An image could not be loaded. Please upload it in the editor."
182
+ ) from None
183
+
184
+ async def adopt_images(self, design):
185
+ """Store editor and Discord images and point the blocks at attachment:// names.
186
+
187
+ The result has attachments: {filename: stored name}. Editor images expire after 30 days.
188
+ """
189
+ if self.media is None:
190
+ raise EditorError("The client needs a MediaStore to adopt images.")
191
+ design = copy.deepcopy(design)
192
+ attachments = dict(design.get("attachments", {}))
193
+ for block in design["blocks"]:
194
+ for key in ("image", "gallery"):
195
+ value = block.get(key)
196
+ if not isinstance(value, str):
197
+ continue
198
+ if self.image_url.fullmatch(value):
199
+ status, raw = await self._request(
200
+ "GET", urlsplit(value).path, limit=MAX_IMAGE, parse=False
201
+ )
202
+ if status != 200:
203
+ raise EditorError("An uploaded image has expired. Please upload it again.")
204
+ elif value.startswith("https://") and urlsplit(value).hostname in DISCORD_MEDIA:
205
+ raw = await self._download(value)
206
+ else:
207
+ continue
208
+ try:
209
+ stored = self.media.save(raw)
210
+ except MediaError as error:
211
+ raise EditorError(str(error)) from None
212
+ filename = self.media.attachment_name(stored, raw)
213
+ attachments[filename] = stored
214
+ block[key] = f"attachment://{filename}"
215
+ if attachments:
216
+ design["attachments"] = attachments
217
+ return design
218
+
219
+ async def expose_attachments(self, design):
220
+ """Reverse of adopt_images: upload stored attachments so the editor can show them."""
221
+ design = copy.deepcopy(design)
222
+ stored = design.pop("attachments", {})
223
+ for block in design.get("blocks", []):
224
+ for key in ("image", "gallery"):
225
+ value = block.get(key)
226
+ if isinstance(value, str) and value.startswith("attachment://"):
227
+ name = stored.get(value.removeprefix("attachment://"))
228
+ if name is None or self.media is None:
229
+ raise EditorError("An image of this template is missing.")
230
+ block[key] = await self.upload(self.media.read(name))
231
+ return validate_design(design)
232
+
233
+ async def open_message(self, message, *, on_row_item=None, **document):
234
+ """Open a sent message in the editor. Returns None if it has components nobody handles."""
235
+ items = [component.to_dict() for component in message.components]
236
+ blocks, color, has_actions = components_to_blocks(items, on_row_item)
237
+ if has_actions:
238
+ return None
239
+ if message.content:
240
+ blocks.insert(0, {"text": message.content})
241
+ for embed in message.embeds:
242
+ parts = [f"## {embed.title}" if embed.title else None, embed.description]
243
+ text = "\n".join(part for part in parts if part)
244
+ if text:
245
+ blocks.append({"text": text})
246
+ if embed.image and embed.image.url:
247
+ blocks.append({"gallery": embed.image.url})
248
+ if color is None and embed.colour:
249
+ color = embed.colour.value
250
+ files = {attachment.filename: attachment for attachment in message.attachments}
251
+ for block in blocks:
252
+ for key in ("image", "gallery"):
253
+ value = block.get(key)
254
+ if not isinstance(value, str):
255
+ continue
256
+ name = (
257
+ value.removeprefix("attachment://")
258
+ if value.startswith("attachment://")
259
+ else PurePosixPath(urlsplit(value).path).name
260
+ )
261
+ if name in files:
262
+ block[key] = await self.upload(await files[name].read())
263
+ design = {"blocks": blocks}
264
+ if color is not None:
265
+ design["color"] = color
266
+ return await self.open({**document, "design": design})
discomposer/format.py ADDED
@@ -0,0 +1,350 @@
1
+ """Validation and normalization of the template format (spec/FORMAT.md)."""
2
+
3
+ import re
4
+ from urllib.parse import urlsplit
5
+
6
+ MAX_BLOCKS = 20
7
+ MAX_TEXT = 3500
8
+ IMAGE_VARIABLES = ("{server_icon}", "{server_banner}")
9
+ BUTTON_STYLES = ("primary", "secondary", "success", "danger")
10
+ SELECT_MODES = ("select", "buttons")
11
+ ROLE_MODES = ("none", "optional", "single")
12
+ SLUG = re.compile(r"[A-Za-z0-9_-]{1,64}")
13
+ SNOWFLAKE = re.compile(r"\d{17,20}")
14
+ CUSTOM_EMOJI = re.compile(r"<a?:\w{1,32}:\d{17,20}>")
15
+ DOCUMENT_KEYS = {
16
+ "version",
17
+ "kind",
18
+ "title",
19
+ "template",
20
+ "note",
21
+ "returnHint",
22
+ "sender",
23
+ "messageType",
24
+ "context",
25
+ "design",
26
+ }
27
+
28
+
29
+ class TemplateError(ValueError):
30
+ """The template violates the format. The message is meant for end users."""
31
+
32
+
33
+ def _text(value, limit, message, *, required=False):
34
+ if value is None and not required:
35
+ return None
36
+ if not isinstance(value, str) or len(value) > limit or (required and not value.strip()):
37
+ raise TemplateError(message)
38
+ return value
39
+
40
+
41
+ def clean_emoji(value):
42
+ """A custom emoji (<:name:id>) or a short Unicode emoji without letters."""
43
+ if value is None or value == "":
44
+ return None
45
+ if isinstance(value, str) and CUSTOM_EMOJI.fullmatch(value):
46
+ return value
47
+ valid = (
48
+ isinstance(value, str)
49
+ and 0 < len(value) <= 16
50
+ and not any(char.isspace() or char.isalpha() or char in "<>" for char in value)
51
+ )
52
+ if not valid:
53
+ raise TemplateError("An emoji is invalid.")
54
+ return value
55
+
56
+
57
+ def https_url(value):
58
+ if not isinstance(value, str) or len(value) > 2048:
59
+ raise TemplateError("Invalid URL.")
60
+ url = urlsplit(value)
61
+ if url.scheme != "https" or not url.hostname or url.username or url.password:
62
+ raise TemplateError("Images and links need a public HTTPS URL.")
63
+ return value
64
+
65
+
66
+ def image_source(value):
67
+ return value if value in IMAGE_VARIABLES else https_url(value)
68
+
69
+
70
+ def _slug(value, message):
71
+ if not isinstance(value, str) or not SLUG.fullmatch(value):
72
+ raise TemplateError(message)
73
+ return value
74
+
75
+
76
+ def _roles(value, limit, message):
77
+ if not isinstance(value, list) or len(value) > limit:
78
+ raise TemplateError(message)
79
+ for role in value:
80
+ if not isinstance(role, str) or not SNOWFLAKE.fullmatch(role):
81
+ raise TemplateError(message)
82
+ return list(dict.fromkeys(value))
83
+
84
+
85
+ def _optional(entry, source, key, cleaner):
86
+ value = source.get(key)
87
+ if value not in (None, ""):
88
+ entry[key] = cleaner(value)
89
+
90
+
91
+ def clean_select(menu):
92
+ if not isinstance(menu, dict):
93
+ raise TemplateError("Invalid selection.")
94
+ select_id = _slug(menu.get("id"), "Invalid selection.")
95
+ purpose = _slug(menu.get("purpose"), "Invalid selection.")
96
+ if menu.get("mode") not in SELECT_MODES or not isinstance(menu.get("exclusive"), bool):
97
+ raise TemplateError("Invalid selection.")
98
+ options = menu.get("options")
99
+ if not isinstance(options, list) or not 1 <= len(options) <= 25:
100
+ raise TemplateError("A selection needs 1 to 25 options.")
101
+ cleaned, seen = [], set()
102
+ for option in options:
103
+ if not isinstance(option, dict):
104
+ raise TemplateError("Invalid option.")
105
+ value = _slug(option.get("value"), "Every option needs a unique value.")
106
+ if value in seen:
107
+ raise TemplateError("Every option needs a unique value.")
108
+ seen.add(value)
109
+ label = _text(option.get("label"), 80, "Every option needs a label.", required=True)
110
+ entry = {"value": value, "label": label}
111
+ _optional(entry, option, "emoji", clean_emoji)
112
+ _optional(
113
+ entry,
114
+ option,
115
+ "description",
116
+ lambda v: _text(v, 100, "The description is too long."),
117
+ )
118
+ if option.get("roles") is not None:
119
+ entry["roles"] = _roles(option["roles"], 10, "An option contains invalid roles.")
120
+ cleaned.append(entry)
121
+ result = {
122
+ "id": select_id,
123
+ "purpose": purpose,
124
+ "mode": menu["mode"],
125
+ "exclusive": menu["exclusive"],
126
+ "options": cleaned,
127
+ }
128
+ _optional(
129
+ result,
130
+ menu,
131
+ "placeholder",
132
+ lambda v: _text(v, 150, "The placeholder is too long."),
133
+ )
134
+ return result
135
+
136
+
137
+ def _clean_button(item):
138
+ if not isinstance(item, dict):
139
+ raise TemplateError("Invalid button.")
140
+ button = {
141
+ "id": _slug(item.get("id"), "Invalid button."),
142
+ "label": _text(item.get("label"), 80, "Invalid button.", required=True),
143
+ "style": item.get("style") or "secondary",
144
+ }
145
+ if button["style"] not in BUTTON_STYLES:
146
+ raise TemplateError("Invalid button.")
147
+ _optional(button, item, "emoji", clean_emoji)
148
+ return button
149
+
150
+
151
+ def _clean_link(item):
152
+ if not isinstance(item, dict):
153
+ raise TemplateError("Link buttons need a label and an HTTPS URL.")
154
+ link = {
155
+ "label": _text(item.get("label"), 80, "Link buttons need a label.", required=True),
156
+ "url": https_url(item.get("url")),
157
+ }
158
+ _optional(link, item, "emoji", clean_emoji)
159
+ return link
160
+
161
+
162
+ def clean_block(block):
163
+ if not isinstance(block, dict) or len(block) != 1:
164
+ raise TemplateError("Invalid block.")
165
+ key, item = next(iter(block.items()))
166
+ if key == "text" and isinstance(item, str):
167
+ return {"text": item}
168
+ if key in ("image", "gallery"):
169
+ return {key: image_source(item)}
170
+ if key == "slot":
171
+ return {"slot": _slug(item, "Invalid dynamic block.")}
172
+ if key == "separator" and item is True:
173
+ return {"separator": True}
174
+ if key == "button":
175
+ return {"button": _clean_button(item)}
176
+ if key == "link":
177
+ return {"link": _clean_link(item)}
178
+ if key == "select":
179
+ return {"select": clean_select(item)}
180
+ raise TemplateError("Unknown block.")
181
+
182
+
183
+ def validate_design(design):
184
+ """Validate a design and return a cleaned copy."""
185
+ if not isinstance(design, dict) or not isinstance(design.get("blocks"), list):
186
+ raise TemplateError("Invalid template.")
187
+ if len(design["blocks"]) > MAX_BLOCKS:
188
+ raise TemplateError(f"At most {MAX_BLOCKS} blocks.")
189
+ blocks, ids = [], set()
190
+ for block in design["blocks"]:
191
+ cleaned = clean_block(block)
192
+ if "select" in cleaned:
193
+ if cleaned["select"]["id"] in ids:
194
+ raise TemplateError("Every selection needs its own ID.")
195
+ ids.add(cleaned["select"]["id"])
196
+ blocks.append(cleaned)
197
+ content = _text(design.get("content"), 2000, "The outside text is too long.")
198
+ length = sum(len(block.get("text", "")) for block in blocks) + len(content or "")
199
+ if length > MAX_TEXT:
200
+ raise TemplateError(f"At most {MAX_TEXT} characters of text.")
201
+ result = {"blocks": blocks}
202
+ if content:
203
+ result["content"] = content
204
+ color = design.get("color")
205
+ if color is not None:
206
+ if type(color) is not int or not 0 <= color <= 0xFFFFFF:
207
+ raise TemplateError("Invalid accent color.")
208
+ result["color"] = color
209
+ keep = design.get("keepBotImage")
210
+ if keep is not None:
211
+ if keep not in ("large", "small"):
212
+ raise TemplateError("Invalid image setting.")
213
+ result["keepBotImage"] = keep
214
+ return result
215
+
216
+
217
+ def _clean_footer(footer):
218
+ if not isinstance(footer, dict) or not isinstance(footer.get("rows"), list):
219
+ raise TemplateError("Invalid context.")
220
+ rows = footer["rows"]
221
+ if len(rows) > 5 or any(not isinstance(row, list) or len(row) > 5 for row in rows):
222
+ raise TemplateError("Invalid context.")
223
+ result = {"rows": [[https_url(url) for url in row] for row in rows]}
224
+ _optional(result, footer, "label", lambda v: _text(v, 100, "Invalid context."))
225
+ return result
226
+
227
+
228
+ def _clean_context(context):
229
+ if not isinstance(context, dict):
230
+ raise TemplateError("Invalid context.")
231
+ result = {}
232
+ for key, items in context.items():
233
+ if key == "accessory":
234
+ result[key] = _slug(items, "Invalid context.")
235
+ continue
236
+ if key == "footer":
237
+ result[key] = _clean_footer(items)
238
+ continue
239
+ if key not in ("channels", "roles", "emojis", "buttons", "slots", "selects"):
240
+ raise TemplateError("Invalid context.")
241
+ if not isinstance(items, list) or len(items) > (20 if key == "selects" else 200):
242
+ raise TemplateError("Invalid context.")
243
+ cleaned = []
244
+ for item in items:
245
+ if not isinstance(item, dict):
246
+ raise TemplateError("Invalid context.")
247
+ if key == "selects":
248
+ entry = {
249
+ "purpose": _slug(item.get("purpose"), "Invalid context."),
250
+ "label": _text(item.get("label"), 80, "Invalid context.", required=True),
251
+ "roles": item.get("roles") or "none",
252
+ }
253
+ modes = item.get("modes")
254
+ if entry["roles"] not in ROLE_MODES:
255
+ raise TemplateError("Invalid context.")
256
+ if modes is not None:
257
+ if (
258
+ not isinstance(modes, list)
259
+ or not modes
260
+ or any(mode not in SELECT_MODES for mode in modes)
261
+ ):
262
+ raise TemplateError("Invalid context.")
263
+ entry["modes"] = modes
264
+ if item.get("exclusive") is not None:
265
+ if not isinstance(item["exclusive"], bool):
266
+ raise TemplateError("Invalid context.")
267
+ entry["exclusive"] = item["exclusive"]
268
+ _optional(entry, item, "hint", lambda v: _text(v, 300, "Invalid context."))
269
+ elif key in ("buttons", "slots"):
270
+ entry = {
271
+ "id": _slug(item.get("id"), "Invalid context."),
272
+ "label": _text(item.get("label"), 80, "Invalid context.", required=True),
273
+ }
274
+ _optional(entry, item, "sample", lambda v: _text(v, 1000, "Invalid context."))
275
+ if item.get("icon") is not None:
276
+ if key != "buttons":
277
+ raise TemplateError("Invalid context.")
278
+ entry["icon"] = https_url(item["icon"])
279
+ else:
280
+ ident, name = item.get("id"), item.get("name")
281
+ if not isinstance(ident, str) or not SNOWFLAKE.fullmatch(ident):
282
+ raise TemplateError("Invalid context.")
283
+ _text(name, 100, "Invalid context.", required=True)
284
+ if key == "emojis" and not re.fullmatch(r"\w{2,32}", name):
285
+ raise TemplateError("Invalid context.")
286
+ entry = {"id": ident, "name": name}
287
+ if key == "emojis" and item.get("animated") is True:
288
+ entry["animated"] = True
289
+ cleaned.append(entry)
290
+ result[key] = cleaned
291
+ return result
292
+
293
+
294
+ def validate(document):
295
+ """Validate a whole document and return a cleaned copy. Raises TemplateError."""
296
+ if not isinstance(document, dict) or set(document) - DOCUMENT_KEYS:
297
+ raise TemplateError("Invalid template.")
298
+ if document.get("version") not in (None, 1):
299
+ raise TemplateError("Unknown format version.")
300
+ result = {}
301
+ for key, limit in (
302
+ ("kind", 80),
303
+ ("title", 80),
304
+ ("template", 80),
305
+ ("note", 300),
306
+ ("returnHint", 300),
307
+ ):
308
+ if document.get(key) is not None:
309
+ result[key] = _text(document[key], limit, "Invalid template.")
310
+ if document.get("version") is not None:
311
+ result["version"] = 1
312
+ if document.get("messageType") not in (None, "general", "member"):
313
+ raise TemplateError("Invalid message type.")
314
+ if document.get("messageType"):
315
+ result["messageType"] = document["messageType"]
316
+ sender = document.get("sender")
317
+ if sender is not None:
318
+ if not isinstance(sender, dict):
319
+ raise TemplateError("Invalid sender.")
320
+ result["sender"] = {"name": _text(sender.get("name"), 80, "Invalid sender.", required=True)}
321
+ if sender.get("avatar") is not None:
322
+ result["sender"]["avatar"] = https_url(sender["avatar"])
323
+ if document.get("context") is not None:
324
+ result["context"] = _clean_context(document["context"])
325
+ result["design"] = validate_design(document.get("design"))
326
+ return result
327
+
328
+
329
+ def find_select(design, select_id):
330
+ for block in design.get("blocks", []):
331
+ if block.get("select", {}).get("id") == select_id:
332
+ return block["select"]
333
+ return None
334
+
335
+
336
+ def selects_for(design, purpose):
337
+ return [
338
+ block["select"]
339
+ for block in design.get("blocks", [])
340
+ if block.get("select", {}).get("purpose") == purpose
341
+ ]
342
+
343
+
344
+ def chosen_options(design, select_id, values):
345
+ """Options matching the chosen values. Always pass the stored design, never interaction data."""
346
+ menu = find_select(design, select_id)
347
+ if menu is None:
348
+ return []
349
+ wanted = set(values)
350
+ return [option for option in menu["options"] if option["value"] in wanted]
discomposer/media.py ADDED
@@ -0,0 +1,67 @@
1
+ import hashlib
2
+ import re
3
+ from pathlib import Path
4
+
5
+ import discord
6
+
7
+ MAX_IMAGE = 8 * 1024 * 1024
8
+ ATTACHMENT_NAME = re.compile(r"pb-[0-9a-f]{16}\.(?:png|jpg|gif|webp)")
9
+ STORED_NAME = re.compile(r"[0-9a-f]{64}")
10
+
11
+
12
+ class MediaError(ValueError):
13
+ """An image can't be stored. The message is meant for end users."""
14
+
15
+
16
+ def image_extension(raw):
17
+ """Detect the format from the bytes. Anything else, including SVG, is rejected."""
18
+ if raw.startswith(b"\x89PNG\r\n\x1a\n"):
19
+ return "png"
20
+ if raw.startswith(b"\xff\xd8\xff"):
21
+ return "jpg"
22
+ if raw.startswith((b"GIF87a", b"GIF89a")):
23
+ return "gif"
24
+ if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
25
+ return "webp"
26
+ return None
27
+
28
+
29
+ class MediaStore:
30
+ """Stores images by SHA-256 in a folder with a size limit."""
31
+
32
+ def __init__(self, path, quota=128 * 1024 * 1024):
33
+ self.path = Path(path)
34
+ self.quota = quota
35
+
36
+ def save(self, raw):
37
+ if len(raw) > MAX_IMAGE or not image_extension(raw):
38
+ raise MediaError("Please use a PNG, JPEG, GIF or WebP image up to 8 MB.")
39
+ self.path.mkdir(parents=True, exist_ok=True)
40
+ name = hashlib.sha256(raw).hexdigest()
41
+ target = self.path / name
42
+ if not target.exists():
43
+ used = sum(p.stat().st_size for p in self.path.iterdir() if p.is_file())
44
+ if used + len(raw) > self.quota:
45
+ raise MediaError("The image store is full. Please clean up old template images.")
46
+ target.write_bytes(raw)
47
+ return name
48
+
49
+ def file(self, stored):
50
+ if not isinstance(stored, str) or not STORED_NAME.fullmatch(stored):
51
+ raise MediaError("Invalid stored image name.")
52
+ return self.path / stored
53
+
54
+ def read(self, stored):
55
+ return self.file(stored).read_bytes()
56
+
57
+ def attachment_name(self, stored, raw):
58
+ return f"pb-{stored[:16]}.{image_extension(raw)}"
59
+
60
+ def files_for(self, design):
61
+ """discord.File objects for all adopted images of a design."""
62
+ files = []
63
+ for filename, stored in design.get("attachments", {}).items():
64
+ if not ATTACHMENT_NAME.fullmatch(filename):
65
+ raise MediaError("Invalid stored image name.")
66
+ files.append(discord.File(self.file(stored), filename=filename))
67
+ return files
discomposer/py.typed ADDED
File without changes
discomposer/render.py ADDED
@@ -0,0 +1,210 @@
1
+ import re
2
+
3
+ import discord
4
+
5
+ from discomposer.format import IMAGE_VARIABLES
6
+ from discomposer.variables import fill_variables
7
+
8
+ BUTTON_STYLES = {
9
+ "primary": discord.ButtonStyle.primary,
10
+ "secondary": discord.ButtonStyle.secondary,
11
+ "success": discord.ButtonStyle.success,
12
+ "danger": discord.ButtonStyle.danger,
13
+ }
14
+ SEPARATOR_TEXT = "────────"
15
+
16
+
17
+ def button_emoji(value):
18
+ return discord.PartialEmoji.from_str(value) if value else None
19
+
20
+
21
+ def button_style(name):
22
+ return BUTTON_STYLES.get(name or "secondary", discord.ButtonStyle.secondary)
23
+
24
+
25
+ def select_rows(menu, custom_id):
26
+ """Default rendering of a select block.
27
+
28
+ Select menu: custom_id as given, values are the option values.
29
+ Buttons: custom_id + ":" + value. Resolve the choice with chosen_options.
30
+ """
31
+ if menu["mode"] == "select":
32
+ select = discord.ui.Select(
33
+ custom_id=custom_id,
34
+ placeholder=menu.get("placeholder"),
35
+ min_values=0 if not menu["exclusive"] else 1,
36
+ max_values=1 if menu["exclusive"] else len(menu["options"]),
37
+ options=[
38
+ discord.SelectOption(
39
+ label=option["label"],
40
+ value=option["value"],
41
+ description=option.get("description"),
42
+ emoji=button_emoji(option.get("emoji")),
43
+ )
44
+ for option in menu["options"]
45
+ ],
46
+ )
47
+ return [discord.ui.ActionRow(select)]
48
+ buttons = [
49
+ discord.ui.Button(
50
+ label=option["label"],
51
+ custom_id=f"{custom_id}:{option['value']}",
52
+ emoji=button_emoji(option.get("emoji")),
53
+ style=discord.ButtonStyle.secondary,
54
+ )
55
+ for option in menu["options"]
56
+ ]
57
+ return [
58
+ discord.ui.ActionRow(*buttons[start : start + 5]) for start in range(0, len(buttons), 5)
59
+ ]
60
+
61
+
62
+ def render(
63
+ container,
64
+ design,
65
+ *,
66
+ member=None,
67
+ guild=None,
68
+ header=(),
69
+ thumbnail=None,
70
+ accessory=None,
71
+ buttons=None,
72
+ slots=None,
73
+ selects=None,
74
+ variables=None,
75
+ ):
76
+ """Fill a discord.ui.Container from a validated design.
77
+
78
+ header: items added first, e.g. a large image of your own.
79
+ thumbnail: top-right image if the design has no image block.
80
+ accessory: item next to the first text if there is no thumbnail.
81
+ buttons: {id: callable(button) -> discord.ui.Button}; unknown ids are skipped.
82
+ slots: {id: text}; missing slots are skipped.
83
+ selects: callable(select, guild) -> items; select blocks are skipped without it.
84
+ variables: extra variables, e.g. {"{level}": "3"}.
85
+ """
86
+ for item in header:
87
+ container.add_item(item)
88
+
89
+ def fill(text):
90
+ return fill_variables(text, member, guild, variables)
91
+
92
+ def resolved(url):
93
+ return fill(url) if url in IMAGE_VARIABLES else url
94
+
95
+ blocks = design.get("blocks", [])
96
+ image = thumbnail or next((block["image"] for block in blocks if "image" in block), None)
97
+ # {server_icon} resolves to an empty string if the server has no icon.
98
+ image = resolved(image) if image else None
99
+
100
+ texts, galleries, links = [], [], []
101
+
102
+ def flush_links():
103
+ for start in range(0, len(links), 5):
104
+ container.add_item(discord.ui.ActionRow(*links[start : start + 5]))
105
+ links.clear()
106
+
107
+ def flush_text():
108
+ nonlocal image, accessory
109
+ if not texts:
110
+ return
111
+ display = discord.ui.TextDisplay("\n".join(texts))
112
+ texts.clear()
113
+ if image:
114
+ container.add_item(discord.ui.Section(display, accessory=discord.ui.Thumbnail(image)))
115
+ image = None
116
+ elif accessory:
117
+ container.add_item(discord.ui.Section(display, accessory=accessory))
118
+ accessory = None
119
+ else:
120
+ container.add_item(display)
121
+
122
+ def flush_gallery():
123
+ if galleries:
124
+ container.add_item(
125
+ discord.ui.MediaGallery(*(discord.MediaGalleryItem(url) for url in galleries[:10]))
126
+ )
127
+ galleries.clear()
128
+
129
+ for block in blocks:
130
+ if "link" in block:
131
+ flush_text()
132
+ flush_gallery()
133
+ link = block["link"]
134
+ links.append(
135
+ discord.ui.Button(
136
+ label=link["label"],
137
+ url=link["url"],
138
+ style=discord.ButtonStyle.link,
139
+ emoji=button_emoji(link.get("emoji")),
140
+ )
141
+ )
142
+ continue
143
+ if "button" in block:
144
+ action = (buttons or {}).get(block["button"]["id"])
145
+ if action:
146
+ flush_text()
147
+ flush_gallery()
148
+ links.append(action(block["button"]))
149
+ continue
150
+ if "select" in block:
151
+ flush_text()
152
+ flush_gallery()
153
+ flush_links()
154
+ if selects:
155
+ for row in selects(block["select"], member.guild if member else guild) or ():
156
+ container.add_item(row)
157
+ continue
158
+ if "slot" in block:
159
+ flush_links()
160
+ flush_gallery()
161
+ value = (slots or {}).get(block["slot"])
162
+ if value:
163
+ texts.append(value)
164
+ continue
165
+ if "image" not in block:
166
+ flush_links()
167
+ if "gallery" in block:
168
+ flush_text()
169
+ url = resolved(block["gallery"])
170
+ if url:
171
+ galleries.append(url)
172
+ continue
173
+ if "text" in block:
174
+ flush_gallery()
175
+ # Discord only renders headings with a space after the #.
176
+ texts.append(re.sub(r"(?m)^(#{1,3})(?=[^#\s])", r"\1 ", fill(block["text"])))
177
+ elif block.get("separator"):
178
+ flush_gallery()
179
+ texts.append(SEPARATOR_TEXT)
180
+ flush_text()
181
+ flush_gallery()
182
+ flush_links()
183
+ if image:
184
+ container.add_item(
185
+ discord.ui.Section(discord.ui.TextDisplay("​"), accessory=discord.ui.Thumbnail(image))
186
+ )
187
+ return container
188
+
189
+
190
+ def outside_text(design, *, member=None, guild=None, variables=None):
191
+ """The design's content as a TextDisplay placed above the container, or None."""
192
+ content = design.get("content")
193
+ if not content:
194
+ return None
195
+ return discord.ui.TextDisplay(fill_variables(content, member, guild, variables))
196
+
197
+
198
+ def render_message(design, **options):
199
+ """Top-level items of a message: the outside text (if any) and the filled container.
200
+
201
+ Takes the same options as render.
202
+ """
203
+ container = render(discord.ui.Container(accent_colour=design.get("color")), design, **options)
204
+ text = outside_text(
205
+ design,
206
+ member=options.get("member"),
207
+ guild=options.get("guild"),
208
+ variables=options.get("variables"),
209
+ )
210
+ return [text, container] if text else [container]
discomposer/tickets.py ADDED
@@ -0,0 +1,95 @@
1
+ """Helpers for ticket bots. Creating and managing tickets stays in your bot."""
2
+
3
+ import discord
4
+
5
+ from discomposer.format import chosen_options, selects_for
6
+
7
+ TICKET_PURPOSE = "ticket_categories"
8
+
9
+
10
+ def ticket_categories(design, purpose=TICKET_PURPOSE):
11
+ """{value: option} for all ticket categories of a stored design."""
12
+ return {
13
+ option["value"]: option
14
+ for menu in selects_for(design, purpose)
15
+ for option in menu["options"]
16
+ }
17
+
18
+
19
+ def chosen_category(design, select_id, value):
20
+ """The chosen category including its roles, looked up in the stored design."""
21
+ options = chosen_options(design, select_id, [value])
22
+ return options[0] if options else None
23
+
24
+
25
+ def category_roles(guild, option):
26
+ """Existing roles of a category, without managed roles and @everyone."""
27
+ roles = []
28
+ for role_id in option.get("roles", []):
29
+ role = guild.get_role(int(role_id))
30
+ if role is not None and not role.is_default() and not role.managed:
31
+ roles.append(role)
32
+ return roles
33
+
34
+
35
+ def missing_roles(guild, design, purpose=TICKET_PURPOSE):
36
+ """Labels of categories whose roles are missing or can't be used."""
37
+ problems = []
38
+ for option in ticket_categories(design, purpose).values():
39
+ if len(category_roles(guild, option)) != len(option.get("roles", [])):
40
+ problems.append(option["label"])
41
+ return problems
42
+
43
+
44
+ def ticket_overwrites(guild, owner, roles=(), *, bot_member=None):
45
+ """Overwrites so that only the owner, the given roles and the bot see the channel."""
46
+ allowed = discord.PermissionOverwrite(
47
+ view_channel=True, send_messages=True, read_message_history=True, attach_files=True
48
+ )
49
+ overwrites = {
50
+ guild.default_role: discord.PermissionOverwrite(view_channel=False),
51
+ owner: allowed,
52
+ }
53
+ for role in roles:
54
+ overwrites[role] = allowed
55
+ me = bot_member or guild.me
56
+ if me is not None:
57
+ overwrites[me] = discord.PermissionOverwrite(
58
+ view_channel=True,
59
+ send_messages=True,
60
+ read_message_history=True,
61
+ attach_files=True,
62
+ manage_channels=True,
63
+ )
64
+ return overwrites
65
+
66
+
67
+ def component_text(items):
68
+ for part in items:
69
+ if part.get("type") == 10:
70
+ yield part.get("content", "")
71
+ yield from component_text(part.get("components", []))
72
+ accessory = part.get("accessory")
73
+ if accessory:
74
+ yield from component_text([accessory])
75
+
76
+
77
+ def transcript_markdown(title, messages, *, details=(), truncated=False):
78
+ """Markdown transcript. details are lines like ("Category", "Support")."""
79
+ lines = [f"# {title}", ""]
80
+ lines += [f"- {key}: {value}" for key, value in details]
81
+ if details:
82
+ lines.append("")
83
+ if truncated:
84
+ lines += ["> Incomplete.", ""]
85
+ for message in messages:
86
+ stamp = message.created_at.strftime("%d.%m.%Y %H:%M")
87
+ lines.append(f"## {message.author.display_name} · {stamp}")
88
+ text = [message.content] if message.content else []
89
+ text += list(component_text([c.to_dict() for c in message.components]))
90
+ for embed in message.embeds:
91
+ text += [part for part in (embed.title, embed.description) if part]
92
+ lines += text or ["*(no text)*"]
93
+ lines += [f"- Attachment: [{a.filename}]({a.url})" for a in message.attachments]
94
+ lines.append("")
95
+ return "\n".join(lines)
@@ -0,0 +1,98 @@
1
+ import re
2
+
3
+ import discord
4
+
5
+ MEMBER_KEYS = (
6
+ "{username}",
7
+ "{nickname}",
8
+ "{user}",
9
+ "{mention}",
10
+ "{member_since}",
11
+ "{member_number}",
12
+ "{early_bird}",
13
+ )
14
+ VARIABLES = {
15
+ "{user}": "Display name",
16
+ "{username}": "Username",
17
+ "{nickname}": "Server nickname",
18
+ "{mention}": "Mention",
19
+ "{member_since}": "Join date",
20
+ "{member_number}": "Member number by join date",
21
+ "{early_bird}": "Share of members who joined later",
22
+ "{server}": "Server name",
23
+ "{member_count}": "Member count",
24
+ "{timestamp}": "Time of sending",
25
+ "{server_icon}": "Server icon (image)",
26
+ "{server_banner}": "Server banner (image)",
27
+ }
28
+
29
+
30
+ def _safe(text):
31
+ return discord.utils.escape_mentions(discord.utils.escape_markdown(text))
32
+
33
+
34
+ UNKNOWN = "unknown"
35
+
36
+
37
+ def _number(value):
38
+ return f"{value:,}"
39
+
40
+
41
+ def _percent(value):
42
+ return f"{value:.1f}%"
43
+
44
+
45
+ def member_rank(member, guild):
46
+ """Position by join date, or None while the member list isn't fully cached."""
47
+ if member.joined_at is None or not guild.chunked:
48
+ return None
49
+ return 1 + sum(
50
+ 1 for other in guild.members if other.joined_at and other.joined_at < member.joined_at
51
+ )
52
+
53
+
54
+ def member_values(member, guild):
55
+ values = {
56
+ "{username}": _safe(member.name),
57
+ "{nickname}": _safe(member.display_name),
58
+ "{user}": _safe(member.display_name),
59
+ "{mention}": member.mention,
60
+ }
61
+ joined = member.joined_at
62
+ values["{member_since}"] = f"<t:{int(joined.timestamp())}:D>" if joined else UNKNOWN
63
+ rank = member_rank(member, guild)
64
+ total = len(guild.members)
65
+ if rank is None or not total:
66
+ values["{member_number}"] = values["{early_bird}"] = UNKNOWN
67
+ else:
68
+ values["{member_number}"] = _number(rank)
69
+ values["{early_bird}"] = _percent((total - rank) / total * 100)
70
+ return values
71
+
72
+
73
+ def variable_values(member=None, guild=None):
74
+ server = member.guild if member else guild
75
+ values = dict.fromkeys(MEMBER_KEYS, "")
76
+ if server:
77
+ values.update(
78
+ {
79
+ "{server}": _safe(server.name),
80
+ "{member_count}": (
81
+ _number(server.member_count) if server.member_count is not None else UNKNOWN
82
+ ),
83
+ "{timestamp}": f"<t:{int(discord.utils.utcnow().timestamp())}:f>",
84
+ "{server_icon}": str(server.icon.url) if server.icon else "",
85
+ "{server_banner}": str(server.banner.url) if server.banner else "",
86
+ }
87
+ )
88
+ if member:
89
+ values.update(member_values(member, server))
90
+ return values
91
+
92
+
93
+ def fill_variables(text, member=None, guild=None, extra=None):
94
+ """Replace variables. Unknown values stay unknown instead of being guessed."""
95
+ values = variable_values(member, guild)
96
+ if extra:
97
+ values.update(extra)
98
+ return re.sub(r"\{\w+\}", lambda match: values.get(match[0], match[0]), text)
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: discomposer
3
+ Version: 0.1.0
4
+ Summary: Validate, fetch and send Discord message templates as Components V2 with discord.py.
5
+ Project-URL: Homepage, https://github.com/caneabi/discomposer
6
+ Project-URL: Editor, https://editor.caneabi.dev
7
+ Project-URL: Documentation, https://github.com/caneabi/discomposer/tree/main/packages/python#readme
8
+ Project-URL: Format, https://github.com/caneabi/discomposer/blob/main/spec/FORMAT.md
9
+ Project-URL: Issues, https://github.com/caneabi/discomposer/issues
10
+ Author: Cane
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: components-v2,discord,discord.py,embeds,message-builder,templates,tickets
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Framework :: AsyncIO
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Communications :: Chat
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: aiohttp<4,>=3.9
26
+ Requires-Dist: discord-py<3,>=2.6
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest-asyncio<2,>=0.24; extra == 'test'
29
+ Requires-Dist: pytest<10,>=8; extra == 'test'
30
+ Description-Content-Type: text/markdown
31
+
32
+ <h1 align="center">discomposer for discord.py</h1>
33
+
34
+ <p align="center">
35
+ Validate, fetch and send Discord message templates as Components V2.<br>
36
+ Design the message in the <a href="https://editor.caneabi.dev">visual editor</a>, send it from your bot with a code.
37
+ </p>
38
+
39
+ <p align="center">
40
+ <a href="https://pypi.org/project/discomposer/"><img src="https://img.shields.io/pypi/v/discomposer?color=7b61ff" alt="PyPI"></a>
41
+ <a href="https://pypi.org/project/discomposer/"><img src="https://img.shields.io/pypi/pyversions/discomposer?color=7b61ff" alt="Python versions"></a>
42
+ <a href="https://github.com/caneabi/discomposer/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-ff7a59" alt="MIT license"></a>
43
+ </p>
44
+
45
+ discomposer only builds messages. What a button or selection does (open a ticket, hand out a role) stays in your bot.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install discomposer
51
+ ```
52
+
53
+ Requires Python 3.11+ and discord.py 2.6+.
54
+
55
+ ## Fetch a template and send it
56
+
57
+ ```python
58
+ import discord
59
+ from discomposer import EditorClient, MediaStore, render_message, select_rows
60
+
61
+ editor = EditorClient(media=MediaStore("data/media"))
62
+
63
+ async def send(channel, code):
64
+ design = await editor.fetch(code) # validates and keeps a copy of every image
65
+ view = discord.ui.LayoutView(timeout=None)
66
+ for item in render_message(
67
+ design,
68
+ guild=channel.guild,
69
+ buttons={"ticket": lambda b: discord.ui.Button(label=b["label"], custom_id="mybot:ticket")},
70
+ slots={"online": "12 members online"},
71
+ selects=lambda menu, guild: select_rows(menu, f"mybot:{menu['id']}"),
72
+ ):
73
+ view.add_item(item)
74
+ await channel.send(view=view, files=editor.media.files_for(design))
75
+ ```
76
+
77
+ `render_message` returns the text outside the box (if any) and the filled container. Use `render` to fill a container you created yourself.
78
+
79
+ ## Offer your bot's features in the editor
80
+
81
+ ```python
82
+ from discomposer import guild_context
83
+
84
+ link = await editor.open({
85
+ "title": "Ticket panel",
86
+ "context": {
87
+ **guild_context(guild),
88
+ "buttons": [{"id": "ticket", "label": "Open ticket"}],
89
+ "selects": [{"purpose": "ticket_categories", "label": "Ticket categories", "roles": "optional"}],
90
+ },
91
+ "design": {"blocks": [{"text": "## Support"}]},
92
+ })
93
+ ```
94
+
95
+ The person designs the message at `link` and gives your bot the new code.
96
+
97
+ ## Handle a selection
98
+
99
+ Values and roles only ever come from the stored template, never from the interaction:
100
+
101
+ ```python
102
+ from discomposer import category_roles, chosen_category, ticket_overwrites
103
+
104
+ option = chosen_category(saved_design, "tickets", interaction.data["values"][0])
105
+ if option:
106
+ roles = category_roles(guild, option)
107
+ channel = await guild.create_text_channel(
108
+ f"ticket-{interaction.user.name}",
109
+ overwrites=ticket_overwrites(guild, interaction.user, roles),
110
+ )
111
+ ```
112
+
113
+ ## API overview
114
+
115
+ | Area | Functions |
116
+ | --- | --- |
117
+ | Format | `validate`, `validate_design`, `find_select`, `selects_for`, `chosen_options` |
118
+ | Sending | `render`, `render_message`, `outside_text`, `select_rows`, `fill_variables`, `VARIABLES` |
119
+ | Editor | `EditorClient` (`open`, `fetch`, `fetch_document`, `upload`, `adopt_images`, `expose_attachments`, `open_message`), `guild_context`, `parse_code` |
120
+ | Images | `MediaStore` (`save`, `files_for`) |
121
+ | Tickets | `ticket_categories`, `chosen_category`, `category_roles`, `missing_roles`, `ticket_overwrites`, `transcript_markdown` |
122
+
123
+ Errors: `TemplateError` (format), `EditorError` (editor, network), `MediaError` (images). Messages are written for end users.
124
+
125
+ The template format is specified in [spec/FORMAT.md](https://github.com/caneabi/discomposer/blob/main/spec/FORMAT.md). A JavaScript version for discord.js is available as [`discomposer` on npm](https://www.npmjs.com/package/discomposer).
126
+
127
+ discomposer is not affiliated with or endorsed by Discord Inc.
@@ -0,0 +1,12 @@
1
+ discomposer/__init__.py,sha256=8ZgeTYqxNawWjG5BTjxXFO04ySdeZR6I-7iZfOUXaRs,1500
2
+ discomposer/editor.py,sha256=tOWPrvMsDDfbyrcmC7nDpNK4DJkcHyXlyKV9lJ6n3xQ,11889
3
+ discomposer/format.py,sha256=xeiOXQfSokATFfMishHZOW63Z0JyZ9E-ua5IAZHxrRU,13172
4
+ discomposer/media.py,sha256=ZwDu39_4EyoKELoOhKiNDZOeS_G5sBTr_3q5P0Z65Ns,2324
5
+ discomposer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ discomposer/render.py,sha256=bcYx8y-UON2UizLMs7wC3LaFctKPcYCqYwQr9219G1w,6871
7
+ discomposer/tickets.py,sha256=32R6I1H-YN48YENAiEeywyELPUTr1W2AWAGPW03Nk-s,3435
8
+ discomposer/variables.py,sha256=JOkyTeNPMl2wslRuhuQjTuWJuLTG-zQOqC8A05y6ysc,2925
9
+ discomposer-0.1.0.dist-info/METADATA,sha256=XRsRMYAjCVUoGYAfVP4v0qepWR--fEHND_uymCqX2VY,5224
10
+ discomposer-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ discomposer-0.1.0.dist-info/licenses/LICENSE,sha256=4B1LJEjLK2TbWCnqS1KPKDsw3FDfJ_988OpCMhmlres,1061
12
+ discomposer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cane
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.