t8ot 0.3.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.
- t8ot/__init__.py +36 -0
- t8ot/app.py +260 -0
- t8ot/base/__init__.py +6 -0
- t8ot/base/callback.py +13 -0
- t8ot/base/command.py +14 -0
- t8ot/base/inline.py +13 -0
- t8ot/base/message.py +14 -0
- t8ot/cli/__init__.py +3 -0
- t8ot/cli/main.py +235 -0
- t8ot/context.py +143 -0
- t8ot/fsm/__init__.py +11 -0
- t8ot/fsm/flow.py +41 -0
- t8ot/fsm/storage.py +188 -0
- t8ot/guards.py +64 -0
- t8ot/middleware/__init__.py +3 -0
- t8ot/middleware/base.py +18 -0
- t8ot/types/__init__.py +3 -0
- t8ot/types/keyboards.py +130 -0
- t8ot-0.3.0.dist-info/METADATA +168 -0
- t8ot-0.3.0.dist-info/RECORD +24 -0
- t8ot-0.3.0.dist-info/WHEEL +5 -0
- t8ot-0.3.0.dist-info/entry_points.txt +2 -0
- t8ot-0.3.0.dist-info/licenses/LICENSE +201 -0
- t8ot-0.3.0.dist-info/top_level.txt +1 -0
t8ot/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from .app import Bot
|
|
2
|
+
from .context import Context
|
|
3
|
+
from .base import BaseCommand, BaseCallback, BaseMessage, BaseInline
|
|
4
|
+
from .fsm import (
|
|
5
|
+
BaseFlow,
|
|
6
|
+
Step,
|
|
7
|
+
BaseStorage,
|
|
8
|
+
MemoryStorage,
|
|
9
|
+
SQLiteStorage,
|
|
10
|
+
RedisStorage,
|
|
11
|
+
)
|
|
12
|
+
from .middleware import BaseMiddleware
|
|
13
|
+
from .guards import admin_only, private_only, group_only
|
|
14
|
+
from .types import InlineKeyboard, ReplyKeyboard, remove_keyboard
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Bot",
|
|
18
|
+
"Context",
|
|
19
|
+
"BaseCommand",
|
|
20
|
+
"BaseCallback",
|
|
21
|
+
"BaseMessage",
|
|
22
|
+
"BaseInline",
|
|
23
|
+
"BaseMiddleware",
|
|
24
|
+
"admin_only",
|
|
25
|
+
"private_only",
|
|
26
|
+
"group_only",
|
|
27
|
+
"BaseFlow",
|
|
28
|
+
"Step",
|
|
29
|
+
"MemoryStorage",
|
|
30
|
+
"SQLiteStorage",
|
|
31
|
+
"RedisStorage",
|
|
32
|
+
"BaseStorage",
|
|
33
|
+
"InlineKeyboard",
|
|
34
|
+
"ReplyKeyboard",
|
|
35
|
+
"remove_keyboard",
|
|
36
|
+
]
|
t8ot/app.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import inspect
|
|
3
|
+
import importlib.util
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Type, Dict, List, Callable, Awaitable
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
from telebot.async_telebot import AsyncTeleBot
|
|
10
|
+
from telebot.types import Message, CallbackQuery, InlineQuery
|
|
11
|
+
|
|
12
|
+
from .context import Context
|
|
13
|
+
from .base import BaseCommand, BaseCallback, BaseMessage, BaseInline
|
|
14
|
+
from .fsm import BaseStorage, MemoryStorage, BaseFlow
|
|
15
|
+
from .middleware import BaseMiddleware
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Bot:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
token: str,
|
|
22
|
+
parse_mode: Optional[str] = "HTML",
|
|
23
|
+
storage: Optional[BaseStorage] = None,
|
|
24
|
+
):
|
|
25
|
+
self.bot = AsyncTeleBot(token=token, parse_mode=parse_mode)
|
|
26
|
+
self.storage: BaseStorage = storage or MemoryStorage()
|
|
27
|
+
self.flows: Dict[str, BaseFlow] = {}
|
|
28
|
+
self.middlewares: List[BaseMiddleware] = []
|
|
29
|
+
|
|
30
|
+
# Global message interceptor for active multi-step flows
|
|
31
|
+
@self.bot.message_handler(
|
|
32
|
+
func=lambda msg: self.storage.get_state(msg.from_user.id) is not None,
|
|
33
|
+
content_types=['text', 'contact', 'location']
|
|
34
|
+
)
|
|
35
|
+
async def flow_interceptor(message: Message):
|
|
36
|
+
ctx = Context(self, message)
|
|
37
|
+
await self._execute_with_middlewares(ctx, self._handle_flow_step)
|
|
38
|
+
|
|
39
|
+
def use(self, middleware: BaseMiddleware) -> None:
|
|
40
|
+
"""Registers a global middleware into the execution pipeline."""
|
|
41
|
+
self.middlewares.append(middleware)
|
|
42
|
+
|
|
43
|
+
async def _execute_with_middlewares(
|
|
44
|
+
self,
|
|
45
|
+
ctx: Context,
|
|
46
|
+
handler: Callable[[Context], Awaitable[None]]
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Executes pre-hooks, the target handler, and post-hooks."""
|
|
49
|
+
executed_middlewares: List[BaseMiddleware] = []
|
|
50
|
+
halted = False
|
|
51
|
+
exception: Optional[Exception] = None
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
# 1. Run pre_process hooks sequentially
|
|
55
|
+
for mw in self.middlewares:
|
|
56
|
+
allowed = await mw.pre_process(ctx)
|
|
57
|
+
executed_middlewares.append(mw)
|
|
58
|
+
if not allowed:
|
|
59
|
+
halted = True # Request halted by middleware
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
# 2. Execute target handler
|
|
63
|
+
if not halted:
|
|
64
|
+
await handler(ctx)
|
|
65
|
+
|
|
66
|
+
except Exception as e:
|
|
67
|
+
exception = e
|
|
68
|
+
|
|
69
|
+
# 3. Unwind post_process hooks in reverse order (success, halt or error)
|
|
70
|
+
for mw in reversed(executed_middlewares):
|
|
71
|
+
await mw.post_process(ctx, exception=exception)
|
|
72
|
+
|
|
73
|
+
if exception is not None:
|
|
74
|
+
raise exception
|
|
75
|
+
|
|
76
|
+
def _import_module_from_file(self, file_path: Path, root: Path) -> Optional[ModuleType]:
|
|
77
|
+
"""Import a handler file under a name derived from its relative path.
|
|
78
|
+
|
|
79
|
+
The relative path keeps same-named files in sibling folders
|
|
80
|
+
(``admin/stats.py`` vs ``user/stats.py``) from clashing in
|
|
81
|
+
``sys.modules``. Returns ``None`` when the file cannot be imported.
|
|
82
|
+
"""
|
|
83
|
+
rel = file_path.relative_to(root).as_posix()
|
|
84
|
+
dotted = rel[:-3].replace("/", ".")
|
|
85
|
+
module_name = f"t8ot_dynamic_{dotted}_{abs(hash(str(file_path.resolve())))}"
|
|
86
|
+
|
|
87
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
88
|
+
if not spec or not spec.loader:
|
|
89
|
+
print(f"[t8ot] Warning: Cannot load '{rel}'.")
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
module = importlib.util.module_from_spec(spec)
|
|
93
|
+
sys.modules[module_name] = module
|
|
94
|
+
try:
|
|
95
|
+
spec.loader.exec_module(module)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
del sys.modules[module_name] # Never cache a half-imported module
|
|
98
|
+
print(f"[t8ot] Warning: Failed to import '{rel}': {e!r}")
|
|
99
|
+
return None
|
|
100
|
+
return module
|
|
101
|
+
|
|
102
|
+
def load_handlers(self, directory: str):
|
|
103
|
+
"""Recursively discover and register handler classes under ``directory``."""
|
|
104
|
+
root = Path(directory)
|
|
105
|
+
if not root.is_dir():
|
|
106
|
+
print(f"[t8ot] Warning: '{directory}' is not a directory.")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
for py_file in sorted(root.rglob("*.py")):
|
|
110
|
+
rel = py_file.relative_to(root)
|
|
111
|
+
# Skip private/hidden modules: __init__.py, _helpers.py, .cache/x.py
|
|
112
|
+
if any(part.startswith(("_", ".")) for part in rel.parts):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
module = self._import_module_from_file(py_file, root)
|
|
116
|
+
if not module:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
for _, obj in inspect.getmembers(module, inspect.isclass):
|
|
120
|
+
if obj.__module__ != module.__name__:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if issubclass(obj, BaseCommand) and obj is not BaseCommand:
|
|
124
|
+
self._register_command(obj)
|
|
125
|
+
|
|
126
|
+
elif issubclass(obj, BaseCallback) and obj is not BaseCallback:
|
|
127
|
+
self._register_callback(obj)
|
|
128
|
+
|
|
129
|
+
elif issubclass(obj, BaseMessage) and obj is not BaseMessage:
|
|
130
|
+
self._register_message(obj)
|
|
131
|
+
|
|
132
|
+
elif issubclass(obj, BaseInline) and obj is not BaseInline:
|
|
133
|
+
self._register_inline(obj)
|
|
134
|
+
|
|
135
|
+
elif issubclass(obj, BaseFlow) and obj is not BaseFlow:
|
|
136
|
+
self._register_flow(obj)
|
|
137
|
+
|
|
138
|
+
def _register_flow(self, cls: Type[BaseFlow]):
|
|
139
|
+
instance = cls()
|
|
140
|
+
flow_name = instance.name or cls.__name__.lower().replace("flow", "")
|
|
141
|
+
self.flows[flow_name] = instance
|
|
142
|
+
print(f"[t8ot] Registered flow: {flow_name}")
|
|
143
|
+
|
|
144
|
+
def _register_command(self, cls: Type[BaseCommand]):
|
|
145
|
+
instance = cls()
|
|
146
|
+
cmd_name = instance.name or cls.__name__.lower().replace("command", "")
|
|
147
|
+
|
|
148
|
+
@self.bot.message_handler(commands=[cmd_name])
|
|
149
|
+
async def handler(message: Message):
|
|
150
|
+
ctx = Context(self, message)
|
|
151
|
+
await self._execute_with_middlewares(ctx, instance.execute)
|
|
152
|
+
|
|
153
|
+
print(f"[t8ot] Registered command: /{cmd_name}")
|
|
154
|
+
|
|
155
|
+
def _register_callback(self, cls: Type[BaseCallback]):
|
|
156
|
+
instance = cls()
|
|
157
|
+
pattern = instance.pattern
|
|
158
|
+
filter_fn = (
|
|
159
|
+
lambda call: (call.data == pattern or (call.data and call.data.startswith(pattern)))
|
|
160
|
+
) if pattern else (lambda call: True)
|
|
161
|
+
|
|
162
|
+
@self.bot.callback_query_handler(func=filter_fn)
|
|
163
|
+
async def handler(call: CallbackQuery):
|
|
164
|
+
ctx = Context(self, call)
|
|
165
|
+
await self._execute_with_middlewares(ctx, instance.execute)
|
|
166
|
+
|
|
167
|
+
print(f"[t8ot] Registered callback: {cls.__name__} (pattern={pattern})")
|
|
168
|
+
|
|
169
|
+
def _register_message(self, cls: Type[BaseMessage]):
|
|
170
|
+
instance = cls()
|
|
171
|
+
filter_fn = (lambda msg: msg.text == instance.text_filter) if instance.text_filter else (lambda msg: True)
|
|
172
|
+
|
|
173
|
+
@self.bot.message_handler(func=filter_fn, content_types=instance.content_types)
|
|
174
|
+
async def handler(message: Message):
|
|
175
|
+
# Ignore if user is currently in a flow
|
|
176
|
+
if self.storage.get_state(message.from_user.id) is not None:
|
|
177
|
+
return
|
|
178
|
+
ctx = Context(self, message)
|
|
179
|
+
await self._execute_with_middlewares(ctx, instance.execute)
|
|
180
|
+
|
|
181
|
+
print(f"[t8ot] Registered message handler: {cls.__name__}")
|
|
182
|
+
|
|
183
|
+
def _register_inline(self, cls: Type[BaseInline]):
|
|
184
|
+
instance = cls()
|
|
185
|
+
|
|
186
|
+
@self.bot.inline_handler(func=lambda query: True)
|
|
187
|
+
async def handler(inline_query: InlineQuery):
|
|
188
|
+
ctx = Context(self, inline_query)
|
|
189
|
+
await self._execute_with_middlewares(ctx, instance.execute)
|
|
190
|
+
|
|
191
|
+
print(f"[t8ot] Registered inline handler: {cls.__name__}")
|
|
192
|
+
|
|
193
|
+
async def start_flow(self, ctx: Context, flow_name: str):
|
|
194
|
+
flow = self.flows.get(flow_name)
|
|
195
|
+
if not flow or not flow.steps:
|
|
196
|
+
raise ValueError(f"Flow '{flow_name}' not found or has no steps.")
|
|
197
|
+
|
|
198
|
+
first_step = flow.steps[0]
|
|
199
|
+
self.storage.set_state(ctx.user.id, f"{flow_name}:0")
|
|
200
|
+
await ctx.reply(first_step.prompt)
|
|
201
|
+
|
|
202
|
+
async def cancel_user_flow(self, ctx: Context):
|
|
203
|
+
state = self.storage.get_state(ctx.user.id)
|
|
204
|
+
if state:
|
|
205
|
+
flow_name, _ = state.split(":")
|
|
206
|
+
flow = self.flows.get(flow_name)
|
|
207
|
+
self.storage.clear(ctx.user.id)
|
|
208
|
+
if flow:
|
|
209
|
+
await flow.on_cancel(ctx)
|
|
210
|
+
else:
|
|
211
|
+
await ctx.reply("Cancelled.")
|
|
212
|
+
|
|
213
|
+
async def _handle_flow_step(self, ctx: Context):
|
|
214
|
+
state = self.storage.get_state(ctx.user.id)
|
|
215
|
+
if not state:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
flow_name, step_idx_str = state.split(":")
|
|
219
|
+
step_idx = int(step_idx_str)
|
|
220
|
+
flow = self.flows.get(flow_name)
|
|
221
|
+
|
|
222
|
+
if not flow:
|
|
223
|
+
self.storage.clear(ctx.user.id)
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
# Check for cancel keywords
|
|
227
|
+
if ctx.text and ctx.text.strip().lower() in [cmd.lower() for cmd in flow.cancel_commands]:
|
|
228
|
+
await self.cancel_user_flow(ctx)
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
current_step = flow.steps[step_idx]
|
|
232
|
+
|
|
233
|
+
# Validation
|
|
234
|
+
if current_step.validator:
|
|
235
|
+
is_valid = current_step.validator(ctx)
|
|
236
|
+
if inspect.iscoroutine(is_valid):
|
|
237
|
+
is_valid = await is_valid
|
|
238
|
+
|
|
239
|
+
if not is_valid:
|
|
240
|
+
await ctx.reply(current_step.error_message)
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
# Save step value
|
|
244
|
+
value = ctx.value
|
|
245
|
+
self.storage.update_data(ctx.user.id, **{current_step.name: value})
|
|
246
|
+
|
|
247
|
+
# Move to next step or complete
|
|
248
|
+
next_idx = step_idx + 1
|
|
249
|
+
if next_idx < len(flow.steps):
|
|
250
|
+
self.storage.set_state(ctx.user.id, f"{flow_name}:{next_idx}")
|
|
251
|
+
next_step = flow.steps[next_idx]
|
|
252
|
+
await ctx.reply(next_step.prompt)
|
|
253
|
+
else:
|
|
254
|
+
data = self.storage.get_data(ctx.user.id)
|
|
255
|
+
self.storage.clear(ctx.user.id)
|
|
256
|
+
await flow.on_finish(ctx, data)
|
|
257
|
+
|
|
258
|
+
def run(self):
|
|
259
|
+
print("[t8ot] Bot is polling...")
|
|
260
|
+
asyncio.run(self.bot.infinity_polling())
|
t8ot/base/__init__.py
ADDED
t8ot/base/callback.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..context import Context
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseCallback(ABC):
|
|
7
|
+
"""Base class for inline button callback handlers."""
|
|
8
|
+
|
|
9
|
+
pattern: Optional[str] = None
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def execute(self, ctx: Context) -> None:
|
|
13
|
+
pass
|
t8ot/base/command.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..context import Context
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseCommand(ABC):
|
|
7
|
+
"""Base class for slash command handlers (e.g., /start)."""
|
|
8
|
+
|
|
9
|
+
name: str = ""
|
|
10
|
+
description: Optional[str] = None
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def execute(self, ctx: Context) -> None:
|
|
14
|
+
pass
|
t8ot/base/inline.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..context import Context
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseInline(ABC):
|
|
7
|
+
"""Base class for inline queries (@bot)."""
|
|
8
|
+
|
|
9
|
+
pattern: Optional[str] = None
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def execute(self, ctx: Context) -> None:
|
|
13
|
+
pass
|
t8ot/base/message.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional, List
|
|
3
|
+
from ..context import Context
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseMessage(ABC):
|
|
7
|
+
"""Base class for standard text or media message handlers."""
|
|
8
|
+
|
|
9
|
+
content_types: List[str] = ["text"]
|
|
10
|
+
text_filter: Optional[str] = None
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def execute(self, ctx: Context) -> None:
|
|
14
|
+
pass
|
t8ot/cli/__init__.py
ADDED
t8ot/cli/main.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable, List, Optional
|
|
5
|
+
|
|
6
|
+
NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")
|
|
7
|
+
|
|
8
|
+
GITIGNORE = """\
|
|
9
|
+
.env
|
|
10
|
+
__pycache__/
|
|
11
|
+
*.pyc
|
|
12
|
+
*.db
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
ENV_EXAMPLE = """\
|
|
16
|
+
BOT_TOKEN=your_token_here
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
MAIN_PY = '''\
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
from t8ot import Bot
|
|
23
|
+
|
|
24
|
+
BOT_TOKEN = os.getenv("BOT_TOKEN", "123456:YOUR_TOKEN_HERE")
|
|
25
|
+
|
|
26
|
+
bot = Bot(token=BOT_TOKEN)
|
|
27
|
+
|
|
28
|
+
# Auto-discovery walks each tree recursively, so you can nest handlers freely.
|
|
29
|
+
bot.load_handlers("commands")
|
|
30
|
+
bot.load_handlers("callbacks")
|
|
31
|
+
bot.load_handlers("flows")
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
bot.run()
|
|
35
|
+
'''
|
|
36
|
+
|
|
37
|
+
START_COMMAND = '''\
|
|
38
|
+
from t8ot import Context, InlineKeyboard
|
|
39
|
+
from t8ot.base import BaseCommand
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class StartCommand(BaseCommand):
|
|
43
|
+
name = "start"
|
|
44
|
+
description = "Welcome message"
|
|
45
|
+
|
|
46
|
+
async def execute(self, ctx: Context) -> None:
|
|
47
|
+
keyboard = (
|
|
48
|
+
InlineKeyboard()
|
|
49
|
+
.button("Website", url="https://telegram.org")
|
|
50
|
+
.button("Settings", callback_data="settings:open")
|
|
51
|
+
.build()
|
|
52
|
+
)
|
|
53
|
+
await ctx.reply("Welcome to your t8ot bot!", reply_markup=keyboard)
|
|
54
|
+
'''
|
|
55
|
+
|
|
56
|
+
COMMAND_TEMPLATE = '''\
|
|
57
|
+
from t8ot import Context
|
|
58
|
+
from t8ot.base import BaseCommand
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class {cls}Command(BaseCommand):
|
|
62
|
+
"""Handles /{name}."""
|
|
63
|
+
|
|
64
|
+
name = "{name}"
|
|
65
|
+
description = "TODO: describe /{name}"
|
|
66
|
+
|
|
67
|
+
async def execute(self, ctx: Context) -> None:
|
|
68
|
+
await ctx.reply("TODO: handle /{name}")
|
|
69
|
+
'''
|
|
70
|
+
|
|
71
|
+
CALLBACK_TEMPLATE = '''\
|
|
72
|
+
from t8ot import Context
|
|
73
|
+
from t8ot.base import BaseCallback
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class {cls}Callback(BaseCallback):
|
|
77
|
+
"""Handles callback data starting with "{pattern}"."""
|
|
78
|
+
|
|
79
|
+
pattern = "{pattern}"
|
|
80
|
+
|
|
81
|
+
async def execute(self, ctx: Context) -> None:
|
|
82
|
+
await ctx.answer("TODO: handle {pattern}")
|
|
83
|
+
'''
|
|
84
|
+
|
|
85
|
+
FLOW_TEMPLATE = '''\
|
|
86
|
+
from t8ot import Context
|
|
87
|
+
from t8ot.fsm import BaseFlow, Step
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_number(ctx: Context) -> bool:
|
|
91
|
+
"""Example validator: rejects anything that is not a plain number."""
|
|
92
|
+
return bool(ctx.text and ctx.text.strip().lstrip("-").isdigit())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class {cls}Flow(BaseFlow):
|
|
96
|
+
"""Two-step {name} flow."""
|
|
97
|
+
|
|
98
|
+
name = "{name}"
|
|
99
|
+
|
|
100
|
+
def define_steps(self):
|
|
101
|
+
return [
|
|
102
|
+
Step(
|
|
103
|
+
name="amount",
|
|
104
|
+
prompt="How much? (send /cancel to stop)",
|
|
105
|
+
validator=is_number,
|
|
106
|
+
error_message="That is not a number. Try again:",
|
|
107
|
+
),
|
|
108
|
+
Step(
|
|
109
|
+
name="reason",
|
|
110
|
+
prompt="Why?",
|
|
111
|
+
),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
async def on_finish(self, ctx: Context, data: dict):
|
|
115
|
+
await ctx.reply(f"Done! Collected: {data}")
|
|
116
|
+
'''
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _class_name(name: str) -> str:
|
|
120
|
+
"""Turns ``user-profile`` / ``user_profile`` into ``UserProfile``."""
|
|
121
|
+
return "".join(part.capitalize() for part in re.split(r"[-_]", name))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _render(template: str, **pairs: str) -> str:
|
|
125
|
+
"""Fills ``{token}`` placeholders without touching generated-code braces."""
|
|
126
|
+
for key, value in pairs.items():
|
|
127
|
+
template = template.replace("{" + key + "}", value)
|
|
128
|
+
return template
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _write(path: Path, content: str) -> bool:
|
|
132
|
+
"""Writes ``content`` to ``path``, never overwriting an existing file."""
|
|
133
|
+
if path.exists():
|
|
134
|
+
print(f"[t8ot] Skipped (already exists): {path}")
|
|
135
|
+
return False
|
|
136
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
path.write_text(content, encoding="utf-8")
|
|
138
|
+
print(f"[t8ot] Created {path}")
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_init(args: argparse.Namespace) -> None:
|
|
143
|
+
"""Scaffolds a runnable bot project in ``<project_name>/``."""
|
|
144
|
+
root = Path(args.project_name)
|
|
145
|
+
|
|
146
|
+
_write(root / ".env.example", ENV_EXAMPLE)
|
|
147
|
+
_write(root / ".gitignore", GITIGNORE)
|
|
148
|
+
_write(root / "main.py", MAIN_PY)
|
|
149
|
+
_write(root / "commands" / "start.py", START_COMMAND)
|
|
150
|
+
for folder in ("commands", "callbacks", "flows"):
|
|
151
|
+
(root / folder).mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
print(f"[t8ot] Project ready: {root.resolve()}")
|
|
154
|
+
print("[t8ot] Next: cp .env.example .env -> add handlers -> python main.py")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_make_command(args: argparse.Namespace) -> None:
|
|
158
|
+
_write(
|
|
159
|
+
Path(args.dir) / f"{args.name}.py",
|
|
160
|
+
_render(COMMAND_TEMPLATE, name=args.name, cls=_class_name(args.name)),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_make_callback(args: argparse.Namespace) -> None:
|
|
165
|
+
pattern = args.pattern or f"{args.name}:"
|
|
166
|
+
_write(
|
|
167
|
+
Path(args.dir) / f"{args.name}.py",
|
|
168
|
+
_render(CALLBACK_TEMPLATE, pattern=pattern, cls=_class_name(args.name)),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_make_flow(args: argparse.Namespace) -> None:
|
|
173
|
+
_write(
|
|
174
|
+
Path(args.dir) / f"{args.name}.py",
|
|
175
|
+
_render(FLOW_TEMPLATE, name=args.name, cls=_class_name(args.name)),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _add_make(
|
|
180
|
+
subparsers,
|
|
181
|
+
command: str,
|
|
182
|
+
handler: Callable[[argparse.Namespace], None],
|
|
183
|
+
help_text: str,
|
|
184
|
+
default_dir: str,
|
|
185
|
+
with_pattern: bool = False,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Registers one ``make:*`` subcommand sharing the name/--dir options."""
|
|
188
|
+
parser = subparsers.add_parser(command, help=help_text)
|
|
189
|
+
parser.add_argument("name", help="Handler name (e.g. user_profile)")
|
|
190
|
+
parser.add_argument("--dir", default=default_dir, help="Target directory")
|
|
191
|
+
if with_pattern:
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
"--pattern", default=None, help="Callback data prefix (default '<name>:')"
|
|
194
|
+
)
|
|
195
|
+
parser.set_defaults(func=handler)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
199
|
+
parser = argparse.ArgumentParser(
|
|
200
|
+
prog="t8ot", description="t8ot CLI tool for Telegram bot scaffolding"
|
|
201
|
+
)
|
|
202
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
203
|
+
|
|
204
|
+
init_parser = subparsers.add_parser(
|
|
205
|
+
"init", help="Create a new bot project scaffold"
|
|
206
|
+
)
|
|
207
|
+
init_parser.add_argument("project_name", help="Directory to create")
|
|
208
|
+
init_parser.set_defaults(func=cmd_init)
|
|
209
|
+
|
|
210
|
+
_add_make(subparsers, "make:command", cmd_make_command, "Generate a command handler", "commands")
|
|
211
|
+
_add_make(
|
|
212
|
+
subparsers, "make:callback", cmd_make_callback, "Generate a callback handler", "callbacks", True
|
|
213
|
+
)
|
|
214
|
+
_add_make(subparsers, "make:flow", cmd_make_flow, "Generate a multi-step flow", "flows")
|
|
215
|
+
return parser
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def cli(argv: Optional[List[str]] = None) -> int:
|
|
219
|
+
"""Entry point for the ``t8ot`` console script."""
|
|
220
|
+
parser = build_parser()
|
|
221
|
+
args = parser.parse_args(argv)
|
|
222
|
+
|
|
223
|
+
name = getattr(args, "name", None)
|
|
224
|
+
if name and not NAME_PATTERN.match(name):
|
|
225
|
+
parser.error(
|
|
226
|
+
f"invalid name '{name}': start with a letter, then use letters, "
|
|
227
|
+
"digits, '-' or '_'"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
args.func(args)
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
raise SystemExit(cli())
|