"""Telegram bot — routes messages to Copilot SDK with persistent shared sessions.""" import asyncio import hashlib import json import logging import re from decimal import Decimal from io import BytesIO from pathlib import Path from typing import Any from copilot import define_tool from copilot.generated.session_events import SessionEventType from copilot.tools import ToolInvocation from pydantic import BaseModel, Field from telegram import ( BotCommand, BotCommandScopeAllChatAdministrators, BotCommandScopeAllGroupChats, BotCommandScopeDefault, InlineKeyboardButton, InlineKeyboardMarkup, InputFile, Update, User, helpers, ) from telegram.error import RetryAfter, TelegramError from telegram.ext import ( Application, ApplicationHandlerStop, CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, PicklePersistence, TypeHandler, filters, ) import tools as _tools_init # noqa: F401 — registers tool sets from background_tasks import BackgroundTaskManager from config import settings from copilot_runtime import copilot, format_prompt_with_history, stream_session from instance import BASE_CONTEXT, SKILLS_CONTEXT, TELEGRAM_START_MESSAGE from learning import extract_learnings_from_turn, format_learnings_for_prompt from llm_costs import extract_usage_and_cost, format_cost_value, summarize_usage def _get_advisor_usage(thread_id: str | None) -> dict | None: """Fetch advisor sidecar usage for *thread_id*, if the module is loaded.""" if not thread_id: return None try: from tools.advisor import get_advisor_usage return get_advisor_usage(thread_id) except ImportError: return None from model_selection import ( ModelSelection, ModelSelectionError, build_provider_config, default_selection, format_known_models, format_selection, resolve_selection, ) from prompt_utils import generate_diff from tool_pipeline import ( active_toolset_names, build_capability_fragment, build_integration_tools, build_onboarding_fragment, ) from tool_registry import registry as tool_registry from ux import ( append_elapsed_status, busy_message, extract_final_text, format_session_error, format_tool_counts, markdown_to_telegram_html, stream_status_updates, stream_trace_event, working_message, ) from web_fallback_store import WebFallbackStore logger = logging.getLogger(__name__) # ── Telegram access control (dynamic owner-approval) ──────────── _OWNER_TG_CHAT_ID: int | None = None if settings.OWNER_TELEGRAM_CHAT_ID.strip(): try: _OWNER_TG_CHAT_ID = int(settings.OWNER_TELEGRAM_CHAT_ID.strip()) logger.info("Owner Telegram chat ID configured: %d", _OWNER_TG_CHAT_ID) except ValueError: logger.warning("OWNER_TELEGRAM_CHAT_ID is not a valid integer, approval gate disabled") # Track pending approval requests so we don't spam the owner _pending_approval_notified: set[int] = set() async def _gate_telegram_access(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Gate Telegram access via owner-approval flow. - Owner (matching OWNER_TELEGRAM_CHAT_ID) → always allowed. - telegram_approved=True → allowed. - telegram_approved=False → silently blocked (denied). - telegram_approved=None (new/pending) → notify owner with approve/deny, tell user "waiting for approval", then block. - Callback queries for approval buttons are always let through. """ if not _OWNER_TG_CHAT_ID: return # no owner configured, open access # Always let approval callback queries through if update.callback_query and update.callback_query.data and update.callback_query.data.startswith("tg_approve:"): return tg_user = update.effective_user if not tg_user: return # system update, let it through # Owner is always allowed if tg_user.id == _OWNER_TG_CHAT_ID: return # Look up approval status from user_store import get_store as _get_user_store store = _get_user_store() db_user = store.resolve_or_create_user( provider="telegram", external_id=str(tg_user.id), display_name=tg_user.full_name or str(tg_user.id), ) if db_user.telegram_approved is True: return # approved if db_user.telegram_approved is False: raise ApplicationHandlerStop() # denied — silent drop # telegram_approved is None → pending approval if tg_user.id not in _pending_approval_notified: _pending_approval_notified.add(tg_user.id) # Send approval request to owner keyboard = InlineKeyboardMarkup( [ [ InlineKeyboardButton("✅ Approve", callback_data=f"tg_approve:yes:{db_user.id}"), InlineKeyboardButton("❌ Deny", callback_data=f"tg_approve:no:{db_user.id}"), ] ] ) user_info = ( f"New Telegram user requesting access:\n" f"• Name: {helpers.escape_markdown(tg_user.full_name or 'unknown')}\n" f"• Username: @{tg_user.username or 'none'}\n" f"• Telegram ID: {tg_user.id}\n" f"• Internal ID: {db_user.id}" ) try: await context.bot.send_message( chat_id=_OWNER_TG_CHAT_ID, text=user_info, parse_mode="HTML", reply_markup=keyboard, ) except TelegramError: logger.warning("Failed to send approval request to owner", exc_info=True) # Tell the user they're pending msg = update.effective_message if msg: try: await msg.reply_text("⏳ Your access request has been sent to the bot owner. Please wait for approval.") except TelegramError: pass raise ApplicationHandlerStop() async def _handle_approval_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle approve/deny inline keyboard callbacks from the owner.""" query = update.callback_query if not query or not query.data: return await query.answer() parts = query.data.split(":") if len(parts) != 3: return _, decision, user_id = parts from user_store import get_store as _get_user_store store = _get_user_store() db_user = store.get_user(user_id) if not db_user: await query.edit_message_text("⚠️ User not found in database.") return approved = decision == "yes" store.set_telegram_approval(user_id, approved) status = "✅ Approved" if approved else "❌ Denied" await query.edit_message_text(f"{status}: {db_user.display_name} (Telegram)") # If approved, notify the user if approved: # Find their Telegram ID from external_identities row = store.conn.execute( "SELECT external_id FROM external_identities WHERE user_id = ? AND provider = 'telegram'", (user_id,), ).fetchone() if row: tg_id = int(row["external_id"]) _pending_approval_notified.discard(tg_id) try: await context.bot.send_message( chat_id=tg_id, text="✅ Your access has been approved! You can now use the bot.", ) except TelegramError: logger.warning("Failed to notify approved user %s", tg_id, exc_info=True) MAX_HISTORY = 40 TG_MESSAGE_LIMIT = 4000 TG_TOPIC_NAME_LIMIT = 128 TG_STATUS_EDIT_INTERVAL = 5.0 TG_SYSTEM_PROMPT_LIMIT = 12000 SKILL_MENU_CALLBACK_PREFIX = "skill_menu:" SKILL_MENU_ITEMS = [ ("create-a-skill", "Create or update skills"), ] _ACTIVE_RUNS_KEY = "active_runs" _USER_MODEL_ALIASES_KEY = "user_model_aliases" # ── Alias file persistence (belt-and-suspenders alongside PicklePersistence) ── def _alias_file_path() -> Path: return Path(settings.TG_PERSISTENCE_DIR or settings.DATA_DIR).expanduser() / "aliases.json" def _load_alias_file() -> dict[str, dict[str, str]]: """Load all aliases from the JSON file. Returns {owner_key: {alias: target}}.""" p = _alias_file_path() if not p.exists(): return {} try: data = json.loads(p.read_text("utf-8")) if isinstance(data, dict): # Filter out any flat alias entries that leaked into the top level; # only keep entries where the value is a nested dict (owner -> aliases). return {k: v for k, v in data.items() if isinstance(v, dict)} except Exception: logger.warning("Failed to read alias file %s, starting fresh", p, exc_info=True) return {} def _save_alias_file(all_aliases: dict[str, dict[str, str]]) -> None: """Atomically write all aliases to the JSON file.""" p = _alias_file_path() p.parent.mkdir(parents=True, exist_ok=True) tmp = p.with_suffix(".tmp") try: tmp.write_text(json.dumps(all_aliases, indent=2, sort_keys=True), "utf-8") tmp.replace(p) except Exception: logger.warning("Failed to write alias file %s", p, exc_info=True) def _normalize_alias_name(raw_name: str) -> str: normalized = str(raw_name or "").strip().lower() if not normalized: raise ValueError("Alias name cannot be empty") if ":" in normalized or any(ch.isspace() for ch in normalized): raise ValueError("Alias names cannot contain spaces or `:`") if not re.fullmatch(r"[a-z0-9][a-z0-9._-]*", normalized): raise ValueError("Alias names may only use lowercase letters, numbers, `.`, `_`, and `-`") return normalized def _alias_owner_key(user: User | None) -> str | None: if user is None: return None return str(user.id) def _user_aliases(context: ContextTypes.DEFAULT_TYPE, user: User | None) -> dict[str, str]: owner_key = _alias_owner_key(user) if owner_key is None: return {} aliases = context.application.bot_data.get(_USER_MODEL_ALIASES_KEY) if not isinstance(aliases, dict): aliases = {} context.application.bot_data[_USER_MODEL_ALIASES_KEY] = aliases user_aliases = aliases.get(owner_key) if not isinstance(user_aliases, dict): user_aliases = {} aliases[owner_key] = user_aliases # Merge aliases from the durable JSON file (file wins for missing keys) file_aliases = _load_alias_file().get(owner_key, {}) for k, v in file_aliases.items(): if k not in user_aliases: user_aliases[k] = v normalized: dict[str, str] = {} changed = False for raw_name, raw_target in list(user_aliases.items()): try: alias_name = _normalize_alias_name(str(raw_name)) except ValueError: changed = True continue target = str(raw_target or "").strip() if not target: changed = True continue normalized[alias_name] = target if raw_name != alias_name or raw_target != target: changed = True if changed or user_aliases is not normalized: aliases[owner_key] = normalized return aliases[owner_key] def _resolve_user_model_alias( context: ContextTypes.DEFAULT_TYPE, user: User | None, requested_model: str ) -> tuple[str, str | None]: normalized_request = str(requested_model or "").strip() if not normalized_request: return normalized_request, None aliases = _user_aliases(context, user) alias_key = normalized_request.lower() target = aliases.get(alias_key) if not target: return normalized_request, None return target, alias_key def _set_user_model_alias( context: ContextTypes.DEFAULT_TYPE, user: User | None, alias_name: str, target_model: str ) -> tuple[str, str]: owner_key = _alias_owner_key(user) if owner_key is None: raise ValueError("User identity is unavailable for alias storage") normalized_alias = _normalize_alias_name(alias_name) normalized_target = str(target_model or "").strip() if not normalized_target: raise ValueError("Model target cannot be empty") aliases = _user_aliases(context, user) aliases[normalized_alias] = normalized_target # Immediately persist to JSON file (durable, survives crashes) _persist_aliases_to_file(context) return normalized_alias, normalized_target def _persist_aliases_to_file(context: ContextTypes.DEFAULT_TYPE) -> None: """Write the full alias dict from bot_data to the JSON file.""" all_aliases = context.application.bot_data.get(_USER_MODEL_ALIASES_KEY) if isinstance(all_aliases, dict): _save_alias_file(all_aliases) def _format_user_aliases(context: ContextTypes.DEFAULT_TYPE, user: User | None) -> str: aliases = _user_aliases(context, user) if not aliases: return "No aliases saved yet.\nUsage: /alias cheap vercel:google/gemma-4-31b-it" lines = ["Your model aliases"] for name in sorted(aliases): lines.append(f"- `{name}` → `{aliases[name]}`") return "\n".join(lines) def _active_runs(context: ContextTypes.DEFAULT_TYPE) -> dict[str, dict[str, Any]]: runs = context.application.bot_data.get(_ACTIVE_RUNS_KEY) if not isinstance(runs, dict): runs = {} context.application.bot_data[_ACTIVE_RUNS_KEY] = runs return runs def _register_active_run(context: ContextTypes.DEFAULT_TYPE, thread_id: str, run_state: dict[str, Any]) -> None: _active_runs(context)[thread_id] = run_state def _clear_active_run(context: ContextTypes.DEFAULT_TYPE, thread_id: str, run_state: dict[str, Any]) -> None: runs = _active_runs(context) if runs.get(thread_id) is run_state: runs.pop(thread_id, None) def _get_active_run( update: Update, context: ContextTypes.DEFAULT_TYPE ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: thread = _current_thread(update, context) if thread is None: return None, None return thread, _active_runs(context).get(thread["id"]) def _effective_topic_thread_id(update: Update) -> int: message = update.effective_message if message is None or not getattr(message, "is_topic_message", False): return 0 return int(getattr(message, "message_thread_id", 0) or 0) def _is_general_topic(update: Update) -> bool: chat = update.effective_chat if chat is None or not getattr(chat, "is_forum", False): return False return _effective_topic_thread_id(update) == 1 def _normalize_topic_name(raw_name: str) -> str: normalized = " ".join(str(raw_name or "").split()) if not normalized: raise ValueError("Topic name cannot be empty") if len(normalized) > TG_TOPIC_NAME_LIMIT: raise ValueError(f"Topic names must be {TG_TOPIC_NAME_LIMIT} characters or fewer") return normalized def _normalize_system_prompt_value(raw_prompt: str) -> str: normalized = str(raw_prompt or "").strip() if not normalized: raise ValueError("System instructions cannot be empty") if len(normalized) > TG_SYSTEM_PROMPT_LIMIT: raise ValueError(f"System instructions must be {TG_SYSTEM_PROMPT_LIMIT} characters or fewer.") return normalized def _format_prompt_diff_message(diff: str) -> str: header = "Proposed System Prompt Change:\n\n" if not diff: return header.rstrip() suffix = "\n\n[diff truncated]" max_diff_length = TG_MESSAGE_LIMIT - len(header) if len(diff) <= max_diff_length: return header + diff truncated_length = max_diff_length - len(suffix) preview = diff[: max(truncated_length, 0)].rstrip("\n") return header + preview + suffix def _topic_session_key(chat_id: int, thread_id: int) -> str: return f"topic:{chat_id}:{thread_id}" def _telegram_agent_context(update: Update) -> str | None: chat = update.effective_chat message = update.effective_message if chat is None or not getattr(chat, "is_forum", False): return None if _is_general_topic(update): current_location = "The current message is inside the General topic." elif getattr(message, "is_topic_message", False): current_location = "The current message is inside a named forum topic." else: current_location = "The current message is in a forum supergroup." return ( "You are running inside a Telegram forum supergroup. " f"{current_location} " "Telegram topic-management tools are available in this run. " "Use them when the user asks to create, rename, or organize forum topics. " "If the user wants one topic per repo or project, inspect the directory first and then create the requested topics. " "Your visible final reply still appears in the current chat or topic even if you create new topics elsewhere." ) def _build_telegram_topic_tools(update: Update, context: ContextTypes.DEFAULT_TYPE) -> list[Any]: chat = update.effective_chat if chat is None or not getattr(chat, "is_forum", False): return [] store = _store(context) class CreateTopicsParams(BaseModel): topic_names: list[str] initial_message: str | None = None async def _create_topics_handler(params: CreateTopicsParams, invocation: ToolInvocation) -> str: unique_names: list[str] = [] seen_names: set[str] = set() invalid_names: list[str] = [] for raw_name in params.topic_names: try: normalized_name = _normalize_topic_name(raw_name) except ValueError as error: invalid_names.append(f"{raw_name!r}: {error}") continue dedupe_key = normalized_name.casefold() if dedupe_key in seen_names: continue seen_names.add(dedupe_key) unique_names.append(normalized_name) if not unique_names: if invalid_names: return "No valid topic names were provided. " + "; ".join(invalid_names) return "No topic names were provided." seeded_message = str(params.initial_message or "").strip() selection = _get_selection(update, context) created_topics: list[str] = [] for topic_name in unique_names: forum_topic = await chat.create_forum_topic(name=topic_name) thread_id = int(forum_topic.message_thread_id) thread = store.get_or_create_session_thread( session_key=_topic_session_key(chat.id, thread_id), title=topic_name, model=selection.model, provider=selection.provider, source="telegram", session_label=f"Telegram topic {topic_name}", ) store.update_thread(thread["id"], title=topic_name, title_source="manual") if seeded_message: await chat.send_message(seeded_message, message_thread_id=thread_id) store.add_message(thread["id"], "assistant", seeded_message) created_topics.append(f"{topic_name} (thread {thread_id})") if invalid_names: return "Created topics: " + ", ".join(created_topics) + ". Skipped: " + "; ".join(invalid_names) return "Created topics: " + ", ".join(created_topics) class RenameTopicParams(BaseModel): topic_name: str async def _rename_topic_handler(params: RenameTopicParams, invocation: ToolInvocation) -> str: normalized_name = _normalize_topic_name(params.topic_name) await _rename_forum_topic(update, normalized_name) thread = _ensure_thread(update, context) store.update_thread(thread["id"], title=normalized_name, title_source="manual") return f"Renamed the current topic to {normalized_name}." create_tool = define_tool( name="telegram_create_topics", description="Create one or more Telegram forum topics in the current chat. Use this when the user asks to start new topics, open one topic per item, or organize work into forum topics.", handler=_create_topics_handler, params_type=CreateTopicsParams, ) rename_tool = define_tool( name="telegram_rename_current_topic", description="Rename the current Telegram forum topic. Use this when the user asks to rename the topic they are currently chatting in.", handler=_rename_topic_handler, params_type=RenameTopicParams, ) class ProposePromptParams(BaseModel): new_prompt: str async def _propose_system_prompt(params: ProposePromptParams, invocation: ToolInvocation) -> str: try: normalized_prompt = _normalize_system_prompt_value(params.new_prompt) except ValueError as error: return f"❌ {error}" session_key = _session_key(update) current_record = store.get_topic_system_prompt(session_key) old_prompt = current_record["prompt"] if current_record else "" diff = generate_diff(old_prompt, normalized_prompt) if not diff: return "The proposed prompt is identical to the current one." pending = context.application.bot_data.setdefault("pending_prompts", {}) pending[session_key] = normalized_prompt keyboard = InlineKeyboardMarkup( [ [ InlineKeyboardButton("✅ Approve", callback_data=f"prompt_app:yes:{session_key}"), InlineKeyboardButton("❌ Decline", callback_data=f"prompt_app:no:{session_key}"), ] ] ) chat = update.effective_chat if chat is None: raise RuntimeError("Telegram update missing chat context") diff_text = _format_prompt_diff_message(diff) send_kwargs: dict[str, Any] = {"reply_markup": keyboard} thread_id = _effective_topic_thread_id(update) if thread_id and not _is_general_topic(update): send_kwargs["message_thread_id"] = thread_id await chat.send_message( diff_text, **send_kwargs, ) return "I have submitted the prompt change for approval." propose_prompt_tool = define_tool( name="propose_system_prompt", description="Propose a change to the current topic's system prompt. This requires human approval via a diff message.", handler=_propose_system_prompt, params_type=ProposePromptParams, ) return [create_tool, rename_tool, propose_prompt_tool] async def _rename_forum_topic(update: Update, topic_name: str) -> None: chat = update.effective_chat message = update.effective_message if chat is None or message is None: raise RuntimeError("Telegram update missing chat context") if not getattr(chat, "is_forum", False) or not getattr(message, "is_topic_message", False): raise RuntimeError("This command only works inside a forum topic") if _is_general_topic(update): await chat.edit_general_forum_topic(name=topic_name) return thread_id = _effective_topic_thread_id(update) if not thread_id: raise RuntimeError("Could not determine the current forum topic") await chat.edit_forum_topic(message_thread_id=thread_id, name=topic_name) def _format_system_prompt(prompt: str) -> str: compact = str(prompt or "").strip() if len(compact) <= TG_MESSAGE_LIMIT: return compact return compact[: TG_MESSAGE_LIMIT - 3].rstrip() + "..." def _topic_system_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> str | None: record = _store(context).get_topic_system_prompt(_session_key(update)) if not record: return None prompt = str(record.get("prompt") or "").strip() return prompt or None def _format_tg_recent_events(user) -> str | None: """Format recent events for injection into the Telegram system message (T028).""" from datetime import datetime from datetime import timezone as tz from user_store import get_store as _us store = _us() events = store.get_recent_events(user.id, window_hours=24) if not events: return None lines = [] now = datetime.now(tz.utc) for ev in events[:15]: try: created = datetime.fromisoformat(ev.created_at) delta = now - created hours = int(delta.total_seconds() // 3600) if hours < 1: ago = f"{int(delta.total_seconds() // 60)}m ago" elif hours < 24: ago = f"{hours}h ago" else: ago = "yesterday" except ValueError: ago = "recently" lines.append(f"- {ago}: {ev.summary}") store.mark_events_consumed([ev.id for ev in events[:15]]) return "Recent activity for you:\n" + "\n".join(lines) def _compose_system_message( update: Update, context: ContextTypes.DEFAULT_TYPE, *, background_summary: str | None = None, user=None, ) -> str: store = _store(context) sections = [BASE_CONTEXT.rstrip(), SKILLS_CONTEXT.rstrip()] # Integration tool prompt fragments active_toolsets = active_toolset_names(user) fragments = tool_registry.get_system_prompt_fragments(active_toolsets) if fragments: sections.extend(fragments) topic_prompt = _topic_system_message(update, context) if topic_prompt: sections.append(f"Persistent topic instruction:\n{topic_prompt}") extra_context = _telegram_agent_context(update) if extra_context: sections.append(extra_context.strip()) if background_summary: sections.append(background_summary.strip()) # Inject learnings (global + project-scoped) thread = _current_thread(update, context) if thread: project_learnings = store.get_project_learnings(thread["id"]) global_learnings = store.get_global_learnings() learnings_text = format_learnings_for_prompt(project_learnings, global_learnings) if learnings_text: sections.append(learnings_text) # Inject recent events (T028) if user: events_section = _format_tg_recent_events(user) if events_section: sections.append(events_section) # Inject onboarding / capability status (T037) if user: onboarding = build_onboarding_fragment(user) if onboarding: sections.append(onboarding) capability = build_capability_fragment(user) if capability: sections.append(capability) return "\n\n".join(section for section in sections if section) def _session_key(update: Update) -> str: chat = update.effective_chat if chat is None: raise RuntimeError("Telegram update missing chat context") thread_id = _effective_topic_thread_id(update) if chat.type == "private": return f"pm:{chat.id}" if thread_id: return f"topic:{chat.id}:{thread_id}" return f"group:{chat.id}" def _store(context: ContextTypes.DEFAULT_TYPE) -> WebFallbackStore: store = getattr(context.application, "_runtime_store", None) if not isinstance(store, WebFallbackStore): raise RuntimeError("Telegram thread store is not configured") return store def _bg_manager(context: ContextTypes.DEFAULT_TYPE) -> BackgroundTaskManager: mgr = getattr(context.application, "_runtime_bg_manager", None) if not isinstance(mgr, BackgroundTaskManager): raise RuntimeError("Background task manager is not configured") return mgr def _session_label(update: Update) -> str: chat = update.effective_chat if chat is None: return "Telegram" if chat.type == "private": username = getattr(chat, "username", None) if username: return f"Telegram DM @{username}" first_name = getattr(chat, "first_name", None) or "" last_name = getattr(chat, "last_name", None) or "" full_name = " ".join(part for part in [first_name, last_name] if part).strip() return f"Telegram DM {full_name}" if full_name else "Telegram DM" title = getattr(chat, "title", None) or f"chat {chat.id}" if _effective_topic_thread_id(update): return f"Telegram topic {title}" return f"Telegram group {title}" def _current_thread(update: Update, context: ContextTypes.DEFAULT_TYPE) -> dict | None: return _store(context).get_session_thread(_session_key(update)) def _current_lock(update: Update, context: ContextTypes.DEFAULT_TYPE): thread = _current_thread(update, context) if thread is None: return None return _store(context).lock(thread["id"]) def _get_selection(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ModelSelection: thread = _current_thread(update, context) if thread is None: return default_selection() return resolve_selection(model=thread.get("model"), provider=thread.get("provider")) def _ensure_thread( update: Update, context: ContextTypes.DEFAULT_TYPE, *, selection: ModelSelection | None = None, ) -> dict: resolved_selection = selection or _get_selection(update, context) return _store(context).get_or_create_session_thread( session_key=_session_key(update), title="New chat", model=resolved_selection.model, provider=resolved_selection.provider, source="telegram", session_label=_session_label(update), ) def _apply_selection(update: Update, context: ContextTypes.DEFAULT_TYPE, selection: ModelSelection) -> None: thread = _ensure_thread(update, context, selection=selection) if thread.get("model") == selection.model and thread.get("provider") == selection.provider: return _store(context).update_thread(thread["id"], model=selection.model, provider=selection.provider) def _skill_menu_markup() -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [InlineKeyboardButton(label, callback_data=f"{SKILL_MENU_CALLBACK_PREFIX}{skill_name}")] for skill_name, label in SKILL_MENU_ITEMS ] ) def _render_skill_entry(skill_name: str) -> str: skill_path = Path(settings.REPOS_DIR) / "code_anywhere" / ".agents" / "skills" / skill_name / "SKILL.md" if not skill_path.exists(): return f"❌ Skill `{skill_name}` is not available." body = skill_path.read_text("utf-8").strip() return f"*Skill:* `{skill_name}`\n*Path:* `{skill_path}`\n\n```md\n{body}\n```" async def cmd_skills(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None: return await message.reply_text( "Choose a skill to inspect. The bot can also invoke matching skills during a run.", reply_markup=_skill_menu_markup(), ) async def handle_skill_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query if not query or not query.data: return await query.answer() skill_name = query.data.removeprefix(SKILL_MENU_CALLBACK_PREFIX) rendered = _render_skill_entry(skill_name) await _send_long(update, rendered) async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None: return await message.reply_text(TELEGRAM_START_MESSAGE) async def cmd_alias(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None or not message.text: return args = message.text.split(maxsplit=2) if len(args) < 3: await message.reply_text(_format_user_aliases(context, update.effective_user), parse_mode="Markdown") return alias_name = args[1].strip() target_model = args[2].strip() current = _get_selection(update, context) try: selection = resolve_selection(model=target_model, current=current) normalized_alias, _ = _set_user_model_alias(context, update.effective_user, alias_name, selection.ref) except (ModelSelectionError, ValueError) as error: await message.reply_text(f"❌ {error}") return # Force PTB to flush bot_data to disk immediately try: await context.application.update_persistence() except Exception: logger.warning("Failed to flush persistence after alias set", exc_info=True) await message.reply_text( f"Saved alias `{normalized_alias}` → `{selection.ref}`", parse_mode="Markdown", ) async def cmd_alias_export(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message user = update.effective_user if message is None or user is None: return aliases = _user_aliases(context, user) payload = { "user_id": user.id, "username": user.username or "", "aliases": dict(sorted(aliases.items())), } data = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") export_name = f"aliases-{user.id}.json" await message.reply_document( document=InputFile(BytesIO(data), filename=export_name), caption=f"Alias backup with {len(aliases)} entr{'y' if len(aliases) == 1 else 'ies'}.", ) async def cmd_model(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None or not message.text: return args = message.text.split(maxsplit=1) current = _get_selection(update, context) if len(args) < 2: await message.reply_text( ( f"{format_selection(current)}\n" "Usage: /model gpt-5.4-mini\n" "Usage: /model openrouter:anthropic/claude-sonnet-4.5" ), parse_mode="Markdown", ) return requested_model = args[1].strip() resolved_model, alias_name = _resolve_user_model_alias(context, update.effective_user, requested_model) try: selection = resolve_selection(model=resolved_model, current=current) except ModelSelectionError as error: await message.reply_text(f"❌ {error}") return _apply_selection(update, context, selection) alias_note = f" via alias `{alias_name}`" if alias_name else "" await message.reply_text( f"Switched to provider `{selection.provider}` with model `{selection.model}`{alias_note}", parse_mode="Markdown" ) async def cmd_provider(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None or not message.text: return current = _get_selection(update, context) args = message.text.split(maxsplit=1) if len(args) < 2: await message.reply_text( (f"Current provider: `{current.provider}`\nUsage: /provider openai\nUsage: /provider openrouter"), parse_mode="Markdown", ) return try: selection = resolve_selection(model=current.model, provider=args[1].strip(), current=current) except ModelSelectionError as error: await message.reply_text(f"❌ {error}") return _apply_selection(update, context, selection) await message.reply_text( f"Provider switched to `{selection.provider}`. Active model: `{selection.model}`", parse_mode="Markdown" ) async def cmd_models(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None or not message.text: return args = message.text.split(maxsplit=1) requested_provider = args[1].strip() if len(args) > 1 else None try: rendered = format_known_models(current=_get_selection(update, context), provider=requested_provider) except ModelSelectionError as error: await message.reply_text(f"❌ {error}") return await message.reply_text(rendered, parse_mode="Markdown") async def cmd_system(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None or message.text is None: return lock = _current_lock(update, context) if lock is not None and lock.locked(): await message.reply_text(busy_message(surface="telegram")) return store = _store(context) args = message.text.split(maxsplit=1) if len(args) < 2 or not args[1].strip(): current = store.get_topic_system_prompt(_session_key(update)) if not current: await message.reply_text("No persistent system instruction is saved for this chat/topic.") return await _send_long( update, "Current persistent system instruction:\n\n" + _format_system_prompt(current["prompt"]) ) return raw_value = args[1].strip() if raw_value.lower() == "clear": cleared = store.clear_topic_system_prompt(_session_key(update)) if cleared: await message.reply_text("Cleared the persistent system instruction for this chat/topic.") else: await message.reply_text("No persistent system instruction was set for this chat/topic.") return try: normalized_prompt = _normalize_system_prompt_value(raw_value) except ValueError as error: await message.reply_text(f"❌ {error}") return store.set_topic_system_prompt(_session_key(update), normalized_prompt) await _send_long( update, "Saved persistent system instruction for this chat/topic. It will be reused after /new.\n\n" + _format_system_prompt(normalized_prompt), ) async def cmd_new(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None: return selection = _get_selection(update, context) _store(context).start_new_session_thread( session_key=_session_key(update), title="New chat", model=selection.model, provider=selection.provider, source="telegram", session_label=_session_label(update), ) await message.reply_text("Started a new session. Older sessions stay available in the web UI.") async def cmd_current(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None: return await message.reply_text(format_selection(_get_selection(update, context)), parse_mode="Markdown") async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None: return thread = _current_thread(update, context) if thread is None: await message.reply_text("No active session in this chat.") return store = _store(context) bg_manager = _bg_manager(context) active_runs = _active_runs(context) current_run = active_runs.get(thread["id"]) all_threads = store.list_threads(limit=500) current_session_key = _session_key(update) current_label = thread.get("session_label") or _session_label(update) active_thread_ids = set(active_runs.keys()) active_threads = [item for item in all_threads if item.get("id") in active_thread_ids] current_chat_threads = [item for item in all_threads if item.get("session_key") == current_session_key] recent_threads = all_threads[:5] status_lines = [ "📊 *System Status*", f"Active runs now: `{len(active_runs)}`", f"Saved sessions: `{len(all_threads)}`", f"Current session: *{helpers.escape_markdown(str(current_label), version=2)}*", f"Current model: `{helpers.escape_markdown(thread.get('model') or 'unknown', version=2)}` via `{helpers.escape_markdown(thread.get('provider') or 'unknown', version=2)}`", f"Messages in current session: `{len(thread.get('messages', []))}`", f"Current chat/topic sessions: `{len(current_chat_threads)}`", ] if current_run: started_by = str(current_run.get("started_by") or "").strip() if started_by: preview_source = started_by.replace(chr(10), " ") preview = preview_source[:120] if len(preview_source) > 120: preview += "..." status_lines.append(f"Current run prompt: _{helpers.escape_markdown(preview, version=2)}_") status_lines.append("Current session state: `busy`") else: status_lines.append("Current session state: `idle`") status_lines.extend( [ "", "🏃 *Active Runs*", f"Concurrent active runs: `{len(active_threads)}`", ] ) if active_threads: for item in active_threads[:5]: title = item.get("title") or item.get("session_label") or item.get("id") or "Untitled" marker = " (here)" if item.get("id") == thread["id"] else "" status_lines.append(f"• {helpers.escape_markdown(str(title), version=2)}{marker}") if len(active_threads) > 5: status_lines.append(f"• _and {len(active_threads) - 5} more active run(s)_") else: status_lines.append("No active runs.") status_lines.extend( [ "", "🕒 *Background Tasks*", helpers.escape_markdown(bg_manager.format_status(thread["id"]), version=2), ] ) latest_bg = bg_manager.get_latest(thread["id"]) if latest_bg and latest_bg.started_at: status_lines.append(f"Last background update: `{latest_bg.started_at.strftime('%Y-%m-%d %H:%M:%SZ')}`") if recent_threads: status_lines.extend( [ "", "🗂️ *Recent Sessions*", ] ) for item in recent_threads: title = item.get("title") or item.get("session_label") or item.get("id") or "Untitled" updated_at = str(item.get("updated_at") or "") marker = " (here)" if item.get("id") == thread["id"] else "" line = f"• {helpers.escape_markdown(str(title), version=2)}" if updated_at: line += f" — `{helpers.escape_markdown(updated_at, version=2)}`" line += marker status_lines.append(line) await message.reply_text("\n".join(status_lines), parse_mode="MarkdownV2") async def cmd_usage(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None: return thread = _current_thread(update, context) if thread is None: await message.reply_text("No usage yet in this chat.") return usage = thread.get("usage") if isinstance(thread, dict) else None summary = summarize_usage(usage) if not summary: await message.reply_text("No usage recorded yet in this chat.") return lines = [ f"Usage so far: {summary['total_tokens']:,} tok across {summary['request_count']} request(s)", f"Input: {summary['prompt_tokens']:,} · Output: {summary['completion_tokens']:,}", ] if summary["cached_tokens"]: lines.append(f"Cached: {summary['cached_tokens']:,}") if summary["reasoning_tokens"]: lines.append(f"Reasoning: {summary['reasoning_tokens']:,}") lines.append(f"Cost: ${format_cost_value(summary['cost_usd'])}") await message.reply_text("\n".join(lines)) async def cmd_learnings(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Show project and global learnings for this thread.""" message = update.effective_message if message is None: return store = _store(context) thread = _current_thread(update, context) lines = [] # Global learnings global_learnings = store.get_global_learnings() if global_learnings: lines.append("🌐 *Global Learnings*") for item in global_learnings: cat = helpers.escape_markdown(str(item.get("category", "general")), version=2) fact = helpers.escape_markdown(str(item.get("fact", "")), version=2) lines.append(f" \\[{cat}] {fact}") # Project learnings if thread: project_learnings = store.get_project_learnings(thread["id"]) if project_learnings: lines.append("") lines.append("📁 *Project Learnings*") for item in project_learnings: cat = helpers.escape_markdown(str(item.get("category", "general")), version=2) fact = helpers.escape_markdown(str(item.get("fact", "")), version=2) lines.append(f" \\[{cat}] {fact}") if not lines: await message.reply_text("No learnings recorded yet.") return await message.reply_text("\n".join(lines), parse_mode="MarkdownV2") async def cmd_stop(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None: return thread, run_state = _get_active_run(update, context) if thread is None: await message.reply_text("No active session in this chat.") return if not isinstance(run_state, dict): await message.reply_text("Nothing is currently running here.") return stop_event = run_state.get("stop_event") if stop_event is None: await message.reply_text("This run cannot be stopped cleanly.") return stop_event.set() await message.reply_text("Stopping the current run…") async def handle_prompt_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: query = update.callback_query await query.answer() data = query.data.split(":", 2) if len(data) < 3 or data[0] != "prompt_app": return approved = data[1] == "yes" session_key = data[2] pending = context.application.bot_data.get("pending_prompts", {}) new_prompt = pending.get(session_key) if not new_prompt: try: await query.edit_message_text("❌ Error: Pending prompt not found or expired.") except (RetryAfter, TelegramError): pass return if approved: store = _store(context) try: store.set_topic_system_prompt(session_key, new_prompt) except ValueError as error: try: await query.edit_message_text(f"❌ Error: {error}") except (RetryAfter, TelegramError): pass else: try: await query.edit_message_text("✅ System prompt updated successfully!") except (RetryAfter, TelegramError): pass else: try: await query.edit_message_text("❌ System prompt change declined.") except (RetryAfter, TelegramError): pass # Cleanup pending pending.pop(session_key, None) context.application.bot_data["pending_prompts"] = pending async def cmd_topic(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None or not message.text: return chat = update.effective_chat args = message.text.split(maxsplit=1) if len(args) < 2: await message.reply_text("Usage: /topic New topic name") return if chat is None or not getattr(chat, "is_forum", False) or not getattr(message, "is_topic_message", False): await message.reply_text("❌ This command only works inside a forum topic") return try: topic_name = _normalize_topic_name(args[1]) except ValueError as error: await message.reply_text(f"❌ {error}") return try: thread = _ensure_thread(update, context) except RuntimeError as error: await message.reply_text(f"❌ {error}") return try: await _rename_forum_topic(update, topic_name) except (RuntimeError, TelegramError) as error: await message.reply_text(f"❌ {error}") return _store(context).update_thread(thread["id"], title=topic_name, title_source="manual") await message.reply_text(f"Renamed this topic to: {topic_name}") async def cmd_newtopic(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Create a new forum topic in the current group.""" message = update.effective_message if message is None or not message.text: return chat = update.effective_chat args = message.text.split(maxsplit=1) if len(args) < 2: await message.reply_text("Usage: /newtopic Topic name") return if chat is None or not getattr(chat, "is_forum", False): await message.reply_text("❌ This command only works in a forum supergroup") return try: topic_name = _normalize_topic_name(args[1]) except ValueError as error: await message.reply_text(f"❌ {error}") return try: forum_topic = await chat.create_forum_topic(name=topic_name) thread_id = int(forum_topic.message_thread_id) selection = _get_selection(update, context) store = _store(context) store.get_or_create_session_thread( session_key=_topic_session_key(chat.id, thread_id), title=topic_name, model=selection.model, provider=selection.provider, source="telegram", session_label=f"Telegram topic {topic_name}", ) await message.reply_text(f"Created new topic: {topic_name}") except TelegramError as error: await message.reply_text(f"❌ Failed to create topic: {error}") except RuntimeError as error: await message.reply_text(f"❌ {error}") def _build_user_parts(text: str, uploads: list[dict]) -> list[dict]: parts: list[dict] = [] if text: parts.append({"type": "input_text", "text": text}) for upload in uploads: parts.append( { "type": "input_image", "file_id": upload["id"], "name": upload.get("name") or "image", "mime_type": upload.get("mime_type") or "image/jpeg", "detail": "auto", } ) return parts def _build_background_tools( update: Update, context: ContextTypes.DEFAULT_TYPE, thread: dict, selection: ModelSelection, system_message: str, ) -> list[Any]: """Build tools that let the LLM spawn and query background agents.""" store = _store(context) bg = _bg_manager(context) bot = context.bot chat_id = update.effective_chat.id tg_thread_id = _effective_topic_thread_id(update) is_general = _is_general_topic(update) class StartBgParams(BaseModel): task: str = Field(description="Detailed description of what the background agent should do.") async def _start_bg_handler(params: StartBgParams, invocation: ToolInvocation) -> str: active = bg.get_active(thread["id"]) if active: return f"A background agent is already running: {active.description}. Wait for it to finish first." async def _on_complete(bg_task) -> None: output = bg_task.result or f"Background agent failed: {bg_task.error}" store.add_message(thread["id"], "assistant", f"[Background agent]\n\n{output}") # Track background agent usage if bg_task.usage: store.add_usage(thread["id"], bg_task.usage) kwargs: dict[str, Any] = {} if tg_thread_id and not is_general: kwargs["message_thread_id"] = tg_thread_id # Split long results across messages prefix = "🔄 Background agent finished:\n\n" for i in range(0, len(output), TG_MESSAGE_LIMIT - len(prefix)): chunk = output[i : i + TG_MESSAGE_LIMIT - len(prefix)] text = (prefix + chunk) if i == 0 else chunk await bot.send_message(chat_id, text, **kwargs) bg.start( thread_id=thread["id"], description=params.task, selection=selection, system_message=system_message, on_complete=_on_complete, ) return "Background agent started. Results will be sent to this chat when done." class CheckStatusParams(BaseModel): pass async def _check_status_handler(params: CheckStatusParams, invocation: ToolInvocation) -> str: return bg.format_status(thread["id"]) start_tool = define_tool( "ask_agent", description=( "Start a background agent for a long-running task (research, multi-file changes, deep investigation). " "The agent runs in a separate session and posts results to the chat when done. " "Only one background agent per thread at a time." ), handler=_start_bg_handler, params_type=StartBgParams, ) status_tool = define_tool( "check_agent", description="Check the status of the background agent in this thread.", handler=_check_status_handler, params_type=CheckStatusParams, ) return [start_tool, status_tool] # Module-level backoff tracker: tracks the earliest time we're allowed to # edit a Telegram message after hitting flood control. _flood_backoff_until: float = 0.0 async def _edit_status_message(message: Any, text: str) -> None: """Edit a Telegram status message, respecting flood-control backoff.""" global _flood_backoff_until loop = asyncio.get_running_loop() now = loop.time() # If we're still in a backoff window, skip this edit entirely. if now < _flood_backoff_until: return try: await message.edit_text(text) except RetryAfter as exc: # Telegram says "retry in N seconds" — honour it and add a small buffer. wait = max(float(exc.retry_after), 1.0) + 0.5 _flood_backoff_until = loop.time() + wait logger.warning("Telegram flood control: backing off %.1fs", wait) except TelegramError: pass async def _run_user_turn( update: Update, context: ContextTypes.DEFAULT_TYPE, *, text: str, uploads: list[dict] | None = None, ) -> None: message = update.effective_message if message is None: return normalized_text = str(text or "").strip() normalized_uploads = uploads or [] if not normalized_text and not normalized_uploads: return # Resolve user identity (T019) from user_store import get_store as _get_user_store tg_user = update.effective_user if tg_user: user_store = _get_user_store() current_user = user_store.resolve_or_create_user( provider="telegram", external_id=str(tg_user.id), display_name=tg_user.full_name or str(tg_user.id), ) else: current_user = None store = _store(context) selection = _get_selection(update, context) thread = _ensure_thread(update, context, selection=selection) lock = store.lock(thread["id"]) if lock.locked(): await message.reply_text(busy_message(surface="telegram")) return async with lock: thinking = await message.reply_text(working_message(surface="telegram")) latest_status = working_message(surface="telegram") tool_counts: dict[str, int] = {} stop_status = asyncio.Event() stop_run = asyncio.Event() run_state = {"stop_event": stop_run, "status_message": thinking, "started_by": normalized_text} _register_active_run(context, thread["id"], run_state) # Grab the running session cost so far (before this turn) try: _pre_usage = store.get_total_usage(thread["id"]) except Exception: _pre_usage = {} _pre_cost = Decimal(str(_pre_usage.get("cost_usd", "0") or "0")) _pre_tokens = int(_pre_usage.get("total_tokens", 0) or 0) def _cost_line() -> str: """Format session cost line for the status bubble. Computes a *live* running cost from collected_events so far (which accumulate ASSISTANT_USAGE events during streaming), adds it to the pre-existing session cost, and returns the combined figure. This keeps the 💰 line fresh as tool calls stream in, rather than showing a stale number. """ try: turn_usage = extract_usage_and_cost( selection.model, selection.provider, collected_events, ) except Exception: turn_usage = {} turn_cost = Decimal(str(turn_usage.get("cost_usd", "0") or "0")) turn_tokens = int(turn_usage.get("total_tokens", 0) or 0) cost = _pre_cost + turn_cost tokens = _pre_tokens + turn_tokens if not tokens and not cost: return "" return f"💰 Session: ${cost:.8f} · {tokens:,} tok" status_changed = asyncio.Event() pending_statuses: list[str] = [] pending_status_keys: set[str] = set() turn_started_at = asyncio.get_running_loop().time() def _queue_status(text: str | None) -> None: nonlocal latest_status normalized = str(text or "").strip() if not normalized: return latest_status = normalized dedupe_key = normalized.casefold() if dedupe_key in pending_status_keys: return pending_status_keys.add(dedupe_key) pending_statuses.append(normalized) status_changed.set() def _drain_status_batch() -> str: if not pending_statuses: return latest_status batch: str = "...\n".join(pending_statuses) pending_statuses.clear() pending_status_keys.clear() return batch async def _status_loop() -> None: """Edit the Telegram placeholder when status changes, throttled to avoid rate-limits.""" last_sent = "" last_edit = 0.0 last_elapsed_prefix = "" loop = asyncio.get_running_loop() while not stop_status.is_set(): if not pending_statuses: status_changed.clear() status_wait = asyncio.create_task(status_changed.wait()) stop_wait = asyncio.create_task(stop_status.wait()) pending_waits = {status_wait, stop_wait} wait_timeout = ( None if last_edit == 0.0 else max(0.0, TG_STATUS_EDIT_INTERVAL - (loop.time() - last_edit)) ) try: done, _ = await asyncio.wait( pending_waits, timeout=wait_timeout, return_when=asyncio.FIRST_COMPLETED, ) if stop_wait in done or stop_status.is_set(): break finally: for task in pending_waits: if not task.done(): task.cancel() await asyncio.gather(*pending_waits, return_exceptions=True) elapsed = loop.time() - last_edit if pending_statuses and last_edit and elapsed < TG_STATUS_EDIT_INTERVAL: try: await asyncio.wait_for(stop_status.wait(), timeout=TG_STATUS_EDIT_INTERVAL - elapsed) break except asyncio.TimeoutError: pass # If flood backoff is active, wait it out before attempting an edit. backoff_remaining = _flood_backoff_until - loop.time() if backoff_remaining > 0: try: await asyncio.wait_for(stop_status.wait(), timeout=backoff_remaining) break except asyncio.TimeoutError: pass display = format_tool_counts(tool_counts, current_status=_drain_status_batch()) display = append_elapsed_status(display, elapsed_seconds=loop.time() - turn_started_at) cost = _cost_line() if cost: display = f"{display}\n{cost}" if display else cost # Skip edit if only the elapsed timer changed (round to 10s to # avoid trivial edits that trigger rate-limits during idle runs). # Build a comparison key that ignores the exact seconds count. elapsed_now = int(loop.time() - turn_started_at) elapsed_bucket = elapsed_now // 10 # only changes every 10s content_key = (display.split("\n")[0] if display else "", cost, elapsed_bucket) if content_key == last_elapsed_prefix and not pending_statuses: continue if display and display != last_sent: await _edit_status_message(thinking, display) last_sent = display last_edit = loop.time() last_elapsed_prefix = content_key status_task = asyncio.create_task(_status_loop()) try: store.add_message( thread["id"], "user", normalized_text, parts=_build_user_parts(normalized_text, normalized_uploads), ) # Log to identity-linked conversation store (T024) _us_session = None if current_user: _us = _get_user_store() _topic = str(_effective_topic_thread_id(update) or "") _us_session = _us.get_or_create_session(current_user.id, "telegram", topic_id=_topic or None) _us.log_message(_us_session.id, "user", normalized_text) _queue_status(f"Inspecting request in {_session_label(update)}") history = store.build_agent_history(thread["id"], limit=MAX_HISTORY * 2) prompt = format_prompt_with_history(history, normalized_text) # Build BlobAttachment dicts directly from the current Telegram uploads so # the SDK receives the image bytes for this turn regardless of how prompt # formatting collapses history into text. current_attachments: list[dict[str, Any]] | None = None if normalized_uploads and not selection.likely_supports_vision: _queue_status(f"⚠️ {selection.model} doesn't support images — sending text only") normalized_uploads = [] if normalized_uploads: blob_attachments: list[dict[str, Any]] = [] for upload in normalized_uploads: try: data_url = store._media_store.build_data_url(upload["id"]) except KeyError: continue m = re.match(r"data:([^;]+);base64,(.+)", data_url, re.DOTALL) if not m: continue blob_attachments.append( { "type": "blob", "mimeType": m.group(1), "data": m.group(2), "displayName": str(upload.get("name") or "image.jpg"), } ) if blob_attachments: current_attachments = blob_attachments topic_tools = _build_telegram_topic_tools(update, context) system_message = _compose_system_message(update, context, user=current_user) bg_tools = _build_background_tools(update, context, thread, selection, system_message) bg_context = _bg_manager(context).context_summary(thread["id"]) if bg_context: system_message = _compose_system_message( update, context, background_summary=bg_context, user=current_user ) all_tools = ( (topic_tools or []) + bg_tools + build_integration_tools(current_user, thread_id=thread["id"], system_prompt=system_message) ) latest_status = "Thinking ..." collected_events: list = [] async for event in stream_session( copilot, model=selection.model, provider_config=build_provider_config(selection), system_message=system_message, prompt=prompt, tools=all_tools or None, attachments=current_attachments, thread_id=thread["id"], ): if stop_run.is_set(): raise asyncio.CancelledError("Stopped by /stop") collected_events.append(event) # Handle session errors if event.type == SessionEventType.SESSION_ERROR: error_msg: str = ( event.data.message if event.data and isinstance(event.data.message, str) else "Unknown session error" ) raise RuntimeError(f"Session error: {error_msg}") # Track tool calls for collapsed count display trace = stream_trace_event(event) if trace: tool_name = trace.get("tool_name") or "" category = trace.get("category", "") if category == "tool_call" and tool_name: # Don't count report_intent in tool counts if tool_name != "report_intent" and not trace.get("output_detail"): tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1 elif category == "subagent": tool_counts["handoffs"] = tool_counts.get("handoffs", 0) + 1 # Update current status text from stream status_updates = stream_status_updates(event) if status_updates: for status_text in status_updates: _queue_status(status_text) elif trace: status_changed.set() _queue_status("Sending final reply") reply = extract_final_text(collected_events) if not reply: reply = ( "I finished that run but did not get a usable final reply back. " "Please send the request again, or narrow it to one repo, file, or command." ) usage = extract_usage_and_cost( selection.model, selection.provider, collected_events, advisor_usage=_get_advisor_usage(thread["id"]), ) total_usage = store.add_usage(thread["id"], usage) cost_footer = "" if total_usage: total_cost = total_usage.get("cost_usd", "0") total_tokens = total_usage.get("total_tokens", 0) cost_footer = f"\n\n---\nTotal Session Cost: ${total_cost}\nTotal Session Tokens: {total_tokens:,}" stop_status.set() # await safe_delete_message(thinking) store.add_message(thread["id"], "assistant", reply) if _us_session and current_user: _get_user_store().log_message(_us_session.id, "assistant", reply) await _send_long(update, reply + cost_footer) except asyncio.CancelledError: stop_status.set() # await safe_delete_message(thinking) partial_usage = extract_usage_and_cost( selection.model, selection.provider, collected_events, advisor_usage=_get_advisor_usage(thread["id"]), ) if ( partial_usage.get("request_count") or partial_usage.get("total_tokens") or partial_usage.get("cost_usd") != "0" ): store.add_usage(thread["id"], partial_usage) await _send_long(update, "Stopped current run.") except Exception as error: logger.exception("Agent error for session %s", _session_key(update)) stop_status.set() # await safe_delete_message(thinking) detail = format_session_error(surface="telegram", error=error) store.add_message(thread["id"], "assistant", detail) await _send_long(update, detail) finally: _clear_active_run(context, thread["id"], run_state) stop_status.set() # Extract learnings from this turn (runs even on error so user # facts are never lost — assistant_message may be empty on failure) try: assistant_msg = reply if "reply" in locals() else "" project_learnings, global_learnings = extract_learnings_from_turn( normalized_text, assistant_msg, ) for fact, category in project_learnings: store.add_project_learning(thread["id"], fact, category=category) for fact, category in global_learnings: store.add_global_learning(fact, category=category) except Exception: logger.warning("Learning extraction failed", exc_info=True) status_task.cancel() try: await status_task except Exception: pass async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None or not message.text: logger.info("Ignoring non-text Telegram update: %s", update) return await _run_user_turn(update, context, text=message.text) async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: message = update.effective_message if message is None or not message.photo: logger.info("Ignoring Telegram photo update without image payload: %s", update) return try: photo = message.photo[-1] telegram_file = await photo.get_file() photo_bytes = bytes(await telegram_file.download_as_bytearray()) upload = _store(context).save_upload( name=f"telegram-photo-{message.message_id}.jpg", data=photo_bytes, mime_type="image/jpeg", ) except Exception as error: logger.exception("Failed to download Telegram photo for session %s", _session_key(update)) await message.reply_text(format_session_error(surface="telegram", error=error)) return await _run_user_turn(update, context, text=message.caption or "", uploads=[upload]) async def _send_long(update: Update, text: str) -> None: """Send a message as Telegram HTML, falling back to plain text.""" thread_id = _effective_topic_thread_id(update) kwargs: dict[str, Any] = {} if thread_id and not _is_general_topic(update): kwargs["message_thread_id"] = thread_id for index in range(0, len(text), TG_MESSAGE_LIMIT): chunk = text[index : index + TG_MESSAGE_LIMIT] try: await update.effective_chat.send_message(markdown_to_telegram_html(chunk), parse_mode="HTML", **kwargs) except TelegramError: # HTML rejected — send as plain text instead await update.effective_chat.send_message(chunk, **kwargs) async def handle_error(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: logger.exception("Telegram application error", exc_info=context.error) def _telegram_persistence_path() -> Path: base_dir = Path(settings.TG_PERSISTENCE_DIR or settings.DATA_DIR).expanduser() return base_dir / "telegram-bot-state.pkl" def build_telegram_app(store: WebFallbackStore) -> Application: if not settings.TG_BOT_TOKEN: raise RuntimeError("TG_BOT_TOKEN is not configured") persistence_path = _telegram_persistence_path() persistence_path.parent.mkdir(parents=True, exist_ok=True) persistence = PicklePersistence(filepath=str(persistence_path), update_interval=10) async def _combined_post_init(app: Application) -> None: # Store runtime objects as app attributes — NOT in bot_data. # bot_data is persisted via PicklePersistence, and these objects # contain unpicklable items (locks, asyncio state) which would # corrupt the pickle file and wipe persisted aliases. app._runtime_store = store # type: ignore[attr-defined] app._runtime_bg_manager = BackgroundTaskManager() # type: ignore[attr-defined] # Restore aliases from durable JSON file if bot_data lost them file_aliases = _load_alias_file() if file_aliases: bd_aliases = app.bot_data.get(_USER_MODEL_ALIASES_KEY) if not isinstance(bd_aliases, dict) or not bd_aliases: app.bot_data[_USER_MODEL_ALIASES_KEY] = file_aliases logger.info("Restored %d alias owner(s) from JSON file", len(file_aliases)) else: # Merge: file fills in missing owners/keys for owner, owner_aliases in file_aliases.items(): if owner not in bd_aliases: bd_aliases[owner] = owner_aliases else: for k, v in owner_aliases.items(): bd_aliases.setdefault(k, v) app.bot_data[_USER_MODEL_ALIASES_KEY] = bd_aliases await _post_init(app) app = ( Application.builder() .token(settings.TG_BOT_TOKEN) .persistence(persistence) .post_init(_combined_post_init) .build() ) app.add_handler(TypeHandler(Update, _gate_telegram_access), group=-1) app.add_handler(CommandHandler(["start", "help"], cmd_start)) app.add_handler(CommandHandler("models", cmd_models)) app.add_handler(CommandHandler("alias", cmd_alias)) app.add_handler(CommandHandler("alias_export", cmd_alias_export)) app.add_handler(CommandHandler("model", cmd_model)) app.add_handler(CommandHandler("provider", cmd_provider)) app.add_handler(CommandHandler(["topic", "rename"], cmd_topic)) app.add_handler(CommandHandler("newtopic", cmd_newtopic)) app.add_handler(CommandHandler("skills", cmd_skills)) app.add_handler(CommandHandler("system", cmd_system)) app.add_handler(CommandHandler("new", cmd_new)) app.add_handler(CommandHandler("current", cmd_current)) app.add_handler(CommandHandler("status", cmd_status)) app.add_handler(CommandHandler("usage", cmd_usage)) app.add_handler(CommandHandler("learnings", cmd_learnings)) app.add_handler(CommandHandler("stop", cmd_stop)) app.add_handler(CallbackQueryHandler(handle_prompt_callback, pattern="^prompt_app:")) app.add_handler(CallbackQueryHandler(_handle_approval_callback, pattern="^tg_approve:")) app.add_handler(CallbackQueryHandler(handle_skill_menu_callback, pattern=f"^{SKILL_MENU_CALLBACK_PREFIX}")) app.add_handler(MessageHandler(filters.PHOTO, handle_photo)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) app.add_error_handler(handle_error) return app def webhook_secret() -> str: return hashlib.sha256(settings.TG_BOT_TOKEN.encode()).hexdigest()[:32] async def _post_init(app: Application) -> None: commands = [ BotCommand("start", "Show help"), BotCommand("help", "Show help"), BotCommand("models", "List model IDs"), BotCommand("alias", "Save personal model alias"), BotCommand("alias_export", "Download personal alias backup"), BotCommand("model", "Switch model"), BotCommand("provider", "Switch provider"), BotCommand("topic", "Rename current topic"), BotCommand("newtopic", "Create a new forum topic"), BotCommand("skills", "Browse available skills"), BotCommand("system", "Set or show topic instruction"), BotCommand("new", "Start a new session"), BotCommand("current", "Show current model"), BotCommand("status", "Show system and task status"), BotCommand("usage", "Show accumulated usage"), BotCommand("learnings", "Show project and global learnings"), BotCommand("stop", "Stop the current run"), ] # Clear stale commands for all scopes, then re-register for scope in (BotCommandScopeDefault(), BotCommandScopeAllGroupChats(), BotCommandScopeAllChatAdministrators()): await app.bot.delete_my_commands(scope=scope) await app.bot.set_my_commands(commands, scope=scope)