From 4a9b2e27fa0c1485d714fba479e6333c1ad3bcf1 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Thu, 9 Apr 2026 02:51:21 +0800 Subject: [PATCH] Improve Telegram bot status and approval UX --- bot/app.py | 174 +++++++++++++++++++++++++++++++++++++++----- bot/telegram_api.py | 61 +++++++++++++++- 2 files changed, 215 insertions(+), 20 deletions(-) diff --git a/bot/app.py b/bot/app.py index 2744741..392b66d 100644 --- a/bot/app.py +++ b/bot/app.py @@ -1,6 +1,8 @@ from __future__ import annotations +import html import json +import re import time from pathlib import Path from typing import Any @@ -11,6 +13,7 @@ from telegram_api import TelegramAPI STATE_FILE = Path(__file__).resolve().parent.parent / ".new-qwen" / "telegram-state.json" +TYPING_INTERVAL_SECONDS = 4.0 def load_state() -> dict[str, Any]: @@ -63,6 +66,82 @@ def send_text_chunks(api: TelegramAPI, chat_id: int, text: str) -> None: api.send_message(chat_id, normalized[start : start + chunk_size]) +def render_markdownish_html(text: str) -> str: + normalized = (text or "Пустой ответ.").replace("\r\n", "\n") + fence_pattern = re.compile(r"```(?:[^\n`]*)\n(.*?)```", re.DOTALL) + placeholders: list[str] = [] + + def replace_fence(match: re.Match[str]) -> str: + placeholders.append(f"
{html.escape(match.group(1).strip())}
") + return f"@@CODEBLOCK_{len(placeholders) - 1}@@" + + normalized = fence_pattern.sub(replace_fence, normalized) + escaped = html.escape(normalized) + escaped = re.sub(r"(?\1", escaped) + escaped = re.sub(r"`([^`\n]+)`", r"\1", escaped) + escaped = re.sub(r"(?m)^\*\s+", "• ", escaped) + escaped = re.sub(r"(?m)^-\s+", "• ", escaped) + for index, block in enumerate(placeholders): + escaped = escaped.replace(f"@@CODEBLOCK_{index}@@", block) + return escaped + + +def update_status_message( + api: TelegramAPI, + job_state: dict[str, Any], + text: str, +) -> None: + normalized = (text or "Пустой ответ.")[:4000] + chat_id = int(job_state["chat_id"]) + message_id = int(job_state.get("status_message_id") or 0) + if message_id: + api.edit_message_text(chat_id, message_id, normalized) + else: + message_id = api.send_message(chat_id, normalized) + job_state["status_message_id"] = message_id + job_state["status_message_text"] = normalized + + +def set_final_message( + api: TelegramAPI, + job_state: dict[str, Any], + text: str, +) -> None: + normalized = text or "Пустой ответ." + chunk_size = 3800 + first_chunk = normalized[:chunk_size] + rendered = render_markdownish_html(first_chunk) + chat_id = int(job_state["chat_id"]) + message_id = int(job_state.get("status_message_id") or 0) + if message_id: + api.edit_message_text(chat_id, message_id, rendered, parse_mode="HTML") + else: + job_state["status_message_id"] = api.send_message( + chat_id, + rendered, + parse_mode="HTML", + ) + job_state["status_message_text"] = first_chunk + for start in range(chunk_size, len(normalized), chunk_size): + api.send_message( + int(job_state["chat_id"]), + render_markdownish_html(normalized[start : start + chunk_size]), + parse_mode="HTML", + ) + + +def keep_job_typing( + api: TelegramAPI, + job_state: dict[str, Any], +) -> None: + now = time.time() + last_typing_at = float(job_state.get("last_typing_at") or 0.0) + if now - last_typing_at < TYPING_INTERVAL_SECONDS: + return + api.send_chat_action(int(job_state["chat_id"]), "typing") + job_state["last_typing_at"] = now + + def summarize_event(event: dict[str, Any]) -> str | None: event_type = event.get("type") if event_type == "job_status": @@ -93,14 +172,23 @@ def summarize_event(event: dict[str, Any]) -> str | None: return None +def format_approval_keyboard(approval_id: str) -> dict[str, Any]: + return { + "inline_keyboard": [ + [ + {"text": "Approve", "callback_data": f"approve:{approval_id}"}, + {"text": "Reject", "callback_data": f"reject:{approval_id}"}, + ] + ] + } + + def format_approval_request(event: dict[str, Any]) -> str: return ( "Нужно подтверждение инструмента.\n" f"approval_id: {event.get('approval_id')}\n" f"tool: {event.get('tool_name')}\n" - f"args: {json.dumps(event.get('arguments', {}), ensure_ascii=False)}\n\n" - f"/approve {event.get('approval_id')}\n" - f"/reject {event.get('approval_id')}" + f"args: {json.dumps(event.get('arguments', {}), ensure_ascii=False)}" ) @@ -203,7 +291,7 @@ def start_chat_job( ) -> None: session_id = state.setdefault("sessions", {}).get(session_key) prefix = "Обрабатываю отложенный запрос..." if delayed else "Обрабатываю запрос..." - api.send_message(chat_id, prefix) + status_message_id = api.send_message(chat_id, prefix) start_result = post_json( f"{config.server_url}/api/v1/chat/start", { @@ -220,7 +308,9 @@ def start_chat_job( "session_key": session_key, "session_id": start_result["session_id"], "seen_seq": 0, - "sent_statuses": [], + "status_message_id": status_message_id, + "status_message_text": prefix, + "last_typing_at": 0.0, } state.setdefault("chat_active_jobs", {})[str(chat_id)] = start_result["job_id"] @@ -392,6 +482,7 @@ def process_active_jobs( pending_approvals = state.setdefault("pending_approvals", {}) for job_id in list(active_jobs.keys()): job_state = active_jobs[job_id] + keep_job_typing(api, job_state) poll_result = post_json( f"{config.server_url}/api/v1/chat/poll", {"job_id": job_id, "since_seq": job_state.get("seen_seq", 0)}, @@ -404,25 +495,22 @@ def process_active_jobs( "approval_id": event["approval_id"], "job_id": job_id, } - send_text_chunks( - api, + api.send_message( int(job_state["chat_id"]), format_approval_request(event), + reply_markup=format_approval_keyboard(str(event["approval_id"])), ) continue summary = summarize_event(event) - sent_statuses = set(job_state.get("sent_statuses", [])) - if summary and summary not in sent_statuses: - api.send_message(int(job_state["chat_id"]), summary[:4000]) - sent_statuses.add(summary) - job_state["sent_statuses"] = sorted(sent_statuses) + if summary and summary != job_state.get("status_message_text"): + update_status_message(api, job_state, summary) status = poll_result.get("status") if status == "completed": state["sessions"][job_state["session_key"]] = poll_result["session_id"] - send_text_chunks( + set_final_message( api, - int(job_state["chat_id"]), + job_state, poll_result.get("answer") or "Пустой ответ от модели.", ) active_jobs.pop(job_id, None) @@ -432,9 +520,9 @@ def process_active_jobs( pending_approvals.pop(str(job_state["chat_id"]), None) start_next_queued_job(api, config, state, int(job_state["chat_id"])) elif status == "failed": - send_text_chunks( + set_final_message( api, - int(job_state["chat_id"]), + job_state, f"Job завершился с ошибкой: {poll_result.get('error')}", ) active_jobs.pop(job_id, None) @@ -444,9 +532,9 @@ def process_active_jobs( pending_approvals.pop(str(job_state["chat_id"]), None) start_next_queued_job(api, config, state, int(job_state["chat_id"])) elif status == "canceled": - send_text_chunks( + set_final_message( api, - int(job_state["chat_id"]), + job_state, f"Job отменён: {poll_result.get('error') or 'Canceled by operator'}", ) active_jobs.pop(job_id, None) @@ -643,6 +731,53 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m start_or_queue_chat_job(api, config, state, chat_id, user_id, session_key, text) +def handle_callback_query( + api: TelegramAPI, + config: BotConfig, + state: dict[str, Any], + callback_query: dict[str, Any], +) -> None: + callback_query_id = str(callback_query.get("id") or "") + data = str(callback_query.get("data") or "") + message = callback_query.get("message") or {} + chat = message.get("chat") or {} + chat_id = int(chat.get("id")) + message_id = int(message.get("message_id")) + actor = str((callback_query.get("from") or {}).get("id") or chat_id) + if ":" not in data: + api.answer_callback_query(callback_query_id, "Неизвестное действие") + return + + action, approval_id = data.split(":", 1) + if action not in {"approve", "reject"}: + api.answer_callback_query(callback_query_id, "Неизвестное действие") + return + + response = post_json( + f"{config.server_url}/api/v1/approval/respond", + { + "approval_id": approval_id, + "approved": action == "approve", + "actor": actor, + }, + ) + status = str(response.get("status") or "unknown") + pending = state.setdefault("pending_approvals", {}).get(str(chat_id)) + if response.get("status") != "pending": + state["pending_approvals"].pop(str(chat_id), None) + + api.edit_message_text( + chat_id, + message_id, + f"Approval {approval_id}: {status}", + reply_markup={"inline_keyboard": []}, + ) + api.answer_callback_query( + callback_query_id, + "Подтверждено" if action == "approve" else "Отклонено", + ) + + def main() -> None: config = BotConfig.load() api = TelegramAPI(config.token, proxy_url=config.proxy_url) @@ -661,6 +796,9 @@ def main() -> None: message = update.get("message") if message: handle_message(api, config, state, message) + callback_query = update.get("callback_query") + if callback_query: + handle_callback_query(api, config, state, callback_query) save_state(state) save_state(state) except Exception as exc: diff --git a/bot/telegram_api.py b/bot/telegram_api.py index 9b0aa70..b42a6de 100644 --- a/bot/telegram_api.py +++ b/bot/telegram_api.py @@ -36,5 +36,62 @@ class TelegramAPI: response = self._get("getUpdates", params) return response.get("result", []) - def send_message(self, chat_id: int, text: str) -> None: - self._post("sendMessage", {"chat_id": chat_id, "text": text}) + def send_message( + self, + chat_id: int, + text: str, + *, + parse_mode: str | None = None, + reply_markup: dict[str, Any] | None = None, + ) -> int: + payload: dict[str, Any] = {"chat_id": chat_id, "text": text} + if parse_mode: + payload["parse_mode"] = parse_mode + if reply_markup: + payload["reply_markup"] = reply_markup + response = self._post("sendMessage", payload) + result = response.get("result") or {} + return int(result.get("message_id", 0)) + + def edit_message_text( + self, + chat_id: int, + message_id: int, + text: str, + *, + parse_mode: str | None = None, + reply_markup: dict[str, Any] | None = None, + ) -> None: + try: + payload: dict[str, Any] = { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + } + if parse_mode: + payload["parse_mode"] = parse_mode + if reply_markup is not None: + payload["reply_markup"] = reply_markup + self._post( + "editMessageText", + payload, + ) + except Exception as exc: + if "message is not modified" in str(exc).lower(): + return + raise + + def send_chat_action(self, chat_id: int, action: str = "typing") -> None: + self._post( + "sendChatAction", + { + "chat_id": chat_id, + "action": action, + }, + ) + + def answer_callback_query(self, callback_query_id: str, text: str | None = None) -> None: + payload: dict[str, Any] = {"callback_query_id": callback_query_id} + if text: + payload["text"] = text + self._post("answerCallbackQuery", payload)