├── __init__.py ├── Tests ├── __init__.py ├── test_basic.py ├── test_escapes.py ├── test_adapters.py ├── test_verbs.py └── test_edgecase.py ├── .github └── FUNDING.yml ├── readthedocs.yml ├── setup.py ├── docs ├── the_interpreter.rst ├── blocks_and_adapters.rst ├── verb.rst ├── utils.rst ├── block.rst ├── interface.rst ├── exceptions.rst ├── adapter.rst ├── requirements.txt ├── credits.rst ├── getting_started.rst ├── Makefile ├── interpreter.rst ├── make.bat ├── index.rst ├── conf.py └── user_blocks.rst ├── requirements.txt ├── TagScriptEngine ├── interface │ ├── __init__.py │ ├── adapter.py │ └── block.py ├── adapter │ ├── intadapter.py │ ├── __init__.py │ ├── functionadapter.py │ ├── objectadapter.py │ ├── stringadapter.py │ └── discordadapters.py ├── block │ ├── shortcutredirect.py │ ├── substr.py │ ├── fiftyfifty.py │ ├── stopblock.py │ ├── redirect.py │ ├── assign.py │ ├── strictvariablegetter.py │ ├── loosevariablegetter.py │ ├── breakblock.py │ ├── urlencodeblock.py │ ├── randomblock.py │ ├── __init__.py │ ├── strf.py │ ├── range.py │ ├── require_blacklist.py │ ├── command.py │ ├── replaceblock.py │ ├── helpers.py │ ├── cooldown.py │ ├── mathblock.py │ ├── control.py │ └── embedblock.py ├── utils.py ├── __init__.py ├── exceptions.py ├── verb.py └── interpreter.py ├── pyproject.toml ├── LICENSE ├── Makefile ├── .pre-commit-config.yaml ├── setup.cfg ├── playground.py ├── benchmark.py ├── .gitignore └── README.md /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: PhenoM4n4n 2 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | python: 2 | version: 3.8 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /docs/the_interpreter.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | The Interpreter 3 | =============== 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | autoflake 2 | black 3 | discord.py==2.2.3 4 | isort 5 | pyparsing 6 | -------------------------------------------------------------------------------- /docs/blocks_and_adapters.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | Blocks and Adapters 3 | =================== 4 | -------------------------------------------------------------------------------- /docs/verb.rst: -------------------------------------------------------------------------------- 1 | ==== 2 | Verb 3 | ==== 4 | 5 | .. automodule:: TagScriptEngine.verb 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/utils.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Utils 3 | ===== 4 | 5 | .. automodule:: TagScriptEngine.utils 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/block.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Block Module 3 | ============ 4 | 5 | .. automodule:: TagScriptEngine.block 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/interface.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Interface 3 | ========= 4 | 5 | .. automodule:: TagScriptEngine.interface 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/exceptions.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Exceptions 3 | ========== 4 | 5 | .. automodule:: TagScriptEngine.exceptions 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/adapter.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Adapter Module 3 | ============== 4 | 5 | .. automodule:: TagScriptEngine.adapter 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py 2 | Jinja2<3.1 3 | karma-sphinx-theme 4 | Sphinx 5 | sphinx-book-theme 6 | sphinx-material 7 | sphinx_rtd_theme 8 | -------------------------------------------------------------------------------- /TagScriptEngine/interface/__init__.py: -------------------------------------------------------------------------------- 1 | from .adapter import Adapter 2 | from .block import Block, verb_required_block 3 | 4 | __all__ = ("Adapter", "Block", "verb_required_block") 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | line-length = 99 7 | 8 | [tool.isort] 9 | profile = "black" 10 | line_length = 99 11 | -------------------------------------------------------------------------------- /docs/credits.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Thank you to the following users who contributed to this documentation! 6 | 7 | * **PhenoM4n4n** ``phenom4n4n`` 8 | * **sravan** ``sravan#0001`` 9 | * **Anik** ``aniksarker_21`` 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This work is licensed under the Creative Commons Attribution 4.0 International License. To view a copy of this license, visit http://creativecommons.org/licenses/by/4.0/ or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA. 2 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting Started 3 | =============== 4 | 5 | Please refer to existing TagScript implementations such as my 6 | `Tags cog `_ 7 | until developer documentation is written. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYTHON ?= python3 2 | 3 | ROOT_DIR:=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) 4 | 5 | reformat: 6 | $(PYTHON) -m black $(ROOT_DIR) 7 | $(PYTHON) -m isort $(ROOT_DIR) 8 | $(PYTHON) -m autoflake -r -i $(ROOT_DIR) 9 | 10 | install: 11 | pip install -U -r requirements.txt 12 | -------------------------------------------------------------------------------- /TagScriptEngine/adapter/intadapter.py: -------------------------------------------------------------------------------- 1 | from ..interface import Adapter 2 | from ..verb import Verb 3 | 4 | 5 | class IntAdapter(Adapter): 6 | __slots__ = ("integer",) 7 | 8 | def __init__(self, integer: int): 9 | self.integer: int = int(integer) 10 | 11 | def __repr__(self): 12 | return f"<{type(self).__qualname__} integer={repr(self.integer)}>" 13 | 14 | def get_value(self, ctx: Verb) -> str: 15 | return str(self.integer) 16 | -------------------------------------------------------------------------------- /TagScriptEngine/adapter/__init__.py: -------------------------------------------------------------------------------- 1 | from .discordadapters import * 2 | from .functionadapter import FunctionAdapter 3 | from .intadapter import IntAdapter 4 | from .objectadapter import SafeObjectAdapter 5 | from .stringadapter import StringAdapter 6 | 7 | __all__ = ( 8 | "SafeObjectAdapter", 9 | "StringAdapter", 10 | "IntAdapter", 11 | "FunctionAdapter", 12 | "AttributeAdapter", 13 | "MemberAdapter", 14 | "ChannelAdapter", 15 | "GuildAdapter", 16 | ) 17 | -------------------------------------------------------------------------------- /TagScriptEngine/adapter/functionadapter.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from ..interface import Adapter 4 | from ..verb import Verb 5 | 6 | 7 | class FunctionAdapter(Adapter): 8 | __slots__ = ("fn",) 9 | 10 | def __init__(self, function_pointer: Callable[[], str]): 11 | self.fn = function_pointer 12 | super().__init__() 13 | 14 | def __repr__(self): 15 | return f"<{type(self).__qualname__} fn={self.fn!r}>" 16 | 17 | def get_value(self, ctx: Verb) -> str: 18 | return str(self.fn()) 19 | -------------------------------------------------------------------------------- /TagScriptEngine/block/shortcutredirect.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..interface import Block 4 | from ..interpreter import Context 5 | from ..verb import Verb 6 | 7 | 8 | class ShortCutRedirectBlock(Block): 9 | def __init__(self, var_name): 10 | self.redirect_name = var_name 11 | 12 | def will_accept(self, ctx: Context) -> bool: 13 | return ctx.verb.declaration.isdigit() 14 | 15 | def process(self, ctx: Context) -> Optional[str]: 16 | blank = Verb() 17 | blank.declaration = self.redirect_name 18 | blank.parameter = ctx.verb.declaration 19 | ctx.verb = blank 20 | return None 21 | -------------------------------------------------------------------------------- /TagScriptEngine/block/substr.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..interface import verb_required_block 4 | from ..interpreter import Context 5 | 6 | 7 | class SubstringBlock(verb_required_block(True, parameter=True)): 8 | ACCEPTED_NAMES = ("substr", "substring") 9 | 10 | def process(self, ctx: Context) -> Optional[str]: 11 | try: 12 | if "-" not in ctx.verb.parameter: 13 | return ctx.verb.payload[int(float(ctx.verb.parameter)) :] 14 | 15 | spl = ctx.verb.parameter.split("-") 16 | start = int(float(spl[0])) 17 | end = int(float(spl[1])) 18 | return ctx.verb.payload[start:end] 19 | except: 20 | return 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /Tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from TagScriptEngine import Verb 4 | 5 | 6 | class TestVerbParsing(unittest.TestCase): 7 | def test_basic(self): 8 | parsed = Verb("{hello:world}") 9 | self.assertTrue(type(parsed) is Verb) 10 | self.assertEqual(parsed.declaration, "hello") 11 | self.assertEqual(parsed.payload, "world") 12 | 13 | bare = Verb("{user}") 14 | self.assertEqual(bare.parameter, None) 15 | self.assertEqual(bare.payload, None) 16 | self.assertEqual(bare.declaration, "user") 17 | 18 | bare = Verb("{user(hello)}") 19 | self.assertEqual(bare.parameter, "hello") 20 | self.assertEqual(bare.payload, None) 21 | self.assertEqual(bare.declaration, "user") 22 | -------------------------------------------------------------------------------- /TagScriptEngine/block/fiftyfifty.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Optional 3 | 4 | from ..interface import verb_required_block 5 | from ..interpreter import Context 6 | 7 | 8 | class FiftyFiftyBlock(verb_required_block(True, payload=True)): 9 | """ 10 | The fifty-fifty block has a 50% change of returning the payload, and 50% chance of returning null. 11 | 12 | **Usage:** ``{50:}`` 13 | 14 | **Aliases:** ``5050, ?`` 15 | 16 | **Payload:** message 17 | 18 | **Parameter:** None 19 | 20 | **Examples:** :: 21 | 22 | I pick {if({5050:.}!=):heads|tails} 23 | # I pick heads 24 | """ 25 | 26 | ACCEPTED_NAMES = ("5050", "50", "?") 27 | 28 | def process(self, ctx: Context) -> Optional[str]: 29 | return random.choice(["", ctx.verb.payload]) 30 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.8 3 | fail_fast: false 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v2.3.0 7 | hooks: 8 | - id: check-yaml 9 | - id: end-of-file-fixer 10 | - id: trailing-whitespace 11 | - id: check-builtin-literals 12 | - id: check-ast 13 | - id: check-json 14 | - id: detect-private-key 15 | # - id: pretty-format-json 16 | # args: [--autofix, --indent 4] 17 | - id: requirements-txt-fixer 18 | - id: trailing-whitespace 19 | args: [--markdown-linebreak-ext=md] 20 | 21 | - repo: https://github.com/psf/black 22 | rev: '20.8b1' 23 | hooks: 24 | - id: black 25 | 26 | - repo: https://github.com/pycqa/isort 27 | rev: '5.8.0' 28 | hooks: 29 | - id: isort 30 | -------------------------------------------------------------------------------- /docs/interpreter.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Interpreter Module 3 | ================== 4 | 5 | ----------- 6 | Interpreter 7 | ----------- 8 | 9 | .. autoclass:: TagScriptEngine.interpreter.Interpreter 10 | :members: 11 | 12 | ^^^^^^^^^^^^^^^^ 13 | AsyncInterpreter 14 | ^^^^^^^^^^^^^^^^ 15 | 16 | .. autoclass:: TagScriptEngine.interpreter.AsyncInterpreter 17 | :members: 18 | 19 | ------- 20 | Context 21 | ------- 22 | 23 | .. autoclass:: TagScriptEngine.interpreter.Context 24 | :members: 25 | 26 | -------- 27 | Response 28 | -------- 29 | 30 | .. autoclass:: TagScriptEngine.interpreter.Response 31 | :members: 32 | 33 | ---- 34 | Node 35 | ---- 36 | 37 | .. autoclass:: TagScriptEngine.interpreter.Node 38 | :members: 39 | 40 | ^^^^^^^^^^^^^^^ 41 | build_node_tree 42 | ^^^^^^^^^^^^^^^ 43 | 44 | .. autofunction:: TagScriptEngine.interpreter.build_node_tree 45 | -------------------------------------------------------------------------------- /TagScriptEngine/adapter/objectadapter.py: -------------------------------------------------------------------------------- 1 | from inspect import ismethod 2 | 3 | from ..interface import Adapter 4 | from ..verb import Verb 5 | 6 | 7 | class SafeObjectAdapter(Adapter): 8 | __slots__ = ("object",) 9 | 10 | def __init__(self, base): 11 | self.object = base 12 | 13 | def __repr__(self): 14 | return f"<{type(self).__qualname__} object={repr(self.object)}>" 15 | 16 | def get_value(self, ctx: Verb) -> str: 17 | if ctx.parameter is None: 18 | return str(self.object) 19 | if ctx.parameter.startswith("_") or "." in ctx.parameter: 20 | return 21 | try: 22 | attribute = getattr(self.object, ctx.parameter) 23 | except AttributeError: 24 | return 25 | if ismethod(attribute): 26 | return 27 | if isinstance(attribute, float): 28 | attribute = int(attribute) 29 | return str(attribute) 30 | -------------------------------------------------------------------------------- /Tests/test_escapes.py: -------------------------------------------------------------------------------- 1 | import TagScriptEngine as tse 2 | 3 | blocks = [ 4 | tse.MathBlock(), 5 | tse.RandomBlock(), 6 | tse.RangeBlock(), 7 | tse.AnyBlock(), 8 | tse.IfBlock(), 9 | tse.AllBlock(), 10 | tse.BreakBlock(), 11 | tse.StrfBlock(), 12 | tse.StopBlock(), 13 | tse.AssignmentBlock(), 14 | tse.FiftyFiftyBlock(), 15 | tse.ShortCutRedirectBlock("args"), 16 | tse.LooseVariableGetterBlock(), 17 | tse.SubstringBlock(), 18 | tse.EmbedBlock(), 19 | tse.ReplaceBlock(), 20 | tse.PythonBlock(), 21 | tse.URLEncodeBlock(), 22 | tse.RequireBlock(), 23 | tse.BlacklistBlock(), 24 | tse.CommandBlock(), 25 | tse.OverrideBlock(), 26 | ] 27 | engine = tse.Interpreter(blocks) 28 | 29 | msg = tse.escape_content("message provided :") 30 | response = engine.process("{if({msg}==):provide a message|{msg}}", {"msg": tse.StringAdapter(msg)}) 31 | 32 | print(response) 33 | print(response.body) 34 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. TagScript documentation master file, created by 2 | sphinx-quickstart on Tue May 4 19:53:15 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to TagScript's documentation! 7 | ===================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 1 11 | :caption: User Guide 12 | 13 | user_blocks 14 | 15 | .. toctree:: 16 | :maxdepth: 2 17 | :caption: Developer Guide 18 | 19 | getting_started 20 | the_interpreter 21 | blocks_and_adapters 22 | 23 | .. toctree:: 24 | :maxdepth: 2 25 | :caption: API Reference 26 | 27 | interpreter 28 | interface 29 | verb 30 | block 31 | adapter 32 | utils 33 | exceptions 34 | 35 | .. toctree:: 36 | :maxdepth: 2 37 | :caption: credits 38 | 39 | credits 40 | 41 | 42 | 43 | Indices and tables 44 | ================== 45 | 46 | * :ref:`genindex` 47 | * :ref:`modindex` 48 | * :ref:`search` 49 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = TagScript 3 | version = attr: TagScriptEngine.__version__ 4 | url = https://github.com/phenom4n4n/TagScript 5 | author = PhenoM4n4n 6 | author_email = noumenondiscord@gmail.com 7 | classifiers = 8 | Development Status :: 5 - Production/Stable 9 | Intended Audience :: Developers 10 | License :: Freely Distributable 11 | Natural Language :: English 12 | Operating System :: OS Independent 13 | Programming Language :: Python :: 3.8 14 | license = Creative Commons Attribution 4.0 International License 15 | license_file = LICENSE 16 | description = An easy drop in user-provided Templating system. 17 | long_description = file: README.md 18 | long_description_content_type = text/markdown; charset=UTF-8; variant=GFM 19 | keywords = tagscript, 20 | 21 | [options] 22 | packages = find_namespace: 23 | install_requires = 24 | discord.py==2.2.3 25 | pyparsing 26 | python_requires = >=3.8 27 | 28 | [options.packages.find] 29 | include = 30 | TagScriptEngine 31 | TagScriptEngine.* 32 | -------------------------------------------------------------------------------- /TagScriptEngine/block/stopblock.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..exceptions import StopError 4 | from ..interface import verb_required_block 5 | from ..interpreter import Context 6 | from . import helper_parse_if 7 | 8 | 9 | class StopBlock(verb_required_block(True, parameter=True)): 10 | """ 11 | The stop block stops tag processing if the given parameter is true. 12 | If a message is passed to the payload it will return that message. 13 | 14 | **Usage:** ``{stop():[string]}`` 15 | 16 | **Aliases:** ``halt, error`` 17 | 18 | **Payload:** string, None 19 | 20 | **Parameter:** bool 21 | 22 | **Example:** :: 23 | 24 | {stop({args}==):You must provide arguments for this tag.} 25 | # enforces providing arguments for a tag 26 | """ 27 | 28 | ACCEPTED_NAMES = ("stop", "halt", "error") 29 | 30 | def process(self, ctx: Context) -> Optional[str]: 31 | if helper_parse_if(ctx.verb.parameter): 32 | raise StopError("" if ctx.verb.payload is None else ctx.verb.payload) 33 | return "" 34 | -------------------------------------------------------------------------------- /playground.py: -------------------------------------------------------------------------------- 1 | from appJar import gui 2 | 3 | from TagScriptEngine import Interpreter, block 4 | 5 | blocks = [ 6 | block.MathBlock(), 7 | block.RandomBlock(), 8 | block.RangeBlock(), 9 | block.AnyBlock(), 10 | block.IfBlock(), 11 | block.AllBlock(), 12 | block.BreakBlock(), 13 | block.StrfBlock(), 14 | block.StopBlock(), 15 | block.AssignmentBlock(), 16 | block.FiftyFiftyBlock(), 17 | block.ShortCutRedirectBlock("message"), 18 | block.LooseVariableGetterBlock(), 19 | block.SubstringBlock(), 20 | ] 21 | x = Interpreter(blocks) 22 | 23 | 24 | def press(button): 25 | o = x.process(app.getTextArea("input")).body 26 | app.clearTextArea("output") 27 | app.setTextArea("output", o) 28 | 29 | 30 | app = gui("TSE Playground", "750x450") 31 | app.setPadding([2, 2]) 32 | app.setInPadding([2, 2]) 33 | app.addTextArea("input", text="I see {rand:1,2,3,4} new items!", row=0, column=0) 34 | app.addTextArea("output", text="Press process to continue", row=0, column=1) 35 | app.addButton("process", press, row=1, column=0, colspan=2) 36 | app.go() 37 | -------------------------------------------------------------------------------- /TagScriptEngine/block/redirect.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..interface import verb_required_block 4 | from ..interpreter import Context 5 | 6 | 7 | class RedirectBlock(verb_required_block(True, parameter=True)): 8 | """ 9 | Redirects the tag response to either the given channel, the author's DMs, 10 | or uses a reply based on what is passed to the parameter. 11 | 12 | **Usage:** ``{redirect(<"dm"|"reply"|channel>)}`` 13 | 14 | **Payload:** None 15 | 16 | **Parameter:** "dm", "reply", channel 17 | 18 | **Examples:** :: 19 | 20 | {redirect(dm)} 21 | {redirect(reply)} 22 | {redirect(#general)} 23 | {redirect(626861902521434160)} 24 | """ 25 | 26 | ACCEPTED_NAMES = ("redirect",) 27 | 28 | def process(self, ctx: Context) -> Optional[str]: 29 | param = ctx.verb.parameter.strip() 30 | if param.lower() == "dm": 31 | target = "dm" 32 | elif param.lower() == "reply": 33 | target = "reply" 34 | else: 35 | target = param 36 | ctx.response.actions["target"] = target 37 | return "" 38 | -------------------------------------------------------------------------------- /TagScriptEngine/interface/adapter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional 4 | 5 | if TYPE_CHECKING: 6 | from ..interpreter import Context 7 | 8 | 9 | class Adapter: 10 | """ 11 | The base class for TagScript blocks. 12 | 13 | Implementations must subclass this to create adapters. 14 | """ 15 | 16 | def __repr__(self): 17 | return f"<{type(self).__qualname__} at {hex(id(self))}>" 18 | 19 | def get_value(self, ctx: Context) -> Optional[str]: 20 | """ 21 | Processes the adapter's actions for a given :class:`~TagScriptEngine.interpreter.Context`. 22 | 23 | Subclasses must implement this. 24 | 25 | Parameters 26 | ---------- 27 | ctx: Context 28 | The context object containing the TagScript :class:`~TagScriptEngine.verb.Verb`. 29 | 30 | Returns 31 | ------- 32 | Optional[str] 33 | The adapters's processed value. 34 | 35 | Raises 36 | ------ 37 | NotImplementedError 38 | The subclass did not implement this required method. 39 | """ 40 | raise NotImplementedError 41 | -------------------------------------------------------------------------------- /TagScriptEngine/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from inspect import isawaitable 3 | from typing import Any, Awaitable, Callable, T, TypeVar, Union 4 | 5 | import discord 6 | 7 | __all__ = ("escape_content", "maybe_await", "DPY2") 8 | 9 | T = TypeVar("T") 10 | 11 | DPY2 = discord.version_info >= (2, 0, 0, "alpha", 0) 12 | 13 | pattern = re.compile(r"(? str: 17 | return "\\" + match[1] 18 | 19 | 20 | def escape_content(string: str) -> str: 21 | """ 22 | Escapes given input to avoid tampering with engine/block behavior. 23 | 24 | Returns 25 | ------- 26 | str 27 | The escaped content. 28 | """ 29 | if string is None: 30 | return 31 | return pattern.sub(_sub_match, string) 32 | 33 | 34 | async def maybe_await(func: Callable[..., Union[T, Awaitable[T]]], *args: Any, **kwargs: Any) -> T: 35 | """ 36 | Await the given function if it is awaitable or call it synchronously. 37 | 38 | Returns 39 | ------- 40 | Any 41 | The result of the awaitable function. 42 | """ 43 | value = func(*args, **kwargs) 44 | return await value if isawaitable(value) else value 45 | -------------------------------------------------------------------------------- /TagScriptEngine/block/assign.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..adapter import StringAdapter 4 | from ..interface import verb_required_block 5 | from ..interpreter import Context 6 | 7 | 8 | class AssignmentBlock(verb_required_block(False, parameter=True)): 9 | """ 10 | Variables are useful for choosing a value and referencing it later in a tag. 11 | Variables can be referenced using brackets as any other block. 12 | 13 | **Usage:** ``{=():}`` 14 | 15 | **Aliases:** ``assign, let, var`` 16 | 17 | **Payload:** value 18 | 19 | **Parameter:** name 20 | 21 | **Examples:** :: 22 | 23 | {=(prefix):!} 24 | The prefix here is `{prefix}`. 25 | # The prefix here is `!`. 26 | 27 | {assign(day):Monday} 28 | {if({day}==Wednesday):It's Wednesday my dudes!|The day is {day}.} 29 | # The day is Monday. 30 | """ 31 | 32 | ACCEPTED_NAMES = ("=", "assign", "let", "var") 33 | 34 | def process(self, ctx: Context) -> Optional[str]: 35 | if ctx.verb.parameter is None: 36 | return None 37 | ctx.response.variables[ctx.verb.parameter] = StringAdapter(str(ctx.verb.payload)) 38 | return "" 39 | -------------------------------------------------------------------------------- /TagScriptEngine/block/strictvariablegetter.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..interface import Block 4 | from ..interpreter import Context 5 | 6 | 7 | class StrictVariableGetterBlock(Block): 8 | """ 9 | The strict variable block represents the adapters for any seeded or defined variables. 10 | This variable implementation is considered "strict" since it checks whether the variable is 11 | valid during :meth:`will_accept` and is only processed if the declaration refers to a valid 12 | variable. 13 | 14 | **Usage:** ``{([parameter]):[payload]}`` 15 | 16 | **Aliases:** This block is valid for any variable name in `Response.variables`. 17 | 18 | **Payload:** Depends on the variable's underlying adapter. 19 | 20 | **Parameter:** Depends on the variable's underlying adapter. 21 | 22 | **Examples:** :: 23 | 24 | {=(var):This is my variable.} 25 | {var} 26 | # This is my variable. 27 | """ 28 | 29 | def will_accept(self, ctx: Context) -> bool: 30 | return ctx.verb.declaration in ctx.response.variables 31 | 32 | def process(self, ctx: Context) -> Optional[str]: 33 | return ctx.response.variables[ctx.verb.declaration].get_value(ctx.verb) 34 | -------------------------------------------------------------------------------- /TagScriptEngine/block/loosevariablegetter.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..interface import Block 4 | from ..interpreter import Context 5 | 6 | 7 | class LooseVariableGetterBlock(Block): 8 | """ 9 | The loose variable block represents the adapters for any seeded or defined variables. 10 | This variable implementation is considered "loose" since it checks whether the variable is 11 | valid during :meth:`process`, rather than :meth:`will_accept`. 12 | 13 | **Usage:** ``{([parameter]):[payload]}`` 14 | 15 | **Aliases:** This block is valid for any inputted declaration. 16 | 17 | **Payload:** Depends on the variable's underlying adapter. 18 | 19 | **Parameter:** Depends on the variable's underlying adapter. 20 | 21 | **Examples:** :: 22 | 23 | {=(var):This is my variable.} 24 | {var} 25 | # This is my variable. 26 | """ 27 | 28 | def will_accept(self, ctx: Context) -> bool: 29 | return True 30 | 31 | def process(self, ctx: Context) -> Optional[str]: 32 | if ctx.verb.declaration in ctx.response.variables: 33 | return ctx.response.variables[ctx.verb.declaration].get_value(ctx.verb) 34 | else: 35 | return None 36 | -------------------------------------------------------------------------------- /TagScriptEngine/block/breakblock.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..interface import Block 4 | from ..interpreter import Context 5 | from . import helper_parse_if 6 | 7 | 8 | class BreakBlock(Block): 9 | """ 10 | The break block will force the tag output to only be the payload of this block, if the passed 11 | expresssion evaluates true. 12 | If no message is provided to the payload, the tag output will be empty. 13 | 14 | This differs from the `StopBlock` as the stop block stops all tagscript processing and returns 15 | its message while the break block continues to process blocks. If command blocks exist after 16 | the break block, they will still execute. 17 | 18 | **Usage:** ``{break():[message]}`` 19 | 20 | **Aliases:** ``short, shortcircuit`` 21 | 22 | **Payload:** message 23 | 24 | **Parameter:** expression 25 | 26 | **Examples:** :: 27 | 28 | {break({args}==):You did not provide any input.} 29 | """ 30 | 31 | ACCEPTED_NAMES = ("break", "shortcircuit", "short") 32 | 33 | def process(self, ctx: Context) -> Optional[str]: 34 | if helper_parse_if(ctx.verb.parameter): 35 | ctx.response.body = ctx.verb.payload if ctx.verb.payload != None else "" 36 | return "" 37 | -------------------------------------------------------------------------------- /benchmark.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from TagScriptEngine import Interpreter, adapter, block 4 | 5 | blocks = [ 6 | block.MathBlock(), 7 | block.RandomBlock(), 8 | block.RangeBlock(), 9 | block.StrfBlock(), 10 | block.AssignmentBlock(), 11 | block.FiftyFiftyBlock(), 12 | block.LooseVariableGetterBlock(), 13 | ] 14 | x = Interpreter(blocks) 15 | 16 | # data to inject 17 | dummy = {"message": adapter.StringAdapter("Hello, this is my message.")} 18 | 19 | 20 | def timerfunc(func): 21 | """ 22 | A timer decorator 23 | """ 24 | 25 | def function_timer(*args, **kwargs): 26 | """ 27 | A nested function for timing other functions 28 | """ 29 | start = time.time() 30 | value = func(*args, **kwargs) 31 | end = time.time() 32 | runtime = end - start 33 | msg = "The runtime for {func} took {time} seconds to complete 1000 times" 34 | print(msg.format(func=func.__name__, time=runtime)) 35 | return value 36 | 37 | return function_timer 38 | 39 | 40 | @timerfunc 41 | def v2_test(): 42 | for _ in range(1000): 43 | x.process( 44 | "{message} {#:1,2,3,4,5,6,7,8,9,10} {range:1-9} {#:1,2,3,4,5} {message} {strf:Its %A}", 45 | dummy, 46 | ) 47 | 48 | 49 | if __name__ == "__main__": 50 | v2_test() 51 | -------------------------------------------------------------------------------- /TagScriptEngine/block/urlencodeblock.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote, quote_plus 2 | 3 | from ..interface import verb_required_block 4 | from ..interpreter import Context 5 | 6 | 7 | class URLEncodeBlock(verb_required_block(True, payload=True)): 8 | """ 9 | This block will encode a given string into a properly formatted url 10 | with non-url compliant characters replaced. Using ``+`` as the parameter 11 | will replace spaces with ``+`` rather than ``%20``. 12 | 13 | **Usage:** ``{urlencode(["+"]):}`` 14 | 15 | **Payload:** string 16 | 17 | **Parameter:** "+", None 18 | 19 | **Examples:** :: 20 | 21 | {urlencode:covid-19 sucks} 22 | # covid-19%20sucks 23 | 24 | {urlencode(+):im stuck at home writing docs} 25 | # im+stuck+at+home+writing+docs 26 | 27 | # the following tagscript can be used to search up tag blocks 28 | # assume {args} = "command block" 29 | 30 | # 31 | """ 32 | 33 | ACCEPTED_NAMES = ("urlencode",) 34 | 35 | def process(self, ctx: Context): 36 | method = quote_plus if ctx.verb.parameter == "+" else quote 37 | return method(ctx.verb.payload) 38 | -------------------------------------------------------------------------------- /Tests/test_adapters.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from TagScriptEngine import Interpreter, adapter, block 4 | 5 | 6 | def dummy_function(): 7 | return 500 8 | 9 | 10 | class TestVerbParsing(unittest.TestCase): 11 | def setUp(self): 12 | self.blocks = [block.StrictVariableGetterBlock()] 13 | self.engine = Interpreter(self.blocks) 14 | 15 | def tearDown(self): 16 | self.blocks = None 17 | self.engine = None 18 | 19 | def test_string_adapter(self): 20 | # Basic string adapter get 21 | data = {"test": adapter.StringAdapter("Hello World, How are you")} 22 | result = self.engine.process("{test}", data).body 23 | self.assertEqual(result, "Hello World, How are you") 24 | 25 | # Slice 26 | result = self.engine.process("{test(1)}", data).body 27 | self.assertEqual(result, "Hello") 28 | 29 | # Plus 30 | result = self.engine.process("{test(3+)}", data).body 31 | self.assertEqual(result, "How are you") 32 | 33 | # up to 34 | result = self.engine.process("{test(+2)}", data).body 35 | self.assertEqual(result, "Hello World,") 36 | 37 | def test_function_adapter(self): 38 | # Basic string adapter get 39 | data = {"fn": adapter.FunctionAdapter(dummy_function)} 40 | result = self.engine.process("{fn}", data).body 41 | self.assertEqual(result, "500") 42 | -------------------------------------------------------------------------------- /TagScriptEngine/__init__.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from .adapter import * 4 | from .block import * 5 | from .exceptions import * 6 | from .interface import * 7 | from .interpreter import * 8 | from .utils import * 9 | from .verb import Verb 10 | 11 | __version__ = "2.6.5" 12 | 13 | 14 | class VersionInfo(namedtuple("VersionInfo", "major minor micro")): 15 | """ 16 | Version information. 17 | 18 | Attributes 19 | ---------- 20 | major: int 21 | Major version number. 22 | minor: int 23 | Minor version number. 24 | micro: int 25 | Micro version number. 26 | """ 27 | 28 | __slots__ = () 29 | 30 | def __str__(self): 31 | """ 32 | Returns a string representation of the version information. 33 | 34 | Returns 35 | ------- 36 | str 37 | String representation of the version information. 38 | """ 39 | return "{major}.{minor}.{micro}".format(**self._asdict()) 40 | 41 | @classmethod 42 | def from_str(cls, version): 43 | """ 44 | Returns a VersionInfo instance from a string. 45 | 46 | Parameters 47 | ---------- 48 | version: str 49 | String representation of the version information. 50 | 51 | Returns 52 | ------- 53 | VersionInfo 54 | Version information. 55 | """ 56 | return cls(*map(int, version.split("."))) 57 | 58 | 59 | version_info = VersionInfo.from_str(__version__) 60 | -------------------------------------------------------------------------------- /TagScriptEngine/adapter/stringadapter.py: -------------------------------------------------------------------------------- 1 | from ..interface import Adapter 2 | from ..utils import escape_content 3 | from ..verb import Verb 4 | 5 | 6 | class StringAdapter(Adapter): 7 | __slots__ = ("string", "escape_content") 8 | 9 | def __init__(self, string: str, *, escape: bool = False): 10 | self.string: str = str(string) 11 | self.escape_content = escape 12 | 13 | def __repr__(self): 14 | return f"<{type(self).__qualname__} string={repr(self.string)}>" 15 | 16 | def get_value(self, ctx: Verb) -> str: 17 | return self.return_value(self.handle_ctx(ctx)) 18 | 19 | def handle_ctx(self, ctx: Verb) -> str: 20 | if ctx.parameter is None: 21 | return self.string 22 | try: 23 | if "+" not in ctx.parameter: 24 | index = int(ctx.parameter) - 1 25 | splitter = " " if ctx.payload is None else ctx.payload 26 | return self.string.split(splitter)[index] 27 | else: 28 | index = int(ctx.parameter.replace("+", "")) - 1 29 | splitter = " " if ctx.payload is None else ctx.payload 30 | if ctx.parameter.startswith("+"): 31 | return splitter.join(self.string.split(splitter)[: index + 1]) 32 | elif ctx.parameter.endswith("+"): 33 | return splitter.join(self.string.split(splitter)[index:]) 34 | else: 35 | return self.string.split(splitter)[index] 36 | except: 37 | return self.string 38 | 39 | def return_value(self, string: str) -> str: 40 | return escape_content(string) if self.escape_content else string 41 | -------------------------------------------------------------------------------- /TagScriptEngine/block/randomblock.py: -------------------------------------------------------------------------------- 1 | from random import Random 2 | from typing import Optional 3 | 4 | from ..interface import verb_required_block 5 | from ..interpreter import Context 6 | 7 | random = Random() 8 | 9 | class RandomBlock(verb_required_block(True, payload=True)): 10 | """ 11 | Pick a random item from a list of strings, split by either ``~`` 12 | or ``,``. An optional seed can be provided to the parameter to 13 | always choose the same item when using that seed. 14 | 15 | **Usage:** ``{random([seed]):}`` 16 | 17 | **Aliases:** ``#, rand`` 18 | 19 | **Payload:** list 20 | 21 | **Parameter:** seed, None 22 | 23 | **Examples:** :: 24 | 25 | {random:Carl,Harold,Josh} attempts to pick the lock! 26 | # Possible Outputs: 27 | # Josh attempts to pick the lock! 28 | # Carl attempts to pick the lock! 29 | # Harold attempts to pick the lock! 30 | 31 | {=(insults):You're so ugly that you went to the salon and it took 3 hours just to get an estimate.~I'll never forget the first time we met, although I'll keep trying.~You look like a before picture.} 32 | {=(insult):{#:{insults}}} 33 | {insult} 34 | # Assigns a random insult to the insult variable 35 | """ 36 | 37 | ACCEPTED_NAMES = ("random", "#", "rand") 38 | 39 | def process(self, ctx: Context) -> Optional[str]: 40 | spl = [] 41 | if "~" in ctx.verb.payload: 42 | spl = ctx.verb.payload.split("~") 43 | else: 44 | spl = ctx.verb.payload.split(",") 45 | random.seed(ctx.verb.parameter) 46 | 47 | choice = random.choice(spl) 48 | random.seed() 49 | return choice 50 | -------------------------------------------------------------------------------- /TagScriptEngine/block/__init__.py: -------------------------------------------------------------------------------- 1 | # isort: off 2 | from .helpers import * 3 | 4 | # isort: on 5 | from .assign import AssignmentBlock 6 | from .breakblock import BreakBlock 7 | from .command import CommandBlock, OverrideBlock 8 | from .control import AllBlock, AnyBlock, IfBlock 9 | from .cooldown import CooldownBlock 10 | from .embedblock import EmbedBlock 11 | from .fiftyfifty import FiftyFiftyBlock 12 | from .loosevariablegetter import LooseVariableGetterBlock 13 | from .mathblock import MathBlock 14 | from .randomblock import RandomBlock 15 | from .range import RangeBlock 16 | from .redirect import RedirectBlock 17 | from .replaceblock import PythonBlock, ReplaceBlock 18 | from .require_blacklist import BlacklistBlock, RequireBlock 19 | from .shortcutredirect import ShortCutRedirectBlock 20 | from .stopblock import StopBlock 21 | from .strf import StrfBlock 22 | from .strictvariablegetter import StrictVariableGetterBlock 23 | from .substr import SubstringBlock 24 | from .urlencodeblock import URLEncodeBlock 25 | 26 | __all__ = ( 27 | "implicit_bool", 28 | "helper_parse_if", 29 | "helper_parse_list_if", 30 | "helper_split", 31 | "AllBlock", 32 | "AnyBlock", 33 | "AssignmentBlock", 34 | "BlacklistBlock", 35 | "BreakBlock", 36 | "CommandBlock", 37 | "CooldownBlock", 38 | "EmbedBlock", 39 | "FiftyFiftyBlock", 40 | "IfBlock", 41 | "LooseVariableGetterBlock", 42 | "MathBlock", 43 | "OverrideBlock", 44 | "PythonBlock", 45 | "RandomBlock", 46 | "RangeBlock", 47 | "RedirectBlock", 48 | "ReplaceBlock", 49 | "RequireBlock", 50 | "ShortCutRedirectBlock", 51 | "StopBlock", 52 | "StrfBlock", 53 | "StrictVariableGetterBlock", 54 | "SubstringBlock", 55 | "URLEncodeBlock", 56 | ) 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | .dmypy.json 112 | dmypy.json 113 | 114 | # Pyre type checker 115 | .pyre/ 116 | 117 | .DS_Store 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Information 2 | 3 | Documentation Status 4 | 5 | 6 |  yPI 7 | 8 | 9 | This repository is a fork of JonSnowbd's [TagScript](https://github.com/JonSnowbd/TagScript), a string templating language. 10 | This fork adds support for Discord object adapters and a couple Discord related blocks, as 11 | well as multiple utility blocks. Additionally, several tweaks have been made to the engine's 12 | behavior. 13 | 14 | This TagScriptEngine is used on [Noumenon, a Discord bot](https://discordapp.com/oauth2/authorize?client_id=634866217764651009&permissions=2080894207&scope=bot%20applications.commands). 15 | An example implementation can be found its [Tags cog](https://github.com/phenom4n4n/phen-cogs/tree/master/tags). 16 | 17 | Additional documentation on the TagScriptEngine library can be [found here](https://tagscript.readthedocs.io/en/latest/). 18 | 19 | ## Installation 20 | 21 | Download the latest version through pip: 22 | 23 | ``` 24 | pip(3) install TagScript 25 | ``` 26 | 27 | Download from a commit: 28 | 29 | ``` 30 | pip(3) install git+https://github.com/phenom4n4n/TagScript.git@ 31 | ``` 32 | 33 | Install for editing/development: 34 | 35 | ``` 36 | git clone https://github.com/phenom4n4n/TagScript.git 37 | pip(3) install -e ./TagScript 38 | ``` 39 | 40 | ## What? 41 | 42 | TagScript is a drop in easy to use string interpreter that lets you provide users with ways of 43 | customizing their profiles or chat rooms with interactive text. 44 | 45 | For example TagScript comes out of the box with a random block that would let users provide 46 | a template that produces a new result each time its ran, or assign math and variables for later 47 | use. 48 | 49 | ## Dependencies 50 | 51 | `Python 3.8+` 52 | 53 | `discord.py` 54 | 55 | `pyparsing` 56 | -------------------------------------------------------------------------------- /TagScriptEngine/block/strf.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Optional 3 | 4 | from ..interface import Block 5 | from ..interpreter import Context 6 | 7 | 8 | class StrfBlock(Block): 9 | """ 10 | The strf block converts and formats timestamps based on `strftime formatting spec `_. 11 | Two types of timestamps are supported: ISO and epoch. 12 | If a timestamp isn't passed, the current UTC time is used. 13 | 14 | Invoking this block with `unix` will return the current Unix timestamp. 15 | 16 | **Usage:** ``{strf([timestamp]):}`` 17 | 18 | **Aliases:** ``unix`` 19 | 20 | **Payload:** format, None 21 | 22 | **Parameter:** timestamp 23 | 24 | **Example:** :: 25 | 26 | {strf:%Y-%m-%d} 27 | # 2021-07-11 28 | 29 | {strf({user(timestamp)}):%c} 30 | # Fri Jun 29 21:10:28 2018 31 | 32 | {strf(1420070400):%A %d, %B %Y} 33 | # Thursday 01, January 2015 34 | 35 | {strf(2019-10-09T01:45:00.805000):%H:%M %d-%B-%Y} 36 | # 01:45 09-October-2019 37 | 38 | {unix} 39 | # 1629182008 40 | """ 41 | 42 | ACCEPTED_NAMES = ("strf", "unix") 43 | 44 | def process(self, ctx: Context) -> Optional[str]: 45 | if ctx.verb.declaration.lower() == "unix": 46 | return str(int(datetime.now(timezone.utc).timestamp())) 47 | if not ctx.verb.payload: 48 | return None 49 | if ctx.verb.parameter: 50 | if ctx.verb.parameter.isdigit(): 51 | try: 52 | t = datetime.fromtimestamp(int(ctx.verb.parameter)) 53 | except: 54 | return 55 | else: 56 | try: 57 | t = datetime.fromisoformat(ctx.verb.parameter) 58 | # converts datetime.__str__ to datetime 59 | except ValueError: 60 | return 61 | else: 62 | t = datetime.now() 63 | if not t.tzinfo: 64 | t = t.replace(tzinfo=timezone.utc) 65 | return t.strftime(ctx.verb.payload) 66 | -------------------------------------------------------------------------------- /TagScriptEngine/block/range.py: -------------------------------------------------------------------------------- 1 | import random 2 | from typing import Optional 3 | 4 | from ..interface import verb_required_block 5 | from ..interpreter import Context 6 | 7 | 8 | class RangeBlock(verb_required_block(True, payload=True)): 9 | """ 10 | The range block picks a random number from a range of numbers seperated by ``-``. 11 | The number range is inclusive, so it can pick the starting/ending number as well. 12 | Using the rangef block will pick a number to the tenth decimal place. 13 | 14 | An optional seed can be provided to the parameter to always choose the same item when using that seed. 15 | 16 | **Usage:** ``{range([seed]):}`` 17 | 18 | **Aliases:** ``rangef`` 19 | 20 | **Payload:** number 21 | 22 | **Parameter:** seed, None 23 | 24 | **Examples:** :: 25 | 26 | Your lucky number is {range:10-30}! 27 | # Your lucky number is 14! 28 | # Your lucky number is 25! 29 | 30 | {=(height):{rangef:5-7}} 31 | I am guessing your height is {height}ft. 32 | # I am guessing your height is 5.3ft. 33 | """ 34 | 35 | ACCEPTED_NAMES = ("rangef", "range") 36 | 37 | def process(self, ctx: Context) -> Optional[str]: 38 | try: 39 | spl = ctx.verb.payload.split("-") 40 | random.seed(ctx.verb.parameter) 41 | if ctx.verb.declaration.lower() == "rangef": 42 | lower = float(spl[0]) 43 | upper = float(spl[1]) 44 | base = random.randint(lower * 10, upper * 10) / 10 45 | return str(base) 46 | # base = random.randint(lower, upper) 47 | # if base == upper: 48 | # return str(base) 49 | # if ctx.verb.parameter != None: 50 | # random.seed(ctx.verb.parameter+"float") 51 | # else: 52 | # random.seed(None) 53 | # return str(str(base)+"."+str(random.randint(1,9))) 54 | else: 55 | lower = int(float(spl[0])) 56 | upper = int(float(spl[1])) 57 | return str(random.randint(lower, upper)) 58 | except: 59 | return None 60 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath("..")) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = "TagScript" 22 | copyright = "2021, JonSnowbd, PhenoM4n4n" 23 | author = "JonSnowbd, PhenoM4n4n" 24 | 25 | 26 | # -- General configuration --------------------------------------------------- 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | "recommonmark", 33 | "sphinx.ext.autodoc", 34 | "sphinx.ext.autosectionlabel", 35 | "sphinx.ext.viewcode", 36 | "sphinx.ext.napoleon", 37 | "sphinx.ext.intersphinx", 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ["_templates"] 42 | 43 | # List of patterns, relative to source directory, that match files and 44 | # directories to ignore when looking for source files. 45 | # This pattern also affects html_static_path and html_extra_path. 46 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 47 | 48 | 49 | # -- Options for HTML output ------------------------------------------------- 50 | 51 | # The theme to use for HTML and HTML Help pages. See the documentation for 52 | # a list of builtin themes. 53 | 54 | # html_theme = "karma_sphinx_theme" 55 | html_theme = "sphinx_rtd_theme" 56 | 57 | # Add any paths that contain custom static files (such as style sheets) here, 58 | # relative to this directory. They are copied after the builtin static files, 59 | # so a file named "default.css" will overwrite the builtin "default.css". 60 | html_static_path = ["_static"] 61 | 62 | # autodoc 63 | autodoc_default_options = {"show-inheritance": True} 64 | autodoc_member_order = "bysource" 65 | 66 | # simple references within backticks 67 | default_role = "any" 68 | 69 | # Intersphinx 70 | intersphinx_mapping = { 71 | "python": ("https://docs.python.org/3", None), 72 | "dpy": ("https://discordpy.readthedocs.io/en/stable/", None), 73 | } 74 | -------------------------------------------------------------------------------- /TagScriptEngine/block/require_blacklist.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..interface import verb_required_block 4 | from ..interpreter import Context 5 | 6 | 7 | class RequireBlock(verb_required_block(True, parameter=True)): 8 | """ 9 | The require block will attempt to convert the given parameter into a channel 10 | or role, using name or ID. If the user running the tag is not in the targeted 11 | channel or doesn't have the targeted role, the tag will stop processing and 12 | it will send the response if one is given. Multiple role or channel 13 | requirements can be given, and should be split by a ",". 14 | 15 | **Usage:** ``{require():[response]}`` 16 | 17 | **Aliases:** ``whitelist`` 18 | 19 | **Payload:** response, None 20 | 21 | **Parameter:** role, channel 22 | 23 | **Examples:** :: 24 | 25 | {require(Moderator)} 26 | {require(#general, #bot-cmds):This tag can only be run in #general and #bot-cmds.} 27 | {require(757425366209134764, 668713062186090506, 737961895356792882):You aren't allowed to use this tag.} 28 | """ 29 | 30 | ACCEPTED_NAMES = ("require", "whitelist") 31 | 32 | def process(self, ctx: Context) -> Optional[str]: 33 | actions = ctx.response.actions.get("requires") 34 | if actions: 35 | return None 36 | ctx.response.actions["requires"] = { 37 | "items": [i.strip() for i in ctx.verb.parameter.split(",")], 38 | "response": ctx.verb.payload, 39 | } 40 | return "" 41 | 42 | 43 | class BlacklistBlock(verb_required_block(True, parameter=True)): 44 | """ 45 | The blacklist block will attempt to convert the given parameter into a channel 46 | or role, using name or ID. If the user running the tag is in the targeted 47 | channel or has the targeted role, the tag will stop processing and 48 | it will send the response if one is given. Multiple role or channel 49 | requirements can be given, and should be split by a ",". 50 | 51 | **Usage:** ``{blacklist():[response]}`` 52 | 53 | **Payload:** response, None 54 | 55 | **Parameter:** role, channel 56 | 57 | **Examples:** :: 58 | 59 | {blacklist(Muted)} 60 | {blacklist(#support):This tag is not allowed in #support.} 61 | {blacklist(Tag Blacklist, 668713062186090506):You are blacklisted from using tags.} 62 | """ 63 | 64 | ACCEPTED_NAMES = ("blacklist",) 65 | 66 | def process(self, ctx: Context) -> Optional[str]: 67 | actions = ctx.response.actions.get("blacklist") 68 | if actions: 69 | return None 70 | ctx.response.actions["blacklist"] = { 71 | "items": [i.strip() for i in ctx.verb.parameter.split(",")], 72 | "response": ctx.verb.payload, 73 | } 74 | return "" 75 | -------------------------------------------------------------------------------- /docs/user_blocks.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Blocks 3 | ====== 4 | 5 | --------- 6 | All Block 7 | --------- 8 | 9 | .. autoclass:: TagScriptEngine.block.AllBlock 10 | 11 | --------- 12 | Any Block 13 | --------- 14 | 15 | .. autoclass:: TagScriptEngine.block.AnyBlock 16 | 17 | ---------------- 18 | Assignment Block 19 | ---------------- 20 | 21 | .. autoclass:: TagScriptEngine.block.AssignmentBlock 22 | 23 | --------------- 24 | Blacklist Block 25 | --------------- 26 | 27 | .. autoclass:: TagScriptEngine.block.BlacklistBlock 28 | 29 | ----------- 30 | Break Block 31 | ----------- 32 | 33 | .. autoclass:: TagScriptEngine.block.BreakBlock 34 | 35 | ------------- 36 | Command Block 37 | ------------- 38 | 39 | .. autoclass:: TagScriptEngine.block.CommandBlock 40 | 41 | -------------- 42 | Cooldown Block 43 | -------------- 44 | 45 | .. autoclass:: TagScriptEngine.block.CooldownBlock 46 | 47 | ----------- 48 | Embed Block 49 | ----------- 50 | 51 | .. autoclass:: TagScriptEngine.block.EmbedBlock 52 | 53 | ----------------- 54 | Fifty Fifty Block 55 | ----------------- 56 | 57 | .. autoclass:: TagScriptEngine.block.FiftyFiftyBlock 58 | 59 | -------- 60 | If Block 61 | -------- 62 | 63 | .. autoclass:: TagScriptEngine.block.IfBlock 64 | 65 | -------------------- 66 | Loose Variable Block 67 | -------------------- 68 | 69 | .. autoclass:: TagScriptEngine.block.LooseVariableGetterBlock 70 | 71 | ---------- 72 | Math Block 73 | ---------- 74 | 75 | .. autoclass:: TagScriptEngine.block.MathBlock 76 | 77 | -------------- 78 | Override Block 79 | -------------- 80 | 81 | .. autoclass:: TagScriptEngine.block.OverrideBlock 82 | 83 | ------------ 84 | Random Block 85 | ------------ 86 | 87 | .. autoclass:: TagScriptEngine.block.RandomBlock 88 | 89 | ----------- 90 | Range Block 91 | ----------- 92 | 93 | .. autoclass:: TagScriptEngine.block.RangeBlock 94 | 95 | -------------- 96 | Redirect Block 97 | -------------- 98 | 99 | .. autoclass:: TagScriptEngine.block.RedirectBlock 100 | 101 | ------------- 102 | Replace Block 103 | ------------- 104 | 105 | .. autoclass:: TagScriptEngine.block.ReplaceBlock 106 | 107 | ------------- 108 | Require Block 109 | ------------- 110 | 111 | .. autoclass:: TagScriptEngine.block.RequireBlock 112 | 113 | ---------------------- 114 | ShortCutRedirect Block 115 | ---------------------- 116 | 117 | .. autoclass:: TagScriptEngine.block.ShortCutRedirectBlock 118 | 119 | ---------- 120 | STRF Block 121 | ---------- 122 | 123 | .. autoclass:: TagScriptEngine.block.StrfBlock 124 | 125 | --------------------- 126 | Strict Variable Block 127 | --------------------- 128 | 129 | .. autoclass:: TagScriptEngine.block.StrictVariableGetterBlock 130 | 131 | -------------------- 132 | SubstringBlock Block 133 | -------------------- 134 | 135 | .. autoclass:: TagScriptEngine.block.SubstringBlock 136 | 137 | ---------------- 138 | URL Encode Block 139 | ---------------- 140 | 141 | .. autoclass:: TagScriptEngine.block.URLEncodeBlock 142 | -------------------------------------------------------------------------------- /TagScriptEngine/block/command.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..interface import Block, verb_required_block 4 | from ..interpreter import Context 5 | 6 | 7 | class CommandBlock(verb_required_block(True, payload=True)): 8 | """ 9 | Run a command as if the tag invoker had ran it. Only 3 command 10 | blocks can be used in a tag. 11 | 12 | **Usage:** ``{command:}`` 13 | 14 | **Aliases:** ``c, com, command`` 15 | 16 | **Payload:** command 17 | 18 | **Parameter:** None 19 | 20 | **Examples:** :: 21 | 22 | {c:ping} 23 | # invokes ping command 24 | 25 | {c:ban {target(id)} Chatflood/spam} 26 | # invokes ban command on the pinged user with the reason as "Chatflood/spam" 27 | """ 28 | 29 | ACCEPTED_NAMES = ("c", "com", "command") 30 | 31 | def __init__(self, limit: int = 3): 32 | self.limit = limit 33 | super().__init__() 34 | 35 | def process(self, ctx: Context) -> Optional[str]: 36 | command = ctx.verb.payload.strip() 37 | actions = ctx.response.actions.get("commands") 38 | if actions: 39 | if len(actions) >= self.limit: 40 | return f"`COMMAND LIMIT REACHED ({self.limit})`" 41 | else: 42 | ctx.response.actions["commands"] = [] 43 | ctx.response.actions["commands"].append(command) 44 | return "" 45 | 46 | 47 | class OverrideBlock(Block): 48 | """ 49 | Override a command's permission requirements. This can override 50 | mod, admin, or general user permission requirements when running commands 51 | with the :ref:`Command Block`. Passing no parameter will default to overriding 52 | all permissions. 53 | 54 | In order to add a tag with the override block, the tag author must have ``Manage 55 | Server`` permissions. 56 | 57 | This will not override bot owner commands or command checks. 58 | 59 | **Usage:** ``{override(["admin"|"mod"|"permissions"]):[command]}`` 60 | 61 | **Payload:** command 62 | 63 | **Parameter:** "admin", "mod", "permissions" 64 | 65 | **Examples:** :: 66 | 67 | {override} 68 | # overrides all commands and permissions 69 | 70 | {override(admin)} 71 | # overrides commands that require the admin role 72 | 73 | {override(permissions)} 74 | {override(mod)} 75 | # overrides commands that require the mod role or have user permission requirements 76 | """ 77 | 78 | ACCEPTED_NAMES = ("override",) 79 | 80 | def process(self, ctx: Context) -> Optional[str]: 81 | param = ctx.verb.parameter 82 | if not param: 83 | ctx.response.actions["overrides"] = {"admin": True, "mod": True, "permissions": True} 84 | return "" 85 | 86 | param = param.strip().lower() 87 | if param not in ("admin", "mod", "permissions"): 88 | return None 89 | overrides = ctx.response.actions.get( 90 | "overrides", {"admin": False, "mod": False, "permissions": False} 91 | ) 92 | overrides[param] = True 93 | ctx.response.actions["overrides"] = overrides 94 | return "" 95 | -------------------------------------------------------------------------------- /TagScriptEngine/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from discord.ext.commands import Cooldown 6 | 7 | if TYPE_CHECKING: 8 | from .interpreter import Interpreter, Response 9 | 10 | 11 | __all__ = ( 12 | "TagScriptError", 13 | "WorkloadExceededError", 14 | "ProcessError", 15 | "EmbedParseError", 16 | "BadColourArgument", 17 | "StopError", 18 | "CooldownExceeded", 19 | ) 20 | 21 | 22 | class TagScriptError(Exception): 23 | """Base class for all module errors.""" 24 | 25 | 26 | class WorkloadExceededError(TagScriptError): 27 | """Raised when the interpreter goes over its passed character limit.""" 28 | 29 | 30 | class ProcessError(TagScriptError): 31 | """ 32 | Raised when an exception occurs during interpreter processing. 33 | 34 | Attributes 35 | ---------- 36 | original: Exception 37 | The original exception that occurred during processing. 38 | response: Response 39 | The incomplete response that was being processed when the exception occurred. 40 | interpreter: Interpreter 41 | The interpreter used for processing. 42 | """ 43 | 44 | def __init__(self, error: Exception, response: Response, interpreter: Interpreter): 45 | self.original: Exception = error 46 | self.response: Response = response 47 | self.interpreter: Interpreter = interpreter 48 | super().__init__(error) 49 | 50 | 51 | class EmbedParseError(TagScriptError): 52 | """Raised if an exception occurs while attempting to parse an embed.""" 53 | 54 | 55 | class BadColourArgument(EmbedParseError): 56 | """ 57 | Raised when the passed input fails to convert to `discord.Colour`. 58 | 59 | Attributes 60 | ---------- 61 | argument: str 62 | The invalid input. 63 | """ 64 | 65 | def __init__(self, argument: str): 66 | self.argument = argument 67 | super().__init__(f'Colour "{argument}" is invalid.') 68 | 69 | 70 | class StopError(TagScriptError): 71 | """ 72 | Raised by the StopBlock to stop processing. 73 | 74 | Attributes 75 | ---------- 76 | message: str 77 | The stop error message. 78 | """ 79 | 80 | def __init__(self, message: str): 81 | self.message = message 82 | super().__init__(message) 83 | 84 | 85 | class CooldownExceeded(StopError): 86 | """ 87 | Raised by the cooldown block when a cooldown is exceeded. 88 | 89 | Attributes 90 | ---------- 91 | message: str 92 | The cooldown error message. 93 | cooldown: discord.ext.commands.Cooldown 94 | The cooldown bucket with information on the cooldown. 95 | key: str 96 | The cooldown key that reached its cooldown. 97 | retry_after: float 98 | The seconds left til the cooldown ends. 99 | """ 100 | 101 | def __init__(self, message: str, cooldown: Cooldown, key: str, retry_after: float): 102 | self.cooldown = cooldown 103 | self.key = key 104 | self.retry_after = retry_after 105 | super().__init__(message) 106 | -------------------------------------------------------------------------------- /TagScriptEngine/block/replaceblock.py: -------------------------------------------------------------------------------- 1 | from ..interface import verb_required_block 2 | from ..interpreter import Context 3 | 4 | 5 | class ReplaceBlock(verb_required_block(True, payload=True, parameter=True)): 6 | """ 7 | The replace block will replace specific characters in a string. 8 | The parameter should split by a ``,``, containing the characters to find 9 | before the command and the replacements after. 10 | 11 | **Usage:** ``{replace():}`` 12 | 13 | **Aliases:** ``None`` 14 | 15 | **Payload:** message 16 | 17 | **Parameter:** original, new 18 | 19 | **Examples:** :: 20 | 21 | {replace(o,i):welcome to the server} 22 | # welcime ti the server 23 | 24 | {replace(1,6):{args}} 25 | # if {args} is 1637812 26 | # 6637862 27 | 28 | {replace(, ):Test} 29 | # T e s t 30 | """ 31 | 32 | ACCEPTED_NAMES = ("replace",) 33 | 34 | def process(self, ctx: Context): 35 | try: 36 | before, after = ctx.verb.parameter.split(",", 1) 37 | except ValueError: 38 | return 39 | 40 | return ctx.verb.payload.replace(before, after) 41 | 42 | 43 | class PythonBlock(verb_required_block(True, payload=True, parameter=True)): 44 | """ 45 | The in block serves three different purposes depending on the alias that is used. 46 | 47 | The ``in`` alias checks if the parameter is anywhere in the payload. 48 | 49 | ``contain`` strictly checks if the parameter is the payload, split by whitespace. 50 | 51 | ``index`` finds the location of the parameter in the payload, split by whitespace. 52 | If the parameter string is not found in the payload, it returns 1. 53 | 54 | index is used to return the value of the string form the given list of 55 | 56 | **Usage:** ``{in():}`` 57 | 58 | **Aliases:** ``index``, ``contains`` 59 | 60 | **Payload:** payload 61 | 62 | **Parameter:** string 63 | 64 | **Examples:** :: 65 | 66 | {in(apple pie):banana pie apple pie and other pie} 67 | # true 68 | {in(mute):How does it feel to be muted?} 69 | # true 70 | {in(a):How does it feel to be muted?} 71 | # false 72 | 73 | {contains(mute):How does it feel to be muted?} 74 | # false 75 | {contains(muted?):How does it feel to be muted?} 76 | # false 77 | 78 | {index(food):I love to eat food. everyone does.} 79 | # 4 80 | {index(pie):I love to eat food. everyone does.} 81 | # -1 82 | """ 83 | 84 | def will_accept(self, ctx: Context): 85 | dec = ctx.verb.declaration.lower() 86 | return dec in ("contains", "in", "index") 87 | 88 | def process(self, ctx: Context): 89 | dec = ctx.verb.declaration.lower() 90 | if dec == "contains": 91 | return str(bool(ctx.verb.parameter in ctx.verb.payload.split())).lower() 92 | elif dec == "in": 93 | return str(bool(ctx.verb.parameter in ctx.verb.payload)).lower() 94 | else: 95 | try: 96 | return str(ctx.verb.payload.strip().split().index(ctx.verb.parameter)) 97 | except ValueError: 98 | return "-1" 99 | -------------------------------------------------------------------------------- /TagScriptEngine/block/helpers.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Optional 3 | 4 | __all__ = ("implicit_bool", "helper_parse_if", "helper_split", "helper_parse_list_if") 5 | 6 | SPLIT_REGEX = re.compile(r"(? Optional[bool]: 11 | """ 12 | Parse a string to a boolean. 13 | 14 | >>> implicit_bool("true") 15 | True 16 | >>> implicit_bool("FALSE") 17 | False 18 | >>> implicit_bool("abc") 19 | None 20 | 21 | Parameters 22 | ---------- 23 | string: str 24 | The string to convert. 25 | 26 | Returns 27 | ------- 28 | bool 29 | The boolean value of the string. 30 | None 31 | The string failed to parse. 32 | """ 33 | return BOOL_LOOKUP.get(string.lower()) 34 | 35 | 36 | def helper_parse_if(string: str) -> Optional[bool]: 37 | """ 38 | Parse an expression string to a boolean. 39 | 40 | >>> helper_parse_if("this == this") 41 | True 42 | >>> helper_parse_if("2>3") 43 | False 44 | >>> helper_parse_if("40 >= 40") 45 | True 46 | >>> helper_parse_if("False") 47 | False 48 | >>> helper_parse_if("1") 49 | None 50 | 51 | Parameters 52 | ---------- 53 | string: str 54 | The string to convert. 55 | 56 | Returns 57 | ------- 58 | bool 59 | The boolean value of the expression. 60 | None 61 | The expression failed to parse. 62 | """ 63 | value = implicit_bool(string) 64 | if value is not None: 65 | return value 66 | try: 67 | if "!=" in string: 68 | spl = string.split("!=") 69 | return spl[0].strip() != spl[1].strip() 70 | if "==" in string: 71 | spl = string.split("==") 72 | return spl[0].strip() == spl[1].strip() 73 | if ">=" in string: 74 | spl = string.split(">=") 75 | return float(spl[0].strip()) >= float(spl[1].strip()) 76 | if "<=" in string: 77 | spl = string.split("<=") 78 | return float(spl[0].strip()) <= float(spl[1].strip()) 79 | if ">" in string: 80 | spl = string.split(">") 81 | return float(spl[0].strip()) > float(spl[1].strip()) 82 | if "<" in string: 83 | spl = string.split("<") 84 | return float(spl[0].strip()) < float(spl[1].strip()) 85 | except: 86 | pass 87 | 88 | 89 | def helper_split( 90 | split_string: str, easy: bool = True, *, maxsplit: int = None 91 | ) -> Optional[List[str]]: 92 | """ 93 | A helper method to universalize the splitting logic used in multiple 94 | blocks and adapters. Please use this wherever a verb needs content to 95 | be chopped at | , or ~! 96 | 97 | >>> helper_split("this, should|work") 98 | ["this, should", "work"] 99 | """ 100 | args = (maxsplit,) if maxsplit is not None else () 101 | if "|" in split_string: 102 | return SPLIT_REGEX.split(split_string, *args) 103 | if easy: 104 | if "~" in split_string: 105 | return split_string.split("~", *args) 106 | if "," in split_string: 107 | return split_string.split(",", *args) 108 | return 109 | 110 | 111 | def helper_parse_list_if(if_string): 112 | split = helper_split(if_string, False) 113 | if split is None: 114 | return [helper_parse_if(if_string)] 115 | return [helper_parse_if(item) for item in split] 116 | -------------------------------------------------------------------------------- /TagScriptEngine/interface/block.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import lru_cache 4 | from typing import TYPE_CHECKING, Optional 5 | 6 | if TYPE_CHECKING: 7 | from ..interpreter import Context 8 | 9 | 10 | __all__ = ("Block", "verb_required_block") 11 | 12 | 13 | class Block: 14 | """ 15 | The base class for TagScript blocks. 16 | 17 | Implementations must subclass this to create new blocks. 18 | 19 | Attributes 20 | ---------- 21 | ACCEPTED_NAMES: Tuple[str, ...] 22 | The accepted names for this block. This ideally should be set as a class attribute. 23 | """ 24 | 25 | ACCEPTED_NAMES = () 26 | 27 | def __repr__(self): 28 | return f"<{type(self).__qualname__} at {hex(id(self))}>" 29 | 30 | @classmethod 31 | def will_accept(cls, ctx: Context) -> bool: 32 | """ 33 | Describes whether the block is valid for the given :class:`~TagScriptEngine.interpreter.Context`. 34 | 35 | Parameters 36 | ---------- 37 | ctx: Context 38 | The context object containing the TagScript :class:`~TagScriptEngine.verb.Verb`. 39 | 40 | Returns 41 | ------- 42 | bool 43 | Whether the block should be processed for this :class:`~TagScriptEngine.interpreter.Context`. 44 | """ 45 | dec = ctx.verb.declaration.lower() 46 | return dec in cls.ACCEPTED_NAMES 47 | 48 | def pre_process(self, ctx: Context): 49 | return None 50 | 51 | def process(self, ctx: Context) -> Optional[str]: 52 | """ 53 | Processes the block's actions for a given :class:`~TagScriptEngine.interpreter.Context`. 54 | 55 | Subclasses must implement this. 56 | 57 | Parameters 58 | ---------- 59 | ctx: Context 60 | The context object containing the TagScript :class:`~TagScriptEngine.verb.Verb`. 61 | 62 | Returns 63 | ------- 64 | Optional[str] 65 | The block's processed value. 66 | 67 | Raises 68 | ------ 69 | NotImplementedError 70 | The subclass did not implement this required method. 71 | """ 72 | raise NotImplementedError 73 | 74 | def post_process(self, ctx: "interpreter.Context"): 75 | return None 76 | 77 | 78 | @lru_cache(maxsize=None) 79 | def verb_required_block( 80 | implicit: bool, 81 | *, 82 | parameter: bool = False, 83 | payload: bool = False, 84 | ) -> Block: 85 | """ 86 | Get a Block subclass that requires a verb to implicitly or explicitly have a parameter or payload passed. 87 | 88 | Parameters 89 | ---------- 90 | implicit: bool 91 | Specifies whether the value is required to be passed implicitly or explicitly. 92 | ``{block()}`` would be allowed if implicit is False. 93 | parameter: bool 94 | Passing True will cause the block to require a parameter to be passed. 95 | payload: bool 96 | Passing True will cause the block to require the payload to be passed. 97 | """ 98 | check = (lambda x: x) if implicit else (lambda x: x is not None) 99 | 100 | class RequireMeta(type): 101 | def __repr__(self): 102 | return f"VerbRequiredBlock(implicit={implicit!r}, payload={payload!r}, parameter={parameter!r})" 103 | 104 | class VerbRequiredBlock(Block, metaclass=RequireMeta): 105 | def will_accept(self, ctx: Context) -> bool: 106 | verb = ctx.verb 107 | if payload and not check(verb.payload): 108 | return False 109 | if parameter and not check(verb.parameter): 110 | return False 111 | return super().will_accept(ctx) 112 | 113 | return VerbRequiredBlock 114 | -------------------------------------------------------------------------------- /TagScriptEngine/block/cooldown.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, Dict, Optional 3 | 4 | from discord.ext.commands import CooldownMapping 5 | 6 | from ..exceptions import CooldownExceeded 7 | from ..interface import verb_required_block 8 | from ..interpreter import Context 9 | from .helpers import helper_split 10 | 11 | __all__ = ("CooldownBlock",) 12 | 13 | 14 | class CooldownBlock(verb_required_block(True, payload=True, parameter=True)): 15 | """ 16 | The cooldown block implements cooldowns when running a tag. 17 | The parameter requires 2 values to be passed: ``rate`` and ``per`` integers. 18 | The ``rate`` is the number of times the tag can be used every ``per`` seconds. 19 | 20 | The payload requires a ``key`` value, which is the key used to store the cooldown. 21 | A key should be any string that is unique. If a channel's ID is passed as a key, 22 | the tag's cooldown will be enforced on that channel. Running the tag in a separate channel 23 | would have a different cooldown with the same ``rate`` and ``per`` values. 24 | 25 | The payload also has an optional ``message`` value, which is the message to be sent when the 26 | cooldown is exceeded. If no message is passed, the default message will be sent instead. 27 | The cooldown message supports 2 blocks: ``key`` and ``retry_after``. 28 | 29 | **Usage:** ``{cooldown(|):|[message]}`` 30 | 31 | **Payload:** key, message 32 | 33 | **Parameter:** rate, per 34 | 35 | **Examples:** :: 36 | 37 | {cooldown(1|10):{author(id)}} 38 | # the tag author used the tag more than once in 10 seconds 39 | # The bucket for 741074175875088424 has reached its cooldown. Retry in 3.25 seconds." 40 | 41 | {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."} 42 | # the tag was used more than 3 times in 3 seconds in a channel 43 | # Slow down! This tag can only be used 3 times per 3 seconds per channel. Try again in **0.74** seconds. 44 | """ 45 | 46 | ACCEPTED_NAMES = ("cooldown",) 47 | COOLDOWNS: Dict[Any, CooldownMapping] = {} 48 | 49 | @classmethod 50 | def create_cooldown(cls, key: Any, rate: int, per: int) -> CooldownMapping: 51 | cooldown = CooldownMapping.from_cooldown(rate, per, lambda x: x) 52 | cls.COOLDOWNS[key] = cooldown 53 | return cooldown 54 | 55 | def process(self, ctx: Context) -> Optional[str]: 56 | verb = ctx.verb 57 | try: 58 | rate, per = helper_split(verb.parameter, maxsplit=1) 59 | per = int(per) 60 | rate = float(rate) 61 | except (ValueError, TypeError): 62 | return 63 | 64 | if split := helper_split(verb.payload, False, maxsplit=1): 65 | key, message = split 66 | else: 67 | key = verb.payload 68 | message = None 69 | 70 | cooldown_key = ctx.response.extra_kwargs.get("cooldown_key") 71 | if cooldown_key is None: 72 | cooldown_key = ctx.original_message 73 | try: 74 | cooldown = self.COOLDOWNS[cooldown_key] 75 | base = cooldown._cooldown 76 | if (rate, per) != (base.rate, base.per): 77 | cooldown = self.create_cooldown(cooldown_key, rate, per) 78 | except KeyError: 79 | cooldown = self.create_cooldown(cooldown_key, rate, per) 80 | 81 | current = time.time() 82 | bucket = cooldown.get_bucket(key, current) 83 | retry_after = bucket.update_rate_limit(current) 84 | if retry_after: 85 | retry_after = round(retry_after, 2) 86 | if message: 87 | message = message.replace("{key}", str(key)).replace( 88 | "{retry_after}", str(retry_after) 89 | ) 90 | else: 91 | message = f"The bucket for {key} has reached its cooldown. Retry in {retry_after} seconds." 92 | raise CooldownExceeded(message, bucket, key, retry_after) 93 | return "" 94 | -------------------------------------------------------------------------------- /Tests/test_verbs.py: -------------------------------------------------------------------------------- 1 | import time 2 | import unittest 3 | 4 | from TagScriptEngine import Interpreter, adapter, block 5 | 6 | 7 | class TestVerbFunctionality(unittest.TestCase): 8 | def setUp(self): 9 | self.blocks = [ 10 | block.BreakBlock(), 11 | block.MathBlock(), 12 | block.RandomBlock(), 13 | block.RangeBlock(), 14 | block.StrfBlock(), 15 | block.AssignmentBlock(), 16 | block.FiftyFiftyBlock(), 17 | block.StrictVariableGetterBlock(), 18 | ] 19 | self.engine = Interpreter(self.blocks) 20 | 21 | def tearDown(self): 22 | self.blocks = None 23 | self.engine = None 24 | 25 | def seen_all(self, string, outcomes, tries=100): 26 | unique_outcomes = set(outcomes) 27 | seen_outcomes = set() 28 | for _ in range(tries): 29 | outcome = self.engine.process(string).body 30 | seen_outcomes.add(outcome) 31 | 32 | result = unique_outcomes == seen_outcomes 33 | 34 | if not result: 35 | print("Error from '" + string + "'") 36 | print("Seen:") 37 | for item in seen_outcomes: 38 | print("> " + str(item)) 39 | print("Expected: ") 40 | for item in unique_outcomes: 41 | print(">> " + str(item)) 42 | 43 | return result 44 | 45 | def test_random(self): 46 | # Test simple random 47 | test = "{random:Hello,Goodbye}" 48 | expect = ["Hello", "Goodbye"] 49 | self.assertTrue(self.seen_all(test, expect)) 50 | 51 | # Test that it wont crash with a false block 52 | test = "{random:{ahad},one,two}" 53 | expect = ["{ahad}", "one", "two"] 54 | self.assertTrue(self.seen_all(test, expect)) 55 | 56 | # Test that inner blocks can use , to sep and outer use ~ without tripping 57 | # Also testing embedded random 58 | test = "{random:{random:1,2} cakes~No cakes}" 59 | expect = ["1 cakes", "2 cakes", "No cakes"] 60 | self.assertTrue(self.seen_all(test, expect)) 61 | 62 | # Test random being able to use a var 63 | test = "{assign(li):1,2,3,4}{random:{li}}" 64 | expect = ["1", "2", "3", "4"] 65 | self.assertTrue(self.seen_all(test, expect)) 66 | 67 | def test_fifty(self): 68 | # Test simple 5050 69 | test = "Hi{5050: :)}" 70 | expect = ["Hi", "Hi :)"] 71 | self.assertTrue(self.seen_all(test, expect)) 72 | 73 | # Test simple embedded 5050 74 | test = "Hi{5050: :){5050: :(}}" 75 | expect = ["Hi", "Hi :)", "Hi :) :("] 76 | self.assertTrue(self.seen_all(test, expect)) 77 | 78 | def test_range(self): 79 | # Test simple range 80 | test = "{range:1-2} cows" 81 | expect = ["1 cows", "2 cows"] 82 | self.assertTrue(self.seen_all(test, expect)) 83 | # Test simple float range 84 | test = "{rangef:1.5-2.5} cows" 85 | self.assertTrue("." in self.engine.process(test).body) 86 | 87 | def test_math(self): 88 | test = "{math:100/2}" 89 | expect = "50.0" # division implies float 90 | self.assertEqual(self.engine.process(test).body, expect) 91 | 92 | test = "{math:100**100**100}" # should 'fail' 93 | self.assertEqual(self.engine.process(test).body, test) 94 | 95 | def test_misc(self): 96 | # Test using a variable to get a variable 97 | data = { 98 | "pointer": adapter.StringAdapter("message"), 99 | "message": adapter.StringAdapter("Hello"), 100 | } 101 | test = "{{pointer}}" 102 | self.assertEqual(self.engine.process(test, data).body, "Hello") 103 | 104 | test = r"\{{pointer}\}" 105 | self.assertEqual(self.engine.process(test, data).body, r"\{message\}") 106 | 107 | test = "{break(10==10):Override.} This is my actual tag!" 108 | self.assertEqual(self.engine.process(test, data).body, "Override.") 109 | 110 | def test_cuddled_strf(self): 111 | t = time.gmtime() 112 | huggle_wuggle = time.strftime("%y%y%y%y") 113 | self.assertEqual(self.engine.process("{strf:%y%y%y%y}").body, huggle_wuggle) 114 | 115 | def test_basic_strf(self): 116 | year = time.strftime("%Y") 117 | self.assertEqual(self.engine.process("Hehe, it's {strf:%Y}").body, f"Hehe, it's {year}") 118 | -------------------------------------------------------------------------------- /TagScriptEngine/verb.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | __all__ = ("Verb",) 4 | 5 | 6 | class Verb: 7 | """ 8 | Represents the passed TagScript block. 9 | 10 | Parameters 11 | ---------- 12 | verb_string: Optional[str] 13 | The string to parse into a verb. 14 | limit: int 15 | The maximum number of characters to parse. 16 | dot_parameter: bool 17 | Whether the parameter should be followed after a "." or use the default of parantheses. 18 | 19 | Attributes 20 | ---------- 21 | declaration: Optional[str] 22 | The text used to declare the block. 23 | parameter: Optional[str] 24 | The text passed to the block parameter in the parentheses. 25 | payload: Optional[str] 26 | The text passed to the block payload after the colon. 27 | 28 | Example 29 | ------- 30 | Below is a visual representation of a block and its attributes:: 31 | 32 | {declaration(parameter):payload} 33 | 34 | # dot_parameter = True 35 | {declaration.parameter:payload} 36 | """ 37 | 38 | __slots__ = ( 39 | "declaration", 40 | "parameter", 41 | "payload", 42 | "parsed_string", 43 | "dec_depth", 44 | "dec_start", 45 | "skip_next", 46 | "parsed_length", 47 | "dot_parameter", 48 | ) 49 | 50 | def __init__( 51 | self, verb_string: Optional[str] = None, *, limit: int = 2000, dot_parameter: bool = False 52 | ): 53 | self.declaration: Optional[str] = None 54 | self.parameter: Optional[str] = None 55 | self.payload: Optional[str] = None 56 | self.dot_parameter = dot_parameter 57 | if verb_string is None: 58 | return 59 | self.__parse(verb_string, limit) 60 | 61 | def __str__(self): 62 | """This makes Verb compatible with str(x)""" 63 | response = "{" 64 | if self.declaration is not None: 65 | response += self.declaration 66 | if self.parameter is not None: 67 | response += f".{self.parameter}" if self.dot_parameter else f"({self.parameter})" 68 | if self.payload is not None: 69 | response += ":" + self.payload 70 | return response + "}" 71 | 72 | def __repr__(self): 73 | attrs = ("declaration", "parameter", "payload") 74 | inner = " ".join(f"{attr}={getattr(self, attr)!r}" for attr in attrs) 75 | return f"" 76 | 77 | def __parse(self, verb_string: str, limit: int): 78 | self.parsed_string = verb_string[1:-1][:limit] 79 | self.parsed_length = len(self.parsed_string) 80 | self.dec_depth = 0 81 | self.dec_start = 0 82 | self.skip_next = False 83 | 84 | parse_parameter = ( 85 | self._parse_dot_parameter if self.dot_parameter else self._parse_paranthesis_parameter 86 | ) 87 | 88 | for i, v in enumerate(self.parsed_string): 89 | if self.skip_next: 90 | self.skip_next = False 91 | continue 92 | elif v == "\\": 93 | self.skip_next = True 94 | continue 95 | 96 | if v == ":" and not self.dec_depth: 97 | # if v == ":" and not dec_depth: 98 | self.set_payload() 99 | return 100 | elif parse_parameter(i, v): 101 | return 102 | else: 103 | self.set_payload() 104 | 105 | def _parse_paranthesis_parameter(self, i: int, v: str) -> bool: 106 | if v == "(": 107 | self.open_parameter(i) 108 | elif v == ")" and self.dec_depth: 109 | return self.close_parameter(i) 110 | return False 111 | 112 | def _parse_dot_parameter(self, i: int, v: str) -> bool: 113 | if v == ".": 114 | self.open_parameter(i) 115 | elif (v == ":" or i == self.parsed_length - 1) and self.dec_depth: 116 | return self.close_parameter(i + 1) 117 | return False 118 | 119 | def set_payload(self): 120 | res = self.parsed_string.split(":", 1) 121 | if len(res) == 2: 122 | self.payload = res[1] 123 | self.declaration = res[0] 124 | 125 | def open_parameter(self, i: int): 126 | self.dec_depth += 1 127 | if not self.dec_start: 128 | self.dec_start = i 129 | self.declaration = self.parsed_string[:i] 130 | 131 | def close_parameter(self, i: int) -> bool: 132 | self.dec_depth -= 1 133 | if self.dec_depth == 0: 134 | self.parameter = self.parsed_string[self.dec_start + 1 : i] 135 | try: 136 | if self.parsed_string[i + 1] == ":": 137 | self.payload = self.parsed_string[i + 2 :] 138 | except IndexError: 139 | pass 140 | return True 141 | return False 142 | -------------------------------------------------------------------------------- /TagScriptEngine/block/mathblock.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | import math 4 | import operator 5 | from typing import Optional 6 | 7 | from pyparsing import ( 8 | CaselessLiteral, 9 | Combine, 10 | Forward, 11 | Group, 12 | Literal, 13 | Optional, 14 | Word, 15 | ZeroOrMore, 16 | alphas, 17 | nums, 18 | oneOf, 19 | ) 20 | 21 | from ..interface import Block 22 | from ..interpreter import Context 23 | 24 | 25 | class NumericStringParser(object): 26 | """ 27 | Most of this code comes from the fourFn.py pyparsing example 28 | 29 | """ 30 | 31 | def pushFirst(self, strg, loc, toks): 32 | self.exprStack.append(toks[0]) 33 | 34 | def pushUMinus(self, strg, loc, toks): 35 | if toks and toks[0] == "-": 36 | self.exprStack.append("unary -") 37 | 38 | def __init__(self): 39 | """ 40 | expop :: '^' 41 | multop :: '*' | '/' 42 | addop :: '+' | '-' 43 | integer :: ['+' | '-'] '0'..'9'+ 44 | atom :: PI | E | real | fn '(' expr ')' | '(' expr ')' 45 | factor :: atom [ expop factor ]* 46 | term :: factor [ multop factor ]* 47 | expr :: term [ addop term ]* 48 | """ 49 | point = Literal(".") 50 | e = CaselessLiteral("E") 51 | fnumber = Combine( 52 | Word("+-" + nums, nums) 53 | + Optional(point + Optional(Word(nums))) 54 | + Optional(e + Word("+-" + nums, nums)) 55 | ) 56 | ident = Word(alphas, alphas + nums + "_$") 57 | mod = Literal("%") 58 | plus = Literal("+") 59 | minus = Literal("-") 60 | mult = Literal("*") 61 | iadd = Literal("+=") 62 | imult = Literal("*=") 63 | idiv = Literal("/=") 64 | isub = Literal("-=") 65 | div = Literal("/") 66 | lpar = Literal("(").suppress() 67 | rpar = Literal(")").suppress() 68 | addop = plus | minus 69 | multop = mult | div | mod 70 | iop = iadd | isub | imult | idiv 71 | expop = Literal("^") 72 | pi = CaselessLiteral("PI") 73 | expr = Forward() 74 | atom = ( 75 | ( 76 | Optional(oneOf("- +")) 77 | + (ident + lpar + expr + rpar | pi | e | fnumber).setParseAction(self.pushFirst) 78 | ) 79 | | Optional(oneOf("- +")) + Group(lpar + expr + rpar) 80 | ).setParseAction(self.pushUMinus) 81 | # by defining exponentiation as "atom [ ^ factor ]..." instead of 82 | # "atom [ ^ atom ]...", we get right-to-left exponents, instead of left-to-right 83 | # that is, 2^3^2 = 2^(3^2), not (2^3)^2. 84 | factor = Forward() 85 | factor << atom + ZeroOrMore((expop + factor).setParseAction(self.pushFirst)) 86 | term = factor + ZeroOrMore((multop + factor).setParseAction(self.pushFirst)) 87 | expr << term + ZeroOrMore((addop + term).setParseAction(self.pushFirst)) 88 | final = expr + ZeroOrMore((iop + expr).setParseAction(self.pushFirst)) 89 | # addop_term = ( addop + term ).setParseAction( self.pushFirst ) 90 | # general_term = term + ZeroOrMore( addop_term ) | OneOrMore( addop_term) 91 | # expr << general_term 92 | self.bnf = final 93 | # map operator symbols to corresponding arithmetic operations 94 | epsilon = 1e-12 95 | self.opn = { 96 | "+": operator.add, 97 | "-": operator.sub, 98 | "+=": operator.iadd, 99 | "-=": operator.isub, 100 | "*": operator.mul, 101 | "*=": operator.imul, 102 | "/": operator.truediv, 103 | "/=": operator.itruediv, 104 | "^": operator.pow, 105 | "%": operator.mod, 106 | } 107 | self.fn = { 108 | "sin": math.sin, 109 | "cos": math.cos, 110 | "tan": math.tan, 111 | "sinh": math.sinh, 112 | "cosh": math.cosh, 113 | "tanh": math.tanh, 114 | "exp": math.exp, 115 | "abs": abs, 116 | "trunc": lambda a: int(a), 117 | "round": round, 118 | "sgn": lambda a: abs(a) > epsilon and ((a > 0) - (a < 0)) or 0, 119 | "log": lambda a: math.log(a, 10), 120 | "ln": math.log, 121 | "log2": math.log2, 122 | "sqrt": math.sqrt, 123 | } 124 | 125 | def evaluateStack(self, s): 126 | op = s.pop() 127 | if op == "unary -": 128 | return -self.evaluateStack(s) 129 | if op in self.opn: 130 | op2 = self.evaluateStack(s) 131 | op1 = self.evaluateStack(s) 132 | return self.opn[op](op1, op2) 133 | elif op == "PI": 134 | return math.pi # 3.1415926535 135 | elif op == "E": 136 | return math.e # 2.718281828 137 | elif op in self.fn: 138 | return self.fn[op](self.evaluateStack(s)) 139 | elif op[0].isalpha(): 140 | return 0 141 | else: 142 | return float(op) 143 | 144 | def eval(self, num_string, parseAll=True): 145 | self.exprStack = [] 146 | results = self.bnf.parseString(num_string, parseAll) 147 | return self.evaluateStack(self.exprStack[:]) 148 | 149 | 150 | NSP = NumericStringParser() 151 | 152 | 153 | class MathBlock(Block): 154 | ACCEPTED_NAMES = ("math", "m", "+", "calc") 155 | 156 | def process(self, ctx: Context): 157 | try: 158 | return str(NSP.eval(ctx.verb.payload.strip(" "))) 159 | except: 160 | return None 161 | -------------------------------------------------------------------------------- /TagScriptEngine/block/control.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..interface import verb_required_block 4 | from ..interpreter import Context 5 | from . import helper_parse_if, helper_parse_list_if, helper_split 6 | 7 | 8 | def parse_into_output(payload: str, result: Optional[bool]) -> Optional[str]: 9 | if result is None: 10 | return 11 | try: 12 | output = helper_split(payload, False) 13 | if output != None and len(output) == 2: 14 | if result: 15 | return output[0] 16 | else: 17 | return output[1] 18 | elif result: 19 | return payload 20 | else: 21 | return "" 22 | except: 23 | return 24 | 25 | 26 | ImplicitPPRBlock = verb_required_block(True, payload=True, parameter=True) 27 | 28 | 29 | class AnyBlock(ImplicitPPRBlock): 30 | """ 31 | The any block checks that any of the passed expressions are true. 32 | Multiple expressions can be passed to the parameter by splitting them with ``|``. 33 | 34 | The payload is a required message that must be split by ``|``. 35 | If the expression evaluates true, then the message before the ``|`` is returned, else the message after is returned. 36 | 37 | **Usage:** ``{any():}`` 38 | 39 | **Aliases:** ``or`` 40 | 41 | **Payload:** message 42 | 43 | **Parameter:** expression 44 | 45 | **Examples:** :: 46 | 47 | {any({args}==hi|{args}==hello|{args}==heyy):Hello {user}!|How rude.} 48 | # if {args} is hi 49 | Hello sravan#0001! 50 | 51 | # if {args} is what's up! 52 | How rude. 53 | """ 54 | 55 | ACCEPTED_NAMES = ("any", "or") 56 | 57 | def process(self, ctx: Context) -> Optional[str]: 58 | result = any(helper_parse_list_if(ctx.verb.parameter) or []) 59 | return parse_into_output(ctx.verb.payload, result) 60 | 61 | 62 | class AllBlock(ImplicitPPRBlock): 63 | """ 64 | The all block checks that all of the passed expressions are true. 65 | Multiple expressions can be passed to the parameter by splitting them with ``|``. 66 | 67 | The payload is a required message that must be split by ``|``. 68 | If the expression evaluates true, then the message before the ``|`` is returned, else the message after is returned. 69 | 70 | **Usage:** ``{all():}`` 71 | 72 | **Aliases:** ``and`` 73 | 74 | **Payload:** message 75 | 76 | **Parameter:** expression 77 | 78 | **Examples:** :: 79 | 80 | {all({args}>=100|{args}<=1000):You picked {args}.|You must provide a number between 100 and 1000.} 81 | # if {args} is 52 82 | You must provide a number between 100 and 1000. 83 | 84 | # if {args} is 282 85 | You picked 282. 86 | """ 87 | 88 | ACCEPTED_NAMES = ("all", "and") 89 | 90 | def process(self, ctx: Context) -> Optional[str]: 91 | result = all(helper_parse_list_if(ctx.verb.parameter) or []) 92 | return parse_into_output(ctx.verb.payload, result) 93 | 94 | 95 | class IfBlock(ImplicitPPRBlock): 96 | """ 97 | The if block returns a message based on the passed expression to the parameter. 98 | An expression is represented by two values compared with an operator. 99 | 100 | The payload is a required message that must be split by ``|``. 101 | If the expression evaluates true, then the message before the ``|`` is returned, else the message after is returned. 102 | 103 | **Expression Operators:** 104 | 105 | +----------+--------------------------+---------+---------------------------------------------+ 106 | | Operator | Check | Example | Description | 107 | +==========+==========================+=========+=============================================+ 108 | | ``==`` | equality | a==a | value 1 is equal to value 2 | 109 | +----------+--------------------------+---------+---------------------------------------------+ 110 | | ``!=`` | inequality | a!=b | value 1 is not equal to value 2 | 111 | +----------+--------------------------+---------+---------------------------------------------+ 112 | | ``>`` | greater than | 5>3 | value 1 is greater than value 2 | 113 | +----------+--------------------------+---------+---------------------------------------------+ 114 | | ``<`` | less than | 4<8 | value 1 is less than value 2 | 115 | +----------+--------------------------+---------+---------------------------------------------+ 116 | | ``>=`` | greater than or equality | 10>=10 | value 1 is greater than or equal to value 2 | 117 | +----------+--------------------------+---------+---------------------------------------------+ 118 | | ``<=`` | less than or equality | 5<=6 | value 1 is less than or equal to value 2 | 119 | +----------+--------------------------+---------+---------------------------------------------+ 120 | 121 | **Usage:** ``{if():]}`` 122 | 123 | **Payload:** message 124 | 125 | **Parameter:** expression 126 | 127 | **Examples:** :: 128 | 129 | {if({args}==63):You guessed it! The number I was thinking of was 63!|Too {if({args}<63):low|high}, try again.} 130 | # if args is 63 131 | # You guessed it! The number I was thinking of was 63! 132 | 133 | # if args is 73 134 | # Too low, try again. 135 | 136 | # if args is 14 137 | # Too high, try again. 138 | """ 139 | 140 | ACCEPTED_NAMES = ("if",) 141 | 142 | def process(self, ctx: Context) -> Optional[str]: 143 | result = helper_parse_if(ctx.verb.parameter) 144 | return parse_into_output(ctx.verb.payload, result) 145 | -------------------------------------------------------------------------------- /TagScriptEngine/adapter/discordadapters.py: -------------------------------------------------------------------------------- 1 | from random import choice 2 | 3 | import discord 4 | 5 | from ..interface import Adapter 6 | from ..utils import DPY2, escape_content 7 | from ..verb import Verb 8 | 9 | __all__ = ( 10 | "AttributeAdapter", 11 | "MemberAdapter", 12 | "ChannelAdapter", 13 | "GuildAdapter", 14 | ) 15 | 16 | 17 | class AttributeAdapter(Adapter): 18 | __slots__ = ("object", "_attributes", "_methods") 19 | 20 | def __init__(self, base): 21 | self.object = base 22 | created_at = getattr(base, "created_at", None) or discord.utils.snowflake_time(base.id) 23 | self._attributes = { 24 | "id": base.id, 25 | "created_at": created_at, 26 | "timestamp": int(created_at.timestamp()), 27 | "name": getattr(base, "name", str(base)), 28 | } 29 | self._methods = {} 30 | self.update_attributes() 31 | self.update_methods() 32 | 33 | def __repr__(self): 34 | return f"<{type(self).__qualname__} object={self.object!r}>" 35 | 36 | def update_attributes(self): 37 | pass 38 | 39 | def update_methods(self): 40 | pass 41 | 42 | def get_value(self, ctx: Verb) -> str: 43 | should_escape = False 44 | 45 | if ctx.parameter is None: 46 | return_value = str(self.object) 47 | else: 48 | try: 49 | value = self._attributes[ctx.parameter] 50 | except KeyError: 51 | if method := self._methods.get(ctx.parameter): 52 | value = method() 53 | else: 54 | return 55 | 56 | if isinstance(value, tuple): 57 | value, should_escape = value 58 | 59 | return_value = str(value) if value is not None else None 60 | 61 | return escape_content(return_value) if should_escape else return_value 62 | 63 | 64 | class MemberAdapter(AttributeAdapter): 65 | """ 66 | The ``{author}`` block with no parameters returns the tag invoker's full username 67 | and discriminator, but passing the attributes listed below to the block payload 68 | will return that attribute instead. 69 | 70 | **Aliases:** ``user`` 71 | 72 | **Usage:** ``{author([attribute])`` 73 | 74 | **Payload:** None 75 | 76 | **Parameter:** attribute, None 77 | 78 | Attributes 79 | ---------- 80 | id 81 | The author's Discord ID. 82 | name 83 | The author's username. 84 | nick 85 | The author's nickname, if they have one, else their username. 86 | avatar 87 | A link to the author's avatar, which can be used in embeds. 88 | discriminator 89 | The author's discriminator. 90 | created_at 91 | The author's account creation date. 92 | timestamp 93 | The author's account creation date as a UTC timestamp. 94 | joined_at 95 | The date the author joined the server. 96 | joinstamp 97 | The author's join date as a UTC timestamp. 98 | mention 99 | A formatted text that pings the author. 100 | bot 101 | Whether or not the author is a bot. 102 | color 103 | The author's top role's color as a hex code. 104 | top_role 105 | The author's top role. 106 | roleids 107 | A list of the author's role IDs, split by spaces. 108 | """ 109 | 110 | def update_attributes(self): 111 | avatar_url = self.object.display_avatar.url if DPY2 else self.object.avatar_url 112 | joined_at = getattr(self.object, "joined_at", self.object.created_at) 113 | additional_attributes = { 114 | "color": self.object.color, 115 | "colour": self.object.color, 116 | "nick": self.object.display_name, 117 | "avatar": (avatar_url, False), 118 | "discriminator": self.object.discriminator, 119 | "joined_at": joined_at, 120 | "joinstamp": int(joined_at.timestamp()), 121 | "mention": self.object.mention, 122 | "bot": self.object.bot, 123 | "top_role": getattr(self.object, "top_role", ""), 124 | } 125 | if roleids := getattr(self.object, "_roles", None): 126 | additional_attributes["roleids"] = " ".join(str(r) for r in roleids) 127 | self._attributes.update(additional_attributes) 128 | 129 | 130 | class ChannelAdapter(AttributeAdapter): 131 | """ 132 | The ``{channel}`` block with no parameters returns the channel's full name 133 | but passing the attributes listed below to the block payload 134 | will return that attribute instead. 135 | 136 | **Usage:** ``{channel([attribute])`` 137 | 138 | **Payload:** None 139 | 140 | **Parameter:** attribute, None 141 | 142 | Attributes 143 | ---------- 144 | id 145 | The channel's ID. 146 | name 147 | The channel's name. 148 | created_at 149 | The channel's creation date. 150 | timestamp 151 | The channel's creation date as a UTC timestamp. 152 | nsfw 153 | Whether the channel is nsfw. 154 | mention 155 | A formatted text that pings the channel. 156 | topic 157 | The channel's topic. 158 | """ 159 | 160 | def update_attributes(self): 161 | if isinstance(self.object, discord.TextChannel): 162 | additional_attributes = { 163 | "nsfw": self.object.nsfw, 164 | "mention": self.object.mention, 165 | "topic": self.object.topic or "", 166 | } 167 | self._attributes.update(additional_attributes) 168 | 169 | 170 | class GuildAdapter(AttributeAdapter): 171 | """ 172 | The ``{server}`` block with no parameters returns the server's name 173 | but passing the attributes listed below to the block payload 174 | will return that attribute instead. 175 | 176 | **Aliases:** ``guild`` 177 | 178 | **Usage:** ``{server([attribute])`` 179 | 180 | **Payload:** None 181 | 182 | **Parameter:** attribute, None 183 | 184 | Attributes 185 | ---------- 186 | id 187 | The server's ID. 188 | name 189 | The server's name. 190 | icon 191 | A link to the server's icon, which can be used in embeds. 192 | created_at 193 | The server's creation date. 194 | timestamp 195 | The server's creation date as a UTC timestamp. 196 | member_count 197 | The server's member count. 198 | bots 199 | The number of bots in the server. 200 | humans 201 | The number of humans in the server. 202 | description 203 | The server's description if one is set, or "No description". 204 | random 205 | A random member from the server. 206 | """ 207 | 208 | def update_attributes(self): 209 | guild = self.object 210 | bots = 0 211 | humans = 0 212 | for m in guild.members: 213 | if m.bot: 214 | bots += 1 215 | else: 216 | humans += 1 217 | member_count = guild.member_count 218 | icon_url = getattr(guild.icon, "url", "") if DPY2 else guild.icon_url 219 | additional_attributes = { 220 | "icon": (icon_url, False), 221 | "member_count": member_count, 222 | "members": member_count, 223 | "bots": bots, 224 | "humans": humans, 225 | "description": guild.description or "No description.", 226 | } 227 | self._attributes.update(additional_attributes) 228 | 229 | def update_methods(self): 230 | additional_methods = {"random": self.random_member} 231 | self._methods.update(additional_methods) 232 | 233 | def random_member(self): 234 | return choice(self.object.members) 235 | -------------------------------------------------------------------------------- /TagScriptEngine/block/embedblock.py: -------------------------------------------------------------------------------- 1 | import json 2 | from inspect import ismethod 3 | from typing import Optional, Union 4 | 5 | from discord import Colour, Embed 6 | 7 | from ..exceptions import BadColourArgument, EmbedParseError 8 | from ..interface import Block 9 | from ..interpreter import Context 10 | from .helpers import helper_split, implicit_bool 11 | 12 | 13 | def string_to_color(argument: str) -> Colour: 14 | arg = argument.replace("0x", "").lower() 15 | 16 | if arg[0] == "#": 17 | arg = arg[1:] 18 | try: 19 | value = int(arg, base=16) 20 | if not (0 <= value <= 0xFFFFFF): 21 | raise BadColourArgument(arg) 22 | return Colour(value=value) 23 | except ValueError: 24 | arg = arg.replace(" ", "_") 25 | method = getattr(Colour, arg, None) 26 | if arg.startswith("from_") or method is None or not ismethod(method): 27 | raise BadColourArgument(arg) 28 | return method() 29 | 30 | 31 | def set_color(embed: Embed, attribute: str, value: str): 32 | value = string_to_color(value) 33 | setattr(embed, attribute, value) 34 | 35 | 36 | def set_dynamic_url(embed: Embed, attribute: str, value: str): 37 | method = getattr(embed, f"set_{attribute}") 38 | method(url=value) 39 | 40 | 41 | def add_field(embed: Embed, _: str, payload: str): 42 | if (data := helper_split(payload, 3)) is None: 43 | raise EmbedParseError("`add_field` payload was not split by |") 44 | try: 45 | name, value, _inline = data 46 | inline = implicit_bool(_inline) 47 | if inline is None: 48 | raise EmbedParseError( 49 | "`inline` argument for `add_field` is not a boolean value (_inline)" 50 | ) 51 | except ValueError: 52 | name, value = helper_split(payload, 2) 53 | inline = False 54 | embed.add_field(name=name, value=value, inline=inline) 55 | 56 | 57 | def set_footer(embed: Embed, _: str, payload: str): 58 | data = helper_split(payload, 2) 59 | if data is None: 60 | embed.set_footer(text=payload) 61 | else: 62 | text, icon_url = data 63 | embed.set_footer(text=text, icon_url=icon_url) 64 | 65 | 66 | class EmbedBlock(Block): 67 | """ 68 | An embed block will send an embed in the tag response. 69 | There are two ways to use the embed block, either by using properly 70 | formatted embed JSON from an embed generator or manually inputting 71 | the accepted embed attributes. 72 | 73 | **JSON** 74 | 75 | Using JSON to create an embed offers complete embed customization. 76 | Multiple embed generators are available online to visualize and generate 77 | embed JSON. 78 | 79 | **Usage:** ``{embed()}`` 80 | 81 | **Payload:** None 82 | 83 | **Parameter:** json 84 | 85 | **Examples:** :: 86 | 87 | {embed({"title":"Hello!", "description":"This is a test embed."})} 88 | {embed({ 89 | "title":"Here's a random duck!", 90 | "image":{"url":"https://random-d.uk/api/randomimg"}, 91 | "color":15194415 92 | })} 93 | 94 | **Manual** 95 | 96 | The following embed attributes can be set manually: 97 | 98 | * ``title`` 99 | * ``description`` 100 | * ``color`` 101 | * ``url`` 102 | * ``thumbnail`` 103 | * ``image`` 104 | * ``footer`` 105 | * ``field`` - (See below) 106 | 107 | Adding a field to an embed requires the payload to be split by ``|``, into 108 | either 2 or 3 parts. The first part is the name of the field, the second is 109 | the text of the field, and the third optionally specifies whether the field 110 | should be inline. 111 | 112 | **Usage:** ``{embed():}`` 113 | 114 | **Payload:** value 115 | 116 | **Parameter:** attribute 117 | 118 | **Examples:** :: 119 | 120 | {embed(color):#37b2cb} 121 | {embed(title):Rules} 122 | {embed(description):Follow these rules to ensure a good experience in our server!} 123 | {embed(field):Rule 1|Respect everyone you speak to.|false} 124 | {embed(footer):Thanks for reading!|{guild(icon)}} 125 | 126 | Both methods can be combined to create an embed in a tag. 127 | The following tagscript uses JSON to create an embed with fields and later 128 | set the embed title. 129 | 130 | :: 131 | 132 | {embed({{"fields":[{"name":"Field 1","value":"field description","inline":false}]})} 133 | {embed(title):my embed title} 134 | """ 135 | 136 | ACCEPTED_NAMES = ("embed",) 137 | 138 | ATTRIBUTE_HANDLERS = { 139 | "description": setattr, 140 | "title": setattr, 141 | "color": set_color, 142 | "colour": set_color, 143 | "url": setattr, 144 | "thumbnail": set_dynamic_url, 145 | "image": set_dynamic_url, 146 | "field": add_field, 147 | "footer": set_footer, 148 | } 149 | 150 | @staticmethod 151 | def get_embed(ctx: Context) -> Embed: 152 | return ctx.response.actions.get("embed", Embed()) 153 | 154 | @staticmethod 155 | def value_to_color(value: Optional[Union[int, str]]) -> Colour: 156 | if value is None or isinstance(value, Colour): 157 | return value 158 | if isinstance(value, int): 159 | return Colour(value) 160 | elif isinstance(value, str): 161 | return string_to_color(value) 162 | else: 163 | raise EmbedParseError("Received invalid type for color key (expected string or int)") 164 | 165 | def text_to_embed(self, text: str) -> Embed: 166 | try: 167 | data = json.loads(text) 168 | except json.decoder.JSONDecodeError as error: 169 | raise EmbedParseError(error) from error 170 | 171 | if data.get("embed"): 172 | data = data["embed"] 173 | if data.get("timestamp"): 174 | data["timestamp"] = data["timestamp"].strip("Z") 175 | 176 | color = data.pop("color", data.pop("colour", None)) 177 | 178 | try: 179 | embed = Embed.from_dict(data) 180 | except Exception as error: 181 | raise EmbedParseError(error) from error 182 | else: 183 | if color := self.value_to_color(color): 184 | embed.color = color 185 | return embed 186 | 187 | @classmethod 188 | def update_embed(cls, embed: Embed, attribute: str, value: str) -> Embed: 189 | handler = cls.ATTRIBUTE_HANDLERS[attribute] 190 | try: 191 | handler(embed, attribute, value) 192 | except Exception as error: 193 | raise EmbedParseError(error) from error 194 | return embed 195 | 196 | @staticmethod 197 | def return_error(error: Exception) -> str: 198 | return f"Embed Parse Error: {error}" 199 | 200 | @staticmethod 201 | def return_embed(ctx: Context, embed: Embed) -> str: 202 | try: 203 | length = len(embed) 204 | except Exception as error: 205 | return str(error) 206 | if length > 6000: 207 | return f"`MAX EMBED LENGTH REACHED ({length}/6000)`" 208 | ctx.response.actions["embed"] = embed 209 | return "" 210 | 211 | def process(self, ctx: Context) -> Optional[str]: 212 | if not ctx.verb.parameter: 213 | return self.return_embed(ctx, self.get_embed(ctx)) 214 | 215 | lowered = ctx.verb.parameter.lower() 216 | try: 217 | if ctx.verb.parameter.startswith("{") and ctx.verb.parameter.endswith("}"): 218 | embed = self.text_to_embed(ctx.verb.parameter) 219 | elif lowered in self.ATTRIBUTE_HANDLERS and ctx.verb.payload: 220 | embed = self.get_embed(ctx) 221 | embed = self.update_embed(embed, lowered, ctx.verb.payload) 222 | else: 223 | return 224 | except EmbedParseError as error: 225 | return self.return_error(error) 226 | 227 | return self.return_embed(ctx, embed) 228 | -------------------------------------------------------------------------------- /Tests/test_edgecase.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from TagScriptEngine import Interpreter, WorkloadExceededError, adapter, block, interface 4 | 5 | 6 | class TestEdgeCases(unittest.TestCase): 7 | def setUp(self): 8 | self.blocks = [ 9 | block.MathBlock(), 10 | block.RandomBlock(), 11 | block.RangeBlock(), 12 | block.AnyBlock(), 13 | block.IfBlock(), 14 | block.AllBlock(), 15 | block.BreakBlock(), 16 | block.StrfBlock(), 17 | block.StopBlock(), 18 | block.AssignmentBlock(), 19 | block.FiftyFiftyBlock(), 20 | block.ShortCutRedirectBlock("message"), 21 | block.LooseVariableGetterBlock(), 22 | block.SubstringBlock(), 23 | block.PythonBlock(), 24 | block.ReplaceBlock(), 25 | ] 26 | self.engine = Interpreter(self.blocks) 27 | 28 | def tearDown(self): 29 | self.blocks = None 30 | self.engine = None 31 | 32 | def test_specific_duplication(self): 33 | # User submitted tag that messes things up. 34 | script = """ 35 | {=(ucode1):𝓪 𝓫 𝓬 𝓭 𝓮 𝓯 𝓰 𝓱 𝓲 𝓳 𝓴 𝓵 𝓶 𝓷 𝓸 𝓹 𝓺 𝓻 𝓼 𝓽 𝓾 𝓿 𝔀 𝔁 𝔂 𝔃} 36 | {=(ucode2):𝕒 𝕓 𝕔 𝕕 𝕖 𝕗 𝕘 𝕙 𝕚 𝕛 𝕜 𝕝 𝕞 𝕟 𝕠 𝕡 𝕢 𝕣 𝕤 𝕥 𝕦 𝕧 𝕨 𝕩 𝕪 𝕫} 37 | {=(ucode3):a b c d e f g h i j k l m n o p q r s t u v w x y z} 38 | {=(ucode4):ⓐ ⓑ ⓒ ⓓ ⓔ ⓕ ⓖ ⓗ ⓘ ⓙ ⓚ ⓛ ⓜ ⓝ ⓞ ⓟ ⓠ ⓡ ⓢ ⓣ ⓤ ⓥ ⓦ ⓧ ⓨ ⓩ} 39 | {=(ucode5):🅐 🅑 🅒 🅓 🅔 🅕 🅖 🅗 🅘 🅙 🅚 🅛 🅜 🅝 🅞 🅟 🅠 🅡 🅢 🅣 🅤 🅥 🅦 🅧 🅨 🅩} 40 | {=(ucode6):𝐚 𝐛 𝐜 𝐝 𝐞 𝐟 𝐠 𝐡 𝐢 𝐣 𝐤 𝐥 𝐦 𝐧 𝐨 𝐩 𝐪 𝐫 𝐬 𝐭 𝐮 𝐯 𝐰 𝐱 𝐲 𝐳} 41 | {=(ucode7):𝖆 𝖇 𝖈 𝖉 𝖊 𝖋 𝖌 𝖍 𝖎 𝖏 𝖐 𝖑 𝖒 𝖓 𝖔 𝖕 𝖖 𝖗 𝖘 𝖙 𝖚 𝖛 𝖜 𝖝 𝖞 𝖟} 42 | {=(ucode8):𝒂 𝒃 𝒄 𝒅 𝒆 𝒇 𝒈 𝒉 𝒊 𝒋 𝒌 𝒍 𝒎 𝒏 𝒐 𝒑 𝒒 𝒓 𝒔 𝒕 𝒖 𝒗 𝒘 𝒙 𝒚 𝒛} 43 | {=(ucode9):𝚊 𝚋 𝚌 𝚍 𝚎 𝚏 𝚐 𝚑 𝚒 𝚓 𝚔 𝚕 𝚖 𝚗 𝚘 𝚙 𝚚 𝚛 𝚜 𝚝 𝚞 𝚟 𝚠 𝚡 𝚢 𝚣} 44 | {=(ucode10):𝖺 𝖻 𝖼 𝖽 𝖾 𝖿 𝗀 𝗁 𝗂 𝗃 𝗄 𝗅 𝗆 𝗇 𝗈 𝗉 𝗊 𝗋 𝗌 𝗍 𝗎 𝗏 𝗐 𝗑 𝗒 𝗓} 45 | {=(ucode11):𝗮 𝗯 𝗰 𝗱 𝗲 𝗳 𝗴 𝗵 𝗶 𝗷 𝗸 𝗹 𝗺 𝗻 𝗼 𝗽 𝗾 𝗿 𝘀 𝘁 𝘂 𝘃 𝘄 𝘅 𝘆 𝘇} 46 | {=(ucode12):𝙖 𝙗 𝙘 𝙙 𝙚 𝙛 𝙜 𝙝 𝙞 𝙟 𝙠 𝙡 𝙢 𝙣 𝙤 𝙥 𝙦 𝙧 𝙨 𝙩 𝙪 𝙫 𝙬 𝙭 𝙮 𝙯} 47 | {=(ucode13):𝘢 𝘣 𝘤 𝘥 𝘦 𝘧 𝘨 𝘩 𝘪 𝘫 𝘬 𝘭 𝘮 𝘯 𝘰 𝘱 𝘲 𝘳 𝘴 𝘵 𝘶 𝘷 𝘸 𝘹 𝘺 𝘻} 48 | {=(ucode14):⒜ ⒝ ⒞ ⒟ ⒠ ⒡ ⒢ ⒣ ⒤ ⒥ ⒦ ⒧ ⒨ ⒩ ⒪ ⒫ ⒬ ⒭ ⒮ ⒯ ⒰ ⒱ ⒲ ⒳ ⒴ ⒵} 49 | {=(ucode15):á b ć d é f ǵ h í j ḱ ĺ ḿ ń ő ṕ q ŕ ś t ú v ẃ x ӳ ź} 50 | {=(ucode16):ค ๒ ƈ ɗ ﻉ ि ﻭ ɦ ٱ ﻝ ᛕ ɭ ๓ ก ѻ ρ ۹ ɼ ร Շ પ ۷ ฝ ซ ץ չ} 51 | {=(ucode17):α в ¢ ∂ є ƒ ﻭ н ι נ к ℓ м η σ ρ ۹ я ѕ т υ ν ω χ у չ} 52 | {=(ucode18):ค ๒ ς ๔ є Ŧ ﻮ ђ เ ן к ɭ ๓ ภ ๏ ק ợ г ร Շ ย ש ฬ א ץ չ} 53 | {=(ucode19):а ъ с ↁ э f Б Ђ і ј к l м и о р q ѓ ѕ т ц v ш х Ў z} 54 | {=(ucode20):ል ጌ ር ዕ ቿ ቻ ኗ ዘ ጎ ጋ ጕ ረ ጠ ክ ዐ የ ዒ ዪ ነ ፕ ሁ ሀ ሠ ሸ ሃ ጊ} 55 | {=(ucode21):𝔞 𝔟 𝔠 𝔡 𝔢 𝔣 𝔤 𝔥 𝔦 𝔧 𝔨 𝔩 𝔪 𝔫 𝔬 𝔭 𝔮 𝔯 𝔰 𝔱 𝔲 𝔳 𝔴 𝔵 𝔶 𝔷} 56 | {=(ucode22):ä ḅ ċ ḋ ë ḟ ġ ḧ ï j ḳ ḷ ṁ ṅ ö ṗ q ṛ ṡ ẗ ü ṿ ẅ ẍ ÿ ż} 57 | {=(ucode23):Ⱥ ƀ ȼ đ ɇ f ǥ ħ ɨ ɉ ꝁ ł m n ø ᵽ ꝗ ɍ s ŧ ᵾ v w x ɏ ƶ} 58 | {=(uppercasesplit):comment variable} 59 | {=(ucode24):𝓐 𝓑 𝓒 𝓓 𝓔 𝓕 𝓖 𝓗 𝓘 𝓙 𝓚 𝓛 𝓜 𝓝 𝓞 𝓟 𝓠 𝓡 𝓢 𝓣 𝓤 𝓥 𝓦 𝓧 𝓨 𝓩} 60 | {=(ucode25):𝔸 𝔹 ℂ 𝔻 𝔼 𝔽 𝔾 ℍ 𝕀 𝕁 𝕂 𝕃 𝕄 ℕ 𝕆 ℙ ℚ ℝ 𝕊 𝕋 𝕌 𝕍 𝕎 𝕏 𝕐 ℤ} 61 | {=(ucode26):Ⓐ Ⓑ Ⓒ Ⓓ Ⓔ Ⓕ Ⓖ Ⓗ Ⓘ Ⓙ Ⓚ Ⓛ Ⓜ Ⓝ Ⓞ Ⓟ Ⓠ Ⓡ Ⓢ Ⓣ Ⓤ Ⓥ Ⓦ Ⓧ Ⓨ Ⓩ} 62 | {=(ucode27):🅐 🅑 🅒 🅓 🅔 🅕 🅖 🅗 🅘 🅙 🅚 🅛 🅜 🅝 🅞 🅟 🅠 🅡 🅢 🅣 🅤 🅥 🅦 🅧 🅨 🅩} 63 | {=(ucode28):A B C D E F G H I J K L M N O P Q R S T U V W X Y Z} 64 | {=(ucode29):𝐀 𝐁 𝐂 𝐃 𝐄 𝐅 𝐆 𝐇 𝐈 𝐉 𝐊 𝐋 𝐌 𝐍 𝐎 𝐏 𝐐 𝐑 𝐒 𝐓 𝐔 𝐕 𝐖 𝐗 𝐘 𝐙} 65 | {=(ucode30):𝕬 𝕭 𝕮 𝕯 𝕰 𝕱 𝕲 𝕳 𝕴 𝕵 𝕶 𝕷 𝕸 𝕹 𝕺 𝕻 𝕼 𝕽 𝕾 𝕿 𝖀 𝖁 𝖂 𝖃 𝖄 𝖅} 66 | {=(ucode31):𝑨 𝑩 𝑪 𝑫 𝑬 𝑭 𝑮 𝑯 𝑰 𝑱 𝑲 𝑳 𝑴 𝑵 𝑶 𝑷 𝑸 𝑹 𝑺 𝑻 𝑼 𝑽 𝑾 𝑿 𝒀 𝒁} 67 | {=(ucode32):𝖠 𝖡 𝖢 𝖣 𝖤 𝖥 𝖦 𝖧 𝖨 𝖩 𝖪 𝖫 𝖬 𝖭 𝖮 𝖯 𝖰 𝖱 𝖲 𝖳 𝖴 𝖵 𝖶 𝖷 𝖸 𝖹} 68 | {=(ucode33):𝙰 𝙱 𝙲 𝙳 𝙴 𝙵 𝙶 𝙷 𝙸 𝙹 𝙺 𝙻 𝙼 𝙽 𝙾 𝙿 𝚀 𝚁 𝚂 𝚃 𝚄 𝚅 𝚆 𝚇 𝚈 𝚉} 69 | {=(ucode34):𝗔 𝗕 𝗖 𝗗 𝗘 𝗙 𝗚 𝗛 𝗜 𝗝 𝗞 𝗟 𝗠 𝗡 𝗢 𝗣 𝗤 𝗥 𝗦 𝗧 𝗨 𝗩 𝗪 𝗫 𝗬 𝗭} 70 | {=(ucode35):𝘼 𝘽 𝘾 𝘿 𝙀 𝙁 𝙂 𝙃 𝙄 𝙅 𝙆 𝙇 𝙈 𝙉 𝙊 𝙋 𝙌 𝙍 𝙎 𝙏 𝙐 𝙑 𝙒 𝙓 𝙔 𝙕} 71 | {=(ucode36):𝘈 𝘉 𝘊 𝘋 𝘌 𝘍 𝘎 𝘏 𝘐 𝘑 𝘒 𝘓 𝘔 𝘕 𝘖 𝘗 𝘘 𝘙 𝘚 𝘛 𝘜 𝘝 𝘞 𝘟 𝘠 𝘡} 72 | {=(ucode37):🇦 🇧 🇨 🇩 🇪 🇫 🇬 🇭 🇮 🇯 🇰 🇱 🇲 🇳 🇴 🇵 🇶 🇷 🇸 🇹 🇺 🇻 🇼 🇽 🇾 🇿} 73 | {=(ucode38):🄰 🄱 🄲 🄳 🄴 🄵 🄶 🄷 🄸 🄹 🄺 🄻 🄼 🄽 🄾 🄿 🅀 🅁 🅂 🅃 🅄 🅅 🅆 🅇 🅈 🅉} 74 | {=(ucode39):🅰 🅱 🅲 🅳 🅴 🅵 🅶 🅷 🅸 🅹 🅺 🅻 🅼 🅽 🅾 🅿 🆀 🆁 🆂 🆃 🆄 🆅 🆆 🆇 🆈 🆉} 75 | {=(ucode40):Á B Ć D É F Ǵ H í J Ḱ Ĺ Ḿ Ń Ő Ṕ Q Ŕ ś T Ű V Ẃ X Ӳ Ź} 76 | {=(ucode41):Д Б Ҁ ↁ Є F Б Н І Ј Ќ L М И Ф Р Q Я Ѕ Г Ц V Щ Ж Ч Z} 77 | {=(ucode42):𝔄 𝔅 ℭ 𝔇 𝔈 𝔉 𝔊 ℌ ℑ 𝔍 𝔎 𝔏 𝔐 𝔑 𝔒 𝔓 𝔔 ℜ 𝔖 𝔗 𝔘 𝔙 𝔚 𝔛 𝔜 ℨ} 78 | {=(ucode43):Ä Ḅ Ċ Ḋ Ё Ḟ Ġ Ḧ Ї J Ḳ Ḷ Ṁ Ṅ Ö Ṗ Q Ṛ Ṡ Ṫ Ü Ṿ Ẅ Ẍ Ÿ Ż} 79 | {=(ucode44):Ⱥ Ƀ Ȼ Đ Ɇ F Ǥ Ħ Ɨ Ɉ Ꝁ Ł M N Ø Ᵽ Ꝗ Ɍ S Ŧ ᵾ V W X Ɏ Ƶ} 80 | {=(ucode45):ᴀ ʙ ᴄ ᴅ ᴇ ғ ɢ ʜ ɪ ᴊ ᴋ ʟ ᴍ ɴ ᴏ ᴘ ǫ ʀ s ᴛ ᴜ ᴠ ᴡ x ʏ ᴢ} 81 | {=(ucode):{ucode1} {ucode2} {ucode3} {ucode4} {ucode5} {ucode6} {ucode7} {ucode8} {ucode9} {ucode10} {ucode11} {ucode12} {ucode13} {ucode14} {ucode15} {ucode16} {ucode17} {ucode18} {ucode19} {ucode20} {ucode21} {ucode22} {ucode23} {ucode24} {ucode25} {ucode26} {ucode27} {ucode28} {ucode29} {ucode30} {ucode31} {ucode32} {ucode33} {ucode34} {ucode35} {ucode36} {ucode37} {ucode38} {ucode39} {ucode40} {ucode41} {ucode42} {ucode43} {ucode44} {ucode45}} 82 | {=(referencemap):a b c d e f g h i j k l m n o p q r s t u v w x y z} 83 | {=(username):{replace(, ):{target}}} 84 | {=(username):{if({contains({username(2)}):{ucode}}==true):{replace({username(2)},{{if({m:trunc({index({username(2)}):{ucode}}+1)}>598):upper|lower}:{referencemap({m:trunc(({index({username(2)}):{ucode}}+1)%26)})}}):{username}}|{username}}} 85 | {=(username):{if({contains({username(3)}):{ucode}}==true):{replace({username(3)},{referencemap({m:trunc(({index({username(3)}):{ucode}}+1)%26)})}):{username}}|{username}}} 86 | {=(username):{if({contains({username(4)}):{ucode}}==true):{replace({username(4)},{referencemap({m:trunc(({index({username(4)}):{ucode}}+1)%26)})}):{username}}|{username}}} 87 | {=(username):{if({contains({username(5)}):{ucode}}==true):{replace({username(5)},{referencemap({m:trunc(({index({username(5)}):{ucode}}+1)%26)})}):{username}}|{username}}} 88 | {=(username):{if({contains({username(6)}):{ucode}}==true):{replace({username(6)},{referencemap({m:trunc(({index({username(6)}):{ucode}}+1)%26)})}):{username}}|{username}}} 89 | {=(username):{if({contains({username(7)}):{ucode}}==true):{replace({username(7)},{referencemap({m:trunc(({index({username(7)}):{ucode}}+1)%26)})}):{username}}|{username}}} 90 | {=(username):{if({contains({username(8)}):{ucode}}==true):{replace({username(8)},{referencemap({m:trunc(({index({username(8)}):{ucode}}+1)%26)})}):{username}}|{username}}} 91 | {=(username):{if({contains({username(9)}):{ucode}}==true):{replace({username(9)},{referencemap({m:trunc(({index({username(9)}):{ucode}}+1)%26)})}):{username}}|{username}}} 92 | {=(username):{if({contains({username(10)}):{ucode}}==true):{replace({username(10)},{referencemap({m:trunc(({index({username(10)}):{ucode}}+1)%26)})}):{username}}|{username}}} 93 | {=(username):{if({contains({username(11)}):{ucode}}==true):{replace({username(11)},{referencemap({m:trunc(({index({username(11)}):{ucode}}+1)%26)})}):{username}}|{username}}} 94 | {=(username):{if({contains({username(12)}):{ucode}}==true):{replace({username(12)},{referencemap({m:trunc(({index({username(12)}):{ucode}}+1)%26)})}):{username}}|{username}}} 95 | {=(username):{if({contains({username(13)}):{ucode}}==true):{replace({username(13)},{referencemap({m:trunc(({index({username(13)}):{ucode}}+1)%26)})}):{username}}|{username}}} 96 | {=(username):{if({contains({username(14)}):{ucode}}==true):{replace({username(14)},{referencemap({m:trunc(({index({username(14)}):{ucode}}+1)%26)})}):{username}}|{username}}} 97 | {=(username):{if({contains({username(15)}):{ucode}}==true):{replace({username(15)},{referencemap({m:trunc(({index({username(15)}):{ucode}}+1)%26)})}):{username}}|{username}}} 98 | {=(username):{if({contains({username(16)}):{ucode}}==true):{replace({username(16)},{referencemap({m:trunc(({index({username(16)}):{ucode}}+1)%26)})}):{username}}|{username}}} 99 | {=(username):{if({contains({username(17)}):{ucode}}==true):{replace({username(17)},{referencemap({m:trunc(({index({username(17)}):{ucode}}+1)%26)})}):{username}}|{username}}} 100 | {=(username):{if({contains({username(18)}):{ucode}}==true):{replace({username(18)},{referencemap({m:trunc(({index({username(18)}):{ucode}}+1)%26)})}):{username}}|{username}}} 101 | {=(username):{if({contains({username(19)}):{ucode}}==true):{replace({username(19)},{referencemap({m:trunc(({index({username(19)}):{ucode}}+1)%26)})}):{username}}|{username}}} 102 | {=(username):{if({contains({username(20)}):{ucode}}==true):{replace({username(20)},{referencemap({m:trunc(({index({username(20)}):{ucode}}+1)%26)})}):{username}}|{username}}} 103 | {=(username):{if({contains({username(21)}):{ucode}}==true):{replace({username(21)},{referencemap({m:trunc(({index({username(21)}):{ucode}}+1)%26)})}):{username}}|{username}}} 104 | {=(username):{if({contains({username(22)}):{ucode}}==true):{replace({username(22)},{referencemap({m:trunc(({index({username(22)}):{ucode}}+1)%26)})}):{username}}|{username}}} 105 | {=(username):{if({contains({username(23)}):{ucode}}==true):{replace({username(23)},{referencemap({m:trunc(({index({username(23)}):{ucode}}+1)%26)})}):{username}}|{username}}} 106 | {=(username):{if({contains({username(24)}):{ucode}}==true):{replace({username(24)},{referencemap({m:trunc(({index({username(24)}):{ucode}}+1)%26)})}):{username}}|{username}}} 107 | {=(username):{if({contains({username(25)}):{ucode}}==true):{replace({username(25)},{referencemap({m:trunc(({index({username(25)}):{ucode}}+1)%26)})}):{username}}|{username}}} 108 | {=(username):{if({contains({username(26)}):{ucode}}==true):{replace({username(26)},{referencemap({m:trunc(({index({username(26)}):{ucode}}+1)%26)})}):{username}}|{username}}} 109 | {=(username):{if({contains({username(27)}):{ucode}}==true):{replace({username(27)},{referencemap({m:trunc(({index({username(27)}):{ucode}}+1)%26)})}):{username}}|{username}}} 110 | {=(username):{if({contains({username(28)}):{ucode}}==true):{replace({username(28)},{referencemap({m:trunc(({index({username(28)}):{ucode}}+1)%26)})}):{username}}|{username}}} 111 | {=(username):{if({contains({username(29)}):{ucode}}==true):{replace({username(29)},{referencemap({m:trunc(({index({username(29)}):{ucode}}+1)%26)})}):{username}}|{username}}} 112 | {=(username):{if({contains({username(30)}):{ucode}}==true):{replace({username(30)},{referencemap({m:trunc(({index({username(30)}):{ucode}}+1)%26)})}):{username}}|{username}}} 113 | {=(username):{if({contains({username(31)}):{ucode}}==true):{replace({username(31)},{referencemap({m:trunc(({index({username(31)}):{ucode}}+1)%26)})}):{username}}|{username}}} 114 | {=(error):You can't change your own nickname with Carlbot. Please mention somebody after the tag invocation.} 115 | {c:{if({target(id)}=={user(id)}):choose {error},{error}|setnick {target(id)} {join():{username}}}} 116 | """ 117 | data = {"target": adapter.StringAdapter("Basic Username")} 118 | result = self.engine.process(script, data).body 119 | print(result) 120 | self.assertTrue(len(result) < 150) 121 | 122 | def test_recursion(self): 123 | data = {"target": adapter.StringAdapter("Basic Username")} 124 | 125 | with self.assertRaises(WorkloadExceededError): 126 | script = """ 127 | {=(recursion):lol} 128 | {=(recursion):{recursion}{recursion}} 129 | {=(recursion):{recursion}{recursion}} 130 | {=(recursion):{recursion}{recursion}} 131 | {=(recursion):{recursion}{recursion}} 132 | {=(recursion):{recursion}{recursion}} 133 | {=(recursion):{recursion}{recursion}} 134 | {=(recursion):{recursion}{recursion}} 135 | {=(recursion):{recursion}{recursion}} 136 | {=(recursion):{recursion}{recursion}} 137 | {=(recursion):{recursion}{recursion}} 138 | {=(recursion):{recursion}{recursion}} 139 | {=(recursion):{recursion}{recursion}} 140 | {=(recursion):{recursion}{recursion}} 141 | {=(recursion):{recursion}{recursion}} 142 | {=(recursion):{recursion}{recursion}} 143 | {=(recursion):{recursion}{recursion}} 144 | {=(recursion):{recursion}{recursion}} 145 | {=(recursion):{recursion}{recursion}} 146 | {=(recursion):{recursion}{recursion}} 147 | {=(recursion):{recursion}{recursion}} 148 | {=(recursion):{recursion}{recursion}} 149 | {=(recursion):{recursion}{recursion}} 150 | {=(recursion):{recursion}{recursion}} 151 | {=(recursion):{recursion}{recursion}} 152 | {=(recursion):{recursion}{recursion}} 153 | {=(recursion):{recursion}{recursion}} 154 | {=(recursion):{recursion}{recursion}} 155 | {=(recursion):{recursion}{recursion}} 156 | {recursion} 157 | """ 158 | 159 | self.engine.process(script, data, charlimit=2000) 160 | -------------------------------------------------------------------------------- /TagScriptEngine/interpreter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from itertools import islice 5 | from typing import Any, Dict, List, Optional, Tuple 6 | 7 | from .exceptions import ProcessError, StopError, TagScriptError, WorkloadExceededError 8 | from .interface import Adapter, Block 9 | from .utils import maybe_await 10 | from .verb import Verb 11 | 12 | __all__ = ( 13 | "Interpreter", 14 | "AsyncInterpreter", 15 | "Context", 16 | "Response", 17 | "Node", 18 | "build_node_tree", 19 | ) 20 | 21 | log = logging.getLogger(__name__) 22 | 23 | AdapterDict = Dict[str, Adapter] 24 | 25 | 26 | class Node: 27 | """ 28 | A low-level object representing a bracketed block. 29 | 30 | Attributes 31 | ---------- 32 | coordinates: Tuple[int, int] 33 | The start and end position of the bracketed text block. 34 | verb: Optional[Verb] 35 | The determined Verb for this node. 36 | output: 37 | The `Block` processed output for this node. 38 | """ 39 | 40 | __slots__ = ("output", "verb", "coordinates") 41 | 42 | def __init__(self, coordinates: Tuple[int, int], verb: Optional[Verb] = None): 43 | self.output: Optional[str] = None 44 | self.verb = verb 45 | self.coordinates = coordinates 46 | 47 | def __str__(self): 48 | return str(self.verb) + " at " + str(self.coordinates) 49 | 50 | def __repr__(self): 51 | return f"" 52 | 53 | 54 | def build_node_tree(message: str) -> List[Node]: 55 | """ 56 | Function that finds all possible nodes in a string. 57 | 58 | Returns 59 | ------- 60 | List[Node] 61 | A list of all possible text bracket blocks. 62 | """ 63 | nodes = [] 64 | previous = r"" 65 | 66 | starts = [] 67 | for i, ch in enumerate(message): 68 | if ch == "{" and previous != r"\\": 69 | starts.append(i) 70 | if ch == "}" and previous != r"\\": 71 | if not starts: 72 | continue 73 | coords = (starts.pop(), i) 74 | n = Node(coords) 75 | nodes.append(n) 76 | 77 | previous = ch 78 | return nodes 79 | 80 | 81 | class Response: 82 | """ 83 | An object containing information on a completed TagScript process. 84 | 85 | Attributes 86 | ---------- 87 | body: str 88 | The cleaned message with all verbs interpreted. 89 | actions: Dict[str, Any] 90 | A dictionary that blocks can access and modify to define post-processing actions. 91 | variables: Dict[str, Adapter] 92 | A dictionary of variables that blocks such as the `LooseVariableGetterBlock` can access. 93 | extra_kwargs: Dict[str, Any] 94 | A dictionary of extra keyword arguments that blocks can use to define their own behavior. 95 | """ 96 | 97 | __slots__ = ("body", "actions", "variables", "extra_kwargs") 98 | 99 | def __init__(self, *, variables: AdapterDict = None, extra_kwargs: Dict[str, Any] = None): 100 | self.body: str = None 101 | self.actions: Dict[str, Any] = {} 102 | self.variables: AdapterDict = variables if variables is not None else {} 103 | self.extra_kwargs: Dict[str, Any] = extra_kwargs if extra_kwargs is not None else {} 104 | 105 | def __repr__(self): 106 | return ( 107 | f"" 108 | ) 109 | 110 | 111 | class Context: 112 | """ 113 | An object containing data on the TagScript block processed by the interpreter. 114 | This class is passed to adapters and blocks during processing. 115 | 116 | Attributes 117 | ---------- 118 | verb: Verb 119 | The Verb object representing a TagScript block. 120 | original_message: str 121 | The original message passed to the interpreter. 122 | interpreter: Interpreter 123 | The interpreter processing the TagScript. 124 | """ 125 | 126 | __slots__ = ("verb", "original_message", "interpreter", "response") 127 | 128 | def __init__(self, verb: Verb, res: Response, interpreter: Interpreter, og: str): 129 | self.verb: Verb = verb 130 | self.original_message: str = og 131 | self.interpreter: Interpreter = interpreter 132 | self.response: Response = res 133 | 134 | def __repr__(self): 135 | return f"" 136 | 137 | 138 | class Interpreter: 139 | """ 140 | The TagScript interpreter. 141 | 142 | Attributes 143 | ---------- 144 | blocks: List[Block] 145 | A list of blocks to be used for TagScript processing. 146 | """ 147 | 148 | __slots__ = ("blocks",) 149 | 150 | def __init__(self, blocks: List[Block]): 151 | self.blocks: List[Block] = blocks 152 | 153 | def __repr__(self): 154 | return f"<{type(self).__name__} blocks={self.blocks!r}>" 155 | 156 | def _get_context( 157 | self, 158 | node: Node, 159 | final: str, 160 | *, 161 | response: Response, 162 | original_message: str, 163 | verb_limit: int, 164 | dot_parameter: bool, 165 | ) -> Context: 166 | # Get the updated verb string from coordinates and make the context 167 | start, end = node.coordinates 168 | node.verb = Verb(final[start : end + 1], limit=verb_limit, dot_parameter=dot_parameter) 169 | return Context(node.verb, response, self, original_message) 170 | 171 | def _get_acceptors(self, ctx: Context) -> List[Block]: 172 | acceptors = [b for b in self.blocks if b.will_accept(ctx)] 173 | log.debug("%r acceptors: %r", ctx, acceptors) 174 | return acceptors 175 | 176 | def _process_blocks(self, ctx: Context, node: Node) -> Optional[str]: 177 | acceptors = self._get_acceptors(ctx) 178 | for b in acceptors: 179 | value = b.process(ctx) 180 | if value is not None: # Value found? We're done here. 181 | value = str(value) 182 | node.output = value 183 | return value 184 | 185 | @staticmethod 186 | def _check_workload(charlimit: int, total_work: int, output: str) -> Optional[int]: 187 | if not charlimit: 188 | return 189 | total_work += len(output) 190 | if total_work > charlimit: 191 | raise WorkloadExceededError( 192 | "The TSE interpreter had its workload exceeded. The total characters " 193 | f"attempted were {total_work}/{charlimit}" 194 | ) 195 | return total_work 196 | 197 | @staticmethod 198 | def _text_deform(start: int, end: int, final: str, output: str) -> Tuple[str, int]: 199 | message_slice_len = (end + 1) - start 200 | replacement_len = len(output) 201 | differential = ( 202 | replacement_len - message_slice_len 203 | ) # The change in size of `final` after the change is applied 204 | final = final[:start] + output + final[end + 1 :] 205 | return final, differential 206 | 207 | @staticmethod 208 | def _translate_nodes(node_ordered_list: List[Node], index: int, start: int, differential: int): 209 | for future_n in islice(node_ordered_list, index + 1, None): 210 | new_start = None 211 | new_end = None 212 | if future_n.coordinates[0] > start: 213 | new_start = future_n.coordinates[0] + differential 214 | else: 215 | new_start = future_n.coordinates[0] 216 | 217 | if future_n.coordinates[1] > start: 218 | new_end = future_n.coordinates[1] + differential 219 | else: 220 | new_end = future_n.coordinates[1] 221 | future_n.coordinates = (new_start, new_end) 222 | 223 | def _solve( 224 | self, 225 | message: str, 226 | node_ordered_list: List[Node], 227 | response: Response, 228 | *, 229 | charlimit: int, 230 | verb_limit: int = 2000, 231 | dot_parameter: bool, 232 | ): 233 | final = message 234 | total_work = 0 235 | for index, node in enumerate(node_ordered_list): 236 | start, end = node.coordinates 237 | ctx = self._get_context( 238 | node, 239 | final, 240 | response=response, 241 | original_message=message, 242 | verb_limit=verb_limit, 243 | dot_parameter=dot_parameter, 244 | ) 245 | log.debug("Processing context %r at (%r, %r)", ctx, start, end) 246 | try: 247 | output = self._process_blocks(ctx, node) 248 | except StopError as exc: 249 | log.debug("StopError raised on node %r", node, exc_info=exc) 250 | return final[:start] + exc.message 251 | if output is None: 252 | continue # If there was no value output, no need to text deform. 253 | 254 | total_work = self._check_workload(charlimit, total_work, output) 255 | final, differential = self._text_deform(start, end, final, output) 256 | self._translate_nodes(node_ordered_list, index, start, differential) 257 | return final 258 | 259 | @staticmethod 260 | def _return_response(response: Response, output: str) -> Response: 261 | if response.body is None: 262 | response.body = output.strip() 263 | else: 264 | # Dont override an overridden response. 265 | response.body = response.body.strip() 266 | return response 267 | 268 | def process( 269 | self, 270 | message: str, 271 | seed_variables: AdapterDict = None, 272 | *, 273 | charlimit: Optional[int] = None, 274 | dot_parameter: bool = False, 275 | **kwargs, 276 | ) -> Response: 277 | """ 278 | Processes a given TagScript string. 279 | 280 | Parameters 281 | ---------- 282 | message: str 283 | A TagScript string to be processed. 284 | seed_variables: Dict[str, Adapter] 285 | A dictionary containing strings to adapters to provide context variables for processing. 286 | charlimit: int 287 | The maximum characters to process. 288 | dot_parameter: bool 289 | Whether the parameter should be followed after a "." or use the default of parantheses. 290 | kwargs: Dict[str, Any] 291 | Additional keyword arguments that may be used by blocks during processing. 292 | 293 | Returns 294 | ------- 295 | Response 296 | A response object containing the processed body, actions and variables. 297 | 298 | Raises 299 | ------ 300 | TagScriptError 301 | A block intentionally raised an exception, most likely due to invalid user input. 302 | WorkloadExceededError 303 | Signifies the interpreter reached the character limit, if one was provided. 304 | ProcessError 305 | An unexpected error occurred while processing blocks. 306 | """ 307 | response = Response(variables=seed_variables, extra_kwargs=kwargs) 308 | node_ordered_list = build_node_tree(message) 309 | try: 310 | output = self._solve( 311 | message, 312 | node_ordered_list, 313 | response, 314 | charlimit=charlimit, 315 | dot_parameter=dot_parameter, 316 | ) 317 | except TagScriptError: 318 | raise 319 | except Exception as error: 320 | raise ProcessError(error, response, self) from error 321 | return self._return_response(response, output) 322 | 323 | 324 | class AsyncInterpreter(Interpreter): 325 | """ 326 | An asynchronous subclass of `Interpreter` that allows blocks to implement asynchronous methods. 327 | Synchronous blocks are still supported. 328 | 329 | This subclass has no additional attributes from the `Interpreter` class. 330 | See `Interpreter` for full documentation. 331 | """ 332 | 333 | async def _get_acceptors(self, ctx: Context) -> List[Block]: 334 | return [b for b in self.blocks if await maybe_await(b.will_accept, ctx)] 335 | 336 | async def _process_blocks(self, ctx: Context, node: Node) -> Optional[str]: 337 | acceptors = await self._get_acceptors(ctx) 338 | for b in acceptors: 339 | value = await maybe_await(b.process, ctx) 340 | if value is not None: # Value found? We're done here. 341 | value = str(value) 342 | node.output = value 343 | return value 344 | 345 | async def _solve( 346 | self, 347 | message: str, 348 | node_ordered_list: List[Node], 349 | response: Response, 350 | *, 351 | charlimit: int, 352 | verb_limit: int = 2000, 353 | dot_parameter: bool, 354 | ): 355 | final = message 356 | total_work = 0 357 | 358 | for index, node in enumerate(node_ordered_list): 359 | start, end = node.coordinates 360 | ctx = self._get_context( 361 | node, 362 | final, 363 | response=response, 364 | original_message=message, 365 | verb_limit=verb_limit, 366 | dot_parameter=dot_parameter, 367 | ) 368 | try: 369 | output = await self._process_blocks(ctx, node) 370 | except StopError as exc: 371 | return final[:start] + exc.message 372 | if output is None: 373 | continue # If there was no value output, no need to text deform. 374 | 375 | total_work = self._check_workload(charlimit, total_work, output) 376 | final, differential = self._text_deform(start, end, final, output) 377 | self._translate_nodes(node_ordered_list, index, start, differential) 378 | return final 379 | 380 | async def process( 381 | self, 382 | message: str, 383 | seed_variables: AdapterDict = None, 384 | *, 385 | charlimit: Optional[int] = None, 386 | dot_parameter: bool = False, 387 | **kwargs, 388 | ) -> Response: 389 | """ 390 | Asynchronously process a given TagScript string. 391 | 392 | This method has no additional attributes from the `Interpreter` class. 393 | See `Interpreter.process` for full documentation. 394 | """ 395 | response = Response(variables=seed_variables, extra_kwargs=kwargs) 396 | node_ordered_list = build_node_tree(message) 397 | try: 398 | output = await self._solve( 399 | message, 400 | node_ordered_list, 401 | response, 402 | charlimit=charlimit, 403 | dot_parameter=dot_parameter, 404 | ) 405 | except TagScriptError: 406 | raise 407 | except Exception as error: 408 | raise ProcessError(error, response, self) from error 409 | return self._return_response(response, output) 410 | --------------------------------------------------------------------------------