mrstack 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. mrstack/__init__.py +4 -0
  2. mrstack/_data/config/com.mrstack.claude-telegram.plist +25 -0
  3. mrstack/_data/config/mcp-config.example.json +23 -0
  4. mrstack/_data/config/start-daemon.sh +53 -0
  5. mrstack/_data/config/start.sh +29 -0
  6. mrstack/_data/schedulers/manage-jobs.sh +87 -0
  7. mrstack/_data/schedulers/morning-briefing.sh +29 -0
  8. mrstack/_data/schedulers/register-jobs.py +182 -0
  9. mrstack/_data/schedulers/run-threads-briefing.sh +36 -0
  10. mrstack/_data/schedulers/weekly-review.sh +26 -0
  11. mrstack/_data/templates/DESIGN-GUIDE.md +160 -0
  12. mrstack/_data/templates/alert.md +56 -0
  13. mrstack/_data/templates/evening-summary.md +73 -0
  14. mrstack/_data/templates/jarvis-alert.md +64 -0
  15. mrstack/_data/templates/morning-briefing.md +53 -0
  16. mrstack/_data/templates/weekly-review.md +79 -0
  17. mrstack/_overlay/api/dashboard.py +223 -0
  18. mrstack/_overlay/api/templates/dashboard.html +328 -0
  19. mrstack/_overlay/bot/handlers/callback.py +1432 -0
  20. mrstack/_overlay/bot/handlers/command.py +1541 -0
  21. mrstack/_overlay/bot/utils/keyboards.py +125 -0
  22. mrstack/_overlay/bot/utils/ui_components.py +166 -0
  23. mrstack/_overlay/claude/session.py +341 -0
  24. mrstack/_overlay/jarvis/__init__.py +77 -0
  25. mrstack/_overlay/jarvis/coach.py +122 -0
  26. mrstack/_overlay/jarvis/context_engine.py +463 -0
  27. mrstack/_overlay/jarvis/pattern_learner.py +255 -0
  28. mrstack/_overlay/jarvis/persona.py +84 -0
  29. mrstack/_overlay/jarvis/platform.py +182 -0
  30. mrstack/_overlay/knowledge/__init__.py +6 -0
  31. mrstack/_overlay/knowledge/manager.py +464 -0
  32. mrstack/_overlay/knowledge/memory_index.py +180 -0
  33. mrstack/cli.py +330 -0
  34. mrstack/constants.py +77 -0
  35. mrstack/daemon.py +325 -0
  36. mrstack/patcher.py +169 -0
  37. mrstack/wizard.py +271 -0
  38. mrstack-1.1.0.dist-info/METADATA +640 -0
  39. mrstack-1.1.0.dist-info/RECORD +42 -0
  40. mrstack-1.1.0.dist-info/WHEEL +4 -0
  41. mrstack-1.1.0.dist-info/entry_points.txt +2 -0
  42. mrstack-1.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1541 @@
1
+ """Command handlers for bot operations."""
2
+
3
+ import subprocess
4
+ from datetime import datetime, timedelta, timezone
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ import httpx
9
+ import structlog
10
+ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
11
+ from telegram.ext import ContextTypes
12
+
13
+ from ...claude.facade import ClaudeIntegration
14
+ from ...config.settings import Settings
15
+ from ...projects import PrivateTopicsUnavailableError, load_project_registry
16
+ from ...security.audit import AuditLogger
17
+ from ...security.validators import SecurityValidator
18
+ from ..utils.html_format import escape_html
19
+
20
+ logger = structlog.get_logger()
21
+
22
+
23
+ def _is_within_root(path: Path, root: Path) -> bool:
24
+ """Check whether path is within root directory."""
25
+ try:
26
+ path.resolve().relative_to(root.resolve())
27
+ return True
28
+ except ValueError:
29
+ return False
30
+
31
+
32
+ def _get_thread_project_root(
33
+ settings: Settings, context: ContextTypes.DEFAULT_TYPE
34
+ ) -> Optional[Path]:
35
+ """Get thread project root when strict thread mode is active."""
36
+ if not settings.enable_project_threads:
37
+ return None
38
+ thread_context = context.user_data.get("_thread_context")
39
+ if not thread_context:
40
+ return None
41
+ return Path(thread_context["project_root"]).resolve()
42
+
43
+
44
+ def _is_private_chat(update: Update) -> bool:
45
+ """Return True when update is from a private chat."""
46
+ chat = update.effective_chat
47
+ return bool(chat and getattr(chat, "type", "") == "private")
48
+
49
+
50
+ def _read_oauth_token() -> Optional[str]:
51
+ """Read Claude OAuth token from macOS Keychain via Swift subprocess."""
52
+ try:
53
+ swift_code = """
54
+ import Foundation
55
+ import Security
56
+ let q: [String: Any] = [
57
+ kSecClass as String: kSecClassGenericPassword,
58
+ kSecAttrService as String: "Claude Code-credentials",
59
+ kSecReturnData as String: true,
60
+ kSecMatchLimit as String: kSecMatchLimitOne
61
+ ]
62
+ var r: AnyObject?
63
+ guard SecItemCopyMatching(q as CFDictionary, &r) == errSecSuccess,
64
+ let d = r as? Data, let s = String(data: d, encoding: .utf8) else { exit(1) }
65
+ if let range = s.range(of: "sk-ant-oat01-[A-Za-z0-9_-]+", options: .regularExpression) {
66
+ print(s[range])
67
+ }
68
+ """
69
+ result = subprocess.run(
70
+ ["swift", "-"],
71
+ input=swift_code,
72
+ capture_output=True,
73
+ text=True,
74
+ timeout=15,
75
+ )
76
+ token = result.stdout.strip()
77
+ if token and token.startswith("sk-ant-oat01-"):
78
+ return token
79
+ return None
80
+ except Exception:
81
+ return None
82
+
83
+
84
+ async def _get_claude_usage() -> Optional[dict[str, Any]]:
85
+ """Fetch Claude Code plan usage from Anthropic OAuth API."""
86
+ try:
87
+ token = _read_oauth_token()
88
+ if not token:
89
+ logger.warning("Failed to read OAuth token from Keychain")
90
+ return None
91
+
92
+ async with httpx.AsyncClient(timeout=10) as client:
93
+ resp = await client.get(
94
+ "https://api.anthropic.com/api/oauth/usage",
95
+ headers={
96
+ "Authorization": f"Bearer {token}",
97
+ "anthropic-beta": "oauth-2025-04-20",
98
+ },
99
+ )
100
+ resp.raise_for_status()
101
+ return resp.json()
102
+ except Exception as e:
103
+ logger.warning("Failed to fetch Claude usage", error=str(e))
104
+ return None
105
+
106
+
107
+ def _format_usage_bar(utilization_pct: float, width: int = 10) -> str:
108
+ """Format a utilization percentage (0-100) as a progress bar."""
109
+ filled = round(utilization_pct / 100 * width)
110
+ filled = max(0, min(width, filled))
111
+ return "β–ˆ" * filled + "β–‘" * (width - filled)
112
+
113
+
114
+ def _format_reset_time(resets_at: str) -> str:
115
+ """Format ISO reset time as human-readable remaining duration."""
116
+ try:
117
+ reset_dt = datetime.fromisoformat(resets_at.replace("Z", "+00:00"))
118
+ now = datetime.now(timezone.utc)
119
+ delta = reset_dt - now
120
+ if delta.total_seconds() <= 0:
121
+ return "κ³§ 리셋"
122
+ hours = int(delta.total_seconds() // 3600)
123
+ minutes = int((delta.total_seconds() % 3600) // 60)
124
+ if hours > 0:
125
+ return f"{hours}h {minutes}m"
126
+ return f"{minutes}m"
127
+ except Exception:
128
+ return resets_at
129
+
130
+
131
+ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
132
+ """Handle /start command."""
133
+ user = update.effective_user
134
+ settings: Settings = context.bot_data["settings"]
135
+ audit_logger: AuditLogger = context.bot_data.get("audit_logger")
136
+ manager = context.bot_data.get("project_threads_manager")
137
+ sync_section = ""
138
+
139
+ if settings.enable_project_threads and settings.project_threads_mode == "private":
140
+ if not _is_private_chat(update):
141
+ await update.message.reply_text(
142
+ "🚫 <b>Private Topics Mode</b>\n\n"
143
+ "Use this bot in a private chat and run <code>/start</code> there.",
144
+ parse_mode="HTML",
145
+ )
146
+ return
147
+
148
+ if (
149
+ settings.enable_project_threads
150
+ and settings.project_threads_mode == "private"
151
+ and _is_private_chat(update)
152
+ ):
153
+ if manager is None:
154
+ await update.message.reply_text(
155
+ "❌ <b>Project thread mode is misconfigured</b>\n\n"
156
+ "Thread manager is not initialized.",
157
+ parse_mode="HTML",
158
+ )
159
+ return
160
+
161
+ try:
162
+ sync_result = await manager.sync_topics(
163
+ context.bot,
164
+ chat_id=update.effective_chat.id,
165
+ )
166
+ sync_section = (
167
+ "\n\n🧡 <b>Project Topics Synced</b>\n"
168
+ f"β€’ Created: <b>{sync_result.created}</b>\n"
169
+ f"β€’ Reused: <b>{sync_result.reused}</b>\n"
170
+ f"β€’ Renamed: <b>{sync_result.renamed}</b>\n"
171
+ f"β€’ Failed: <b>{sync_result.failed}</b>\n\n"
172
+ "Use a project topic thread to start coding."
173
+ )
174
+ except PrivateTopicsUnavailableError:
175
+ await update.message.reply_text(
176
+ manager.private_topics_unavailable_message(),
177
+ parse_mode="HTML",
178
+ )
179
+ if audit_logger:
180
+ await audit_logger.log_command(
181
+ user_id=user.id,
182
+ command="start",
183
+ args=[],
184
+ success=False,
185
+ )
186
+ return
187
+ except Exception as e:
188
+ sync_section = (
189
+ "\n\n⚠️ <b>Topic Sync Warning</b>\n"
190
+ f"{escape_html(str(e))}\n\n"
191
+ "Run <code>/sync_threads</code> to retry."
192
+ )
193
+
194
+ welcome_message = (
195
+ f"πŸ‘‹ Welcome to Claude Code Telegram Bot, {escape_html(user.first_name)}!\n\n"
196
+ f"πŸ€– I help you access Claude Code remotely through Telegram.\n\n"
197
+ f"<b>Available Commands:</b>\n"
198
+ f"β€’ <code>/help</code> - Show detailed help\n"
199
+ f"β€’ <code>/new</code> - Start a new Claude session\n"
200
+ f"β€’ <code>/ls</code> - List files in current directory\n"
201
+ f"β€’ <code>/cd &lt;dir&gt;</code> - Change directory\n"
202
+ f"β€’ <code>/projects</code> - Show available projects\n"
203
+ f"β€’ <code>/status</code> - Show session status\n"
204
+ f"β€’ <code>/actions</code> - Show quick actions\n"
205
+ f"β€’ <code>/git</code> - Git repository commands\n\n"
206
+ f"<b>Quick Start:</b>\n"
207
+ f"1. Use <code>/projects</code> to see available projects\n"
208
+ f"2. Use <code>/cd &lt;project&gt;</code> to navigate to a project\n"
209
+ f"3. Send any message to start coding with Claude!\n\n"
210
+ f"πŸ”’ Your access is secured and all actions are logged.\n"
211
+ f"πŸ“Š Use <code>/status</code> to check your usage limits."
212
+ f"{sync_section}"
213
+ )
214
+
215
+ # Add quick action buttons
216
+ keyboard = [
217
+ [
218
+ InlineKeyboardButton(
219
+ "πŸ“ Show Projects", callback_data="action:show_projects"
220
+ ),
221
+ InlineKeyboardButton("❓ Get Help", callback_data="action:help"),
222
+ ],
223
+ [
224
+ InlineKeyboardButton("πŸ†• New Session", callback_data="action:new_session"),
225
+ InlineKeyboardButton("πŸ“Š Check Status", callback_data="action:status"),
226
+ ],
227
+ ]
228
+ reply_markup = InlineKeyboardMarkup(keyboard)
229
+
230
+ await update.message.reply_text(
231
+ welcome_message, parse_mode="HTML", reply_markup=reply_markup
232
+ )
233
+
234
+ # Log command
235
+ if audit_logger:
236
+ await audit_logger.log_command(
237
+ user_id=user.id, command="start", args=[], success=True
238
+ )
239
+
240
+
241
+ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
242
+ """Handle /help command."""
243
+ help_text = (
244
+ "πŸ€– <b>Claude Code Telegram Bot Help</b>\n\n"
245
+ "<b>Navigation Commands:</b>\n"
246
+ "β€’ <code>/ls</code> - List files and directories\n"
247
+ "β€’ <code>/cd &lt;directory&gt;</code> - Change to directory\n"
248
+ "β€’ <code>/pwd</code> - Show current directory\n"
249
+ "β€’ <code>/projects</code> - Show available projects\n\n"
250
+ "<b>Session Commands:</b>\n"
251
+ "β€’ <code>/new</code> - Clear context and start a fresh session\n"
252
+ "β€’ <code>/continue [message]</code> - Explicitly continue last session\n"
253
+ "β€’ <code>/end</code> - End current session and clear context\n"
254
+ "β€’ <code>/status</code> - Show session and usage status\n"
255
+ "β€’ <code>/export</code> - Export session history\n"
256
+ "β€’ <code>/actions</code> - Show context-aware quick actions\n"
257
+ "β€’ <code>/git</code> - Git repository information\n\n"
258
+ "<b>Session Behavior:</b>\n"
259
+ "β€’ Sessions are automatically maintained per project directory\n"
260
+ "β€’ Switching directories with <code>/cd</code> resumes the session for that project\n"
261
+ "β€’ Use <code>/new</code> or <code>/end</code> to explicitly clear session context\n"
262
+ "β€’ Sessions persist across bot restarts\n\n"
263
+ "<b>Usage Examples:</b>\n"
264
+ "β€’ <code>cd myproject</code> - Enter project directory\n"
265
+ "β€’ <code>ls</code> - See what's in current directory\n"
266
+ "β€’ <code>Create a simple Python script</code> - Ask Claude to code\n"
267
+ "β€’ Send a file to have Claude review it\n\n"
268
+ "<b>File Operations:</b>\n"
269
+ "β€’ Send text files (.py, .js, .md, etc.) for review\n"
270
+ "β€’ Claude can read, modify, and create files\n"
271
+ "β€’ All file operations are within your approved directory\n\n"
272
+ "<b>Security Features:</b>\n"
273
+ "β€’ πŸ”’ Path traversal protection\n"
274
+ "β€’ ⏱️ Rate limiting to prevent abuse\n"
275
+ "β€’ πŸ“Š Usage tracking and limits\n"
276
+ "β€’ πŸ›‘οΈ Input validation and sanitization\n\n"
277
+ "<b>Tips:</b>\n"
278
+ "β€’ Use specific, clear requests for best results\n"
279
+ "β€’ Check <code>/status</code> to monitor your usage\n"
280
+ "β€’ Use quick action buttons when available\n"
281
+ "β€’ File uploads are automatically processed by Claude\n\n"
282
+ "Need more help? Contact your administrator."
283
+ )
284
+
285
+ await update.message.reply_text(help_text, parse_mode="HTML")
286
+
287
+
288
+ async def sync_threads(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
289
+ """Synchronize project topics in the configured forum chat."""
290
+ settings: Settings = context.bot_data["settings"]
291
+ audit_logger: AuditLogger = context.bot_data.get("audit_logger")
292
+ user_id = update.effective_user.id
293
+
294
+ if not settings.enable_project_threads:
295
+ await update.message.reply_text(
296
+ "ℹ️ <b>Project thread mode is disabled.</b>", parse_mode="HTML"
297
+ )
298
+ return
299
+
300
+ manager = context.bot_data.get("project_threads_manager")
301
+ if not manager:
302
+ await update.message.reply_text(
303
+ "❌ <b>Project thread manager not initialized.</b>", parse_mode="HTML"
304
+ )
305
+ return
306
+
307
+ status_msg = await update.message.reply_text(
308
+ "πŸ”„ <b>Syncing project topics...</b>", parse_mode="HTML"
309
+ )
310
+
311
+ if settings.project_threads_mode == "private":
312
+ if not _is_private_chat(update):
313
+ await status_msg.edit_text(
314
+ "❌ <b>Private Thread Mode</b>\n\n"
315
+ "Run <code>/sync_threads</code> in your private chat with the bot.",
316
+ parse_mode="HTML",
317
+ )
318
+ return
319
+ target_chat_id = update.effective_chat.id
320
+ else:
321
+ if settings.project_threads_chat_id is None:
322
+ await status_msg.edit_text(
323
+ "❌ <b>Group Thread Mode Misconfigured</b>\n\n"
324
+ "Set <code>PROJECT_THREADS_CHAT_ID</code> first.",
325
+ parse_mode="HTML",
326
+ )
327
+ return
328
+ if (
329
+ not update.effective_chat
330
+ or update.effective_chat.id != settings.project_threads_chat_id
331
+ ):
332
+ await status_msg.edit_text(
333
+ "❌ <b>Group Thread Mode</b>\n\n"
334
+ "Run <code>/sync_threads</code> in the configured project threads group.",
335
+ parse_mode="HTML",
336
+ )
337
+ return
338
+ target_chat_id = settings.project_threads_chat_id
339
+
340
+ try:
341
+ if not settings.projects_config_path:
342
+ await status_msg.edit_text(
343
+ "❌ <b>Project thread mode is misconfigured</b>\n\n"
344
+ "Set <code>PROJECTS_CONFIG_PATH</code> to a valid YAML file.",
345
+ parse_mode="HTML",
346
+ )
347
+ if audit_logger:
348
+ await audit_logger.log_command(user_id, "sync_threads", [], False)
349
+ return
350
+
351
+ registry = load_project_registry(
352
+ config_path=settings.projects_config_path,
353
+ approved_directory=settings.approved_directory,
354
+ )
355
+ manager.registry = registry
356
+ context.bot_data["project_registry"] = registry
357
+
358
+ result = await manager.sync_topics(context.bot, chat_id=target_chat_id)
359
+ await status_msg.edit_text(
360
+ "βœ… <b>Project topic sync complete</b>\n\n"
361
+ f"β€’ Created: <b>{result.created}</b>\n"
362
+ f"β€’ Reused: <b>{result.reused}</b>\n"
363
+ f"β€’ Renamed: <b>{result.renamed}</b>\n"
364
+ f"β€’ Reopened: <b>{result.reopened}</b>\n"
365
+ f"β€’ Closed: <b>{result.closed}</b>\n"
366
+ f"β€’ Deactivated: <b>{result.deactivated}</b>\n"
367
+ f"β€’ Failed: <b>{result.failed}</b>",
368
+ parse_mode="HTML",
369
+ )
370
+ if audit_logger:
371
+ await audit_logger.log_command(user_id, "sync_threads", [], True)
372
+ except PrivateTopicsUnavailableError:
373
+ await status_msg.edit_text(
374
+ manager.private_topics_unavailable_message(),
375
+ parse_mode="HTML",
376
+ )
377
+ if audit_logger:
378
+ await audit_logger.log_command(user_id, "sync_threads", [], False)
379
+ except Exception as e:
380
+ await status_msg.edit_text(
381
+ f"❌ <b>Project topic sync failed</b>\n\n{escape_html(str(e))}",
382
+ parse_mode="HTML",
383
+ )
384
+ if audit_logger:
385
+ await audit_logger.log_command(user_id, "sync_threads", [], False)
386
+
387
+
388
+ async def new_session(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
389
+ """Handle /new command - explicitly starts a fresh session, clearing previous context."""
390
+ settings: Settings = context.bot_data["settings"]
391
+
392
+ # Get current directory (default to approved directory)
393
+ current_dir = context.user_data.get(
394
+ "current_directory", settings.approved_directory
395
+ )
396
+ relative_path = current_dir.relative_to(settings.approved_directory)
397
+
398
+ # Track what was cleared for user feedback
399
+ old_session_id = context.user_data.get("claude_session_id")
400
+
401
+ # Clear existing session data - this is the explicit way to reset context
402
+ context.user_data["claude_session_id"] = None
403
+ context.user_data["session_started"] = True
404
+ context.user_data["force_new_session"] = True
405
+
406
+ cleared_info = ""
407
+ if old_session_id:
408
+ cleared_info = (
409
+ f"\nπŸ—‘οΈ Previous session <code>{old_session_id[:8]}...</code> cleared."
410
+ )
411
+
412
+ keyboard = [
413
+ [
414
+ InlineKeyboardButton(
415
+ "πŸ“ Start Coding", callback_data="action:start_coding"
416
+ ),
417
+ InlineKeyboardButton(
418
+ "πŸ“ Change Project", callback_data="action:show_projects"
419
+ ),
420
+ ],
421
+ [
422
+ InlineKeyboardButton(
423
+ "πŸ“‹ Quick Actions", callback_data="action:quick_actions"
424
+ ),
425
+ InlineKeyboardButton("❓ Help", callback_data="action:help"),
426
+ ],
427
+ ]
428
+ reply_markup = InlineKeyboardMarkup(keyboard)
429
+
430
+ await update.message.reply_text(
431
+ f"πŸ†• <b>New Claude Code Session</b>\n\n"
432
+ f"πŸ“‚ Working directory: <code>{relative_path}/</code>{cleared_info}\n\n"
433
+ f"Context has been cleared. Send a message to start fresh, "
434
+ f"or use the buttons below:",
435
+ parse_mode="HTML",
436
+ reply_markup=reply_markup,
437
+ )
438
+
439
+
440
+ async def continue_session(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
441
+ """Handle /continue command with optional prompt."""
442
+ user_id = update.effective_user.id
443
+ settings: Settings = context.bot_data["settings"]
444
+ claude_integration: ClaudeIntegration = context.bot_data.get("claude_integration")
445
+ audit_logger: AuditLogger = context.bot_data.get("audit_logger")
446
+
447
+ # Parse optional prompt from command arguments
448
+ # If no prompt provided, use a default to continue the conversation
449
+ prompt = " ".join(context.args) if context.args else None
450
+ default_prompt = "Please continue where we left off"
451
+
452
+ current_dir = context.user_data.get(
453
+ "current_directory", settings.approved_directory
454
+ )
455
+
456
+ try:
457
+ if not claude_integration:
458
+ await update.message.reply_text(
459
+ "❌ <b>Claude Integration Not Available</b>\n\n"
460
+ "Claude integration is not properly configured."
461
+ )
462
+ return
463
+
464
+ # Check if there's an existing session in user context
465
+ claude_session_id = context.user_data.get("claude_session_id")
466
+
467
+ if claude_session_id:
468
+ # We have a session in context, continue it directly
469
+ status_msg = await update.message.reply_text(
470
+ f"πŸ”„ <b>Continuing Session</b>\n\n"
471
+ f"Session ID: <code>{claude_session_id[:8]}...</code>\n"
472
+ f"Directory: <code>{current_dir.relative_to(settings.approved_directory)}/</code>\n\n"
473
+ f"{'Processing your message...' if prompt else 'Continuing where you left off...'}",
474
+ parse_mode="HTML",
475
+ )
476
+
477
+ # Continue with the existing session
478
+ # Use default prompt if none provided (Claude CLI requires a prompt)
479
+ claude_response = await claude_integration.run_command(
480
+ prompt=prompt or default_prompt,
481
+ working_directory=current_dir,
482
+ user_id=user_id,
483
+ session_id=claude_session_id,
484
+ )
485
+ else:
486
+ # No session in context, try to find the most recent session
487
+ status_msg = await update.message.reply_text(
488
+ "πŸ” <b>Looking for Recent Session</b>\n\n"
489
+ "Searching for your most recent session in this directory...",
490
+ parse_mode="HTML",
491
+ )
492
+
493
+ # Use default prompt if none provided
494
+ claude_response = await claude_integration.continue_session(
495
+ user_id=user_id,
496
+ working_directory=current_dir,
497
+ prompt=prompt or default_prompt,
498
+ )
499
+
500
+ if claude_response:
501
+ # Update session ID in context
502
+ context.user_data["claude_session_id"] = claude_response.session_id
503
+
504
+ # Delete status message and send response
505
+ await status_msg.delete()
506
+
507
+ # Format and send Claude's response
508
+ from ..utils.formatting import ResponseFormatter
509
+
510
+ formatter = ResponseFormatter(settings)
511
+ formatted_messages = formatter.format_claude_response(
512
+ claude_response.content
513
+ )
514
+
515
+ for msg in formatted_messages:
516
+ await update.message.reply_text(
517
+ msg.text,
518
+ parse_mode=msg.parse_mode,
519
+ reply_markup=msg.reply_markup,
520
+ )
521
+
522
+ # Log successful continue
523
+ if audit_logger:
524
+ await audit_logger.log_command(
525
+ user_id=user_id,
526
+ command="continue",
527
+ args=context.args or [],
528
+ success=True,
529
+ )
530
+
531
+ else:
532
+ # No session found to continue
533
+ await status_msg.edit_text(
534
+ "❌ <b>No Session Found</b>\n\n"
535
+ f"No recent Claude session found in this directory.\n"
536
+ f"Directory: <code>{current_dir.relative_to(settings.approved_directory)}/</code>\n\n"
537
+ f"<b>What you can do:</b>\n"
538
+ f"β€’ Use <code>/new</code> to start a fresh session\n"
539
+ f"β€’ Use <code>/status</code> to check your sessions\n"
540
+ f"β€’ Navigate to a different directory with <code>/cd</code>",
541
+ parse_mode="HTML",
542
+ reply_markup=InlineKeyboardMarkup(
543
+ [
544
+ [
545
+ InlineKeyboardButton(
546
+ "πŸ†• New Session", callback_data="action:new_session"
547
+ ),
548
+ InlineKeyboardButton(
549
+ "πŸ“Š Status", callback_data="action:status"
550
+ ),
551
+ ]
552
+ ]
553
+ ),
554
+ )
555
+
556
+ except Exception as e:
557
+ error_msg = str(e)
558
+ logger.error("Error in continue command", error=error_msg, user_id=user_id)
559
+
560
+ # Delete status message if it exists
561
+ try:
562
+ if "status_msg" in locals():
563
+ await status_msg.delete()
564
+ except Exception:
565
+ pass
566
+
567
+ # Send error response
568
+ await update.message.reply_text(
569
+ f"❌ <b>Error Continuing Session</b>\n\n"
570
+ f"An error occurred while trying to continue your session:\n\n"
571
+ f"<code>{error_msg}</code>\n\n"
572
+ f"<b>Suggestions:</b>\n"
573
+ f"β€’ Try starting a new session with <code>/new</code>\n"
574
+ f"β€’ Check your session status with <code>/status</code>\n"
575
+ f"β€’ Contact support if the issue persists",
576
+ parse_mode="HTML",
577
+ )
578
+
579
+ # Log failed continue
580
+ if audit_logger:
581
+ await audit_logger.log_command(
582
+ user_id=user_id,
583
+ command="continue",
584
+ args=context.args or [],
585
+ success=False,
586
+ )
587
+
588
+
589
+ async def list_files(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
590
+ """Handle /ls command."""
591
+ user_id = update.effective_user.id
592
+ settings: Settings = context.bot_data["settings"]
593
+ audit_logger: AuditLogger = context.bot_data.get("audit_logger")
594
+
595
+ # Get current directory
596
+ current_dir = context.user_data.get(
597
+ "current_directory", settings.approved_directory
598
+ )
599
+
600
+ try:
601
+ # List directory contents
602
+ items = []
603
+ directories = []
604
+ files = []
605
+
606
+ for item in sorted(current_dir.iterdir()):
607
+ # Skip hidden files (starting with .)
608
+ if item.name.startswith("."):
609
+ continue
610
+
611
+ # Escape HTML special characters in filenames
612
+ safe_name = _escape_markdown(item.name)
613
+
614
+ if item.is_dir():
615
+ directories.append(f"πŸ“ {safe_name}/")
616
+ else:
617
+ # Get file size
618
+ try:
619
+ size = item.stat().st_size
620
+ size_str = _format_file_size(size)
621
+ files.append(f"πŸ“„ {safe_name} ({size_str})")
622
+ except OSError:
623
+ files.append(f"πŸ“„ {safe_name}")
624
+
625
+ # Combine directories first, then files
626
+ items = directories + files
627
+
628
+ # Format response
629
+ relative_path = current_dir.relative_to(settings.approved_directory)
630
+ if not items:
631
+ message = f"πŸ“‚ <code>{relative_path}/</code>\n\n<i>(empty directory)</i>"
632
+ else:
633
+ message = f"πŸ“‚ <code>{relative_path}/</code>\n\n"
634
+
635
+ # Limit items shown to prevent message being too long
636
+ max_items = 50
637
+ if len(items) > max_items:
638
+ shown_items = items[:max_items]
639
+ message += "\n".join(shown_items)
640
+ message += f"\n\n<i>... and {len(items) - max_items} more items</i>"
641
+ else:
642
+ message += "\n".join(items)
643
+
644
+ # Add navigation buttons if not at root
645
+ keyboard = []
646
+ if current_dir != settings.approved_directory:
647
+ keyboard.append(
648
+ [
649
+ InlineKeyboardButton("⬆️ Go Up", callback_data="cd:.."),
650
+ InlineKeyboardButton("🏠 Go to Root", callback_data="cd:/"),
651
+ ]
652
+ )
653
+
654
+ keyboard.append(
655
+ [
656
+ InlineKeyboardButton("πŸ”„ Refresh", callback_data="action:refresh_ls"),
657
+ InlineKeyboardButton(
658
+ "πŸ“ Projects", callback_data="action:show_projects"
659
+ ),
660
+ ]
661
+ )
662
+
663
+ reply_markup = InlineKeyboardMarkup(keyboard) if keyboard else None
664
+
665
+ await update.message.reply_text(
666
+ message, parse_mode="HTML", reply_markup=reply_markup
667
+ )
668
+
669
+ # Log successful command
670
+ if audit_logger:
671
+ await audit_logger.log_command(user_id, "ls", [], True)
672
+
673
+ except Exception as e:
674
+ error_msg = f"❌ Error listing directory: {str(e)}"
675
+ await update.message.reply_text(error_msg)
676
+
677
+ # Log failed command
678
+ if audit_logger:
679
+ await audit_logger.log_command(user_id, "ls", [], False)
680
+
681
+ logger.error("Error in list_files command", error=str(e), user_id=user_id)
682
+
683
+
684
+ async def change_directory(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
685
+ """Handle /cd command."""
686
+ user_id = update.effective_user.id
687
+ settings: Settings = context.bot_data["settings"]
688
+ security_validator: SecurityValidator = context.bot_data.get("security_validator")
689
+ audit_logger: AuditLogger = context.bot_data.get("audit_logger")
690
+
691
+ # Parse arguments
692
+ if not context.args:
693
+ await update.message.reply_text(
694
+ "<b>Usage:</b> <code>/cd &lt;directory&gt;</code>\n\n"
695
+ "<b>Examples:</b>\n"
696
+ "β€’ <code>/cd myproject</code> - Enter subdirectory\n"
697
+ "β€’ <code>/cd ..</code> - Go up one level\n"
698
+ "β€’ <code>/cd /</code> - Go to root of approved directory\n\n"
699
+ "<b>Tips:</b>\n"
700
+ "β€’ Use <code>/ls</code> to see available directories\n"
701
+ "β€’ Use <code>/projects</code> to see all projects",
702
+ parse_mode="HTML",
703
+ )
704
+ return
705
+
706
+ target_path = " ".join(context.args)
707
+ current_dir = context.user_data.get(
708
+ "current_directory", settings.approved_directory
709
+ )
710
+ project_root = _get_thread_project_root(settings, context)
711
+ directory_root = project_root or settings.approved_directory
712
+
713
+ try:
714
+ # Handle known navigation shortcuts first
715
+ if target_path == "/":
716
+ resolved_path = directory_root
717
+ elif target_path == "..":
718
+ resolved_path = current_dir.parent
719
+ if not _is_within_root(resolved_path, directory_root):
720
+ resolved_path = directory_root
721
+ else:
722
+ # Validate path using security validator
723
+ if security_validator:
724
+ valid, resolved_path, error = security_validator.validate_path(
725
+ target_path, current_dir
726
+ )
727
+
728
+ if not valid:
729
+ await update.message.reply_text(
730
+ f"❌ <b>Access Denied</b>\n\n{error}"
731
+ )
732
+
733
+ # Log security violation
734
+ if audit_logger:
735
+ await audit_logger.log_security_violation(
736
+ user_id=user_id,
737
+ violation_type="path_traversal_attempt",
738
+ details=f"Attempted path: {target_path}",
739
+ severity="medium",
740
+ )
741
+ return
742
+ else:
743
+ resolved_path = current_dir / target_path
744
+ resolved_path = resolved_path.resolve()
745
+
746
+ if project_root and not _is_within_root(resolved_path, project_root):
747
+ await update.message.reply_text(
748
+ "❌ <b>Access Denied</b>\n\n"
749
+ "In thread mode, navigation is limited to the current project root.",
750
+ parse_mode="HTML",
751
+ )
752
+ return
753
+
754
+ # Check if directory exists and is actually a directory
755
+ if not resolved_path.exists():
756
+ await update.message.reply_text(
757
+ f"❌ <b>Directory Not Found</b>\n\n<code>{target_path}</code> does not exist."
758
+ )
759
+ return
760
+
761
+ if not resolved_path.is_dir():
762
+ await update.message.reply_text(
763
+ f"❌ <b>Not a Directory</b>\n\n<code>{target_path}</code> is not a directory."
764
+ )
765
+ return
766
+
767
+ # Update current directory in user data
768
+ context.user_data["current_directory"] = resolved_path
769
+
770
+ # Look up existing session for the new directory instead of clearing
771
+ claude_integration: ClaudeIntegration = context.bot_data.get(
772
+ "claude_integration"
773
+ )
774
+ resumed_session_info = ""
775
+ if claude_integration:
776
+ existing_session = await claude_integration._find_resumable_session(
777
+ user_id, resolved_path
778
+ )
779
+ if existing_session:
780
+ context.user_data["claude_session_id"] = existing_session.session_id
781
+ resumed_session_info = (
782
+ f"\nπŸ”„ Resumed session <code>{existing_session.session_id[:8]}...</code> "
783
+ f"({existing_session.message_count} messages)"
784
+ )
785
+ else:
786
+ # No session for this directory - clear the current one
787
+ context.user_data["claude_session_id"] = None
788
+ resumed_session_info = (
789
+ "\nπŸ†• No existing session. Send a message to start a new one."
790
+ )
791
+
792
+ # Send confirmation
793
+ relative_base = project_root or settings.approved_directory
794
+ relative_path = resolved_path.relative_to(relative_base)
795
+ relative_display = "/" if str(relative_path) == "." else f"{relative_path}/"
796
+ await update.message.reply_text(
797
+ f"βœ… <b>Directory Changed</b>\n\n"
798
+ f"πŸ“‚ Current directory: <code>{relative_display}</code>"
799
+ f"{resumed_session_info}",
800
+ parse_mode="HTML",
801
+ )
802
+
803
+ # Log successful command
804
+ if audit_logger:
805
+ await audit_logger.log_command(user_id, "cd", [target_path], True)
806
+
807
+ except Exception as e:
808
+ error_msg = f"❌ <b>Error changing directory</b>\n\n{str(e)}"
809
+ await update.message.reply_text(error_msg, parse_mode="HTML")
810
+
811
+ # Log failed command
812
+ if audit_logger:
813
+ await audit_logger.log_command(user_id, "cd", [target_path], False)
814
+
815
+ logger.error("Error in change_directory command", error=str(e), user_id=user_id)
816
+
817
+
818
+ async def print_working_directory(
819
+ update: Update, context: ContextTypes.DEFAULT_TYPE
820
+ ) -> None:
821
+ """Handle /pwd command."""
822
+ settings: Settings = context.bot_data["settings"]
823
+ current_dir = context.user_data.get(
824
+ "current_directory", settings.approved_directory
825
+ )
826
+
827
+ relative_path = current_dir.relative_to(settings.approved_directory)
828
+ absolute_path = str(current_dir)
829
+
830
+ # Add quick navigation buttons
831
+ keyboard = [
832
+ [
833
+ InlineKeyboardButton("πŸ“ List Files", callback_data="action:ls"),
834
+ InlineKeyboardButton("πŸ“‹ Projects", callback_data="action:show_projects"),
835
+ ]
836
+ ]
837
+ reply_markup = InlineKeyboardMarkup(keyboard)
838
+
839
+ await update.message.reply_text(
840
+ f"πŸ“ <b>Current Directory</b>\n\n"
841
+ f"Relative: <code>{relative_path}/</code>\n"
842
+ f"Absolute: <code>{absolute_path}</code>",
843
+ parse_mode="HTML",
844
+ reply_markup=reply_markup,
845
+ )
846
+
847
+
848
+ async def show_projects(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
849
+ """Handle /projects command."""
850
+ settings: Settings = context.bot_data["settings"]
851
+
852
+ try:
853
+ if settings.enable_project_threads:
854
+ registry = context.bot_data.get("project_registry")
855
+ manager = context.bot_data.get("project_threads_manager")
856
+ if manager and getattr(manager, "registry", None):
857
+ registry = manager.registry
858
+ if not registry:
859
+ await update.message.reply_text(
860
+ "❌ <b>Project registry is not initialized.</b>",
861
+ parse_mode="HTML",
862
+ )
863
+ return
864
+
865
+ projects = registry.list_enabled()
866
+ if not projects:
867
+ await update.message.reply_text(
868
+ "πŸ“ <b>No Projects Found</b>\n\n"
869
+ "No enabled projects found in projects config.",
870
+ parse_mode="HTML",
871
+ )
872
+ return
873
+
874
+ project_list = "\n".join(
875
+ [
876
+ f"β€’ <b>{escape_html(p.name)}</b> "
877
+ f"(<code>{escape_html(p.slug)}</code>) "
878
+ f"β†’ <code>{escape_html(str(p.relative_path))}</code>"
879
+ for p in projects
880
+ ]
881
+ )
882
+
883
+ await update.message.reply_text(
884
+ f"πŸ“ <b>Configured Projects</b>\n\n{project_list}",
885
+ parse_mode="HTML",
886
+ )
887
+ return
888
+
889
+ # Get directories in approved directory (these are "projects")
890
+ projects = []
891
+ for item in sorted(settings.approved_directory.iterdir()):
892
+ if item.is_dir() and not item.name.startswith("."):
893
+ projects.append(item.name)
894
+
895
+ if not projects:
896
+ await update.message.reply_text(
897
+ "πŸ“ <b>No Projects Found</b>\n\n"
898
+ "No subdirectories found in your approved directory.\n"
899
+ "Create some directories to organize your projects!"
900
+ )
901
+ return
902
+
903
+ # Create inline keyboard with project buttons
904
+ keyboard = []
905
+ for i in range(0, len(projects), 2):
906
+ row = []
907
+ for j in range(2):
908
+ if i + j < len(projects):
909
+ project = projects[i + j]
910
+ row.append(
911
+ InlineKeyboardButton(
912
+ f"πŸ“ {project}", callback_data=f"cd:{project}"
913
+ )
914
+ )
915
+ keyboard.append(row)
916
+
917
+ # Add navigation buttons
918
+ keyboard.append(
919
+ [
920
+ InlineKeyboardButton("🏠 Go to Root", callback_data="cd:/"),
921
+ InlineKeyboardButton(
922
+ "πŸ”„ Refresh", callback_data="action:show_projects"
923
+ ),
924
+ ]
925
+ )
926
+
927
+ reply_markup = InlineKeyboardMarkup(keyboard)
928
+
929
+ project_list = "\n".join([f"β€’ <code>{project}/</code>" for project in projects])
930
+
931
+ await update.message.reply_text(
932
+ f"πŸ“ <b>Available Projects</b>\n\n"
933
+ f"{project_list}\n\n"
934
+ f"Click a project below to navigate to it:",
935
+ parse_mode="HTML",
936
+ reply_markup=reply_markup,
937
+ )
938
+
939
+ except Exception as e:
940
+ await update.message.reply_text(f"❌ Error loading projects: {str(e)}")
941
+ logger.error("Error in show_projects command", error=str(e))
942
+
943
+
944
+ async def session_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
945
+ """Handle /status command."""
946
+ user_id = update.effective_user.id
947
+ settings: Settings = context.bot_data["settings"]
948
+
949
+ # Get session info
950
+ claude_session_id = context.user_data.get("claude_session_id")
951
+ current_dir = context.user_data.get(
952
+ "current_directory", settings.approved_directory
953
+ )
954
+ relative_path = current_dir.relative_to(settings.approved_directory)
955
+
956
+ # Get session usage info
957
+ usage_info = ""
958
+ claude_integration: ClaudeIntegration = context.bot_data.get("claude_integration")
959
+ if claude_integration and claude_session_id:
960
+ try:
961
+ session_info = await claude_integration.session_manager.get_session_info(
962
+ claude_session_id
963
+ )
964
+ if session_info:
965
+ turns = session_info.get("turns", 0)
966
+ msgs = session_info.get("messages", 0)
967
+ tools = session_info.get("tools_used", [])
968
+ usage_info = (
969
+ f"πŸ“ˆ Session: {msgs} messages, {turns} turns\n"
970
+ f"πŸ”§ Tools: {', '.join(tools[:5]) if tools else 'None'}\n"
971
+ )
972
+ except Exception:
973
+ usage_info = ""
974
+
975
+ # Check if there's a resumable session from the database
976
+ resumable_info = ""
977
+ if not claude_session_id:
978
+ claude_integration: ClaudeIntegration = context.bot_data.get(
979
+ "claude_integration"
980
+ )
981
+ if claude_integration:
982
+ existing = await claude_integration._find_resumable_session(
983
+ user_id, current_dir
984
+ )
985
+ if existing:
986
+ resumable_info = (
987
+ f"πŸ”„ Resumable: <code>{existing.session_id[:8]}...</code> "
988
+ f"({existing.message_count} msgs)"
989
+ )
990
+
991
+ # Format status message
992
+ status_lines = [
993
+ "πŸ“Š <b>Session Status</b>",
994
+ "",
995
+ f"πŸ“‚ Directory: <code>{relative_path}/</code>",
996
+ f"πŸ€– Claude Session: {'βœ… Active' if claude_session_id else '❌ None'}",
997
+ usage_info.rstrip(),
998
+ f"πŸ• Last Update: {update.message.date.strftime('%H:%M:%S UTC')}",
999
+ ]
1000
+
1001
+ if claude_session_id:
1002
+ status_lines.append(f"πŸ†” Session ID: <code>{claude_session_id[:8]}...</code>")
1003
+ elif resumable_info:
1004
+ status_lines.append(resumable_info)
1005
+ status_lines.append("πŸ’‘ Session will auto-resume on your next message")
1006
+
1007
+ # Add action buttons
1008
+ keyboard = []
1009
+ if claude_session_id:
1010
+ keyboard.append(
1011
+ [
1012
+ InlineKeyboardButton("πŸ”„ Continue", callback_data="action:continue"),
1013
+ InlineKeyboardButton(
1014
+ "πŸ†• New Session", callback_data="action:new_session"
1015
+ ),
1016
+ ]
1017
+ )
1018
+ else:
1019
+ keyboard.append(
1020
+ [
1021
+ InlineKeyboardButton(
1022
+ "πŸ†• Start Session", callback_data="action:new_session"
1023
+ )
1024
+ ]
1025
+ )
1026
+
1027
+ keyboard.append(
1028
+ [
1029
+ InlineKeyboardButton("πŸ“€ Export", callback_data="action:export"),
1030
+ InlineKeyboardButton("πŸ”„ Refresh", callback_data="action:refresh_status"),
1031
+ ]
1032
+ )
1033
+
1034
+ reply_markup = InlineKeyboardMarkup(keyboard)
1035
+
1036
+ await update.message.reply_text(
1037
+ "\n".join(status_lines), parse_mode="HTML", reply_markup=reply_markup
1038
+ )
1039
+
1040
+
1041
+ async def export_session(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1042
+ """Handle /export command."""
1043
+ update.effective_user.id
1044
+ features = context.bot_data.get("features")
1045
+
1046
+ # Check if session export is available
1047
+ session_exporter = features.get_session_export() if features else None
1048
+
1049
+ if not session_exporter:
1050
+ await update.message.reply_text(
1051
+ "πŸ“€ <b>Export Session</b>\n\n"
1052
+ "Session export functionality is not available.\n\n"
1053
+ "<b>Planned features:</b>\n"
1054
+ "β€’ Export conversation history\n"
1055
+ "β€’ Save session state\n"
1056
+ "β€’ Share conversations\n"
1057
+ "β€’ Create session backups"
1058
+ )
1059
+ return
1060
+
1061
+ # Get current session
1062
+ claude_session_id = context.user_data.get("claude_session_id")
1063
+
1064
+ if not claude_session_id:
1065
+ await update.message.reply_text(
1066
+ "❌ <b>No Active Session</b>\n\n"
1067
+ "There's no active Claude session to export.\n\n"
1068
+ "<b>What you can do:</b>\n"
1069
+ "β€’ Start a new session with <code>/new</code>\n"
1070
+ "β€’ Continue an existing session with <code>/continue</code>\n"
1071
+ "β€’ Check your status with <code>/status</code>"
1072
+ )
1073
+ return
1074
+
1075
+ # Create export format selection keyboard
1076
+ keyboard = [
1077
+ [
1078
+ InlineKeyboardButton("πŸ“ Markdown", callback_data="export:markdown"),
1079
+ InlineKeyboardButton("🌐 HTML", callback_data="export:html"),
1080
+ ],
1081
+ [
1082
+ InlineKeyboardButton("πŸ“‹ JSON", callback_data="export:json"),
1083
+ InlineKeyboardButton("❌ Cancel", callback_data="export:cancel"),
1084
+ ],
1085
+ ]
1086
+ reply_markup = InlineKeyboardMarkup(keyboard)
1087
+
1088
+ await update.message.reply_text(
1089
+ "πŸ“€ <b>Export Session</b>\n\n"
1090
+ f"Ready to export session: <code>{claude_session_id[:8]}...</code>\n\n"
1091
+ "<b>Choose export format:</b>",
1092
+ parse_mode="HTML",
1093
+ reply_markup=reply_markup,
1094
+ )
1095
+
1096
+
1097
+ async def end_session(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1098
+ """Handle /end command to terminate the current session."""
1099
+ user_id = update.effective_user.id
1100
+ settings: Settings = context.bot_data["settings"]
1101
+
1102
+ # Check if there's an active session
1103
+ claude_session_id = context.user_data.get("claude_session_id")
1104
+
1105
+ if not claude_session_id:
1106
+ await update.message.reply_text(
1107
+ "ℹ️ <b>No Active Session</b>\n\n"
1108
+ "There's no active Claude session to end.\n\n"
1109
+ "<b>What you can do:</b>\n"
1110
+ "β€’ Use <code>/new</code> to start a new session\n"
1111
+ "β€’ Use <code>/status</code> to check your session status\n"
1112
+ "β€’ Send any message to start a conversation"
1113
+ )
1114
+ return
1115
+
1116
+ # Get current directory for display
1117
+ current_dir = context.user_data.get(
1118
+ "current_directory", settings.approved_directory
1119
+ )
1120
+ relative_path = current_dir.relative_to(settings.approved_directory)
1121
+
1122
+ # Clear session data
1123
+ context.user_data["claude_session_id"] = None
1124
+ context.user_data["session_started"] = False
1125
+ context.user_data["last_message"] = None
1126
+
1127
+ # Create quick action buttons
1128
+ keyboard = [
1129
+ [
1130
+ InlineKeyboardButton("πŸ†• New Session", callback_data="action:new_session"),
1131
+ InlineKeyboardButton(
1132
+ "πŸ“ Change Project", callback_data="action:show_projects"
1133
+ ),
1134
+ ],
1135
+ [
1136
+ InlineKeyboardButton("πŸ“Š Status", callback_data="action:status"),
1137
+ InlineKeyboardButton("❓ Help", callback_data="action:help"),
1138
+ ],
1139
+ ]
1140
+ reply_markup = InlineKeyboardMarkup(keyboard)
1141
+
1142
+ await update.message.reply_text(
1143
+ "βœ… <b>Session Ended</b>\n\n"
1144
+ f"Your Claude session has been terminated.\n\n"
1145
+ f"<b>Current Status:</b>\n"
1146
+ f"β€’ Directory: <code>{relative_path}/</code>\n"
1147
+ f"β€’ Session: None\n"
1148
+ f"β€’ Ready for new commands\n\n"
1149
+ f"<b>Next Steps:</b>\n"
1150
+ f"β€’ Start a new session with <code>/new</code>\n"
1151
+ f"β€’ Check status with <code>/status</code>\n"
1152
+ f"β€’ Send any message to begin a new conversation",
1153
+ parse_mode="HTML",
1154
+ reply_markup=reply_markup,
1155
+ )
1156
+
1157
+ logger.info("Session ended by user", user_id=user_id, session_id=claude_session_id)
1158
+
1159
+
1160
+ async def quick_actions(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1161
+ """Handle /actions command to show quick actions."""
1162
+ user_id = update.effective_user.id
1163
+ settings: Settings = context.bot_data["settings"]
1164
+ features = context.bot_data.get("features")
1165
+
1166
+ if not features or not features.is_enabled("quick_actions"):
1167
+ await update.message.reply_text(
1168
+ "❌ <b>Quick Actions Disabled</b>\n\n"
1169
+ "Quick actions feature is not enabled.\n"
1170
+ "Contact your administrator to enable this feature."
1171
+ )
1172
+ return
1173
+
1174
+ # Get current directory
1175
+ current_dir = context.user_data.get(
1176
+ "current_directory", settings.approved_directory
1177
+ )
1178
+
1179
+ try:
1180
+ quick_action_manager = features.get_quick_actions()
1181
+ if not quick_action_manager:
1182
+ await update.message.reply_text(
1183
+ "❌ <b>Quick Actions Unavailable</b>\n\n"
1184
+ "Quick actions service is not available."
1185
+ )
1186
+ return
1187
+
1188
+ # Get context-aware actions
1189
+ actions = await quick_action_manager.get_suggestions(
1190
+ session_data={"working_directory": str(current_dir), "user_id": user_id}
1191
+ )
1192
+
1193
+ if not actions:
1194
+ await update.message.reply_text(
1195
+ "πŸ€– <b>No Actions Available</b>\n\n"
1196
+ "No quick actions are available for the current context.\n\n"
1197
+ "<b>Try:</b>\n"
1198
+ "β€’ Navigating to a project directory with <code>/cd</code>\n"
1199
+ "β€’ Creating some code files\n"
1200
+ "β€’ Starting a Claude session with <code>/new</code>"
1201
+ )
1202
+ return
1203
+
1204
+ # Create inline keyboard
1205
+ keyboard = quick_action_manager.create_inline_keyboard(actions, max_columns=2)
1206
+
1207
+ relative_path = current_dir.relative_to(settings.approved_directory)
1208
+ await update.message.reply_text(
1209
+ f"⚑ <b>Quick Actions</b>\n\n"
1210
+ f"πŸ“‚ Context: <code>{relative_path}/</code>\n\n"
1211
+ f"Select an action to execute:",
1212
+ parse_mode="HTML",
1213
+ reply_markup=keyboard,
1214
+ )
1215
+
1216
+ except Exception as e:
1217
+ await update.message.reply_text(f"❌ <b>Error Loading Actions</b>\n\n{str(e)}")
1218
+ logger.error("Error in quick_actions command", error=str(e), user_id=user_id)
1219
+
1220
+
1221
+ async def git_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1222
+ """Handle /git command to show git repository information."""
1223
+ user_id = update.effective_user.id
1224
+ settings: Settings = context.bot_data["settings"]
1225
+ features = context.bot_data.get("features")
1226
+
1227
+ if not features or not features.is_enabled("git"):
1228
+ await update.message.reply_text(
1229
+ "❌ <b>Git Integration Disabled</b>\n\n"
1230
+ "Git integration feature is not enabled.\n"
1231
+ "Contact your administrator to enable this feature."
1232
+ )
1233
+ return
1234
+
1235
+ # Get current directory
1236
+ current_dir = context.user_data.get(
1237
+ "current_directory", settings.approved_directory
1238
+ )
1239
+
1240
+ try:
1241
+ git_integration = features.get_git_integration()
1242
+ if not git_integration:
1243
+ await update.message.reply_text(
1244
+ "❌ <b>Git Integration Unavailable</b>\n\n"
1245
+ "Git integration service is not available."
1246
+ )
1247
+ return
1248
+
1249
+ # Check if current directory is a git repository
1250
+ if not (current_dir / ".git").exists():
1251
+ await update.message.reply_text(
1252
+ f"πŸ“‚ <b>Not a Git Repository</b>\n\n"
1253
+ f"Current directory <code>{current_dir.relative_to(settings.approved_directory)}/</code> is not a git repository.\n\n"
1254
+ f"<b>Options:</b>\n"
1255
+ f"β€’ Navigate to a git repository with <code>/cd</code>\n"
1256
+ f"β€’ Initialize a new repository (ask Claude to help)\n"
1257
+ f"β€’ Clone an existing repository (ask Claude to help)"
1258
+ )
1259
+ return
1260
+
1261
+ # Get git status
1262
+ git_status = await git_integration.get_status(current_dir)
1263
+
1264
+ # Format status message
1265
+ relative_path = current_dir.relative_to(settings.approved_directory)
1266
+ status_message = "πŸ”— <b>Git Repository Status</b>\n\n"
1267
+ status_message += f"πŸ“‚ Directory: <code>{relative_path}/</code>\n"
1268
+ status_message += f"🌿 Branch: <code>{git_status.branch}</code>\n"
1269
+
1270
+ if git_status.ahead > 0:
1271
+ status_message += f"⬆️ Ahead: {git_status.ahead} commits\n"
1272
+ if git_status.behind > 0:
1273
+ status_message += f"⬇️ Behind: {git_status.behind} commits\n"
1274
+
1275
+ # Show file changes
1276
+ if not git_status.is_clean:
1277
+ status_message += "\n<b>Changes:</b>\n"
1278
+ if git_status.modified:
1279
+ status_message += f"πŸ“ Modified: {len(git_status.modified)} files\n"
1280
+ if git_status.added:
1281
+ status_message += f"βž• Added: {len(git_status.added)} files\n"
1282
+ if git_status.deleted:
1283
+ status_message += f"βž– Deleted: {len(git_status.deleted)} files\n"
1284
+ if git_status.untracked:
1285
+ status_message += f"❓ Untracked: {len(git_status.untracked)} files\n"
1286
+ else:
1287
+ status_message += "\nβœ… Working directory clean\n"
1288
+
1289
+ # Create action buttons
1290
+ keyboard = [
1291
+ [
1292
+ InlineKeyboardButton("πŸ“Š Show Diff", callback_data="git:diff"),
1293
+ InlineKeyboardButton("πŸ“œ Show Log", callback_data="git:log"),
1294
+ ],
1295
+ [
1296
+ InlineKeyboardButton("πŸ”„ Refresh", callback_data="git:status"),
1297
+ InlineKeyboardButton("πŸ“ Files", callback_data="action:ls"),
1298
+ ],
1299
+ ]
1300
+
1301
+ reply_markup = InlineKeyboardMarkup(keyboard)
1302
+
1303
+ await update.message.reply_text(
1304
+ status_message, parse_mode="HTML", reply_markup=reply_markup
1305
+ )
1306
+
1307
+ except Exception as e:
1308
+ await update.message.reply_text(f"❌ <b>Git Error</b>\n\n{str(e)}")
1309
+ logger.error("Error in git_command", error=str(e), user_id=user_id)
1310
+
1311
+
1312
+ def _format_file_size(size: int) -> str:
1313
+ """Format file size in human-readable format."""
1314
+ for unit in ["B", "KB", "MB", "GB"]:
1315
+ if size < 1024:
1316
+ return f"{size:.1f}{unit}" if unit != "B" else f"{size}B"
1317
+ size /= 1024
1318
+ return f"{size:.1f}TB"
1319
+
1320
+
1321
+ def _escape_markdown(text: str) -> str:
1322
+ """Escape HTML-special characters in text for Telegram.
1323
+
1324
+ Legacy name kept for compatibility with callers; actually escapes HTML.
1325
+ """
1326
+ return escape_html(text)
1327
+
1328
+
1329
+ async def usage_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
1330
+ """Handle /usage command - show daily and weekly usage statistics."""
1331
+ user_id = update.effective_user.id
1332
+ settings: Settings = context.bot_data["settings"]
1333
+
1334
+ try:
1335
+ db_manager = context.bot_data.get("db_manager")
1336
+ if not db_manager:
1337
+ await update.message.reply_text("❌ λ°μ΄ν„°λ² μ΄μŠ€μ— μ—°κ²°ν•  수 μ—†μŠ΅λ‹ˆλ‹€.")
1338
+ return
1339
+
1340
+ now = datetime.now(timezone.utc)
1341
+ today = now.strftime("%Y-%m-%d")
1342
+ week_ago = (now - timedelta(days=7)).strftime("%Y-%m-%d")
1343
+ month_ago = (now - timedelta(days=30)).strftime("%Y-%m-%d")
1344
+
1345
+ async with db_manager.get_connection() as conn:
1346
+ # --- Today's stats ---
1347
+ cursor = await conn.execute(
1348
+ """
1349
+ SELECT COUNT(*) as msg_count,
1350
+ COALESCE(SUM(cost), 0) as total_cost,
1351
+ COALESCE(AVG(duration_ms), 0) as avg_duration
1352
+ FROM messages
1353
+ WHERE user_id = ? AND date(timestamp) = date(?)
1354
+ """,
1355
+ (user_id, today),
1356
+ )
1357
+ today_row = await cursor.fetchone()
1358
+ today_msgs = today_row[0] if today_row else 0
1359
+ today_cost = today_row[1] if today_row else 0.0
1360
+ today_avg_ms = today_row[2] if today_row else 0.0
1361
+
1362
+ # --- Today's sessions ---
1363
+ cursor = await conn.execute(
1364
+ """
1365
+ SELECT COUNT(DISTINCT session_id) as session_count
1366
+ FROM messages
1367
+ WHERE user_id = ? AND date(timestamp) = date(?)
1368
+ """,
1369
+ (user_id, today),
1370
+ )
1371
+ today_sessions_row = await cursor.fetchone()
1372
+ today_sessions = today_sessions_row[0] if today_sessions_row else 0
1373
+
1374
+ # --- Weekly stats (last 7 days) ---
1375
+ cursor = await conn.execute(
1376
+ """
1377
+ SELECT COUNT(*) as msg_count,
1378
+ COALESCE(SUM(cost), 0) as total_cost,
1379
+ COUNT(DISTINCT date(timestamp)) as active_days
1380
+ FROM messages
1381
+ WHERE user_id = ? AND date(timestamp) >= date(?)
1382
+ """,
1383
+ (user_id, week_ago),
1384
+ )
1385
+ week_row = await cursor.fetchone()
1386
+ week_msgs = week_row[0] if week_row else 0
1387
+ week_cost = week_row[1] if week_row else 0.0
1388
+ week_active_days = week_row[2] if week_row else 0
1389
+
1390
+ # --- Weekly sessions ---
1391
+ cursor = await conn.execute(
1392
+ """
1393
+ SELECT COUNT(DISTINCT session_id) as session_count
1394
+ FROM messages
1395
+ WHERE user_id = ? AND date(timestamp) >= date(?)
1396
+ """,
1397
+ (user_id, week_ago),
1398
+ )
1399
+ week_sessions_row = await cursor.fetchone()
1400
+ week_sessions = week_sessions_row[0] if week_sessions_row else 0
1401
+
1402
+ # --- Daily breakdown (last 7 days) ---
1403
+ cursor = await conn.execute(
1404
+ """
1405
+ SELECT date(timestamp) as day,
1406
+ COUNT(*) as msg_count,
1407
+ COALESCE(SUM(cost), 0) as daily_cost
1408
+ FROM messages
1409
+ WHERE user_id = ? AND date(timestamp) >= date(?)
1410
+ GROUP BY date(timestamp)
1411
+ ORDER BY day DESC
1412
+ """,
1413
+ (user_id, week_ago),
1414
+ )
1415
+ daily_rows = await cursor.fetchall()
1416
+
1417
+ # --- Top tools (last 7 days) ---
1418
+ cursor = await conn.execute(
1419
+ """
1420
+ SELECT tool_name, COUNT(*) as cnt
1421
+ FROM tool_usage
1422
+ WHERE session_id IN (
1423
+ SELECT DISTINCT session_id FROM messages
1424
+ WHERE user_id = ? AND date(timestamp) >= date(?)
1425
+ )
1426
+ GROUP BY tool_name
1427
+ ORDER BY cnt DESC
1428
+ LIMIT 5
1429
+ """,
1430
+ (user_id, week_ago),
1431
+ )
1432
+ tool_rows = await cursor.fetchall()
1433
+
1434
+ # --- All-time total ---
1435
+ cursor = await conn.execute(
1436
+ """
1437
+ SELECT COALESCE(SUM(cost), 0) as total_cost,
1438
+ COUNT(*) as total_msgs
1439
+ FROM messages WHERE user_id = ?
1440
+ """,
1441
+ (user_id,),
1442
+ )
1443
+ all_row = await cursor.fetchone()
1444
+ all_cost = all_row[0] if all_row else 0.0
1445
+ all_msgs = all_row[1] if all_row else 0
1446
+
1447
+ # --- Fetch Claude Code plan usage (non-blocking) ---
1448
+ claude_usage = await _get_claude_usage()
1449
+
1450
+ # --- Build message ---
1451
+ lines = []
1452
+ lines.append("πŸ“Š <b>μ‚¬μš©λŸ‰ 리포트</b>")
1453
+ lines.append("")
1454
+
1455
+ # Claude Code Plan
1456
+ if claude_usage:
1457
+ lines.append("━━━ ⚑ <b>Claude Code ν”Œλžœ</b> ━━━")
1458
+ lines.append("πŸ“‹ ν”Œλžœ: <b>Max (5x)</b>")
1459
+
1460
+ five = claude_usage.get("five_hour") or {}
1461
+ seven = claude_usage.get("seven_day") or {}
1462
+
1463
+ if five:
1464
+ pct5 = five.get("utilization", 0)
1465
+ bar5 = _format_usage_bar(pct5)
1466
+ reset5 = _format_reset_time(five.get("resets_at", ""))
1467
+ lines.append(
1468
+ f"πŸ• 5μ‹œκ°„: <code>{bar5}</code> <b>{pct5:.0f}%</b> (리셋: {reset5})"
1469
+ )
1470
+
1471
+ if seven:
1472
+ pct7 = seven.get("utilization", 0)
1473
+ bar7 = _format_usage_bar(pct7)
1474
+ reset7 = _format_reset_time(seven.get("resets_at", ""))
1475
+ lines.append(
1476
+ f"πŸ“… 7일간: <code>{bar7}</code> <b>{pct7:.0f}%</b> (리셋: {reset7})"
1477
+ )
1478
+
1479
+ lines.append("")
1480
+
1481
+ # Today
1482
+ lines.append("━━━ πŸ“… <b>였늘</b> ━━━")
1483
+ lines.append(f"πŸ’¬ λ©”μ‹œμ§€: <b>{today_msgs}</b>건")
1484
+ lines.append(f"πŸ”„ μ„Έμ…˜: <b>{today_sessions}</b>개")
1485
+ lines.append(f"πŸ’° λΉ„μš©: <b>${today_cost:.4f}</b>")
1486
+ if today_avg_ms > 0:
1487
+ lines.append(f"⏱ 평균 응닡: <b>{today_avg_ms / 1000:.1f}s</b>")
1488
+ lines.append("")
1489
+
1490
+ # Weekly
1491
+ lines.append("━━━ πŸ“† <b>졜근 7일</b> ━━━")
1492
+ lines.append(f"πŸ’¬ λ©”μ‹œμ§€: <b>{week_msgs}</b>건")
1493
+ lines.append(f"πŸ”„ μ„Έμ…˜: <b>{week_sessions}</b>개")
1494
+ lines.append(f"πŸ“… ν™œλ™μΌ: <b>{week_active_days}</b>일 / 7일")
1495
+ lines.append(f"πŸ’° λΉ„μš©: <b>${week_cost:.4f}</b>")
1496
+ if week_active_days > 0:
1497
+ lines.append(f"πŸ“ˆ 일평균: <b>${week_cost / week_active_days:.4f}</b>/일")
1498
+ lines.append("")
1499
+
1500
+ # Daily breakdown chart
1501
+ if daily_rows:
1502
+ lines.append("━━━ πŸ“‰ <b>일별 좔이</b> ━━━")
1503
+ max_msgs = max(row[1] for row in daily_rows) if daily_rows else 1
1504
+ for row in daily_rows:
1505
+ day_str = row[0]
1506
+ msg_count = row[1]
1507
+ day_cost = row[2]
1508
+ bar_len = int((msg_count / max_msgs) * 8) if max_msgs > 0 else 0
1509
+ bar = "β–ˆ" * bar_len + "β–‘" * (8 - bar_len)
1510
+ # Show month/day only
1511
+ short_date = day_str[5:] # "MM-DD"
1512
+ lines.append(
1513
+ f"<code>{short_date}</code> {bar} <b>{msg_count}</b>건 ${day_cost:.3f}"
1514
+ )
1515
+ lines.append("")
1516
+
1517
+ # Top tools
1518
+ if tool_rows:
1519
+ lines.append("━━━ πŸ”§ <b>자주 μ“΄ 도ꡬ (7일)</b> ━━━")
1520
+ tool_icons = {
1521
+ "Read": "πŸ“–", "Write": "✏️", "Edit": "πŸ“",
1522
+ "Bash": "πŸ’»", "Glob": "πŸ”", "Grep": "πŸ”Ž",
1523
+ "Task": "πŸ€–", "WebFetch": "🌐", "WebSearch": "πŸ”",
1524
+ }
1525
+ for row in tool_rows:
1526
+ tool_name = row[0]
1527
+ tool_count = row[1]
1528
+ icon = tool_icons.get(tool_name, "βš™οΈ")
1529
+ lines.append(f"{icon} {tool_name}: <b>{tool_count}</b>회")
1530
+ lines.append("")
1531
+
1532
+ # All-time
1533
+ lines.append(f"🏦 <b>λˆ„μ </b>: ${all_cost:.4f} ({all_msgs}건)")
1534
+
1535
+ await update.message.reply_text(
1536
+ "\n".join(lines), parse_mode="HTML"
1537
+ )
1538
+
1539
+ except Exception as e:
1540
+ logger.error("Error in usage_command", error=str(e), user_id=user_id)
1541
+ await update.message.reply_text(f"❌ μ‚¬μš©λŸ‰ 쑰회 쀑 였λ₯˜: {str(e)}")