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 ADDED
@@ -0,0 +1,3 @@
1
+ """Botable CLI — Generate Telegram bots with AI."""
2
+
3
+ __version__ = "0.3.1"
botable/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m botable`."""
2
+
3
+ from botable.cli import app
4
+
5
+ app()
botable/api_client.py ADDED
@@ -0,0 +1,203 @@
1
+ """
2
+ HTTP client for Botable Generator API.
3
+
4
+ Handles authentication, project CRUD, and SSE event streaming.
5
+ Implemented in Stage 024.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, AsyncIterator, Dict, Optional
10
+
11
+ import httpx
12
+
13
+ from botable.config import get_api_url, get_auth_token
14
+
15
+
16
+ class BotableClient:
17
+ """HTTP client for the Botable Generator API."""
18
+
19
+ def __init__(
20
+ self,
21
+ base_url: Optional[str] = None,
22
+ api_key: Optional[str] = None,
23
+ ):
24
+ self.base_url = (base_url or get_api_url()).rstrip("/")
25
+ self.api_key = api_key or get_auth_token()
26
+ self._client: Optional[httpx.AsyncClient] = None
27
+
28
+ def _headers(self) -> Dict[str, str]:
29
+ headers = {"Content-Type": "application/json"}
30
+ if self.api_key:
31
+ headers["Authorization"] = f"Bearer {self.api_key}"
32
+ return headers
33
+
34
+ async def _get_client(self) -> httpx.AsyncClient:
35
+ if self._client is None:
36
+ # Retry on transient network errors (TCP reset, DNS timeout)
37
+ transport = httpx.AsyncHTTPTransport(retries=3)
38
+ self._client = httpx.AsyncClient(
39
+ base_url=self.base_url,
40
+ headers=self._headers(),
41
+ timeout=60.0,
42
+ transport=transport,
43
+ )
44
+ return self._client
45
+
46
+ async def close(self) -> None:
47
+ if self._client:
48
+ await self._client.aclose()
49
+ self._client = None
50
+
51
+ # ── Project operations (Stage 024) ───────────────────────────────────
52
+
53
+ async def create_project(self, name: str, description: str = "") -> Dict[str, Any]:
54
+ """Create a new project on the generator."""
55
+ client = await self._get_client()
56
+ resp = await client.post("/api/projects", json={
57
+ "name": name,
58
+ "description": description,
59
+ })
60
+ resp.raise_for_status()
61
+ return resp.json()
62
+
63
+ async def send_message(self, project_id: str, text: str) -> Dict[str, Any]:
64
+ """Send a message to a project's FSM."""
65
+ client = await self._get_client()
66
+ resp = await client.post(f"/api/projects/{project_id}/message", json={
67
+ "text": text,
68
+ })
69
+ resp.raise_for_status()
70
+ return resp.json()
71
+
72
+ async def get_project(self, project_id: str) -> Dict[str, Any]:
73
+ """Get project status."""
74
+ client = await self._get_client()
75
+ resp = await client.get(f"/api/projects/{project_id}")
76
+ resp.raise_for_status()
77
+ return resp.json()
78
+
79
+ # ── SSE streaming (Stage 024) ────────────────────────────────────────
80
+
81
+ async def stream_events(self, project_id: str) -> AsyncIterator[Dict[str, Any]]:
82
+ """Stream SSE events for a project."""
83
+ import json
84
+
85
+ client = await self._get_client()
86
+ async with client.stream(
87
+ "GET",
88
+ f"/api/v2/projects/{project_id}/events",
89
+ headers={"Accept": "text/event-stream"},
90
+ ) as resp:
91
+ async for line in resp.aiter_lines():
92
+ if line.startswith("data: "):
93
+ try:
94
+ yield json.loads(line[6:])
95
+ except json.JSONDecodeError:
96
+ continue
97
+
98
+ # ── Deploy (Stage 038) ────────────────────────────────────────────
99
+
100
+ async def deploy_project(self, tarball: bytes) -> Dict[str, Any]:
101
+ """Upload a tarball and trigger deployment.
102
+
103
+ Uses multipart/form-data (not JSON) so we build a one-shot client.
104
+ """
105
+ headers: Dict[str, str] = {}
106
+ if self.api_key:
107
+ headers["Authorization"] = f"Bearer {self.api_key}"
108
+
109
+ async with httpx.AsyncClient(
110
+ base_url=self.base_url, timeout=120.0,
111
+ ) as client:
112
+ files = {"file": ("project.tar.gz", tarball, "application/gzip")}
113
+ resp = await client.post(
114
+ "/api/v1/cli/deploy", files=files, headers=headers,
115
+ )
116
+ resp.raise_for_status()
117
+ return resp.json()
118
+
119
+ # ── Bots management (Stage 039) ───────────────────────────────────
120
+
121
+ async def list_bots(self) -> Dict[str, Any]:
122
+ """List all user's bots/projects."""
123
+ client = await self._get_client()
124
+ resp = await client.get("/api/v1/cli/bots")
125
+ resp.raise_for_status()
126
+ return resp.json()
127
+
128
+ async def stop_bot(self, project_id: str) -> Dict[str, Any]:
129
+ """Stop a running bot."""
130
+ client = await self._get_client()
131
+ resp = await client.post(f"/api/v1/cli/bots/{project_id}/stop")
132
+ resp.raise_for_status()
133
+ return resp.json()
134
+
135
+ async def get_bot_status(self, project_id: str) -> Dict[str, Any]:
136
+ """Get bot logs/status."""
137
+ client = await self._get_client()
138
+ resp = await client.get(f"/api/v1/cli/bots/{project_id}/logs")
139
+ resp.raise_for_status()
140
+ return resp.json()
141
+
142
+ async def start_bot(self, project_id: str) -> Dict[str, Any]:
143
+ """Start a stopped bot."""
144
+ client = await self._get_client()
145
+ resp = await client.post(f"/api/v1/cli/bots/{project_id}/start")
146
+ resp.raise_for_status()
147
+ return resp.json()
148
+
149
+ async def delete_bot(self, project_id: str) -> Dict[str, Any]:
150
+ """Delete a bot/project."""
151
+ client = await self._get_client()
152
+ resp = await client.delete(f"/api/v1/cli/bots/{project_id}")
153
+ resp.raise_for_status()
154
+ return resp.json()
155
+
156
+ async def edit_bot(self, project_id: str, instruction: str) -> Dict[str, Any]:
157
+ """Send an edit instruction to a bot."""
158
+ client = await self._get_client()
159
+ resp = await client.post(f"/api/v1/cli/bots/{project_id}/edit", json={
160
+ "instruction": instruction,
161
+ })
162
+ resp.raise_for_status()
163
+ return resp.json()
164
+
165
+ # ── API Keys (CLI expansion) ─────────────────────────────────────────
166
+
167
+ async def list_api_keys(self) -> Dict[str, Any]:
168
+ """List all API keys."""
169
+ client = await self._get_client()
170
+ resp = await client.get("/api/v1/api-keys/")
171
+ resp.raise_for_status()
172
+ return resp.json()
173
+
174
+ async def create_api_key(self, name: str) -> Dict[str, Any]:
175
+ """Create a new API key."""
176
+ client = await self._get_client()
177
+ resp = await client.post("/api/v1/api-keys/", json={"name": name})
178
+ resp.raise_for_status()
179
+ return resp.json()
180
+
181
+ async def revoke_api_key(self, key_id: str) -> Dict[str, Any]:
182
+ """Revoke an API key."""
183
+ client = await self._get_client()
184
+ resp = await client.delete(f"/api/v1/api-keys/{key_id}")
185
+ resp.raise_for_status()
186
+ return resp.json()
187
+
188
+ # ── Credits & Templates ──────────────────────────────────────────────
189
+
190
+ async def get_credits(self) -> Dict[str, Any]:
191
+ """Get current credit balance."""
192
+ client = await self._get_client()
193
+ resp = await client.get("/api/v1/credits/")
194
+ resp.raise_for_status()
195
+ return resp.json()
196
+
197
+ async def list_templates(self) -> Dict[str, Any]:
198
+ """List available bot templates."""
199
+ client = await self._get_client()
200
+ resp = await client.get("/api/v1/templates")
201
+ resp.raise_for_status()
202
+ return resp.json()
203
+