nullabot 1.0.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.
- nullabot/__init__.py +3 -0
- nullabot/agents/__init__.py +7 -0
- nullabot/agents/claude_agent.py +785 -0
- nullabot/bot/__init__.py +5 -0
- nullabot/bot/telegram.py +1729 -0
- nullabot/cli.py +740 -0
- nullabot/core/__init__.py +13 -0
- nullabot/core/claude_code.py +303 -0
- nullabot/core/memory.py +864 -0
- nullabot/core/project.py +194 -0
- nullabot/core/rate_limiter.py +484 -0
- nullabot/core/reliability.py +420 -0
- nullabot/core/sandbox.py +143 -0
- nullabot/core/state.py +214 -0
- nullabot-1.0.1.dist-info/METADATA +130 -0
- nullabot-1.0.1.dist-info/RECORD +19 -0
- nullabot-1.0.1.dist-info/WHEEL +4 -0
- nullabot-1.0.1.dist-info/entry_points.txt +2 -0
- nullabot-1.0.1.dist-info/licenses/LICENSE +21 -0
nullabot/bot/telegram.py
ADDED
|
@@ -0,0 +1,1729 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Telegram Bot - Visual interface for Nullabot.
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
- Multi-user support (admin + regular users)
|
|
6
|
+
- User-specific project directories
|
|
7
|
+
- Interactive buttons and menus
|
|
8
|
+
- Cost tracking display
|
|
9
|
+
- Memory status
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand
|
|
19
|
+
from telegram.ext import (
|
|
20
|
+
Application,
|
|
21
|
+
CommandHandler,
|
|
22
|
+
CallbackQueryHandler,
|
|
23
|
+
MessageHandler,
|
|
24
|
+
ContextTypes,
|
|
25
|
+
filters,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Conversation states
|
|
29
|
+
WAITING_TASK = 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TelegramBot:
|
|
33
|
+
"""
|
|
34
|
+
Visual Telegram bot with multi-user support.
|
|
35
|
+
|
|
36
|
+
- Admins: See all projects, manage all users
|
|
37
|
+
- Users: See only their own projects
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
token: str,
|
|
43
|
+
projects_dir: Path,
|
|
44
|
+
allowed_users: Optional[list[int]] = None,
|
|
45
|
+
admins: Optional[list[int]] = None,
|
|
46
|
+
):
|
|
47
|
+
self.token = token
|
|
48
|
+
self.base_projects_dir = Path(projects_dir)
|
|
49
|
+
self.base_projects_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
self.allowed_users = set(allowed_users or [])
|
|
52
|
+
self.admins = set(admins or [])
|
|
53
|
+
|
|
54
|
+
# Users file for persistence
|
|
55
|
+
self._users_file = self.base_projects_dir / ".users.json"
|
|
56
|
+
self._load_users()
|
|
57
|
+
|
|
58
|
+
# Active agents: key = "user_id:project_name"
|
|
59
|
+
self._active_agents: dict[str, asyncio.Task] = {}
|
|
60
|
+
self._agent_chat_ids: dict[str, int] = {}
|
|
61
|
+
|
|
62
|
+
# Pending actions (for conversation flow)
|
|
63
|
+
self._pending_action: dict[int, dict] = {}
|
|
64
|
+
|
|
65
|
+
# Chat conversation history (temporary, per chat_id)
|
|
66
|
+
self._chat_history: dict[int, list] = {}
|
|
67
|
+
|
|
68
|
+
self._app: Optional[Application] = None
|
|
69
|
+
|
|
70
|
+
def _load_users(self) -> None:
|
|
71
|
+
"""Load users from persistent file."""
|
|
72
|
+
if self._users_file.exists():
|
|
73
|
+
try:
|
|
74
|
+
data = json.loads(self._users_file.read_text())
|
|
75
|
+
# Merge file users with config users
|
|
76
|
+
self.allowed_users.update(data.get("allowed", []))
|
|
77
|
+
self._pending_users = set(data.get("pending", []))
|
|
78
|
+
self._user_names = data.get("names", {})
|
|
79
|
+
except:
|
|
80
|
+
self._pending_users = set()
|
|
81
|
+
self._user_names = {}
|
|
82
|
+
else:
|
|
83
|
+
self._pending_users = set()
|
|
84
|
+
self._user_names = {}
|
|
85
|
+
|
|
86
|
+
def _save_users(self) -> None:
|
|
87
|
+
"""Save users to persistent file."""
|
|
88
|
+
data = {
|
|
89
|
+
"allowed": list(self.allowed_users - self.admins),
|
|
90
|
+
"pending": list(self._pending_users),
|
|
91
|
+
"names": self._user_names,
|
|
92
|
+
}
|
|
93
|
+
self._users_file.write_text(json.dumps(data, indent=2))
|
|
94
|
+
|
|
95
|
+
def _check_user(self, user_id: int, user=None) -> bool:
|
|
96
|
+
"""Check if user is allowed. Also updates stored name if user object provided."""
|
|
97
|
+
if user:
|
|
98
|
+
self._update_user_name(user)
|
|
99
|
+
if not self.allowed_users and not self.admins:
|
|
100
|
+
return True
|
|
101
|
+
return user_id in self.allowed_users or user_id in self.admins
|
|
102
|
+
|
|
103
|
+
def _is_admin(self, user_id: int) -> bool:
|
|
104
|
+
"""Check if user is admin."""
|
|
105
|
+
return user_id in self.admins
|
|
106
|
+
|
|
107
|
+
def _update_user_name(self, user) -> None:
|
|
108
|
+
"""Update stored name for a user from Telegram user object."""
|
|
109
|
+
if user:
|
|
110
|
+
name = user.full_name or user.username or str(user.id)
|
|
111
|
+
if user.username:
|
|
112
|
+
name = f"{name} (@{user.username})"
|
|
113
|
+
self._user_names[str(user.id)] = name
|
|
114
|
+
|
|
115
|
+
def _get_user_display(self, user_id: int) -> str:
|
|
116
|
+
"""Get display name for a user."""
|
|
117
|
+
name = self._user_names.get(str(user_id))
|
|
118
|
+
if name:
|
|
119
|
+
return name
|
|
120
|
+
return str(user_id)
|
|
121
|
+
|
|
122
|
+
async def _fetch_user_names(self, app) -> None:
|
|
123
|
+
"""Fetch names for all configured users from Telegram API."""
|
|
124
|
+
all_user_ids = self.admins | self.allowed_users | self._pending_users
|
|
125
|
+
updated = False
|
|
126
|
+
|
|
127
|
+
for uid in all_user_ids:
|
|
128
|
+
if str(uid) not in self._user_names:
|
|
129
|
+
try:
|
|
130
|
+
chat = await app.bot.get_chat(uid)
|
|
131
|
+
if chat:
|
|
132
|
+
name = chat.full_name or chat.username or str(uid)
|
|
133
|
+
if chat.username:
|
|
134
|
+
name = f"{name} (@{chat.username})"
|
|
135
|
+
self._user_names[str(uid)] = name
|
|
136
|
+
updated = True
|
|
137
|
+
print(f" Fetched name for {uid}: {name}")
|
|
138
|
+
except Exception:
|
|
139
|
+
# User hasn't interacted with bot yet
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
if updated:
|
|
143
|
+
self._save_users()
|
|
144
|
+
|
|
145
|
+
def _get_user_projects_dir(self, user_id: int) -> Path:
|
|
146
|
+
"""Get projects directory for a user."""
|
|
147
|
+
if self._is_admin(user_id):
|
|
148
|
+
# Admins use the base directory (can see all)
|
|
149
|
+
return self.base_projects_dir
|
|
150
|
+
# Regular users get their own subdirectory
|
|
151
|
+
user_dir = self.base_projects_dir / str(user_id)
|
|
152
|
+
user_dir.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
return user_dir
|
|
154
|
+
|
|
155
|
+
def _get_project_path(self, user_id: int, name: str) -> Path:
|
|
156
|
+
"""Get path to a specific project."""
|
|
157
|
+
return self._get_user_projects_dir(user_id) / name
|
|
158
|
+
|
|
159
|
+
def _get_agent_key(self, user_id: int, project_name: str) -> str:
|
|
160
|
+
"""Get unique key for agent tracking."""
|
|
161
|
+
return f"{user_id}:{project_name}"
|
|
162
|
+
|
|
163
|
+
def _list_projects(self, user_id: int) -> list[Path]:
|
|
164
|
+
"""List projects for a user."""
|
|
165
|
+
projects_dir = self._get_user_projects_dir(user_id)
|
|
166
|
+
if not projects_dir.exists():
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
projects = []
|
|
170
|
+
for p in projects_dir.iterdir():
|
|
171
|
+
if p.is_dir() and (p / ".nullabot").exists():
|
|
172
|
+
projects.append(p)
|
|
173
|
+
# For admins, also check user subdirectories
|
|
174
|
+
elif self._is_admin(user_id) and p.is_dir() and p.name.isdigit():
|
|
175
|
+
for subp in p.iterdir():
|
|
176
|
+
if subp.is_dir() and (subp / ".nullabot").exists():
|
|
177
|
+
projects.append(subp)
|
|
178
|
+
|
|
179
|
+
return projects
|
|
180
|
+
|
|
181
|
+
def _get_project_state(self, project_path: Path, agent_type: str = None) -> dict:
|
|
182
|
+
"""Get state of a project (or specific agent)."""
|
|
183
|
+
nulla_dir = project_path / ".nullabot"
|
|
184
|
+
|
|
185
|
+
if agent_type:
|
|
186
|
+
# Get specific agent's state
|
|
187
|
+
state_file = nulla_dir / f"state_{agent_type}.json"
|
|
188
|
+
if state_file.exists():
|
|
189
|
+
try:
|
|
190
|
+
return json.loads(state_file.read_text())
|
|
191
|
+
except:
|
|
192
|
+
pass
|
|
193
|
+
return {}
|
|
194
|
+
|
|
195
|
+
# Get most recent agent state
|
|
196
|
+
latest_state = {}
|
|
197
|
+
latest_time = None
|
|
198
|
+
|
|
199
|
+
for state_file in nulla_dir.glob("state_*.json"):
|
|
200
|
+
try:
|
|
201
|
+
state = json.loads(state_file.read_text())
|
|
202
|
+
updated = state.get("updated_at", "")
|
|
203
|
+
if not latest_time or updated > latest_time:
|
|
204
|
+
latest_time = updated
|
|
205
|
+
latest_state = state
|
|
206
|
+
except:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
# Fallback to old format
|
|
210
|
+
if not latest_state:
|
|
211
|
+
old_state_file = nulla_dir / "state.json"
|
|
212
|
+
if old_state_file.exists():
|
|
213
|
+
try:
|
|
214
|
+
return json.loads(old_state_file.read_text())
|
|
215
|
+
except:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
return latest_state
|
|
219
|
+
|
|
220
|
+
def _get_all_agent_states(self, project_path: Path) -> dict[str, dict]:
|
|
221
|
+
"""Get states of all agents in a project."""
|
|
222
|
+
states = {}
|
|
223
|
+
nulla_dir = project_path / ".nullabot"
|
|
224
|
+
|
|
225
|
+
for agent_type in ["thinker", "designer", "coder"]:
|
|
226
|
+
state_file = nulla_dir / f"state_{agent_type}.json"
|
|
227
|
+
if state_file.exists():
|
|
228
|
+
try:
|
|
229
|
+
states[agent_type] = json.loads(state_file.read_text())
|
|
230
|
+
except:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
return states
|
|
234
|
+
|
|
235
|
+
def _get_project_usage(self, project_path: Path) -> dict:
|
|
236
|
+
"""Get usage/cost info for a project."""
|
|
237
|
+
usage_file = project_path / ".nullabot" / "usage.json"
|
|
238
|
+
if usage_file.exists():
|
|
239
|
+
try:
|
|
240
|
+
return json.loads(usage_file.read_text())
|
|
241
|
+
except:
|
|
242
|
+
pass
|
|
243
|
+
return {"total_cost_usd": 0, "total_cycles": 0}
|
|
244
|
+
|
|
245
|
+
def _get_project_memory(self, project_path: Path) -> dict:
|
|
246
|
+
"""Get memory info for a project."""
|
|
247
|
+
memory_file = project_path / ".nullabot" / "memory.json"
|
|
248
|
+
if memory_file.exists():
|
|
249
|
+
try:
|
|
250
|
+
data = json.loads(memory_file.read_text())
|
|
251
|
+
return {
|
|
252
|
+
"long_term": len(data.get("long_term", [])),
|
|
253
|
+
"short_term": len(data.get("short_term", [])),
|
|
254
|
+
"agents": list(k for k, v in data.get("agent_summaries", {}).items() if v),
|
|
255
|
+
}
|
|
256
|
+
except:
|
|
257
|
+
pass
|
|
258
|
+
return {"long_term": 0, "short_term": 0, "agents": []}
|
|
259
|
+
|
|
260
|
+
async def _send_notification(self, chat_id: int, message: str, buttons: list = None) -> None:
|
|
261
|
+
"""Send notification to user."""
|
|
262
|
+
if self._app:
|
|
263
|
+
try:
|
|
264
|
+
reply_markup = InlineKeyboardMarkup(buttons) if buttons else None
|
|
265
|
+
await self._app.bot.send_message(
|
|
266
|
+
chat_id=chat_id,
|
|
267
|
+
text=message,
|
|
268
|
+
parse_mode="Markdown",
|
|
269
|
+
reply_markup=reply_markup,
|
|
270
|
+
)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
print(f"Notification error: {e}")
|
|
273
|
+
|
|
274
|
+
def _main_menu_keyboard(self, user_id: int) -> InlineKeyboardMarkup:
|
|
275
|
+
"""Main menu with visual buttons."""
|
|
276
|
+
keyboard = [
|
|
277
|
+
[
|
|
278
|
+
InlineKeyboardButton("๐ Projects", callback_data="menu:projects"),
|
|
279
|
+
InlineKeyboardButton("โ New Project", callback_data="menu:new"),
|
|
280
|
+
],
|
|
281
|
+
[
|
|
282
|
+
InlineKeyboardButton("๐ง Think", callback_data="agent:thinker"),
|
|
283
|
+
InlineKeyboardButton("๐จ Design", callback_data="agent:designer"),
|
|
284
|
+
InlineKeyboardButton("๐ป Code", callback_data="agent:coder"),
|
|
285
|
+
],
|
|
286
|
+
[
|
|
287
|
+
InlineKeyboardButton("โถ๏ธ Resume", callback_data="menu:resume"),
|
|
288
|
+
InlineKeyboardButton("๐ Status", callback_data="menu:status"),
|
|
289
|
+
InlineKeyboardButton("๐ Stop", callback_data="menu:stop"),
|
|
290
|
+
],
|
|
291
|
+
[
|
|
292
|
+
InlineKeyboardButton("๐ฐ Costs", callback_data="menu:costs"),
|
|
293
|
+
InlineKeyboardButton("๐ฌ Chat", callback_data="menu:chat"),
|
|
294
|
+
],
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
# Admin-only options
|
|
298
|
+
if self._is_admin(user_id):
|
|
299
|
+
keyboard.append([
|
|
300
|
+
InlineKeyboardButton("๐ฅ All Users", callback_data="admin:users"),
|
|
301
|
+
])
|
|
302
|
+
|
|
303
|
+
return InlineKeyboardMarkup(keyboard)
|
|
304
|
+
|
|
305
|
+
def _get_paused_projects(self, user_id: int) -> list[tuple[Path, dict]]:
|
|
306
|
+
"""Get projects with paused agents."""
|
|
307
|
+
paused = []
|
|
308
|
+
for p in self._list_projects(user_id):
|
|
309
|
+
# Check each agent type for paused state
|
|
310
|
+
agent_states = self._get_all_agent_states(p)
|
|
311
|
+
for agent_type, state in agent_states.items():
|
|
312
|
+
agent_key = self._get_agent_key(user_id, f"{p.name}:{agent_type}")
|
|
313
|
+
if agent_key in self._active_agents:
|
|
314
|
+
continue
|
|
315
|
+
if state.get("status") == "paused" and state.get("task"):
|
|
316
|
+
# Include agent_type in state for resume
|
|
317
|
+
state["_agent_type"] = agent_type
|
|
318
|
+
state["_project_name"] = p.name
|
|
319
|
+
paused.append((p, state))
|
|
320
|
+
return paused
|
|
321
|
+
|
|
322
|
+
def _projects_keyboard(self, user_id: int, action: str = "select") -> InlineKeyboardMarkup:
|
|
323
|
+
"""Keyboard with project buttons."""
|
|
324
|
+
projects = self._list_projects(user_id)
|
|
325
|
+
keyboard = []
|
|
326
|
+
|
|
327
|
+
for p in sorted(projects, key=lambda x: x.name)[:10]:
|
|
328
|
+
agent_key = self._get_agent_key(user_id, p.name)
|
|
329
|
+
state = self._get_project_state(p)
|
|
330
|
+
usage = self._get_project_usage(p)
|
|
331
|
+
|
|
332
|
+
status = "๐ข" if agent_key in self._active_agents else "โช"
|
|
333
|
+
cost = usage.get("total_cost_usd", 0)
|
|
334
|
+
|
|
335
|
+
label = f"{status} {p.name}"
|
|
336
|
+
if cost > 0:
|
|
337
|
+
label += f" (${cost:.2f})"
|
|
338
|
+
|
|
339
|
+
keyboard.append([
|
|
340
|
+
InlineKeyboardButton(label, callback_data=f"project:{action}:{p.name}")
|
|
341
|
+
])
|
|
342
|
+
|
|
343
|
+
keyboard.append([InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")])
|
|
344
|
+
return InlineKeyboardMarkup(keyboard)
|
|
345
|
+
|
|
346
|
+
def _agent_keyboard(self, project_name: str) -> InlineKeyboardMarkup:
|
|
347
|
+
"""Agent selection for a project."""
|
|
348
|
+
keyboard = [
|
|
349
|
+
[InlineKeyboardButton("๐ง Thinker", callback_data=f"start:thinker:{project_name}")],
|
|
350
|
+
[InlineKeyboardButton("๐จ Designer", callback_data=f"start:designer:{project_name}")],
|
|
351
|
+
[InlineKeyboardButton("๐ป Coder", callback_data=f"start:coder:{project_name}")],
|
|
352
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:projects")],
|
|
353
|
+
]
|
|
354
|
+
return InlineKeyboardMarkup(keyboard)
|
|
355
|
+
|
|
356
|
+
def _running_agents_keyboard(self, user_id: int) -> InlineKeyboardMarkup:
|
|
357
|
+
"""Keyboard with running agents to stop."""
|
|
358
|
+
keyboard = []
|
|
359
|
+
prefix = f"{user_id}:" if not self._is_admin(user_id) else ""
|
|
360
|
+
|
|
361
|
+
for key in self._active_agents:
|
|
362
|
+
if prefix and not key.startswith(prefix):
|
|
363
|
+
continue
|
|
364
|
+
# Extract project name
|
|
365
|
+
parts = key.split(":", 1)
|
|
366
|
+
project_name = parts[1] if len(parts) > 1 else parts[0]
|
|
367
|
+
keyboard.append([
|
|
368
|
+
InlineKeyboardButton(f"๐ Stop {project_name}", callback_data=f"stop:{project_name}")
|
|
369
|
+
])
|
|
370
|
+
|
|
371
|
+
keyboard.append([InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")])
|
|
372
|
+
return InlineKeyboardMarkup(keyboard)
|
|
373
|
+
|
|
374
|
+
# === Command Handlers ===
|
|
375
|
+
|
|
376
|
+
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
377
|
+
"""Handle /start command."""
|
|
378
|
+
user_id = update.effective_user.id
|
|
379
|
+
self._update_user_name(update.effective_user)
|
|
380
|
+
user_display = self._get_user_display(user_id)
|
|
381
|
+
|
|
382
|
+
if not self._check_user(user_id):
|
|
383
|
+
# Add to pending if not already
|
|
384
|
+
if user_id not in self._pending_users:
|
|
385
|
+
self._pending_users.add(user_id)
|
|
386
|
+
self._save_users()
|
|
387
|
+
|
|
388
|
+
# Notify admins
|
|
389
|
+
for admin_id in self.admins:
|
|
390
|
+
try:
|
|
391
|
+
await context.bot.send_message(
|
|
392
|
+
admin_id,
|
|
393
|
+
f"๐ *Access Request*\n\n"
|
|
394
|
+
f"๐ค {user_display}\n\n"
|
|
395
|
+
f"To approve: `/approve {user_id}`\n"
|
|
396
|
+
f"To deny: `/deny {user_id}`",
|
|
397
|
+
parse_mode="Markdown",
|
|
398
|
+
)
|
|
399
|
+
except:
|
|
400
|
+
pass
|
|
401
|
+
|
|
402
|
+
await update.message.reply_text(
|
|
403
|
+
f"๐ *Welcome to Nullabot!*\n\n"
|
|
404
|
+
f"Your access request has been sent to the admin.\n"
|
|
405
|
+
f"Please wait for approval.",
|
|
406
|
+
parse_mode="Markdown",
|
|
407
|
+
)
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
role = "๐ Admin" if self._is_admin(user_id) else "๐ค User"
|
|
411
|
+
await update.message.reply_text(
|
|
412
|
+
f"๐ *Welcome to Nullabot*\n\n"
|
|
413
|
+
f"๐ค 24/7 AI Workforce powered by *Claude Opus*\n"
|
|
414
|
+
f"๐ Role: {role}\n\n"
|
|
415
|
+
f"Choose an action below:",
|
|
416
|
+
parse_mode="Markdown",
|
|
417
|
+
reply_markup=self._main_menu_keyboard(user_id),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
async def cmd_menu(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
421
|
+
"""Show main menu."""
|
|
422
|
+
user_id = update.effective_user.id
|
|
423
|
+
if not self._check_user(user_id, update.effective_user):
|
|
424
|
+
return
|
|
425
|
+
await update.message.reply_text(
|
|
426
|
+
"๐ *Main Menu*\n\nChoose an action:",
|
|
427
|
+
parse_mode="Markdown",
|
|
428
|
+
reply_markup=self._main_menu_keyboard(user_id),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
async def cmd_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
432
|
+
"""Show help."""
|
|
433
|
+
user_id = update.effective_user.id
|
|
434
|
+
if not self._check_user(user_id, update.effective_user):
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
help_text = (
|
|
438
|
+
"๐ *Nullabot Help*\n\n"
|
|
439
|
+
"*Commands:*\n"
|
|
440
|
+
"/start - Start bot & show menu\n"
|
|
441
|
+
"/menu - Show main menu\n"
|
|
442
|
+
"/usage - Show 5hr window & costs\n"
|
|
443
|
+
"/chat <question> - Ask about status/limits\n"
|
|
444
|
+
"/files <project> - List project files\n"
|
|
445
|
+
"/rules - View/manage global rules\n"
|
|
446
|
+
"/help - Show this help\n\n"
|
|
447
|
+
"*Buttons:*\n"
|
|
448
|
+
"๐ *Projects* - View all projects\n"
|
|
449
|
+
"โ *New Project* - Create new project\n"
|
|
450
|
+
"๐ง *Think* - Research & ideation agent\n"
|
|
451
|
+
"๐จ *Design* - UI/UX design agent\n"
|
|
452
|
+
"๐ป *Code* - Software engineering agent\n"
|
|
453
|
+
"โถ๏ธ *Resume* - Continue paused agents\n"
|
|
454
|
+
"๐ *Status* - View running agents\n"
|
|
455
|
+
"๐ *Stop* - Stop running agents\n"
|
|
456
|
+
"๐ฐ *Costs* - View project costs\n\n"
|
|
457
|
+
"*Agent Handoff:*\n"
|
|
458
|
+
"Agents automatically see what previous agents did!\n"
|
|
459
|
+
"Thinker โ Designer โ Coder flow works seamlessly.\n\n"
|
|
460
|
+
"*Memory System:*\n"
|
|
461
|
+
"- ๐ค User memory: Your preferences across all projects\n"
|
|
462
|
+
"- ๐ Project memory: Context for this specific project\n"
|
|
463
|
+
"- ๐ Short-term: Recent session activity\n"
|
|
464
|
+
"- Agents share context automatically"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if self._is_admin(user_id):
|
|
468
|
+
help_text += (
|
|
469
|
+
"\n\n*Admin Commands:*\n"
|
|
470
|
+
"/users - List all users\n"
|
|
471
|
+
"/approve <id> - Approve pending user\n"
|
|
472
|
+
"/deny <id> - Deny pending user\n"
|
|
473
|
+
"/revoke <id> - Remove user access"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
await update.message.reply_text(
|
|
477
|
+
help_text,
|
|
478
|
+
parse_mode="Markdown",
|
|
479
|
+
reply_markup=self._main_menu_keyboard(user_id),
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
async def cmd_approve(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
483
|
+
"""Approve a pending user (admin only)."""
|
|
484
|
+
user_id = update.effective_user.id
|
|
485
|
+
if not self._is_admin(user_id):
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
if not context.args:
|
|
489
|
+
await update.message.reply_text("Usage: `/approve <user_id>`", parse_mode="Markdown")
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
target_id = int(context.args[0])
|
|
494
|
+
except ValueError:
|
|
495
|
+
await update.message.reply_text("Invalid user ID")
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
# Remove from pending, add to allowed
|
|
499
|
+
self._pending_users.discard(target_id)
|
|
500
|
+
self.allowed_users.add(target_id)
|
|
501
|
+
self._save_users()
|
|
502
|
+
|
|
503
|
+
name = self._get_user_display(target_id)
|
|
504
|
+
await update.message.reply_text(f"โ
Approved: {name}", parse_mode="Markdown")
|
|
505
|
+
|
|
506
|
+
# Notify the approved user
|
|
507
|
+
try:
|
|
508
|
+
await context.bot.send_message(
|
|
509
|
+
target_id,
|
|
510
|
+
"๐ *Access Granted!*\n\n"
|
|
511
|
+
"Your access request has been approved.\n"
|
|
512
|
+
"Use /start to begin!",
|
|
513
|
+
parse_mode="Markdown",
|
|
514
|
+
)
|
|
515
|
+
except:
|
|
516
|
+
pass
|
|
517
|
+
|
|
518
|
+
async def cmd_deny(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
519
|
+
"""Deny a pending user (admin only)."""
|
|
520
|
+
user_id = update.effective_user.id
|
|
521
|
+
if not self._is_admin(user_id):
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
if not context.args:
|
|
525
|
+
await update.message.reply_text("Usage: `/deny <user_id>`", parse_mode="Markdown")
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
target_id = int(context.args[0])
|
|
530
|
+
except ValueError:
|
|
531
|
+
await update.message.reply_text("Invalid user ID")
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
self._pending_users.discard(target_id)
|
|
535
|
+
self._save_users()
|
|
536
|
+
|
|
537
|
+
name = self._get_user_display(target_id)
|
|
538
|
+
await update.message.reply_text(f"โ Denied: {name}", parse_mode="Markdown")
|
|
539
|
+
|
|
540
|
+
async def cmd_users(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
541
|
+
"""List all users (admin only)."""
|
|
542
|
+
user_id = update.effective_user.id
|
|
543
|
+
if not self._is_admin(user_id):
|
|
544
|
+
return
|
|
545
|
+
|
|
546
|
+
lines = ["๐ฅ *Users*\n"]
|
|
547
|
+
|
|
548
|
+
# Admins
|
|
549
|
+
if self.admins:
|
|
550
|
+
lines.append("*Admins:*")
|
|
551
|
+
for uid in self.admins:
|
|
552
|
+
name = self._get_user_display(uid)
|
|
553
|
+
lines.append(f" ๐ {name}")
|
|
554
|
+
|
|
555
|
+
# Allowed users
|
|
556
|
+
non_admin_users = self.allowed_users - self.admins
|
|
557
|
+
if non_admin_users:
|
|
558
|
+
lines.append("\n*Allowed:*")
|
|
559
|
+
for uid in non_admin_users:
|
|
560
|
+
name = self._get_user_display(uid)
|
|
561
|
+
lines.append(f" โ
{name}")
|
|
562
|
+
|
|
563
|
+
# Pending (show ID for /approve command)
|
|
564
|
+
if self._pending_users:
|
|
565
|
+
lines.append("\n*Pending:*")
|
|
566
|
+
for uid in self._pending_users:
|
|
567
|
+
name = self._get_user_display(uid)
|
|
568
|
+
lines.append(f" โณ {name}")
|
|
569
|
+
lines.append(f" `/approve {uid}`")
|
|
570
|
+
|
|
571
|
+
if len(lines) == 1:
|
|
572
|
+
lines.append("No users yet")
|
|
573
|
+
|
|
574
|
+
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
|
|
575
|
+
|
|
576
|
+
async def cmd_revoke(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
577
|
+
"""Revoke user access (admin only)."""
|
|
578
|
+
user_id = update.effective_user.id
|
|
579
|
+
if not self._is_admin(user_id):
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
if not context.args:
|
|
583
|
+
await update.message.reply_text("Usage: `/revoke <user_id>`", parse_mode="Markdown")
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
target_id = int(context.args[0])
|
|
588
|
+
except ValueError:
|
|
589
|
+
await update.message.reply_text("Invalid user ID")
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
if target_id in self.admins:
|
|
593
|
+
await update.message.reply_text("Cannot revoke admin access")
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
self.allowed_users.discard(target_id)
|
|
597
|
+
self._save_users()
|
|
598
|
+
|
|
599
|
+
name = self._get_user_display(target_id)
|
|
600
|
+
await update.message.reply_text(f"๐ซ Revoked: {name}", parse_mode="Markdown")
|
|
601
|
+
|
|
602
|
+
async def cmd_chat(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
603
|
+
"""Handle /chat command - ask Claude about projects, usage, etc."""
|
|
604
|
+
user_id = update.effective_user.id
|
|
605
|
+
if not self._check_user(user_id, update.effective_user):
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
question = " ".join(context.args) if context.args else ""
|
|
609
|
+
chat_id = update.effective_chat.id
|
|
610
|
+
|
|
611
|
+
if not question:
|
|
612
|
+
# Start new conversation - clear history
|
|
613
|
+
self._chat_history[chat_id] = []
|
|
614
|
+
self._pending_action[chat_id] = {"action": "chat_mode", "user_id": user_id}
|
|
615
|
+
await update.message.reply_text(
|
|
616
|
+
"๐ฌ *New Conversation*\n\n"
|
|
617
|
+
"Type your question. I'll remember our chat until you end it.\n\n"
|
|
618
|
+
"_Examples:_\n"
|
|
619
|
+
"โข How is my 5hr limit?\n"
|
|
620
|
+
"โข What's my total cost?\n"
|
|
621
|
+
"โข Summarize real-estate-agent",
|
|
622
|
+
parse_mode="Markdown",
|
|
623
|
+
reply_markup=InlineKeyboardMarkup([
|
|
624
|
+
[InlineKeyboardButton("๐ End Chat", callback_data="menu:main")],
|
|
625
|
+
]),
|
|
626
|
+
)
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
# Process the question
|
|
630
|
+
await self._process_chat_question(update, user_id, question)
|
|
631
|
+
|
|
632
|
+
async def _process_chat_question(self, update: Update, user_id: int, question: str) -> None:
|
|
633
|
+
"""Process a chat question using Claude CLI with conversation history."""
|
|
634
|
+
import subprocess
|
|
635
|
+
|
|
636
|
+
# Show typing indicator
|
|
637
|
+
await update.message.reply_chat_action("typing")
|
|
638
|
+
|
|
639
|
+
chat_id = update.effective_chat.id
|
|
640
|
+
continue_keyboard = InlineKeyboardMarkup([
|
|
641
|
+
[InlineKeyboardButton("๐ End Chat", callback_data="menu:main")],
|
|
642
|
+
])
|
|
643
|
+
|
|
644
|
+
# Build prompt with conversation history
|
|
645
|
+
history = self._chat_history.get(chat_id, [])
|
|
646
|
+
|
|
647
|
+
if history:
|
|
648
|
+
# Include conversation context
|
|
649
|
+
context_parts = ["Previous conversation:"]
|
|
650
|
+
for h in history[-6:]: # Last 6 exchanges max
|
|
651
|
+
context_parts.append(f"User: {h['q']}")
|
|
652
|
+
context_parts.append(f"Assistant: {h['a'][:500]}")
|
|
653
|
+
context_parts.append(f"\nNew question: {question}")
|
|
654
|
+
full_prompt = "\n".join(context_parts)
|
|
655
|
+
else:
|
|
656
|
+
full_prompt = question
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
# Use Claude CLI to answer
|
|
660
|
+
result = subprocess.run(
|
|
661
|
+
["claude", "-p", full_prompt, "--output-format", "json"],
|
|
662
|
+
capture_output=True,
|
|
663
|
+
text=True,
|
|
664
|
+
timeout=120,
|
|
665
|
+
cwd=str(self.base_projects_dir.parent),
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
if result.returncode == 0:
|
|
669
|
+
import json as json_module
|
|
670
|
+
data = json_module.loads(result.stdout)
|
|
671
|
+
response = data.get("result", "No response")
|
|
672
|
+
|
|
673
|
+
# Save to history
|
|
674
|
+
if chat_id not in self._chat_history:
|
|
675
|
+
self._chat_history[chat_id] = []
|
|
676
|
+
self._chat_history[chat_id].append({"q": question, "a": response})
|
|
677
|
+
|
|
678
|
+
# Truncate if too long for Telegram (4096 char limit)
|
|
679
|
+
if len(response) > 3500:
|
|
680
|
+
response = response[:3500] + "\n\n_...truncated_"
|
|
681
|
+
|
|
682
|
+
# Show message count
|
|
683
|
+
msg_count = len(self._chat_history.get(chat_id, []))
|
|
684
|
+
await update.message.reply_text(
|
|
685
|
+
f"๐ฌ {response}\n\n_Message {msg_count} in this chat_",
|
|
686
|
+
parse_mode="Markdown",
|
|
687
|
+
reply_markup=continue_keyboard,
|
|
688
|
+
)
|
|
689
|
+
else:
|
|
690
|
+
await update.message.reply_text(
|
|
691
|
+
f"โ Error: {result.stderr[:500] if result.stderr else 'Unknown error'}",
|
|
692
|
+
reply_markup=continue_keyboard,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
except subprocess.TimeoutExpired:
|
|
696
|
+
await update.message.reply_text(
|
|
697
|
+
"โฑ Request timed out. Try a simpler question.",
|
|
698
|
+
reply_markup=continue_keyboard,
|
|
699
|
+
)
|
|
700
|
+
except Exception as e:
|
|
701
|
+
await update.message.reply_text(
|
|
702
|
+
f"โ Error: {str(e)[:200]}",
|
|
703
|
+
reply_markup=continue_keyboard,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def _make_progress_bar(self, percentage: float, width: int = 10) -> str:
|
|
707
|
+
"""Create a text progress bar."""
|
|
708
|
+
filled = int(width * percentage / 100)
|
|
709
|
+
empty = width - filled
|
|
710
|
+
return f"[{'โ' * filled}{'โ' * empty}]"
|
|
711
|
+
|
|
712
|
+
async def callback_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
713
|
+
"""Handle all button callbacks."""
|
|
714
|
+
query = update.callback_query
|
|
715
|
+
await query.answer()
|
|
716
|
+
|
|
717
|
+
user_id = update.effective_user.id
|
|
718
|
+
if not self._check_user(user_id, update.effective_user):
|
|
719
|
+
return
|
|
720
|
+
|
|
721
|
+
data = query.data
|
|
722
|
+
chat_id = update.effective_chat.id
|
|
723
|
+
|
|
724
|
+
# Clear pending actions on menu navigation
|
|
725
|
+
if data.startswith("menu:"):
|
|
726
|
+
self._pending_action.pop(chat_id, None)
|
|
727
|
+
|
|
728
|
+
# Menu navigation
|
|
729
|
+
if data == "menu:main":
|
|
730
|
+
# Clear chat history when returning to main menu
|
|
731
|
+
self._chat_history.pop(chat_id, None)
|
|
732
|
+
await query.edit_message_text(
|
|
733
|
+
"๐ *Main Menu*\n\nChoose an action:",
|
|
734
|
+
parse_mode="Markdown",
|
|
735
|
+
reply_markup=self._main_menu_keyboard(user_id),
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
elif data == "menu:projects":
|
|
739
|
+
projects = self._list_projects(user_id)
|
|
740
|
+
if not projects:
|
|
741
|
+
await query.edit_message_text(
|
|
742
|
+
"๐ *No projects yet*\n\nCreate one first!",
|
|
743
|
+
parse_mode="Markdown",
|
|
744
|
+
reply_markup=InlineKeyboardMarkup([
|
|
745
|
+
[InlineKeyboardButton("โ New Project", callback_data="menu:new")],
|
|
746
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
747
|
+
]),
|
|
748
|
+
)
|
|
749
|
+
else:
|
|
750
|
+
await query.edit_message_text(
|
|
751
|
+
"๐ *Select a Project*\n\nTap to start an agent:",
|
|
752
|
+
parse_mode="Markdown",
|
|
753
|
+
reply_markup=self._projects_keyboard(user_id, "select"),
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
elif data == "menu:new":
|
|
757
|
+
self._pending_action[chat_id] = {"action": "create_project", "user_id": user_id}
|
|
758
|
+
await query.edit_message_text(
|
|
759
|
+
"โ *Create New Project*\n\n"
|
|
760
|
+
"Send me the project name:\n"
|
|
761
|
+
"(e.g., `my-app` or `website-redesign`)\n\n"
|
|
762
|
+
"_Or tap Back to cancel_",
|
|
763
|
+
parse_mode="Markdown",
|
|
764
|
+
reply_markup=InlineKeyboardMarkup([
|
|
765
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
766
|
+
]),
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
elif data == "menu:status":
|
|
770
|
+
await self._show_status(query, user_id)
|
|
771
|
+
|
|
772
|
+
elif data == "menu:stop":
|
|
773
|
+
# Check for running agents for this user
|
|
774
|
+
user_agents = [k for k in self._active_agents if k.startswith(f"{user_id}:") or self._is_admin(user_id)]
|
|
775
|
+
if not user_agents:
|
|
776
|
+
await query.edit_message_text(
|
|
777
|
+
"No agents running.",
|
|
778
|
+
reply_markup=InlineKeyboardMarkup([
|
|
779
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
780
|
+
]),
|
|
781
|
+
)
|
|
782
|
+
else:
|
|
783
|
+
await query.edit_message_text(
|
|
784
|
+
"๐ *Stop Agent*\n\nSelect agent to stop:",
|
|
785
|
+
parse_mode="Markdown",
|
|
786
|
+
reply_markup=self._running_agents_keyboard(user_id),
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
elif data == "menu:resume":
|
|
790
|
+
paused = self._get_paused_projects(user_id)
|
|
791
|
+
if not paused:
|
|
792
|
+
await query.edit_message_text(
|
|
793
|
+
"โถ๏ธ *No paused projects*\n\nNo agents to resume.",
|
|
794
|
+
parse_mode="Markdown",
|
|
795
|
+
reply_markup=InlineKeyboardMarkup([
|
|
796
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
797
|
+
]),
|
|
798
|
+
)
|
|
799
|
+
else:
|
|
800
|
+
keyboard = []
|
|
801
|
+
for p, state in paused:
|
|
802
|
+
cycles = state.get("cycles", 0)
|
|
803
|
+
agent_type = state.get("_agent_type", state.get("agent_type", "thinker"))
|
|
804
|
+
emoji = {"thinker": "๐ง ", "designer": "๐จ", "coder": "๐ป"}.get(agent_type, "๐ค")
|
|
805
|
+
keyboard.append([
|
|
806
|
+
InlineKeyboardButton(
|
|
807
|
+
f"โถ๏ธ {p.name} {emoji} {agent_type} ({cycles} cycles)",
|
|
808
|
+
callback_data=f"resume:{p.name}:{agent_type}"
|
|
809
|
+
)
|
|
810
|
+
])
|
|
811
|
+
keyboard.append([InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")])
|
|
812
|
+
|
|
813
|
+
await query.edit_message_text(
|
|
814
|
+
"โถ๏ธ *Resume Paused Agent*\n\nSelect to continue:",
|
|
815
|
+
parse_mode="Markdown",
|
|
816
|
+
reply_markup=InlineKeyboardMarkup(keyboard),
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
elif data == "menu:costs":
|
|
820
|
+
await self._show_costs(query, user_id)
|
|
821
|
+
|
|
822
|
+
elif data == "menu:chat":
|
|
823
|
+
# Start new conversation - clear history
|
|
824
|
+
self._chat_history[chat_id] = []
|
|
825
|
+
self._pending_action[chat_id] = {"action": "chat_mode", "user_id": user_id}
|
|
826
|
+
await query.edit_message_text(
|
|
827
|
+
"๐ฌ *New Conversation*\n\n"
|
|
828
|
+
"Type your question. I'll remember our chat until you go back.\n\n"
|
|
829
|
+
"_Examples:_\n"
|
|
830
|
+
"โข How is my 5hr limit?\n"
|
|
831
|
+
"โข What's my total cost?\n"
|
|
832
|
+
"โข Summarize real-estate-agent",
|
|
833
|
+
parse_mode="Markdown",
|
|
834
|
+
reply_markup=InlineKeyboardMarkup([
|
|
835
|
+
[InlineKeyboardButton("๐ End Chat", callback_data="menu:main")],
|
|
836
|
+
]),
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
# Admin: show all users
|
|
840
|
+
elif data == "admin:users" and self._is_admin(user_id):
|
|
841
|
+
await self._show_all_users(query)
|
|
842
|
+
|
|
843
|
+
# Refresh usage
|
|
844
|
+
elif data == "usage:refresh":
|
|
845
|
+
await self._show_usage_inline(query, user_id)
|
|
846
|
+
|
|
847
|
+
# Continue chat mode
|
|
848
|
+
elif data == "chat:continue":
|
|
849
|
+
self._pending_action[chat_id] = {"action": "chat_mode", "user_id": user_id}
|
|
850
|
+
await query.edit_message_text(
|
|
851
|
+
"๐ฌ *Chat Mode*\n\n"
|
|
852
|
+
"Type your question and send.",
|
|
853
|
+
parse_mode="Markdown",
|
|
854
|
+
reply_markup=InlineKeyboardMarkup([
|
|
855
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
856
|
+
]),
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
# Resume a paused project
|
|
860
|
+
elif data.startswith("resume:"):
|
|
861
|
+
parts = data.split(":")
|
|
862
|
+
project_name = parts[1]
|
|
863
|
+
agent_type = parts[2] if len(parts) > 2 else "thinker"
|
|
864
|
+
|
|
865
|
+
project_path = self._get_project_path(user_id, project_name)
|
|
866
|
+
state = self._get_project_state(project_path, agent_type)
|
|
867
|
+
task = state.get("task", "")
|
|
868
|
+
cycles = state.get("cycles", 0)
|
|
869
|
+
|
|
870
|
+
if not task:
|
|
871
|
+
await query.edit_message_text(
|
|
872
|
+
f"โ No task found for `{project_name}`",
|
|
873
|
+
parse_mode="Markdown",
|
|
874
|
+
reply_markup=InlineKeyboardMarkup([
|
|
875
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
876
|
+
]),
|
|
877
|
+
)
|
|
878
|
+
return
|
|
879
|
+
|
|
880
|
+
await self._resume_agent(user_id, project_name, agent_type, task, chat_id)
|
|
881
|
+
|
|
882
|
+
emoji = {"thinker": "๐ง ", "designer": "๐จ", "coder": "๐ป"}.get(agent_type, "๐ค")
|
|
883
|
+
await query.edit_message_text(
|
|
884
|
+
f"โถ๏ธ *Resumed!*\n\n"
|
|
885
|
+
f"{emoji} *Type:* {agent_type.upper()}\n"
|
|
886
|
+
f"๐ *Project:* `{project_name}`\n"
|
|
887
|
+
f"๐ *From cycle:* {cycles}\n\n"
|
|
888
|
+
f"๐ *Task:*\n_{task[:150]}_",
|
|
889
|
+
parse_mode="Markdown",
|
|
890
|
+
reply_markup=InlineKeyboardMarkup([
|
|
891
|
+
[InlineKeyboardButton("๐ Stop", callback_data=f"stop:{project_name}")],
|
|
892
|
+
[InlineKeyboardButton("๐ Status", callback_data="menu:status")],
|
|
893
|
+
]),
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
# Agent type selection (before project)
|
|
897
|
+
elif data.startswith("agent:"):
|
|
898
|
+
agent_type = data.split(":")[1]
|
|
899
|
+
projects = self._list_projects(user_id)
|
|
900
|
+
|
|
901
|
+
if not projects:
|
|
902
|
+
await query.edit_message_text(
|
|
903
|
+
f"๐ *No projects yet*\n\nCreate a project first to use {agent_type}!",
|
|
904
|
+
parse_mode="Markdown",
|
|
905
|
+
reply_markup=InlineKeyboardMarkup([
|
|
906
|
+
[InlineKeyboardButton("โ New Project", callback_data="menu:new")],
|
|
907
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
908
|
+
]),
|
|
909
|
+
)
|
|
910
|
+
else:
|
|
911
|
+
keyboard = []
|
|
912
|
+
for p in sorted(projects, key=lambda x: x.name)[:10]:
|
|
913
|
+
agent_key = self._get_agent_key(user_id, p.name)
|
|
914
|
+
status = "๐ข" if agent_key in self._active_agents else "โช"
|
|
915
|
+
memory = self._get_project_memory(p)
|
|
916
|
+
agents_done = ", ".join(memory.get("agents", [])) or "none"
|
|
917
|
+
|
|
918
|
+
keyboard.append([
|
|
919
|
+
InlineKeyboardButton(
|
|
920
|
+
f"{status} {p.name} ({agents_done})",
|
|
921
|
+
callback_data=f"start:{agent_type}:{p.name}"
|
|
922
|
+
)
|
|
923
|
+
])
|
|
924
|
+
keyboard.append([InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")])
|
|
925
|
+
|
|
926
|
+
emoji = {"thinker": "๐ง ", "designer": "๐จ", "coder": "๐ป"}.get(agent_type, "๐ค")
|
|
927
|
+
await query.edit_message_text(
|
|
928
|
+
f"{emoji} *{agent_type.upper()}*\n\n"
|
|
929
|
+
f"Select project:\n"
|
|
930
|
+
f"_(shows which agents already ran)_",
|
|
931
|
+
parse_mode="Markdown",
|
|
932
|
+
reply_markup=InlineKeyboardMarkup(keyboard),
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
# Project selection
|
|
936
|
+
elif data.startswith("project:select:"):
|
|
937
|
+
project_name = data.split(":")[2]
|
|
938
|
+
project_path = self._get_project_path(user_id, project_name)
|
|
939
|
+
memory = self._get_project_memory(project_path)
|
|
940
|
+
usage = self._get_project_usage(project_path)
|
|
941
|
+
|
|
942
|
+
agents_done = ", ".join(memory.get("agents", [])) or "none yet"
|
|
943
|
+
|
|
944
|
+
await query.edit_message_text(
|
|
945
|
+
f"๐ *{project_name}*\n\n"
|
|
946
|
+
f"๐ฐ Cost: ${usage.get('total_cost_usd', 0):.2f}\n"
|
|
947
|
+
f"๐ง Agents completed: {agents_done}\n"
|
|
948
|
+
f"๐ Memories: {memory.get('long_term', 0)} long-term\n\n"
|
|
949
|
+
f"Select agent type:",
|
|
950
|
+
parse_mode="Markdown",
|
|
951
|
+
reply_markup=self._agent_keyboard(project_name),
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
# Start agent
|
|
955
|
+
elif data.startswith("start:"):
|
|
956
|
+
parts = data.split(":")
|
|
957
|
+
agent_type = parts[1]
|
|
958
|
+
project_name = parts[2]
|
|
959
|
+
agent_key = self._get_agent_key(user_id, project_name)
|
|
960
|
+
|
|
961
|
+
if agent_key in self._active_agents:
|
|
962
|
+
await query.edit_message_text(
|
|
963
|
+
f"โ ๏ธ Agent already running on `{project_name}`!\n\n"
|
|
964
|
+
f"Stop it first to start a new one.",
|
|
965
|
+
parse_mode="Markdown",
|
|
966
|
+
reply_markup=InlineKeyboardMarkup([
|
|
967
|
+
[InlineKeyboardButton(f"๐ Stop {project_name}", callback_data=f"stop:{project_name}")],
|
|
968
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
969
|
+
]),
|
|
970
|
+
)
|
|
971
|
+
return
|
|
972
|
+
|
|
973
|
+
# Ask for task
|
|
974
|
+
self._pending_action[chat_id] = {
|
|
975
|
+
"action": "start_agent",
|
|
976
|
+
"user_id": user_id,
|
|
977
|
+
"agent_type": agent_type,
|
|
978
|
+
"project": project_name,
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
# Show context hint
|
|
982
|
+
project_path = self._get_project_path(user_id, project_name)
|
|
983
|
+
memory = self._get_project_memory(project_path)
|
|
984
|
+
agents_done = memory.get("agents", [])
|
|
985
|
+
|
|
986
|
+
context_hint = ""
|
|
987
|
+
if agents_done:
|
|
988
|
+
context_hint = f"\n\nโจ _{agent_type} will automatically see work from: {', '.join(agents_done)}_"
|
|
989
|
+
|
|
990
|
+
emoji = {"thinker": "๐ง ", "designer": "๐จ", "coder": "๐ป"}.get(agent_type, "๐ค")
|
|
991
|
+
await query.edit_message_text(
|
|
992
|
+
f"{emoji} *{agent_type.upper()}* on `{project_name}`\n\n"
|
|
993
|
+
f"๐ Send me the task:{context_hint}\n\n"
|
|
994
|
+
f"_Example: Research AI features for a real estate app_\n\n"
|
|
995
|
+
f"_Or tap Back to cancel_",
|
|
996
|
+
parse_mode="Markdown",
|
|
997
|
+
reply_markup=InlineKeyboardMarkup([
|
|
998
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
999
|
+
]),
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
# Stop agent
|
|
1003
|
+
elif data.startswith("stop:"):
|
|
1004
|
+
project_name = data.split(":")[1]
|
|
1005
|
+
await self._stop_agent(user_id, project_name, query)
|
|
1006
|
+
|
|
1007
|
+
async def _show_status(self, query, user_id: int) -> None:
|
|
1008
|
+
"""Show status with visual formatting."""
|
|
1009
|
+
from datetime import datetime as dt
|
|
1010
|
+
now = dt.now().strftime("%H:%M:%S")
|
|
1011
|
+
|
|
1012
|
+
# Get running agents for this user
|
|
1013
|
+
user_agents = []
|
|
1014
|
+
for key in self._active_agents:
|
|
1015
|
+
if key.startswith(f"{user_id}:") or self._is_admin(user_id):
|
|
1016
|
+
parts = key.split(":", 1)
|
|
1017
|
+
owner_id = int(parts[0])
|
|
1018
|
+
project_name = parts[1]
|
|
1019
|
+
user_agents.append((owner_id, project_name))
|
|
1020
|
+
|
|
1021
|
+
if not user_agents:
|
|
1022
|
+
text = f"๐ *Status* _{now}_\n\nโจ No agents running\n\nStart one from the menu!"
|
|
1023
|
+
else:
|
|
1024
|
+
lines = [f"๐ *Running Agents* _{now}_\n"]
|
|
1025
|
+
for owner_id, project_name in user_agents:
|
|
1026
|
+
project_path = self._get_project_path(owner_id, project_name)
|
|
1027
|
+
state = self._get_project_state(project_path)
|
|
1028
|
+
usage = self._get_project_usage(project_path)
|
|
1029
|
+
cycles = state.get("cycles", 0)
|
|
1030
|
+
files = len(state.get("files_created", []))
|
|
1031
|
+
cost = usage.get("total_cost_usd", 0)
|
|
1032
|
+
task = state.get("task", "")[:40]
|
|
1033
|
+
|
|
1034
|
+
owner_label = "" if owner_id == user_id else f" (user {owner_id})"
|
|
1035
|
+
lines.append(f"๐ข *{project_name}*{owner_label}")
|
|
1036
|
+
lines.append(f" ๐ Cycles: {cycles} | Files: {files} | Cost: ${cost:.2f}")
|
|
1037
|
+
if task:
|
|
1038
|
+
lines.append(f" ๐ _{task}..._")
|
|
1039
|
+
lines.append("")
|
|
1040
|
+
|
|
1041
|
+
text = "\n".join(lines)
|
|
1042
|
+
|
|
1043
|
+
try:
|
|
1044
|
+
await query.edit_message_text(
|
|
1045
|
+
text,
|
|
1046
|
+
parse_mode="Markdown",
|
|
1047
|
+
reply_markup=InlineKeyboardMarkup([
|
|
1048
|
+
[InlineKeyboardButton("๐ Refresh", callback_data="menu:status")],
|
|
1049
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
1050
|
+
]),
|
|
1051
|
+
)
|
|
1052
|
+
except Exception:
|
|
1053
|
+
pass
|
|
1054
|
+
|
|
1055
|
+
async def _show_costs(self, query, user_id: int) -> None:
|
|
1056
|
+
"""Show cost summary for all projects."""
|
|
1057
|
+
projects = self._list_projects(user_id)
|
|
1058
|
+
|
|
1059
|
+
if not projects:
|
|
1060
|
+
await query.edit_message_text(
|
|
1061
|
+
"๐ฐ *Costs*\n\nNo projects yet.",
|
|
1062
|
+
parse_mode="Markdown",
|
|
1063
|
+
reply_markup=InlineKeyboardMarkup([
|
|
1064
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
1065
|
+
]),
|
|
1066
|
+
)
|
|
1067
|
+
return
|
|
1068
|
+
|
|
1069
|
+
total_cost = 0
|
|
1070
|
+
total_cycles = 0
|
|
1071
|
+
lines = ["๐ฐ *Cost Summary*\n"]
|
|
1072
|
+
|
|
1073
|
+
for p in sorted(projects, key=lambda x: x.name):
|
|
1074
|
+
usage = self._get_project_usage(p)
|
|
1075
|
+
cost = usage.get("total_cost_usd", 0)
|
|
1076
|
+
cycles = usage.get("total_cycles", 0)
|
|
1077
|
+
total_cost += cost
|
|
1078
|
+
total_cycles += cycles
|
|
1079
|
+
|
|
1080
|
+
if cost > 0:
|
|
1081
|
+
lines.append(f"โข `{p.name}`: ${cost:.2f} ({cycles} cycles)")
|
|
1082
|
+
|
|
1083
|
+
lines.append(f"\n*Total: ${total_cost:.2f}* ({total_cycles} cycles)")
|
|
1084
|
+
|
|
1085
|
+
await query.edit_message_text(
|
|
1086
|
+
"\n".join(lines),
|
|
1087
|
+
parse_mode="Markdown",
|
|
1088
|
+
reply_markup=InlineKeyboardMarkup([
|
|
1089
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
1090
|
+
]),
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
async def _show_usage_inline(self, query, user_id: int) -> None:
|
|
1094
|
+
"""Show usage stats inline (for refresh button)."""
|
|
1095
|
+
from nullabot.core.memory import UsageTracker
|
|
1096
|
+
from datetime import datetime
|
|
1097
|
+
|
|
1098
|
+
base_dir = self.base_projects_dir.parent
|
|
1099
|
+
|
|
1100
|
+
lines = [
|
|
1101
|
+
"๐ *Nullabot Usage*\n",
|
|
1102
|
+
]
|
|
1103
|
+
|
|
1104
|
+
# Per-project costs
|
|
1105
|
+
total_cost = 0
|
|
1106
|
+
total_cycles = 0
|
|
1107
|
+
total_hours = 0
|
|
1108
|
+
|
|
1109
|
+
for item in self.base_projects_dir.iterdir():
|
|
1110
|
+
if item.is_dir() and (item / ".nullabot").exists():
|
|
1111
|
+
try:
|
|
1112
|
+
tracker = UsageTracker(item, base_dir)
|
|
1113
|
+
summary = tracker.get_summary()
|
|
1114
|
+
if summary["total_cycles"] > 0:
|
|
1115
|
+
lines.append(f"๐ *{item.name}*")
|
|
1116
|
+
lines.append(f" {summary['total_cycles']} cycles ยท {summary['total_hours']:.1f}hrs ยท ${summary['total_cost_usd']:.2f}")
|
|
1117
|
+
total_cost += summary["total_cost_usd"]
|
|
1118
|
+
total_cycles += summary["total_cycles"]
|
|
1119
|
+
total_hours += summary["total_hours"]
|
|
1120
|
+
except:
|
|
1121
|
+
pass
|
|
1122
|
+
|
|
1123
|
+
if total_cycles > 0:
|
|
1124
|
+
lines.append(f"\n๐ฐ *Total:* {total_cycles} cycles ยท {total_hours:.1f}hrs ยท ${total_cost:.2f}")
|
|
1125
|
+
else:
|
|
1126
|
+
lines.append("_No usage yet_")
|
|
1127
|
+
|
|
1128
|
+
lines.append(f"\n_Updated: {datetime.now().strftime('%H:%M:%S')}_")
|
|
1129
|
+
lines.append("\nโโโโโโโโโโโโโโโโโ")
|
|
1130
|
+
lines.append("โน๏ธ _For real Claude limits:_")
|
|
1131
|
+
lines.append("_`claude` โ `/usage` in terminal_")
|
|
1132
|
+
|
|
1133
|
+
await query.edit_message_text(
|
|
1134
|
+
"\n".join(lines),
|
|
1135
|
+
parse_mode="Markdown",
|
|
1136
|
+
reply_markup=InlineKeyboardMarkup([
|
|
1137
|
+
[InlineKeyboardButton("๐ Refresh", callback_data="usage:refresh")],
|
|
1138
|
+
[InlineKeyboardButton("โฌ
๏ธ Menu", callback_data="menu:main")],
|
|
1139
|
+
]),
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
async def _show_all_users(self, query) -> None:
|
|
1143
|
+
"""Admin: Show all users and their projects."""
|
|
1144
|
+
lines = ["๐ฅ *All Users*\n"]
|
|
1145
|
+
|
|
1146
|
+
# Show admin projects first
|
|
1147
|
+
admin_projects = []
|
|
1148
|
+
user_sections = []
|
|
1149
|
+
|
|
1150
|
+
# Scan base projects directory
|
|
1151
|
+
for item in self.base_projects_dir.iterdir():
|
|
1152
|
+
if item.is_dir():
|
|
1153
|
+
if item.name.isdigit():
|
|
1154
|
+
# User subdirectory
|
|
1155
|
+
uid = int(item.name)
|
|
1156
|
+
name = self._get_user_display(uid)
|
|
1157
|
+
projects = [p for p in item.iterdir() if p.is_dir() and (p / ".nullabot").exists()]
|
|
1158
|
+
if projects:
|
|
1159
|
+
user_sections.append(f"๐ค *{name}*")
|
|
1160
|
+
for p in projects:
|
|
1161
|
+
user_sections.append(f" ๐ {p.name}")
|
|
1162
|
+
elif (item / ".nullabot").exists():
|
|
1163
|
+
# Direct project (admin's)
|
|
1164
|
+
admin_projects.append(item.name)
|
|
1165
|
+
|
|
1166
|
+
# Add admin projects with admin's name
|
|
1167
|
+
if admin_projects and self.admins:
|
|
1168
|
+
admin_id = list(self.admins)[0]
|
|
1169
|
+
admin_name = self._get_user_display(admin_id)
|
|
1170
|
+
lines.append(f"๐ *{admin_name}*")
|
|
1171
|
+
for proj_name in admin_projects:
|
|
1172
|
+
lines.append(f" ๐ {proj_name}")
|
|
1173
|
+
|
|
1174
|
+
# Add user sections
|
|
1175
|
+
if user_sections:
|
|
1176
|
+
if admin_projects:
|
|
1177
|
+
lines.append("")
|
|
1178
|
+
lines.extend(user_sections)
|
|
1179
|
+
|
|
1180
|
+
if len(lines) == 1:
|
|
1181
|
+
lines.append("No projects yet")
|
|
1182
|
+
|
|
1183
|
+
await query.edit_message_text(
|
|
1184
|
+
"\n".join(lines),
|
|
1185
|
+
parse_mode="Markdown",
|
|
1186
|
+
reply_markup=InlineKeyboardMarkup([
|
|
1187
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
1188
|
+
]),
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
async def _stop_agent(self, user_id: int, project_name: str, query) -> None:
|
|
1192
|
+
"""Stop an agent."""
|
|
1193
|
+
agent_key = self._get_agent_key(user_id, project_name)
|
|
1194
|
+
|
|
1195
|
+
if agent_key not in self._active_agents:
|
|
1196
|
+
await query.edit_message_text(
|
|
1197
|
+
f"No agent running on `{project_name}`.",
|
|
1198
|
+
parse_mode="Markdown",
|
|
1199
|
+
reply_markup=InlineKeyboardMarkup([
|
|
1200
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
1201
|
+
]),
|
|
1202
|
+
)
|
|
1203
|
+
return
|
|
1204
|
+
|
|
1205
|
+
task = self._active_agents[agent_key]
|
|
1206
|
+
task.cancel()
|
|
1207
|
+
|
|
1208
|
+
project_path = self._get_project_path(user_id, project_name)
|
|
1209
|
+
state_file = project_path / ".nullabot" / "state.json"
|
|
1210
|
+
if state_file.exists():
|
|
1211
|
+
state = json.loads(state_file.read_text())
|
|
1212
|
+
state["status"] = "paused"
|
|
1213
|
+
state_file.write_text(json.dumps(state, indent=2))
|
|
1214
|
+
|
|
1215
|
+
self._active_agents.pop(agent_key, None)
|
|
1216
|
+
self._agent_chat_ids.pop(agent_key, None)
|
|
1217
|
+
|
|
1218
|
+
await query.edit_message_text(
|
|
1219
|
+
f"๐ *Stopped* `{project_name}`\n\n"
|
|
1220
|
+
f"State saved. You can resume later with the same task.",
|
|
1221
|
+
parse_mode="Markdown",
|
|
1222
|
+
reply_markup=InlineKeyboardMarkup([
|
|
1223
|
+
[InlineKeyboardButton("๐ Menu", callback_data="menu:main")],
|
|
1224
|
+
]),
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
1228
|
+
"""Handle text messages for conversations."""
|
|
1229
|
+
from nullabot.core.memory import UserMemory
|
|
1230
|
+
|
|
1231
|
+
user_id = update.effective_user.id
|
|
1232
|
+
if not self._check_user(user_id, update.effective_user):
|
|
1233
|
+
return
|
|
1234
|
+
|
|
1235
|
+
chat_id = update.effective_chat.id
|
|
1236
|
+
text = update.message.text.strip()
|
|
1237
|
+
|
|
1238
|
+
# Auto-extract rules from user messages
|
|
1239
|
+
try:
|
|
1240
|
+
user_memory = UserMemory(self.base_projects_dir.parent, user_id)
|
|
1241
|
+
user_memory.extract_from_response(text)
|
|
1242
|
+
except:
|
|
1243
|
+
pass
|
|
1244
|
+
|
|
1245
|
+
if chat_id in self._pending_action:
|
|
1246
|
+
action = self._pending_action[chat_id]
|
|
1247
|
+
|
|
1248
|
+
if action["action"] == "create_project":
|
|
1249
|
+
await self._create_project(update, user_id, text)
|
|
1250
|
+
del self._pending_action[chat_id]
|
|
1251
|
+
|
|
1252
|
+
elif action["action"] == "start_agent":
|
|
1253
|
+
await self._start_agent_with_task(
|
|
1254
|
+
update,
|
|
1255
|
+
action["user_id"],
|
|
1256
|
+
action["project"],
|
|
1257
|
+
action["agent_type"],
|
|
1258
|
+
text,
|
|
1259
|
+
)
|
|
1260
|
+
del self._pending_action[chat_id]
|
|
1261
|
+
|
|
1262
|
+
elif action["action"] == "chat_mode":
|
|
1263
|
+
# Process as chat question, keep chat mode active
|
|
1264
|
+
await self._process_chat_question(update, user_id, text)
|
|
1265
|
+
|
|
1266
|
+
else:
|
|
1267
|
+
await update.message.reply_text(
|
|
1268
|
+
"๐ Tap a button below to get started!",
|
|
1269
|
+
reply_markup=self._main_menu_keyboard(user_id),
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1272
|
+
async def _create_project(self, update: Update, user_id: int, name: str) -> None:
|
|
1273
|
+
"""Create a new project."""
|
|
1274
|
+
name = name.lower().replace(" ", "-")
|
|
1275
|
+
project_path = self._get_project_path(user_id, name)
|
|
1276
|
+
|
|
1277
|
+
if project_path.exists():
|
|
1278
|
+
await update.message.reply_text(
|
|
1279
|
+
f"โ Project `{name}` already exists!",
|
|
1280
|
+
parse_mode="Markdown",
|
|
1281
|
+
reply_markup=self._main_menu_keyboard(user_id),
|
|
1282
|
+
)
|
|
1283
|
+
return
|
|
1284
|
+
|
|
1285
|
+
project_path.mkdir(parents=True)
|
|
1286
|
+
(project_path / ".nullabot").mkdir()
|
|
1287
|
+
config = {"name": name, "created_at": datetime.now().isoformat(), "user_id": user_id}
|
|
1288
|
+
(project_path / ".nullabot" / "config.json").write_text(json.dumps(config, indent=2))
|
|
1289
|
+
|
|
1290
|
+
await update.message.reply_text(
|
|
1291
|
+
f"โ
*Project Created!*\n\n"
|
|
1292
|
+
f"๐ `{name}`\n\n"
|
|
1293
|
+
f"Now start an agent:",
|
|
1294
|
+
parse_mode="Markdown",
|
|
1295
|
+
reply_markup=self._agent_keyboard(name),
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
async def _start_agent_with_task(
|
|
1299
|
+
self,
|
|
1300
|
+
update: Update,
|
|
1301
|
+
user_id: int,
|
|
1302
|
+
project_name: str,
|
|
1303
|
+
agent_type: str,
|
|
1304
|
+
task: str,
|
|
1305
|
+
) -> None:
|
|
1306
|
+
"""Start agent with the given task."""
|
|
1307
|
+
chat_id = update.effective_chat.id
|
|
1308
|
+
project_path = self._get_project_path(user_id, project_name)
|
|
1309
|
+
agent_key = self._get_agent_key(user_id, project_name)
|
|
1310
|
+
|
|
1311
|
+
if agent_key in self._active_agents:
|
|
1312
|
+
await update.message.reply_text(
|
|
1313
|
+
f"โ ๏ธ Agent already running on `{project_name}`!",
|
|
1314
|
+
parse_mode="Markdown",
|
|
1315
|
+
)
|
|
1316
|
+
return
|
|
1317
|
+
|
|
1318
|
+
self._agent_chat_ids[agent_key] = chat_id
|
|
1319
|
+
|
|
1320
|
+
from nullabot.agents.claude_agent import ClaudeAgent
|
|
1321
|
+
|
|
1322
|
+
async def on_notify(event: str, message: str, data: dict = None):
|
|
1323
|
+
buttons = None
|
|
1324
|
+
if event == "complete":
|
|
1325
|
+
buttons = [[
|
|
1326
|
+
InlineKeyboardButton("๐ View Files", callback_data=f"files:{project_name}"),
|
|
1327
|
+
InlineKeyboardButton("๐ Menu", callback_data="menu:main"),
|
|
1328
|
+
]]
|
|
1329
|
+
elif event == "error":
|
|
1330
|
+
buttons = [[
|
|
1331
|
+
InlineKeyboardButton("๐ Retry", callback_data=f"start:{agent_type}:{project_name}"),
|
|
1332
|
+
InlineKeyboardButton("๐ Menu", callback_data="menu:main"),
|
|
1333
|
+
]]
|
|
1334
|
+
await self._send_notification(chat_id, message, buttons)
|
|
1335
|
+
|
|
1336
|
+
async def run_agent():
|
|
1337
|
+
try:
|
|
1338
|
+
agent = ClaudeAgent(
|
|
1339
|
+
workspace=project_path,
|
|
1340
|
+
agent_type=agent_type,
|
|
1341
|
+
model="opus",
|
|
1342
|
+
on_notify=on_notify,
|
|
1343
|
+
user_id=user_id,
|
|
1344
|
+
base_dir=self.base_projects_dir.parent,
|
|
1345
|
+
)
|
|
1346
|
+
await agent.start(task, continuous=True)
|
|
1347
|
+
except Exception as e:
|
|
1348
|
+
await self._send_notification(
|
|
1349
|
+
chat_id,
|
|
1350
|
+
f"โ *Error:* `{str(e)[:200]}`",
|
|
1351
|
+
[[InlineKeyboardButton("๐ Menu", callback_data="menu:main")]],
|
|
1352
|
+
)
|
|
1353
|
+
finally:
|
|
1354
|
+
self._active_agents.pop(agent_key, None)
|
|
1355
|
+
self._agent_chat_ids.pop(agent_key, None)
|
|
1356
|
+
|
|
1357
|
+
agent_task = asyncio.create_task(run_agent())
|
|
1358
|
+
self._active_agents[agent_key] = agent_task
|
|
1359
|
+
|
|
1360
|
+
# Get memory info
|
|
1361
|
+
memory = self._get_project_memory(project_path)
|
|
1362
|
+
agents_done = memory.get("agents", [])
|
|
1363
|
+
context_note = ""
|
|
1364
|
+
if agents_done:
|
|
1365
|
+
context_note = f"\nโจ Using context from: {', '.join(agents_done)}"
|
|
1366
|
+
|
|
1367
|
+
emoji = {"thinker": "๐ง ", "designer": "๐จ", "coder": "๐ป"}.get(agent_type, "๐ค")
|
|
1368
|
+
await update.message.reply_text(
|
|
1369
|
+
f"๐ *Agent Started!*\n\n"
|
|
1370
|
+
f"{emoji} *Type:* {agent_type.upper()}\n"
|
|
1371
|
+
f"๐ *Project:* `{project_name}`\n"
|
|
1372
|
+
f"๐ค *Model:* Opus\n"
|
|
1373
|
+
f"โฑ *Timeout:* 30 min{context_note}\n\n"
|
|
1374
|
+
f"๐ *Task:*\n_{task[:200]}_\n\n"
|
|
1375
|
+
f"๐ข You'll receive updates for every cycle!",
|
|
1376
|
+
parse_mode="Markdown",
|
|
1377
|
+
reply_markup=InlineKeyboardMarkup([
|
|
1378
|
+
[InlineKeyboardButton("๐ Stop", callback_data=f"stop:{project_name}")],
|
|
1379
|
+
[InlineKeyboardButton("๐ Status", callback_data="menu:status")],
|
|
1380
|
+
]),
|
|
1381
|
+
)
|
|
1382
|
+
|
|
1383
|
+
async def _resume_agent(
|
|
1384
|
+
self,
|
|
1385
|
+
user_id: int,
|
|
1386
|
+
project_name: str,
|
|
1387
|
+
agent_type: str,
|
|
1388
|
+
task: str,
|
|
1389
|
+
chat_id: int,
|
|
1390
|
+
) -> None:
|
|
1391
|
+
"""Resume an agent."""
|
|
1392
|
+
agent_key = self._get_agent_key(user_id, project_name)
|
|
1393
|
+
if agent_key in self._active_agents:
|
|
1394
|
+
return
|
|
1395
|
+
|
|
1396
|
+
project_path = self._get_project_path(user_id, project_name)
|
|
1397
|
+
self._agent_chat_ids[agent_key] = chat_id
|
|
1398
|
+
|
|
1399
|
+
from nullabot.agents.claude_agent import ClaudeAgent
|
|
1400
|
+
|
|
1401
|
+
async def on_notify(event: str, message: str, data: dict = None):
|
|
1402
|
+
buttons = None
|
|
1403
|
+
if event == "complete":
|
|
1404
|
+
buttons = [[
|
|
1405
|
+
InlineKeyboardButton("๐ View Files", callback_data=f"files:{project_name}"),
|
|
1406
|
+
InlineKeyboardButton("๐ Menu", callback_data="menu:main"),
|
|
1407
|
+
]]
|
|
1408
|
+
elif event == "error":
|
|
1409
|
+
buttons = [[
|
|
1410
|
+
InlineKeyboardButton("๐ Retry", callback_data=f"start:{agent_type}:{project_name}"),
|
|
1411
|
+
InlineKeyboardButton("๐ Menu", callback_data="menu:main"),
|
|
1412
|
+
]]
|
|
1413
|
+
await self._send_notification(chat_id, message, buttons)
|
|
1414
|
+
|
|
1415
|
+
async def run_agent():
|
|
1416
|
+
try:
|
|
1417
|
+
agent = ClaudeAgent(
|
|
1418
|
+
workspace=project_path,
|
|
1419
|
+
agent_type=agent_type,
|
|
1420
|
+
model="opus",
|
|
1421
|
+
on_notify=on_notify,
|
|
1422
|
+
user_id=user_id,
|
|
1423
|
+
base_dir=self.base_projects_dir.parent,
|
|
1424
|
+
)
|
|
1425
|
+
await agent.start(task, continuous=True)
|
|
1426
|
+
except Exception as e:
|
|
1427
|
+
await self._send_notification(
|
|
1428
|
+
chat_id,
|
|
1429
|
+
f"โ *Error:* `{str(e)[:200]}`",
|
|
1430
|
+
[[InlineKeyboardButton("๐ Menu", callback_data="menu:main")]],
|
|
1431
|
+
)
|
|
1432
|
+
finally:
|
|
1433
|
+
self._active_agents.pop(agent_key, None)
|
|
1434
|
+
self._agent_chat_ids.pop(agent_key, None)
|
|
1435
|
+
|
|
1436
|
+
agent_task = asyncio.create_task(run_agent())
|
|
1437
|
+
self._active_agents[agent_key] = agent_task
|
|
1438
|
+
|
|
1439
|
+
def _find_resumable_projects(self) -> list[tuple[str, dict]]:
|
|
1440
|
+
"""Find projects that were running and can be resumed."""
|
|
1441
|
+
resumable = []
|
|
1442
|
+
for p in self.base_projects_dir.rglob(".nullabot/state.json"):
|
|
1443
|
+
try:
|
|
1444
|
+
state = json.loads(p.read_text())
|
|
1445
|
+
if state.get("status") == "running" and state.get("task"):
|
|
1446
|
+
project_path = p.parent.parent
|
|
1447
|
+
resumable.append((project_path.name, state))
|
|
1448
|
+
except:
|
|
1449
|
+
pass
|
|
1450
|
+
return resumable
|
|
1451
|
+
|
|
1452
|
+
async def _auto_resume_agents(self, app: Application) -> None:
|
|
1453
|
+
"""Auto-resume agents that were running before restart."""
|
|
1454
|
+
resumable = self._find_resumable_projects()
|
|
1455
|
+
|
|
1456
|
+
if not resumable:
|
|
1457
|
+
print("๐ No agents to resume")
|
|
1458
|
+
return
|
|
1459
|
+
|
|
1460
|
+
print(f"๐ Found {len(resumable)} agent(s) to resume...")
|
|
1461
|
+
|
|
1462
|
+
for project_name, state in resumable:
|
|
1463
|
+
task = state.get("task", "")
|
|
1464
|
+
agent_type = state.get("agent_type", "thinker")
|
|
1465
|
+
|
|
1466
|
+
chat_id = None
|
|
1467
|
+
if self.admins:
|
|
1468
|
+
chat_id = list(self.admins)[0]
|
|
1469
|
+
elif self.allowed_users:
|
|
1470
|
+
chat_id = list(self.allowed_users)[0]
|
|
1471
|
+
|
|
1472
|
+
if not chat_id:
|
|
1473
|
+
print(f"โ ๏ธ Cannot resume {project_name}: no chat_id available")
|
|
1474
|
+
continue
|
|
1475
|
+
|
|
1476
|
+
print(f"โถ๏ธ Resuming {agent_type} on {project_name}...")
|
|
1477
|
+
|
|
1478
|
+
# Use admin ID for resumed agents
|
|
1479
|
+
await self._resume_agent(list(self.admins)[0] if self.admins else chat_id, project_name, agent_type, task, chat_id)
|
|
1480
|
+
|
|
1481
|
+
await self._send_notification(
|
|
1482
|
+
chat_id,
|
|
1483
|
+
f"๐ *Auto-resumed* `{project_name}`\n\n"
|
|
1484
|
+
f"๐ Task: _{task[:100]}_\n"
|
|
1485
|
+
f"๐ Cycles so far: {state.get('cycles', 0)}",
|
|
1486
|
+
[[InlineKeyboardButton("๐ Stop", callback_data=f"stop:{project_name}")]],
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1489
|
+
async def cmd_files(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
1490
|
+
"""Handle /files command."""
|
|
1491
|
+
user_id = update.effective_user.id
|
|
1492
|
+
if not self._check_user(user_id, update.effective_user):
|
|
1493
|
+
return
|
|
1494
|
+
|
|
1495
|
+
if not context.args:
|
|
1496
|
+
await update.message.reply_text(
|
|
1497
|
+
"๐ *View Files*\n\nSelect a project:",
|
|
1498
|
+
parse_mode="Markdown",
|
|
1499
|
+
reply_markup=self._projects_keyboard(user_id, "files"),
|
|
1500
|
+
)
|
|
1501
|
+
return
|
|
1502
|
+
|
|
1503
|
+
project_name = context.args[0]
|
|
1504
|
+
await self._show_files(update.message, user_id, project_name)
|
|
1505
|
+
|
|
1506
|
+
async def cmd_rules(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
1507
|
+
"""Handle /rules command - view and manage global rules."""
|
|
1508
|
+
from nullabot.core.memory import UserMemory
|
|
1509
|
+
|
|
1510
|
+
user_id = update.effective_user.id
|
|
1511
|
+
if not self._check_user(user_id, update.effective_user):
|
|
1512
|
+
return
|
|
1513
|
+
|
|
1514
|
+
# Load user memory
|
|
1515
|
+
user_memory = UserMemory(self.base_projects_dir.parent, user_id)
|
|
1516
|
+
|
|
1517
|
+
# Check for subcommands: /rules add do <rule> or /rules add dont <rule>
|
|
1518
|
+
if context.args:
|
|
1519
|
+
action = context.args[0].lower()
|
|
1520
|
+
|
|
1521
|
+
if action == "add" and len(context.args) >= 3:
|
|
1522
|
+
rule_type = context.args[1].lower()
|
|
1523
|
+
rule_text = " ".join(context.args[2:])
|
|
1524
|
+
|
|
1525
|
+
if rule_type in ["do", "dont", "don't"]:
|
|
1526
|
+
rule_type = "do" if rule_type == "do" else "dont"
|
|
1527
|
+
user_memory.add_rule(rule_text, rule_type)
|
|
1528
|
+
emoji = "โ
" if rule_type == "do" else "โ"
|
|
1529
|
+
await update.message.reply_text(
|
|
1530
|
+
f"{emoji} Rule added: {rule_text}",
|
|
1531
|
+
parse_mode="Markdown",
|
|
1532
|
+
)
|
|
1533
|
+
return
|
|
1534
|
+
else:
|
|
1535
|
+
await update.message.reply_text(
|
|
1536
|
+
"Usage: `/rules add do <rule>` or `/rules add dont <rule>`",
|
|
1537
|
+
parse_mode="Markdown",
|
|
1538
|
+
)
|
|
1539
|
+
return
|
|
1540
|
+
|
|
1541
|
+
elif action == "clear":
|
|
1542
|
+
user_memory._memory["rules"] = []
|
|
1543
|
+
user_memory._save()
|
|
1544
|
+
await update.message.reply_text("๐๏ธ All rules cleared.")
|
|
1545
|
+
return
|
|
1546
|
+
|
|
1547
|
+
# Show current rules
|
|
1548
|
+
rules = user_memory.get_rules()
|
|
1549
|
+
|
|
1550
|
+
if not rules:
|
|
1551
|
+
await update.message.reply_text(
|
|
1552
|
+
"๐ *Global Rules*\n\n"
|
|
1553
|
+
"No rules yet.\n\n"
|
|
1554
|
+
"*Add rules:*\n"
|
|
1555
|
+
"`/rules add do Always use TypeScript`\n"
|
|
1556
|
+
"`/rules add dont Never use console.log`",
|
|
1557
|
+
parse_mode="Markdown",
|
|
1558
|
+
)
|
|
1559
|
+
return
|
|
1560
|
+
|
|
1561
|
+
lines = ["๐ *Global Rules*\n"]
|
|
1562
|
+
|
|
1563
|
+
do_rules = [r for r in rules if r.get("type") == "do"]
|
|
1564
|
+
dont_rules = [r for r in rules if r.get("type") == "dont"]
|
|
1565
|
+
|
|
1566
|
+
if do_rules:
|
|
1567
|
+
lines.append("โ
*ALWAYS DO:*")
|
|
1568
|
+
for r in do_rules:
|
|
1569
|
+
lines.append(f" โข {r['rule']}")
|
|
1570
|
+
|
|
1571
|
+
if dont_rules:
|
|
1572
|
+
if do_rules:
|
|
1573
|
+
lines.append("")
|
|
1574
|
+
lines.append("โ *NEVER DO:*")
|
|
1575
|
+
for r in dont_rules:
|
|
1576
|
+
lines.append(f" โข {r['rule']}")
|
|
1577
|
+
|
|
1578
|
+
lines.append("\n*Commands:*")
|
|
1579
|
+
lines.append("`/rules add do <rule>`")
|
|
1580
|
+
lines.append("`/rules add dont <rule>`")
|
|
1581
|
+
lines.append("`/rules clear`")
|
|
1582
|
+
|
|
1583
|
+
await update.message.reply_text(
|
|
1584
|
+
"\n".join(lines),
|
|
1585
|
+
parse_mode="Markdown",
|
|
1586
|
+
)
|
|
1587
|
+
|
|
1588
|
+
async def cmd_usage(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
1589
|
+
"""Handle /usage command - show nullabot usage and costs."""
|
|
1590
|
+
from nullabot.core.memory import get_global_window_tracker, UsageTracker
|
|
1591
|
+
|
|
1592
|
+
user_id = update.effective_user.id
|
|
1593
|
+
if not self._check_user(user_id, update.effective_user):
|
|
1594
|
+
return
|
|
1595
|
+
|
|
1596
|
+
base_dir = self.base_projects_dir.parent
|
|
1597
|
+
|
|
1598
|
+
lines = [
|
|
1599
|
+
"๐ *Nullabot Usage*\n",
|
|
1600
|
+
]
|
|
1601
|
+
|
|
1602
|
+
# Per-project costs
|
|
1603
|
+
total_cost = 0
|
|
1604
|
+
total_cycles = 0
|
|
1605
|
+
total_hours = 0
|
|
1606
|
+
|
|
1607
|
+
for item in self.base_projects_dir.iterdir():
|
|
1608
|
+
if item.is_dir() and (item / ".nullabot").exists():
|
|
1609
|
+
try:
|
|
1610
|
+
tracker = UsageTracker(item, base_dir)
|
|
1611
|
+
summary = tracker.get_summary()
|
|
1612
|
+
if summary["total_cycles"] > 0:
|
|
1613
|
+
lines.append(f"๐ *{item.name}*")
|
|
1614
|
+
lines.append(f" {summary['total_cycles']} cycles ยท {summary['total_hours']:.1f}hrs ยท ${summary['total_cost_usd']:.2f}")
|
|
1615
|
+
total_cost += summary["total_cost_usd"]
|
|
1616
|
+
total_cycles += summary["total_cycles"]
|
|
1617
|
+
total_hours += summary["total_hours"]
|
|
1618
|
+
except:
|
|
1619
|
+
pass
|
|
1620
|
+
|
|
1621
|
+
if total_cycles > 0:
|
|
1622
|
+
lines.append(f"\n๐ฐ *Total:* {total_cycles} cycles ยท {total_hours:.1f}hrs ยท ${total_cost:.2f}")
|
|
1623
|
+
else:
|
|
1624
|
+
lines.append("_No usage yet_")
|
|
1625
|
+
|
|
1626
|
+
lines.append("\nโโโโโโโโโโโโโโโโโ")
|
|
1627
|
+
lines.append("โน๏ธ _For real Claude Code limits:_")
|
|
1628
|
+
lines.append("_Run `claude` โ `/usage` in terminal_")
|
|
1629
|
+
|
|
1630
|
+
await update.message.reply_text(
|
|
1631
|
+
"\n".join(lines),
|
|
1632
|
+
parse_mode="Markdown",
|
|
1633
|
+
reply_markup=InlineKeyboardMarkup([
|
|
1634
|
+
[InlineKeyboardButton("๐ Refresh", callback_data="usage:refresh")],
|
|
1635
|
+
[InlineKeyboardButton("โฌ
๏ธ Menu", callback_data="menu:main")],
|
|
1636
|
+
]),
|
|
1637
|
+
)
|
|
1638
|
+
|
|
1639
|
+
async def _show_files(self, message, user_id: int, project_name: str) -> None:
|
|
1640
|
+
"""Show files in a project."""
|
|
1641
|
+
project_path = self._get_project_path(user_id, project_name)
|
|
1642
|
+
|
|
1643
|
+
if not project_path.exists():
|
|
1644
|
+
await message.reply_text(f"โ Project `{project_name}` not found.", parse_mode="Markdown")
|
|
1645
|
+
return
|
|
1646
|
+
|
|
1647
|
+
files = []
|
|
1648
|
+
for f in project_path.rglob("*"):
|
|
1649
|
+
if f.is_file() and ".nullabot" not in str(f):
|
|
1650
|
+
rel = str(f.relative_to(project_path))
|
|
1651
|
+
size = f.stat().st_size
|
|
1652
|
+
files.append((rel, size))
|
|
1653
|
+
|
|
1654
|
+
if not files:
|
|
1655
|
+
await message.reply_text(
|
|
1656
|
+
f"๐ `{project_name}` has no files yet.",
|
|
1657
|
+
parse_mode="Markdown",
|
|
1658
|
+
reply_markup=InlineKeyboardMarkup([
|
|
1659
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
1660
|
+
]),
|
|
1661
|
+
)
|
|
1662
|
+
return
|
|
1663
|
+
|
|
1664
|
+
lines = [f"๐ *{project_name}* ({len(files)} files)\n"]
|
|
1665
|
+
for fname, size in sorted(files)[:20]:
|
|
1666
|
+
size_str = f"{size}B" if size < 1024 else f"{size // 1024}KB"
|
|
1667
|
+
lines.append(f"โข `{fname}` _{size_str}_")
|
|
1668
|
+
|
|
1669
|
+
if len(files) > 20:
|
|
1670
|
+
lines.append(f"\n_+{len(files) - 20} more files_")
|
|
1671
|
+
|
|
1672
|
+
await message.reply_text(
|
|
1673
|
+
"\n".join(lines),
|
|
1674
|
+
parse_mode="Markdown",
|
|
1675
|
+
reply_markup=InlineKeyboardMarkup([
|
|
1676
|
+
[InlineKeyboardButton("โฌ
๏ธ Back", callback_data="menu:main")],
|
|
1677
|
+
]),
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
def run(self) -> None:
|
|
1681
|
+
"""Run the bot."""
|
|
1682
|
+
self._app = Application.builder().token(self.token).build()
|
|
1683
|
+
|
|
1684
|
+
# Command handlers
|
|
1685
|
+
self._app.add_handler(CommandHandler("start", self.cmd_start))
|
|
1686
|
+
self._app.add_handler(CommandHandler("menu", self.cmd_menu))
|
|
1687
|
+
self._app.add_handler(CommandHandler("help", self.cmd_help))
|
|
1688
|
+
self._app.add_handler(CommandHandler("files", self.cmd_files))
|
|
1689
|
+
self._app.add_handler(CommandHandler("chat", self.cmd_chat))
|
|
1690
|
+
self._app.add_handler(CommandHandler("rules", self.cmd_rules))
|
|
1691
|
+
self._app.add_handler(CommandHandler("usage", self.cmd_usage))
|
|
1692
|
+
# Admin commands
|
|
1693
|
+
self._app.add_handler(CommandHandler("approve", self.cmd_approve))
|
|
1694
|
+
self._app.add_handler(CommandHandler("deny", self.cmd_deny))
|
|
1695
|
+
self._app.add_handler(CommandHandler("users", self.cmd_users))
|
|
1696
|
+
self._app.add_handler(CommandHandler("revoke", self.cmd_revoke))
|
|
1697
|
+
|
|
1698
|
+
# Callback handler for buttons
|
|
1699
|
+
self._app.add_handler(CallbackQueryHandler(self.callback_handler))
|
|
1700
|
+
|
|
1701
|
+
# Message handler
|
|
1702
|
+
self._app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_message))
|
|
1703
|
+
|
|
1704
|
+
print("๐ค Nullabot Telegram Bot started!")
|
|
1705
|
+
print("๐ข Notifications enabled for all events")
|
|
1706
|
+
print("๐ฏ Using Claude Code CLI with Opus model")
|
|
1707
|
+
print("๐ฐ Cost tracking enabled")
|
|
1708
|
+
print("๐ง Memory system enabled")
|
|
1709
|
+
|
|
1710
|
+
# Set up bot commands menu and auto-resume
|
|
1711
|
+
async def post_init(app):
|
|
1712
|
+
# Register commands in Telegram menu
|
|
1713
|
+
await app.bot.set_my_commands([
|
|
1714
|
+
BotCommand("start", "Start"),
|
|
1715
|
+
BotCommand("menu", "Main menu"),
|
|
1716
|
+
BotCommand("chat", "Chat with Claude"),
|
|
1717
|
+
BotCommand("usage", "Project costs"),
|
|
1718
|
+
BotCommand("rules", "Global rules"),
|
|
1719
|
+
BotCommand("users", "Manage users"),
|
|
1720
|
+
BotCommand("help", "Help"),
|
|
1721
|
+
])
|
|
1722
|
+
# Fetch names for configured users (if they've interacted before)
|
|
1723
|
+
await self._fetch_user_names(app)
|
|
1724
|
+
# Auto-resume agents
|
|
1725
|
+
await self._auto_resume_agents(app)
|
|
1726
|
+
|
|
1727
|
+
self._app.post_init = post_init
|
|
1728
|
+
|
|
1729
|
+
self._app.run_polling()
|