discomposer 0.1.0__tar.gz

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,8 @@
1
+ node_modules/
2
+ dist/
3
+ .venv/
4
+ __pycache__/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+
@@ -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.
@@ -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,96 @@
1
+ <h1 align="center">discomposer for discord.py</h1>
2
+
3
+ <p align="center">
4
+ Validate, fetch and send Discord message templates as Components V2.<br>
5
+ Design the message in the <a href="https://editor.caneabi.dev">visual editor</a>, send it from your bot with a code.
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="https://pypi.org/project/discomposer/"><img src="https://img.shields.io/pypi/v/discomposer?color=7b61ff" alt="PyPI"></a>
10
+ <a href="https://pypi.org/project/discomposer/"><img src="https://img.shields.io/pypi/pyversions/discomposer?color=7b61ff" alt="Python versions"></a>
11
+ <a href="https://github.com/caneabi/discomposer/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-ff7a59" alt="MIT license"></a>
12
+ </p>
13
+
14
+ discomposer only builds messages. What a button or selection does (open a ticket, hand out a role) stays in your bot.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install discomposer
20
+ ```
21
+
22
+ Requires Python 3.11+ and discord.py 2.6+.
23
+
24
+ ## Fetch a template and send it
25
+
26
+ ```python
27
+ import discord
28
+ from discomposer import EditorClient, MediaStore, render_message, select_rows
29
+
30
+ editor = EditorClient(media=MediaStore("data/media"))
31
+
32
+ async def send(channel, code):
33
+ design = await editor.fetch(code) # validates and keeps a copy of every image
34
+ view = discord.ui.LayoutView(timeout=None)
35
+ for item in render_message(
36
+ design,
37
+ guild=channel.guild,
38
+ buttons={"ticket": lambda b: discord.ui.Button(label=b["label"], custom_id="mybot:ticket")},
39
+ slots={"online": "12 members online"},
40
+ selects=lambda menu, guild: select_rows(menu, f"mybot:{menu['id']}"),
41
+ ):
42
+ view.add_item(item)
43
+ await channel.send(view=view, files=editor.media.files_for(design))
44
+ ```
45
+
46
+ `render_message` returns the text outside the box (if any) and the filled container. Use `render` to fill a container you created yourself.
47
+
48
+ ## Offer your bot's features in the editor
49
+
50
+ ```python
51
+ from discomposer import guild_context
52
+
53
+ link = await editor.open({
54
+ "title": "Ticket panel",
55
+ "context": {
56
+ **guild_context(guild),
57
+ "buttons": [{"id": "ticket", "label": "Open ticket"}],
58
+ "selects": [{"purpose": "ticket_categories", "label": "Ticket categories", "roles": "optional"}],
59
+ },
60
+ "design": {"blocks": [{"text": "## Support"}]},
61
+ })
62
+ ```
63
+
64
+ The person designs the message at `link` and gives your bot the new code.
65
+
66
+ ## Handle a selection
67
+
68
+ Values and roles only ever come from the stored template, never from the interaction:
69
+
70
+ ```python
71
+ from discomposer import category_roles, chosen_category, ticket_overwrites
72
+
73
+ option = chosen_category(saved_design, "tickets", interaction.data["values"][0])
74
+ if option:
75
+ roles = category_roles(guild, option)
76
+ channel = await guild.create_text_channel(
77
+ f"ticket-{interaction.user.name}",
78
+ overwrites=ticket_overwrites(guild, interaction.user, roles),
79
+ )
80
+ ```
81
+
82
+ ## API overview
83
+
84
+ | Area | Functions |
85
+ | --- | --- |
86
+ | Format | `validate`, `validate_design`, `find_select`, `selects_for`, `chosen_options` |
87
+ | Sending | `render`, `render_message`, `outside_text`, `select_rows`, `fill_variables`, `VARIABLES` |
88
+ | Editor | `EditorClient` (`open`, `fetch`, `fetch_document`, `upload`, `adopt_images`, `expose_attachments`, `open_message`), `guild_context`, `parse_code` |
89
+ | Images | `MediaStore` (`save`, `files_for`) |
90
+ | Tickets | `ticket_categories`, `chosen_category`, `category_roles`, `missing_roles`, `ticket_overwrites`, `transcript_markdown` |
91
+
92
+ Errors: `TemplateError` (format), `EditorError` (editor, network), `MediaError` (images). Messages are written for end users.
93
+
94
+ 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).
95
+
96
+ discomposer is not affiliated with or endorsed by Discord Inc.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "discomposer"
7
+ version = "0.1.0"
8
+ description = "Validate, fetch and send Discord message templates as Components V2 with discord.py."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ dependencies = ["discord.py>=2.6,<3", "aiohttp>=3.9,<4"]
14
+ authors = [{ name = "Cane" }]
15
+ keywords = ["discord", "discord.py", "components-v2", "embeds", "templates", "message-builder", "tickets"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Framework :: AsyncIO",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Communications :: Chat",
26
+ "Typing :: Typed",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/caneabi/discomposer"
31
+ Editor = "https://editor.caneabi.dev"
32
+ Documentation = "https://github.com/caneabi/discomposer/tree/main/packages/python#readme"
33
+ Format = "https://github.com/caneabi/discomposer/blob/main/spec/FORMAT.md"
34
+ Issues = "https://github.com/caneabi/discomposer/issues"
35
+
36
+ [project.optional-dependencies]
37
+ test = ["pytest>=8,<10", "pytest-asyncio>=0.24,<2"]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/discomposer"]
41
+
42
+ [tool.pytest.ini_options]
43
+ asyncio_mode = "auto"
44
+ asyncio_default_fixture_loop_scope = "function"
45
+ testpaths = ["tests"]
46
+
47
+ [tool.ruff]
48
+ target-version = "py311"
49
+ line-length = 100
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "I", "UP", "B"]
53
+
@@ -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
+ ]
@@ -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})