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.
@@ -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()