tgblocks 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of tgblocks might be problematic. Click here for more details.
- tgblocks/__init__.py +26 -0
- tgblocks/__main__.py +6 -0
- tgblocks/cli.py +290 -0
- tgblocks/convert.py +1023 -0
- tgblocks/dsl/__init__.py +33 -0
- tgblocks/dsl/codegen.py +942 -0
- tgblocks/dsl/parser.py +919 -0
- tgblocks/model.py +865 -0
- tgblocks/network.py +174 -0
- tgblocks/runner.py +86 -0
- tgblocks/textlang.py +666 -0
- tgblocks/visual/__init__.py +5 -0
- tgblocks/visual/server.py +990 -0
- tgblocks/visual/static/blocks.html +634 -0
- tgblocks/visual/static/nodes.html +1966 -0
- tgblocks/visual/static/text.html +551 -0
- tgblocks/visual/static/tgblocks-project.js +239 -0
- tgblocks/visual/static/tgblocks-ui.js +327 -0
- tgblocks/visual/static/vendor/blockly.js +1691 -0
- tgblocks-0.2.0.dist-info/METADATA +382 -0
- tgblocks-0.2.0.dist-info/RECORD +24 -0
- tgblocks-0.2.0.dist-info/WHEEL +4 -0
- tgblocks-0.2.0.dist-info/entry_points.txt +2 -0
- tgblocks-0.2.0.dist-info/licenses/LICENSE +21 -0
tgblocks/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# tgblocks: a tiny DSL, a text syntax and a visual editor for Telegram bots.
|
|
2
|
+
#
|
|
3
|
+
# The package is a compiler: .tg DSL or the editor syntax -> aiogram 3 code.
|
|
4
|
+
#
|
|
5
|
+
# from tgblocks import parse, generate
|
|
6
|
+
# code = generate(parse(open("bot.tg").read()))
|
|
7
|
+
#
|
|
8
|
+
# from tgblocks.textlang import compile_text
|
|
9
|
+
# code = compile_text(open("bot.txt").read())
|
|
10
|
+
|
|
11
|
+
from .dsl.codegen import generate
|
|
12
|
+
from .dsl.parser import BotSpec, DSLParseError, parse, parse_file
|
|
13
|
+
from .textlang import compile_text, parse_text
|
|
14
|
+
|
|
15
|
+
__version__ = "0.2.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"__version__",
|
|
19
|
+
"BotSpec",
|
|
20
|
+
"DSLParseError",
|
|
21
|
+
"compile_text",
|
|
22
|
+
"generate",
|
|
23
|
+
"parse",
|
|
24
|
+
"parse_file",
|
|
25
|
+
"parse_text",
|
|
26
|
+
]
|
tgblocks/__main__.py
ADDED
tgblocks/cli.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# Command line interface: run, compile, check and the visual editor.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import runpy
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .dsl.codegen import generate
|
|
15
|
+
from .dsl.parser import DSLParseError, parse_file
|
|
16
|
+
from .runner import missing_token_error, usable_token
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _print_warnings(bot, stream) -> None:
|
|
20
|
+
for warning in getattr(bot, "warnings", ()):
|
|
21
|
+
print("warning: {0}".format(warning), file=stream)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _describe(handler) -> str:
|
|
25
|
+
# One column that actually distinguishes two handlers of the same kind.
|
|
26
|
+
if handler.kind == "command":
|
|
27
|
+
return handler.command or "/"
|
|
28
|
+
if handler.kind == "state":
|
|
29
|
+
return "state[{0}]".format(handler.state)
|
|
30
|
+
if handler.kind == "callback":
|
|
31
|
+
return "callback[{0}]".format(handler.data) if handler.data else "callback"
|
|
32
|
+
extras = []
|
|
33
|
+
if handler.patterns:
|
|
34
|
+
extras.append(", ".join(handler.patterns))
|
|
35
|
+
if handler.chat:
|
|
36
|
+
extras.append("chat=" + handler.chat)
|
|
37
|
+
if handler.from_user:
|
|
38
|
+
extras.append("from_user={0}".format(handler.from_user))
|
|
39
|
+
if handler.regex:
|
|
40
|
+
extras.append("regex=" + handler.regex)
|
|
41
|
+
return "{0}[{1}]".format(handler.kind, "; ".join(extras)) if extras else handler.kind
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
45
|
+
bot = parse_file(args.file)
|
|
46
|
+
events = [_describe(handler) for handler in bot.handlers]
|
|
47
|
+
print(
|
|
48
|
+
"OK {0}: handlers={1} - {2}".format(
|
|
49
|
+
args.file, len(bot.handlers), ", ".join(events) or "none"
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
_print_warnings(bot, sys.stderr)
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_compile(args: argparse.Namespace) -> int:
|
|
57
|
+
bot = parse_file(args.file)
|
|
58
|
+
code = generate(bot, embed_token=args.embed_token)
|
|
59
|
+
_print_warnings(bot, sys.stderr)
|
|
60
|
+
if args.output == "-":
|
|
61
|
+
sys.stdout.write(code)
|
|
62
|
+
else:
|
|
63
|
+
target = Path(args.output)
|
|
64
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
target.write_text(code, encoding="utf-8")
|
|
66
|
+
print("wrote {0} ({1} lines)".format(target, code.count("\n")))
|
|
67
|
+
if not args.embed_token:
|
|
68
|
+
print(
|
|
69
|
+
"note: the token is not written into the file. "
|
|
70
|
+
"Start the bot with TG_BOT_TOKEN=... or pass --embed-token.",
|
|
71
|
+
file=sys.stderr,
|
|
72
|
+
)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
77
|
+
bot = parse_file(args.file)
|
|
78
|
+
_print_warnings(bot, sys.stderr)
|
|
79
|
+
|
|
80
|
+
if args.dry_run:
|
|
81
|
+
# Inspection output must be safe to paste into a repository.
|
|
82
|
+
sys.stdout.write(generate(bot, embed_token=False))
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
token = args.token or os.environ.get("TG_BOT_TOKEN") or usable_token(bot)
|
|
86
|
+
error = missing_token_error(bot, token)
|
|
87
|
+
if error:
|
|
88
|
+
print("error: {0} (file: {1})".format(error, args.file), file=sys.stderr)
|
|
89
|
+
return 2
|
|
90
|
+
|
|
91
|
+
code = generate(bot, embed_token=False)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
import aiogram # noqa: F401
|
|
95
|
+
except ImportError:
|
|
96
|
+
print(
|
|
97
|
+
'error: aiogram is not installed. Run: pip install "tgblocks[runtime]"',
|
|
98
|
+
file=sys.stderr,
|
|
99
|
+
)
|
|
100
|
+
return 2
|
|
101
|
+
|
|
102
|
+
# Write the generated code to a temp file and execute it as __main__.
|
|
103
|
+
with tempfile.TemporaryDirectory(prefix="tgblocks-") as folder:
|
|
104
|
+
target = Path(folder) / "bot.py"
|
|
105
|
+
target.write_text(code, encoding="utf-8")
|
|
106
|
+
print("starting {0} (Ctrl+C to stop)".format(args.file))
|
|
107
|
+
runpy.run_path(str(target), run_name="__main__")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_proxy(args: argparse.Namespace) -> int:
|
|
112
|
+
from .network import ProxyError, check_proxy, parse_proxy, proxy_hint
|
|
113
|
+
|
|
114
|
+
proxies = list(args.proxies)
|
|
115
|
+
if not proxies:
|
|
116
|
+
from os import environ
|
|
117
|
+
|
|
118
|
+
configured = environ.get("TG_PROXY", "").strip()
|
|
119
|
+
if not configured:
|
|
120
|
+
print(
|
|
121
|
+
"error: no proxy given and TG_PROXY is not set.\n"
|
|
122
|
+
" Usage: tgblocks proxy socks5://host:port",
|
|
123
|
+
file=sys.stderr,
|
|
124
|
+
)
|
|
125
|
+
return 2
|
|
126
|
+
proxies = [configured]
|
|
127
|
+
|
|
128
|
+
failed = 0
|
|
129
|
+
for url in proxies:
|
|
130
|
+
try:
|
|
131
|
+
parse_proxy(url)
|
|
132
|
+
except ProxyError as error:
|
|
133
|
+
print("error: {0}: {1}".format(url, error), file=sys.stderr)
|
|
134
|
+
failed += 1
|
|
135
|
+
continue
|
|
136
|
+
works, message, seconds = check_proxy(url, timeout=args.timeout)
|
|
137
|
+
mark = "OK " if works else "FAIL"
|
|
138
|
+
print("{0} {1} ({2:.1f} c) {3}".format(mark, url, seconds, message))
|
|
139
|
+
if works:
|
|
140
|
+
for line in proxy_hint(url).splitlines():
|
|
141
|
+
print(" {0}".format(line))
|
|
142
|
+
else:
|
|
143
|
+
failed += 1
|
|
144
|
+
return 0 if not failed else 1
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_visual(args: argparse.Namespace) -> int:
|
|
148
|
+
from .visual.server import serve
|
|
149
|
+
|
|
150
|
+
serve(
|
|
151
|
+
host=args.host,
|
|
152
|
+
port=args.port,
|
|
153
|
+
open_browser=not args.no_browser,
|
|
154
|
+
view=args.view,
|
|
155
|
+
allow_remote=args.allow_remote,
|
|
156
|
+
)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
161
|
+
parser = argparse.ArgumentParser(
|
|
162
|
+
prog="tgblocks",
|
|
163
|
+
description="A tiny text DSL and three visual editors for Telegram bots. "
|
|
164
|
+
"Run `tgblocks` alone to open the editor, or `tgblocks bot.tg` to start a bot.",
|
|
165
|
+
epilog="examples: tgblocks | tgblocks bot.tg | tgblocks compile bot.tg -o bot.py",
|
|
166
|
+
)
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--version", action="version", version="tgblocks {0}".format(__version__)
|
|
169
|
+
)
|
|
170
|
+
subparsers = parser.add_subparsers(dest="command", metavar="command")
|
|
171
|
+
|
|
172
|
+
run_parser = subparsers.add_parser("run", help="compile a .tg file and start the bot")
|
|
173
|
+
run_parser.add_argument("file", help="path to a .tg file")
|
|
174
|
+
run_parser.add_argument("--token", help="bot token (overrides the DSL and the env)")
|
|
175
|
+
run_parser.add_argument(
|
|
176
|
+
"--dry-run",
|
|
177
|
+
action="store_true",
|
|
178
|
+
help="print the generated aiogram code instead of starting the bot",
|
|
179
|
+
)
|
|
180
|
+
run_parser.set_defaults(func=cmd_run)
|
|
181
|
+
|
|
182
|
+
compile_parser = subparsers.add_parser(
|
|
183
|
+
"compile", help="translate a .tg file into aiogram 3 Python code"
|
|
184
|
+
)
|
|
185
|
+
compile_parser.add_argument("file", help="path to a .tg file")
|
|
186
|
+
compile_parser.add_argument(
|
|
187
|
+
"-o", "--output", default="-", help="output file, or - for stdout (default)"
|
|
188
|
+
)
|
|
189
|
+
compile_parser.add_argument(
|
|
190
|
+
"--embed-token",
|
|
191
|
+
action="store_true",
|
|
192
|
+
help="write the token from the .tg file into the output (off by default: "
|
|
193
|
+
"generated bots read TG_BOT_TOKEN)",
|
|
194
|
+
)
|
|
195
|
+
compile_parser.set_defaults(func=cmd_compile)
|
|
196
|
+
|
|
197
|
+
check_parser = subparsers.add_parser("check", help="validate a .tg file only")
|
|
198
|
+
check_parser.add_argument("file", help="path to a .tg file")
|
|
199
|
+
check_parser.set_defaults(func=cmd_check)
|
|
200
|
+
|
|
201
|
+
proxy_parser = subparsers.add_parser(
|
|
202
|
+
"proxy", help="check that a proxy can reach api.telegram.org"
|
|
203
|
+
)
|
|
204
|
+
proxy_parser.add_argument(
|
|
205
|
+
"proxies", nargs="*",
|
|
206
|
+
help="proxy URLs: socks5://host:port, socks4://, http://, https:// "
|
|
207
|
+
"(default: TG_PROXY)",
|
|
208
|
+
)
|
|
209
|
+
proxy_parser.add_argument(
|
|
210
|
+
"--timeout", type=float, default=8.0, help="seconds per proxy (default 8)"
|
|
211
|
+
)
|
|
212
|
+
proxy_parser.set_defaults(func=cmd_proxy)
|
|
213
|
+
|
|
214
|
+
visual_parser = subparsers.add_parser(
|
|
215
|
+
"visual", help="open the local visual editor in a browser"
|
|
216
|
+
)
|
|
217
|
+
visual_parser.add_argument("--host", default="127.0.0.1", help="bind address")
|
|
218
|
+
visual_parser.add_argument("--port", type=int, default=8765, help="port (default 8765)")
|
|
219
|
+
visual_parser.add_argument(
|
|
220
|
+
"--view",
|
|
221
|
+
choices=["nodes", "blocks", "text"],
|
|
222
|
+
default="nodes",
|
|
223
|
+
help="editor at / : nodes (boxes), blocks (Blockly) or text",
|
|
224
|
+
)
|
|
225
|
+
visual_parser.add_argument(
|
|
226
|
+
"--no-browser", action="store_true", help="do not open a browser window"
|
|
227
|
+
)
|
|
228
|
+
visual_parser.add_argument(
|
|
229
|
+
"--allow-remote",
|
|
230
|
+
action="store_true",
|
|
231
|
+
help="allow --host other than 127.0.0.1 (the editor can run generated code)",
|
|
232
|
+
)
|
|
233
|
+
visual_parser.set_defaults(func=cmd_visual)
|
|
234
|
+
|
|
235
|
+
return parser
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
COMMANDS = {"run", "compile", "check", "visual"}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_bot_file(argument: str) -> bool:
|
|
242
|
+
# `tgblocks bot.tg` is shorthand for `tgblocks run bot.tg`: the command has to
|
|
243
|
+
# work from any folder, without remembering a subcommand.
|
|
244
|
+
if not argument or argument.startswith("-") or argument in COMMANDS:
|
|
245
|
+
return False
|
|
246
|
+
if argument.lower().endswith((".tg", ".txt", ".py")):
|
|
247
|
+
return True
|
|
248
|
+
return Path(argument).is_file()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
252
|
+
parser = build_parser()
|
|
253
|
+
# A redirected stdout on Windows uses the ANSI codepage; without this, any
|
|
254
|
+
# character outside it (a Cyrillic name in a message, an em dash) would raise
|
|
255
|
+
# UnicodeEncodeError instead of printing.
|
|
256
|
+
for stream in (sys.stdout, sys.stderr):
|
|
257
|
+
try:
|
|
258
|
+
stream.reconfigure(errors="replace")
|
|
259
|
+
except (AttributeError, ValueError): # pragma: no cover - exotic streams
|
|
260
|
+
pass
|
|
261
|
+
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
262
|
+
if not arguments:
|
|
263
|
+
# Bare `tgblocks`: the editor is what people mean, and it is also what
|
|
264
|
+
# the README promises.
|
|
265
|
+
arguments = ["visual"]
|
|
266
|
+
elif _is_bot_file(arguments[0]):
|
|
267
|
+
arguments = ["run"] + arguments
|
|
268
|
+
elif arguments[0].startswith("-") and arguments[0] not in ("-h", "--help", "--version"):
|
|
269
|
+
# `tgblocks --port 9000` is the same as `tgblocks visual --port 9000`:
|
|
270
|
+
# the subcommand is optional, the flags are not second-class citizens.
|
|
271
|
+
arguments = ["visual"] + arguments
|
|
272
|
+
args = parser.parse_args(arguments)
|
|
273
|
+
if not getattr(args, "command", None):
|
|
274
|
+
parser.print_help()
|
|
275
|
+
return 0
|
|
276
|
+
try:
|
|
277
|
+
return args.func(args) or 0
|
|
278
|
+
except DSLParseError as error:
|
|
279
|
+
print(error.render(getattr(args, "file", "<dsl>")), file=sys.stderr)
|
|
280
|
+
return 1
|
|
281
|
+
except FileNotFoundError as error:
|
|
282
|
+
print("error: {0}".format(error), file=sys.stderr)
|
|
283
|
+
return 2
|
|
284
|
+
except KeyboardInterrupt:
|
|
285
|
+
print("\nstopped", file=sys.stderr)
|
|
286
|
+
return 130
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
if __name__ == "__main__":
|
|
290
|
+
raise SystemExit(main())
|