from __future__ import annotations import logging import os import signal import tempfile from pathlib import Path from typing import TYPE_CHECKING, Any from pyrogram.enums import MessageEntityType from pyrogram.types import MessageEntity from app.agentic import parse_agent_invocation, run_agent_turn from app.commands.core import CommandRegistry, normalize_command_name from app.transcription import transcribe_file if TYPE_CHECKING: from app.commands.core import CommandContext command_registry = CommandRegistry() DEFAULT_REGISTRY = command_registry log = logging.getLogger(__name__) def _utf16_length(value: str) -> int: return len(value.encode("utf-16-le")) // 2 def _split_transcript(value: str, *, limit: int = 4000) -> list[str]: chunks: list[str] = [] remaining = value.strip() while remaining: units = 0 cut = len(remaining) for index, character in enumerate(remaining): units += _utf16_length(character) if units > limit: cut = index break if cut == len(remaining): chunks.append(remaining) break whitespace = max(remaining.rfind("\n", 0, cut), remaining.rfind(" ", 0, cut)) if whitespace > cut // 2: cut = whitespace chunk = remaining[:cut].strip() if chunk: chunks.append(chunk) remaining = remaining[cut:].strip() return chunks async def _set_reaction(message: Any, emoji: str | None) -> None: try: await message.react(emoji) except Exception: log.exception( "Could not set reaction %r on message %s", emoji, getattr(message, "id", None), ) async def _dislike(message: Any) -> None: await _set_reaction(message, "👎") def _transcribable_media(message: Any) -> Any | None: for field in ("voice", "video_note", "video"): media = getattr(message, field, None) if media is not None: return media return None def _user_label(user: Any | None) -> str: if user is None: return "unknown" parts = [getattr(user, "first_name", None), getattr(user, "last_name", None)] full_name = " ".join(part for part in parts if part) username = getattr(user, "username", None) if full_name and username: return f"{full_name} (@{username})" if full_name: return full_name if username: return f"@{username}" return str(getattr(user, "id", "unknown")) def _display_command(command: str) -> str: return f"..{command}" def _value_or_none(value: Any) -> str: if value is None or value == "": return "none" return str(value) def _message_text_preview(message: Any, *, limit: int = 180) -> str | None: text = getattr(message, "text", None) or getattr(message, "caption", None) if not text: return None text = str(text).replace("\n", " ").strip() if len(text) <= limit: return text return text[: limit - 1].rstrip() + "..." def _topic_id(message: Any) -> Any | None: topic = getattr(message, "topic", None) topic_id = getattr(topic, "id", None) if topic_id is not None: return topic_id for field in ( "message_thread_id", "direct_messages_topic_id", "reply_to_top_message_id", ): value = getattr(message, field, None) if value is not None: return value created_topic = getattr(message, "forum_topic_created", None) return getattr(created_topic, "id", None) def _topic_title(message: Any) -> str | None: topic = getattr(message, "topic", None) title = getattr(topic, "title", None) if title: return str(title) created_topic = getattr(message, "forum_topic_created", None) title = getattr(created_topic, "title", None) return str(title) if title else None def _append_chat_lines(lines: list[str], chat: Any, *, prefix: str = "chat") -> None: lines.append(f"{prefix}_id: {_value_or_none(getattr(chat, 'id', None))}") chat_type = getattr(chat, "type", None) if chat_type is not None: value = getattr(chat_type, "value", chat_type) lines.append(f"{prefix}_type: {_value_or_none(value)}") title = getattr(chat, "title", None) if title: lines.append(f"{prefix}_title: {title}") username = getattr(chat, "username", None) if username: lines.append(f"{prefix}_username: @{username}") def _append_user_lines(lines: list[str], user: Any | None, *, prefix: str = "user") -> None: lines.append(f"{prefix}_id: {_value_or_none(getattr(user, 'id', None))}") if user is None: return username = getattr(user, "username", None) if username: lines.append(f"{prefix}_username: @{username}") lines.append(f"{prefix}_name: {_user_label(user)}") def _append_sender_chat_lines(lines: list[str], chat: Any | None, *, prefix: str = "sender_chat") -> None: if chat is None: return lines.append(f"{prefix}_id: {_value_or_none(getattr(chat, 'id', None))}") title = getattr(chat, "title", None) if title: lines.append(f"{prefix}_title: {title}") username = getattr(chat, "username", None) if username: lines.append(f"{prefix}_username: @{username}") def _format_id_report(ctx: "CommandContext") -> str: message = ctx.message lines: list[str] = ["Current message:"] _append_chat_lines(lines, getattr(message, "chat", None)) lines.append(f"message_id: {_value_or_none(getattr(message, 'id', None))}") lines.append(f"thread_id: {_value_or_none(_topic_id(message))}") topic_title = _topic_title(message) if topic_title: lines.append(f"thread_title: {topic_title}") _append_user_lines(lines, getattr(message, "from_user", None), prefix="user") _append_sender_chat_lines(lines, getattr(message, "sender_chat", None)) reply = getattr(message, "reply_to_message", None) if reply is not None: lines.extend(["", "Replied message:"]) lines.append(f"reply_message_id: {_value_or_none(getattr(reply, 'id', None))}") lines.append(f"reply_thread_id: {_value_or_none(_topic_id(reply))}") reply_topic_title = _topic_title(reply) if reply_topic_title: lines.append(f"reply_thread_title: {reply_topic_title}") _append_user_lines(lines, getattr(reply, "from_user", None), prefix="reply_user") _append_sender_chat_lines(lines, getattr(reply, "sender_chat", None), prefix="reply_sender_chat") media = getattr(reply, "media", None) if media is not None: lines.append(f"reply_media: {_value_or_none(getattr(media, 'value', media))}") preview = _message_text_preview(reply) if preview: lines.append(f"reply_text: {preview}") return "\n".join(lines) def _format_permissions(ctx: "CommandContext") -> str: store = ctx.acl_store target = ctx.replied_user if store is None: return "Permission store is not initialized" if target is None: return "Reply to a user's message first" allowed = store.list_allowed(target.id) denied = store.list_denied(target.id) lines = [f"Permissions for {_user_label(target)} ({target.id}):"] lines.append( "allowed: " + (", ".join(_display_command(command) for command in sorted(allowed)) if allowed else "none") ) lines.append( "denied: " + (", ".join(_display_command(command) for command in sorted(denied)) if denied else "none") ) return "\n".join(lines) def _resolve_target_commands(ctx: "CommandContext") -> tuple[list[str], list[str]]: commands: list[str] = [] errors: list[str] = [] seen: set[str] = set() for token in ctx.argv: for part in (piece.strip() for piece in token.split(",")): normalized = normalize_command_name(part) if not normalized: continue definition = ctx.manager.command_registry.resolve(normalized) if definition is None: errors.append(f"Unknown command: {part}") continue if not definition.is_shared: errors.append(f"{definition.display_name} is owner-only and cannot be granted") continue if definition.name in seen: continue seen.add(definition.name) commands.append(definition.name) return commands, errors async def _change_permissions(ctx: "CommandContext", *, allow: bool) -> None: target = ctx.replied_user if target is None: await ctx.reply("Reply to the user's message first") return owner_id = ctx.owner_id if owner_id is not None and target.id == owner_id: await ctx.reply("Owner access cannot be changed") return commands, errors = _resolve_target_commands(ctx) if not commands and not errors: await ctx.reply("Provide at least one command name") return store = ctx.acl_store if store is None: await ctx.reply("Permission store is not initialized") return changed: list[str] = [] unchanged: list[str] = [] for command in commands: did_change = store.allow(target.id, command) if allow else store.deny(target.id, command) if did_change: changed.append(command) else: unchanged.append(command) lines = [ f"Target: {_user_label(target)} ({target.id})", f"Action: {'allow' if allow else 'deny'}", ] if changed: lines.append("Changed: " + ", ".join(_display_command(command) for command in sorted(changed))) if unchanged: lines.append( "Already set: " + ", ".join(_display_command(command) for command in sorted(unchanged)) ) if errors: lines.append("Errors:") lines.extend(f"- {error}" for error in errors) await ctx.reply("\n".join(lines)) @command_registry.command( "ping", "Check that the account is alive", usage=".ping", category="system", ) async def ping(ctx: "CommandContext") -> None: me = ctx.account.me await ctx.reply( "\n".join( [ "pong", f"account: {ctx.account.config.name}", f"session: {ctx.account.config.session}", f"user_id: {getattr(me, 'id', 'unknown')}", ] ) ) @command_registry.command( "status", "Show account and ACL state", usage=".status", category="system", ) async def status(ctx: "CommandContext") -> None: lines = [ctx.manager.build_status_report()] store = ctx.acl_store if store is not None: stats = store.stats() lines.append( "ACL stats: " f"allowed_users={stats['allowed_users']}, " f"denied_users={stats['denied_users']}, " f"allow_rules={stats['allow_rules']}, " f"deny_rules={stats['deny_rules']}" ) await ctx.reply("\n".join(lines)) @command_registry.command( "restart", "Stop the bot process so its supervisor can restart it", usage=".restart", category="system", ) async def restart(ctx: "CommandContext") -> None: await ctx.reply("Stopping bot process for restart") os.kill(os.getpid(), signal.SIGTERM) @command_registry.command( "id", "Show chat, thread, user, and replied message IDs", usage=".id", category="system", ) async def id_command(ctx: "CommandContext") -> None: await ctx.reply(_format_id_report(ctx)) @command_registry.command( "help", "Show command catalog", usage=".help", category="system", ) async def help_command(ctx: "CommandContext") -> None: await ctx.reply(ctx.manager.command_registry.render_help(ctx)) @command_registry.command( "rights", "Show ACL state for the replied user", usage=".rights", category="access", ).alias("acl").require_reply() async def rights(ctx: "CommandContext") -> None: await ctx.reply(_format_permissions(ctx)) @command_registry.command( "allow", "Grant a command to the replied user", usage=".allow [more...]", category="access", ).require_reply().require_args() async def allow(ctx: "CommandContext") -> None: await _change_permissions(ctx, allow=True) @command_registry.command( "deny", "Revoke a command from the replied user", usage=".deny [more...]", category="access", ).require_reply().require_args() async def deny(ctx: "CommandContext") -> None: await _change_permissions(ctx, allow=False) @command_registry.command( "echo", "Echo text for granted users", usage=".echo / ..echo ", category="shared", ).shared() async def echo(ctx: "CommandContext") -> None: text = ctx.args.strip() if not text: await ctx.reply("Usage: .echo / ..echo ") return await ctx.reply(text) @command_registry.command( "ai", "Run an agent with optional Telegram history and media", usage=".ai / ..ai [messages] [.glm|.ds|.cl|.or] [.th] ", category="shared", ).shared() async def ai(ctx: "CommandContext") -> None: await _set_reaction(ctx.message, "🤔") failed = False try: invocation = parse_agent_invocation(ctx.args, ctx.manager.config.ai) turn = await run_agent_turn( ctx.client, ctx.message, ctx.account, ctx.account.ai_sessions, invocation, ctx.manager.config.ai, ) for chunk in _split_transcript(turn.answer): sent = await ctx.reply(chunk) ctx.account.ai_sessions.register_response(turn.handle, sent) except Exception: log.exception("AI command failed for message %s", getattr(ctx.message, "id", None)) failed = True if failed: await _dislike(ctx.message) else: await _set_reaction(ctx.message, None) @command_registry.command( "txt", "Transcribe a replied voice message, video note, or video", usage=".txt / ..txt (reply to media)", category="shared", ).shared() async def txt(ctx: "CommandContext") -> None: reply = ctx.replied_message media = _transcribable_media(reply) if reply is not None else None if ctx.args.strip() or reply is None or media is None: await _dislike(ctx.message) return try: with tempfile.TemporaryDirectory(prefix="multi-bot-txt-") as temp_dir: downloaded = await ctx.client.download_media( reply, file_name=f"{temp_dir}{os.sep}", ) if not isinstance(downloaded, str): raise RuntimeError("Telegram media download returned no file") transcript = await transcribe_file( Path(downloaded), ctx.manager.config.elevenlabs, content_type=getattr(media, "mime_type", None), ) for chunk in _split_transcript(transcript): entity = MessageEntity( type=MessageEntityType.BLOCKQUOTE, offset=0, length=_utf16_length(chunk), expandable=True, ) await ctx.reply(chunk, entities=[entity]) except Exception: log.exception("Failed to transcribe media for message %s", getattr(ctx.message, "id", None)) await _dislike(ctx.message)