botable-dev 0.3.1__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.
- botable/__init__.py +3 -0
- botable/__main__.py +5 -0
- botable/api_client.py +203 -0
- botable/cli.py +781 -0
- botable/config.py +193 -0
- botable/errors.py +53 -0
- botable/generate.py +288 -0
- botable/mcp_install.py +72 -0
- botable/prompts.py +137 -0
- botable/runner.py +333 -0
- botable/scaffold.py +104 -0
- botable/ui.py +48 -0
- botable_dev-0.3.1.dist-info/METADATA +186 -0
- botable_dev-0.3.1.dist-info/RECORD +19 -0
- botable_dev-0.3.1.dist-info/WHEEL +4 -0
- botable_dev-0.3.1.dist-info/entry_points.txt +3 -0
- botable_dev-0.3.1.dist-info/licenses/LICENSE +21 -0
- botable_mcp/__init__.py +3 -0
- botable_mcp/server.py +227 -0
botable/prompts.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
System prompts for Botable CLI bot generation.
|
|
3
|
+
|
|
4
|
+
Adapted from generator/llm_integration/constants.py for standalone CLI use.
|
|
5
|
+
Uses aiogram patterns (MIT license) — see Phase 6.3.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
# ── Bot generation system prompt ─────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
SYSTEM_PROMPT = """\
|
|
12
|
+
You are a Python Telegram bot code generator.
|
|
13
|
+
|
|
14
|
+
# Design guidelines:
|
|
15
|
+
- Write code for a single main.py file
|
|
16
|
+
- Write simple, maintainable, production-ready code
|
|
17
|
+
- Use best practices and clean structure
|
|
18
|
+
|
|
19
|
+
# Critical requirements:
|
|
20
|
+
- Generate ONLY Python code without any markdown formatting (no ```python blocks)
|
|
21
|
+
- All comments must be in English using # syntax
|
|
22
|
+
- Use aiogram library (version 3.x, async-native)
|
|
23
|
+
- Use AsyncOpenAI client with model 'gpt-4o-mini' when AI features are needed
|
|
24
|
+
- Use the language specified by the user for bot messages (default: English)
|
|
25
|
+
- Use dotenv for environment variable loading
|
|
26
|
+
- ALL environment variables MUST come from .env via os.getenv()
|
|
27
|
+
|
|
28
|
+
# ⚠️ CRITICAL ASYNC/AWAIT RULES:
|
|
29
|
+
1. ALL handler functions MUST use 'async def'
|
|
30
|
+
2. ALL calls to async methods MUST use 'await':
|
|
31
|
+
- await message.answer(...)
|
|
32
|
+
- await ai_client.chat.completions.create(...)
|
|
33
|
+
- await client.get(...) with httpx.AsyncClient
|
|
34
|
+
3. For HTTP requests use 'httpx' (async), NOT 'requests'
|
|
35
|
+
|
|
36
|
+
# aiogram code structure:
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
import asyncio
|
|
40
|
+
from dotenv import load_dotenv
|
|
41
|
+
from aiogram import Bot, Dispatcher, types, F
|
|
42
|
+
from aiogram.filters import Command
|
|
43
|
+
|
|
44
|
+
load_dotenv()
|
|
45
|
+
|
|
46
|
+
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
|
47
|
+
if not TELEGRAM_BOT_TOKEN:
|
|
48
|
+
raise ValueError("TELEGRAM_BOT_TOKEN not found in .env")
|
|
49
|
+
|
|
50
|
+
bot = Bot(token=TELEGRAM_BOT_TOKEN)
|
|
51
|
+
dp = Dispatcher()
|
|
52
|
+
|
|
53
|
+
@dp.message(Command("start"))
|
|
54
|
+
async def start(message: types.Message) -> None:
|
|
55
|
+
await message.answer("Hello!")
|
|
56
|
+
|
|
57
|
+
@dp.message(F.text)
|
|
58
|
+
async def handle_message(message: types.Message) -> None:
|
|
59
|
+
await message.answer(f"You said: {message.text}")
|
|
60
|
+
|
|
61
|
+
async def main() -> None:
|
|
62
|
+
await dp.start_polling(bot)
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
asyncio.run(main())
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
# When AI features are needed:
|
|
69
|
+
```python
|
|
70
|
+
from openai import AsyncOpenAI
|
|
71
|
+
|
|
72
|
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
|
73
|
+
if not OPENAI_API_KEY:
|
|
74
|
+
raise ValueError("OPENAI_API_KEY not found in .env")
|
|
75
|
+
|
|
76
|
+
ai_client = AsyncOpenAI(api_key=OPENAI_API_KEY)
|
|
77
|
+
|
|
78
|
+
@dp.message(F.text)
|
|
79
|
+
async def handle_message(message: types.Message) -> None:
|
|
80
|
+
completion = await ai_client.chat.completions.create(
|
|
81
|
+
model="gpt-4o-mini",
|
|
82
|
+
messages=[{"role": "user", "content": message.text}]
|
|
83
|
+
)
|
|
84
|
+
await message.answer(completion.choices[0].message.content)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
# For callback queries and inline keyboards:
|
|
88
|
+
```python
|
|
89
|
+
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
|
90
|
+
|
|
91
|
+
@dp.callback_query(F.data == "action")
|
|
92
|
+
async def handle_callback(callback: types.CallbackQuery) -> None:
|
|
93
|
+
await callback.answer()
|
|
94
|
+
await callback.message.answer("Action processed!")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
# For FSM (conversation states):
|
|
98
|
+
```python
|
|
99
|
+
from aiogram.fsm.state import State, StatesGroup
|
|
100
|
+
from aiogram.fsm.context import FSMContext
|
|
101
|
+
|
|
102
|
+
class Form(StatesGroup):
|
|
103
|
+
name = State()
|
|
104
|
+
email = State()
|
|
105
|
+
|
|
106
|
+
@dp.message(Command("form"))
|
|
107
|
+
async def cmd_form(message: types.Message, state: FSMContext) -> None:
|
|
108
|
+
await state.set_state(Form.name)
|
|
109
|
+
await message.answer("What is your name?")
|
|
110
|
+
|
|
111
|
+
@dp.message(Form.name)
|
|
112
|
+
async def process_name(message: types.Message, state: FSMContext) -> None:
|
|
113
|
+
await state.update_data(name=message.text)
|
|
114
|
+
await state.set_state(Form.email)
|
|
115
|
+
await message.answer("What is your email?")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
IMPORTANT: Generate ONLY Python code. No markdown, no explanations.
|
|
119
|
+
Use HTML parse_mode (not Markdown) for formatted messages.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
# ── Requirements generation prompt ───────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
REQUIREMENTS_PROMPT = """\
|
|
125
|
+
Given the following bot description, list ONLY the Python package names \
|
|
126
|
+
needed (one per line, no versions, no comments).
|
|
127
|
+
|
|
128
|
+
Always include:
|
|
129
|
+
- aiogram
|
|
130
|
+
- python-dotenv
|
|
131
|
+
|
|
132
|
+
Only add 'openai' if the bot uses AI/LLM features.
|
|
133
|
+
Only add 'httpx' if the bot makes external HTTP requests.
|
|
134
|
+
Do NOT include standard library modules.
|
|
135
|
+
|
|
136
|
+
Bot description: {description}
|
|
137
|
+
"""
|
botable/runner.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local bot runner for Botable CLI.
|
|
3
|
+
|
|
4
|
+
Manages the bot subprocess lifecycle:
|
|
5
|
+
- Creates/reuses a virtual environment
|
|
6
|
+
- Installs requirements
|
|
7
|
+
- Launches bot as a subprocess
|
|
8
|
+
- Captures and displays stdout/stderr
|
|
9
|
+
- Handles graceful shutdown (Ctrl+C)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
25
|
+
from botable.ui import success, error, info, warn
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BotRunner:
|
|
31
|
+
"""Manages a bot subprocess with venv and live output."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, project_root: Path):
|
|
34
|
+
self.project_root = project_root
|
|
35
|
+
self.venv_dir = project_root / ".venv"
|
|
36
|
+
self.python = self.venv_dir / "bin" / "python"
|
|
37
|
+
self.pip = self.venv_dir / "bin" / "pip"
|
|
38
|
+
self.process: Optional[subprocess.Popen] = None
|
|
39
|
+
self._shutting_down = False
|
|
40
|
+
|
|
41
|
+
# ── Venv management ──────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def ensure_venv(self) -> bool:
|
|
44
|
+
"""Create virtual environment if not exists."""
|
|
45
|
+
if self.venv_dir.exists() and self.python.exists():
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
info("Creating virtual environment...")
|
|
49
|
+
try:
|
|
50
|
+
subprocess.run(
|
|
51
|
+
[sys.executable, "-m", "venv", str(self.venv_dir)],
|
|
52
|
+
check=True,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
)
|
|
56
|
+
success("Virtual environment created")
|
|
57
|
+
return True
|
|
58
|
+
except subprocess.CalledProcessError as e:
|
|
59
|
+
error(f"Failed to create venv: {e.stderr}")
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
def install_requirements(self) -> bool:
|
|
63
|
+
"""Install requirements.txt into the venv."""
|
|
64
|
+
req_file = self.project_root / "requirements.txt"
|
|
65
|
+
if not req_file.exists():
|
|
66
|
+
warn("No requirements.txt found — skipping dependency install")
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
info("Installing dependencies...")
|
|
70
|
+
try:
|
|
71
|
+
result = subprocess.run(
|
|
72
|
+
[str(self.pip), "install", "-q", "-r", str(req_file)],
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
timeout=120,
|
|
76
|
+
)
|
|
77
|
+
if result.returncode != 0:
|
|
78
|
+
error(f"pip install failed:\n{result.stderr[:500]}")
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
# Count installed packages
|
|
82
|
+
pkgs = [l.strip() for l in req_file.read_text().splitlines() if l.strip() and not l.startswith("#")]
|
|
83
|
+
success(f"Dependencies installed ({len(pkgs)} packages)")
|
|
84
|
+
return True
|
|
85
|
+
except subprocess.TimeoutExpired:
|
|
86
|
+
error("pip install timed out (120s)")
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
# ── Bot process management ───────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def start(self) -> bool:
|
|
92
|
+
"""Launch the bot as a subprocess."""
|
|
93
|
+
main_py = self.project_root / "main.py"
|
|
94
|
+
if not main_py.exists():
|
|
95
|
+
error("No [cyan]main.py[/] found. Run [cyan]botable generate[/] first.")
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
# Set up environment: inherit current env + load .env vars
|
|
99
|
+
env = os.environ.copy()
|
|
100
|
+
env_file = self.project_root / ".env"
|
|
101
|
+
if env_file.exists():
|
|
102
|
+
for line in env_file.read_text().splitlines():
|
|
103
|
+
line = line.strip()
|
|
104
|
+
if line and not line.startswith("#") and "=" in line:
|
|
105
|
+
key, _, value = line.partition("=")
|
|
106
|
+
# Strip surrounding quotes
|
|
107
|
+
value = value.strip().strip("'\"")
|
|
108
|
+
env[key.strip()] = value
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
self.process = subprocess.Popen(
|
|
112
|
+
[str(self.python), str(main_py)],
|
|
113
|
+
stdout=subprocess.PIPE,
|
|
114
|
+
stderr=subprocess.PIPE,
|
|
115
|
+
cwd=str(self.project_root),
|
|
116
|
+
env=env,
|
|
117
|
+
text=True,
|
|
118
|
+
bufsize=1, # Line buffered
|
|
119
|
+
)
|
|
120
|
+
return True
|
|
121
|
+
except Exception as e:
|
|
122
|
+
error(f"Failed to start bot: {e}")
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def stop(self) -> None:
|
|
126
|
+
"""Gracefully stop the bot subprocess."""
|
|
127
|
+
if self.process is None:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
self._shutting_down = True
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
self.process.send_signal(signal.SIGTERM)
|
|
134
|
+
try:
|
|
135
|
+
self.process.wait(timeout=5)
|
|
136
|
+
except subprocess.TimeoutExpired:
|
|
137
|
+
self.process.kill()
|
|
138
|
+
self.process.wait(timeout=2)
|
|
139
|
+
except (ProcessLookupError, OSError):
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
self.process = None
|
|
143
|
+
|
|
144
|
+
def is_running(self) -> bool:
|
|
145
|
+
"""Check if the bot process is still running."""
|
|
146
|
+
if self.process is None:
|
|
147
|
+
return False
|
|
148
|
+
return self.process.poll() is None
|
|
149
|
+
|
|
150
|
+
# ── Live output display ──────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
def run_with_output(self) -> int:
|
|
153
|
+
"""Run the bot and display output until exit or Ctrl+C.
|
|
154
|
+
|
|
155
|
+
Returns the bot's exit code.
|
|
156
|
+
"""
|
|
157
|
+
import selectors
|
|
158
|
+
|
|
159
|
+
if self.process is None:
|
|
160
|
+
return 1
|
|
161
|
+
|
|
162
|
+
console.print()
|
|
163
|
+
console.print(
|
|
164
|
+
Panel(
|
|
165
|
+
"[bold green]🤖 Bot running![/] Press [bold yellow]Ctrl+C[/] to stop.",
|
|
166
|
+
border_style="green",
|
|
167
|
+
padding=(0, 2),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
console.print()
|
|
171
|
+
|
|
172
|
+
# Use selectors for non-blocking I/O on stdout/stderr
|
|
173
|
+
sel = selectors.DefaultSelector()
|
|
174
|
+
sel.register(self.process.stdout, selectors.EVENT_READ)
|
|
175
|
+
sel.register(self.process.stderr, selectors.EVENT_READ)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
while self.is_running():
|
|
179
|
+
events = sel.select(timeout=0.5)
|
|
180
|
+
for key, _ in events:
|
|
181
|
+
line = key.fileobj.readline()
|
|
182
|
+
if line:
|
|
183
|
+
if key.fileobj is self.process.stderr:
|
|
184
|
+
console.print(f" [red]stderr[/] │ {line.rstrip()}")
|
|
185
|
+
else:
|
|
186
|
+
console.print(f" [dim]stdout[/] │ {line.rstrip()}")
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
console.print()
|
|
189
|
+
info("Stopping bot...")
|
|
190
|
+
self.stop()
|
|
191
|
+
success("Bot stopped")
|
|
192
|
+
return 0
|
|
193
|
+
finally:
|
|
194
|
+
sel.close()
|
|
195
|
+
|
|
196
|
+
# Bot exited on its own
|
|
197
|
+
exit_code = self.process.returncode if self.process else 1
|
|
198
|
+
|
|
199
|
+
if exit_code != 0 and not self._shutting_down:
|
|
200
|
+
# Show remaining stderr
|
|
201
|
+
if self.process and self.process.stderr:
|
|
202
|
+
remaining = self.process.stderr.read()
|
|
203
|
+
if remaining:
|
|
204
|
+
console.print()
|
|
205
|
+
console.print(Panel(
|
|
206
|
+
remaining.strip(),
|
|
207
|
+
title="[red]Bot Error[/]",
|
|
208
|
+
border_style="red",
|
|
209
|
+
))
|
|
210
|
+
|
|
211
|
+
error(f"Bot exited with code {exit_code}")
|
|
212
|
+
else:
|
|
213
|
+
success("Bot stopped")
|
|
214
|
+
|
|
215
|
+
return exit_code
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_dev(watch: bool = False) -> int:
|
|
219
|
+
"""Main entry point for `botable dev`.
|
|
220
|
+
|
|
221
|
+
Returns exit code (0 = success).
|
|
222
|
+
"""
|
|
223
|
+
from botable.config import load_project_config
|
|
224
|
+
|
|
225
|
+
config = load_project_config()
|
|
226
|
+
if config is None:
|
|
227
|
+
error("No [yellow]botable.toml[/] found. Run [cyan]botable init[/] first.")
|
|
228
|
+
return 1
|
|
229
|
+
|
|
230
|
+
project_root = Path(config["_project_root"])
|
|
231
|
+
|
|
232
|
+
runner = BotRunner(project_root)
|
|
233
|
+
|
|
234
|
+
# 1. Ensure venv
|
|
235
|
+
if not runner.ensure_venv():
|
|
236
|
+
return 1
|
|
237
|
+
|
|
238
|
+
# 2. Install dependencies
|
|
239
|
+
if not runner.install_requirements():
|
|
240
|
+
return 1
|
|
241
|
+
|
|
242
|
+
if watch:
|
|
243
|
+
return _run_with_watcher(runner, project_root)
|
|
244
|
+
else:
|
|
245
|
+
# 3. Start bot
|
|
246
|
+
if not runner.start():
|
|
247
|
+
return 1
|
|
248
|
+
|
|
249
|
+
# 4. Run with live output
|
|
250
|
+
return runner.run_with_output()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _run_with_watcher(runner: BotRunner, project_root: Path) -> int:
|
|
254
|
+
"""Run bot with file watcher for hot reload."""
|
|
255
|
+
try:
|
|
256
|
+
from watchfiles import watch
|
|
257
|
+
except ImportError:
|
|
258
|
+
warn("watchfiles not installed — running without hot reload")
|
|
259
|
+
warn("Install with: pip install watchfiles")
|
|
260
|
+
if not runner.start():
|
|
261
|
+
return 1
|
|
262
|
+
return runner.run_with_output()
|
|
263
|
+
|
|
264
|
+
info("Watching for file changes (hot reload enabled)...")
|
|
265
|
+
|
|
266
|
+
# Start bot for the first time
|
|
267
|
+
if not runner.start():
|
|
268
|
+
return 1
|
|
269
|
+
|
|
270
|
+
console.print()
|
|
271
|
+
console.print(
|
|
272
|
+
Panel(
|
|
273
|
+
"[bold green]🤖 Bot running with hot reload![/]\n"
|
|
274
|
+
"Press [bold yellow]Ctrl+C[/] to stop.",
|
|
275
|
+
border_style="green",
|
|
276
|
+
padding=(0, 2),
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
import selectors
|
|
281
|
+
import threading
|
|
282
|
+
|
|
283
|
+
# Run file watcher in a thread
|
|
284
|
+
stop_event = threading.Event()
|
|
285
|
+
|
|
286
|
+
def file_watcher():
|
|
287
|
+
for changes in watch(
|
|
288
|
+
str(project_root),
|
|
289
|
+
watch_filter=lambda change, path: path.endswith((".py", ".env")),
|
|
290
|
+
stop_event=stop_event,
|
|
291
|
+
):
|
|
292
|
+
if stop_event.is_set():
|
|
293
|
+
break
|
|
294
|
+
changed_files = [Path(p).name for _, p in changes]
|
|
295
|
+
console.print(f"\n [yellow]↻[/] Files changed: {', '.join(changed_files)} — restarting...")
|
|
296
|
+
runner.stop()
|
|
297
|
+
time.sleep(0.3)
|
|
298
|
+
runner.install_requirements()
|
|
299
|
+
if runner.start():
|
|
300
|
+
success("Bot restarted")
|
|
301
|
+
else:
|
|
302
|
+
error("Failed to restart bot")
|
|
303
|
+
|
|
304
|
+
watcher_thread = threading.Thread(target=file_watcher, daemon=True)
|
|
305
|
+
watcher_thread.start()
|
|
306
|
+
|
|
307
|
+
# Main thread: display output
|
|
308
|
+
try:
|
|
309
|
+
while True:
|
|
310
|
+
if runner.is_running():
|
|
311
|
+
sel = selectors.DefaultSelector()
|
|
312
|
+
sel.register(runner.process.stdout, selectors.EVENT_READ)
|
|
313
|
+
sel.register(runner.process.stderr, selectors.EVENT_READ)
|
|
314
|
+
try:
|
|
315
|
+
events = sel.select(timeout=0.5)
|
|
316
|
+
for key, _ in events:
|
|
317
|
+
line = key.fileobj.readline()
|
|
318
|
+
if line:
|
|
319
|
+
if key.fileobj is runner.process.stderr:
|
|
320
|
+
console.print(f" [red]stderr[/] │ {line.rstrip()}")
|
|
321
|
+
else:
|
|
322
|
+
console.print(f" [dim]stdout[/] │ {line.rstrip()}")
|
|
323
|
+
finally:
|
|
324
|
+
sel.close()
|
|
325
|
+
else:
|
|
326
|
+
time.sleep(0.5)
|
|
327
|
+
except KeyboardInterrupt:
|
|
328
|
+
console.print()
|
|
329
|
+
info("Shutting down...")
|
|
330
|
+
stop_event.set()
|
|
331
|
+
runner.stop()
|
|
332
|
+
success("Bot stopped")
|
|
333
|
+
return 0
|
botable/scaffold.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project scaffolding for `botable init`.
|
|
3
|
+
|
|
4
|
+
Creates a new bot project directory with:
|
|
5
|
+
- botable.toml (project config)
|
|
6
|
+
- .env (secrets placeholder)
|
|
7
|
+
- .gitignore
|
|
8
|
+
- README.md
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from botable.config import save_project_config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def scaffold_project(name: str) -> Path:
|
|
18
|
+
"""Create a new bot project directory.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
name: Project name (used as directory name).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Path to the created project directory.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
FileExistsError: If the directory already exists.
|
|
28
|
+
"""
|
|
29
|
+
project_dir = Path.cwd() / name
|
|
30
|
+
|
|
31
|
+
if project_dir.exists():
|
|
32
|
+
raise FileExistsError(f"Directory '{name}' already exists")
|
|
33
|
+
|
|
34
|
+
project_dir.mkdir(parents=True)
|
|
35
|
+
|
|
36
|
+
# ── botable.toml ──
|
|
37
|
+
config = {
|
|
38
|
+
"project": {
|
|
39
|
+
"name": name,
|
|
40
|
+
"description": "",
|
|
41
|
+
"version": "0.1.0",
|
|
42
|
+
},
|
|
43
|
+
"bot": {
|
|
44
|
+
"template": "custom",
|
|
45
|
+
},
|
|
46
|
+
"branding": {
|
|
47
|
+
"enabled": True,
|
|
48
|
+
"text": "Built with Botable (botable.dev)",
|
|
49
|
+
"style": "comment", # "comment" | "docstring" | "none"
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
save_project_config(config, project_dir / "botable.toml")
|
|
53
|
+
|
|
54
|
+
# ── .env ──
|
|
55
|
+
env_content = """# Botable project secrets — DO NOT COMMIT
|
|
56
|
+
# Get your bot token from @BotFather on Telegram
|
|
57
|
+
BOT_TOKEN=your-token-here
|
|
58
|
+
|
|
59
|
+
# Optional: OpenAI API key for AI-powered features
|
|
60
|
+
# OPENAI_API_KEY=sk-...
|
|
61
|
+
"""
|
|
62
|
+
(project_dir / ".env").write_text(env_content)
|
|
63
|
+
|
|
64
|
+
# ── .gitignore ──
|
|
65
|
+
gitignore = """.env
|
|
66
|
+
__pycache__/
|
|
67
|
+
*.pyc
|
|
68
|
+
.venv/
|
|
69
|
+
venv/
|
|
70
|
+
bot/__pycache__/
|
|
71
|
+
"""
|
|
72
|
+
(project_dir / ".gitignore").write_text(gitignore)
|
|
73
|
+
|
|
74
|
+
# ── README.md ──
|
|
75
|
+
readme = f"""# {name}
|
|
76
|
+
|
|
77
|
+
A Telegram bot generated with [Botable](https://botable.dev).
|
|
78
|
+
|
|
79
|
+
## Quick Start
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Set your bot token
|
|
83
|
+
echo "BOT_TOKEN=your-token" > .env
|
|
84
|
+
|
|
85
|
+
# Generate the bot
|
|
86
|
+
botable generate "your bot description here"
|
|
87
|
+
|
|
88
|
+
# Run locally
|
|
89
|
+
botable dev
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Deploy
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
botable deploy
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
⚡ Built with [Botable](https://botable.dev)
|
|
101
|
+
"""
|
|
102
|
+
(project_dir / "README.md").write_text(readme)
|
|
103
|
+
|
|
104
|
+
return project_dir
|
botable/ui.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rich console UI helpers for Botable CLI.
|
|
3
|
+
|
|
4
|
+
Provides styled output: banner, success/error/info messages,
|
|
5
|
+
progress bars, and status spinners.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def banner() -> None:
|
|
17
|
+
"""Print the Botable ASCII banner."""
|
|
18
|
+
logo = Text.from_markup(
|
|
19
|
+
"[bold cyan]⚡ botable[/] [dim]— generate Telegram bots with AI[/]"
|
|
20
|
+
)
|
|
21
|
+
console.print()
|
|
22
|
+
console.print(Panel(logo, border_style="cyan", padding=(0, 2)))
|
|
23
|
+
console.print()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def success(msg: str) -> None:
|
|
27
|
+
"""Print a success message."""
|
|
28
|
+
console.print(f" [bold green]✅[/] {msg}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def error(msg: str) -> None:
|
|
32
|
+
"""Print an error message."""
|
|
33
|
+
console.print(f" [bold red]❌[/] {msg}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def info(msg: str) -> None:
|
|
37
|
+
"""Print an info message."""
|
|
38
|
+
console.print(f" [bold blue]ℹ[/] {msg}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def warn(msg: str) -> None:
|
|
42
|
+
"""Print a warning message."""
|
|
43
|
+
console.print(f" [bold yellow]⚠️[/] {msg}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def step(n: int, msg: str) -> None:
|
|
47
|
+
"""Print a numbered step."""
|
|
48
|
+
console.print(f" [bold cyan]{n}.[/] {msg}")
|