from __future__ import annotations import html import json import re import time from pathlib import Path from typing import Any from urllib import error, request from config import BotConfig from telegram_api import TelegramAPI STATE_FILE = Path(__file__).resolve().parent.parent / ".new-qwen" / "telegram-state.json" TYPING_INTERVAL_SECONDS = 4.0 BOT_COMMANDS = [ {"command": "help", "description": "Список команд"}, {"command": "status", "description": "Статус сервера и чата"}, {"command": "auth", "description": "Запустить Qwen OAuth"}, {"command": "provider", "description": "Выбрать провайдера"}, {"command": "model", "description": "Выбрать модель"}, {"command": "session", "description": "Показать сессию"}, {"command": "clear", "description": "Очистить контекст"}, {"command": "cancel", "description": "Отменить активный job"}, ] def load_state() -> dict[str, Any]: if not STATE_FILE.exists(): return { "offset": None, "sessions": {}, "auth_flows": {}, "active_jobs": {}, "chat_active_jobs": {}, "chat_queues": {}, "pending_approvals": {}, "chat_preferences": {}, } state = json.loads(STATE_FILE.read_text(encoding="utf-8")) state.setdefault("sessions", {}) state.setdefault("auth_flows", {}) state.setdefault("active_jobs", {}) state.setdefault("chat_active_jobs", {}) state.setdefault("chat_queues", {}) state.setdefault("pending_approvals", {}) state.setdefault("chat_preferences", {}) return state def save_state(state: dict[str, Any]) -> None: STATE_FILE.parent.mkdir(parents=True, exist_ok=True) STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") def post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]: data = json.dumps(payload).encode("utf-8") req = request.Request( url, data=data, headers={"Content-Type": "application/json"}, method="POST", ) with request.urlopen(req, timeout=300) as response: return json.loads(response.read().decode("utf-8")) def get_json(url: str) -> dict[str, Any]: with request.urlopen(url, timeout=60) as response: return json.loads(response.read().decode("utf-8")) def send_text_chunks(api: TelegramAPI, chat_id: int, text: str) -> None: normalized = text or "Пустой ответ." chunk_size = 3800 for start in range(0, len(normalized), chunk_size): api.send_message(chat_id, normalized[start : start + chunk_size]) def ensure_bot_commands(api: TelegramAPI, state: dict[str, Any]) -> None: api.set_my_commands(BOT_COMMANDS) def get_provider_catalog(config: BotConfig) -> dict[str, Any]: return get_json(f"{config.server_url}/api/v1/provider-catalog") def get_chat_preferences(state: dict[str, Any], chat_id: int) -> dict[str, Any]: prefs = state.setdefault("chat_preferences", {}).setdefault(str(chat_id), {}) prefs.setdefault("provider", None) prefs.setdefault("model", None) return prefs def get_selected_provider_and_model( state: dict[str, Any], chat_id: int, catalog: dict[str, Any], ) -> tuple[str | None, str | None]: prefs = get_chat_preferences(state, chat_id) provider = prefs.get("provider") or catalog.get("default_provider") model = prefs.get("model") providers = {item.get("name"): item for item in catalog.get("providers", [])} provider_info = providers.get(provider or "") available_models = provider_info.get("models", []) if provider_info else [] if not model and available_models: model = available_models[0].get("id") return provider, model def format_status_text(job_state: dict[str, Any], text: str) -> str: del job_state return (text or "Пустой ответ.")[:4000] def format_final_signature(job_state: dict[str, Any]) -> str: model = job_state.get("model") if isinstance(job_state.get("model"), str) else None if not model: return "" return f"\n\n{html.escape(model)}" 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 = format_status_text(job_state, text) 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 job_state["status_body_text"] = text or "Пустой ответ." def set_final_message( api: TelegramAPI, job_state: dict[str, Any], text: str, ) -> None: normalized = text or "Пустой ответ." chunk_size = 3800 signature = format_final_signature(job_state) first_chunk = normalized[:chunk_size] rendered = render_markdownish_html(first_chunk) + signature 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": message = str(event.get("message") or "").strip() if message == "Запрос принят сервером": return "Смотрю, что можно сделать." if message == "Ответ готов": return "Формулирую ответ." return message or None if event_type == "model_request": return "Думаю над ответом." if event_type == "tool_call": tool_name = str(event.get("name") or "") if tool_name in {"read_file", "list_files", "glob_search", "grep_text", "stat_path"}: return "Просматриваю файлы и контекст." if tool_name in {"git_status", "git_diff"}: return "Проверяю изменения в проекте." if tool_name in {"exec_command"}: return "Проверяю окружение и команды." if tool_name in {"web_search"}: return "Ищу нужную информацию." return "Проверяю детали." if event_type == "tool_result": result = event.get("result", {}) if isinstance(result, dict) and "error" in result: return "Наткнулся на проблему, перепроверяю." return "Собрал нужные данные." if event_type == "error": return f"Ошибка: {event.get('message')}" if event_type == "approval_result": status = event.get("status") tool_name = event.get("tool_name") if status == "approved": return f"Получил подтверждение для {tool_name}." return f"Подтверждение для {tool_name} отклонено." 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_provider_keyboard(catalog: dict[str, Any]) -> dict[str, Any]: rows: list[list[dict[str, str]]] = [] row: list[dict[str, str]] = [] for item in catalog.get("providers", []): name = str(item.get("name") or "") if not name: continue row.append({"text": name, "callback_data": f"provider:{name}"}) if len(row) == 2: rows.append(row) row = [] if row: rows.append(row) return {"inline_keyboard": rows} def format_model_keyboard(provider: str, models: list[dict[str, str]]) -> dict[str, Any]: rows = [ [{"text": str(item.get("name") or item.get("id") or "-"), "callback_data": f"model:{provider}:{item.get('id')}"}] for item in models if item.get("id") ] return {"inline_keyboard": rows} def set_chat_provider( state: dict[str, Any], chat_id: int, provider: str | None, *, reset_model: bool = True, ) -> dict[str, Any]: prefs = get_chat_preferences(state, chat_id) prefs["provider"] = provider if reset_model: prefs["model"] = None return prefs def set_chat_model(state: dict[str, Any], chat_id: int, model: str | None) -> dict[str, Any]: prefs = get_chat_preferences(state, chat_id) prefs["model"] = model return prefs def format_provider_selection( catalog: dict[str, Any], selected_provider: str | None, ) -> str: lines = ["Выбери провайдера."] for item in catalog.get("providers", []): name = str(item.get("name") or "") if not name: continue marker = "•" if name == selected_provider else " " availability = "ready" if item.get("available") else f"unavailable: {item.get('reason') or 'unknown'}" lines.append(f"{marker} {name} ({availability})") return "\n".join(lines) def format_model_selection( provider: str, models: list[dict[str, str]], selected_model: str | None, ) -> str: lines = [f"Выбери модель для {provider}."] for item in models: model_id = str(item.get("id") or "") if not model_id: continue marker = "•" if model_id == selected_model else " " description = str(item.get("description") or "").strip() suffix = f" ({description})" if description else "" lines.append(f"{marker} {model_id}{suffix}") return "\n".join(lines) 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)}" ) def get_auth_flow(state: dict[str, Any], chat_id: int) -> dict[str, Any] | None: return state.setdefault("auth_flows", {}).get(str(chat_id)) def start_auth_flow( api: TelegramAPI, config: BotConfig, state: dict[str, Any], chat_id: int, *, force_new: bool = False, ) -> dict[str, Any]: existing = get_auth_flow(state, chat_id) now = time.time() if existing and not force_new and existing.get("expires_at", 0) > now: send_text_chunks( api, chat_id, "Авторизация Qwen OAuth ещё не завершена.\n" f"Открой ссылку:\n{existing['verification_uri_complete']}\n\n" "Бот сам проверит завершение и продолжит работу.", ) return existing started = post_json(f"{config.server_url}/api/v1/auth/device/start", {}) flow = { "flow_id": started["flow_id"], "user_code": started.get("user_code"), "verification_uri": started.get("verification_uri"), "verification_uri_complete": started["verification_uri_complete"], "expires_at": started["expires_at"], "interval_seconds": started.get("interval_seconds", 2), "next_poll_at": now + started.get("interval_seconds", 2), "pending_messages": existing.get("pending_messages", []) if existing else [], } state.setdefault("auth_flows", {})[str(chat_id)] = flow send_text_chunks( api, chat_id, "Нужна авторизация Qwen OAuth.\n" f"Открой ссылку:\n{flow['verification_uri_complete']}\n\n" f"user_code: {flow.get('user_code')}\n" "После подтверждения бот сам продолжит работу.", ) return flow def ensure_auth(api: TelegramAPI, config: BotConfig, state: dict[str, Any], chat_id: int) -> bool: status = get_json(f"{config.server_url}/api/v1/auth/status") if status.get("ready") or status.get("available_providers"): return True default_provider = status.get("default_provider") fallback_providers = status.get("fallback_providers") or [] if default_provider != "qwen" and "qwen" not in fallback_providers: api.send_message( chat_id, "На сервере нет доступных model provider-ов. " f"Текущий default_provider: {default_provider}. " "Для GigaChat/YandexGPT нужно настроить серверные credentials.", ) return False start_auth_flow(api, config, state, chat_id) return False def enqueue_pending_message( state: dict[str, Any], chat_id: int, user_id: str, session_key: str, text: str, ) -> None: flow = get_auth_flow(state, chat_id) if not flow: return pending_messages = flow.setdefault("pending_messages", []) pending_messages.append( { "user_id": user_id, "session_key": session_key, "text": text, "created_at": int(time.time()), } ) def start_chat_job( api: TelegramAPI, config: BotConfig, state: dict[str, Any], chat_id: int, user_id: str, session_key: str, text: str, *, delayed: bool = False, ) -> None: session_id = state.setdefault("sessions", {}).get(session_key) catalog = get_provider_catalog(config) provider, model = get_selected_provider_and_model(state, chat_id, catalog) prefix = "Возвращаюсь к вашему сообщению." if delayed else "Смотрю, что можно сделать." status_message_id = api.send_message(chat_id, prefix) start_result = post_json( f"{config.server_url}/api/v1/chat/start", { "session_id": session_id, "user_id": user_id, "message": text, "provider": provider, "model": model, }, ) state["sessions"][session_key] = start_result["session_id"] state.setdefault("active_jobs", {})[start_result["job_id"]] = { "job_id": start_result["job_id"], "chat_id": chat_id, "user_id": user_id, "session_key": session_key, "session_id": start_result["session_id"], "seen_seq": 0, "status_message_id": status_message_id, "status_message_text": prefix, "status_body_text": prefix, "requested_provider": provider, "requested_model": model, "provider": start_result.get("provider") or provider, "model": start_result.get("model") or model, "fallback_notified": False, "last_typing_at": 0.0, } state.setdefault("chat_active_jobs", {})[str(chat_id)] = start_result["job_id"] def enqueue_chat_message( state: dict[str, Any], chat_id: int, user_id: str, session_key: str, text: str, *, delayed: bool = False, ) -> int: queue = state.setdefault("chat_queues", {}).setdefault(str(chat_id), []) queue.append( { "user_id": user_id, "session_key": session_key, "text": text, "delayed": delayed, "created_at": int(time.time()), } ) return len(queue) def start_or_queue_chat_job( api: TelegramAPI, config: BotConfig, state: dict[str, Any], chat_id: int, user_id: str, session_key: str, text: str, *, delayed: bool = False, ) -> None: active_job_id = state.setdefault("chat_active_jobs", {}).get(str(chat_id)) if active_job_id: queue_size = enqueue_chat_message( state, chat_id, user_id, session_key, text, delayed=delayed, ) api.send_message( chat_id, f"В этом чате уже есть активный запрос. Сообщение поставлено в очередь: {queue_size}.", ) return start_chat_job( api, config, state, chat_id, user_id, session_key, text, delayed=delayed, ) def start_next_queued_job( api: TelegramAPI, config: BotConfig, state: dict[str, Any], chat_id: int, ) -> None: if state.setdefault("chat_active_jobs", {}).get(str(chat_id)): return queue = state.setdefault("chat_queues", {}).get(str(chat_id)) or [] if not queue: return next_item = queue.pop(0) if not queue: state["chat_queues"].pop(str(chat_id), None) start_chat_job( api, config, state, chat_id, next_item["user_id"], next_item["session_key"], next_item["text"], delayed=bool(next_item.get("delayed")), ) def poll_auth_flow( api: TelegramAPI, config: BotConfig, state: dict[str, Any], chat_id: int, *, force: bool = False, ) -> bool: flow = get_auth_flow(state, chat_id) if not flow: return False now = time.time() if flow.get("expires_at", 0) <= now: state["auth_flows"].pop(str(chat_id), None) api.send_message(chat_id, "OAuth flow истёк. Запусти /auth ещё раз.") return False if not force and now < flow.get("next_poll_at", 0): return False try: result = post_json( f"{config.server_url}/api/v1/auth/device/poll", {"flow_id": flow["flow_id"]}, ) except error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") state["auth_flows"].pop(str(chat_id), None) send_text_chunks( api, chat_id, "Не удалось завершить OAuth flow на сервере.\n" f"Ответ сервера: {body}\n" "Запусти /auth ещё раз.", ) return False if not result.get("done"): interval = result.get("interval_seconds", flow.get("interval_seconds", 2)) flow["interval_seconds"] = interval flow["next_poll_at"] = now + interval return False state["auth_flows"].pop(str(chat_id), None) api.send_message(chat_id, "Qwen OAuth успешно настроен.") for item in flow.get("pending_messages", []): start_or_queue_chat_job( api, config, state, chat_id, item["user_id"], item["session_key"], item["text"], delayed=True, ) if not flow.get("pending_messages"): api.send_message(chat_id, "Можно отправлять обычные сообщения.") return True def process_auth_flows( api: TelegramAPI, config: BotConfig, state: dict[str, Any], ) -> None: for chat_id_raw in list(state.setdefault("auth_flows", {}).keys()): try: poll_auth_flow(api, config, state, int(chat_id_raw), force=False) except Exception as exc: print(f"auth flow poll error for chat {chat_id_raw}: {exc}") def process_active_jobs( api: TelegramAPI, config: BotConfig, state: dict[str, Any], ) -> None: active_jobs = state.setdefault("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)}, ) for event in poll_result.get("events", []): seq = int(event.get("seq", 0)) job_state["seen_seq"] = max(job_state.get("seen_seq", 0), seq) if event.get("type") == "approval_request": pending_approvals[str(job_state["chat_id"])] = { "approval_id": event["approval_id"], "job_id": job_id, } api.send_message( int(job_state["chat_id"]), format_approval_request(event), reply_markup=format_approval_keyboard(str(event["approval_id"])), ) continue if event.get("type") == "model_request": selection_reason = str(event.get("selection_reason") or "") if ( "fell back to" in selection_reason and not job_state.get("fallback_notified") ): requested_provider = job_state.get("requested_provider") or "unknown" api.send_message( int(job_state["chat_id"]), "Выбранный провайдер не ответил, поэтому продолжаю через запасной вариант.\n" f"Изначально был выбран: {requested_provider}", ) job_state["fallback_notified"] = True if event.get("provider"): job_state["provider"] = event.get("provider") if event.get("model"): job_state["model"] = event.get("model") summary = summarize_event(event) if summary and summary != job_state.get("status_body_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"] set_final_message( api, job_state, poll_result.get("answer") or "Пустой ответ от модели.", ) active_jobs.pop(job_id, None) state.setdefault("chat_active_jobs", {}).pop(str(job_state["chat_id"]), None) pending = pending_approvals.get(str(job_state["chat_id"])) if pending and pending.get("job_id") == job_id: pending_approvals.pop(str(job_state["chat_id"]), None) start_next_queued_job(api, config, state, int(job_state["chat_id"])) elif status == "failed": set_final_message( api, job_state, f"Job завершился с ошибкой: {poll_result.get('error')}", ) active_jobs.pop(job_id, None) state.setdefault("chat_active_jobs", {}).pop(str(job_state["chat_id"]), None) pending = pending_approvals.get(str(job_state["chat_id"])) if pending and pending.get("job_id") == job_id: pending_approvals.pop(str(job_state["chat_id"]), None) start_next_queued_job(api, config, state, int(job_state["chat_id"])) elif status == "canceled": set_final_message( api, job_state, f"Job отменён: {poll_result.get('error') or 'Canceled by operator'}", ) active_jobs.pop(job_id, None) state.setdefault("chat_active_jobs", {}).pop(str(job_state["chat_id"]), None) pending = pending_approvals.get(str(job_state["chat_id"])) if pending and pending.get("job_id") == job_id: pending_approvals.pop(str(job_state["chat_id"]), None) start_next_queued_job(api, config, state, int(job_state["chat_id"])) def cancel_chat_work( api: TelegramAPI, config: BotConfig, state: dict[str, Any], chat_id: int, actor: str, *, clear_queue: bool, ) -> bool: canceled = False active_job_id = state.setdefault("chat_active_jobs", {}).get(str(chat_id)) if active_job_id: post_json( f"{config.server_url}/api/v1/chat/cancel", { "job_id": active_job_id, "actor": actor, "reason": "Canceled from Telegram bot", }, ) canceled = True if clear_queue: queue = state.setdefault("chat_queues", {}).pop(str(chat_id), []) canceled = canceled or bool(queue) pending = state.setdefault("pending_approvals", {}).get(str(chat_id)) if pending and pending.get("job_id") == active_job_id: state["pending_approvals"].pop(str(chat_id), None) return canceled def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], message: dict[str, Any]) -> None: chat_id = message["chat"]["id"] user_id = str(message.get("from", {}).get("id", chat_id)) text = (message.get("text") or "").strip() if not text: api.send_message(chat_id, "Поддерживаются только текстовые сообщения.") return session_key = f"{chat_id}:{user_id}" session_id = state.setdefault("sessions", {}).get(session_key) state.setdefault("auth_flows", {}) if text == "/start": api.send_message( chat_id, "new-qwen bot готов.\nКоманды доступны через Telegram menu: /help, /auth, /status, /provider, /model, /session, /cancel, /clear.", ) return if text == "/help": api.send_message( chat_id, "Команды:\n" "/auth - начать Qwen OAuth\n" "/auth_check [flow_id] - проверить авторизацию\n" "/status - статус OAuth и сервера\n" "/provider [name] - выбрать провайдера\n" "/model [id] - выбрать модель\n" "/session - показать текущую сессию\n" "/cancel - отменить активный запрос и очистить очередь\n" "/approve [approval_id] - подтвердить инструмент\n" "/reject [approval_id] - отклонить инструмент\n" "/clear - очистить контекст", ) return if text.startswith("/approve") or text.startswith("/reject"): parts = text.split(maxsplit=1) approval = state.setdefault("pending_approvals", {}).get(str(chat_id)) approval_id = parts[1] if len(parts) == 2 else approval.get("approval_id") if approval else None if not approval_id: api.send_message(chat_id, "Нет pending approval для этого чата.") return response = post_json( f"{config.server_url}/api/v1/approval/respond", { "approval_id": approval_id, "approved": text.startswith("/approve"), "actor": user_id, }, ) if response.get("status") != "pending": state["pending_approvals"].pop(str(chat_id), None) api.send_message( chat_id, f"Approval {approval_id}: {response.get('status')}", ) return if text == "/auth": start_auth_flow(api, config, state, chat_id, force_new=True) return if text.startswith("/auth_check"): parts = text.split(maxsplit=1) if len(parts) == 2: flow = get_auth_flow(state, chat_id) if flow: flow["flow_id"] = parts[1] flow = get_auth_flow(state, chat_id) if not flow: api.send_message(chat_id, "Нет активного flow_id. Сначала вызови /auth.") return if not poll_auth_flow(api, config, state, chat_id, force=True): api.send_message(chat_id, "Авторизация ещё не завершена. Повторите команду через пару секунд.") return if text == "/status": status = get_json(f"{config.server_url}/api/v1/auth/status") catalog = get_provider_catalog(config) queue_size = len(state.setdefault("chat_queues", {}).get(str(chat_id), [])) active_job = state.setdefault("chat_active_jobs", {}).get(str(chat_id)) provider, model = get_selected_provider_and_model(state, chat_id, catalog) providers_info = "\n".join( [ f" - {item['name']}: model={item.get('model')}, available={item.get('available')}" for item in catalog.get("providers", []) ] ) or " Нет доступных провайдеров" send_text_chunks( api, chat_id, "Сервер доступен.\n" f"OAuth: {'configured' if status.get('authenticated') else 'not configured'}\n" f"ready: {status.get('ready')}\n" f"available_providers: {', '.join(status.get('available_providers') or []) or '-'}\n" f"default_provider: {status.get('default_provider')}\n" f"fallback_providers: {', '.join(status.get('fallback_providers') or []) or '-'}\n" f"selected_provider: {provider}\n" f"selected_model: {model}\n" f"resource_url: {status.get('resource_url')}\n" f"expires_at: {status.get('expires_at')}\n" f"tool_policy: {status.get('tool_policy')}\n" f"Провайдеры и модели:\n{providers_info}\n" f"active_job: {active_job}\n" f"queued_messages: {queue_size}", ) return if text.startswith("/provider"): catalog = get_provider_catalog(config) parts = text.split(maxsplit=1) if len(parts) == 1: provider, _ = get_selected_provider_and_model(state, chat_id, catalog) api.send_message( chat_id, format_provider_selection(catalog, provider), reply_markup=format_provider_keyboard(catalog), ) return provider_name = parts[1].strip() provider_names = {str(item.get("name") or "") for item in catalog.get("providers", [])} if provider_name not in provider_names: api.send_message(chat_id, f"Неизвестный provider: {provider_name}") return set_chat_provider(state, chat_id, provider_name, reset_model=True) _, model = get_selected_provider_and_model(state, chat_id, catalog) api.send_message(chat_id, f"Выбран provider: {provider_name}\nmodel: {model or '-'}") return if text.startswith("/model"): catalog = get_provider_catalog(config) provider, selected_model = get_selected_provider_and_model(state, chat_id, catalog) provider_map = {str(item.get("name") or ""): item for item in catalog.get("providers", [])} provider_info = provider_map.get(provider or "") models = provider_info.get("models", []) if provider_info else [] parts = text.split(maxsplit=1) if len(parts) == 1: api.send_message( chat_id, format_model_selection(provider or "-", models, selected_model), reply_markup=format_model_keyboard(provider or "-", models), ) return model_id = parts[1].strip() valid_model_ids = {str(item.get("id") or "") for item in models} if model_id not in valid_model_ids: api.send_message(chat_id, f"Неизвестная модель для {provider}: {model_id}") return set_chat_model(state, chat_id, model_id) api.send_message(chat_id, f"Выбрана модель: {model_id}\nprovider: {provider}") return if text == "/cancel": canceled = cancel_chat_work( api, config, state, chat_id, user_id, clear_queue=True, ) if canceled: api.send_message(chat_id, "Активный job отменён, очередь чата очищена.") else: api.send_message(chat_id, "Для этого чата нет активных или queued jobs.") return if text == "/session": if not session_id: api.send_message(chat_id, "У этого чата ещё нет активной сессии.") return session = post_json( f"{config.server_url}/api/v1/session/get", {"session_id": session_id}, ) send_text_chunks( api, chat_id, "session_id: {session_id}\nmessages: {count}\nlast_answer: {last_answer}".format( session_id=session_id, count=len(session.get("messages", [])), last_answer=session.get("last_answer") or "-", ), ) return if text == "/clear": cancel_chat_work( api, config, state, chat_id, user_id, clear_queue=True, ) if session_id: post_json(f"{config.server_url}/api/v1/session/clear", {"session_id": session_id}) state["sessions"].pop(session_key, None) api.send_message(chat_id, "Контекст сессии очищен.") return if not ensure_auth(api, config, state, chat_id): enqueue_pending_message(state, chat_id, user_id, session_key, text) api.send_message(chat_id, "Сообщение поставлено в очередь до завершения авторизации.") return 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"}: if action == "provider": catalog = get_provider_catalog(config) provider_names = {str(item.get("name") or "") for item in catalog.get("providers", [])} if approval_id not in provider_names: api.answer_callback_query(callback_query_id, "Неизвестный провайдер") return set_chat_provider(state, chat_id, approval_id, reset_model=True) provider, model = get_selected_provider_and_model(state, chat_id, catalog) api.edit_message_text( chat_id, message_id, f"Выбран provider: {provider}\nmodel: {model or '-'}", reply_markup={"inline_keyboard": []}, ) api.answer_callback_query(callback_query_id, "Провайдер переключен") return if action == "model": provider, model_id = approval_id.split(":", 1) if ":" in approval_id else ("", "") catalog = get_provider_catalog(config) provider_map = {str(item.get("name") or ""): item for item in catalog.get("providers", [])} provider_info = provider_map.get(provider) model_ids = { str(item.get("id") or "") for item in (provider_info.get("models", []) if provider_info else []) } if model_id not in model_ids: api.answer_callback_query(callback_query_id, "Неизвестная модель") return set_chat_provider(state, chat_id, provider, reset_model=False) set_chat_model(state, chat_id, model_id) api.edit_message_text( chat_id, message_id, f"Выбрана модель: {model_id}\nprovider: {provider}", reply_markup={"inline_keyboard": []}, ) api.answer_callback_query(callback_query_id, "Модель переключена") return 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) state = load_state() ensure_bot_commands(api, state) save_state(state) print("new-qwen bot polling started") while True: try: process_auth_flows(api, config, state) process_active_jobs(api, config, state) timeout = config.poll_timeout if state.get("active_jobs"): timeout = min(timeout, 3) updates = api.get_updates(state.get("offset"), timeout) for update in updates: state["offset"] = update["update_id"] + 1 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: print(f"bot loop error: {exc}") time.sleep(3) if __name__ == "__main__": main()