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,1432 @@
1
+ """Handle inline keyboard callbacks."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import structlog
7
+ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
8
+ from telegram.ext import ContextTypes
9
+
10
+ from ...claude.facade import ClaudeIntegration
11
+ from ...config.settings import Settings
12
+ from ...security.audit import AuditLogger
13
+ from ...security.validators import SecurityValidator
14
+ from ..utils.html_format import escape_html
15
+
16
+ logger = structlog.get_logger()
17
+
18
+
19
+ def _is_within_root(path: Path, root: Path) -> bool:
20
+ """Check whether path is within root directory."""
21
+ try:
22
+ path.resolve().relative_to(root.resolve())
23
+ return True
24
+ except ValueError:
25
+ return False
26
+
27
+
28
+ def _get_thread_project_root(
29
+ settings: Settings, context: ContextTypes.DEFAULT_TYPE
30
+ ) -> Optional[Path]:
31
+ """Get thread project root when strict thread mode is active."""
32
+ if not settings.enable_project_threads:
33
+ return None
34
+ thread_context = context.user_data.get("_thread_context")
35
+ if not thread_context:
36
+ return None
37
+ return Path(thread_context["project_root"]).resolve()
38
+
39
+
40
+ async def handle_callback_query(
41
+ update: Update, context: ContextTypes.DEFAULT_TYPE
42
+ ) -> None:
43
+ """Route callback queries to appropriate handlers."""
44
+ query = update.callback_query
45
+ await query.answer() # Acknowledge the callback
46
+
47
+ user_id = query.from_user.id
48
+ data = query.data
49
+
50
+ logger.info("Processing callback query", user_id=user_id, callback_data=data)
51
+
52
+ try:
53
+ # Parse callback data
54
+ if ":" in data:
55
+ action, param = data.split(":", 1)
56
+ else:
57
+ action, param = data, None
58
+
59
+ # Route to appropriate handler
60
+ handlers = {
61
+ "cd": handle_cd_callback,
62
+ "action": handle_action_callback,
63
+ "agentic": handle_agentic_callback,
64
+ "confirm": handle_confirm_callback,
65
+ "quick": handle_quick_action_callback,
66
+ "followup": handle_followup_callback,
67
+ "conversation": handle_conversation_callback,
68
+ "git": handle_git_callback,
69
+ "export": handle_export_callback,
70
+ }
71
+
72
+ handler = handlers.get(action)
73
+ if handler:
74
+ await handler(query, param, context)
75
+ else:
76
+ await query.edit_message_text(
77
+ "❌ <b>Unknown Action</b>\n\n"
78
+ "This button action is not recognized. "
79
+ "The bot may have been updated since this message was sent.",
80
+ parse_mode="HTML",
81
+ )
82
+
83
+ except Exception as e:
84
+ logger.error(
85
+ "Error handling callback query",
86
+ error=str(e),
87
+ user_id=user_id,
88
+ callback_data=data,
89
+ )
90
+
91
+ try:
92
+ await query.edit_message_text(
93
+ "❌ <b>Error Processing Action</b>\n\n"
94
+ "An error occurred while processing your request.\n"
95
+ "Please try again or use text commands.",
96
+ parse_mode="HTML",
97
+ )
98
+ except Exception:
99
+ # If we can't edit the message, send a new one
100
+ await query.message.reply_text(
101
+ "❌ <b>Error Processing Action</b>\n\n"
102
+ "An error occurred while processing your request.",
103
+ parse_mode="HTML",
104
+ )
105
+
106
+
107
+ async def handle_agentic_callback(
108
+ query, action_type: str, context: ContextTypes.DEFAULT_TYPE
109
+ ) -> None:
110
+ """Handle agentic mode callbacks (debug, full_error, continue, new_task)."""
111
+ # Map agentic actions to existing action handlers
112
+ action_map = {
113
+ "continue": "continue",
114
+ "new_task": "new_session",
115
+ "run_tests": "start_coding",
116
+ "show_diff": "start_coding",
117
+ "status": "status",
118
+ }
119
+
120
+ # Simple redirect actions
121
+ if action_type in action_map:
122
+ mapped_action = action_map[action_type]
123
+ await handle_action_callback(query, mapped_action, context)
124
+ return
125
+
126
+ user_id = query.from_user.id
127
+ settings: Settings = context.bot_data["settings"]
128
+ claude_integration: ClaudeIntegration = context.bot_data.get("claude_integration")
129
+
130
+ if action_type == "full_error":
131
+ # Extract error text from the original message
132
+ original_text = query.message.text or query.message.text_html or ""
133
+ await query.edit_message_text(
134
+ f"<b>Full Error Details</b>\n\n"
135
+ f"<pre><code>{escape_html(original_text[:3500])}</code></pre>",
136
+ parse_mode="HTML",
137
+ reply_markup=InlineKeyboardMarkup([
138
+ [
139
+ InlineKeyboardButton("Debug", callback_data="agentic:debug"),
140
+ InlineKeyboardButton("New Session", callback_data="agentic:new_task"),
141
+ ]
142
+ ]),
143
+ )
144
+ return
145
+
146
+ if action_type == "debug":
147
+ if not claude_integration:
148
+ await query.edit_message_text(
149
+ "Claude integration is not available.",
150
+ parse_mode="HTML",
151
+ )
152
+ return
153
+
154
+ # Extract error from the original message to send to Claude for debugging
155
+ original_text = query.message.text or ""
156
+ current_dir = context.user_data.get(
157
+ "current_directory", settings.approved_directory
158
+ )
159
+ session_id = context.user_data.get("claude_session_id")
160
+
161
+ debug_prompt = (
162
+ f"The following error occurred. Analyze the root cause and suggest a fix:\n\n"
163
+ f"{original_text[:2000]}"
164
+ )
165
+
166
+ await query.edit_message_text(
167
+ "Analyzing error...",
168
+ parse_mode="HTML",
169
+ )
170
+
171
+ try:
172
+ claude_response = await claude_integration.run_command(
173
+ prompt=debug_prompt,
174
+ working_directory=current_dir,
175
+ user_id=user_id,
176
+ session_id=session_id,
177
+ )
178
+
179
+ if claude_response:
180
+ context.user_data["claude_session_id"] = claude_response.session_id
181
+ response_text = escape_html(claude_response.content[:3500])
182
+ await query.message.reply_text(
183
+ f"<b>Debug Analysis</b>\n\n{response_text}",
184
+ parse_mode="HTML",
185
+ reply_markup=InlineKeyboardMarkup([
186
+ [
187
+ InlineKeyboardButton("Continue", callback_data="agentic:continue"),
188
+ InlineKeyboardButton("New Session", callback_data="agentic:new_task"),
189
+ ]
190
+ ]),
191
+ )
192
+ else:
193
+ await query.edit_message_text(
194
+ "Failed to analyze the error. Try sending a new message.",
195
+ parse_mode="HTML",
196
+ )
197
+
198
+ except Exception as e:
199
+ logger.error("Debug analysis failed", error=str(e), user_id=user_id)
200
+ await query.edit_message_text(
201
+ f"Debug analysis failed: <code>{escape_html(str(e)[:200])}</code>\n\n"
202
+ "Try using /new to start a fresh session.",
203
+ parse_mode="HTML",
204
+ reply_markup=InlineKeyboardMarkup([
205
+ [InlineKeyboardButton("New Session", callback_data="agentic:new_task")]
206
+ ]),
207
+ )
208
+ return
209
+
210
+ # Unknown agentic action
211
+ await query.edit_message_text(
212
+ f"Unknown action: {escape_html(action_type)}",
213
+ parse_mode="HTML",
214
+ )
215
+
216
+
217
+ async def handle_cd_callback(
218
+ query, project_name: str, context: ContextTypes.DEFAULT_TYPE
219
+ ) -> None:
220
+ """Handle directory change from inline keyboard."""
221
+ user_id = query.from_user.id
222
+ settings: Settings = context.bot_data["settings"]
223
+ security_validator: SecurityValidator = context.bot_data.get("security_validator")
224
+ audit_logger: AuditLogger = context.bot_data.get("audit_logger")
225
+ claude_integration: ClaudeIntegration = context.bot_data.get("claude_integration")
226
+
227
+ try:
228
+ current_dir = context.user_data.get(
229
+ "current_directory", settings.approved_directory
230
+ )
231
+ project_root = _get_thread_project_root(settings, context)
232
+ directory_root = project_root or settings.approved_directory
233
+
234
+ # Handle special paths
235
+ if project_name == "/":
236
+ new_path = directory_root
237
+ elif project_name == "..":
238
+ new_path = current_dir.parent
239
+ if not _is_within_root(new_path, directory_root):
240
+ new_path = directory_root
241
+ else:
242
+ if project_root:
243
+ new_path = current_dir / project_name
244
+ else:
245
+ new_path = settings.approved_directory / project_name
246
+
247
+ # Validate path if security validator is available
248
+ if security_validator:
249
+ # Pass the absolute path for validation
250
+ valid, resolved_path, error = security_validator.validate_path(
251
+ str(new_path), settings.approved_directory
252
+ )
253
+ if not valid:
254
+ await query.edit_message_text(
255
+ f"❌ <b>Access Denied</b>\n\n{escape_html(error)}",
256
+ parse_mode="HTML",
257
+ )
258
+ return
259
+ # Use the validated path
260
+ new_path = resolved_path
261
+
262
+ if project_root and not _is_within_root(new_path, project_root):
263
+ await query.edit_message_text(
264
+ "❌ <b>Access Denied</b>\n\n"
265
+ "In thread mode, navigation is limited to the current project root.",
266
+ parse_mode="HTML",
267
+ )
268
+ return
269
+
270
+ # Check if directory exists
271
+ if not new_path.exists() or not new_path.is_dir():
272
+ await query.edit_message_text(
273
+ f"❌ <b>Directory Not Found</b>\n\n"
274
+ f"The directory <code>{escape_html(project_name)}</code> no longer exists or is not accessible.",
275
+ parse_mode="HTML",
276
+ )
277
+ return
278
+
279
+ # Update directory and resume session for that directory when available
280
+ context.user_data["current_directory"] = new_path
281
+
282
+ resumed_session_info = ""
283
+ if claude_integration:
284
+ existing_session = await claude_integration._find_resumable_session(
285
+ user_id, new_path
286
+ )
287
+ if existing_session:
288
+ context.user_data["claude_session_id"] = existing_session.session_id
289
+ resumed_session_info = (
290
+ f"\n🔄 Resumed session <code>{escape_html(existing_session.session_id[:8])}...</code> "
291
+ f"({existing_session.message_count} messages)"
292
+ )
293
+ else:
294
+ context.user_data["claude_session_id"] = None
295
+ resumed_session_info = (
296
+ "\n🆕 No existing session. Send a message to start a new one."
297
+ )
298
+ else:
299
+ context.user_data["claude_session_id"] = None
300
+ resumed_session_info = "\n🆕 Send a message to start a new session."
301
+
302
+ # Send confirmation with new directory info
303
+ relative_base = project_root or settings.approved_directory
304
+ relative_path = new_path.relative_to(relative_base)
305
+ relative_display = "/" if str(relative_path) == "." else f"{relative_path}/"
306
+
307
+ # Add navigation buttons
308
+ keyboard = [
309
+ [
310
+ InlineKeyboardButton("📁 List Files", callback_data="action:ls"),
311
+ InlineKeyboardButton(
312
+ "🆕 New Session", callback_data="action:new_session"
313
+ ),
314
+ ],
315
+ [
316
+ InlineKeyboardButton(
317
+ "📋 Projects", callback_data="action:show_projects"
318
+ ),
319
+ InlineKeyboardButton("📊 Status", callback_data="action:status"),
320
+ ],
321
+ ]
322
+ reply_markup = InlineKeyboardMarkup(keyboard)
323
+
324
+ await query.edit_message_text(
325
+ f"✅ <b>Directory Changed</b>\n\n"
326
+ f"📂 Current directory: <code>{escape_html(str(relative_display))}</code>"
327
+ f"{resumed_session_info}",
328
+ parse_mode="HTML",
329
+ reply_markup=reply_markup,
330
+ )
331
+
332
+ # Log successful directory change
333
+ if audit_logger:
334
+ await audit_logger.log_command(
335
+ user_id=user_id, command="cd", args=[project_name], success=True
336
+ )
337
+
338
+ except Exception as e:
339
+ await query.edit_message_text(
340
+ f"❌ <b>Error changing directory</b>\n\n{escape_html(str(e))}",
341
+ parse_mode="HTML",
342
+ )
343
+
344
+ if audit_logger:
345
+ await audit_logger.log_command(
346
+ user_id=user_id, command="cd", args=[project_name], success=False
347
+ )
348
+
349
+
350
+ async def handle_action_callback(
351
+ query, action_type: str, context: ContextTypes.DEFAULT_TYPE
352
+ ) -> None:
353
+ """Handle general action callbacks."""
354
+ actions = {
355
+ "help": _handle_help_action,
356
+ "show_projects": _handle_show_projects_action,
357
+ "new_session": _handle_new_session_action,
358
+ "continue": _handle_continue_action,
359
+ "end_session": _handle_end_session_action,
360
+ "status": _handle_status_action,
361
+ "ls": _handle_ls_action,
362
+ "start_coding": _handle_start_coding_action,
363
+ "quick_actions": _handle_quick_actions_action,
364
+ "refresh_status": _handle_refresh_status_action,
365
+ "refresh_ls": _handle_refresh_ls_action,
366
+ "export": _handle_export_action,
367
+ }
368
+
369
+ handler = actions.get(action_type)
370
+ if handler:
371
+ await handler(query, context)
372
+ else:
373
+ await query.edit_message_text(
374
+ f"❌ <b>Unknown Action: {escape_html(action_type)}</b>\n\n"
375
+ "This action is not implemented yet.",
376
+ parse_mode="HTML",
377
+ )
378
+
379
+
380
+ async def handle_confirm_callback(
381
+ query, confirmation_type: str, context: ContextTypes.DEFAULT_TYPE
382
+ ) -> None:
383
+ """Handle confirmation dialogs."""
384
+ if confirmation_type == "yes":
385
+ await query.edit_message_text(
386
+ "✅ <b>Confirmed</b>\n\nAction will be processed.",
387
+ parse_mode="HTML",
388
+ )
389
+ elif confirmation_type == "no":
390
+ await query.edit_message_text(
391
+ "❌ <b>Cancelled</b>\n\nAction was cancelled.",
392
+ parse_mode="HTML",
393
+ )
394
+ else:
395
+ await query.edit_message_text(
396
+ "❓ <b>Unknown confirmation response</b>",
397
+ parse_mode="HTML",
398
+ )
399
+
400
+
401
+ # Action handlers
402
+
403
+
404
+ async def _handle_help_action(query, context: ContextTypes.DEFAULT_TYPE) -> None:
405
+ """Handle help action."""
406
+ help_text = (
407
+ "🤖 <b>Quick Help</b>\n\n"
408
+ "<b>Navigation:</b>\n"
409
+ "• <code>/ls</code> - List files\n"
410
+ "• <code>/cd &lt;dir&gt;</code> - Change directory\n"
411
+ "• <code>/projects</code> - Show projects\n\n"
412
+ "<b>Sessions:</b>\n"
413
+ "• <code>/new</code> - New Claude session\n"
414
+ "• <code>/status</code> - Session status\n\n"
415
+ "<b>Tips:</b>\n"
416
+ "• Send any text to interact with Claude\n"
417
+ "• Upload files for code review\n"
418
+ "• Use buttons for quick actions\n\n"
419
+ "Use <code>/help</code> for detailed help."
420
+ )
421
+
422
+ keyboard = [
423
+ [
424
+ InlineKeyboardButton("📖 Full Help", callback_data="action:full_help"),
425
+ InlineKeyboardButton("🏠 Main Menu", callback_data="action:main_menu"),
426
+ ]
427
+ ]
428
+ reply_markup = InlineKeyboardMarkup(keyboard)
429
+
430
+ await query.edit_message_text(
431
+ help_text, parse_mode="HTML", reply_markup=reply_markup
432
+ )
433
+
434
+
435
+ async def _handle_show_projects_action(
436
+ query, context: ContextTypes.DEFAULT_TYPE
437
+ ) -> None:
438
+ """Handle show projects action."""
439
+ settings: Settings = context.bot_data["settings"]
440
+
441
+ try:
442
+ if settings.enable_project_threads:
443
+ registry = context.bot_data.get("project_registry")
444
+ if not registry:
445
+ await query.edit_message_text(
446
+ "❌ <b>Project registry is not initialized.</b>",
447
+ parse_mode="HTML",
448
+ )
449
+ return
450
+
451
+ projects = registry.list_enabled()
452
+ if not projects:
453
+ await query.edit_message_text(
454
+ "📁 <b>No Projects Found</b>\n\n"
455
+ "No enabled projects found in projects config.",
456
+ parse_mode="HTML",
457
+ )
458
+ return
459
+
460
+ project_list = "\n".join(
461
+ [
462
+ f"• <b>{escape_html(p.name)}</b> "
463
+ f"(<code>{escape_html(p.slug)}</code>) "
464
+ f"→ <code>{escape_html(str(p.relative_path))}</code>"
465
+ for p in projects
466
+ ]
467
+ )
468
+
469
+ await query.edit_message_text(
470
+ f"📁 <b>Configured Projects</b>\n\n{project_list}",
471
+ parse_mode="HTML",
472
+ )
473
+ return
474
+
475
+ # Get directories in approved directory
476
+ projects = []
477
+ for item in sorted(settings.approved_directory.iterdir()):
478
+ if item.is_dir() and not item.name.startswith("."):
479
+ projects.append(item.name)
480
+
481
+ if not projects:
482
+ await query.edit_message_text(
483
+ "📁 <b>No Projects Found</b>\n\n"
484
+ "No subdirectories found in your approved directory.\n"
485
+ "Create some directories to organize your projects!",
486
+ parse_mode="HTML",
487
+ )
488
+ return
489
+
490
+ # Create project buttons
491
+ keyboard = []
492
+ for i in range(0, len(projects), 2):
493
+ row = []
494
+ for j in range(2):
495
+ if i + j < len(projects):
496
+ project = projects[i + j]
497
+ row.append(
498
+ InlineKeyboardButton(
499
+ f"📁 {project}", callback_data=f"cd:{project}"
500
+ )
501
+ )
502
+ keyboard.append(row)
503
+
504
+ # Add navigation buttons
505
+ keyboard.append(
506
+ [
507
+ InlineKeyboardButton("🏠 Root", callback_data="cd:/"),
508
+ InlineKeyboardButton(
509
+ "🔄 Refresh", callback_data="action:show_projects"
510
+ ),
511
+ ]
512
+ )
513
+
514
+ reply_markup = InlineKeyboardMarkup(keyboard)
515
+ project_list = "\n".join(
516
+ [f"• <code>{escape_html(project)}/</code>" for project in projects]
517
+ )
518
+
519
+ await query.edit_message_text(
520
+ f"📁 <b>Available Projects</b>\n\n"
521
+ f"{project_list}\n\n"
522
+ f"Click a project to navigate to it:",
523
+ parse_mode="HTML",
524
+ reply_markup=reply_markup,
525
+ )
526
+
527
+ except Exception as e:
528
+ await query.edit_message_text(f"❌ Error loading projects: {str(e)}")
529
+
530
+
531
+ async def _handle_new_session_action(query, context: ContextTypes.DEFAULT_TYPE) -> None:
532
+ """Handle new session action."""
533
+ settings: Settings = context.bot_data["settings"]
534
+
535
+ # Clear session and force new on next message
536
+ context.user_data["claude_session_id"] = None
537
+ context.user_data["session_started"] = True
538
+ context.user_data["force_new_session"] = True
539
+
540
+ current_dir = context.user_data.get(
541
+ "current_directory", settings.approved_directory
542
+ )
543
+ relative_path = current_dir.relative_to(settings.approved_directory)
544
+
545
+ keyboard = [
546
+ [
547
+ InlineKeyboardButton(
548
+ "📝 Start Coding", callback_data="action:start_coding"
549
+ ),
550
+ InlineKeyboardButton(
551
+ "📁 Change Project", callback_data="action:show_projects"
552
+ ),
553
+ ],
554
+ [
555
+ InlineKeyboardButton(
556
+ "📋 Quick Actions", callback_data="action:quick_actions"
557
+ ),
558
+ InlineKeyboardButton("❓ Help", callback_data="action:help"),
559
+ ],
560
+ ]
561
+ reply_markup = InlineKeyboardMarkup(keyboard)
562
+
563
+ await query.edit_message_text(
564
+ f"🆕 <b>New Claude Code Session</b>\n\n"
565
+ f"📂 Working directory: <code>{escape_html(str(relative_path))}/</code>\n\n"
566
+ f"Ready to help you code! Send me a message to get started:",
567
+ parse_mode="HTML",
568
+ reply_markup=reply_markup,
569
+ )
570
+
571
+
572
+ async def _handle_end_session_action(query, context: ContextTypes.DEFAULT_TYPE) -> None:
573
+ """Handle end session action."""
574
+ settings: Settings = context.bot_data["settings"]
575
+
576
+ # Check if there's an active session
577
+ claude_session_id = context.user_data.get("claude_session_id")
578
+
579
+ if not claude_session_id:
580
+ await query.edit_message_text(
581
+ "ℹ️ <b>No Active Session</b>\n\n"
582
+ "There's no active Claude session to end.\n\n"
583
+ "<b>What you can do:</b>\n"
584
+ "• Use the button below to start a new session\n"
585
+ "• Check your session status\n"
586
+ "• Send any message to start a conversation",
587
+ parse_mode="HTML",
588
+ reply_markup=InlineKeyboardMarkup(
589
+ [
590
+ [
591
+ InlineKeyboardButton(
592
+ "🆕 New Session", callback_data="action:new_session"
593
+ )
594
+ ],
595
+ [InlineKeyboardButton("📊 Status", callback_data="action:status")],
596
+ ]
597
+ ),
598
+ )
599
+ return
600
+
601
+ # Get current directory for display
602
+ current_dir = context.user_data.get(
603
+ "current_directory", settings.approved_directory
604
+ )
605
+ relative_path = current_dir.relative_to(settings.approved_directory)
606
+
607
+ # Clear session data
608
+ context.user_data["claude_session_id"] = None
609
+ context.user_data["session_started"] = False
610
+ context.user_data["last_message"] = None
611
+
612
+ # Create quick action buttons
613
+ keyboard = [
614
+ [
615
+ InlineKeyboardButton("🆕 New Session", callback_data="action:new_session"),
616
+ InlineKeyboardButton(
617
+ "📁 Change Project", callback_data="action:show_projects"
618
+ ),
619
+ ],
620
+ [
621
+ InlineKeyboardButton("📊 Status", callback_data="action:status"),
622
+ InlineKeyboardButton("❓ Help", callback_data="action:help"),
623
+ ],
624
+ ]
625
+ reply_markup = InlineKeyboardMarkup(keyboard)
626
+
627
+ await query.edit_message_text(
628
+ "✅ <b>Session Ended</b>\n\n"
629
+ f"Your Claude session has been terminated.\n\n"
630
+ f"<b>Current Status:</b>\n"
631
+ f"• Directory: <code>{escape_html(str(relative_path))}/</code>\n"
632
+ f"• Session: None\n"
633
+ f"• Ready for new commands\n\n"
634
+ f"<b>Next Steps:</b>\n"
635
+ f"• Start a new session\n"
636
+ f"• Check status\n"
637
+ f"• Send any message to begin a new conversation",
638
+ parse_mode="HTML",
639
+ reply_markup=reply_markup,
640
+ )
641
+
642
+
643
+ async def _handle_continue_action(query, context: ContextTypes.DEFAULT_TYPE) -> None:
644
+ """Handle continue session action."""
645
+ user_id = query.from_user.id
646
+ settings: Settings = context.bot_data["settings"]
647
+ claude_integration: ClaudeIntegration = context.bot_data.get("claude_integration")
648
+
649
+ current_dir = context.user_data.get(
650
+ "current_directory", settings.approved_directory
651
+ )
652
+
653
+ try:
654
+ if not claude_integration:
655
+ await query.edit_message_text(
656
+ "❌ <b>Claude Integration Not Available</b>\n\n"
657
+ "Claude integration is not properly configured.",
658
+ parse_mode="HTML",
659
+ )
660
+ return
661
+
662
+ # Check if there's an existing session in user context
663
+ claude_session_id = context.user_data.get("claude_session_id")
664
+
665
+ if claude_session_id:
666
+ # Continue with the existing session (no prompt = use --continue)
667
+ await query.edit_message_text(
668
+ f"🔄 <b>Continuing Session</b>\n\n"
669
+ f"Session ID: <code>{escape_html(claude_session_id[:8])}...</code>\n"
670
+ f"Directory: <code>{escape_html(str(current_dir.relative_to(settings.approved_directory)))}/</code>\n\n"
671
+ f"Continuing where you left off...",
672
+ parse_mode="HTML",
673
+ )
674
+
675
+ claude_response = await claude_integration.run_command(
676
+ prompt="", # Empty prompt triggers --continue
677
+ working_directory=current_dir,
678
+ user_id=user_id,
679
+ session_id=claude_session_id,
680
+ )
681
+ else:
682
+ # No session in context, try to find the most recent session
683
+ await query.edit_message_text(
684
+ "🔍 <b>Looking for Recent Session</b>\n\n"
685
+ "Searching for your most recent session in this directory...",
686
+ parse_mode="HTML",
687
+ )
688
+
689
+ claude_response = await claude_integration.continue_session(
690
+ user_id=user_id,
691
+ working_directory=current_dir,
692
+ prompt=None, # No prompt = use --continue
693
+ )
694
+
695
+ if claude_response:
696
+ # Update session ID in context
697
+ context.user_data["claude_session_id"] = claude_response.session_id
698
+
699
+ # Send Claude's response
700
+ await query.message.reply_text(
701
+ f"✅ <b>Session Continued</b>\n\n"
702
+ f"{escape_html(claude_response.content[:500])}{'...' if len(claude_response.content) > 500 else ''}",
703
+ parse_mode="HTML",
704
+ )
705
+ else:
706
+ # No session found to continue
707
+ await query.edit_message_text(
708
+ "❌ <b>No Session Found</b>\n\n"
709
+ f"No recent Claude session found in this directory.\n"
710
+ f"Directory: <code>{escape_html(str(current_dir.relative_to(settings.approved_directory)))}/</code>\n\n"
711
+ f"<b>What you can do:</b>\n"
712
+ f"• Use the button below to start a fresh session\n"
713
+ f"• Check your session status\n"
714
+ f"• Navigate to a different directory",
715
+ parse_mode="HTML",
716
+ reply_markup=InlineKeyboardMarkup(
717
+ [
718
+ [
719
+ InlineKeyboardButton(
720
+ "🆕 New Session", callback_data="action:new_session"
721
+ ),
722
+ InlineKeyboardButton(
723
+ "📊 Status", callback_data="action:status"
724
+ ),
725
+ ]
726
+ ]
727
+ ),
728
+ )
729
+
730
+ except Exception as e:
731
+ logger.error("Error in continue action", error=str(e), user_id=user_id)
732
+ await query.edit_message_text(
733
+ f"❌ <b>Error Continuing Session</b>\n\n"
734
+ f"An error occurred: <code>{escape_html(str(e))}</code>\n\n"
735
+ f"Try starting a new session instead.",
736
+ parse_mode="HTML",
737
+ reply_markup=InlineKeyboardMarkup(
738
+ [
739
+ [
740
+ InlineKeyboardButton(
741
+ "🆕 New Session", callback_data="action:new_session"
742
+ )
743
+ ]
744
+ ]
745
+ ),
746
+ )
747
+
748
+
749
+ async def _handle_status_action(query, context: ContextTypes.DEFAULT_TYPE) -> None:
750
+ """Handle status action."""
751
+ # This essentially duplicates the /status command functionality
752
+ user_id = query.from_user.id
753
+ settings: Settings = context.bot_data["settings"]
754
+
755
+ claude_session_id = context.user_data.get("claude_session_id")
756
+ current_dir = context.user_data.get(
757
+ "current_directory", settings.approved_directory
758
+ )
759
+ relative_path = current_dir.relative_to(settings.approved_directory)
760
+
761
+ # Get session usage info
762
+ claude_integration: ClaudeIntegration = context.bot_data.get("claude_integration")
763
+ usage_info = ""
764
+ if claude_integration and claude_session_id:
765
+ try:
766
+ session_info = await claude_integration.session_manager.get_session_info(
767
+ claude_session_id
768
+ )
769
+ if session_info:
770
+ turns = session_info.get("turns", 0)
771
+ msgs = session_info.get("messages", 0)
772
+ tools = session_info.get("tools_used", [])
773
+ usage_info = (
774
+ f"📈 Session: {msgs} messages, {turns} turns\n"
775
+ f"🔧 Tools: {', '.join(tools[:5]) if tools else 'None'}\n"
776
+ )
777
+ except Exception:
778
+ usage_info = ""
779
+
780
+ status_lines = [
781
+ "📊 <b>Session Status</b>",
782
+ "",
783
+ f"📂 Directory: <code>{escape_html(str(relative_path))}/</code>",
784
+ f"🤖 Claude Session: {'✅ Active' if claude_session_id else '❌ None'}",
785
+ usage_info.rstrip(),
786
+ ]
787
+
788
+ if claude_session_id:
789
+ status_lines.append(
790
+ f"🆔 Session ID: <code>{escape_html(claude_session_id[:8])}...</code>"
791
+ )
792
+
793
+ # Add action buttons
794
+ keyboard = []
795
+ if claude_session_id:
796
+ keyboard.append(
797
+ [
798
+ InlineKeyboardButton("🔄 Continue", callback_data="action:continue"),
799
+ InlineKeyboardButton(
800
+ "🛑 End Session", callback_data="action:end_session"
801
+ ),
802
+ ]
803
+ )
804
+ keyboard.append(
805
+ [
806
+ InlineKeyboardButton(
807
+ "🆕 New Session", callback_data="action:new_session"
808
+ ),
809
+ ]
810
+ )
811
+ else:
812
+ keyboard.append(
813
+ [
814
+ InlineKeyboardButton(
815
+ "🆕 Start Session", callback_data="action:new_session"
816
+ )
817
+ ]
818
+ )
819
+
820
+ keyboard.append(
821
+ [
822
+ InlineKeyboardButton("🔄 Refresh", callback_data="action:refresh_status"),
823
+ InlineKeyboardButton("📁 Projects", callback_data="action:show_projects"),
824
+ ]
825
+ )
826
+
827
+ reply_markup = InlineKeyboardMarkup(keyboard)
828
+
829
+ await query.edit_message_text(
830
+ "\n".join(status_lines), parse_mode="HTML", reply_markup=reply_markup
831
+ )
832
+
833
+
834
+ async def _handle_ls_action(query, context: ContextTypes.DEFAULT_TYPE) -> None:
835
+ """Handle ls action."""
836
+ settings: Settings = context.bot_data["settings"]
837
+ current_dir = context.user_data.get(
838
+ "current_directory", settings.approved_directory
839
+ )
840
+
841
+ try:
842
+ # List directory contents (similar to /ls command)
843
+ items = []
844
+ directories = []
845
+ files = []
846
+
847
+ for item in sorted(current_dir.iterdir()):
848
+ if item.name.startswith("."):
849
+ continue
850
+
851
+ # Escape markdown special characters in filenames
852
+ safe_name = _escape_markdown(item.name)
853
+
854
+ if item.is_dir():
855
+ directories.append(f"📁 {safe_name}/")
856
+ else:
857
+ try:
858
+ size = item.stat().st_size
859
+ size_str = _format_file_size(size)
860
+ files.append(f"📄 {safe_name} ({size_str})")
861
+ except OSError:
862
+ files.append(f"📄 {safe_name}")
863
+
864
+ items = directories + files
865
+ relative_path = current_dir.relative_to(settings.approved_directory)
866
+
867
+ if not items:
868
+ message = f"📂 <code>{escape_html(str(relative_path))}/</code>\n\n<i>(empty directory)</i>"
869
+ else:
870
+ message = f"📂 <code>{escape_html(str(relative_path))}/</code>\n\n"
871
+ max_items = 30 # Limit for inline display
872
+ if len(items) > max_items:
873
+ shown_items = items[:max_items]
874
+ message += "\n".join(shown_items)
875
+ message += f"\n\n<i>... and {len(items) - max_items} more items</i>"
876
+ else:
877
+ message += "\n".join(items)
878
+
879
+ # Add buttons
880
+ keyboard = []
881
+ if current_dir != settings.approved_directory:
882
+ keyboard.append(
883
+ [
884
+ InlineKeyboardButton("⬆️ Go Up", callback_data="cd:.."),
885
+ InlineKeyboardButton("🏠 Root", callback_data="cd:/"),
886
+ ]
887
+ )
888
+
889
+ keyboard.append(
890
+ [
891
+ InlineKeyboardButton("🔄 Refresh", callback_data="action:refresh_ls"),
892
+ InlineKeyboardButton(
893
+ "📋 Projects", callback_data="action:show_projects"
894
+ ),
895
+ ]
896
+ )
897
+
898
+ reply_markup = InlineKeyboardMarkup(keyboard)
899
+
900
+ await query.edit_message_text(
901
+ message, parse_mode="HTML", reply_markup=reply_markup
902
+ )
903
+
904
+ except Exception as e:
905
+ await query.edit_message_text(f"❌ Error listing directory: {str(e)}")
906
+
907
+
908
+ async def _handle_start_coding_action(
909
+ query, context: ContextTypes.DEFAULT_TYPE
910
+ ) -> None:
911
+ """Handle start coding action."""
912
+ await query.edit_message_text(
913
+ "🚀 <b>Ready to Code!</b>\n\n"
914
+ "Send me any message to start coding with Claude:\n\n"
915
+ "<b>Examples:</b>\n"
916
+ '• <i>"Create a Python script that..."</i>\n'
917
+ '• <i>"Help me debug this code..."</i>\n'
918
+ '• <i>"Explain how this file works..."</i>\n'
919
+ "• Upload a file for review\n\n"
920
+ "I'm here to help with all your coding needs!",
921
+ parse_mode="HTML",
922
+ )
923
+
924
+
925
+ async def _handle_quick_actions_action(
926
+ query, context: ContextTypes.DEFAULT_TYPE
927
+ ) -> None:
928
+ """Handle quick actions menu."""
929
+ keyboard = [
930
+ [
931
+ InlineKeyboardButton("🧪 Run Tests", callback_data="quick:test"),
932
+ InlineKeyboardButton("📦 Install Deps", callback_data="quick:install"),
933
+ ],
934
+ [
935
+ InlineKeyboardButton("🎨 Format Code", callback_data="quick:format"),
936
+ InlineKeyboardButton("🔍 Find TODOs", callback_data="quick:find_todos"),
937
+ ],
938
+ [
939
+ InlineKeyboardButton("🔨 Build", callback_data="quick:build"),
940
+ InlineKeyboardButton("🚀 Start Server", callback_data="quick:start"),
941
+ ],
942
+ [
943
+ InlineKeyboardButton("📊 Git Status", callback_data="quick:git_status"),
944
+ InlineKeyboardButton("🔧 Lint Code", callback_data="quick:lint"),
945
+ ],
946
+ [InlineKeyboardButton("⬅️ Back", callback_data="action:new_session")],
947
+ ]
948
+ reply_markup = InlineKeyboardMarkup(keyboard)
949
+
950
+ await query.edit_message_text(
951
+ "🛠️ <b>Quick Actions</b>\n\n"
952
+ "Choose a common development task:\n\n"
953
+ "<i>Note: These will be fully functional once Claude Code integration is complete.</i>",
954
+ parse_mode="HTML",
955
+ reply_markup=reply_markup,
956
+ )
957
+
958
+
959
+ async def _handle_refresh_status_action(
960
+ query, context: ContextTypes.DEFAULT_TYPE
961
+ ) -> None:
962
+ """Handle refresh status action."""
963
+ await _handle_status_action(query, context)
964
+
965
+
966
+ async def _handle_refresh_ls_action(query, context: ContextTypes.DEFAULT_TYPE) -> None:
967
+ """Handle refresh ls action."""
968
+ await _handle_ls_action(query, context)
969
+
970
+
971
+ async def _handle_export_action(query, context: ContextTypes.DEFAULT_TYPE) -> None:
972
+ """Handle export action."""
973
+ await query.edit_message_text(
974
+ "📤 <b>Export Session</b>\n\n"
975
+ "Session export functionality will be available once the storage layer is implemented.\n\n"
976
+ "<b>Planned features:</b>\n"
977
+ "• Export conversation history\n"
978
+ "• Save session state\n"
979
+ "• Share conversations\n"
980
+ "• Create session backups\n\n"
981
+ "<i>Coming in the next development phase!</i>",
982
+ parse_mode="HTML",
983
+ )
984
+
985
+
986
+ async def handle_quick_action_callback(
987
+ query, action_id: str, context: ContextTypes.DEFAULT_TYPE
988
+ ) -> None:
989
+ """Handle quick action callbacks."""
990
+ user_id = query.from_user.id
991
+
992
+ # Get quick actions manager from bot data if available
993
+ quick_actions = context.bot_data.get("quick_actions")
994
+
995
+ if not quick_actions:
996
+ await query.edit_message_text(
997
+ "❌ <b>Quick Actions Not Available</b>\n\n"
998
+ "Quick actions feature is not available.",
999
+ parse_mode="HTML",
1000
+ )
1001
+ return
1002
+
1003
+ # Get Claude integration
1004
+ claude_integration: ClaudeIntegration = context.bot_data.get("claude_integration")
1005
+ if not claude_integration:
1006
+ await query.edit_message_text(
1007
+ "❌ <b>Claude Integration Not Available</b>\n\n"
1008
+ "Claude integration is not properly configured.",
1009
+ parse_mode="HTML",
1010
+ )
1011
+ return
1012
+
1013
+ settings: Settings = context.bot_data["settings"]
1014
+ current_dir = context.user_data.get(
1015
+ "current_directory", settings.approved_directory
1016
+ )
1017
+
1018
+ try:
1019
+ # Get the action from the manager
1020
+ action = quick_actions.actions.get(action_id)
1021
+ if not action:
1022
+ await query.edit_message_text(
1023
+ f"❌ <b>Action Not Found</b>\n\n"
1024
+ f"Quick action '{escape_html(action_id)}' is not available.",
1025
+ parse_mode="HTML",
1026
+ )
1027
+ return
1028
+
1029
+ # Execute the action
1030
+ await query.edit_message_text(
1031
+ f"🚀 <b>Executing {action.icon} {escape_html(action.name)}</b>\n\n"
1032
+ f"Running quick action in directory: <code>{escape_html(str(current_dir.relative_to(settings.approved_directory)))}/</code>\n\n"
1033
+ f"Please wait...",
1034
+ parse_mode="HTML",
1035
+ )
1036
+
1037
+ # Run the action through Claude
1038
+ claude_response = await claude_integration.run_command(
1039
+ prompt=action.prompt, working_directory=current_dir, user_id=user_id
1040
+ )
1041
+
1042
+ if claude_response:
1043
+ # Format and send the response
1044
+ response_text = escape_html(claude_response.content)
1045
+ if len(response_text) > 4000:
1046
+ response_text = (
1047
+ response_text[:4000] + "...\n\n<i>(Response truncated)</i>"
1048
+ )
1049
+
1050
+ await query.message.reply_text(
1051
+ f"✅ <b>{action.icon} {escape_html(action.name)} Complete</b>\n\n{response_text}",
1052
+ parse_mode="HTML",
1053
+ )
1054
+ else:
1055
+ await query.edit_message_text(
1056
+ f"❌ <b>Action Failed</b>\n\n"
1057
+ f"Failed to execute {escape_html(action.name)}. Please try again.",
1058
+ parse_mode="HTML",
1059
+ )
1060
+
1061
+ except Exception as e:
1062
+ logger.error("Quick action execution failed", error=str(e), user_id=user_id)
1063
+ await query.edit_message_text(
1064
+ f"❌ <b>Action Error</b>\n\n"
1065
+ f"An error occurred while executing {escape_html(action_id)}: {escape_html(str(e))}",
1066
+ parse_mode="HTML",
1067
+ )
1068
+
1069
+
1070
+ async def handle_followup_callback(
1071
+ query, suggestion_hash: str, context: ContextTypes.DEFAULT_TYPE
1072
+ ) -> None:
1073
+ """Handle follow-up suggestion callbacks."""
1074
+ user_id = query.from_user.id
1075
+
1076
+ # Get conversation enhancer from bot data if available
1077
+ conversation_enhancer = context.bot_data.get("conversation_enhancer")
1078
+
1079
+ if not conversation_enhancer:
1080
+ await query.edit_message_text(
1081
+ "❌ <b>Follow-up Not Available</b>\n\n"
1082
+ "Conversation enhancement features are not available.",
1083
+ parse_mode="HTML",
1084
+ )
1085
+ return
1086
+
1087
+ try:
1088
+ # Get stored suggestions (this would need to be implemented in the enhancer)
1089
+ # For now, we'll provide a generic response
1090
+ await query.edit_message_text(
1091
+ "💡 <b>Follow-up Suggestion Selected</b>\n\n"
1092
+ "This follow-up suggestion will be implemented once the conversation "
1093
+ "enhancement system is fully integrated with the message handler.\n\n"
1094
+ "<b>Current Status:</b>\n"
1095
+ "• Suggestion received ✅\n"
1096
+ "• Integration pending 🔄\n\n"
1097
+ "<i>You can continue the conversation by sending a new message.</i>",
1098
+ parse_mode="HTML",
1099
+ )
1100
+
1101
+ logger.info(
1102
+ "Follow-up suggestion selected",
1103
+ user_id=user_id,
1104
+ suggestion_hash=suggestion_hash,
1105
+ )
1106
+
1107
+ except Exception as e:
1108
+ logger.error(
1109
+ "Error handling follow-up callback",
1110
+ error=str(e),
1111
+ user_id=user_id,
1112
+ suggestion_hash=suggestion_hash,
1113
+ )
1114
+
1115
+ await query.edit_message_text(
1116
+ "❌ <b>Error Processing Follow-up</b>\n\n"
1117
+ "An error occurred while processing your follow-up suggestion.",
1118
+ parse_mode="HTML",
1119
+ )
1120
+
1121
+
1122
+ async def handle_conversation_callback(
1123
+ query, action_type: str, context: ContextTypes.DEFAULT_TYPE
1124
+ ) -> None:
1125
+ """Handle conversation control callbacks."""
1126
+ user_id = query.from_user.id
1127
+ settings: Settings = context.bot_data["settings"]
1128
+
1129
+ if action_type == "continue":
1130
+ # Remove suggestion buttons and show continue message
1131
+ await query.edit_message_text(
1132
+ "✅ <b>Continuing Conversation</b>\n\n"
1133
+ "Send me your next message to continue coding!\n\n"
1134
+ "I'm ready to help with:\n"
1135
+ "• Code review and debugging\n"
1136
+ "• Feature implementation\n"
1137
+ "• Architecture decisions\n"
1138
+ "• Testing and optimization\n"
1139
+ "• Documentation\n\n"
1140
+ "<i>Just type your request or upload files.</i>",
1141
+ parse_mode="HTML",
1142
+ )
1143
+
1144
+ elif action_type == "end":
1145
+ # End the current session
1146
+ conversation_enhancer = context.bot_data.get("conversation_enhancer")
1147
+ if conversation_enhancer:
1148
+ conversation_enhancer.clear_context(user_id)
1149
+
1150
+ # Clear session data
1151
+ context.user_data["claude_session_id"] = None
1152
+ context.user_data["session_started"] = False
1153
+
1154
+ current_dir = context.user_data.get(
1155
+ "current_directory", settings.approved_directory
1156
+ )
1157
+ relative_path = current_dir.relative_to(settings.approved_directory)
1158
+
1159
+ # Create quick action buttons
1160
+ keyboard = [
1161
+ [
1162
+ InlineKeyboardButton(
1163
+ "🆕 New Session", callback_data="action:new_session"
1164
+ ),
1165
+ InlineKeyboardButton(
1166
+ "📁 Change Project", callback_data="action:show_projects"
1167
+ ),
1168
+ ],
1169
+ [
1170
+ InlineKeyboardButton("📊 Status", callback_data="action:status"),
1171
+ InlineKeyboardButton("❓ Help", callback_data="action:help"),
1172
+ ],
1173
+ ]
1174
+ reply_markup = InlineKeyboardMarkup(keyboard)
1175
+
1176
+ await query.edit_message_text(
1177
+ "✅ <b>Conversation Ended</b>\n\n"
1178
+ f"Your Claude session has been terminated.\n\n"
1179
+ f"<b>Current Status:</b>\n"
1180
+ f"• Directory: <code>{escape_html(str(relative_path))}/</code>\n"
1181
+ f"• Session: None\n"
1182
+ f"• Ready for new commands\n\n"
1183
+ f"<b>Next Steps:</b>\n"
1184
+ f"• Start a new session\n"
1185
+ f"• Check status\n"
1186
+ f"• Send any message to begin a new conversation",
1187
+ parse_mode="HTML",
1188
+ reply_markup=reply_markup,
1189
+ )
1190
+
1191
+ logger.info("Conversation ended via callback", user_id=user_id)
1192
+
1193
+ else:
1194
+ await query.edit_message_text(
1195
+ f"❌ <b>Unknown Conversation Action: {escape_html(action_type)}</b>\n\n"
1196
+ "This conversation action is not recognized.",
1197
+ parse_mode="HTML",
1198
+ )
1199
+
1200
+
1201
+ async def handle_git_callback(
1202
+ query, git_action: str, context: ContextTypes.DEFAULT_TYPE
1203
+ ) -> None:
1204
+ """Handle git-related callbacks."""
1205
+ user_id = query.from_user.id
1206
+ settings: Settings = context.bot_data["settings"]
1207
+ features = context.bot_data.get("features")
1208
+
1209
+ if not features or not features.is_enabled("git"):
1210
+ await query.edit_message_text(
1211
+ "❌ <b>Git Integration Disabled</b>\n\n"
1212
+ "Git integration feature is not enabled.",
1213
+ parse_mode="HTML",
1214
+ )
1215
+ return
1216
+
1217
+ current_dir = context.user_data.get(
1218
+ "current_directory", settings.approved_directory
1219
+ )
1220
+
1221
+ try:
1222
+ git_integration = features.get_git_integration()
1223
+ if not git_integration:
1224
+ await query.edit_message_text(
1225
+ "❌ <b>Git Integration Unavailable</b>\n\n"
1226
+ "Git integration service is not available.",
1227
+ parse_mode="HTML",
1228
+ )
1229
+ return
1230
+
1231
+ if git_action == "status":
1232
+ # Refresh git status
1233
+ git_status = await git_integration.get_status(current_dir)
1234
+ status_message = git_integration.format_status(git_status)
1235
+
1236
+ keyboard = [
1237
+ [
1238
+ InlineKeyboardButton("📊 Show Diff", callback_data="git:diff"),
1239
+ InlineKeyboardButton("📜 Show Log", callback_data="git:log"),
1240
+ ],
1241
+ [
1242
+ InlineKeyboardButton("🔄 Refresh", callback_data="git:status"),
1243
+ InlineKeyboardButton("📁 Files", callback_data="action:ls"),
1244
+ ],
1245
+ ]
1246
+ reply_markup = InlineKeyboardMarkup(keyboard)
1247
+
1248
+ await query.edit_message_text(
1249
+ status_message, parse_mode="HTML", reply_markup=reply_markup
1250
+ )
1251
+
1252
+ elif git_action == "diff":
1253
+ # Show git diff
1254
+ diff_output = await git_integration.get_diff(current_dir)
1255
+
1256
+ if not diff_output.strip():
1257
+ diff_message = "📊 <b>Git Diff</b>\n\n<i>No changes to show.</i>"
1258
+ else:
1259
+ # Clean up diff output for Telegram
1260
+ # Remove emoji symbols that interfere with parsing
1261
+ clean_diff = (
1262
+ diff_output.replace("➕", "+").replace("➖", "-").replace("📍", "@")
1263
+ )
1264
+
1265
+ # Limit diff output (leave room for header + HTML tags within
1266
+ # Telegram's 4096-char message limit)
1267
+ max_length = 3500
1268
+ if len(clean_diff) > max_length:
1269
+ clean_diff = (
1270
+ clean_diff[:max_length] + "\n\n... output truncated ..."
1271
+ )
1272
+
1273
+ escaped_diff = escape_html(clean_diff)
1274
+ diff_message = (
1275
+ f"📊 <b>Git Diff</b>\n\n<pre><code>{escaped_diff}</code></pre>"
1276
+ )
1277
+
1278
+ keyboard = [
1279
+ [
1280
+ InlineKeyboardButton("📜 Show Log", callback_data="git:log"),
1281
+ InlineKeyboardButton("📊 Status", callback_data="git:status"),
1282
+ ]
1283
+ ]
1284
+ reply_markup = InlineKeyboardMarkup(keyboard)
1285
+
1286
+ await query.edit_message_text(
1287
+ diff_message, parse_mode="HTML", reply_markup=reply_markup
1288
+ )
1289
+
1290
+ elif git_action == "log":
1291
+ # Show git log
1292
+ commits = await git_integration.get_file_history(current_dir, ".")
1293
+
1294
+ if not commits:
1295
+ log_message = "📜 <b>Git Log</b>\n\n<i>No commits found.</i>"
1296
+ else:
1297
+ log_message = "📜 <b>Git Log</b>\n\n"
1298
+ for commit in commits[:10]: # Show last 10 commits
1299
+ short_hash = commit.hash[:7]
1300
+ short_message = escape_html(commit.message[:60])
1301
+ if len(commit.message) > 60:
1302
+ short_message += "..."
1303
+ log_message += f"• <code>{short_hash}</code> {short_message}\n"
1304
+
1305
+ keyboard = [
1306
+ [
1307
+ InlineKeyboardButton("📊 Show Diff", callback_data="git:diff"),
1308
+ InlineKeyboardButton("📊 Status", callback_data="git:status"),
1309
+ ]
1310
+ ]
1311
+ reply_markup = InlineKeyboardMarkup(keyboard)
1312
+
1313
+ await query.edit_message_text(
1314
+ log_message, parse_mode="HTML", reply_markup=reply_markup
1315
+ )
1316
+
1317
+ else:
1318
+ await query.edit_message_text(
1319
+ f"❌ <b>Unknown Git Action: {escape_html(git_action)}</b>\n\n"
1320
+ "This git action is not recognized.",
1321
+ parse_mode="HTML",
1322
+ )
1323
+
1324
+ except Exception as e:
1325
+ logger.error(
1326
+ "Error in git callback",
1327
+ error=str(e),
1328
+ git_action=git_action,
1329
+ user_id=user_id,
1330
+ )
1331
+ await query.edit_message_text(
1332
+ f"❌ <b>Git Error</b>\n\n{escape_html(str(e))}",
1333
+ parse_mode="HTML",
1334
+ )
1335
+
1336
+
1337
+ async def handle_export_callback(
1338
+ query, export_format: str, context: ContextTypes.DEFAULT_TYPE
1339
+ ) -> None:
1340
+ """Handle export format selection callbacks."""
1341
+ user_id = query.from_user.id
1342
+ features = context.bot_data.get("features")
1343
+
1344
+ if export_format == "cancel":
1345
+ await query.edit_message_text(
1346
+ "📤 <b>Export Cancelled</b>\n\n" "Session export has been cancelled.",
1347
+ parse_mode="HTML",
1348
+ )
1349
+ return
1350
+
1351
+ session_exporter = features.get_session_export() if features else None
1352
+ if not session_exporter:
1353
+ await query.edit_message_text(
1354
+ "❌ <b>Export Unavailable</b>\n\n"
1355
+ "Session export service is not available.",
1356
+ parse_mode="HTML",
1357
+ )
1358
+ return
1359
+
1360
+ # Get current session
1361
+ claude_session_id = context.user_data.get("claude_session_id")
1362
+ if not claude_session_id:
1363
+ await query.edit_message_text(
1364
+ "❌ <b>No Active Session</b>\n\n" "There's no active session to export.",
1365
+ parse_mode="HTML",
1366
+ )
1367
+ return
1368
+
1369
+ try:
1370
+ # Show processing message
1371
+ await query.edit_message_text(
1372
+ f"📤 <b>Exporting Session</b>\n\n"
1373
+ f"Generating {escape_html(export_format.upper())} export...",
1374
+ parse_mode="HTML",
1375
+ )
1376
+
1377
+ # Export session
1378
+ exported_session = await session_exporter.export_session(
1379
+ claude_session_id, export_format
1380
+ )
1381
+
1382
+ # Send the exported file
1383
+ from io import BytesIO
1384
+
1385
+ file_bytes = BytesIO(exported_session.content.encode("utf-8"))
1386
+ file_bytes.name = exported_session.filename
1387
+
1388
+ await query.message.reply_document(
1389
+ document=file_bytes,
1390
+ filename=exported_session.filename,
1391
+ caption=(
1392
+ f"📤 <b>Session Export Complete</b>\n\n"
1393
+ f"Format: {escape_html(exported_session.format.upper())}\n"
1394
+ f"Size: {exported_session.size_bytes:,} bytes\n"
1395
+ f"Created: {exported_session.created_at.strftime('%Y-%m-%d %H:%M:%S')}"
1396
+ ),
1397
+ parse_mode="HTML",
1398
+ )
1399
+
1400
+ # Update the original message
1401
+ await query.edit_message_text(
1402
+ f"✅ <b>Export Complete</b>\n\n"
1403
+ f"Your session has been exported as {escape_html(exported_session.filename)}.\n"
1404
+ f"Check the file above for your complete conversation history.",
1405
+ parse_mode="HTML",
1406
+ )
1407
+
1408
+ except Exception as e:
1409
+ logger.error(
1410
+ "Export failed", error=str(e), user_id=user_id, format=export_format
1411
+ )
1412
+ await query.edit_message_text(
1413
+ f"❌ <b>Export Failed</b>\n\n{escape_html(str(e))}",
1414
+ parse_mode="HTML",
1415
+ )
1416
+
1417
+
1418
+ def _format_file_size(size: int) -> str:
1419
+ """Format file size in human-readable format."""
1420
+ for unit in ["B", "KB", "MB", "GB"]:
1421
+ if size < 1024:
1422
+ return f"{size:.1f}{unit}" if unit != "B" else f"{size}B"
1423
+ size /= 1024
1424
+ return f"{size:.1f}TB"
1425
+
1426
+
1427
+ def _escape_markdown(text: str) -> str:
1428
+ """Escape HTML-special characters in text for Telegram.
1429
+
1430
+ Legacy name kept for compatibility with callers; actually escapes HTML.
1431
+ """
1432
+ return escape_html(text)