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.
- mrstack/__init__.py +4 -0
- mrstack/_data/config/com.mrstack.claude-telegram.plist +25 -0
- mrstack/_data/config/mcp-config.example.json +23 -0
- mrstack/_data/config/start-daemon.sh +53 -0
- mrstack/_data/config/start.sh +29 -0
- mrstack/_data/schedulers/manage-jobs.sh +87 -0
- mrstack/_data/schedulers/morning-briefing.sh +29 -0
- mrstack/_data/schedulers/register-jobs.py +182 -0
- mrstack/_data/schedulers/run-threads-briefing.sh +36 -0
- mrstack/_data/schedulers/weekly-review.sh +26 -0
- mrstack/_data/templates/DESIGN-GUIDE.md +160 -0
- mrstack/_data/templates/alert.md +56 -0
- mrstack/_data/templates/evening-summary.md +73 -0
- mrstack/_data/templates/jarvis-alert.md +64 -0
- mrstack/_data/templates/morning-briefing.md +53 -0
- mrstack/_data/templates/weekly-review.md +79 -0
- mrstack/_overlay/api/dashboard.py +223 -0
- mrstack/_overlay/api/templates/dashboard.html +328 -0
- mrstack/_overlay/bot/handlers/callback.py +1432 -0
- mrstack/_overlay/bot/handlers/command.py +1541 -0
- mrstack/_overlay/bot/utils/keyboards.py +125 -0
- mrstack/_overlay/bot/utils/ui_components.py +166 -0
- mrstack/_overlay/claude/session.py +341 -0
- mrstack/_overlay/jarvis/__init__.py +77 -0
- mrstack/_overlay/jarvis/coach.py +122 -0
- mrstack/_overlay/jarvis/context_engine.py +463 -0
- mrstack/_overlay/jarvis/pattern_learner.py +255 -0
- mrstack/_overlay/jarvis/persona.py +84 -0
- mrstack/_overlay/jarvis/platform.py +182 -0
- mrstack/_overlay/knowledge/__init__.py +6 -0
- mrstack/_overlay/knowledge/manager.py +464 -0
- mrstack/_overlay/knowledge/memory_index.py +180 -0
- mrstack/cli.py +330 -0
- mrstack/constants.py +77 -0
- mrstack/daemon.py +325 -0
- mrstack/patcher.py +169 -0
- mrstack/wizard.py +271 -0
- mrstack-1.1.0.dist-info/METADATA +640 -0
- mrstack-1.1.0.dist-info/RECORD +42 -0
- mrstack-1.1.0.dist-info/WHEEL +4 -0
- mrstack-1.1.0.dist-info/entry_points.txt +2 -0
- 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 <dir></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 <project></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 <directory></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 <directory></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)}")
|