claude-code-tg 0.8.3__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.
- claude_code_tg/__init__.py +0 -0
- claude_code_tg/attachment_cleanup.py +132 -0
- claude_code_tg/attachments.py +244 -0
- claude_code_tg/bot.py +509 -0
- claude_code_tg/bot_app.py +326 -0
- claude_code_tg/bot_commands.py +1281 -0
- claude_code_tg/bot_processing.py +398 -0
- claude_code_tg/claude_sessions.py +375 -0
- claude_code_tg/cli.py +456 -0
- claude_code_tg/cli_init.py +391 -0
- claude_code_tg/cli_instances.py +169 -0
- claude_code_tg/cli_parser.py +182 -0
- claude_code_tg/command_menu.py +330 -0
- claude_code_tg/command_view.py +107 -0
- claude_code_tg/config.py +235 -0
- claude_code_tg/diagnostics.py +517 -0
- claude_code_tg/executor.py +841 -0
- claude_code_tg/file_security.py +351 -0
- claude_code_tg/instance_store.py +208 -0
- claude_code_tg/interaction_log.py +64 -0
- claude_code_tg/message_input.py +142 -0
- claude_code_tg/message_output.py +57 -0
- claude_code_tg/pending_reply.py +64 -0
- claude_code_tg/process_control.py +58 -0
- claude_code_tg/py.typed +1 -0
- claude_code_tg/result_view.py +99 -0
- claude_code_tg/resume_view.py +113 -0
- claude_code_tg/run_view.py +618 -0
- claude_code_tg/sanitizer.py +45 -0
- claude_code_tg/server.py +142 -0
- claude_code_tg/sessions.py +402 -0
- claude_code_tg/telegram_ui.py +123 -0
- claude_code_tg/utils.py +133 -0
- claude_code_tg/web_console.py +260 -0
- claude_code_tg-0.8.3.dist-info/METADATA +245 -0
- claude_code_tg-0.8.3.dist-info/RECORD +39 -0
- claude_code_tg-0.8.3.dist-info/WHEEL +4 -0
- claude_code_tg-0.8.3.dist-info/entry_points.txt +2 -0
- claude_code_tg-0.8.3.dist-info/licenses/LICENSE +21 -0
claude_code_tg/bot.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""Telegram Bot handlers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from telegram import Update
|
|
12
|
+
from telegram.ext import Application, ContextTypes
|
|
13
|
+
|
|
14
|
+
from claude_code_tg.attachments import (
|
|
15
|
+
DEFAULT_ATTACHMENT_MAX_BYTES,
|
|
16
|
+
DEFAULT_ATTACHMENT_MODE,
|
|
17
|
+
PROJECT_ATTACHMENT_DIRNAME,
|
|
18
|
+
prune_attachment_tree,
|
|
19
|
+
)
|
|
20
|
+
from claude_code_tg.bot_app import build_telegram_app
|
|
21
|
+
from claude_code_tg.bot_commands import BotCommandHandlers
|
|
22
|
+
from claude_code_tg.bot_processing import BotMessageProcessor
|
|
23
|
+
from claude_code_tg.command_view import CommandPickerStore
|
|
24
|
+
from claude_code_tg.executor import (
|
|
25
|
+
Executor,
|
|
26
|
+
normalize_effort,
|
|
27
|
+
normalize_model,
|
|
28
|
+
normalize_permission_mode,
|
|
29
|
+
)
|
|
30
|
+
from claude_code_tg.message_input import TelegramInputBuilder
|
|
31
|
+
from claude_code_tg.message_output import MAX_TG_MESSAGE_LENGTH
|
|
32
|
+
from claude_code_tg.pending_reply import PendingReplyStore
|
|
33
|
+
from claude_code_tg.result_view import ResultActionStore
|
|
34
|
+
from claude_code_tg.resume_view import ResumePickerStore
|
|
35
|
+
from claude_code_tg.run_view import RunViewStore
|
|
36
|
+
from claude_code_tg.sessions import ChatSessionStore, ReplyCallback
|
|
37
|
+
from claude_code_tg.utils import _format_uptime
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
__all__ = ["MAX_TG_MESSAGE_LENGTH", "TGBot"]
|
|
42
|
+
|
|
43
|
+
HEARTBEAT_LOG_INTERVAL = 10
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TGBot(BotMessageProcessor, BotCommandHandlers):
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
token: str,
|
|
50
|
+
admin_ids: set[int],
|
|
51
|
+
allowed_ids: set[int],
|
|
52
|
+
project_dir: str,
|
|
53
|
+
timeout: int = 300,
|
|
54
|
+
queue_max_size: int = 3,
|
|
55
|
+
permission_mode: str | None = None,
|
|
56
|
+
model: str | None = None,
|
|
57
|
+
effort: str | None = None,
|
|
58
|
+
attachment_dir: Path | None = None,
|
|
59
|
+
attachment_max_bytes: int = DEFAULT_ATTACHMENT_MAX_BYTES,
|
|
60
|
+
attachment_mode: str = DEFAULT_ATTACHMENT_MODE,
|
|
61
|
+
attachment_retention_days: float | None = None,
|
|
62
|
+
command_menu_enabled: bool = False,
|
|
63
|
+
draft_preview_enabled: bool = False,
|
|
64
|
+
mini_app_enabled: bool = False,
|
|
65
|
+
mini_app_public_url: str = "",
|
|
66
|
+
mini_app_host: str = "127.0.0.1",
|
|
67
|
+
mini_app_port: int = 8787,
|
|
68
|
+
mini_app_menu_text: str = "tgcc",
|
|
69
|
+
cli_resume_compat: bool = False,
|
|
70
|
+
status_file: Path | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.token = token
|
|
73
|
+
self.admin_ids = admin_ids
|
|
74
|
+
self.allowed_ids = allowed_ids | admin_ids
|
|
75
|
+
self.project_dir = project_dir
|
|
76
|
+
self.timeout = timeout
|
|
77
|
+
default_attachment_dir = (
|
|
78
|
+
status_file.parent / "attachments"
|
|
79
|
+
if status_file
|
|
80
|
+
else Path.home() / ".tgcc" / "attachments"
|
|
81
|
+
)
|
|
82
|
+
self.input_builder = TelegramInputBuilder(
|
|
83
|
+
attachment_dir=attachment_dir or default_attachment_dir,
|
|
84
|
+
project_dir=project_dir,
|
|
85
|
+
attachment_max_bytes=attachment_max_bytes,
|
|
86
|
+
attachment_mode=attachment_mode,
|
|
87
|
+
)
|
|
88
|
+
self.attachment_dir = self.input_builder.attachment_dir
|
|
89
|
+
self.attachment_max_bytes = self.input_builder.attachment_max_bytes
|
|
90
|
+
self.attachment_mode = self.input_builder.attachment_mode
|
|
91
|
+
self.attachment_retention_days = attachment_retention_days
|
|
92
|
+
self.command_menu_enabled = command_menu_enabled
|
|
93
|
+
self.draft_preview_enabled = draft_preview_enabled
|
|
94
|
+
self.mini_app_enabled = mini_app_enabled
|
|
95
|
+
self.mini_app_public_url = mini_app_public_url
|
|
96
|
+
self.mini_app_host = mini_app_host
|
|
97
|
+
self.mini_app_port = mini_app_port
|
|
98
|
+
self.mini_app_menu_text = mini_app_menu_text
|
|
99
|
+
self.cli_resume_compat_enabled = cli_resume_compat
|
|
100
|
+
self._mini_app_server: object | None = None
|
|
101
|
+
self._mini_app_task: asyncio.Task[Any] | None = None
|
|
102
|
+
self.command_menu_cache_file = (
|
|
103
|
+
status_file.parent / "command-menu.json" if status_file else None
|
|
104
|
+
)
|
|
105
|
+
self.run_views = RunViewStore()
|
|
106
|
+
self.resume_pickers = ResumePickerStore()
|
|
107
|
+
self.command_pickers = CommandPickerStore()
|
|
108
|
+
self.result_actions = ResultActionStore()
|
|
109
|
+
self.pending_replies = PendingReplyStore()
|
|
110
|
+
self.last_prompts: dict[int, str] = {}
|
|
111
|
+
|
|
112
|
+
self.executor = Executor()
|
|
113
|
+
# tg-safe command name -> Claude slash command; filled in post_init.
|
|
114
|
+
self.claude_command_map: dict[str, str] = {}
|
|
115
|
+
self.state = ChatSessionStore(
|
|
116
|
+
queue_max_size=queue_max_size,
|
|
117
|
+
permission_mode=permission_mode,
|
|
118
|
+
model=model,
|
|
119
|
+
effort=effort,
|
|
120
|
+
status_file=status_file,
|
|
121
|
+
)
|
|
122
|
+
self.queue_max_size = self.state.queue_max_size
|
|
123
|
+
self.default_permission_mode = self.state.default_permission_mode
|
|
124
|
+
self.default_model = self.state.default_model
|
|
125
|
+
self.default_effort = self.state.default_effort
|
|
126
|
+
self.sessions = self.state.sessions
|
|
127
|
+
self.permission_modes = self.state.permission_modes
|
|
128
|
+
self.model_overrides = self.state.model_overrides
|
|
129
|
+
self.effort_overrides = self.state.effort_overrides
|
|
130
|
+
self._session_versions = self.state.session_versions
|
|
131
|
+
self.busy = self.state.busy
|
|
132
|
+
self.queues = self.state.queues
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def queue_max_size(self) -> int:
|
|
136
|
+
return self.state.queue_max_size
|
|
137
|
+
|
|
138
|
+
@queue_max_size.setter
|
|
139
|
+
def queue_max_size(self, value: int) -> None:
|
|
140
|
+
self.state.queue_max_size = max(value, 1)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def default_permission_mode(self) -> str | None:
|
|
144
|
+
return self.state.default_permission_mode
|
|
145
|
+
|
|
146
|
+
@default_permission_mode.setter
|
|
147
|
+
def default_permission_mode(self, value: str | None) -> None:
|
|
148
|
+
self.state.default_permission_mode = normalize_permission_mode(value)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def default_model(self) -> str | None:
|
|
152
|
+
return self.state.default_model
|
|
153
|
+
|
|
154
|
+
@default_model.setter
|
|
155
|
+
def default_model(self, value: str | None) -> None:
|
|
156
|
+
self.state.default_model = normalize_model(value)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def default_effort(self) -> str | None:
|
|
160
|
+
return self.state.default_effort
|
|
161
|
+
|
|
162
|
+
@default_effort.setter
|
|
163
|
+
def default_effort(self, value: str | None) -> None:
|
|
164
|
+
self.state.default_effort = normalize_effort(value)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def status_file(self) -> Path | None:
|
|
168
|
+
return self.state.status_file
|
|
169
|
+
|
|
170
|
+
@status_file.setter
|
|
171
|
+
def status_file(self, value: Path | None) -> None:
|
|
172
|
+
self.state.status_file = value
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def _start_time(self) -> float:
|
|
176
|
+
return self.state.start_time
|
|
177
|
+
|
|
178
|
+
@_start_time.setter
|
|
179
|
+
def _start_time(self, value: float) -> None:
|
|
180
|
+
self.state.start_time = value
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def _heartbeat_counter(self) -> int:
|
|
184
|
+
return self.state.heartbeat_counter
|
|
185
|
+
|
|
186
|
+
@_heartbeat_counter.setter
|
|
187
|
+
def _heartbeat_counter(self, value: int) -> None:
|
|
188
|
+
self.state.heartbeat_counter = value
|
|
189
|
+
|
|
190
|
+
def _is_authorized(self, user_id: int) -> bool:
|
|
191
|
+
return user_id in self.allowed_ids
|
|
192
|
+
|
|
193
|
+
def _write_status(self) -> None:
|
|
194
|
+
"""Write current bot status to JSON file for `tgcc status` consumption."""
|
|
195
|
+
error = self.state.write_status()
|
|
196
|
+
if error:
|
|
197
|
+
logger.debug("Failed to write status file: %s", error)
|
|
198
|
+
|
|
199
|
+
def _restore_sessions(self) -> None:
|
|
200
|
+
"""Restore sessions from status.json after restart."""
|
|
201
|
+
restored = self.state.restore_sessions()
|
|
202
|
+
if restored:
|
|
203
|
+
logger.info(f"Restored {restored} session(s) from status file")
|
|
204
|
+
|
|
205
|
+
def _record_periodic_status(self) -> None:
|
|
206
|
+
"""Write status and periodically emit a lightweight heartbeat log."""
|
|
207
|
+
self._write_status()
|
|
208
|
+
self._heartbeat_counter += 1
|
|
209
|
+
if self._heartbeat_counter < HEARTBEAT_LOG_INTERVAL:
|
|
210
|
+
return
|
|
211
|
+
self._heartbeat_counter = 0
|
|
212
|
+
queue_total = self.state.queue_total()
|
|
213
|
+
uptime = int(time.time() - self._start_time)
|
|
214
|
+
logger.info(
|
|
215
|
+
"Heartbeat | sessions=%d busy=%d queue=%d uptime=%s",
|
|
216
|
+
len(self.sessions),
|
|
217
|
+
len(self.busy),
|
|
218
|
+
queue_total,
|
|
219
|
+
_format_uptime(uptime),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _attachment_cleanup_roots(self) -> list[tuple[str, Path]]:
|
|
223
|
+
roots = [
|
|
224
|
+
("instance", self.attachment_dir),
|
|
225
|
+
(
|
|
226
|
+
"project",
|
|
227
|
+
Path(self.project_dir).expanduser().resolve(strict=False)
|
|
228
|
+
/ PROJECT_ATTACHMENT_DIRNAME,
|
|
229
|
+
),
|
|
230
|
+
]
|
|
231
|
+
seen: set[str] = set()
|
|
232
|
+
unique_roots: list[tuple[str, Path]] = []
|
|
233
|
+
for label, root in roots:
|
|
234
|
+
root_key = str(root.expanduser().resolve(strict=False))
|
|
235
|
+
if root_key in seen:
|
|
236
|
+
continue
|
|
237
|
+
seen.add(root_key)
|
|
238
|
+
unique_roots.append((label, root))
|
|
239
|
+
return unique_roots
|
|
240
|
+
|
|
241
|
+
def _run_attachment_retention_cleanup(self) -> tuple[int, int, int]:
|
|
242
|
+
"""Prune old attachment files when automatic retention is configured."""
|
|
243
|
+
if self.attachment_retention_days is None:
|
|
244
|
+
return (0, 0, 0)
|
|
245
|
+
|
|
246
|
+
older_than_seconds = self.attachment_retention_days * 86400
|
|
247
|
+
total_files = 0
|
|
248
|
+
total_bytes = 0
|
|
249
|
+
total_errors = 0
|
|
250
|
+
for label, root in self._attachment_cleanup_roots():
|
|
251
|
+
result = prune_attachment_tree(
|
|
252
|
+
root,
|
|
253
|
+
older_than_seconds=older_than_seconds,
|
|
254
|
+
dry_run=False,
|
|
255
|
+
)
|
|
256
|
+
total_files += result.files
|
|
257
|
+
total_bytes += result.byte_count
|
|
258
|
+
total_errors += len(result.errors)
|
|
259
|
+
for error in result.errors:
|
|
260
|
+
logger.warning("Attachment cleanup warning | scope=%s %s", label, error)
|
|
261
|
+
if result.root_exists and (result.files or result.dirs_removed):
|
|
262
|
+
logger.info(
|
|
263
|
+
"Attachment cleanup | scope=%s files=%d bytes=%d dirs=%d",
|
|
264
|
+
label,
|
|
265
|
+
result.files,
|
|
266
|
+
result.byte_count,
|
|
267
|
+
result.dirs_removed,
|
|
268
|
+
)
|
|
269
|
+
if total_errors:
|
|
270
|
+
logger.warning(
|
|
271
|
+
"Attachment cleanup completed with %d warning(s)", total_errors
|
|
272
|
+
)
|
|
273
|
+
return (total_files, total_bytes, total_errors)
|
|
274
|
+
|
|
275
|
+
def _get_or_create_session(self, chat_id: int) -> tuple[str | None, bool]:
|
|
276
|
+
"""Returns (session_id, is_existing). None means new session."""
|
|
277
|
+
return self.state.get_or_create_session(chat_id)
|
|
278
|
+
|
|
279
|
+
def _effective_permission_mode(self, chat_id: int) -> str | None:
|
|
280
|
+
return self.state.effective_permission_mode(chat_id)
|
|
281
|
+
|
|
282
|
+
def _permission_mode_label(self, chat_id: int) -> str:
|
|
283
|
+
return self.state.permission_mode_label(chat_id)
|
|
284
|
+
|
|
285
|
+
def _effective_model(self, chat_id: int) -> str | None:
|
|
286
|
+
return self.state.effective_model(chat_id)
|
|
287
|
+
|
|
288
|
+
def _model_label(self, chat_id: int) -> str:
|
|
289
|
+
return self.state.model_label(chat_id)
|
|
290
|
+
|
|
291
|
+
def _effective_effort(self, chat_id: int) -> str | None:
|
|
292
|
+
return self.state.effective_effort(chat_id)
|
|
293
|
+
|
|
294
|
+
def _effort_label(self, chat_id: int) -> str:
|
|
295
|
+
return self.state.effort_label(chat_id)
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _normalize_session_id(session_id: str) -> str | None:
|
|
299
|
+
"""Return a canonical Claude session UUID, or None if invalid."""
|
|
300
|
+
try:
|
|
301
|
+
return str(uuid.UUID(session_id.strip()))
|
|
302
|
+
except (AttributeError, ValueError):
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
async def _try_enqueue(
|
|
306
|
+
self,
|
|
307
|
+
chat_id: int,
|
|
308
|
+
user_id: int,
|
|
309
|
+
prompt: str,
|
|
310
|
+
reply_fn: ReplyCallback,
|
|
311
|
+
) -> bool:
|
|
312
|
+
"""Try to enqueue a message. Returns True if enqueued, False if should process immediately."""
|
|
313
|
+
result = await self.state.try_enqueue(chat_id, user_id, prompt, reply_fn)
|
|
314
|
+
self._write_status()
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
async def _prompt_from_update(
|
|
318
|
+
self, update: Update, context: ContextTypes.DEFAULT_TYPE
|
|
319
|
+
) -> str:
|
|
320
|
+
return await self.input_builder.prompt_from_update(update, context)
|
|
321
|
+
|
|
322
|
+
async def handle_message(
|
|
323
|
+
self, update: Update, context: ContextTypes.DEFAULT_TYPE
|
|
324
|
+
) -> None:
|
|
325
|
+
if not update.message or not update.effective_user or not update.effective_chat:
|
|
326
|
+
return
|
|
327
|
+
user_id = update.effective_user.id
|
|
328
|
+
chat = update.effective_chat
|
|
329
|
+
chat_id = chat.id
|
|
330
|
+
|
|
331
|
+
if not self._is_authorized(user_id):
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
# Group chat: only respond to @bot or reply-to-bot
|
|
335
|
+
if chat.type != "private":
|
|
336
|
+
bot_username = context.bot.username
|
|
337
|
+
msg = update.message
|
|
338
|
+
msg_text = msg.text or msg.caption or ""
|
|
339
|
+
is_mention = bot_username and f"@{bot_username}" in msg_text
|
|
340
|
+
is_reply_to_bot = (
|
|
341
|
+
msg.reply_to_message
|
|
342
|
+
and msg.reply_to_message.from_user
|
|
343
|
+
and msg.reply_to_message.from_user.id == context.bot.id
|
|
344
|
+
)
|
|
345
|
+
if not is_mention and not is_reply_to_bot:
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
prompt = await self._prompt_from_update(update, context)
|
|
350
|
+
except ValueError as e:
|
|
351
|
+
await update.message.reply_text(f"⚠️ {e}")
|
|
352
|
+
return
|
|
353
|
+
except Exception:
|
|
354
|
+
logger.exception("Failed to download Telegram attachment")
|
|
355
|
+
await update.message.reply_text("❌ 附件下载失败,请稍后重试。")
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
if not prompt:
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
if await self._try_enqueue(chat_id, user_id, prompt, update.message.reply_text):
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
await self._process_message(chat_id, user_id, prompt, context)
|
|
365
|
+
|
|
366
|
+
def build_app(self) -> Application:
|
|
367
|
+
return build_telegram_app(self)
|
|
368
|
+
|
|
369
|
+
async def start_mini_app(self, telegram_bot: Any) -> None:
|
|
370
|
+
"""Start the optional Mini App web console alongside polling."""
|
|
371
|
+
if not self.mini_app_enabled:
|
|
372
|
+
return
|
|
373
|
+
try:
|
|
374
|
+
import uvicorn
|
|
375
|
+
|
|
376
|
+
from claude_code_tg.web_console import build_web_console_app
|
|
377
|
+
except ImportError as exc:
|
|
378
|
+
raise RuntimeError(
|
|
379
|
+
"Mini App support requires `uvicorn` and `starlette`; "
|
|
380
|
+
"install with `uv sync --extra mini-app`."
|
|
381
|
+
) from exc
|
|
382
|
+
|
|
383
|
+
app = build_web_console_app(self, telegram_bot)
|
|
384
|
+
config = uvicorn.Config(
|
|
385
|
+
cast(Any, app),
|
|
386
|
+
host=self.mini_app_host,
|
|
387
|
+
port=self.mini_app_port,
|
|
388
|
+
log_level="info",
|
|
389
|
+
lifespan="off",
|
|
390
|
+
)
|
|
391
|
+
server = uvicorn.Server(config)
|
|
392
|
+
self._mini_app_server = server
|
|
393
|
+
self._mini_app_task = asyncio.create_task(server.serve())
|
|
394
|
+
|
|
395
|
+
async def stop_mini_app(self) -> None:
|
|
396
|
+
task = self._mini_app_task
|
|
397
|
+
server = self._mini_app_server
|
|
398
|
+
if server is not None and hasattr(server, "should_exit"):
|
|
399
|
+
cast(Any, server).should_exit = True
|
|
400
|
+
if task is not None:
|
|
401
|
+
from contextlib import suppress
|
|
402
|
+
|
|
403
|
+
with suppress(asyncio.CancelledError):
|
|
404
|
+
await task
|
|
405
|
+
self._mini_app_task = None
|
|
406
|
+
self._mini_app_server = None
|
|
407
|
+
|
|
408
|
+
def mini_app_status(self, chat_id: int) -> dict[str, object]:
|
|
409
|
+
queue_len = len(self.queues.get(chat_id, []))
|
|
410
|
+
session_id = self.sessions.get(chat_id)
|
|
411
|
+
latest = self.run_views.latest(chat_id)
|
|
412
|
+
current_tool = latest.current_tool if latest else None
|
|
413
|
+
return {
|
|
414
|
+
"chat_id": chat_id,
|
|
415
|
+
"busy": chat_id in self.busy,
|
|
416
|
+
"session_id": session_id,
|
|
417
|
+
"queue": {"current": queue_len, "max": self.queue_max_size},
|
|
418
|
+
"permission_mode": self._permission_mode_label(chat_id),
|
|
419
|
+
"model": self._model_label(chat_id),
|
|
420
|
+
"effort": self._effort_label(chat_id),
|
|
421
|
+
"attachment": self._attachment_config_label(),
|
|
422
|
+
"last_prompt_available": chat_id in self.last_prompts,
|
|
423
|
+
"latest_run": None
|
|
424
|
+
if latest is None
|
|
425
|
+
else {
|
|
426
|
+
"run_id": latest.run_id,
|
|
427
|
+
"status": latest.status,
|
|
428
|
+
"task": latest.task_summary,
|
|
429
|
+
"tool_count": latest.tool_count,
|
|
430
|
+
"current_tool": None
|
|
431
|
+
if current_tool is None
|
|
432
|
+
else {
|
|
433
|
+
"index": current_tool.index,
|
|
434
|
+
"name": current_tool.name,
|
|
435
|
+
"summary": current_tool.summary,
|
|
436
|
+
"output": current_tool.output,
|
|
437
|
+
"is_error": current_tool.is_error,
|
|
438
|
+
},
|
|
439
|
+
"latest_output": latest.latest_output,
|
|
440
|
+
},
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async def handle_mini_app_action(
|
|
444
|
+
self,
|
|
445
|
+
chat_id: int,
|
|
446
|
+
user_id: int,
|
|
447
|
+
action: str,
|
|
448
|
+
payload: dict[str, object],
|
|
449
|
+
telegram_bot: Any,
|
|
450
|
+
) -> dict[str, object]:
|
|
451
|
+
if not self._is_authorized(user_id):
|
|
452
|
+
return {"ok": False, "error": "unauthorized"}
|
|
453
|
+
if action == "stop":
|
|
454
|
+
return {"ok": True, "stopped": await self.executor.stop(chat_id)}
|
|
455
|
+
if action == "new":
|
|
456
|
+
dropped = self.state.reset_chat(chat_id)
|
|
457
|
+
stopped = (
|
|
458
|
+
await self.executor.stop(chat_id) if chat_id in self.busy else False
|
|
459
|
+
)
|
|
460
|
+
self._write_status()
|
|
461
|
+
return {"ok": True, "stopped": stopped, "dropped": dropped}
|
|
462
|
+
if action == "resume":
|
|
463
|
+
session_id = self._normalize_session_id(str(payload.get("session_id", "")))
|
|
464
|
+
if not session_id:
|
|
465
|
+
return {"ok": False, "error": "invalid_session_id"}
|
|
466
|
+
message_text = await self._attach_session_text(chat_id, session_id)
|
|
467
|
+
return {"ok": True, "message": message_text}
|
|
468
|
+
if action == "set_model":
|
|
469
|
+
model_text = self._apply_model_choice(
|
|
470
|
+
chat_id, str(payload.get("model", ""))
|
|
471
|
+
)
|
|
472
|
+
if model_text is None:
|
|
473
|
+
return {"ok": False, "error": "invalid_model"}
|
|
474
|
+
self._write_status()
|
|
475
|
+
return {"ok": True, "message": model_text}
|
|
476
|
+
if action == "set_permissions":
|
|
477
|
+
permission_text = self._apply_permission_choice(
|
|
478
|
+
chat_id, str(payload.get("mode", ""))
|
|
479
|
+
)
|
|
480
|
+
if permission_text is None:
|
|
481
|
+
return {"ok": False, "error": "invalid_permission_mode"}
|
|
482
|
+
self._write_status()
|
|
483
|
+
return {"ok": True, "message": permission_text}
|
|
484
|
+
if action == "set_effort":
|
|
485
|
+
effort_text = self._apply_effort_choice(
|
|
486
|
+
chat_id, str(payload.get("effort", ""))
|
|
487
|
+
)
|
|
488
|
+
if effort_text is None:
|
|
489
|
+
return {"ok": False, "error": "invalid_effort"}
|
|
490
|
+
self._write_status()
|
|
491
|
+
return {"ok": True, "message": effort_text}
|
|
492
|
+
if action == "rerun":
|
|
493
|
+
prompt = self.last_prompts.get(chat_id)
|
|
494
|
+
if not prompt:
|
|
495
|
+
return {"ok": False, "error": "no_last_prompt"}
|
|
496
|
+
|
|
497
|
+
async def reply_enqueue(text: str) -> None:
|
|
498
|
+
await telegram_bot.send_message(chat_id=chat_id, text=text)
|
|
499
|
+
|
|
500
|
+
if await self._try_enqueue(chat_id, user_id, prompt, reply_enqueue):
|
|
501
|
+
return {"ok": True, "queued": True}
|
|
502
|
+
context = cast(ContextTypes.DEFAULT_TYPE, SimpleNamespace(bot=telegram_bot))
|
|
503
|
+
await self._process_message(chat_id, user_id, prompt, context)
|
|
504
|
+
return {"ok": True, "queued": False}
|
|
505
|
+
return {"ok": False, "error": "unknown_action"}
|
|
506
|
+
|
|
507
|
+
def run(self) -> None:
|
|
508
|
+
app = self.build_app()
|
|
509
|
+
app.run_polling(drop_pending_updates=True)
|