#!/usr/bin/env python3
"""
🔐 ДОЧИН СЕЙФ (Docha Seif Vault) — v1.0 Open-Source Local Agent
Официальный локальный агент с открытым исходным кодом для @papina_do4a_bot

ГАРАНТИИ БЕЗОПАСНОСТИ (ZERO-TRUST):
1. Ключ авторизации Telegram (.session / auth_key) хранится ТОЛЬКО локально
   на вашем устройстве в ~/.docha_seif/ (права 0600) и НИКОГДА не передаётся на сервер.
2. Все запросы к Telegram идут напрямую с вашего домашнего / серверного IP-адреса.
3. Белый список методов (Method Firewall): агент исполняет только разрешённые вами
   категории действий (--allow) и проверяет каждое действие локально до вызова Telethon.
4. Жёсткая блокировка (HARD_BLOCKED) вшита в код и не может быть отключена:
   - Запрещено чтение системных чатов Telegram (777000, 42777 — коды входа) и личных переписок с людьми.
   - Запрещены смена пароля 2FA, смена номера, сброс сессий, экспорт ключа и удаление аккаунта.

Быстрый запуск:
  pip3 install telethon aiohttp
  python3 docha_seif.py --token ds_live_ВАШ_ТОКЕН
"""
from __future__ import annotations

import argparse
import asyncio
import base64
import getpass
import io
import json
import os
import re
import stat
import sys
import time
from pathlib import Path
from typing import Any

try:
    import aiohttp
    from telethon import TelegramClient, events
    from telethon.errors import FloodWaitError, PeerFloodError, RPCError, SessionPasswordNeededError, SlowModeWaitError
    from telethon.extensions import html as tg_html
    from telethon.tl.functions.account import SetPrivacyRequest, UpdateProfileRequest
    from telethon.tl.functions.channels import JoinChannelRequest
    from telethon.tl.functions.chatlists import CheckChatlistInviteRequest, JoinChatlistInviteRequest
    from telethon.tl.functions.photos import UploadProfilePhotoRequest
    from telethon.tl.types import (
        InputPrivacyKeyChatInvite,
        InputPrivacyKeyPhoneNumber,
        InputPrivacyValueAllowContacts,
        InputPrivacyValueDisallowAll,
    )
except ImportError:
    print("❌ Не установлены библиотеки telethon и aiohttp.")
    print("👉 Выполните: pip3 install telethon aiohttp")
    sys.exit(1)

VERSION = "1.0.0"
DEFAULT_ENDPOINTS = (
    "wss://papa.theatralka.ru/ws/seif",
    "wss://papa.107-173-89-192.nip.io/ws/seif",
)
# Официальная публичная пара Telegram Desktop (можно переопределить через --api-id / --api-hash)
DEFAULT_API_ID = int(os.environ.get("TG_API_ID", "2040"))
DEFAULT_API_HASH = os.environ.get("TG_API_HASH", "b18441a1ff607e10a989891a5462e627")

ALL_SCOPES = ("send", "dialogs", "join", "delete", "profile", "spambot")

# Системные ID Telegram, чтение которых заблокировано навсегда на уровне кода
HARD_BLOCKED_PEER_IDS = frozenset({777000, 42777, 333000})

DOT_CMD_PREFIXES = (
    ".флуд", ".папка", ".саб", ".статус", ".помощь", ".оберег", ".отписка",
    "!флуд", "/флуд",
)


def _secure_dir() -> Path:
    base = Path.home() / ".docha_seif"
    base.mkdir(parents=True, exist_ok=True)
    try:
        os.chmod(base, stat.S_IRWXU)
    except OSError:
        pass
    return base


def _is_forbidden_private_peer(peer: Any, *, allow_spambot: bool = False) -> bool:
    """Блокирует чтение сервисного чата 777000 и любых личных диалогов с людьми."""
    if peer in ("me", "self"):
        return False
    if isinstance(peer, str):
        clean = peer.strip().lstrip("@").lower()
        if clean in ("me", "self"):
            return False
        if clean == "spambot" and allow_spambot:
            return False
        if clean in ("777000", "42777", "telegram"):
            return True
        return False
    if isinstance(peer, int):
        if peer in HARD_BLOCKED_PEER_IDS:
            return True
        # Положительные ID в MTProto — личные чаты с пользователями (кроме SpamBot 178220800)
        if peer == 178220800 and allow_spambot:
            return False
        if peer > 0:
            return True
    return False


async def interactive_login_if_needed(client: TelegramClient, session_file: Path) -> Any:
    """Локальный вход в вашем терминале. Пароль 2FA и код НЕ покидают ваш компьютер."""
    await client.connect()
    if await client.is_user_authorized():
        me = await client.get_me()
        try:
            os.chmod(session_file, stat.S_IRUSR | stat.S_IWUSR)
        except OSError:
            pass
        return me

    print("\n🔐 Первый запуск узла «Дочин Сейф» — авторизация хранится ТОЛЬКО локально:")
    print(f"   Файл сессии: {session_file} (права 600, на сервер не передаётся)\n")
    phone = input("📱 Введите номер телефона Telegram (например +79991234567): ").strip()
    if not phone:
        raise RuntimeError("Номер телефона не указан")

    sent = await client.send_code_request(phone)
    code = input("📩 Введите код из Telegram / SMS: ").strip()
    try:
        await client.sign_in(phone=phone, code=code, phone_code_hash=sent.phone_code_hash)
    except SessionPasswordNeededError:
        pwd = getpass.getpass("🔑 Введите облачный пароль 2FA (скрыт, остаётся только у вас): ")
        await client.sign_in(password=pwd)

    try:
        os.chmod(session_file, stat.S_IRUSR | stat.S_IWUSR)
    except OSError:
        pass
    return await client.get_me()


class SeifLocalExecutor:
    """Локальный исполнитель с двойной проверкой белого списка методов."""

    def __init__(self, client: TelegramClient, local_scopes: set[str]) -> None:
        self.client = client
        self.local_scopes = set(local_scopes)
        self.server_scopes: set[str] = set(ALL_SCOPES)

    def _require_scope(self, scope: str) -> None:
        if scope not in self.local_scopes:
            raise PermissionError(f"Действие «{scope}» отключено локальным флагом --allow на вашем устройстве")
        if scope not in self.server_scopes:
            raise PermissionError(f"Действие «{scope}» отключено в настройках узла Дочиного Сейфа")

    async def _resolve_target(self, target: Any) -> Any:
        if isinstance(target, int) and target < 0:
            try:
                return await self.client.get_input_entity(target)
            except ValueError:
                async for _ in self.client.iter_dialogs(limit=400):
                    pass
                return await self.client.get_input_entity(target)
        return target

    async def execute(self, op: str, params: dict[str, Any]) -> dict[str, Any]:
        if op == "ping":
            return {"pong": True, "ts": time.time()}

        if op == "update_scopes":
            incoming = params.get("scopes")
            if isinstance(incoming, list):
                self.server_scopes = {str(s) for s in incoming}
            return {"active_scopes": sorted(self.local_scopes & self.server_scopes)}

        if op == "send_message":
            target = params["target"]
            text = str(params.get("text") or "")
            is_spambot_start = (
                (str(target).lstrip("@").lower() == "spambot" or target == 178220800)
                and text.strip() == "/start"
            )
            if is_spambot_start:
                self._require_scope("spambot")
            else:
                self._require_scope("send")
            if _is_forbidden_private_peer(target, allow_spambot=is_spambot_start):
                raise PermissionError("Отправка в системный чат 777000 и личные диалоги заблокирована Дочиным Сейфом")
            peer_obj = await self._resolve_target(target)
            parse_mode = params.get("parse_mode")
            link_preview = bool(params.get("link_preview", False))
            reply_to = params.get("reply_to")
            comment_to = params.get("comment_to")
            silent = bool(params.get("silent", False))
            html_mode = bool(params.get("is_html", False))
            entities = None
            if html_mode and text:
                text, entities = tg_html.parse(text)
                parse_mode = None
            sent = await self.client.send_message(
                peer_obj,
                text,
                parse_mode=parse_mode,
                formatting_entities=entities,
                link_preview=link_preview,
                reply_to=reply_to,
                comment_to=comment_to,
                silent=silent,
            )
            return {"id": int(getattr(sent, "id", 0) or 0), "text": getattr(sent, "message", "") or ""}

        if op == "send_file":
            self._require_scope("send")
            target = params["target"]
            if _is_forbidden_private_peer(target):
                raise PermissionError("Отправка файлов в личные чаты и 777000 заблокирована Дочиным Сейфом")
            peer_obj = await self._resolve_target(target)
            b64_data = str(params.get("file_b64") or "")
            raw = base64.b64decode(b64_data)
            buf = io.BytesIO(raw)
            buf.name = str(params.get("file_name") or "media.jpg")
            caption = str(params.get("caption") or "")
            entities = None
            if params.get("is_html") and caption:
                caption, entities = tg_html.parse(caption)
            sent = await self.client.send_file(
                peer_obj,
                buf,
                caption=caption or None,
                formatting_entities=entities,
                silent=bool(params.get("silent", False)),
            )
            first = sent[0] if isinstance(sent, (list, tuple)) and sent else sent
            return {"id": int(getattr(first, "id", 0) or 0)}

        if op == "forward_messages":
            self._require_scope("send")
            target = params["target"]
            from_peer = params["from_peer"]
            msg_ids = params["message_ids"]
            if _is_forbidden_private_peer(from_peer) or _is_forbidden_private_peer(target):
                raise PermissionError("Пересылка для личных чатов и 777000 заблокирована Дочиным Сейфом")
            target_obj = await self._resolve_target(target)
            from_obj = await self._resolve_target(from_peer)
            fwd = await self.client.forward_messages(target_obj, msg_ids, from_peer=from_obj)
            first = fwd[0] if isinstance(fwd, (list, tuple)) and fwd else fwd
            return {"id": int(getattr(first, "id", 0) or 0)}

        if op == "inline_send":
            self._require_scope("send")
            bot_username = str(params["bot"]).lstrip("@")
            query = str(params.get("query") or "")
            target = params["target"]
            if _is_forbidden_private_peer(target):
                raise PermissionError("Инлайн-отправка в личные чаты и 777000 заблокирована Дочиным Сейфом")
            target_obj = await self._resolve_target(target)
            results = await self.client.inline_query(bot_username, query)
            if not results:
                return {"id": 0, "empty": True}
            click_opts: dict[str, Any] = {
                "silent": bool(params.get("silent", False)),
                "hide_via": False,
            }
            if params.get("reply_to") is not None:
                click_opts["reply_to"] = params["reply_to"]
            if params.get("comment_to") is not None:
                click_opts["comment_to"] = params["comment_to"]
            sent = await results[0].click(target_obj, **click_opts)
            return {"id": int(getattr(sent, "id", 0) or 0)}

        if op == "get_dialogs":
            self._require_scope("dialogs")
            limit = int(params.get("limit") or 200)
            out = []
            async for d in self.client.iter_dialogs(limit=min(max(limit, 1), 500)):
                # Личные переписки с людьми НЕ передаются на сервер — только группы и каналы!
                if d.is_user:
                    continue
                ent = d.entity
                uname = getattr(ent, "username", None) or ""
                out.append({
                    "id": int(d.id),
                    "title": d.name or "",
                    "username": uname,
                    "is_group": bool(d.is_group),
                    "is_channel": bool(d.is_channel and not d.is_group),
                })
            return {"dialogs": out}

        if op == "get_messages":
            peer = params["peer"]
            is_spambot = str(peer).lstrip("@").lower() == "spambot" or peer == 178220800
            if is_spambot:
                self._require_scope("spambot")
            else:
                self._require_scope("dialogs")
            if _is_forbidden_private_peer(peer, allow_spambot=is_spambot):
                raise PermissionError("Дочин Сейф блокирует чтение личных переписок и кодов 777000")
            ids = params.get("ids")
            limit = min(int(params.get("limit") or 5), 20)
            msgs = await self.client.get_messages(peer, ids=ids, limit=None if ids else limit)
            if msgs is None:
                return {"messages": []}
            seq = msgs if isinstance(msgs, (list, tuple)) else [msgs]
            serialized = []
            for m in seq:
                if m is None:
                    continue
                html_txt = ""
                try:
                    html_txt = tg_html.unparse(getattr(m, "message", "") or "", getattr(m, "entities", None) or [])
                except Exception:
                    html_txt = getattr(m, "message", "") or ""
                serialized.append({
                    "id": int(getattr(m, "id", 0) or 0),
                    "out": bool(getattr(m, "out", False)),
                    "raw_text": str(getattr(m, "raw_text", "") or getattr(m, "message", "") or ""),
                    "html_text": html_txt,
                    "has_media": bool(getattr(m, "media", None)),
                })
            return {"messages": serialized}

        if op == "delete_messages":
            self._require_scope("delete")
            peer = params["peer"]
            if _is_forbidden_private_peer(peer):
                raise PermissionError("Удаление в личных переписках заблокировано")
            mids = params.get("message_ids") or []
            await self.client.delete_messages(peer, mids)
            return {"deleted": len(mids) if isinstance(mids, list) else 1}

        if op == "join_channel":
            self._require_scope("join")
            channel = params["channel"]
            await self.client(JoinChannelRequest(channel))
            return {"joined": True}

        if op == "join_folder":
            self._require_scope("join")
            slug = str(params["slug"])
            info = await self.client(CheckChatlistInviteRequest(slug=slug))
            peers = list(getattr(info, "missing_peers", None) or getattr(info, "peers", None) or [])
            joined_now = 0
            if peers:
                try:
                    await self.client(JoinChatlistInviteRequest(slug=slug, peers=peers))
                    joined_now = len(peers)
                except Exception:
                    pass
            chats_out = []
            for ch in getattr(info, "chats", None) or []:
                raw_id = int(getattr(ch, "id", 0) or 0)
                if not raw_id:
                    continue
                cid = raw_id if str(raw_id).startswith("-100") else (-1000000000000 - abs(raw_id))
                chats_out.append({
                    "id": cid,
                    "title": str(getattr(ch, "title", "") or f"Чат {cid}"),
                    "username": getattr(ch, "username", None),
                    "participants_count": int(getattr(ch, "participants_count", 0) or 500),
                    "megagroup": bool(getattr(ch, "megagroup", False)),
                    "broadcast": bool(getattr(ch, "broadcast", False)),
                })
            return {"joined_count": joined_now, "chats": chats_out}

        if op == "update_profile":
            self._require_scope("profile")
            kw: dict[str, str] = {"about": str(params.get("about") or "")[:70]}
            if params.get("first_name"):
                kw["first_name"] = str(params["first_name"])[:64]
            await self.client(UpdateProfileRequest(**kw))
            return {"updated": True}

        if op == "set_privacy":
            self._require_scope("profile")
            await self.client(SetPrivacyRequest(key=InputPrivacyKeyPhoneNumber(), rules=[InputPrivacyValueDisallowAll()]))
            await self.client(SetPrivacyRequest(key=InputPrivacyKeyChatInvite(), rules=[InputPrivacyValueAllowContacts()]))
            return {"privacy_applied": True}

        if op == "upload_avatar":
            self._require_scope("profile")
            raw = base64.b64decode(str(params.get("b64") or ""))
            buf = io.BytesIO(raw)
            buf.name = "avatar.jpg"
            up = await self.client.upload_file(buf, file_name="avatar.jpg")
            await self.client(UploadProfilePhotoRequest(file=up))
            return {"avatar_updated": True}

        raise PermissionError(f"Метод «{op}» отсутствует в белом списке Дочиного Сейфа")


async def run_agent(args: argparse.Namespace) -> None:
    token = args.token.strip()
    if not token.startswith("ds_live_"):
        print("❌ Некорректный токен. Скопируйте токен вида ds_live_... в разделе «🔐 Дочин Сейф» мини-аппа.")
        sys.exit(1)

    local_scopes = {s.strip().lower() for s in args.allow.split(",") if s.strip()} & set(ALL_SCOPES)
    if not local_scopes:
        print("❌ Пустой список --allow. Доступные категории:", ", ".join(ALL_SCOPES))
        sys.exit(1)

    sec_dir = _secure_dir()
    session_path = Path(args.session_file) if args.session_file else (sec_dir / f"node_{token[8:18]}.session")
    client = TelegramClient(
        str(session_path),
        args.api_id,
        args.api_hash,
        device_model="Docha Seif Local Vault",
        system_version="Local Zero-Trust",
        app_version=VERSION,
    )

    me = await interactive_login_if_needed(client, session_path)
    phone_str = f"+{me.phone}" if getattr(me, "phone", None) and not str(me.phone).startswith("+") else (getattr(me, "phone", None) or f"id{me.id}")
    print(f"\n✅ Локальная сессия активна: {phone_str} (@{getattr(me, 'username', '') or 'без_юзернейма'})")
    print(f"🛡 Разрешённые локально методы: {', '.join(sorted(local_scopes))}")
    print("🔒 Чтение кодов 777000, личных чатов и смена пароля/номера заблокированы в коде.\n")

    executor = SeifLocalExecutor(client, local_scopes)
    ws_ref: dict[str, Any] = {"ws": None}

    @client.on(events.NewMessage(outgoing=True))
    async def _on_local_dot_command(event: Any) -> None:
        raw = str(getattr(event, "raw_text", "") or "").strip()
        low = raw.lower()
        if not any(low.startswith(p) for p in DOT_CMD_PREFIXES):
            return
        ws = ws_ref.get("ws")
        if ws is None or ws.closed:
            return
        try:
            await ws.send_json({
                "type": "outgoing_command",
                "chat_id": int(getattr(event, "chat_id", 0) or 0),
                "msg_id": int(getattr(event.message, "id", 0) or 0),
                "raw_text": raw,
            })
        except Exception:
            pass

    endpoints = [args.server] if args.server else list(DEFAULT_ENDPOINTS)
    ep_idx = 0
    backoff = 2.0

    async with aiohttp.ClientSession() as http:
        while True:
            url = endpoints[ep_idx % len(endpoints)]
            try:
                print(f"🌐 Подключаемся к шлюзу управления ДОЧИ ({url})...")
                async with http.ws_connect(
                    url,
                    headers={"Authorization": f"Bearer {token}", "X-Seif-Version": VERSION},
                    heartbeat=25.0,
                    timeout=aiohttp.ClientWSTimeout(ws_close=10.0),
                ) as ws:
                    ws_ref["ws"] = ws
                    backoff = 2.0
                    await ws.send_json({
                        "type": "hello",
                        "version": VERSION,
                        "phone": phone_str,
                        "tg_user_id": int(me.id),
                        "username": getattr(me, "username", None),
                        "first_name": getattr(me, "first_name", None),
                        "local_scopes": sorted(local_scopes),
                    })
                    print("🟢 Узел «Дочин Сейф» в сети! Управляйте задачами в привычном кабинете @papina_do4a_bot.")
                    async for msg in ws:
                        if msg.type == aiohttp.WSMsgType.TEXT:
                            data = json.loads(msg.data)
                            if data.get("type") == "welcome":
                                srv_scopes = data.get("allowed_scopes")
                                if isinstance(srv_scopes, list):
                                    executor.server_scopes = set(srv_scopes)
                                continue
                            if data.get("type") != "rpc":
                                continue
                            req_id = data.get("req_id")
                            op = str(data.get("op") or "")
                            params = data.get("params") or {}
                            try:
                                result = await executor.execute(op, params)
                                await ws.send_json({"type": "rpc_result", "req_id": req_id, "ok": True, "result": result})
                            except (FloodWaitError, SlowModeWaitError) as exc:
                                await ws.send_json({
                                    "type": "rpc_result",
                                    "req_id": req_id,
                                    "ok": False,
                                    "error_type": type(exc).__name__,
                                    "seconds": int(getattr(exc, "seconds", 1) or 1),
                                    "message": str(exc),
                                })
                            except (PeerFloodError, RPCError) as exc:
                                await ws.send_json({
                                    "type": "rpc_result",
                                    "req_id": req_id,
                                    "ok": False,
                                    "error_type": type(exc).__name__,
                                    "message": str(exc),
                                })
                            except Exception as exc:
                                await ws.send_json({
                                    "type": "rpc_result",
                                    "req_id": req_id,
                                    "ok": False,
                                    "error_type": type(exc).__name__,
                                    "message": str(exc),
                                })
                        elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
                            break
            except asyncio.CancelledError:
                break
            except Exception as exc:
                ws_ref["ws"] = None
                ep_idx += 1
                print(f"⚠️ Связь прервана ({type(exc).__name__}: {exc}). Переподключение через {int(backoff)} сек...")
                await asyncio.sleep(backoff)
                backoff = min(backoff * 1.7, 20.0)


def main() -> None:
    parser = argparse.ArgumentParser(description="🔐 Дочин Сейф — локальный Zero-Trust агент для @papina_do4a_bot")
    parser.add_argument("--token", required=True, help="Токен узла из раздела «Дочин Сейф» (ds_live_...)")
    parser.add_argument(
        "--allow",
        default=",".join(ALL_SCOPES),
        help=f"Разрешённые категории через запятую (по умолчанию: {','.join(ALL_SCOPES)})",
    )
    parser.add_argument("--server", default="", help="Свой WSS адрес шлюза (необязательно)")
    parser.add_argument("--session-file", default="", help="Путь к локальному .session файлу (необязательно)")
    parser.add_argument("--api-id", type=int, default=DEFAULT_API_ID, help="Telegram API ID")
    parser.add_argument("--api-hash", default=DEFAULT_API_HASH, help="Telegram API Hash")
    parser.add_argument("--daemon", action="store_true", help="После входа уйти в фоновый режим (можно закрыть терминал)")
    args = parser.parse_args()
    if getattr(args, "daemon", False) and hasattr(os, "fork"):
        # Сначала убеждаемся, что сессия авторизована интерактивно, затем уходим в фон
        sess_path = Path(args.session_file) if args.session_file else (_secure_dir() / f"seif_{args.token[8:18]}.session")
        if sess_path.exists():
            pid = os.fork()
            if pid > 0:
                print(f"👻 Дочин Сейф переведён в фоновый режим (PID {pid}). Терминал можно закрыть!")
                return
            os.setsid()
    try:
        asyncio.run(run_agent(args))
    except KeyboardInterrupt:
        print("\n👋 Узел «Дочин Сейф» остановлен пользователем. Сессия осталась только на вашем устройстве.")


if __name__ == "__main__":
    main()
