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.
- discomposer/__init__.py +72 -0
- discomposer/editor.py +266 -0
- discomposer/format.py +350 -0
- discomposer/media.py +67 -0
- discomposer/py.typed +0 -0
- discomposer/render.py +210 -0
- discomposer/tickets.py +95 -0
- discomposer/variables.py +98 -0
- discomposer-0.1.0.dist-info/METADATA +127 -0
- discomposer-0.1.0.dist-info/RECORD +12 -0
- discomposer-0.1.0.dist-info/WHEEL +4 -0
- discomposer-0.1.0.dist-info/licenses/LICENSE +21 -0
discomposer/__init__.py
ADDED
|
@@ -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)
|
discomposer/variables.py
ADDED
|
@@ -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,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.
|