AdvancedTagScript 3.2.4__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.
Files changed (48) hide show
  1. TagScriptEngine/__init__.py +227 -0
  2. TagScriptEngine/_warnings.py +88 -0
  3. TagScriptEngine/adapter/__init__.py +50 -0
  4. TagScriptEngine/adapter/discordadapters.py +596 -0
  5. TagScriptEngine/adapter/functionadapter.py +23 -0
  6. TagScriptEngine/adapter/intadapter.py +22 -0
  7. TagScriptEngine/adapter/objectadapter.py +35 -0
  8. TagScriptEngine/adapter/redbotadapters.py +161 -0
  9. TagScriptEngine/adapter/stringadapter.py +47 -0
  10. TagScriptEngine/block/__init__.py +130 -0
  11. TagScriptEngine/block/allowedmentions.py +60 -0
  12. TagScriptEngine/block/assign.py +43 -0
  13. TagScriptEngine/block/breakblock.py +41 -0
  14. TagScriptEngine/block/case.py +63 -0
  15. TagScriptEngine/block/command.py +141 -0
  16. TagScriptEngine/block/comment.py +29 -0
  17. TagScriptEngine/block/control.py +149 -0
  18. TagScriptEngine/block/cooldown.py +95 -0
  19. TagScriptEngine/block/count.py +68 -0
  20. TagScriptEngine/block/embedblock.py +306 -0
  21. TagScriptEngine/block/fiftyfifty.py +34 -0
  22. TagScriptEngine/block/helpers.py +164 -0
  23. TagScriptEngine/block/loosevariablegetter.py +40 -0
  24. TagScriptEngine/block/mathblock.py +164 -0
  25. TagScriptEngine/block/randomblock.py +51 -0
  26. TagScriptEngine/block/range.py +56 -0
  27. TagScriptEngine/block/redirect.py +42 -0
  28. TagScriptEngine/block/replaceblock.py +110 -0
  29. TagScriptEngine/block/require_blacklist.py +79 -0
  30. TagScriptEngine/block/shortcutredirect.py +23 -0
  31. TagScriptEngine/block/stopblock.py +38 -0
  32. TagScriptEngine/block/strf.py +70 -0
  33. TagScriptEngine/block/strictvariablegetter.py +38 -0
  34. TagScriptEngine/block/substr.py +25 -0
  35. TagScriptEngine/block/urlencodeblock.py +41 -0
  36. TagScriptEngine/exceptions.py +105 -0
  37. TagScriptEngine/interface/__init__.py +14 -0
  38. TagScriptEngine/interface/adapter.py +75 -0
  39. TagScriptEngine/interface/block.py +124 -0
  40. TagScriptEngine/interpreter.py +502 -0
  41. TagScriptEngine/py.typed +0 -0
  42. TagScriptEngine/utils.py +71 -0
  43. TagScriptEngine/verb.py +160 -0
  44. advancedtagscript-3.2.4.dist-info/METADATA +99 -0
  45. advancedtagscript-3.2.4.dist-info/RECORD +48 -0
  46. advancedtagscript-3.2.4.dist-info/WHEEL +5 -0
  47. advancedtagscript-3.2.4.dist-info/licenses/LICENSE +1 -0
  48. advancedtagscript-3.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,29 @@
1
+ from typing import Optional, Tuple
2
+ from ..interface import Block
3
+ from ..interpreter import Context
4
+
5
+
6
+ class CommentBlock(Block):
7
+ """
8
+ The comment block is just for comments, it will not be parsed,
9
+ however it will be removed from your tag's output.
10
+
11
+ **Usage:** ``{comment([other]):[text]}``
12
+
13
+ **Aliases:** /, Comment, comment, //, #
14
+
15
+ **Payload:** ``text``
16
+
17
+ **Parameter:** ``other``
18
+
19
+ .. tagscript::
20
+
21
+ {#:Comment!}
22
+
23
+ {Comment(Something):Comment!}
24
+ """
25
+
26
+ ACCEPTED_NAMES: Tuple[str, ...] = ("/", "Comment", "comment", "//", "#")
27
+
28
+ def process(self, ctx: Context) -> Optional[str]:
29
+ return ""
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, Tuple, cast
4
+
5
+ from ..interface import verb_required_block, Block
6
+ from ..interpreter import Context
7
+ from . import helper_parse_if, helper_parse_list_if, helper_split
8
+
9
+
10
+ __all__: Tuple[str, ...] = ("AnyBlock", "AllBlock", "IfBlock")
11
+
12
+
13
+ def parse_into_output(payload: str, result: Optional[bool]) -> Optional[str]:
14
+ if result is None:
15
+ return
16
+ try:
17
+ output = helper_split(payload, False)
18
+ if output is not None and len(output) == 2:
19
+ if result:
20
+ return output[0]
21
+ else:
22
+ return output[1]
23
+ elif result:
24
+ return payload
25
+ else:
26
+ return ""
27
+ except: # noqa: E722
28
+ return
29
+
30
+
31
+ ImplicitPPRBlock: Block = verb_required_block(True, payload=True, parameter=True)
32
+
33
+
34
+ class AnyBlock(ImplicitPPRBlock): # type: ignore
35
+ """
36
+ The any block checks that any of the passed expressions are true.
37
+ Multiple expressions can be passed to the parameter by splitting them with ``|``.
38
+
39
+ The payload is a required message that must be split by ``|``.
40
+ If the expression evaluates true, then the message before the ``|`` is returned, else the message after is returned.
41
+
42
+ **Usage:** ``{any(<expression|expression|...>):<message>}``
43
+
44
+ **Aliases:** ``or``
45
+
46
+ **Payload:** message
47
+
48
+ **Parameter:** expression
49
+
50
+ **Examples:** ::
51
+
52
+ {any({args}==hi|{args}==hello|{args}==heyy):Hello {user}!|How rude.}
53
+ # if {args} is hi
54
+ Hello sravan#0001!
55
+
56
+ # if {args} is what's up!
57
+ How rude.
58
+ """
59
+
60
+ ACCEPTED_NAMES: Tuple[str, ...] = ("any", "or")
61
+
62
+ def process(self, ctx: Context) -> Optional[str]:
63
+ result = any(helper_parse_list_if(ctx.verb.parameter) or [])
64
+ return parse_into_output(cast(str, ctx.verb.payload), result)
65
+
66
+
67
+ class AllBlock(ImplicitPPRBlock): # type: ignore
68
+ """
69
+ The all block checks that all of the passed expressions are true.
70
+ Multiple expressions can be passed to the parameter by splitting them with ``|``.
71
+
72
+ The payload is a required message that must be split by ``|``.
73
+ If the expression evaluates true, then the message before the ``|`` is returned, else the message after is returned.
74
+
75
+ **Usage:** ``{all(<expression|expression|...>):<message>}``
76
+
77
+ **Aliases:** ``and``
78
+
79
+ **Payload:** message
80
+
81
+ **Parameter:** expression
82
+
83
+ **Examples:** ::
84
+
85
+ {all({args}>=100|{args}<=1000):You picked {args}.|You must provide a number between 100 and 1000.}
86
+ # if {args} is 52
87
+ You must provide a number between 100 and 1000.
88
+
89
+ # if {args} is 282
90
+ You picked 282.
91
+ """
92
+
93
+ ACCEPTED_NAMES: Tuple[str, ...] = ("all", "and")
94
+
95
+ def process(self, ctx: Context) -> Optional[str]:
96
+ result = all(helper_parse_list_if(ctx.verb.parameter) or [])
97
+ return parse_into_output(cast(str, ctx.verb.payload), result)
98
+
99
+
100
+ class IfBlock(ImplicitPPRBlock): # type: ignore
101
+ """
102
+ The if block returns a message based on the passed expression to the parameter.
103
+ An expression is represented by two values compared with an operator.
104
+
105
+ The payload is a required message that must be split by ``|``.
106
+ If the expression evaluates true, then the message before the ``|`` is returned, else the message after is returned.
107
+
108
+ **Expression Operators:**
109
+
110
+ +----------+--------------------------+---------+---------------------------------------------+
111
+ | Operator | Check | Example | Description |
112
+ +==========+==========================+=========+=============================================+
113
+ | ``==`` | equality | a==a | value 1 is equal to value 2 |
114
+ +----------+--------------------------+---------+---------------------------------------------+
115
+ | ``!=`` | inequality | a!=b | value 1 is not equal to value 2 |
116
+ +----------+--------------------------+---------+---------------------------------------------+
117
+ | ``>`` | greater than | 5>3 | value 1 is greater than value 2 |
118
+ +----------+--------------------------+---------+---------------------------------------------+
119
+ | ``<`` | less than | 4<8 | value 1 is less than value 2 |
120
+ +----------+--------------------------+---------+---------------------------------------------+
121
+ | ``>=`` | greater than or equality | 10>=10 | value 1 is greater than or equal to value 2 |
122
+ +----------+--------------------------+---------+---------------------------------------------+
123
+ | ``<=`` | less than or equality | 5<=6 | value 1 is less than or equal to value 2 |
124
+ +----------+--------------------------+---------+---------------------------------------------+
125
+
126
+ **Usage:** ``{if(<expression>):<message>]}``
127
+
128
+ **Payload:** message
129
+
130
+ **Parameter:** expression
131
+
132
+ **Examples:** ::
133
+
134
+ {if({args}==63):You guessed it! The number I was thinking of was 63!|Too {if({args}<63):low|high}, try again.}
135
+ # if args is 63
136
+ # You guessed it! The number I was thinking of was 63!
137
+
138
+ # if args is 73
139
+ # Too low, try again.
140
+
141
+ # if args is 14
142
+ # Too high, try again.
143
+ """
144
+
145
+ ACCEPTED_NAMES: Tuple[str, ...] = ("if",)
146
+
147
+ def process(self, ctx: Context) -> Optional[str]:
148
+ result = helper_parse_if(cast(str, ctx.verb.parameter))
149
+ return parse_into_output(cast(str, ctx.verb.payload), result)
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Dict, List, Optional, Tuple, cast
5
+
6
+ from discord.ext.commands import Cooldown, CooldownMapping
7
+
8
+ from ..exceptions import CooldownExceeded
9
+ from ..interface import verb_required_block
10
+ from ..interpreter import Context
11
+ from .helpers import helper_split
12
+
13
+ __all__: Tuple[str, ...] = ("CooldownBlock",)
14
+
15
+
16
+ class CooldownBlock(verb_required_block(True, payload=True, parameter=True)): # type: ignore
17
+ """
18
+ The cooldown block implements cooldowns when running a tag.
19
+ The parameter requires 2 values to be passed: ``rate`` and ``per`` integers.
20
+ The ``rate`` is the number of times the tag can be used every ``per`` seconds.
21
+
22
+ The payload requires a ``key`` value, which is the key used to store the cooldown.
23
+ A key should be any string that is unique. If a channel's ID is passed as a key,
24
+ the tag's cooldown will be enforced on that channel. Running the tag in a separate channel
25
+ would have a different cooldown with the same ``rate`` and ``per`` values.
26
+
27
+ The payload also has an optional ``message`` value, which is the message to be sent when the
28
+ cooldown is exceeded. If no message is passed, the default message will be sent instead.
29
+ The cooldown message supports 2 blocks: ``key`` and ``retry_after``.
30
+
31
+ **Usage:** ``{cooldown(<rate>|<per>):<key>|[message]}``
32
+
33
+ **Payload:** key, message
34
+
35
+ **Parameter:** rate, per
36
+
37
+ **Examples:** ::
38
+
39
+ {cooldown(1|10):{author(id)}}
40
+ # the tag author used the tag more than once in 10 seconds
41
+ # The bucket for 741074175875088424 has reached its cooldown. Retry in 3.25 seconds."
42
+
43
+ {cooldown(3|3):{channel(id)}|Slow down! This tag can only be used 3 times per 3 seconds per channel. Try again in **{retry_after}** seconds."}
44
+ # the tag was used more than 3 times in 3 seconds in a channel
45
+ # Slow down! This tag can only be used 3 times per 3 seconds per channel. Try again in **0.74** seconds.
46
+ """
47
+
48
+ ACCEPTED_NAMES: Tuple[str, ...] = ("cooldown",)
49
+ COOLDOWNS: Dict[Any, CooldownMapping] = {}
50
+
51
+ @classmethod
52
+ def create_cooldown(cls, key: Any, rate: float, per: int) -> CooldownMapping:
53
+ cooldown = CooldownMapping.from_cooldown(rate, per, lambda x: x)
54
+ cls.COOLDOWNS[key] = cooldown
55
+ return cooldown
56
+
57
+ def process(self, ctx: Context) -> Optional[str]:
58
+ verb = ctx.verb
59
+ try:
60
+ rate, per = cast(List[str], helper_split(cast(str, verb.parameter), maxsplit=1))
61
+ per = int(per)
62
+ rate = float(rate)
63
+ except (ValueError, TypeError):
64
+ return
65
+
66
+ if split := helper_split(cast(str, verb.payload), False, maxsplit=1):
67
+ key, message = split
68
+ else:
69
+ key = verb.payload
70
+ message = None
71
+
72
+ cooldown_key = ctx.response.extra_kwargs.get("cooldown_key")
73
+ if cooldown_key is None:
74
+ cooldown_key = ctx.original_message
75
+ try:
76
+ cooldown = self.COOLDOWNS[cooldown_key]
77
+ base = cast(Cooldown, cooldown._cooldown)
78
+ if (rate, per) != (base.rate, base.per):
79
+ cooldown = self.create_cooldown(cooldown_key, rate, per)
80
+ except KeyError:
81
+ cooldown = self.create_cooldown(cooldown_key, rate, per)
82
+
83
+ current = time.time()
84
+ bucket = cast(Cooldown, cooldown.get_bucket(key, current))
85
+ retry_after = bucket.update_rate_limit(current)
86
+ if retry_after:
87
+ retry_after = round(retry_after, 2)
88
+ if message:
89
+ message = message.replace("{key}", str(key)).replace(
90
+ "{retry_after}", str(retry_after)
91
+ )
92
+ else:
93
+ message = f"The bucket for {key} has reached its cooldown. Retry in {retry_after} seconds."
94
+ raise CooldownExceeded(message, bucket, cast(str, key), retry_after)
95
+ return ""
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, Tuple, cast
4
+
5
+ from ..interface import verb_required_block
6
+ from ..interpreter import Context
7
+
8
+
9
+ __all__: Tuple[str, ...] = ("CountBlock", "LengthBlock")
10
+
11
+
12
+ class CountBlock(verb_required_block(True, payload=True)): # type: ignore
13
+ """
14
+ The count block will count how much of text is in message.
15
+ This is case sensitive and will include substrings, if you
16
+ don't provide a parameter, it will count the spaces in the
17
+ message.
18
+
19
+ **Usage:** ``{count([text]):<message>}``
20
+
21
+ **Aliases:** ``None``
22
+
23
+ **Payload:** ``message``
24
+
25
+ **Parameter:** text
26
+
27
+ .. tagscript::
28
+ {count(Tag):TagScriptEngine}
29
+ # 1
30
+
31
+ {count(Tag): Tag Script Engine TagScriptEngine}
32
+ # 2
33
+ """
34
+
35
+ ACCEPTED_NAMES: Tuple[str, ...] = ("count",)
36
+
37
+ def process(self, ctx: Context) -> Optional[str]:
38
+ if ctx.verb.parameter:
39
+ payload: str = cast(str, ctx.verb.payload)
40
+ return str(payload.count(ctx.verb.parameter))
41
+ return str(len(cast(str, ctx.verb.payload)) + 1)
42
+
43
+
44
+ class LengthBlock(verb_required_block(True, payload=True)): # type: ignore
45
+ """
46
+ The length block will check the length of the given String.
47
+ If a parameter is passed in, the block will check the length
48
+ based on what you passed in, w for word, s for spaces.
49
+ If you provide an invalid parameter, the block will return -1.
50
+
51
+ **Usage:** ``{length(<text>)}``
52
+
53
+ **Aliases:** ``len``
54
+
55
+ **Payload:** None
56
+
57
+ **Parameter:** ``text``
58
+
59
+ .. tagscript::
60
+
61
+ {len("TagScriptEngine")}
62
+ 15
63
+ """
64
+
65
+ ACCEPTED_NAMES: Tuple[str, ...] = ("length", "len")
66
+
67
+ def process(self, ctx: Context) -> Optional[str]:
68
+ return str(len(ctx.verb.parameter)) if ctx.verb.parameter else "-1"
@@ -0,0 +1,306 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from inspect import ismethod
5
+ from typing import Any, Dict, List, Optional, Tuple, Union, cast
6
+
7
+ from discord import Colour, Embed
8
+
9
+ from ..interface import Block
10
+ from ..interpreter import Context
11
+ from .helpers import helper_split, easier_helper_split, implicit_bool
12
+ from ..utils import truncate
13
+ from ..exceptions import BadColourArgument, EmbedParseError
14
+ from .._warnings import removal
15
+
16
+ try:
17
+ import orjson # noqa: F401
18
+ except ModuleNotFoundError:
19
+ _has_orjson: bool = False
20
+ else:
21
+ _has_orjson: bool = True
22
+
23
+
24
+ __all__: Tuple[str, ...] = ("EmbedBlock",)
25
+
26
+
27
+ if _has_orjson:
28
+ _from_json = orjson.loads # type: ignore
29
+ else:
30
+ _from_json = json.loads
31
+
32
+
33
+ def string_to_color(argument: str) -> Colour:
34
+ arg = argument.replace("0x", "").lower()
35
+
36
+ if arg[0] == "#":
37
+ arg = arg[1:]
38
+ try:
39
+ value = int(arg, base=16)
40
+ if not (0 <= value <= 0xFFFFFF):
41
+ raise BadColourArgument(arg)
42
+ return Colour(value=value)
43
+ except ValueError:
44
+ arg = arg.replace(" ", "_")
45
+ method = getattr(Colour, arg, None)
46
+ if arg.startswith("from_") or method is None or not ismethod(method):
47
+ raise BadColourArgument(arg)
48
+ return method()
49
+
50
+
51
+ def set_color(embed: Embed, attribute: str, value: str) -> None:
52
+ value = string_to_color(value) # type: ignore
53
+ setattr(embed, attribute, value)
54
+
55
+
56
+ def set_dynamic_url(embed: Embed, attribute: str, value: str) -> None:
57
+ method = getattr(embed, f"set_{attribute}")
58
+ method(url=value)
59
+
60
+
61
+ def add_field(embed: Embed, _: str, payload: str) -> None:
62
+ if (data := easier_helper_split(payload, maxsplit=3)) is None: # type: ignore
63
+ raise EmbedParseError("`add_field` payload was not split by |")
64
+ try:
65
+ name, value, _inline = data
66
+ inline = implicit_bool(_inline)
67
+ if inline is None:
68
+ raise EmbedParseError(
69
+ "`inline` argument for `add_field` is not a boolean value (_inline)"
70
+ )
71
+ except ValueError:
72
+ name, value = cast(List[str], helper_split(payload, 2)) # type: ignore
73
+ inline = False
74
+ name = truncate(name, max=FIELD_LIMITS["field.name"])
75
+ value = truncate(value, max=FIELD_LIMITS["field.value"])
76
+ embed.add_field(name=name, value=value, inline=inline)
77
+
78
+
79
+ def set_footer(embed: Embed, _: str, payload: str) -> None:
80
+ data = easier_helper_split(payload, maxsplit=2) # type: ignore
81
+ if data is None:
82
+ embed.set_footer(text=truncate(payload, max=FIELD_LIMITS["footer.text"]))
83
+ else:
84
+ text, icon_url = data
85
+ embed.set_footer(text=truncate(text, max=FIELD_LIMITS["footer.text"]), icon_url=icon_url)
86
+
87
+
88
+ # Discord embed field character limits
89
+ # https://docs.discord.com/developers/resources/message#embed-object-embed-limits
90
+ FIELD_LIMITS: Dict[str, int] = {
91
+ "title": 256,
92
+ "description": 4096,
93
+ "footer.text": 2048,
94
+ "author.name": 256,
95
+ "field.name": 256,
96
+ "field.value": 1024,
97
+ }
98
+
99
+
100
+ class EmbedBlock(Block):
101
+ """
102
+ An embed block will send an embed in the tag response.
103
+ There are two ways to use the embed block, either by using properly
104
+ formatted embed JSON from an embed generator or manually inputting
105
+ the accepted embed attributes.
106
+
107
+ **JSON**
108
+
109
+ Using JSON to create an embed offers complete embed customization.
110
+ Multiple embed generators are available online to visualize and generate
111
+ embed JSON.
112
+
113
+ **Usage:** ``{embed(<json>)}``
114
+
115
+ **Payload:** None
116
+
117
+ **Parameter:** json
118
+
119
+ **Examples:** ::
120
+
121
+ {embed({"title":"Hello!", "description":"This is a test embed."})}
122
+ {embed({
123
+ "title":"Here's a random duck!",
124
+ "image":{"url":"https://random-d.uk/api/randomimg"},
125
+ "color":15194415
126
+ })}
127
+
128
+ **Manual**
129
+
130
+ The following embed attributes can be set manually:
131
+
132
+ * ``title``
133
+ * ``description``
134
+ * ``color``
135
+ * ``url``
136
+ * ``thumbnail``
137
+ * ``image``
138
+ * ``footer``
139
+ * ``field`` - (See below)
140
+
141
+ Adding a field to an embed requires the payload to be split by ``|``,
142
+ ``;`` or ``,`` into either 2 or 3 parts. The first part is the name
143
+ of the field, the second is the text of the field, and the third
144
+ optionally specifies whether the field should be inline.
145
+
146
+ **Usage:** ``{embed(<attribute>):<value>}``
147
+
148
+ **Payload:** value
149
+
150
+ **Parameter:** attribute
151
+
152
+ **Examples:** ::
153
+
154
+ {embed(color):#37b2cb}
155
+ {embed(title):Rules}
156
+ {embed(description):Follow these rules to ensure a good experience in our server!}
157
+ {embed(field):Rule 1|Respect everyone you speak to.|false}
158
+ {embed(footer):Thanks for reading!|{guild(icon)}}
159
+
160
+ Both methods can be combined to create an embed in a tag.
161
+ The following tagscript uses JSON to create an embed with fields and later
162
+ set the embed title.
163
+
164
+ ::
165
+
166
+ {embed(title):my embed title}
167
+ {embed({{
168
+ "fields": [
169
+ {
170
+ "name": "Field 1",
171
+ "value": "field description",
172
+ "inline": false
173
+ }
174
+ ]
175
+ })}
176
+ """
177
+
178
+ ACCEPTED_NAMES: Tuple[str, ...] = ("embed",)
179
+
180
+ ATTRIBUTE_HANDLERS: Dict[str, Any] = {
181
+ "description": setattr,
182
+ "title": setattr,
183
+ "color": set_color,
184
+ "colour": set_color,
185
+ "url": setattr,
186
+ "thumbnail": set_dynamic_url,
187
+ "image": set_dynamic_url,
188
+ "field": add_field,
189
+ "footer": set_footer,
190
+ }
191
+
192
+ @removal(
193
+ name="EmbedBlock",
194
+ reason=(
195
+ "One of EmbedBlock's trait is scheduled to be removed in the next minor release, "
196
+ "A minor exception handling which would restrict the embed from getting sent and "
197
+ "would raise TagScriptEngine.exceptions.EmbedParseError incase it had more than "
198
+ "6000 characters, to know more about the limitations of discord embeds refer to the "
199
+ "[Official Discord API Docs](https://discord.com/developers/docs/resources/channel#embed-object-embed-limits)."
200
+ ),
201
+ version="3.2.0",
202
+ )
203
+ def __init__(self) -> None:
204
+ super().__init__()
205
+
206
+ @staticmethod
207
+ def get_embed(ctx: Context) -> Embed:
208
+ return ctx.response.actions.get("embed", Embed())
209
+
210
+ @staticmethod
211
+ def value_to_color(value: Optional[Union[int, str]]) -> Colour:
212
+ if value is None or isinstance(value, Colour):
213
+ return value # type: ignore
214
+ if isinstance(value, int):
215
+ return Colour(value)
216
+ elif isinstance(value, str):
217
+ return string_to_color(value)
218
+ else:
219
+ raise EmbedParseError("Received invalid type for color key (expected string or int)")
220
+
221
+ def text_to_embed(self, text: str) -> Embed:
222
+ try:
223
+ data = _from_json(text)
224
+ except (json.decoder.JSONDecodeError, ValueError) as error:
225
+ raise EmbedParseError(error) from error
226
+
227
+ if data.get("embed"):
228
+ data = data["embed"]
229
+ if data.get("timestamp"):
230
+ data["timestamp"] = data["timestamp"].strip("Z")
231
+
232
+ color = data.pop("color", data.pop("colour", None))
233
+
234
+ try:
235
+ embed = Embed.from_dict(data)
236
+ except Exception as error:
237
+ raise EmbedParseError(error) from error
238
+ else:
239
+ if color := self.value_to_color(color):
240
+ embed.color = color
241
+ self._truncate_embed_fields(embed)
242
+ return embed
243
+
244
+ @classmethod
245
+ def update_embed(cls, embed: Embed, attribute: str, value: str) -> Embed:
246
+ # Truncate value to Discord's per-field limit before setting
247
+ if attribute in FIELD_LIMITS:
248
+ value = truncate(value, max=FIELD_LIMITS[attribute])
249
+ handler = cls.ATTRIBUTE_HANDLERS[attribute]
250
+ try:
251
+ handler(embed, attribute, value)
252
+ except Exception as error:
253
+ raise EmbedParseError(error) from error
254
+ return embed
255
+
256
+ @staticmethod
257
+ def _truncate_embed_fields(embed: Embed) -> None:
258
+ """Truncate embed fields to Discord's per-field character limits."""
259
+ if embed.title and len(embed.title) > FIELD_LIMITS["title"]:
260
+ embed.title = truncate(embed.title, max=FIELD_LIMITS["title"])
261
+ if embed.description and len(embed.description) > FIELD_LIMITS["description"]:
262
+ embed.description = truncate(embed.description, max=FIELD_LIMITS["description"])
263
+ if embed.footer and embed.footer.text and len(embed.footer.text) > FIELD_LIMITS["footer.text"]:
264
+ embed.set_footer(text=truncate(embed.footer.text, max=FIELD_LIMITS["footer.text"]), icon_url=embed.footer.icon_url)
265
+ if embed.author and embed.author.name and len(embed.author.name) > FIELD_LIMITS["author.name"]:
266
+ embed.set_author(name=truncate(embed.author.name, max=FIELD_LIMITS["author.name"]), url=embed.author.url, icon_url=embed.author.icon_url)
267
+ for field in embed.fields:
268
+ if field.name and len(field.name) > FIELD_LIMITS["field.name"]:
269
+ idx = embed.fields.index(field)
270
+ embed.set_field_at(idx, name=truncate(field.name, max=FIELD_LIMITS["field.name"]), value=field.value, inline=field.inline)
271
+ if field.value and len(field.value) > FIELD_LIMITS["field.value"]:
272
+ idx = embed.fields.index(field)
273
+ embed.set_field_at(idx, name=field.name, value=truncate(field.value, max=FIELD_LIMITS["field.value"]), inline=field.inline)
274
+
275
+ @staticmethod
276
+ def return_error(error: Exception) -> str:
277
+ return f"Embed Parse Error: {error}"
278
+
279
+ @staticmethod
280
+ def return_embed(ctx: Context, embed: Embed) -> str:
281
+ try:
282
+ length = len(embed)
283
+ except Exception as error:
284
+ return str(error)
285
+ if length > 6000:
286
+ return f"`MAX EMBED LENGTH REACHED ({length}/6000)`"
287
+ ctx.response.actions["embed"] = embed
288
+ return ""
289
+
290
+ def process(self, ctx: Context) -> Optional[str]:
291
+ if not ctx.verb.parameter:
292
+ return self.return_embed(ctx, self.get_embed(ctx))
293
+
294
+ lowered = ctx.verb.parameter.lower()
295
+ try:
296
+ if ctx.verb.parameter.startswith("{") and ctx.verb.parameter.endswith("}"):
297
+ embed = self.text_to_embed(ctx.verb.parameter)
298
+ elif lowered in self.ATTRIBUTE_HANDLERS and ctx.verb.payload:
299
+ embed = self.get_embed(ctx)
300
+ embed = self.update_embed(embed, lowered, ctx.verb.payload)
301
+ else:
302
+ return
303
+ except EmbedParseError as error:
304
+ return self.return_error(error)
305
+
306
+ return self.return_embed(ctx, embed)
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from typing import Optional, Tuple
5
+
6
+ from ..interface import verb_required_block
7
+ from ..interpreter import Context
8
+
9
+
10
+ __all__: Tuple[str, ...] = ("FiftyFiftyBlock",)
11
+
12
+
13
+ class FiftyFiftyBlock(verb_required_block(True, payload=True)): # type: ignore
14
+ """
15
+ The fifty-fifty block has a 50% change of returning the payload, and 50% chance of returning null.
16
+
17
+ **Usage:** ``{50:<message>}``
18
+
19
+ **Aliases:** ``5050, ?``
20
+
21
+ **Payload:** message
22
+
23
+ **Parameter:** None
24
+
25
+ **Examples:** ::
26
+
27
+ I pick {if({5050:.}!=):heads|tails}
28
+ # I pick heads
29
+ """
30
+
31
+ ACCEPTED_NAMES: Tuple[str, ...] = ("5050", "50", "?")
32
+
33
+ def process(self, ctx: Context) -> Optional[str]:
34
+ return random.choice(["", ctx.verb.payload])