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.
- discomposer-0.1.0/.gitignore +8 -0
- discomposer-0.1.0/LICENSE +21 -0
- discomposer-0.1.0/PKG-INFO +127 -0
- discomposer-0.1.0/README.md +96 -0
- discomposer-0.1.0/pyproject.toml +53 -0
- discomposer-0.1.0/src/discomposer/__init__.py +72 -0
- discomposer-0.1.0/src/discomposer/editor.py +266 -0
- discomposer-0.1.0/src/discomposer/format.py +350 -0
- discomposer-0.1.0/src/discomposer/media.py +67 -0
- discomposer-0.1.0/src/discomposer/py.typed +0 -0
- discomposer-0.1.0/src/discomposer/render.py +210 -0
- discomposer-0.1.0/src/discomposer/tickets.py +95 -0
- discomposer-0.1.0/src/discomposer/variables.py +98 -0
- discomposer-0.1.0/tests/test_editor.py +73 -0
- discomposer-0.1.0/tests/test_format.py +46 -0
- discomposer-0.1.0/tests/test_render.py +117 -0
|
@@ -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})
|