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 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
@@ -0,0 +1,6 @@
1
+ from .command import BaseCommand
2
+ from .callback import BaseCallback
3
+ from .message import BaseMessage
4
+ from .inline import BaseInline
5
+
6
+ __all__ = ["BaseCommand", "BaseCallback", "BaseMessage", "BaseInline"]
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
@@ -0,0 +1,3 @@
1
+ from .main import build_parser, cli
2
+
3
+ __all__ = ["cli", "build_parser"]
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())