diff --git a/.env.example b/.env.example index ec2c8ec..4e9911a 100644 --- a/.env.example +++ b/.env.example @@ -17,10 +17,13 @@ db_name = "openmax" db_file = "" -certfile = "cert.pem" -keyfile = "key.pem" +certfile = "/certs/cert.pem" +keyfile = "/certs/key.pem" +domain = "openmax.su" avatar_base_url = "http://127.0.0.1/avatar/" telegram_bot_token = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ" telegram_bot_enabled = "1" -telegram_whitelist_ids = "1,2,3" \ No newline at end of file +telegram_whitelist_ids = "1,2,3" +origins="http://127.0.0.1,https://web.openmax.su" +sms_gateway_url = "http://127.0.0.1:8100/sms-gateway" \ No newline at end of file diff --git a/.gitignore b/.gitignore index babc0d7..8ee9d44 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__ .env *.pem -*.sqlite \ No newline at end of file +*.sqlite +*.crt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..172113f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ + +WORKDIR /app/src + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f6abed6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +services: + app: + build: . + restart: unless-stopped + ports: + - "${oneme_tcp_port:-443}:443" + - "${tamtam_tcp_port:-4433}:4433" + - "${oneme_ws_port:-81}:81" + - "${tamtam_ws_port:-82}:82" + volumes: + - /etc/letsencrypt/live/${domain}/fullchain.pem:/certs/cert.pem:ro + - /etc/letsencrypt/live/${domain}/privkey.pem:/certs/key.pem:ro + env_file: + - .env + environment: + - db_host=db + depends_on: + db: + condition: service_healthy + + db: + image: mysql:8.0 + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${db_password:-openmax} + MYSQL_DATABASE: ${db_name:-openmax} + MYSQL_USER: ${db_user:-openmax} + MYSQL_PASSWORD: ${db_password:-openmax} + volumes: + - mysql_data:/var/lib/mysql + - ./tables.sql:/docker-entrypoint-initdb.d/tables.sql:ro + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + mysql_data: \ No newline at end of file diff --git a/docs/proto/oneme_tcp.md b/docs/proto/oneme_tcp.md deleted file mode 100644 index 30404ce..0000000 --- a/docs/proto/oneme_tcp.md +++ /dev/null @@ -1 +0,0 @@ -TODO \ No newline at end of file diff --git a/docs/proto/tamtam_ws.md b/docs/proto/tamtam_ws.md deleted file mode 100644 index bd4e712..0000000 --- a/docs/proto/tamtam_ws.md +++ /dev/null @@ -1,29 +0,0 @@ -# Описание протокола TamTam по Websocket - -## Основная информация -В веб версии мессенджера ТамТам используется протокол, работающий поверх Websocket. - -Пакеты в этом протоколе являются текстовыми JSON данными. - -Структура пакета: -``` -{ - ver: int, - cmd: int, - seq: int, - opcode: int, - payload: {} -} -``` - -* ver - версия протокола -* cmd - определяет, от кого отправлен пакет. клиент - 0, сервер - 1 -* seq - порядковый номер пакета (сервер дублирует его из запроса клиента) -* opcode - команда -* payload - полезная нагрузка команды - -## Команды протокола - -### PING (1) -Клиент периодически отправляет пакет с командой PING и пустой нагрузкой серверу. -Сервер отвечает ему тем же. \ No newline at end of file diff --git a/faq/install.md b/faq/install.md index 32e30ac..c503bda 100644 --- a/faq/install.md +++ b/faq/install.md @@ -1,18 +1,80 @@ # Установка +## Вручную + 1. Склонируйте репозиторий 2. Установите зависимости - ```bash pip install -r requirements.txt ``` -3. Настройте сервер (пример в `.env.example`) -4. Импортируйте схему таблиц в свою базу данных из `tables.sql` -5. Запустите сервер +3. Сгенерируйте сертификат + +Для тестирования (самоподписанный): +```bash +openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365 +``` + +Для прода — [Let's Encrypt](https://certbot.eff.org/): +```bash +apt install certbot +certbot certonly --standalone -d openmax.su +``` + +4. Настройте сервер (пример в `.env.example`) +5. Импортируйте схему таблиц в свою базу данных из `tables.sql` +6. Запустите сервер ```bash python3 main.py ``` -6. Создайте пользователя -7. Зайдите со своего любимого клиента +7. Создайте пользователя через Telegram бот (`/register`) +8. Зайдите со своего любимого клиента + +--- + +## Docker + +1. Склонируйте репозиторий +2. Настройте `.env` (пример в `.env.example`), укажите `db_user` отличный от `root` +3. Получите сертификат Let's Encrypt: +```bash +apt install certbot +certbot certonly --standalone -d openmax.su +``` + +Укажите домен и пути в `.env`: +``` +certfile=/certs/cert.pem +keyfile=/certs/key.pem +domain=openmax.su +``` + +4. Запустите +```bash +docker compose up -d +``` + +База данных инициализируется автоматически из `tables.sql`. + +5. Создайте пользователя через Telegram бот (`/register`) +6. Зайдите со своего любимого клиента + +--- + +## SMS-шлюз + +По умолчанию коды авторизации доставляются через Telegram бот. Если вы хотите принимать пользователей с произвольными номерами без привязки к Telegram — поднимите [SMS Gateway](https://github.com/openmax-server/server/sms-gateway), укажите его адрес в `.env` и отключите Telegram бот: +``` +telegram_bot_enabled=false +sms_gateway_url=http://localhost:8100/sms-gateway +``` + +Клиент MAX ожидает 6-значный код. Если ваш SMS-провайдер отправляет 5-значные коды и не поддерживает настройку длины — сервер автоматически дублирует последнюю цифру: `26541` → `265411`. Пользователь получает SMS с 5 цифрами и вводит их дважды последнюю: `2-6-5-4-1-1`. + +--- + +## Автопродление сертификата +```bash +certbot renew --deploy-hook "docker compose -f /opt/server/docker-compose.yml restart app" +``` \ No newline at end of file diff --git a/readme.md b/readme.md index 1faf673..a916879 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ > Проект находится на ранней стадии разработки и вероятно полон багов. > > Использование в профессиональных средах не рекомендовано. -> + # OpenMAX Эмулятор сервера MAX и ТамТам diff --git a/requirements.txt b/requirements.txt index 6f985ec..1ba8704 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,10 @@ -pyTelegramBotAPI +aiogram aiomysql msgpack lz4 websockets pydantic aiosqlite -python-dotenv \ No newline at end of file +aiohttp +python-dotenv +cryptography \ No newline at end of file diff --git a/sms-gateway/Dockerfile b/sms-gateway/Dockerfile new file mode 100644 index 0000000..95c9554 --- /dev/null +++ b/sms-gateway/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/sms-gateway/README.md b/sms-gateway/README.md new file mode 100644 index 0000000..a8f82e2 --- /dev/null +++ b/sms-gateway/README.md @@ -0,0 +1,90 @@ +# Смс шлюз + +Микросервис для отправки SMS-кодов с маршрутизацией по провайдерам в зависимости от страны. + +## Требования + +- Docker и Docker Compose + +## Запуск +```bash +docker compose up -d +``` + +Сервис доступен на порту `8100`, API монтируется по префиксу `/sms-gateway`. + +## Конфигурация + +Все настройки находятся в `config.yaml`. Перезагрузка конфига без перезапуска: +```bash +curl -X POST http://localhost:8100/sms-gateway/admin/reload +``` + +### Провайдеры + +Два типа провайдеров: + +**`sms_api`** — внешний HTTP-сервис, отправляет реальное SMS. Параметры: +- `base_url` — базовый адрес сервиса +- `send_endpoint` — эндпоинт отправки (по умолчанию `/auth/code`) +- `timeout` — таймаут запроса в секундах + +**`lk_api`** — внутренний провайдер, SMS не отправляет. Генерирует код и сохраняет его в Redis для отображения в личном кабинете. + +### Маршрутизация + +Правила задаются в `routing.rules`. Для каждого правила указываются префиксы номеров, основной провайдер и опциональный fallback. Если ни одно правило не совпало — используется `default_provider`. + +Пример: номера `+7` идут через `sms_api`, при недоступности — через `lk_api`. Все остальные номера сразу через `lk_api`. + +### Rate limiting + +Настраивается в `settings.rate_limit`: +- `max_attempts` — максимум запросов с одного номера +- `window_seconds` — окно в секундах + +## API + +### Отправка кода +``` +POST /sms-gateway/sms/send +{"phone_number": "+79001234567"} +``` + +### Личный кабинет + +Получить все ожидающие коды: +``` +GET /sms-gateway/lk/codes +``` + +Получить код по номеру: +``` +GET /sms-gateway/lk/code?phone=+79001234567 +``` + +Получить и удалить код (разовое считывание): +``` +DELETE /sms-gateway/lk/code?phone=+79001234567 +``` + +### Администрирование + +Проверить, какой провайдер выберется для номера: +``` +GET /sms-gateway/admin/routing/resolve?phone=+79001234567 +``` + +Список правил маршрутизации: +``` +GET /sms-gateway/admin/routing/rules +``` + +Список активных провайдеров: +``` +GET /sms-gateway/admin/providers +``` + +## Swagger + +Документация доступна по адресу: `http://localhost:8100/sms-gateway/docs` \ No newline at end of file diff --git a/sms-gateway/app/config.py b/sms-gateway/app/config.py new file mode 100644 index 0000000..ce64301 --- /dev/null +++ b/sms-gateway/app/config.py @@ -0,0 +1,79 @@ +from __future__ import annotations +import os +from functools import lru_cache +from pathlib import Path +from typing import Any +import yaml +from pydantic import BaseModel + +class ProviderConfig(BaseModel): + type: str + enabled: bool = True + model_config = {"extra": "allow"} + + def extra(self) -> dict[str, Any]: + return dict(self.__pydantic_extra__) if self.__pydantic_extra__ else {} + +class RoutingRule(BaseModel): + name: str + prefixes: list[str] + provider: str + fallback: str | None = None + + def matches(self, phone: str) -> bool: + normalized = phone if phone.startswith("+") else f"+{phone}" + for prefix in sorted(self.prefixes, key=len, reverse=True): + if normalized.startswith(prefix): + return True + return False + +class RoutingConfig(BaseModel): + rules: list[RoutingRule] = [] + default_provider: str = "lk_api" + default_fallback: str | None = None + +class RateLimitSettings(BaseModel): + enabled: bool = True + max_attempts: int = 3 + window_seconds: int = 600 + +class AppSettings(BaseModel): + log_codes: bool = True + code_ttl_seconds: int = 300 + rate_limit: RateLimitSettings = RateLimitSettings() + +class RedisConfig(BaseModel): + host: str = "redis" + port: int = 6379 + db: int = 0 + password: str | None = None + + def url(self) -> str: + if self.password: + return f"redis://:{self.password}@{self.host}:{self.port}/{self.db}" + return f"redis://{self.host}:{self.port}/{self.db}" + +class Config(BaseModel): + providers: dict[str, ProviderConfig] + routing: RoutingConfig + settings: AppSettings = AppSettings() + redis: RedisConfig = RedisConfig() + + def resolve_provider(self, phone: str) -> tuple[str, str | None]: + for rule in self.routing.rules: + if rule.matches(phone): + return rule.provider, rule.fallback + return self.routing.default_provider, self.routing.default_fallback + +@lru_cache(maxsize=1) +def load_config() -> Config: + path = Path(os.getenv("CONFIG_PATH", "config.yaml")) + if not path.exists(): + raise FileNotFoundError(f"Конфиг не найден: {path}") + with open(path, encoding="utf-8") as f: + raw = yaml.safe_load(f) + return Config.model_validate(raw) + +def reload_config() -> Config: + load_config.cache_clear() + return load_config() \ No newline at end of file diff --git a/sms-gateway/app/deps.py b/sms-gateway/app/deps.py new file mode 100644 index 0000000..c2e7ccf --- /dev/null +++ b/sms-gateway/app/deps.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from app.config import Config, load_config +from app.providers.registry import build_all_providers +from app.redis_client import get_redis +from app.service import SmsService + +_service: SmsService | None = None + +def init_service() -> None: + global _service + config = load_config() + providers = build_all_providers(config) + redis = get_redis() + _service = SmsService(config, providers, redis) + +def get_sms_service() -> SmsService: + global _service + if _service is None: + init_service() + return _service \ No newline at end of file diff --git a/sms-gateway/app/main.py b/sms-gateway/app/main.py new file mode 100644 index 0000000..6c32b20 --- /dev/null +++ b/sms-gateway/app/main.py @@ -0,0 +1,41 @@ +from __future__ import annotations +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI +from app.config import load_config +from app.redis_client import close_redis, init_redis +from app.routers import admin, lk, sms +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + +@asynccontextmanager +async def lifespan(app: FastAPI): + config = load_config() + await init_redis(config.redis) + logger.info("Redis подключён: %s", config.redis.url()) + logger.info( + "Провайдеры: %s | Правил маршрутизации: %d", + list(config.providers.keys()), + len(config.routing.rules), + ) + yield + await close_redis() + logger.info("SMS Gateway остановлен") + +app = FastAPI( + title="SMS Gateway", + description="Маршрутизация SMS по провайдерам в зависимости от страны", + version="1.0.0", + lifespan=lifespan, + root_path="/sms-gateway", +) +app.include_router(sms.router) +app.include_router(lk.router) +app.include_router(admin.router) + +@app.get("/health") +async def health() -> dict: + return {"status": "ok"} \ No newline at end of file diff --git a/sms-gateway/app/providers/__init__.py b/sms-gateway/app/providers/__init__.py new file mode 100644 index 0000000..41e6e5b --- /dev/null +++ b/sms-gateway/app/providers/__init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations +from dataclasses import dataclass, field + +@dataclass +class SendResult: + success: bool + provider: str + code: str | None = None + raw_response: dict = field(default_factory=dict) + error: str | None = None \ No newline at end of file diff --git a/sms-gateway/app/providers/base.py b/sms-gateway/app/providers/base.py new file mode 100644 index 0000000..3f9a169 --- /dev/null +++ b/sms-gateway/app/providers/base.py @@ -0,0 +1,10 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from app.providers import SendResult + +class BaseProvider(ABC): + name: str = "base" + + @abstractmethod + async def send(self, phone_number: str, code: str | None = None) -> SendResult: + pass \ No newline at end of file diff --git a/sms-gateway/app/providers/lk_api.py b/sms-gateway/app/providers/lk_api.py new file mode 100644 index 0000000..b7d9453 --- /dev/null +++ b/sms-gateway/app/providers/lk_api.py @@ -0,0 +1,36 @@ +from __future__ import annotations +import logging +import random +import uuid +from app.config import ProviderConfig +from app.providers import SendResult +from app.providers.base import BaseProvider + +logger = logging.getLogger(__name__) + +class LkApiProvider(BaseProvider): + """ + Внутренний провайдер — SMS не шлёт. + Генерирует код, который отображается в личном кабинете. + Используется для всех стран кроме России. + """ + name = "lk_api" + + def __init__(self, config: ProviderConfig | None = None) -> None: + pass + + async def send(self, phone_number: str, code: str | None = None) -> SendResult: + normalized = phone_number if phone_number.startswith("+") else f"+{phone_number}" + if not code: + code = str(random.randint(10000, 99999)) + request_uuid = str(uuid.uuid4()) + logger.info( + "lk_api: код для ЛК | phone=%s code=%s uuid=%s", + normalized, code, request_uuid, + ) + return SendResult( + success=True, + provider=self.name, + code=code, + raw_response={"code": int(code), "uuid": request_uuid, "note": "displayed in personal cabinet"}, + ) \ No newline at end of file diff --git a/sms-gateway/app/providers/registry.py b/sms-gateway/app/providers/registry.py new file mode 100644 index 0000000..3be572f --- /dev/null +++ b/sms-gateway/app/providers/registry.py @@ -0,0 +1,34 @@ +from __future__ import annotations +import logging +from app.config import Config, ProviderConfig +from app.providers.base import BaseProvider +from app.providers.lk_api import LkApiProvider +from app.providers.sms_api import SmsApiProvider + +logger = logging.getLogger(__name__) +PROVIDER_REGISTRY: dict[str, type[BaseProvider]] = { + "sms_api": SmsApiProvider, + "lk_api": LkApiProvider, +} + +def build_provider(name: str, config: ProviderConfig) -> BaseProvider | None: + cls = PROVIDER_REGISTRY.get(config.type) + if cls is None: + logger.error("Неизвестный тип провайдера: %s", config.type) + return None + if not config.enabled: + logger.debug("Провайдер %s отключён", name) + return None + return cls(config) + +def build_all_providers(config: Config) -> dict[str, BaseProvider]: + result: dict[str, BaseProvider] = {} + for name, provider_cfg in config.providers.items(): + provider = build_provider(name, provider_cfg) + if provider is not None: + result[name] = provider + logger.info("Провайдер загружен: %s (тип: %s)", name, provider_cfg.type) + if "lk_api" not in result: + result["lk_api"] = LkApiProvider() + logger.info("lk_api добавлен как fallback по умолчанию") + return result \ No newline at end of file diff --git a/sms-gateway/app/providers/sms_api.py b/sms-gateway/app/providers/sms_api.py new file mode 100644 index 0000000..180d01e --- /dev/null +++ b/sms-gateway/app/providers/sms_api.py @@ -0,0 +1,52 @@ +from __future__ import annotations +import logging +import httpx +from app.config import ProviderConfig +from app.providers import SendResult +from app.providers.base import BaseProvider + +logger = logging.getLogger(__name__) + +class SmsApiProvider(BaseProvider): + """ + Внешний SMS-сервис. + Отправляет реальное SMS, возвращает код и uuid. + Используется для России (+7). + """ + name = "sms_api" + + def __init__(self, config: ProviderConfig) -> None: + extra = config.extra() + self.base_url: str = extra.get("base_url", "").rstrip("/") + self.send_endpoint: str = extra.get("send_endpoint", "/auth/code") + self.timeout: int = int(extra.get("timeout", 10)) + + async def send(self, phone_number: str, code: str | None = None) -> SendResult: + normalized = phone_number if phone_number.startswith("+") else f"+{phone_number}" + url = f"{self.base_url}{self.send_endpoint}" + payload: dict = {"phone_number": normalized} + if code: + payload["code"] = code + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + url, + json=payload, + headers={"accept": "application/json", "Content-Type": "application/json"}, + ) + response.raise_for_status() + data = response.json() + code = str(data.get("code", "")) + logger.info("sms_api: SMS отправлен на %s | uuid=%s code=%s", normalized, data.get("uuid"), code) + return SendResult( + success=True, + provider=self.name, + code=code, + raw_response=data, + ) + except httpx.HTTPStatusError as e: + logger.error("sms_api HTTP %s для %s: %s", e.response.status_code, normalized, e) + return SendResult(success=False, provider=self.name, error=str(e)) + except Exception as e: + logger.error("sms_api ошибка для %s: %s", normalized, e) + return SendResult(success=False, provider=self.name, error=str(e)) \ No newline at end of file diff --git a/sms-gateway/app/redis_client.py b/sms-gateway/app/redis_client.py new file mode 100644 index 0000000..280aed8 --- /dev/null +++ b/sms-gateway/app/redis_client.py @@ -0,0 +1,25 @@ +from __future__ import annotations +import redis.asyncio as aioredis +from app.config import RedisConfig +_redis: aioredis.Redis | None = None + +async def init_redis(cfg: RedisConfig) -> aioredis.Redis: + global _redis + _redis = aioredis.from_url( + cfg.url(), + encoding="utf-8", + decode_responses=True, + ) + await _redis.ping() + return _redis + +async def close_redis() -> None: + global _redis + if _redis: + await _redis.aclose() + _redis = None + +def get_redis() -> aioredis.Redis: + if _redis is None: + raise RuntimeError("Redis не инициализирован") + return _redis \ No newline at end of file diff --git a/src/oneme_tcp/__init__.py b/sms-gateway/app/routers/__init__.py similarity index 100% rename from src/oneme_tcp/__init__.py rename to sms-gateway/app/routers/__init__.py diff --git a/sms-gateway/app/routers/admin.py b/sms-gateway/app/routers/admin.py new file mode 100644 index 0000000..32a7a1d --- /dev/null +++ b/sms-gateway/app/routers/admin.py @@ -0,0 +1,51 @@ +from __future__ import annotations +import logging +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from app.config import reload_config +from app.deps import get_sms_service, init_service +from app.service import SmsService + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/admin", tags=["Admin"]) + +class RoutingInfo(BaseModel): + phone: str + primary_provider: str + fallback_provider: str | None + +@router.post("/reload", response_model=dict) +async def reload() -> dict: + """Перечитать config.yaml без перезапуска сервиса.""" + new_config = reload_config() + init_service() + providers = list(new_config.providers.keys()) + rules_count = len(new_config.routing.rules) + logger.info("Конфиг перезагружен: провайдеры=%s правил=%d", providers, rules_count) + return {"success": True, "providers": providers, "routing_rules": rules_count} + +@router.get("/routing/resolve", response_model=RoutingInfo) +async def resolve_routing( + phone: str, + service: SmsService = Depends(get_sms_service), +) -> RoutingInfo: + """Проверить, какой провайдер будет выбран для номера.""" + primary, fallback = service.config.resolve_provider(phone) + return RoutingInfo(phone=phone, primary_provider=primary, fallback_provider=fallback) + +@router.get("/routing/rules", response_model=list[dict]) +async def list_rules( + service: SmsService = Depends(get_sms_service), +) -> list[dict]: + """Список всех правил маршрутизации.""" + return [rule.model_dump() for rule in service.config.routing.rules] + +@router.get("/providers", response_model=list[dict]) +async def list_providers( + service: SmsService = Depends(get_sms_service), +) -> list[dict]: + """Список активных провайдеров.""" + return [ + {"name": name, "type": name, "enabled": True} + for name in service.providers.keys() + ] \ No newline at end of file diff --git a/sms-gateway/app/routers/lk.py b/sms-gateway/app/routers/lk.py new file mode 100644 index 0000000..0b47823 --- /dev/null +++ b/sms-gateway/app/routers/lk.py @@ -0,0 +1,43 @@ +from __future__ import annotations +import logging +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from app.deps import get_sms_service +from app.service import SmsService + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/lk", tags=["Личный кабинет"]) + +class PendingCode(BaseModel): + phone: str + code: str + expires_in: int + +@router.get("/codes", response_model=list[PendingCode]) +async def list_codes( + service: SmsService = Depends(get_sms_service), +) -> list[PendingCode]: + items = await service.list_pending_codes() + return [PendingCode(**item) for item in items] + +@router.get("/code", response_model=PendingCode) +async def get_code( + phone: str = Query(..., description="Номер телефона"), + service: SmsService = Depends(get_sms_service), +) -> PendingCode: + items = await service.list_pending_codes() + normalized = phone if phone.startswith("+") else f"+{phone}" + for item in items: + if item["phone"] == normalized: + return PendingCode(**item) + raise HTTPException(status_code=404, detail="Код не найден или истёк") + +@router.delete("/code", response_model=dict) +async def consume_code( + phone: str = Query(..., description="Номер телефона"), + service: SmsService = Depends(get_sms_service), +) -> dict: + code = await service.consume_code(phone) + if code is None: + raise HTTPException(status_code=404, detail="Код не найден или истёк") + return {"success": True, "phone": phone, "consumed_code": code} \ No newline at end of file diff --git a/sms-gateway/app/routers/sms.py b/sms-gateway/app/routers/sms.py new file mode 100644 index 0000000..c88018b --- /dev/null +++ b/sms-gateway/app/routers/sms.py @@ -0,0 +1,41 @@ +from __future__ import annotations +import logging +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from app.deps import get_sms_service +from app.service import RateLimitExceeded, SmsService + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/sms", tags=["SMS"]) + +class SendCodeRequest(BaseModel): + phone_number: str + +class SendCodeResponse(BaseModel): + success: bool + provider: str + phone_number: str + code: str | None = None + error: str | None = None + +@router.post("/send", response_model=SendCodeResponse) +async def send_code( + request: SendCodeRequest, + service: SmsService = Depends(get_sms_service), +) -> SendCodeResponse: + try: + result = await service.send_code(request.phone_number) + except RateLimitExceeded as e: + raise HTTPException( + status_code=429, + detail={"error": "Слишком много запросов для этого номера", "retry_after": e.retry_after}, + headers={"Retry-After": str(e.retry_after)}, + ) + if not result.success: + raise HTTPException(status_code=502, detail=result.error or "Ошибка отправки SMS") + return SendCodeResponse( + success=True, + provider=result.provider, + phone_number=request.phone_number, + code=result.code, + ) \ No newline at end of file diff --git a/sms-gateway/app/service.py b/sms-gateway/app/service.py new file mode 100644 index 0000000..ccfadd5 --- /dev/null +++ b/sms-gateway/app/service.py @@ -0,0 +1,90 @@ +from __future__ import annotations +import logging +import redis.asyncio as aioredis +from app.config import Config +from app.providers import SendResult +from app.providers.base import BaseProvider +logger = logging.getLogger(__name__) +RATE_KEY = "sms:rate:{phone}" +CODE_KEY = "sms:code:{phone}" + +class RateLimitExceeded(Exception): + def __init__(self, retry_after: int) -> None: + self.retry_after = retry_after + super().__init__(f"Rate limit exceeded, retry after {retry_after}s") + +class SmsService: + def __init__(self, config: Config, providers: dict[str, BaseProvider], redis: aioredis.Redis) -> None: + self.config = config + self.providers = providers + self.redis = redis + + async def send_code(self, phone_number: str, code: str | None = None) -> SendResult: + normalized = phone_number if phone_number.startswith("+") else f"+{phone_number}" + await self._check_rate_limit(normalized) + primary_name, fallback_name = self.config.resolve_provider(normalized) + result = await self._try_send(primary_name, normalized, code=code) + if not result.success and fallback_name: + logger.warning( + "Провайдер %s недоступен для %s, пробуем fallback: %s", + primary_name, normalized, fallback_name, + ) + result = await self._try_send(fallback_name, normalized, code=code) + if result.success and result.code: + ttl = self.config.settings.code_ttl_seconds + key = CODE_KEY.format(phone=normalized) + await self.redis.set(key, result.code, ex=ttl) + if self.config.settings.log_codes: + logger.info("Код сохранён: phone=%s code=%s provider=%s", normalized, result.code, result.provider) + return result + + async def _check_rate_limit(self, phone: str) -> None: + rl = self.config.settings.rate_limit + if not rl.enabled: + return + key = RATE_KEY.format(phone=phone) + pipe = self.redis.pipeline() + pipe.incr(key) + pipe.ttl(key) + count, ttl = await pipe.execute() + if count == 1: + await self.redis.expire(key, rl.window_seconds) + ttl = rl.window_seconds + if count > rl.max_attempts: + retry_after = ttl if ttl > 0 else rl.window_seconds + logger.warning("Rate limit для %s: попытка %d/%d, retry_after=%ds", phone, count, rl.max_attempts, retry_after) + raise RateLimitExceeded(retry_after=retry_after) + + async def _try_send(self, provider_name: str, phone: str, code: str | None = None) -> SendResult: + provider = self.providers.get(provider_name) + if provider is None: + logger.error("Провайдер не найден: %s", provider_name) + return SendResult(success=False, provider=provider_name, error=f"Provider '{provider_name}' not found") + return await provider.send(phone, code=code) + + async def get_pending_code(self, phone_number: str) -> str | None: + normalized = phone_number if phone_number.startswith("+") else f"+{phone_number}" + key = CODE_KEY.format(phone=normalized) + return await self.redis.get(key) + + async def consume_code(self, phone_number: str) -> str | None: + normalized = phone_number if phone_number.startswith("+") else f"+{phone_number}" + key = CODE_KEY.format(phone=normalized) + pipe = self.redis.pipeline() + pipe.get(key) + pipe.delete(key) + code, _ = await pipe.execute() + return code + + async def list_pending_codes(self) -> list[dict]: + pattern = CODE_KEY.format(phone="*") + result = [] + async for key in self.redis.scan_iter(pattern): + pipe = self.redis.pipeline() + pipe.get(key) + pipe.ttl(key) + code, ttl = await pipe.execute() + if code: + phone = key.replace("sms:code:", "") + result.append({"phone": phone, "code": code, "expires_in": max(ttl, 0)}) + return result \ No newline at end of file diff --git a/sms-gateway/config.yaml b/sms-gateway/config.yaml new file mode 100644 index 0000000..851953c --- /dev/null +++ b/sms-gateway/config.yaml @@ -0,0 +1,34 @@ +providers: + sms_api: + type: sms_api + enabled: false + base_url: "http://localhost:8000" + send_endpoint: "/auth/code" + timeout: 10 + + lk_api: + type: lk_api + enabled: true + +routing: + rules: + - name: "Russia" + prefixes: ["+7"] + provider: "sms_api" + fallback: "lk_api" + + default_provider: "lk_api" + default_fallback: null + +settings: + log_codes: true + code_ttl_seconds: 300 + rate_limit: + enabled: true + max_attempts: 3 + window_seconds: 600 + +redis: + host: "redis" + port: 6379 + db: 0 \ No newline at end of file diff --git a/sms-gateway/docker-compose.yml b/sms-gateway/docker-compose.yml new file mode 100644 index 0000000..78492fa --- /dev/null +++ b/sms-gateway/docker-compose.yml @@ -0,0 +1,28 @@ +services: + sms-gateway: + build: . + ports: + - "8100:8000" + volumes: + - ./config.yaml:/app/config.yaml:ro + environment: + - CONFIG_PATH=/app/config.yaml + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + + redis: + image: redis:7-alpine + command: redis-server --save 60 1 --loglevel warning + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + restart: unless-stopped + +volumes: + redis_data: \ No newline at end of file diff --git a/sms-gateway/requirements.txt b/sms-gateway/requirements.txt new file mode 100644 index 0000000..e5da733 --- /dev/null +++ b/sms-gateway/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +httpx>=0.27.0 +pydantic>=2.7.0 +pyyaml>=6.0.1 +redis>=5.0.0 \ No newline at end of file diff --git a/src/classes/baseprocessor.py b/src/classes/baseprocessor.py new file mode 100644 index 0000000..c23ce7e --- /dev/null +++ b/src/classes/baseprocessor.py @@ -0,0 +1,53 @@ +import logging +from common.config import ServerConfig +from common.static import Static +from common.tools import Tools +from common.proto_tcp import MobileProto +from common.proto_web import WebProto +from common.opcodes import Opcodes + +class BaseProcessor: + def __init__(self, db_pool=None, clients=None, send_event=None, type="socket"): + if clients is None: + clients = {} + self.config = ServerConfig() + self.static = Static() + self.tools = Tools() + self.opcodes = Opcodes() + self.error_types = self.static.ErrorTypes() + + self.db_pool = db_pool + self.clients = clients + self.event = send_event + self.logger = logging.getLogger(__name__) + + self.type = type + + if type == "socket": + self.proto = MobileProto() + elif type == "web": + self.proto = WebProto() + + async def _send(self, writer, packet): + try: + # Если объектом является вебсокет, то используем функцию send для отправки + if hasattr(writer, 'send'): + await writer.send(packet) + else: # В ином случае отправляем как в обычный сокет + writer.write(packet) + await writer.drain() + except Exception: + pass + + async def _send_error(self, seq, opcode, error_type, writer): + payload = self.static.ERROR_TYPES.get(error_type, { + "localizedMessage": "Неизвестная ошибка", + "error": "unknown.error", + "message": "Unknown error", + "title": "Неизвестная ошибка" + }) + + packet = self.proto.pack_packet( + cmd=self.proto.CMD_ERR, seq=seq, opcode=opcode, payload=payload + ) + await self._send(writer, packet) \ No newline at end of file diff --git a/src/common/config.py b/src/common/config.py index 8b56ba7..dc14fa9 100644 --- a/src/common/config.py +++ b/src/common/config.py @@ -44,4 +44,10 @@ def __init__(self): ### Telegram bot telegram_bot_token = os.getenv("telegram_bot_token") or "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ" telegram_bot_enabled = bool(os.getenv("telegram_bot_enabled")) or True - telegram_whitelist_ids = [x.strip() for x in os.getenv("telegram_whitelist_ids", "").split(",") if x.strip()] \ No newline at end of file + telegram_whitelist_ids = [x.strip() for x in os.getenv("telegram_whitelist_ids", "").split(",") if x.strip()] + + ### origins + origins = [x.strip() for x in os.getenv("origins", "").split(",") if x.strip()] if os.getenv("origins") else None + + ### sms шлюз + sms_gateway_url = os.getenv("sms_gateway_url") or "http://127.0.0.1/sms-gateway" \ No newline at end of file diff --git a/src/common/opcodes.py b/src/common/opcodes.py new file mode 100644 index 0000000..b30c26f --- /dev/null +++ b/src/common/opcodes.py @@ -0,0 +1,156 @@ +class Opcodes: + def __init__(self): + pass + + PING = 1 + DEBUG = 2 + RECONNECT = 3 + LOG = 5 + SESSION_INIT = 6 + PROFILE = 16 + AUTH_REQUEST = 17 + AUTH = 18 + LOGIN = 19 + LOGOUT = 20 + SYNC = 21 + CONFIG = 22 + AUTH_CONFIRM = 23 + AUTH_CREATE_TRACK = 112 + AUTH_CHECK_PASSWORD = 113 + AUTH_LOGIN_CHECK_PASSWORD = 115 + AUTH_LOGIN_PROFILE_DELETE = 116 + AUTH_LOGIN_RESTORE_PASSWORD = 101 + AUTH_VALIDATE_PASSWORD = 107 + AUTH_VALIDATE_HINT = 108 + AUTH_VERIFY_EMAIL = 109 + AUTH_CHECK_EMAIL = 110 + AUTH_SET_2FA = 111 + AUTH_2FA_DETAILS = 104 + ASSETS_GET = 26 + ASSETS_UPDATE = 27 + ASSETS_GET_BY_IDS = 28 + ASSETS_LIST_MODIFY = 261 + ASSETS_REMOVE = 259 + ASSETS_MOVE = 260 + ASSETS_ADD = 29 + PRESET_AVATARS = 25 + CONTACT_INFO = 32 + CONTACT_INFO_BY_PHONE = 46 + CONTACT_ADD = 33 + CONTACT_UPDATE = 34 + CONTACT_PRESENCE = 35 + CONTACT_LIST = 36 + CONTACT_SEARCH = 37 + CONTACT_MUTUAL = 38 + CONTACT_PHOTOS = 39 + CONTACT_SORT = 40 + CONTACT_VERIFY = 42 + REMOVE_CONTACT_PHOTO = 43 + CHAT_INFO = 48 + CHAT_HISTORY = 49 + CHAT_MARK = 50 + CHAT_MEDIA = 51 + CHAT_DELETE = 52 + CHATS_LIST = 53 + CHAT_CLEAR = 54 + CHAT_UPDATE = 55 + CHAT_CHECK_LINK = 56 + CHAT_JOIN = 57 + CHAT_LEAVE = 58 + CHAT_MEMBERS = 59 + PUBLIC_SEARCH = 60 + CHAT_PERSONAL_CONFIG = 61 + CHAT_CREATE = 63 + REACTIONS_SETTINGS_GET_BY_CHAT_ID = 258 + CHAT_REACTIONS_SETTINGS_SET = 257 + MSG_SEND = 64 + MSG_TYPING = 65 + MSG_DELETE = 66 + MSG_EDIT = 67 + MSG_DELETE_RANGE = 92 + MSG_REACTION = 178 + MSG_CANCEL_REACTION = 179 + MSG_GET_REACTIONS = 180 + MSG_GET_DETAILED_REACTIONS = 181 + CHAT_SEARCH = 68 + MSG_SHARE_PREVIEW = 70 + MSG_GET = 71 + MSG_SEARCH_TOUCH = 72 + MSG_SEARCH = 73 + MSG_GET_STAT = 74 + CHAT_SUBSCRIBE = 75 + VIDEO_CHAT_START = 76 + VIDEO_CHAT_START_ACTIVE = 78 + CHAT_MEMBERS_UPDATE = 77 + VIDEO_CHAT_HISTORY = 79 + PHOTO_UPLOAD = 80 + STICKER_UPLOAD = 81 + VIDEO_UPLOAD = 82 + VIDEO_PLAY = 83 + VIDEO_CHAT_CREATE_JOIN_LINK = 84 + CHAT_PIN_SET_VISIBILITY = 86 + FILE_UPLOAD = 87 + FILE_DOWNLOAD = 88 + LINK_INFO = 89 + SESSIONS_INFO = 96 + SESSIONS_CLOSE = 97 + PHONE_BIND_REQUEST = 98 + PHONE_BIND_CONFIRM = 99 + GET_INBOUND_CALLS = 103 + EXTERNAL_CALLBACK = 105 + OK_TOKEN = 158 + CHAT_COMPLAIN = 117 + MSG_SEND_CALLBACK = 118 + SUSPEND_BOT = 119 + LOCATION_STOP = 124 + GET_LAST_MENTIONS = 127 + STICKER_CREATE = 193 + STICKER_SUGGEST = 194 + VIDEO_CHAT_MEMBERS = 195 + NOTIF_MESSAGE = 128 + NOTIF_TYPING = 129 + NOTIF_MARK = 130 + NOTIF_CONTACT = 131 + NOTIF_PRESENCE = 132 + NOTIF_CONFIG = 134 + NOTIF_CHAT = 135 + NOTIF_ATTACH = 136 + NOTIF_CALL_START = 137 + NOTIF_CONTACT_SORT = 139 + NOTIF_MSG_DELETE_RANGE = 140 + NOTIF_MSG_DELETE = 142 + NOTIF_MSG_REACTIONS_CHANGED = 155 + NOTIF_MSG_YOU_REACTED = 156 + NOTIF_CALLBACK_ANSWER = 143 + CHAT_BOT_COMMANDS = 144 + BOT_INFO = 145 + NOTIF_LOCATION = 147 + NOTIF_LOCATION_REQUEST = 148 + NOTIF_ASSETS_UPDATE = 150 + NOTIF_DRAFT = 152 + NOTIF_DRAFT_DISCARD = 153 + DRAFT_SAVE = 176 + DRAFT_DISCARD = 177 + CHAT_HIDE = 196 + CHAT_SEARCH_COMMON_PARTICIPANTS = 198 + NOTIF_MSG_DELAYED = 154 + NOTIF_PROFILE = 159 + PROFILE_DELETE = 199 + PROFILE_DELETE_TIME = 200 + WEB_APP_INIT_DATA = 160 + COMPLAIN = 161 + COMPLAIN_REASONS_GET = 162 + FOLDERS_GET = 272 + FOLDERS_GET_BY_ID = 273 + FOLDERS_UPDATE = 274 + FOLDERS_REORDER = 275 + FOLDERS_DELETE = 276 + NOTIF_FOLDERS = 277 + + AUTH_QR_APPROVE = 290 + NOTIF_BANNERS = 292 + CHAT_SUGGEST = 300 + AUDIO_PLAY = 301 + SEND_VOTE = 304 + VOTERS_LIST_BY_ANSWER = 305 + GET_POLL_UPDATES = 306 \ No newline at end of file diff --git a/src/common/proto_tcp.py b/src/common/proto_tcp.py new file mode 100644 index 0000000..646dffb --- /dev/null +++ b/src/common/proto_tcp.py @@ -0,0 +1,147 @@ +import logging + +import lz4.block +import msgpack + + +class MobileProto: + def __init__(self) -> None: + self.logger = logging.getLogger(__name__) + + # TODO узнать какие должны быть лимиты и поменять, + # сейчас это больше заглушка + MAX_PAYLOAD_SIZE = 1048576 # 1 MB + MAX_DECOMPRESSED_SIZE = 1048576 # 1 MB + HEADER_SIZE = 10 # 1+2+1+2+4 + + ### Работа с протоколом + def unpack_packet(self, data: bytes) -> dict | None: + # Проверяем минимальный размер пакета + if len(data) < self.HEADER_SIZE: + self.logger.warning(f"Пакет слишком маленький: {len(data)} байт") + return None + + # Распаковываем заголовок + ver = int.from_bytes(data[0:1], "big", signed=False) + cmd = int.from_bytes(data[1:2], "big", signed=False) + seq = int.from_bytes(data[2:4], "big", signed=False) + opcode = int.from_bytes(data[4:6], "big", signed=False) + packed_len = int.from_bytes(data[6:10], "big", signed=False) + + # Флаг упаковки + comp_flag = packed_len >> 24 + + # Парсим данные пакета + payload_length = packed_len & 0xFFFFFF + + # Проверяем размер payload + if payload_length > self.MAX_PAYLOAD_SIZE: + self.logger.warning( + f"Payload слишком большой: {payload_length} B (лимит {self.MAX_PAYLOAD_SIZE})" + ) + return None + + # Проверяем длину пакета + if len(data) < self.HEADER_SIZE + payload_length: + self.logger.warning( + f"Пакет неполный: требуется {self.HEADER_SIZE + payload_length} B, получено {len(data)}" + ) + return None + + payload_bytes = data[10 : 10 + payload_length] + payload = None + + # Декодируем данные пакета + if payload_bytes: + # Разжимаем данные пакета, если требуется + if comp_flag != 0: + compressed_data = payload_bytes + try: + payload_bytes = lz4.block.decompress( + compressed_data, + uncompressed_size=self.MAX_DECOMPRESSED_SIZE, + ) + except lz4.block.LZ4BlockError: + self.logger.warning("Ошибка декомпрессии LZ4") + return None + + # Распаковываем msgpack + payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False) + + self.logger.debug( + f"Распаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload} comp_flag={comp_flag}" + ) + + # Возвращаем + return { + "ver": ver, + "cmd": cmd, + "seq": seq, + "opcode": opcode, + "payload": payload, + } + + def pack_packet( + self, + ver: int = 10, + cmd: int = 1, + seq: int = 1, + opcode: int = 6, + payload: dict = {}, + ) -> bytes: + # Запаковываем заголовок + ver_b = ver.to_bytes(1, "big") + cmd_b = cmd.to_bytes(1, "big") + seq_b = seq.to_bytes(2, "big") + opcode_b = opcode.to_bytes(2, "big") + + # Запаковываем данные пакета + payload_bytes: bytes | None = msgpack.packb(payload) + if payload_bytes is None: + payload_bytes = b"" + + # Флаг сжатия + comp_flag = 0 + + # Пробуем сжать данные пакета + try: + payload_comp = lz4.block.compress( + payload_bytes, + mode='high_compression', + store_size=False, + ) + + # Если сжатие нам выгодно, то используем его + if len(payload_bytes) > len(payload_comp): + final_payload = payload_comp + + # Официальный сервер MAX отправлял мне в качестве + # флага сжатия 2, поэтому думаю стоит использовать ее + comp_flag = 2 + else: + # В случае если сжатие нам не выгодно, используем + # только запакованные данные через msgpack + final_payload = payload_bytes + except Exception as e: + self.logger.warning(f"Ошибка сжатия LZ4: {e}") + + # В случае ошибки сжатия используем + # только запакованные данные через msgpack + final_payload = payload_bytes + + payload_len = len(final_payload) & 0xFFFFFF + packed_len = (comp_flag << 24) | payload_len + payload_len_b = packed_len.to_bytes(4, "big") + + self.logger.debug( + f"Упаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload} comp_flag={comp_flag}" + ) + + # Возвращаем пакет + return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + final_payload + + ### Констаты протокола + CMD_OK = 1 + CMD_NOF = 2 + CMD_ERR = 3 + PROTO_VER = 10 diff --git a/src/common/proto_web.py b/src/common/proto_web.py new file mode 100644 index 0000000..8cd166b --- /dev/null +++ b/src/common/proto_web.py @@ -0,0 +1,48 @@ +import json + +class WebProto: + def pack_packet(self, ver=10, cmd=1, seq=0, opcode=1, payload=None): + # а разве не надо в жсон запаковывать ещё + # о всё + return json.dumps({ + "ver": ver, + "cmd": cmd, + "seq": seq, + "opcode": opcode, + "payload": payload + }) + + MAX_PACKET_SIZE = 65536 # 64 KB, заглушка, нужно узнать реальные лимиты и поменять, хотя кто будет это делать... + + def unpack_packet(self, packet): + # try catch чтобы не сыпалось всё при неверных пакетах + if isinstance(packet, (str, bytes)) and len(packet) > self.MAX_PACKET_SIZE: + return {} + + try: + parsed_packet = json.loads(packet) + except (json.JSONDecodeError, TypeError, ValueError): + return {} + + return parsed_packet + # мне кажется долго вручную всё писать + # а как еще + # ну вставить сюда целиком и потом через multiline cursor удалить лишнее + # ну ты удалишь тогда. я на тачпаде + # ладно щас другим способом удалю + # всё нахуй + # TAMTAM SOURCE LEAK 2026 + # так ну че делать будем + # так ну + + # 19 опкод сделан? + # нет сэр пошли библиотеку тамы смотреть + # мб найдем че. она без обфускации + # а ты ее видишь? + # пошли + + ### Констаты протокола + CMD_OK = 1 + CMD_NOF = 2 + CMD_ERR = 3 + PROTO_VER = 10 diff --git a/src/common/rate_limiter.py b/src/common/rate_limiter.py index 0cfe4fc..19b7511 100644 --- a/src/common/rate_limiter.py +++ b/src/common/rate_limiter.py @@ -1,10 +1,12 @@ -import time, logging +import logging +import time class RateLimiter: """ ip rate limiter using sliding window algorithm """ + def __init__(self, max_attempts=5, window_seconds=60): self.max_attempts = max_attempts self.window_seconds = window_seconds @@ -21,7 +23,9 @@ def is_allowed(self, ip: str) -> bool: self.attempts[ip] = [t for t in self.attempts[ip] if t > cutoff] if len(self.attempts[ip]) >= self.max_attempts: - self.logger.warning(f"request limit exceeded for {ip}: {len(self.attempts[ip])}/{self.max_attempts}") + self.logger.warning( + f"request limit exceeded for {ip}: {len(self.attempts[ip])}/{self.max_attempts}" + ) return False self.attempts[ip].append(now) diff --git a/src/common/sms.py b/src/common/sms.py new file mode 100644 index 0000000..64f29e9 --- /dev/null +++ b/src/common/sms.py @@ -0,0 +1,34 @@ +import aiohttp +import ssl +import logging + +logger = logging.getLogger(__name__) + +async def send_sms_code(gateway_url: str, phone: str) -> str | None: + url = f"{gateway_url}/sms/send" + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + connector = aiohttp.TCPConnector(ssl=ssl_context) + async with aiohttp.ClientSession(connector=connector) as session: + try: + async with session.post(url, json={"phone_number": phone}) as resp: + data = await resp.json() + except Exception as e: + logger.error(f"Ошибка подключения к SMS шлюзу: {e}") + return None + if not data.get("success"): + logger.error(f"SMS шлюз вернул ошибку: {data.get('error')}") + return None + code = data.get("code") + if not code: + logger.error("SMS шлюз не вернул код") + return None + code = str(code) + # Если шлюз вернул 5-значный код — повторяем последнюю цифру. + # Пример: 26541 -> 265411, 26542 -> 265422 + # Пользователь получает SMS с 5 цифрами и дописывает последнюю (такую же). + if len(code) == 5: + code = code + code[-1] + logger.debug(f"Код дополнен до 6 цифр: {code}") + return code \ No newline at end of file diff --git a/src/common/sql_queries.py b/src/common/sql_queries.py index 4847261..478ddeb 100644 --- a/src/common/sql_queries.py +++ b/src/common/sql_queries.py @@ -6,13 +6,13 @@ def __init__(self): INSERT_USER = """ INSERT INTO users - (phone, telegram_id, firstname, lastname, username, + (id, phone, telegram_id, firstname, lastname, username, profileoptions, options, accountstatus, updatetime, lastseen) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """ INSERT_USER_DATA = """ INSERT INTO user_data - (phone, chats, contacts, folders, user_config, chat_config) - VALUES (%s, %s, %s, %s, %s, %s) + (phone, contacts, folders, user_config, chat_config) + VALUES (%s, %s, %s, %s, %s) """ \ No newline at end of file diff --git a/src/common/static.py b/src/common/static.py index 472c255..d10b6c6 100644 --- a/src/common/static.py +++ b/src/common/static.py @@ -176,3 +176,23 @@ class BotMessageTypes: "M_CALL_PUSH_NOTIFICATION": "ON", "QUICK_REPLY": False } + + ### Коды стран, которым разрешён вход + REG_COUNTRY_CODES = ['AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', + 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', + 'BO', 'BR', 'BS', 'BT', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', + 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', + 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', + 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', + 'GQ', 'GR', 'GT', 'GU', 'GW', 'GY', 'HK', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', + 'IM', 'IS', 'IN', 'IO', 'IQ', 'IR', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', + 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', + 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', + 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', + 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', + 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', + 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SK', + 'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TG', + 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', + 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'XK', 'YE', + 'YT', 'ZA', 'ZM', 'ZW'] \ No newline at end of file diff --git a/src/common/tools.py b/src/common/tools.py index 70843c4..cb63c28 100644 --- a/src/common/tools.py +++ b/src/common/tools.py @@ -1,15 +1,28 @@ -import json, time +import hashlib +import json +import random +import time + class Tools: def __init__(self): pass def generate_profile( - self, id=1, phone=70000000000, avatarUrl=None, - photoId=None, updateTime=0, - firstName="Test", lastName="Account", options=[], - description=None, accountStatus=0, profileOptions=[], - includeProfileOptions=True, username=None + self, + id=1, + phone=70000000000, + avatarUrl=None, + photoId=None, + updateTime=0, + firstName="Test", + lastName="Account", + options=[], + description=None, + accountStatus=0, + profileOptions=[], + includeProfileOptions=True, + username=None, ): contact = { "id": id, @@ -20,14 +33,13 @@ def generate_profile( "name": firstName, "firstName": firstName, "lastName": lastName, - "type": "ONEME" + "type": "ONEME", } ], "options": options, - "accountStatus": accountStatus + "accountStatus": accountStatus, } - if avatarUrl: contact["photoId"] = photoId contact["baseUrl"] = avatarUrl @@ -39,20 +51,55 @@ def generate_profile( if username: contact["link"] = "https://max.ru/" + username - if includeProfileOptions == True: - return { - "contact": contact, - "profileOptions": profileOptions - } + if includeProfileOptions: + return {"contact": contact, "profileOptions": profileOptions} else: return contact - - def generate_chat(self, id, owner, type, participants, lastMessage, lastEventTime): + + def generate_profile_tt( + self, + id=1, + phone=70000000000, + avatarUrl=None, + photoId=None, + updateTime=0, + firstName="Test", + lastName="Account", + options=[], + description=None, + username=None, + ): + contact = { + "id": id, + "updateTime": updateTime, + "phone": phone, + "names": [{"name": f"{firstName} {lastName}", "type": "TT"}], + "options": options, + } + + if avatarUrl: + contact["photoId"] = photoId + contact["baseUrl"] = avatarUrl + contact["baseRawUrl"] = avatarUrl + + if description: + contact["description"] = description + + if username: + contact["link"] = "https://tamtam.chat/" + username + + return contact + + def generate_chat( + self, id, owner, type, participants, lastMessage, lastEventTime, prevMessageId=0 + ): """Генерация чата""" # Генерируем список участников - result_participants = { - str(participant): 0 for participant in participants - } + if isinstance(participants, dict): + result_participants = {str(k): v for k, v in participants.items()} + else: + # assume list + result_participants = {str(participant): 0 for participant in participants} result = None @@ -69,14 +116,16 @@ def generate_chat(self, id, owner, type, participants, lastMessage, lastEventTim "lastDelayedUpdateTime": 0, "lastFireDelayedErrorTime": 0, "created": 1, + "cid": id, + "prevMessageId": prevMessageId, "joinTime": 1, - "modified": lastEventTime + "modified": lastEventTime, } # Возвращаем return result - async def generate_chats(self, chatIds, db_pool, senderId): + async def generate_chats(self, chatIds, db_pool, senderId, include_favourites=True, protocol_type='mobile'): """Генерирует чаты для отдачи клиенту""" # Готовый список с чатами chats = [] @@ -86,19 +135,30 @@ async def generate_chats(self, chatIds, db_pool, senderId): async with db_pool.acquire() as db_connection: async with db_connection.cursor() as cursor: # Получаем чат по id - await cursor.execute("SELECT * FROM `chats` WHERE id = %s", (chatId,)) + await cursor.execute( + "SELECT * FROM `chats` WHERE id = %s", (chatId,) + ) row = await cursor.fetchone() if row: # Получаем последнее сообщение из чата message, messageTime = await self.get_last_message( + chatId, db_pool, protocol_type=protocol_type + ) + + # Формируем список участников с временем последней активности + participant_ids = await self.get_chat_participants( chatId, db_pool ) - # Формируем список участников - participants = { - str(participant): 0 for participant in row.get("participants") - } + participants = await self.get_participant_last_activity( + chatId, participant_ids, db_pool + ) + + # Получаем ID предыдущего сообщения + prevMessageId = await self.get_previous_message_id( + chatId, db_pool, protocol_type=protocol_type + ) # Выносим результат в лист chats.append( @@ -108,69 +168,98 @@ async def generate_chats(self, chatIds, db_pool, senderId): row.get("type"), participants, message, - messageTime + messageTime, + prevMessageId, ) ) - # Получаем последнее сообщение из избранного - message, messageTime = await self.get_last_message( - senderId, db_pool - ) - - # ID избранного - chatId = senderId ^ senderId - - # Хардкодим в лист чатов избранное - chats.append( - self.generate_chat( - chatId, - senderId, - "DIALOG", - [senderId], - message, - messageTime + if include_favourites: + # Получаем последнее сообщение из избранного + message, messageTime = await self.get_last_message( + senderId, db_pool, protocol_type=protocol_type + ) + + # ID избранного + chatId = senderId ^ senderId + + # Получаем последнюю активность участника (отправителя) в избранном + participants = await self.get_participant_last_activity( + senderId, [senderId], db_pool + ) + + # Получаем ID предыдущего сообщения для избранного (чат ID = senderId) + prevMessageId = await self.get_previous_message_id(senderId, db_pool, protocol_type=protocol_type) + + # Хардкодим в лист чатов избранное + chats.append( + self.generate_chat( + chatId if protocol_type == 'mobile' else str(chatId), + senderId, + "DIALOG", + participants, + message, + messageTime, + prevMessageId, + ) ) - ) return chats - async def insert_message(self, chatId, senderId, text, attaches, elements, cid, type, db_pool): + async def insert_message( + self, chatId, senderId, text, attaches, elements, cid, type, db_pool + ): """Добавление сообщения в историю""" async with db_pool.acquire() as db_connection: async with db_connection.cursor() as cursor: # Получаем id последнего сообщения в чате - await cursor.execute("SELECT id FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1", (chatId,)) + await cursor.execute( + "SELECT id FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1", + (chatId,), + ) row = await cursor.fetchone() or {} - last_message_id = row.get("id") or 0 # последнее id сообщения в чате + last_message_id = row.get("id") or 0 # последнее id сообщения в чате + message_id = self.generate_id() + message_time = int(time.time() * 1000) # время отправки сообщения # Вносим новое сообщение в таблицу await cursor.execute( - "INSERT INTO `messages` (chat_id, sender, time, text, attaches, cid, elements, type) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", - (chatId, senderId, int(time.time() * 1000), text, json.dumps(attaches), cid, json.dumps(elements), type) + "INSERT INTO `messages` (id, chat_id, sender, time, text, attaches, cid, elements, type) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)", + ( + message_id, + chatId, + senderId, + message_time, + text, + json.dumps(attaches), + cid, + json.dumps(elements), + type, + ), ) - message_id = cursor.lastrowid # id сообщения - # Возвращаем айдишки - return int(message_id), int(last_message_id) + return int(message_id), int(last_message_id), message_time - async def get_last_message(self, chatId, db_pool): + async def get_last_message(self, chatId, db_pool, protocol_type='mobile'): """Получение последнего сообщения в чате""" async with db_pool.acquire() as db_connection: async with db_connection.cursor() as cursor: # Получаем id последнего сообщения в чате - await cursor.execute("SELECT * FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1", (chatId,)) + await cursor.execute( + "SELECT * FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1", + (chatId,), + ) row = await cursor.fetchone() - + # Если нет результатов - возвращаем None if not row: return None, None # Собираем сообщение message = { - "id": row.get("id"), + "id": row.get("id") if protocol_type == 'mobile' else str(row.get('id')), "time": int(row.get("time")), "type": row.get("type"), "sender": row.get("sender"), @@ -178,8 +267,85 @@ async def get_last_message(self, chatId, db_pool): "text": row.get("text"), "attaches": json.loads(row.get("attaches")), "elements": json.loads(row.get("elements")), - "reactionInfo": {} + "reactionInfo": {}, } # Возвращаем - return message, int(row.get("time")) \ No newline at end of file + return message, int(row.get("time")) + + async def get_previous_message_id(self, chatId, db_pool, protocol_type='mobile'): + """Получение ID предыдущего сообщения (второго с конца) в чате.""" + async with db_pool.acquire() as db_connection: + async with db_connection.cursor() as cursor: + await cursor.execute( + "SELECT id FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1 OFFSET 1", + (chatId,), + ) + row = await cursor.fetchone() + + # Если результат есть, возвращаем его + if row: + return row.get("id") if protocol_type == 'mobile' else str(row.get('id')) + + # В ином случае возвращаем 0 + return 0 if protocol_type == 'mobile' else "0" + + async def get_participant_last_activity(self, chatId, participant_ids, db_pool): + """Возвращает словарь {participant_id: last_activity_time} для участников чата.""" + if not participant_ids: + return {} + + async with db_pool.acquire() as db_connection: + async with db_connection.cursor() as cursor: + # Собираем всех участников + placeholders = ",".join(["%s"] * len(participant_ids)) + query = f""" + SELECT sender, MAX(time) as last_time + FROM messages + WHERE chat_id = %s AND sender IN ({placeholders}) + GROUP BY sender + """ + params = (chatId,) + tuple(participant_ids) + await cursor.execute(query, params) + rows = await cursor.fetchall() + + # Собираем список участников без времени последней активности в чате + result = {str(pid): 0 for pid in participant_ids} + + # Обновляем для каждого участника время последней активности в чате + for row in rows: + sender = str(row["sender"]) + last_time = row["last_time"] + if last_time is not None: + result[sender] = int(last_time) + + return result + + async def get_chat_participants(self, chatId, db_pool): + """Возвращает список ID участников чата из таблицы chat_participants.""" + async with db_pool.acquire() as db_connection: + async with db_connection.cursor() as cursor: + await cursor.execute( + "SELECT user_id FROM chat_participants WHERE chat_id = %s", + (chatId,), + ) + rows = await cursor.fetchall() + return [row["user_id"] for row in rows] + + async def auth_required(self, userPhone, coro, *args): + if userPhone: + await coro(*args) + + def generate_id(self): + # Получаем время в юниксе + timestamp = int(time.time()) + + # Генерируем дополнительно рандомное число + random_number = random.randint(0, 9999) + + # Собираем их вместе и вычисляем хеш + combined = f"{timestamp}{random_number}".encode() + unique_id = int(hashlib.md5(combined).hexdigest(), 16) % 1000000000 + + # Возвращаем + return unique_id diff --git a/src/main.py b/src/main.py index e9214e1..68c72c6 100644 --- a/src/main.py +++ b/src/main.py @@ -1,13 +1,17 @@ # Импортирование библиотек -import ssl, logging, asyncio +import asyncio +import logging +import ssl + from common.config import ServerConfig -from oneme_tcp.controller import OnemeMobileController +from oneme.controller import OnemeController +from tamtam.controller import TTController from telegrambot.controller import TelegramBotController -from tamtam_tcp.controller import TTMobileController -from tamtam_ws.controller import TTWSController + # Конфиг сервера server_config = ServerConfig() + async def init_db(): """Инициализация базы данных""" @@ -15,6 +19,7 @@ async def init_db(): if server_config.db_type == "mysql": import aiomysql + db = await aiomysql.create_pool( host=server_config.db_host, port=server_config.db_port, @@ -22,16 +27,18 @@ async def init_db(): password=server_config.db_password, db=server_config.db_name, cursorclass=aiomysql.DictCursor, - autocommit=True + autocommit=True, ) elif server_config.db_type == "sqlite": import aiosqlite + raw_db = await aiosqlite.connect(server_config.db_file) - db["acquire"] = raw_db + db["acquire"] = lambda: raw_db # Возвращаем return db + def init_ssl(): """Создание контекста SSL""" # Создаем контекст SSL @@ -41,11 +48,12 @@ def init_ssl(): # Возвращаем return ssl_context + def set_logging(): """Настройка уровня логирования""" # Настройка уровня логирования log_level = server_config.log_level - + if log_level == "debug": logging.basicConfig(level=logging.DEBUG) elif log_level == "info": @@ -53,12 +61,14 @@ def set_logging(): else: logging.basicConfig(level=None) + async def main(): """Запуск сервера""" + async def api_event(target, eventData): - for client in api.get("clients").get(target, {}).get("clients", {}): + for client in api.get("clients", {}).get(target, {}).get("clients", {}): await controllers[client["protocol"]].event(target, client, eventData) - + set_logging() db = await init_db() ssl_context = init_ssl() @@ -68,25 +78,23 @@ async def api_event(target, eventData): "db": db, "ssl": ssl_context, "clients": clients, - "event": api_event + "event": api_event, + "origins": server_config.origins, } controllers = { - "oneme_mobile": OnemeMobileController(), - "tamtam_mobile": TTMobileController(), - "tamtam_ws": TTWSController(), - "telegrambot": TelegramBotController() + "oneme": OnemeController(), + "tamtam": TTController(), + "telegrambot": TelegramBotController(), } api["telegram_bot"] = controllers["telegrambot"] - tasks = [ - controller.launch(api) - for controller in controllers.values() - ] + tasks = [controller.launch(api) for controller in controllers.values()] # Запускаем контроллеры await asyncio.gather(*tasks) - + + if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/src/tamtam_tcp/__init__.py b/src/oneme/__init__.py similarity index 100% rename from src/tamtam_tcp/__init__.py rename to src/oneme/__init__.py diff --git a/src/oneme/config.py b/src/oneme/config.py new file mode 100644 index 0000000..e3073a2 --- /dev/null +++ b/src/oneme/config.py @@ -0,0 +1,122 @@ +class OnemeConfig: + def __init__(self): + pass + + SERVER_CONFIG = { + "async-tracer": 0, + "presence-ttl": 300, + "non-contact-sync-time": 86400, + "contact-batching-variant": 0, + "account-nickname-enabled": True, + "web-ad-banner": { + "enabled": False + }, + "edit-timeout": 0, + "reactions-menu": [], + "invite-long": "", + "calls-endpoint": "", + "calls-test-domain": "", + "max-readmarks": 100, + "max-cname-length": 200, + "max-description-length": 400, + "new-avatar-gradient-colors-enabled": True, + "max-msg-length": 4000, + "file-upload-unsupported-types": [], + "file-upload-max-size": 4294967296, + "image-quality": 0.8, + "image-width": 1920, + "image-height": 1920, + "image-size": 10000000, + "max-favorite-chats": 5, + "bot-complaint-enabled": True, + "reactions-max": 8, + "welcome-sticker-ids": [], + "edit-chat-type-screen-enabled": True, + "edit-channel-type-screen-enabled": True, + "esia-verify-botId": 0, + "official-org": False, + "esia-enabled": False, + "calls-debug-mode": False, + "channels-suggests-folder": True, + "delete-msg-fys-large-chat-disabled": False, + "calls-web-download-logs": False, + "calls-web-upload-logs": False, + "calls-video-zoom": False, + "calls-fullscreen-mode": False, + "group-call-part-limit": 100, + "call-chat-members-load-config": {}, + "cfs": False, + "cse": False, + "calls-hotkeys": True, + "gc-link-pre-settings": False, + "gc-from-p2p": False, + "call-rate": {}, + "channels-enabled": True, + "max-participants": 20000, + "max-added-participants": 100, + "saved-messages-aliases": [], + "author-visibility-forward-enabled": False, + "official-bot-naming-enabled": False, + "search-webapps-showcase": { + "items": [] + }, + "settings-entry-banners": [], + "settings-business": "https://telegram.org/blog/telegram-business", + "appearance-multi-theme-screen-enabled": True, + "moscow-theme-enabled": True, + "creation-2fa-config": { + "enabled": False, + "pass_min_len": 6, + "pass_max_len": 64, + "hint_max_len": 30 + }, + "lebedev-theme-enabled": True, + "quotes-enabled": True, + "channels-complaint-enabled": True, + "reactions-settings-enabled": True, + "channel-statistics-botid": 0, + "enable-unknown-contact-bottom-sheet": 0, + "informer-enabled": True, + "family-protection-botid": 0, + "new-year-theme-2026": True, + "scheduled-messages-enabled": True, + "scheduled-posts-enabled": True, + "scheduled-faves-enabled": True, + "non-contact-complaints-enabled": True, + "join-requests": True, + "web-persistent-cache": False, + "create-channel-type-screen": True, + "show-warning-links": True, + "white-list-links": [], + "february-23-26-theme": True, + "march-8-26-theme": True, + "audio-play-cmd": False, + "audio-play-opus": False, + "bots-channel-adding": True, + "stickers-botid": 0, + "sticker-set-edit-enabled": True, + "calls-new-history-enabled": True, + "y-map": { + "tile": "", + "geocoder": "", + "static": "" + }, + "enable-audio-messages-transcription": True, + "enable-video-messages-transcription": True, + "retry-transcribe-attempt": 5, + "retry-transcribe-timeout": 2000, + "org-profile": False, + "media-not-ready-retry-delay": 2000, + "polls-in-chats": True, + "polls-in-channels": True, + "render-polls": True, + "poll-ttl": { + "chat": 5000, + "bigchat": 15000, + "channel": 25000 + }, + "new-collage": False, + "channel-profile-invite-link": False, + "rename-profile-to-settings": True, + "live-streams": True + } \ No newline at end of file diff --git a/src/oneme_tcp/controller.py b/src/oneme/controller.py similarity index 60% rename from src/oneme_tcp/controller.py rename to src/oneme/controller.py index 1663bc2..4c6f374 100644 --- a/src/oneme_tcp/controller.py +++ b/src/oneme/controller.py @@ -1,13 +1,17 @@ import asyncio -from oneme_tcp.server import OnemeMobileServer -from oneme_tcp.proto import Proto +from oneme.socket import OnemeMobile +from oneme.websocket import OnemeWS +from common.proto_tcp import MobileProto +from common.proto_web import WebProto from classes.controllerbase import ControllerBase from common.config import ServerConfig +from common.opcodes import Opcodes -class OnemeMobileController(ControllerBase): +class OnemeController(ControllerBase): def __init__(self): self.config = ServerConfig() - self.proto = Proto() + self.proto = MobileProto() + self.opcodes = Opcodes() async def event(self, target, client, eventData): # Извлекаем тип события и врайтер @@ -34,7 +38,7 @@ async def event(self, target, client, eventData): # Создаем пакет packet = self.proto.pack_packet( - cmd=0, seq=1, opcode=self.proto.NOTIF_MESSAGE, payload=payload + cmd=0, seq=1, opcode=self.opcodes.NOTIF_MESSAGE, payload=payload ) elif eventType == "typing": # Данные события @@ -51,17 +55,31 @@ async def event(self, target, client, eventData): # Создаем пакет packet = self.proto.pack_packet( - cmd=0, seq=1, opcode=self.proto.NOTIF_TYPING, payload=payload + cmd=0, seq=1, opcode=self.opcodes.NOTIF_TYPING, payload=payload + ) + elif eventType == "profile_updated": + # Данные события + profile = eventData.get("profile") + + # Данные пакета + payload = { + "profile": profile + } + + # Создаем пакет + packet = self.proto.pack_packet( + cmd=0, seq=1, opcode=self.opcodes.NOTIF_PROFILE, payload=payload ) # Отправляем пакет - writer.write(packet) - await writer.drain() + if writer != eventData.get("writer"): + writer.write(packet) + await writer.drain() def launch(self, api): async def _start_all(): await asyncio.gather( - OnemeMobileServer( + OnemeMobile( host=self.config.host, port=self.config.oneme_tcp_port, ssl_context=api['ssl'], @@ -69,6 +87,14 @@ async def _start_all(): clients=api['clients'], send_event=api['event'], telegram_bot=api.get('telegram_bot'), + ).start(), + OnemeWS( + host=self.config.host, + port=self.config.oneme_ws_port, + clients=api['clients'], + ssl_context=api['ssl'], + db_pool=api['db'], + send_event=api['event'] ).start() ) diff --git a/src/oneme_tcp/models.py b/src/oneme/models.py similarity index 65% rename from src/oneme_tcp/models.py rename to src/oneme/models.py index 03f0cc4..4ea34f8 100644 --- a/src/oneme_tcp/models.py +++ b/src/oneme/models.py @@ -7,15 +7,15 @@ class UserAgentModel(pydantic.BaseModel): timezone: str release: int = None screen: str - pushDeviceType: str + pushDeviceType: str = None arch: str = None locale: str - buildNumber: int + buildNumber: int = None deviceName: str deviceLocale: str class HelloPayloadModel(pydantic.BaseModel): - clientSessionId: int + clientSessionId: int = None mt_instanceid: str = None userAgent: UserAgentModel deviceId: str @@ -48,7 +48,7 @@ class LoginPayloadModel(pydantic.BaseModel): token: str class PingPayloadModel(pydantic.BaseModel): - interactive: bool + interactive: bool = None class AssetsPayloadModel(pydantic.BaseModel): sync: int @@ -59,11 +59,11 @@ class GetCallHistoryPayloadModel(pydantic.BaseModel): count: int class MessageModel(pydantic.BaseModel): - isLive: bool - detectShare: bool - elements: list + isLive: bool = None + detectShare: bool = None + elements: list = None attaches: list = None - cid: int + cid: int = None text: str = None class SendMessagePayloadModel(pydantic.BaseModel): @@ -93,4 +93,41 @@ class SearchUsersPayloadModel(pydantic.BaseModel): contactIds: list class ComplainReasonsGetPayloadModel(pydantic.BaseModel): - complainSync: int \ No newline at end of file + complainSync: int + +class UpdateProfilePayloadModel(pydantic.BaseModel): + description: str = None + firstName: str = None + lastName: str = None + +class AuthConfirmRegisterPayloadModel(pydantic.BaseModel): + token: str + firstName: str + lastName: str = None + tokenType: str + + @pydantic.field_validator('firstName') + def validate_first_name(cls, v): + v = v.strip() + if not v: + raise ValueError('firstName must not be empty') + if len(v) > 59: + raise ValueError('firstName too long') + return v + + @pydantic.field_validator('lastName') + def validate_last_name(cls, v): + if v is None: + return v + v = v.strip() + if len(v) > 59: + raise ValueError('lastName too long') + return v + +class ChatHistoryPayloadModel(pydantic.BaseModel): + chatId: int + backward: int + +class ChatSubscribePayloadModel(pydantic.BaseModel): + chatId: int + subscribe: bool \ No newline at end of file diff --git a/src/oneme/processors/__init__.py b/src/oneme/processors/__init__.py new file mode 100644 index 0000000..87d877a --- /dev/null +++ b/src/oneme/processors/__init__.py @@ -0,0 +1,26 @@ +from .assets import AssetsProcessors +from .auth import AuthProcessors +from .calls import CallsProcessors +from .chats import ChatsProcessors +from .complains import ComplainsProcessors +from .folders import FoldersProcessors +from .history import HistoryProcessors +from .main import MainProcessors +from .messages import MessagesProcessors +from .search import SearchProcessors +from .sessions import SessionsProcessors + +class Processors( + AssetsProcessors, + AuthProcessors, + CallsProcessors, + ChatsProcessors, + ComplainsProcessors, + FoldersProcessors, + HistoryProcessors, + MainProcessors, + MessagesProcessors, + SearchProcessors, + SessionsProcessors +): + pass \ No newline at end of file diff --git a/src/oneme/processors/assets.py b/src/oneme/processors/assets.py new file mode 100644 index 0000000..45e1c15 --- /dev/null +++ b/src/oneme/processors/assets.py @@ -0,0 +1,31 @@ +import pydantic +import time +from classes.baseprocessor import BaseProcessor +from oneme.models import AssetsPayloadModel + +class AssetsProcessors(BaseProcessor): + async def assets_update(self, payload, seq, writer): + """Обработчик запроса ассетов клиента на сервере""" + # Валидируем данные пакета + try: + AssetsPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.ASSETS_UPDATE, self.error_types.INVALID_PAYLOAD, writer) + return + + # TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию + + # Данные пакета + payload = { + "sections": [], + "sync": int(time.time() * 1000) + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_UPDATE, payload=payload + ) + + # Отправляем + await self._send(writer, packet) \ No newline at end of file diff --git a/src/oneme/processors/auth.py b/src/oneme/processors/auth.py new file mode 100644 index 0000000..c2c70bf --- /dev/null +++ b/src/oneme/processors/auth.py @@ -0,0 +1,454 @@ +import json +import secrets +import hashlib +import time +import logging +import pydantic +from classes.baseprocessor import BaseProcessor +from oneme.models import ( + RequestCodePayloadModel, + VerifyCodePayloadModel, + AuthConfirmRegisterPayloadModel, + LoginPayloadModel, +) +from oneme.config import OnemeConfig +from common.sms import send_sms_code + + +class AuthProcessors(BaseProcessor): + def __init__(self, db_pool=None, clients=None, send_event=None, telegram_bot=None, type="socket"): + super().__init__(db_pool, clients, send_event, type) + self.server_config = OnemeConfig().SERVER_CONFIG + self.telegram_bot = telegram_bot + + async def auth_request(self, payload, seq, writer): + """Обработчик запроса кода""" + try: + RequestCodePayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.AUTH_REQUEST, self.error_types.INVALID_PAYLOAD, writer) + return + + # Извлекаем телефон из пакета + phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "") + + # Генерируем токен + token = secrets.token_urlsafe(102) + token_hash = hashlib.sha256(token.encode()).hexdigest() + + # Время истечения токена + expires = int(time.time()) + 300 + + user_exists = False + + # Ищем пользователя + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,)) + user = await cursor.fetchone() + + # Получаем код через SMS шлюз или генерируем локально (безопасность прежде всего) + if self.config.sms_gateway_url: + code = await send_sms_code(self.config.sms_gateway_url, phone) + + if code is None: + code = str(secrets.randbelow(900000) + 100000) + else: + code = str(secrets.randbelow(900000) + 100000) + + # Хешируем + code_hash = hashlib.sha256(code.encode()).hexdigest() + + # Сохраняем токен и если нужно отправляем код через тг + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + if user: + user_exists = True + # Сохраняем токен + await cursor.execute( + "INSERT INTO auth_tokens (phone, token_hash, code_hash, expires) VALUES (%s, %s, %s, %s)", + (phone, token_hash, code_hash, expires,) + ) + + # Если тг бот включен, и тг привязан к аккаунту - отправляем туда сообщение + if not self.config.sms_gateway_url and self.telegram_bot and user.get("telegram_id"): + await self.telegram_bot.send_code(chat_id=int(user.get("telegram_id")), phone=phone, code=code) + else: + # Пользователь не найден - сохраняем токен со state='register' + # чтобы после верификации кода направить на экран регистрации + await cursor.execute( + "INSERT INTO auth_tokens (phone, token_hash, code_hash, expires, state) VALUES (%s, %s, %s, %s, %s)", + (phone, token_hash, code_hash, expires, "register",) + ) + + # Данные пакета + payload = { + "token": token, + "codeLength": 6, + "requestMaxDuration": 60000, + "requestCountLeft": 10, + "altActionDuration": 60000 + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH_REQUEST, payload=payload + ) + + # Отправляем + await self._send(writer, packet) + self.logger.debug(f"Код для {phone}: {code} (существующий={user_exists})") + + async def auth(self, payload, seq, writer, deviceType, deviceName): + """Обработчик проверки кода""" + try: + VerifyCodePayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.AUTH, self.error_types.INVALID_PAYLOAD, writer) + return + + # Извлекаем данные из пакета + code = payload.get("verifyCode") + token = payload.get("token") + + # Хешируем токен с кодом + hashed_code = hashlib.sha256(code.encode()).hexdigest() + hashed_token = hashlib.sha256(token.encode()).hexdigest() + + # Генерируем постоянный токен + login = secrets.token_urlsafe(128) + hashed_login = hashlib.sha256(login.encode()).hexdigest() + + # Ищем токен с кодом + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + # Ищем токен + await cursor.execute( + "SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", + (hashed_token,) + ) + stored_token = await cursor.fetchone() + + # Если токен просрочен, или его нет - отправляем ошибку + if stored_token is None: + await self._send_error(seq, self.opcodes.AUTH, self.error_types.CODE_EXPIRED, writer) + return + + # Проверяем код + if stored_token.get("code_hash") != hashed_code: + await self._send_error(seq, self.opcodes.AUTH, self.error_types.INVALID_CODE, writer) + return + + # Если это новый пользователь - переводим токен в state='verified' + # и отдаём клиенту REGISTER токен, чтобы он показал экран ввода имени + if stored_token.get("state") == "register": + await cursor.execute( + "UPDATE auth_tokens SET state = %s WHERE token_hash = %s", + ("verified", hashed_token,) + ) + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH, + payload={ + "tokenAttrs": { + "REGISTER": { + "token": token + } + }, + "presetAvatars": [] + } + ) + await self._send(writer, packet) + return + + # Ищем аккаунт + await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),)) + account = await cursor.fetchone() + + # Удаляем токен + await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,)) + + # Создаем сессию + await cursor.execute( + "INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)", + (stored_token.get("phone"), hashed_login, deviceType, deviceName, "Little Saint James Island", int(time.time()),) # весь покрытый зеленью, абсолютно весь, остров невезения в океане есть + ) + + # Генерируем профиль + # Аватарка с биографией + photoId = None if not account.get("avatar_id") else int(account.get("avatar_id")) + avatar_url = None if not photoId else self.config.avatar_base_url + photoId + description = None if not account.get("description") else account.get("description") + + # Собираем данные пакета + payload = { + "tokenAttrs": { + "LOGIN": { + "token": login + } + }, + "profile": self.tools.generate_profile( + id=account.get("id"), + phone=int(account.get("phone")), + avatarUrl=avatar_url, + photoId=photoId, + updateTime=int(account.get("updatetime")), + firstName=account.get("firstname"), + lastName=account.get("lastname"), + options=json.loads(account.get("options")), + description=description, + accountStatus=int(account.get("accountstatus")), + profileOptions=json.loads(account.get("profileoptions")), + includeProfileOptions=True, + username=account.get("username") + ) + } + + # Создаем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH, payload=payload + ) + + # Отправляем + await self._send(writer, packet) + + async def auth_confirm(self, payload, seq, writer, deviceType, deviceName): + """Обработчик подтверждения регистрации нового пользователя""" + # Валидируем данные пакета + try: + AuthConfirmRegisterPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.AUTH_CONFIRM, self.error_types.INVALID_PAYLOAD, writer) + return + + # Извлекаем данные из пакета + token = payload.get("token") + first_name = payload.get("firstName").strip() + last_name = (payload.get("lastName") or "").strip() + + # Хешируем токен + hashed_token = hashlib.sha256(token.encode()).hexdigest() + + # Генерируем постоянный логин-токен + login = secrets.token_urlsafe(128) + hashed_login = hashlib.sha256(login.encode()).hexdigest() + + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + # Ищем токен - он должен быть в state='verified' + await cursor.execute( + "SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP() AND state = %s", + (hashed_token, "verified",) + ) + stored_token = await cursor.fetchone() + + # Если токен не найден или просрочен - отправляем ошибку + if stored_token is None: + await self._send_error(seq, self.opcodes.AUTH_CONFIRM, self.error_types.CODE_EXPIRED, writer) + return + + phone = stored_token.get("phone") + + # Проверяем что пользователь с таким телефоном ещё не существует + await cursor.execute("SELECT id FROM users WHERE phone = %s", (phone,)) + if await cursor.fetchone(): + await self._send_error(seq, self.opcodes.AUTH_CONFIRM, self.error_types.INVALID_PAYLOAD, writer) + return + + now_ms = int(time.time() * 1000) + now_s = int(time.time()) + + # Создаем пользователя + await cursor.execute( + """ + INSERT INTO users + (id, phone, telegram_id, firstname, lastname, username, + profileoptions, options, accountstatus, updatetime, lastseen) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + self.tools.generate_id(), phone, None, first_name, last_name, None, + json.dumps([]), json.dumps(["TT", "ONEME"]), + 0, str(now_ms), str(now_s), + ) + ) + + user_id = cursor.lastrowid + + # Добавляем данные аккаунта + await cursor.execute( + """ + INSERT INTO user_data + (phone, contacts, folders, user_config, chat_config) + VALUES (%s, %s, %s, %s, %s) + """, + ( + phone, + json.dumps([]), + json.dumps(self.static.USER_FOLDERS), + json.dumps(self.static.USER_SETTINGS), + json.dumps({}), + ) + ) + + # Удаляем токен + await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,)) + + # Создаем сессию + await cursor.execute( + "INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)", + (phone, hashed_login, deviceType or "ANDROID", deviceName or "Unknown", "Little Saint James Island", now_s,) + ) + + # Генерируем профиль + profile = self.tools.generate_profile( + id=user_id, + phone=int(phone), + avatarUrl=None, + photoId=None, + updateTime=now_ms, + firstName=first_name, + lastName=last_name, + options=["ONEME"], + description=None, + accountStatus=0, + profileOptions=[], + includeProfileOptions=True, + username=None + ) + + # Собираем данные пакета + payload = { + "userToken": "0", + "profile": profile, + "tokenType": "LOGIN", + "token": login + } + + # Создаем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH_CONFIRM, payload=payload + ) + + # Отправляем + await self._send(writer, packet) + self.logger.info(f"Новый пользователь зарегистрирован: phone={phone} id={user_id} name={first_name} {last_name}") + + async def login(self, payload, seq, writer): + """Обработчик авторизации клиента на сервере""" + # Валидируем данные пакета + try: + LoginPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.LOGIN, self.error_types.INVALID_PAYLOAD, writer) + return + + # Чаты, где состоит пользователь + chats = [] + + # Получаем данные из пакета + token = payload.get("token") + + # Хешируем токен + hashed_token = hashlib.sha256(token.encode()).hexdigest() + + # Ищем токен в бд + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT * FROM tokens WHERE token_hash = %s", (hashed_token,)) + token_data = await cursor.fetchone() + + # Если токен не найден, отправляем ошибку + if token_data is None: + await self._send_error(seq, self.opcodes.LOGIN, self.error_types.INVALID_TOKEN, writer) + return + + # Ищем аккаунт пользователя в бд + await cursor.execute("SELECT * FROM users WHERE phone = %s", (token_data.get("phone"),)) + user = await cursor.fetchone() + + # Ищем данные пользователя в бд + await cursor.execute("SELECT * FROM user_data WHERE phone = %s", (token_data.get("phone"),)) + user_data = await cursor.fetchone() + + # Ищем все чаты, где состоит пользователь + await cursor.execute( + "SELECT * FROM chat_participants WHERE user_id = %s", + (user.get('id')) + ) + user_chats = await cursor.fetchall() + + for chat in user_chats: + chats.append( + chat.get("chat_id") + ) + + # Аватарка с биографией + photoId = None if not user.get("avatar_id") else int(user.get("avatar_id")) + avatar_url = None if not photoId else self.config.avatar_base_url + photoId + description = None if not user.get("description") else user.get("description") + + # Генерируем профиль + profile = self.tools.generate_profile( + id=user.get("id"), + phone=int(user.get("phone")), + avatarUrl=avatar_url, + photoId=photoId, + updateTime=int(user.get("updatetime")), + firstName=user.get("firstname"), + lastName=user.get("lastname"), + options=json.loads(user.get("options")), + description=description, + accountStatus=int(user.get("accountstatus")), + profileOptions=json.loads(user.get("profileoptions")), + includeProfileOptions=True, + username=user.get("username") + ) + + chats = await self.tools.generate_chats( + chats, self.db_pool, user.get("id"), protocol_type=self.type + ) + + # Формируем данные пакета + payload = { + "profile": profile, + "chats": chats, + "chatMarker": 0, + "messages": {}, + "contacts": [], + "presence": {}, + "config": { + "server": self.server_config, + "user": json.loads(user_data.get("user_config")) + }, + "token": token, + "videoChatHistory": False, + "time": int(time.time() * 1000) + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOGIN, payload=payload + ) + + # Отправляем + await self._send(writer, packet) + return int(user.get("phone")), int(user.get("id")), hashed_token + + async def logout(self, seq, writer, hashedToken): + """Обработчик завершения сессии""" + # Удаляем токен из бд + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute("DELETE FROM tokens WHERE token_hash = %s", (hashedToken,)) + + # Создаем пакет + response = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOGOUT, payload=None + ) + + # Отправляем + await self._send(writer, response) \ No newline at end of file diff --git a/src/oneme/processors/calls.py b/src/oneme/processors/calls.py new file mode 100644 index 0000000..4ff02fe --- /dev/null +++ b/src/oneme/processors/calls.py @@ -0,0 +1,64 @@ +import pydantic +import secrets +import time +from classes.baseprocessor import BaseProcessor +from oneme.models import ( + GetCallTokenPayloadModel, + GetCallHistoryPayloadModel +) + +class CallsProcessors(BaseProcessor): + async def ok_token(self, payload, seq, writer): + """Получение токена для звонка""" + # Валидируем данные пакета + try: + GetCallTokenPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.OK_TOKEN, self.error_types.INVALID_PAYLOAD, writer) + return + + # TODO: когда-то взяться за звонки + + # Данные пакета + payload = { + "token": secrets.token_urlsafe(128), + "token_lifetime_ts": int(time.time() * 1000), + "token_refresh_ts": int(time.time() * 1000) + } + + # Создаем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.OK_TOKEN, payload=payload + ) + + # Отправляем + await self._send(writer, packet) + + async def video_chat_history(self, payload, seq, writer): + """Обработчик получения истории звонков""" + # Валидируем данные пакета + try: + GetCallHistoryPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.VIDEO_CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer) + return + + # TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию + + # Данные пакета + payload = { + "hasMore": False, + "history": [], + "backwardMarker": 0, + "forwardMarker": 0 + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.VIDEO_CHAT_HISTORY, payload=payload + ) + + # Отправляем + await self._send(writer, packet) \ No newline at end of file diff --git a/src/oneme/processors/chats.py b/src/oneme/processors/chats.py new file mode 100644 index 0000000..7a57be7 --- /dev/null +++ b/src/oneme/processors/chats.py @@ -0,0 +1,20 @@ +import pydantic +from classes.baseprocessor import BaseProcessor +from oneme.models import ChatSubscribePayloadModel + +class ChatsProcessors(BaseProcessor): + async def chat_subscribe(self, payload, seq, writer): + # Валидируем входные данные + try: + ChatSubscribePayloadModel.model_validate(payload) + except Exception as e: + await self._send_error(seq, self.opcodes.CHAT_SUBSCRIBE, self.error_types.INVALID_PAYLOAD, writer) + return + + # Созадаем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CHAT_SUBSCRIBE, payload=None + ) + + # Отправялем + await self._send(writer, packet) diff --git a/src/oneme/processors/complains.py b/src/oneme/processors/complains.py new file mode 100644 index 0000000..1b5eb80 --- /dev/null +++ b/src/oneme/processors/complains.py @@ -0,0 +1,29 @@ +import pydantic +import time +from classes.baseprocessor import BaseProcessor +from oneme.models import ComplainReasonsGetPayloadModel + +class ComplainsProcessors(BaseProcessor): + async def complain_reasons_get(self, payload, seq, writer): + """Обработчик получения причин жалоб""" + # Валидируем данные пакета + try: + ComplainReasonsGetPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.COMPLAIN_REASONS_GET, self.error_types.INVALID_PAYLOAD, writer) + return + + # Собираем данные пакета + payload = { + "complains": self.static.COMPLAIN_REASONS, + "complainSync": int(time.time()) + } + + # Создаем пакет + packet = self.proto.pack_packet( + seq=seq, opcode=self.opcodes.COMPLAIN_REASONS_GET, payload=payload + ) + + # Отправляем пакет + await self._send(writer, packet) diff --git a/src/oneme/processors/folders.py b/src/oneme/processors/folders.py new file mode 100644 index 0000000..fccda9f --- /dev/null +++ b/src/oneme/processors/folders.py @@ -0,0 +1,39 @@ +import pydantic +import json +import time +from classes.baseprocessor import BaseProcessor +from oneme.models import SyncFoldersPayloadModel + +class FoldersProcessors(BaseProcessor): + async def folders_get(self, payload, seq, writer, senderPhone): + """Синхронизация папок с сервером""" + # Валидируем данные пакета + try: + SyncFoldersPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.FOLDERS_GET, self.error_types.INVALID_PAYLOAD, writer) + return + + # Ищем папки в бд + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT folders FROM user_data WHERE phone = %s", (int(senderPhone),)) + result_folders = await cursor.fetchone() + user_folders = json.loads(result_folders.get("folders")) + + # Создаем данные пакета + payload = { + "folderSync": int(time.time() * 1000), + "folders": self.static.ALL_CHAT_FOLDER + user_folders.get("folders"), + "foldersOrder": self.static.ALL_CHAT_FOLDER_ORDER + user_folders.get("foldersOrder"), + "allFilterExcludeFolders": user_folders.get("allFilterExcludeFolders") + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.FOLDERS_GET, payload=payload + ) + + # Отправляем + await self._send(writer, packet) \ No newline at end of file diff --git a/src/oneme/processors/history.py b/src/oneme/processors/history.py new file mode 100644 index 0000000..3470f67 --- /dev/null +++ b/src/oneme/processors/history.py @@ -0,0 +1,107 @@ +import pydantic +import json +from classes.baseprocessor import BaseProcessor +from oneme.models import ChatHistoryPayloadModel + +class HistoryProcessors(BaseProcessor): + async def chat_history(self, payload, seq, writer, senderId): + """Обработчик получения истории чата""" + # Валидируем данные пакета + try: + ChatHistoryPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer) + return + + # Извлекаем данные из пакета + chatId = payload.get("chatId") + forward = payload.get("forward", 0) + backward = payload.get("backward", 0) + from_time = payload.get("from", 0) + getMessages = payload.get("getMessages", True) + messages = [] + + # Если пользователь хочет получить историю из избранного, + # то выставляем в качестве ID чата его ID + if chatId == 0: + chatId = senderId + + # Проверяем, существует ли чат + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + # Проверяем состоит ли пользователь в чате, + # только в случае того, если это не избранное + if chatId != senderId: + await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,)) + chat = await cursor.fetchone() + + # Выбрасываем ошибку, если чата нет + if not chat: + await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.CHAT_NOT_FOUND, writer) + return + + # Проверяем, является ли пользователь участником чата + participants = await self.tools.get_chat_participants(chatId, self.db_pool) + if int(senderId) not in participants: + await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.CHAT_NOT_ACCESS, writer) + return + + # Если запрошены сообщения + if getMessages: + if backward > 0: + await cursor.execute( + "SELECT * FROM messages WHERE chat_id = %s AND time < %s ORDER BY time ASC LIMIT %s", + (chatId, from_time, backward) + ) + + result = await cursor.fetchall() + + for row in result: + # TODO: Сборку тела сообщения нужно вынести в отдельную функцию + messages.append({ + "id": row.get("id") if self.type == 'mobile' else str(row.get('id')), + "time": int(row.get("time")), + "type": row.get("type"), + "sender": row.get("sender"), + "text": row.get("text"), + "attaches": json.loads(row.get("attaches")), + "elements": json.loads(row.get("elements")), + "reactionInfo": {} + }) + + if forward > 0: + await cursor.execute( + "SELECT * FROM messages WHERE chat_id = %s AND time > %s ORDER BY time ASC LIMIT %s", + (chatId, from_time, forward) + ) + + result = await cursor.fetchall() + + for row in result: + messages.append({ + "id": row.get("id"), + "time": int(row.get("time")), + "type": row.get("type"), + "sender": row.get("sender"), + "text": row.get("text"), + "attaches": json.loads(row.get("attaches")), + "elements": json.loads(row.get("elements")), + "reactionInfo": {} + }) + + # Сортируем сообщения по времени + messages.sort(key=lambda x: x["time"]) + + # Формируем ответ + payload = { + "messages": messages + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CHAT_HISTORY, payload=payload + ) + + # Отправялем + await self._send(writer, packet) \ No newline at end of file diff --git a/src/oneme/processors/main.py b/src/oneme/processors/main.py new file mode 100644 index 0000000..d050e3e --- /dev/null +++ b/src/oneme/processors/main.py @@ -0,0 +1,131 @@ +import pydantic +import json +from classes.baseprocessor import BaseProcessor +from oneme.models import ( + HelloPayloadModel, + PingPayloadModel, + UpdateProfilePayloadModel +) + + +class MainProcessors(BaseProcessor): + def __init__(self, db_pool=None, clients=None, send_event=None, type="socket"): + super().__init__(db_pool, clients, send_event, type) + + async def session_init(self, payload, seq, writer): + """Обработчик приветствия""" + # Валидируем данные пакета + try: + HelloPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.SESSION_INIT, self.error_types.INVALID_PAYLOAD, writer) + return None, None + + # Получаем данные из пакета + deviceType = payload.get("userAgent").get("deviceType") + deviceName = payload.get("userAgent").get("deviceName") + + # Данные пакета + payload = { + "location": "RU", + "app-update-type": 0, # 1 = принудительное обновление + "reg-country-code": self.static.REG_COUNTRY_CODES, + "phone-auto-complete-enabled": False, + "qr-auth-enabled": False, + "lang": True + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.SESSION_INIT, payload=payload + ) + + # Отправляем + await self._send(writer, packet) + return deviceType, deviceName + + async def ping(self, payload, seq, writer): + """Обработчик пинга""" + # Валидируем данные пакета + try: + PingPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.PING, self.error_types.INVALID_PAYLOAD, writer) + return + + # Собираем пакет + response = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.PING, payload=None + ) + + # Отправляем + await self._send(writer, response) + + async def log(self, payload, seq, writer): + """Обработчик телеметрии""" + # TODO: можно было бы реализовать валидацию телеметрии, но сейчас это не особо важно + + # Собираем пакет + response = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOG, payload=None + ) + + # Отправляем + await self._send(writer, response) + + async def profile(self, payload, seq, writer, userId): + # Валидируем входные данные + try: + UpdateProfilePayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.PROFILE, self.error_types.INVALID_PAYLOAD, writer) + return + + # Ищем пользователя в бд + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT * FROM users WHERE id = %s", (userId,)) + user = await cursor.fetchone() + + # Если пользователь не найден + if not user: + await self._send_error(seq, self.opcodes.PROFILE, self.error_types.USER_NOT_FOUND, writer) + return + + # Аватарка с биографией + photoId = None if not user.get("avatar_id") else int(user.get("avatar_id")) + avatar_url = None if not photoId else self.config.avatar_base_url + photoId + description = None if not user.get("description") else user.get("description") + + # Генерируем профиль + profile = self.tools.generate_profile( + id=user.get("id"), + phone=int(user.get("phone")), + avatarUrl=avatar_url, + photoId=photoId, + updateTime=int(user.get("updatetime")), + firstName=user.get("firstname"), + lastName=user.get("lastname"), + options=json.loads(user.get("options")), + description=description, + accountStatus=int(user.get("accountstatus")), + profileOptions=json.loads(user.get("profileoptions")), + includeProfileOptions=True, + username=user.get("username") + ) + + # Создаем данные пакета + payload = { + "profile": profile + } + + # Собираем пакет + response = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.PROFILE, payload=payload + ) + + # Отправляем + await self._send(writer, response) \ No newline at end of file diff --git a/src/oneme/processors/messages.py b/src/oneme/processors/messages.py new file mode 100644 index 0000000..d7e193a --- /dev/null +++ b/src/oneme/processors/messages.py @@ -0,0 +1,168 @@ +import pydantic +from classes.baseprocessor import BaseProcessor +from oneme.models import ( + TypingPayloadModel, + SendMessagePayloadModel +) + +class MessagesProcessors(BaseProcessor): + async def msg_typing(self, payload, seq, writer, senderId): + """Обработчик события печатания""" + # Валидируем данные пакета + try: + TypingPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.MSG_TYPING, self.error_types.INVALID_PAYLOAD, writer) + return + + # Извлекаем данные из пакета + chatId = payload.get("chatId") + type = payload.get("type") or "TYPING" + + # Ищем чат в базе данных + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,)) + chat = await cursor.fetchone() + + # Если чат не найден, отправляем ошибку + if not chat: + await self._send_error(seq, self.opcodes.MSG_TYPING, self.error_types.CHAT_NOT_FOUND, writer) + return + + # Участники чата + participants = await self.tools.get_chat_participants(chatId, self.db_pool) + + # Проверяем, является ли отправитель участником чата + if int(senderId) not in participants: + await self._send_error(seq, self.opcodes.MSG_TYPING, self.error_types.CHAT_NOT_ACCESS, writer) + return + + # Рассылаем событие участникам чата + for participant in participants: + if participant != senderId: + # Если участник не является отправителем, отправляем + await self.event( + participant, + { + "eventType": "typing", + "chatId": chatId, + "type": type, + "userId": senderId + } + ) + + # Создаем пакет + packet = self.proto.pack_packet( + seq=seq, opcode=self.opcodes.MSG_TYPING + ) + + # Отправляем пакет + await self._send(writer, packet) + + async def msg_send(self, payload, seq, writer, senderId, db_pool): + """Функция отправки сообщения""" + # Валидируем данные пакета + try: + SendMessagePayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.INVALID_PAYLOAD, writer) + return + + # Извлекаем данные из пакета + userId = payload.get("userId") + chatId = payload.get("chatId") + message = payload.get("message") + + elements = message.get("elements") or [] + attaches = message.get("attaches") or [] + cid = message.get("cid") or 0 + text = message.get("text") or "" + + # Вычисляем ID чата по ID пользователя и ID отправителя, + # в случае отсутствия ID чата + if chatId is None: + chatId = userId ^ senderId + + # Если клиент хочет отправить сообщение в избранное, + # то выставляем в качестве ID чата ID отправителя + # (А ещё используем это, если клиент вообще ничего не указал) + if chatId == 0 or not chatId: + chatId = senderId + participants = [senderId] + else: + # Если все таки клиент хочет отправить сообщение в нормальный чат, + # то ищем его в базе данных (извлекать список участников все таки тоже надо) + async with db_pool.acquire() as db_connection: + async with db_connection.cursor() as cursor: + await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,)) + chat = await cursor.fetchone() + + # Если нет такого чата - выбрасываем ошибку + if not chat: + await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CHAT_NOT_FOUND, writer) + return + + # Список участников + participants = await self.tools.get_chat_participants(chatId, db_pool) + + # Проверяем, является ли отправитель участником чата + if int(senderId) not in participants: + await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CHAT_NOT_ACCESS, writer) + return + + # Добавляем сообщение в историю + messageId, lastMessageId, messageTime = await self.tools.insert_message( + chatId=chatId, + senderId=senderId, + text=text, + attaches=attaches, + elements=elements, + cid=cid, + type="USER", + db_pool=self.db_pool + ) + + # Готовое тело сообщения + bodyMessage = { + "id": messageId, + "time": messageTime, + "type": "USER", + "sender": senderId, + "cid": cid, + "text": text, + "attaches": attaches, + "elements": elements + } + + # Отправляем событие всем участникам чата + for participant in participants: + await self.event( + participant, + { + "eventType": "new_msg", + "chatId": 0 if chatId == senderId else chatId, + "message": bodyMessage, + "prevMessageId": lastMessageId, + "time": messageTime, + "writer": writer + } + ) + + # Данные пакета + payload = { + "chatId": 0 if chatId == senderId else chatId, + "message": bodyMessage, + "unread": 0, + "mark": messageTime + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.MSG_SEND, payload=payload + ) + + # Отправляем + await self._send(writer, packet) \ No newline at end of file diff --git a/src/oneme/processors/search.py b/src/oneme/processors/search.py new file mode 100644 index 0000000..353ee18 --- /dev/null +++ b/src/oneme/processors/search.py @@ -0,0 +1,226 @@ +import json +import pydantic +from classes.baseprocessor import BaseProcessor +from oneme.models import ( + SearchUsersPayloadModel, + SearchChatsPayloadModel, + SearchByPhonePayloadModel +) + + +class SearchProcessors(BaseProcessor): + async def contact_info(self, payload, seq, writer): + """Поиск пользователей по ID""" + # Валидируем данные пакета + try: + SearchUsersPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.CONTACT_INFO, self.error_types.INVALID_PAYLOAD, writer) + return + + # Итоговый список пользователей + users = [] + + # ID пользователей, которые нам предстоит найти + contactIds = payload.get("contactIds") + + # Ищем пользователей в бд + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + for contactId in contactIds: + await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,)) + user = await cursor.fetchone() + + # Если такой пользователь есть, добавляем его в список + if user: + # Аватарка с биографией + photoId = None if not user.get("avatar_id") else int(user.get("avatar_id")) + avatar_url = None if not photoId else self.config.avatar_base_url + photoId + description = None if not user.get("description") else user.get("description") + + # Генерируем профиль + users.append( + self.tools.generate_profile( + id=user.get("id"), + phone=int(user.get("phone")), + avatarUrl=avatar_url, + photoId=photoId, + updateTime=int(user.get("updatetime")), + firstName=user.get("firstname"), + lastName=user.get("lastname"), + options=json.loads(user.get("options")), + description=description, + accountStatus=int(user.get("accountstatus")), + profileOptions=json.loads(user.get("profileoptions")), + includeProfileOptions=False, + username=user.get("username") + ) + ) + + # Создаем данные пакета + payload = { + "contacts": users + } + + # Создаем пакет + response = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_INFO, payload=payload + ) + + # Отправляем + await self._send(writer, response) + + async def contact_info_by_phone(self, payload, seq, writer, senderId): + """Поиск по номеру телефона""" + # Валидируем данные пакета + try: + SearchByPhonePayloadModel.model_validate(payload) + except Exception as e: + await self._send_error(seq, self.opcodes.CONTACT_INFO_BY_PHONE, self.error_types.INVALID_PAYLOAD, writer) + return + + # Ищем пользователя в бд + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT * FROM users WHERE phone = %s", (int(payload.get("phone")),)) + user = await cursor.fetchone() + + # Если пользователь не найден, отправляем ошибку + if not user: + await self._send_error(seq, self.opcodes.CONTACT_INFO_BY_PHONE, self.error_types.USER_NOT_FOUND, writer) + return + + # ID чата + chatId = senderId ^ user.get("id") + + # Ищем диалог в бд + await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,)) + chat = await cursor.fetchone() + + # Если диалога нет - создаем + if not chat: + await cursor.execute( + "INSERT INTO chats (id, owner, type) VALUES (%s, %s, %s)", + (chatId, senderId, "DIALOG") + ) + + # Добавляем участников в таблицу chat_participants + participants = [int(senderId), int(user.get("id"))] + + for user_id in participants: + await cursor.execute( + "INSERT INTO chat_participants (chat_id, user_id) VALUES (%s, %s)", + (chatId, user_id) + ) + + # Аватарка с биографией + photoId = None if not user.get("avatar_id") else int(user.get("avatar_id")) + avatar_url = None if not photoId else self.config.avatar_base_url + photoId + description = None if not user.get("description") else user.get("description") + + # Генерируем профиль + profile = self.tools.generate_profile( + id=user.get("id"), + phone=int(user.get("phone")), + avatarUrl=avatar_url, + photoId=photoId, + updateTime=int(user.get("updatetime")), + firstName=user.get("firstname"), + lastName=user.get("lastname"), + options=json.loads(user.get("options")), + description=description, + accountStatus=int(user.get("accountstatus")), + profileOptions=json.loads(user.get("profileoptions")), + includeProfileOptions=False, + username=user.get("username") + ) + + # Создаем данные пакета + payload = { + "contact": profile + } + + # Создаем пакет + response = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_INFO_BY_PHONE, payload=payload + ) + + # Отправляем + await self._send(writer, response) + + async def chat_info(self, payload, seq, writer, senderId): + """Поиск чатов по ID""" + # Валидируем данные пакета + try: + SearchChatsPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.CHAT_INFO, self.error_types.INVALID_PAYLOAD, writer) + return + + # Итоговый список чатов + chats = [] + + # ID чатов, которые нам предстоит найти + chatIds = payload.get("chatIds") + + # Ищем чаты в бд + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + for chatId in chatIds: + if chatId != 0: + await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,)) + chat = await cursor.fetchone() + + if chat: + # Проверяем, является ли пользователь участником чата + participants = await self.tools.get_chat_participants(chatId, self.db_pool) + # (в max нельзя смотреть и отправлять сообщения в чат, в котором ты не участник, в отличие от tg (например, комментарии в каналах), + # так что надо тоже так делать) + if int(senderId) not in participants: + continue + + # Получаем последнее сообщение из чата + message, messageTime = await self.tools.get_last_message( + chatId, self.db_pool, protocol_type=self.type + ) + + # Добавляем чат в список + chats.append( + self.tools.generate_chat( + chatId, chat.get("owner"), + chat.get("type"), participants, + message, messageTime + ) + ) + else: + # Получаем последнее сообщение из чата + message, messageTime = await self.tools.get_last_message( + senderId, self.db_pool, protocol_type=self.type + ) + + # ID избранного + chatId = senderId ^ senderId + + # Добавляем чат в список + chats.append( + self.tools.generate_chat( + chatId, senderId, + "DIALOG", [senderId], + message, messageTime + ) + ) + + # Создаем данные пакета + payload = { + "chats": chats + } + + # Собираем пакет + response = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CHAT_INFO, payload=payload + ) + + # Отправляем + await self._send(writer, response) diff --git a/src/oneme/processors/sessions.py b/src/oneme/processors/sessions.py new file mode 100644 index 0000000..6218397 --- /dev/null +++ b/src/oneme/processors/sessions.py @@ -0,0 +1,38 @@ +from classes.baseprocessor import BaseProcessor + +class SessionsProcessors(BaseProcessor): + async def sessions_info(self, payload, seq, writer, senderPhone, hashedToken): + """Получение активных сессий на аккаунте""" + # Готовый список сессий + sessions = [] + + # Ищем сессии в бд + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT * FROM tokens WHERE phone = %s", (str(senderPhone),)) + user_sessions = await cursor.fetchall() + + # Собираем сессии в список + for session in user_sessions: + sessions.append( + { + "time": int(session.get("time")), + "client": f"MAX {session.get('device_type')}", + "info": session.get("device_name"), + "location": session.get("location"), + "current": True if session.get("token_hash") == hashedToken else False + } + ) + + # Создаем данные пакета + payload = { + "sessions": sessions + } + + # Создаем пакет + response = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.SESSIONS_INFO, payload=payload + ) + + # Отправляем + await self._send(writer, response) diff --git a/src/oneme/socket.py b/src/oneme/socket.py new file mode 100644 index 0000000..820d09d --- /dev/null +++ b/src/oneme/socket.py @@ -0,0 +1,339 @@ +import asyncio +import logging +import traceback + +from common.opcodes import Opcodes +from common.proto_tcp import MobileProto +from common.rate_limiter import RateLimiter +from common.tools import Tools +from oneme.processors import Processors + + +class OnemeMobile: + def __init__( + self, host, port, ssl_context, db_pool, clients, send_event, telegram_bot + ): + self.host = host + self.port = port + self.ssl_context = ssl_context + self.server = None + self.logger = logging.getLogger(__name__) + self.db_pool = db_pool + self.clients = clients + + self.proto = MobileProto() + self.auth_required = Tools().auth_required + self.processors = Processors( + db_pool=db_pool, + clients=clients, + send_event=send_event, + telegram_bot=telegram_bot, + ) + self.opcodes = Opcodes() + + # rate limiter anti ddos brute force protection + self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60) + + self.read_timeout = 300 # Таймаут чтения из сокета (секунды) + self.max_read_size = 65536 # Максимальный размер данных из сокета + + async def handle_client(self, reader, writer): + """Функция для обработки подключений""" + # IP-адрес клиента + address = writer.get_extra_info("peername") + self.logger.info(f"Работаю с клиентом {address[0]}:{address[1]}") + + deviceType = None + deviceName = None + + userPhone = None + userId = None + hashedToken = None + + try: + while True: + # Читаем новые данные из сокета с таймаутом + try: + data = await asyncio.wait_for( + reader.read(self.max_read_size), timeout=self.read_timeout + ) + except asyncio.TimeoutError: + self.logger.info( + f"Таймаут соединения для {address[0]}:{address[1]}" + ) + break + + # Если сокет закрыт - выходим из цикла + if not data: + break + + if len(data) > self.max_read_size: + self.logger.warning( + f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(data)} байт)" + ) + break + + # Распаковываем данные + packet = self.proto.unpack_packet(data) + + # Скип если пакет невалидный + if packet is None: + self.logger.warning( + f"Невалидный пакет от {address[0]}:{address[1]}" + ) + continue + + opcode = packet.get("opcode") + seq = packet.get("seq") + payload = packet.get("payload") + + match opcode: + case self.opcodes.SESSION_INIT: + deviceType, deviceName = await self.processors.session_init( + payload, seq, writer + ) + case self.opcodes.AUTH_REQUEST: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error( + seq, + self.opcodes.AUTH_REQUEST, + self.processors.error_types.RATE_LIMITED, + writer, + ) + else: + await self.processors.auth_request(payload, seq, writer) + case self.opcodes.AUTH: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error( + seq, + self.opcodes.AUTH, + self.processors.error_types.RATE_LIMITED, + writer, + ) + else: + await self.processors.auth( + payload, seq, writer, deviceType, deviceName + ) + case self.opcodes.AUTH_CONFIRM: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error( + seq, + self.opcodes.AUTH_CONFIRM, + self.processors.error_types.RATE_LIMITED, + writer, + ) + elif payload and payload.get("tokenType") == "REGISTER": + await self.processors.auth_confirm( + payload, seq, writer, deviceType, deviceName + ) + else: + self.logger.warning( + f"AUTH_CONFIRM с неизвестным tokenType: {payload}" + ) + case self.opcodes.LOGIN: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error( + seq, + self.opcodes.LOGIN, + self.processors.error_types.RATE_LIMITED, + writer, + ) + else: + ( + userPhone, + userId, + hashedToken, + ) = await self.processors.login(payload, seq, writer) + + if userPhone: + await self._finish_auth( + writer, address, userPhone, userId + ) + case self.opcodes.LOGOUT: + await self.processors.logout( + seq, writer, hashedToken=hashedToken + ) + break + case self.opcodes.PING: + await self.processors.ping(payload, seq, writer) + case self.opcodes.LOG: + await self.processors.log(payload, seq, writer) + case self.opcodes.ASSETS_UPDATE: + await self.auth_required( + userPhone, + self.processors.assets_update, + payload, + seq, + writer, + ) + case self.opcodes.VIDEO_CHAT_HISTORY: + await self.auth_required( + userPhone, + self.processors.video_chat_history, + payload, + seq, + writer, + ) + case self.opcodes.MSG_SEND: + await self.auth_required( + userPhone, + self.processors.msg_send, + payload, + seq, + writer, + userId, + self.db_pool, + ) + case self.opcodes.FOLDERS_GET: + await self.auth_required( + userPhone, + self.processors.folders_get, + payload, + seq, + writer, + userPhone, + ) + case self.opcodes.SESSIONS_INFO: + await self.auth_required( + userPhone, + self.processors.sessions_info, + payload, + seq, + writer, + userPhone, + hashedToken, + ) + case self.opcodes.CHAT_INFO: + await self.auth_required( + userPhone, + self.processors.chat_info, + payload, + seq, + writer, + userId, + ) + case self.opcodes.CHAT_HISTORY: + await self.auth_required( + userPhone, + self.processors.chat_history, + payload, + seq, + writer, + userId, + ) + case self.opcodes.CONTACT_INFO_BY_PHONE: + await self.auth_required( + userPhone, + self.processors.contact_info_by_phone, + payload, + seq, + writer, + userId, + ) + case self.opcodes.OK_TOKEN: + await self.auth_required( + userPhone, self.processors.ok_token, payload, seq, writer + ) + case self.opcodes.MSG_TYPING: + await self.auth_required( + userPhone, + self.processors.msg_typing, + payload, + seq, + writer, + userId, + ) + case self.opcodes.CONTACT_INFO: + await self.auth_required( + userPhone, + self.processors.contact_info, + payload, + seq, + writer, + ) + case self.opcodes.COMPLAIN_REASONS_GET: + await self.auth_required( + userPhone, + self.processors.complain_reasons_get, + payload, + seq, + writer, + ) + case self.opcodes.PROFILE: + await self.processors.profile( + payload, seq, writer, userId=userId + ) + case self.opcodes.CHAT_SUBSCRIBE: + await self.auth_required( + userPhone, + self.processors.chat_subscribe, + payload, + seq, + writer, + ) + case _: + self.logger.warning(f"Неизвестный опкод {opcode}") + except Exception as e: + self.logger.error( + f"Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}" + ) + traceback.print_exc() + + # Удаляем клиента из словаря + if userPhone: + await self._end_session(userId, address[0], address[1]) + + writer.close() + self.logger.info( + f"Прекратил работать работать с клиентом {address[0]}:{address[1]}" + ) + + async def _finish_auth(self, writer, addr, phone, id): + """Завершение открытия сессии""" + # Ищем пользователя в словаре + user = self.clients.get(id) + + # Добавляем новое подключение в словарь + if user: + user["clients"].append( + {"writer": writer, "ip": addr[0], "port": addr[1], "protocol": "oneme"} + ) + else: + self.clients[id] = { + "phone": phone, + "id": id, + "clients": [ + { + "writer": writer, + "ip": addr[0], + "port": addr[1], + "protocol": "oneme", + } + ], + } + + async def _end_session(self, id, ip, port): + """Завершение сессии""" + # Получаем пользователя в списке + user = self.clients.get(id) + if not user: + return + + # Получаем подключения пользователя + clients = user.get("clients", []) + + # Удаляем нужное подключение из словаря + for i, client in enumerate(clients): + if (client.get("ip"), client.get("port")) == (ip, port): + clients.pop(i) + + async def start(self): + """Функция для запуска сервера""" + self.server = await asyncio.start_server( + self.handle_client, self.host, self.port, ssl=self.ssl_context + ) + + self.logger.info(f"Сокет запущен на порту {self.port}") + + async with self.server: + await self.server.serve_forever() diff --git a/src/oneme/websocket.py b/src/oneme/websocket.py new file mode 100644 index 0000000..276e9e8 --- /dev/null +++ b/src/oneme/websocket.py @@ -0,0 +1,317 @@ +import logging +import traceback +import websockets +from common.proto_web import WebProto +from oneme.processors import Processors +from common.rate_limiter import RateLimiter +from common.opcodes import Opcodes +from common.tools import Tools + +class OnemeWS: + def __init__(self, host, port, clients, ssl_context, db_pool, send_event): + self.host = host + self.port = port + self.ssl_context = ssl_context + self.server = None + self.logger = logging.getLogger(__name__) + self.db_pool = db_pool + self.clients = clients + + self.opcodes = Opcodes() + + self.proto = WebProto() + self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event, type="web") + self.auth_required = Tools().auth_required + + # rate limiter + self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60) + + self.read_timeout = 300 # Таймаут чтения из websocket (секунды) + self.max_read_size = 65536 # Максимальный размер данных + + async def handle_client(self, websocket): + """Функция для обработки WebSocket подключений""" + # IP-адрес клиента + address = websocket.remote_address + self.logger.info(f"Работаю с клиентом {address[0]}:{address[1]}") + + deviceType = None + deviceName = None + + userPhone = None + userId = None + hashedToken = None + + try: + async for message in websocket: + # Проверяем размер данных + if len(message) > self.max_read_size: + self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(message)} байт)") + break + + # Распаковываем данные + packet = self.proto.unpack_packet(message) + + # Если пакет невалидный — пропускаем + if not packet: + self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}") + continue + + opcode = packet.get("opcode") + seq = packet.get("seq") + payload = packet.get("payload") + + match opcode: + case self.opcodes.SESSION_INIT: + deviceType, deviceName = await self.processors.session_init( + payload, seq, websocket + ) + case self.opcodes.AUTH_REQUEST: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error( + seq, + self.opcodes.AUTH_REQUEST, + self.processors.error_types.RATE_LIMITED, + websocket, + ) + else: + await self.processors.auth_request(payload, seq, websocket) + case self.opcodes.AUTH: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error( + seq, + self.opcodes.AUTH, + self.processors.error_types.RATE_LIMITED, + websocket, + ) + else: + await self.processors.auth( + payload, seq, websocket, deviceType, deviceName + ) + case self.opcodes.AUTH_CONFIRM: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error( + seq, + self.opcodes.AUTH_CONFIRM, + self.processors.error_types.RATE_LIMITED, + websocket, + ) + elif payload and payload.get("tokenType") == "REGISTER": + await self.processors.auth_confirm( + payload, seq, websocket, deviceType, deviceName + ) + else: + self.logger.warning( + f"AUTH_CONFIRM с неизвестным tokenType: {payload}" + ) + case self.opcodes.LOGIN: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error( + seq, + self.opcodes.LOGIN, + self.processors.error_types.RATE_LIMITED, + websocket, + ) + else: + ( + userPhone, + userId, + hashedToken, + ) = await self.processors.login(payload, seq, websocket) + + if userPhone: + await self._finish_auth( + websocket, address, userPhone, userId + ) + case self.opcodes.LOGOUT: + await self.processors.logout( + seq, websocket, hashedToken=hashedToken + ) + break + case self.opcodes.PING: + await self.processors.ping(payload, seq, websocket) + case self.opcodes.LOG: + await self.processors.log(payload, seq, websocket) + case self.opcodes.ASSETS_UPDATE: + await self.auth_required( + userPhone, + self.processors.assets_update, + payload, + seq, + websocket, + ) + case self.opcodes.VIDEO_CHAT_HISTORY: + await self.auth_required( + userPhone, + self.processors.video_chat_history, + payload, + seq, + websocket, + ) + case self.opcodes.MSG_SEND: + await self.auth_required( + userPhone, + self.processors.msg_send, + payload, + seq, + websocket, + userId, + self.db_pool, + ) + case self.opcodes.FOLDERS_GET: + await self.auth_required( + userPhone, + self.processors.folders_get, + payload, + seq, + websocket, + userPhone, + ) + case self.opcodes.SESSIONS_INFO: + await self.auth_required( + userPhone, + self.processors.sessions_info, + payload, + seq, + websocket, + userPhone, + hashedToken, + ) + case self.opcodes.CHAT_INFO: + await self.auth_required( + userPhone, + self.processors.chat_info, + payload, + seq, + websocket, + userId, + ) + case self.opcodes.CHAT_HISTORY: + await self.auth_required( + userPhone, + self.processors.chat_history, + payload, + seq, + websocket, + userId, + ) + case self.opcodes.CONTACT_INFO_BY_PHONE: + await self.auth_required( + userPhone, + self.processors.contact_info_by_phone, + payload, + seq, + websocket, + userId, + ) + case self.opcodes.OK_TOKEN: + await self.auth_required( + userPhone, self.processors.ok_token, payload, seq, websocket + ) + case self.opcodes.MSG_TYPING: + await self.auth_required( + userPhone, + self.processors.msg_typing, + payload, + seq, + websocket, + userId, + ) + case self.opcodes.CONTACT_INFO: + await self.auth_required( + userPhone, + self.processors.contact_info, + payload, + seq, + websocket, + ) + case self.opcodes.COMPLAIN_REASONS_GET: + await self.auth_required( + userPhone, + self.processors.complain_reasons_get, + payload, + seq, + websocket, + ) + case self.opcodes.PROFILE: + await self.processors.profile( + payload, seq, websocket, userId=userId + ) + case self.opcodes.CHAT_SUBSCRIBE: + await self.auth_required( + userPhone, + self.processors.chat_subscribe, + payload, + seq, + websocket, + ) + case _: + self.logger.warning(f"Неизвестный опкод {opcode}") + except websockets.exceptions.ConnectionClosed: + self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}") + except Exception as e: + self.logger.error(f" Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}") + traceback.print_exc() + + # Удаляем клиента из словаря при отключении + if userId: + await self._end_session(userId, address[0], address[1]) + + self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}") + + async def _finish_auth(self, websocket, addr, phone, id): + """Завершение открытия сессии""" + # Ищем пользователя в словаре + user = self.clients.get(id) + + # Добавляем новое подключение в словарь + if user: + user["clients"].append( + { + "writer": websocket, + "ip": addr[0], + "port": addr[1], + "protocol": "oneme" + } + ) + else: + self.clients[id] = { + "phone": phone, + "id": id, + "clients": [ + { + "writer": websocket, + "ip": addr[0], + "port": addr[1], + "protocol": "oneme" + } + ] + } + + async def _end_session(self, id, ip, port): + """Завершение сессии""" + # Получаем пользователя в списке + user = self.clients.get(id) + if not user: + return + + # Получаем подключения пользователя + clients = user.get("clients", []) + + # Удаляем нужное подключение из словаря + for i, client in enumerate(clients): + if (client.get("ip"), client.get("port")) == (ip, port): + clients.pop(i) + + async def start(self): + """Функция для запуска WebSocket сервера""" + self.server = await websockets.serve( + self.handle_client, + self.host, + self.port, + ssl=self.ssl_context + ) + + self.logger.info(f"WebSocket запущен на порту {self.port}") + + await self.server.wait_closed() \ No newline at end of file diff --git a/src/oneme_tcp/config.py b/src/oneme_tcp/config.py deleted file mode 100644 index c9ff193..0000000 --- a/src/oneme_tcp/config.py +++ /dev/null @@ -1,383 +0,0 @@ -class OnemeConfig: - def __init__(self): - pass - - # TODO: почистить вообще надо, и настройки потыкать - SERVER_CONFIG = { - "account-nickname-enabled": False, - "account-removal-enabled": False, - "anr-config": { - "enabled": True, - "timeout": { - "low": 5000, - "avg": 5000, - "high": 5000 - } - }, - "appearance-multi-theme-screen-enabled": True, - "audio-transcription-locales": [], - "available-complaints": [ - "FAKE", - "SPAM", - "PORNO", - "EXTREMISM", - "THREAT", - "OTHER" - ], - "avatars-screen-enabled": True, - "bad-networ-indicator-config": { - "signalingConfig": { - "dcReportNetworkStatEnabled": False - } - }, - "bots-channel-adding": True, - "cache-msg-preprocess": True, - "call-incoming-ab": 2, - "call-permissions-interval": 259200, - "call-pinch-to-zoom": True, - "call-rate": { - "limit": 3, - "sdk-limit": 2, - "duration": 10, - "delay": 86400 - }, - "callDontUseVpnForRtp": False, - "callEnableIceRenomination": False, - "calls-endpoint": "https://calls.okcdn.ru/", - "calls-sdk-am-speaker-fix": True, - "calls-sdk-audio-dynamic-redundancy": { - "mab": 16, - "dsb": 64, - "nl": True, - "df": True, - "dlb": True - }, - "calls-sdk-enable-nohost": True, - "calls-sdk-incall-stat": False, - "calls-sdk-linear-opus-bwe": True, - "calls-sdk-mapping": { - "off": True - }, - "calls-sdk-remove-nonopus-audiocodecs": True, - "calls-use-call-end-reason-fix": True, - "calls-use-ws-url-validation": True, - "cfs": True, - "channels-complaint-enabled": True, - "channels-enabled": True, - "channels-search-subscribers-visible": True, - "chat-complaint-enabled": False, - "chat-gif-autoplay-enabled": True, - "chat-history-notif-msg-strategy": 1, - "chat-history-persist": False, - "chat-history-warm-opts": 0, - "chat-invite-link-permissions-enabled": True, - "chat-media-scrollable-caption-enabled": True, - "chat-video-autoplay-enabled": True, - "chat-video-call-button": True, - "chatlist-subtitle-ver": 1, - "chats-folder-enabled": True, - "chats-page-size": 50, - "chats-preload-period": 15, - "cis-enabled": True, - "contact-add-bottom-sheet": True, - "creation-2fa-config": { - "pass_min_len": 6, - "pass_max_len": 64, - "hint_max_len": 30, - "enabled": True - }, - "debug-profile-info": False, - "default-reactions-settings": { - "isActive": True, - "count": 8, - "included": False, - "reactionIds": [] - }, - "delete-msg-fys-large-chat-disabled": True, - "devnull": { - "opcode": True, - "upload_hang": True - }, - "disconnect-timeout": 300, - "double-tap-reaction": "👍", - "double-tap-reaction-enabled": True, - "drafts-sync-enabled": False, - "edit-chat-type-screen-enabled": False, - "edit-timeout": 604800, - "enable-filters-for-folders": True, - "enable-unknown-contact-bottom-sheet": 2, - "fake-chats": True, - "family-protection-botid": 67804175, - "february-23-26-theme": True, - "file-preview": True, - "file-upload-enabled": True, - "file-upload-max-size": 4294967296, - "file-upload-unsupported-types": [ - "exe" - ], - "force-play-embed": True, - "gc-from-p2p": True, - "gce": False, - "group-call-part-limit": 100, - "grse": False, - "gsse": True, - "hide-incoming-call-notif": True, - "host-reachability": True, - "image-height": 1920, - "image-quality": 0.800000011920929, - "image-size": 40000000, - "image-width": 1920, - "in-app-review-triggers": 255, - "informer-enabled": True, - "inline-ev-player": True, - "invalidate-db-msg-exception": True, - "invite-friends-sheet-frequency": [ - 2, - 7 - ], - "invite-link": "https://t.me/openmax_alerts", - "invite-long": "Я пользуюсь OpenMAX. Присоединяйся! https://t.me/openmax_alerts", - "invite-short": "Я пользуюсь OpenMAX. Присоединяйся! https://t.me/openmax_alerts", - "join-requests": True, - "js-download-delegate": False, - "keep-connection": 2, - "lebedev-theme-enabled": True, - "lgce": True, - "markdown-enabled": True, - "markdown-menu": 0, - "max-audio-length": 3600, - "max-description-length": 400, - "max-favorite-chats": 5, - "max-favorite-sticker-sets": 100, - "max-favorite-stickers": 100, - "max-msg-length": 4000, - "max-participants": 20000, - "max-readmarks": 100, - "max-theme-length": 200, - "max-video-duration-download": 1200, - "max-video-message-length": 60, - "media-order": 1, - "media-playlist-enabled": True, - "media-transform": { - "enabled": True, - "hdr_enabled": False, - "hevc_enabled": True, - "max_enc_frames": { - "low": 1, - "avg": 1, - "high": 2 - } - }, - "media-viewer-rotation-enabled": True, - "media-viewer-video-collage-enabled": True, - "mentions-enabled": True, - "mentions_entity_names_limit": 3, - "migrate-unsafe-warn": True, - "min-image-side-size": 64, - "miui-menu-enabled": True, - "money-transfer-botid": 1134691, - "moscow-theme-enabled": True, - "msg-get-reactions-page-size": 40, - "music-files-enabled": False, - "mytracker-enabled": False, - "net-client-dns-enabled": True, - "net-session-suppress-bad-disconnected-state": True, - "net-stat-config": [ - 64, - 48, - 128, - 135 - ], - "new-admin-permissions": True, - "new-logout-logic": False, - "new-media-upload-ui": True, - "new-media-viewer-enabled": True, - "new-settings-storage-screen-enabled": False, - "new-width-text-bubbles-mob": True, - "new-year-theme-2026": False, - "nick-max-length": 60, - "nick-min-length": 7, - "official-org": True, - "one-video-failover": True, - "one-video-player": True, - "one-video-uploader": True, - "one-video-uploader-audio": True, - "one-video-uploader-progress-fix": True, - "perf-events": { - "startup_report": 2, - "web_app": 2 - }, - "player-load-control": { - "mp_autoplay_enabled": False, - "time_over_size": False, - "buffer_after_rebuffer_ms": 3000, - "buffer_ms": 500, - "max_buffer_ms": 13000, - "min_buffer_ms": 5000, - "use_min_size_lc": True, - "min_size_lc_fmt_mis_sf": 4 - }, - "progress-diff-for-notify": 1, - "push-delivery": True, - "qr-auth-enabled": True, - "quotes-enabled": True, - "react-errors": [ - "error.comment.chat.access", - "error.comment.invalid", - "error.message.invalid", - "error.message.chat.access", - "error.message.like.unknown.like", - "error.message.like.unknown.reaction", - "error.too-many-unlikes-dialog", - "error.too-many-unlikes-chat", - "error.too-many-likes", - "error.reactions.not.allowed" - ], - "react-permission": 2, - "reactions-enabled": True, - "reactions-max": 8, - "reactions-menu": [ - "👍", - "❤️", - "🤣", - "🔥", - "😭", - "💯", - "💩", - "😡" - ], - "reactions-settings-enabled": True, - "reconnect-call-ringtone": True, - "ringtone-am-mode": True, - "saved-messages-aliases": [ - "избранное", - "saved", - "favourite", - "favorite", - "личное", - "моё", - "мои", - "мой", - "моя", - "любимое", - "сохраненные", - "сохраненное", - "заметки", - "закладки" - ], - "scheduled-messages-enabled": True, - "scheduled-posts-enabled": True, - "search-webapps-showcase": { - "items": [ - { - "id": 4479862, - "icon": "https://st.max.ru/icons/icon_channel_square.webp", - "title": "Каналы" - } - ] - }, - "send-location-enabled": True, - "send-logs-interval-sec": 900, - "server-side-complains-enabled": True, - "set-audio-device": False, - "set-unread-timeout": 31536000, - "settings-entry-banners": [ - { - "id": 1, - "logo": "https://st.max.ru/icons/epgu_white_111125.png", - "align": 2, - "items": [ - { - "icon": "https://st.max.ru/icons/digital_id_new_40_3x.png", - "title": "Цифровой ID", - "appid": 8250447 - } - ] - }, - { - "id": 2, - "items": [ - { - "icon": "https://st.max.ru/icons/sferum_with_padding_120.png", - "title": "Войти в Cферум", - "appid": 2340831 - } - ] - } - ], - "show-reactions-on-multiselect": True, - "show-warning-links": True, - "speedy-upload": True, - "speedy-voice-messages": True, - "sse": True, - "stat-session-background-threshold": 60000, - "sticker-suggestion": [ - "RECENT", - "NEW", - "TOP" - ], - "stickers-controller-suspend": True, - "stickers-db-batch": True, - "streamable-mp4": True, - "stub": "stub2", - "suspend-video-converter": True, - "system-default-ringtone-opt": True, - "transfer-botid": 1134691, - "typing-enabled-FILE": True, - "unique-favorites": True, - "unsafe-files-alert": True, - "upload-reusability": True, - "upload-rx-no-blocking": True, - "user-debug-report": 2340932, - "video-msg-channels-enabled": True, - "video-msg-config": { - "duration": 60, - "quality": 480, - "min_frame_rate": 30, - "max_frame_rate": 30 - }, - "video-msg-enabled": True, - "video-transcoding-class": [ - 2, - 3 - ], - "views-count-enabled": True, - "watchdog-config": { - "enabled": True, - "stuck": 10, - "hang": 60 - }, - "webapp-exc": [ - 63602953, - 8250447 - ], - "webapp-push-open": True, - "webview-cache-enabled": False, - "welcome-sticker-ids": [ - 272821, - 295349, - 13571, - 546741, - 476341 - ], - "white-list-links": [ - "max.ru", - "vk.com", - "vk.ru", - "gosuslugi.ru", - "mail.ru", - "vk.ru", - "vkvideo.ru" - ], - "wm-analytics-enabled": True, - "wm-workers-limit": 80, - "wud": False, - "y-map": { - "tile": "34c7fd82-723d-4b23-8abb-33376729a893", - "geocoder": "34c7fd82-723d-4b23-8abb-33376729a893", - "static": "34c7fd82-723d-4b23-8abb-33376729a893", - "logoLight": "https://st.max.ru/icons/ya_maps_logo_light.webp", - "logoDark": "https://st.max.ru/icons/ya_maps_logo_dark.webp" - }, - "has-phone": True - } diff --git a/src/oneme_tcp/processors.py b/src/oneme_tcp/processors.py deleted file mode 100644 index 91209f8..0000000 --- a/src/oneme_tcp/processors.py +++ /dev/null @@ -1,888 +0,0 @@ -import json, secrets, hashlib, time, logging -from oneme_tcp.models import * -from oneme_tcp.proto import Proto -from oneme_tcp.config import OnemeConfig -from common.tools import Tools -from common.config import ServerConfig -from common.static import Static - -class Processors: - def __init__(self, db_pool=None, clients={}, send_event=None, telegram_bot=None): - self.proto = Proto() - self.tools = Tools() - self.config = ServerConfig() - self.static = Static() - self.server_config = OnemeConfig().SERVER_CONFIG - self.error_types = self.static.ErrorTypes() - self.chat_types = self.static.ChatTypes() - - self.db_pool = db_pool - self.event = send_event - self.clients = clients - self.telegram_bot = telegram_bot - self.logger = logging.getLogger(__name__) - - async def _send(self, writer, packet): - try: - writer.write(packet) - await writer.drain() - except Exception as error: - self.logger.error(f"Ошибка при отправке пакета - {error}") - - async def _send_error(self, seq, opcode, type, writer): - payload = self.static.ERROR_TYPES.get(type, { - "localizedMessage": "Неизвестная ошибка", - "error": "unknown.error", - "message": "Unknown error", - "title": "Неизвестная ошибка" - }) - - packet = self.proto.pack_packet( - cmd=self.proto.CMD_ERR, seq=seq, opcode=opcode, payload=payload - ) - - await self._send(writer, packet) - - async def process_hello(self, payload, seq, writer): - """Обработчик приветствия""" - # Валидируем данные пакета - try: - HelloPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.HELLO, self.error_types.INVALID_PAYLOAD, writer) - return None, None - - # Получаем данные из пакета - deviceType = payload.get("userAgent").get("deviceType") - deviceName = payload.get("userAgent").get("deviceName") - - # Данные пакета - payload = { - "location": "RU", - "app-update-type": 0, # 1 = принудительное обновление - "reg-country-code": [ - # Список стран, который отдает официальный сервер - "AZ", "AM", "KZ", "KG", "MD", "TJ", "UZ", "GE", "TH", "TR", - "TM", "AE", "LA", "MY", "ID", "CU", "KH", "VN", - - # Список стран, который приделали уже мы - "US", "CA", "UA" - ], - "phone-auto-complete-enabled": False, - "lang": True - } - - # Собираем пакет - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.SESSION_INIT, payload=payload - ) - - # Отправляем - await self._send(writer, packet) - return deviceType, deviceName - - async def process_ping(self, payload, seq, writer): - """Обработчик пинга""" - # Валидируем данные пакета - try: - PingPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.PING, self.error_types.INVALID_PAYLOAD, writer) - return - - # Собираем пакет - response = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.PING, payload=None - ) - - # Отправляем - await self._send(writer, response) - - async def process_telemetry(self, payload, seq, writer): - """Обработчик телеметрии""" - # TODO: можно было бы реализовать валидацию телеметрии, но сейчас это не особо важно - - # Собираем пакет - response = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.LOG, payload=None - ) - - # Отправляем - await self._send(writer, response) - - async def process_request_code(self, payload, seq, writer): - """Обработчик запроса кода""" - # Валидируем данные пакета - try: - RequestCodePayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.AUTH_REQUEST, self.error_types.INVALID_PAYLOAD, writer) - return - - # Извлекаем телефон из пакета - phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "") - - # Генерируем токен с кодом (безопасность прежде всего) - code = str(secrets.randbelow(900000) + 100000) - token = secrets.token_urlsafe(128) - - # Хешируем - code_hash = hashlib.sha256(code.encode()).hexdigest() - token_hash = hashlib.sha256(token.encode()).hexdigest() - - # Время истечения токена - expires = int(time.time()) + 300 - - # Ищем пользователя, и если он существует, сохраняем токен - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,)) - user = await cursor.fetchone() - - # Если пользователя нет - отдаем ошибку - if user is None: - await self._send_error(seq, self.proto.AUTH_REQUEST, self.error_types.USER_NOT_FOUND, writer) - return - - # Сохраняем токен - await cursor.execute("INSERT INTO auth_tokens (phone, token_hash, code_hash, expires) VALUES (%s, %s, %s, %s)", (phone, token_hash, code_hash, expires,)) - - # Если тг бот включен, и тг привязан к аккаунту - отправляем туда сообщение - if self.telegram_bot and user.get("telegram_id"): - await self.telegram_bot.send_code(chat_id=int(user.get("telegram_id")), phone=phone, code=code) - - # Данные пакета - payload = { - "requestMaxDuration": 60000, - "requestCountLeft": 10, - "altActionDuration": 60000, - "codeLength": 6, - "token": token - } - - # Собираем пакет - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.AUTH_REQUEST, payload=payload - ) - - # Отправляем - await self._send(writer, packet) - self.logger.debug(f"Код для {phone}: {code}") - - async def process_verify_code(self, payload, seq, writer, deviceType, deviceName): - """Обработчик проверки кода""" - # Валидируем данные пакета - try: - VerifyCodePayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.AUTH, self.error_types.INVALID_PAYLOAD, writer) - return - - # Извлекаем данные из пакета - code = payload.get("verifyCode") - token = payload.get("token") - - # Хешируем токен с кодом - hashed_code = hashlib.sha256(code.encode()).hexdigest() - hashed_token = hashlib.sha256(token.encode()).hexdigest() - - # Генерируем постоянный токен - login = secrets.token_urlsafe(128) - hashed_login = hashlib.sha256(login.encode()).hexdigest() - - # Ищем токен с кодом - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - # Ищем токен - await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", (hashed_token,)) - stored_token = await cursor.fetchone() - - # Если токен просрочен, или его нет - отправляем ошибку - if stored_token is None: - await self._send_error(seq, self.proto.AUTH, self.error_types.CODE_EXPIRED, writer) - return - - # Проверяем код - if stored_token.get("code_hash") != hashed_code: - await self._send_error(seq, self.proto.AUTH, self.error_types.INVALID_CODE, writer) - return - - # Ищем аккаунт - await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),)) - account = await cursor.fetchone() - - # Удаляем токен - await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,)) - - # Создаем сессию - await cursor.execute( - "INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)", - (stored_token.get("phone"), hashed_login, deviceType, deviceName, "Little Saint James Island", int(time.time()),) # весь покрытый зеленью, абсолютно весь, остров невезения в океане есть - ) - - # Генерируем профиль - # Аватарка с биографией - photoId = None if not account.get("avatar_id") else int(account.get("avatar_id")) - avatar_url = None if not photoId else self.config.avatar_base_url + photoId - description = None if not account.get("description") else account.get("description") - - # Собираем данные пакета - payload = { - "tokenAttrs": { - "LOGIN": { - "token": login - } - }, - "profile": self.tools.generate_profile( - id=account.get("id"), - phone=int(account.get("phone")), - avatarUrl=avatar_url, - photoId=photoId, - updateTime=int(account.get("updatetime")), - firstName=account.get("firstname"), - lastName=account.get("lastname"), - options=json.loads(account.get("options")), - description=description, - accountStatus=int(account.get("accountstatus")), - profileOptions=json.loads(account.get("profileoptions")), - includeProfileOptions=True, - username=account.get("username") - ) - } - - # Создаем пакет - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.AUTH, payload=payload - ) - - # Отправляем - await self._send(writer, packet) - - async def process_login(self, payload, seq, writer): - """Обработчик авторизации клиента на сервере""" - # Валидируем данные пакета - try: - LoginPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.LOGIN, self.error_types.INVALID_PAYLOAD, writer) - return - - # Получаем данные из пакета - token = payload.get("token") - - # Хешируем токен - hashed_token = hashlib.sha256(token.encode()).hexdigest() - - # Ищем токен в бд - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - await cursor.execute("SELECT * FROM tokens WHERE token_hash = %s", (hashed_token,)) - token_data = await cursor.fetchone() - - # Если токен не найден, отправляем ошибку - if token_data is None: - await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer) - return - - # Ищем аккаунт пользователя в бд - await cursor.execute("SELECT * FROM users WHERE phone = %s", (token_data.get("phone"),)) - user = await cursor.fetchone() - - # Ищем данные пользователя в бд - await cursor.execute("SELECT * FROM user_data WHERE phone = %s", (token_data.get("phone"),)) - user_data = await cursor.fetchone() - - # Аватарка с биографией - photoId = None if not user.get("avatar_id") else int(user.get("avatar_id")) - avatar_url = None if not photoId else self.config.avatar_base_url + photoId - description = None if not user.get("description") else user.get("description") - - # Генерируем профиль - profile = self.tools.generate_profile( - id=user.get("id"), - phone=int(user.get("phone")), - avatarUrl=avatar_url, - photoId=photoId, - updateTime=int(user.get("updatetime")), - firstName=user.get("firstname"), - lastName=user.get("lastname"), - options=json.loads(user.get("options")), - description=description, - accountStatus=int(user.get("accountstatus")), - profileOptions=json.loads(user.get("profileoptions")), - includeProfileOptions=True, - username=user.get("username") - ) - - chats = await self.tools.generate_chats( - json.loads(user_data.get("chats")), - self.db_pool, user.get("id") - ) - - # Формируем данные пакета - payload = { - "profile": profile, - "chats": chats, - "chatMarker": 0, - "messages": {}, - "contacts": [], - "presence": {}, - "config": { - "server": self.server_config, - "user": json.loads(user_data.get("user_config")) - }, - "token": token, - "videoChatHistory": False, - "time": int(time.time() * 1000) - } - - # Собираем пакет - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.LOGIN, payload=payload - ) - - # Отправляем - await self._send(writer, packet) - return int(user.get("phone")), int(user.get("id")), hashed_token - - async def process_logout(self, seq, writer, hashedToken): - """Обработчик завершения сессии""" - # Удаляем токен из бд - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - await cursor.execute("DELETE FROM tokens WHERE token_hash = %s", (hashedToken,)) - - # Создаем пакет - response = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.LOGOUT, payload=None - ) - - # Отправляем - await self._send(writer, response) - - async def process_get_assets(self, payload, seq, writer): - """Обработчик запроса ассетов клиента на сервере""" - # Валидируем данные пакета - try: - AssetsPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.ASSETS_UPDATE, self.error_types.INVALID_PAYLOAD, writer) - return - - # TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию - - # Данные пакета - payload = { - "sections": [], - "sync": int(time.time() * 1000) - } - - # Собираем пакет - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.ASSETS_UPDATE, payload=payload - ) - - # Отправляем - await self._send(writer, packet) - - async def process_get_call_history(self, payload, seq, writer): - """Обработчик получения истории звонков""" - # Валидируем данные пакета - try: - GetCallHistoryPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.VIDEO_CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer) - return - - # TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию - - # Данные пакета - payload = { - "hasMore": False, - "history": [], - "backwardMarker": 0, - "forwardMarker": 0 - } - - # Собираем пакет - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.VIDEO_CHAT_HISTORY, payload=payload - ) - - # Отправляем - await self._send(writer, packet) - - async def process_send_message(self, payload, seq, writer, senderId, db_pool): - """Функция отправки сообщения""" - # Валидируем данные пакета - try: - SendMessagePayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.MSG_SEND, self.error_types.INVALID_PAYLOAD, writer) - return - - # Извлекаем данные из пакета - userId = payload.get("userId") - chatId = payload.get("chatId") - message = payload.get("message") - - elements = message.get("elements") or [] - attaches = message.get("attaches") or [] - cid = message.get("cid") or 0 - text = message.get("text") or "" - - # Если клиент вообще ничего не указал в пакете, то выбрасываем ошибку - if not all([userId, chatId, elements, attaches, cid, text]): - await self._send_error(seq, self.proto.MSG_SEND, self.error_types.INVALID_PAYLOAD, writer) - return - - # Время отправки сообщения - messageTime = int(time.time() * 1000) - - # Вычисляем ID чата по ID пользователя и ID отправителя, - # в случае отсутствия ID чата - if not chatId: - chatId = userId ^ senderId - - # Если клиент хочет отправить сообщение в избранное, - # то выставляем в качестве ID чата ID отправителя - # (А ещё используем это, если клиент вообще ничего не указал) - if chatId == 0 or not chatId: - chatId = senderId - participants = [senderId] - else: - # Если все таки клиент хочет отправить сообщение в нормальный чат, - # то ищем его в базе данных (извлекать список участников все таки тоже надо) - async with db_pool.acquire() as db_connection: - async with db_connection.cursor() as cursor: - await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,)) - chat = await cursor.fetchone() - - # Если нет такого чата - выбрасываем ошибку - if not chat: - await self._send_error(seq, self.proto.MSG_SEND, self.error_types.CHAT_NOT_FOUND, writer) - return - - # Список участников - participants = json.loads(chat.get("participants")) - - # Проверяем, является ли отправитель участником чата - if int(senderId) not in participants: - await self._send_error(seq, self.proto.MSG_SEND, self.error_types.CHAT_NOT_ACCESS, writer) - return - - # Добавляем сообщение в историю - messageId, lastMessageId = await self.tools.insert_message( - chatId=chatId, - senderId=senderId, - text=text, - attaches=attaches, - elements=elements, - cid=cid, - type="USER", - db_pool=self.db_pool - ) - - # Готовое тело сообщения - bodyMessage = { - "id": messageId, - "time": messageTime, - "type": "USER", - "sender": senderId, - "cid": cid, - "text": text, - "attaches": attaches, - "elements": elements - } - - # Отправляем событие всем участникам чата - for participant in participants: - await self.event( - participant, - { - "eventType": "new_msg", - "chatId": 0 if chatId == senderId else chatId, - "message": bodyMessage, - "prevMessageId": lastMessageId, - "time": messageTime - } - ) - - # Данные пакета - payload = { - "chatId": 0 if chatId == senderId else chatId, - "message": bodyMessage, - "unread": 0, - "mark": messageTime - } - - # Собираем пакет - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.MSG_SEND, payload=payload - ) - - # Отправляем - await self._send(writer, packet) - - async def process_get_folders(self, payload, seq, writer, senderPhone): - """Синхронизация папок с сервером""" - # Валидируем данные пакета - try: - SyncFoldersPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.FOLDERS_GET, self.error_types.INVALID_PAYLOAD, writer) - return - - # Ищем папки в бд - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - await cursor.execute("SELECT folders FROM user_data WHERE phone = %s", (int(senderPhone),)) - result_folders = await cursor.fetchone() - user_folders = json.loads(result_folders.get("folders")) - - # Создаем данные пакета - payload = { - "folderSync": int(time.time() * 1000), - "folders": self.static.ALL_CHAT_FOLDER + user_folders.get("folders"), - "foldersOrder": self.static.ALL_CHAT_FOLDER_ORDER + user_folders.get("foldersOrder"), - "allFilterExcludeFolders": user_folders.get("allFilterExcludeFolders") - } - - # Собираем пакет - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.FOLDERS_GET, payload=payload - ) - - # Отправляем - await self._send(writer, packet) - - async def process_get_sessions(self, payload, seq, writer, senderPhone, hashedToken): - """Получение активных сессий на аккаунте""" - # Готовый список сессий - sessions = [] - - # Ищем сессии в бд - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - await cursor.execute("SELECT * FROM tokens WHERE phone = %s", (str(senderPhone),)) - user_sessions = await cursor.fetchall() - - # Собираем сессии в список - for session in user_sessions: - sessions.append( - { - "time": int(session.get("time")), - "client": f"MAX {session.get('device_type')}", - "info": session.get("device_name"), - "location": session.get("location"), - "current": True if session.get("token_hash") == hashedToken else False - } - ) - - # Создаем данные пакета - payload = { - "sessions": sessions - } - - # Создаем пакет - response = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.SESSIONS_INFO, payload=payload - ) - - # Отправляем - await self._send(writer, response) - - async def process_search_users(self, payload, seq, writer): - """Поиск пользователей по ID""" - # Валидируем данные пакета - try: - SearchUsersPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.CONTACT_INFO, self.error_types.INVALID_PAYLOAD, writer) - return - - # Итоговый список пользователей - users = [] - - # ID пользователей, которые нам предстоит найти - contactIds = payload.get("contactIds") - - # Ищем пользователей в бд - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - for contactId in contactIds: - await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,)) - user = await cursor.fetchone() - - # Если такой пользователь есть, добавляем его в список - if user: - # Аватарка с биографией - photoId = None if not user.get("avatar_id") else int(user.get("avatar_id")) - avatar_url = None if not photoId else self.config.avatar_base_url + photoId - description = None if not user.get("description") else user.get("description") - - # Генерируем профиль - users.append( - self.tools.generate_profile( - id=user.get("id"), - phone=int(user.get("phone")), - avatarUrl=avatar_url, - photoId=photoId, - updateTime=int(user.get("updatetime")), - firstName=user.get("firstname"), - lastName=user.get("lastname"), - options=json.loads(user.get("options")), - description=description, - accountStatus=int(user.get("accountstatus")), - profileOptions=json.loads(user.get("profileoptions")), - includeProfileOptions=False, - username=user.get("username") - ) - ) - - # Создаем данные пакета - payload = { - "contacts": users - } - - # Создаем пакет - response = self.proto.pack_packet( - seq=seq, opcode=self.proto.CONTACT_INFO, payload=payload - ) - - # Отправляем - await self._send(writer, response) - - async def process_search_chats(self, payload, seq, writer, senderId): - """Поиск чатов по ID""" - # Валидируем данные пакета - try: - SearchChatsPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.CHAT_INFO, self.error_types.INVALID_PAYLOAD, writer) - return - - # Итоговый список чатов - chats = [] - - # ID чатов, которые нам предстоит найти - chatIds = payload.get("chatIds") - - # Ищем чаты в бд - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - for chatId in chatIds: - if chatId != 0: - await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,)) - chat = await cursor.fetchone() - - if chat: - # Проверяем, является ли пользователь участником чата - - # (в max нельзя смотреть и отправлять сообщения в чат, в котором ты не участник, в отличие от tg (например, комментарии в каналах), - # так что надо тоже так делать) - if senderId not in json.loads(chat.get("participants")): - continue - - # Получаем последнее сообщение из чата - message, messageTime = await self.tools.get_last_message( - chatId, self.db_pool - ) - - # Добавляем чат в список - chats.append( - self.tools.generate_chat( - chatId, chat.get("owner"), - chat.get("type"), json.loads(chat.get("participants")), - message, messageTime - ) - ) - else: - # Получаем последнее сообщение из чата - message, messageTime = await self.tools.get_last_message( - senderId, self.db_pool - ) - - # ID избранного - chatId = senderId ^ senderId - - # Добавляем чат в список - chats.append( - self.tools.generate_chat( - chatId, senderId, - "DIALOG", [senderId], - message, messageTime - ) - ) - - # Создаем данные пакета - payload = { - "chats": chats - } - - # Собираем пакет - response = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.CHAT_INFO, payload=payload - ) - - # Отправляем - await self._send(writer, response) - - async def process_search_by_phone(self, payload, seq, writer, senderId): - """Поиск по номеру телефона""" - # Валидируем данные пакета - try: - SearchByPhonePayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.CONTACT_INFO_BY_PHONE, self.error_types.INVALID_PAYLOAD, writer) - return - - # Ищем пользователя в бд - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - await cursor.execute("SELECT * FROM users WHERE phone = %s", (int(payload.get("phone")),)) - user = await cursor.fetchone() - - # Если пользователь не найден, отправляем ошибку - if not user: - await self._send_error(seq, self.proto.CONTACT_INFO_BY_PHONE, self.error_types.USER_NOT_FOUND, writer) - return - - # ID чата - chatId = senderId ^ user.get("id") - - # Ищем диалог в бд - await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,)) - chat = await cursor.fetchone() - - # Если диалога нет - создаем - if not chat: - await cursor.execute( - "INSERT INTO chats (id, owner, type, participants) VALUES (%s, %s, %s, %s)", - (chatId, senderId, "DIALOG", json.dumps([int(senderId), int(user.get("id"))])) - ) - - # Аватарка с биографией - photoId = None if not user.get("avatar_id") else int(user.get("avatar_id")) - avatar_url = None if not photoId else self.config.avatar_base_url + photoId - description = None if not user.get("description") else user.get("description") - - # Генерируем профиль - profile = self.tools.generate_profile( - id=user.get("id"), - phone=int(user.get("phone")), - avatarUrl=avatar_url, - photoId=photoId, - updateTime=int(user.get("updatetime")), - firstName=user.get("firstname"), - lastName=user.get("lastname"), - options=json.loads(user.get("options")), - description=description, - accountStatus=int(user.get("accountstatus")), - profileOptions=json.loads(user.get("profileoptions")), - includeProfileOptions=False, - username=user.get("username") - ) - - # Создаем данные пакета - payload = { - "contact": profile - } - - # Создаем пакет - response = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.CONTACT_INFO_BY_PHONE, payload=payload - ) - - # Отправляем - await self._send(writer, response) - - async def process_get_call_token(self, payload, seq, writer): - """Получение токена для звонка""" - # Валидируем данные пакета - try: - GetCallTokenPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.OK_TOKEN, self.error_types.INVALID_PAYLOAD, writer) - return - - # TODO: когда-то взяться за звонки - - await self._send_error(seq, self.proto.OK_TOKEN, self.error_types.NOT_IMPLEMENTED, writer) - - async def process_typing(self, payload, seq, writer, senderId): - """Обработчик события печатания""" - # Валидируем данные пакета - try: - TypingPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.MSG_TYPING, self.error_types.INVALID_PAYLOAD, writer) - return - - # Извлекаем данные из пакета - chatId = payload.get("chatId") - type = payload.get("type") or "TYPING" - - # Ищем чат в базе данных - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,)) - chat = await cursor.fetchone() - - # Если чат не найден, отправляем ошибку - if not chat: - await self._send_error(seq, self.proto.MSG_TYPING, self.error_types.CHAT_NOT_FOUND, writer) - return - - # Участники чата - participants = json.loads(chat.get("participants")) - - # Проверяем, является ли отправитель участником чата - if int(senderId) not in participants: - await self._send_error(seq, self.proto.MSG_TYPING, self.error_types.CHAT_NOT_ACCESS, writer) - return - - # Рассылаем событие участникам чата - for participant in participants: - if participant != senderId: - # Если участник не является отправителем, отправляем - await self.event( - participant, - { - "eventType": "typing", - "chatId": chatId, - "type": type, - "userId": senderId - } - ) - - # Создаем пакет - packet = self.proto.pack_packet( - seq=seq, opcode=self.proto.MSG_TYPING - ) - - # Отправляем пакет - await self._send(writer, packet) - - async def process_complain_reasons_get(self, payload, seq, writer): - """Обработчик получения причин жалоб""" - # Валидируем данные пакета - try: - ComplainReasonsGetPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.COMPLAIN_REASONS_GET, self.error_types.INVALID_PAYLOAD, writer) - return - - # Собираем данные пакета - payload = { - "complains": self.static.COMPLAIN_REASONS, - "complainSync": int(time.time()) - } - - # Создаем пакет - packet = self.proto.pack_packet( - seq=seq, opcode=self.proto.COMPLAIN_REASONS_GET, payload=payload - ) - - # Отправляем пакет - await self._send(writer, packet) \ No newline at end of file diff --git a/src/oneme_tcp/proto.py b/src/oneme_tcp/proto.py deleted file mode 100644 index 0c085ba..0000000 --- a/src/oneme_tcp/proto.py +++ /dev/null @@ -1,251 +0,0 @@ -import lz4.block, msgpack, logging, json - -class Proto: - def __init__(self) -> None: - self.logger = logging.getLogger(__name__) - - # TODO узнать какие должны быть лимиты и поменять, - # сейчас это больше заглушка - MAX_PAYLOAD_SIZE = 1048576 # 1 MB - MAX_DECOMPRESSED_SIZE = 1048576 # 1 MB - HEADER_SIZE = 10 # 1+2+1+2+4 - - ### Работа с протоколом - def unpack_packet(self, data: bytes) -> dict | None: - # Проверяем минимальный размер пакета - if len(data) < self.HEADER_SIZE: - self.logger.warning(f"Пакет слишком маленький: {len(data)} байт") - return None - - # Распаковываем заголовок - ver = int.from_bytes(data[0:1], "big") - cmd = int.from_bytes(data[1:3], "big") - seq = int.from_bytes(data[3:4], "big") - opcode = int.from_bytes(data[4:6], "big") - packed_len = int.from_bytes(data[6:10], "big") - - # Флаг упаковки - comp_flag = packed_len >> 24 - - # Парсим данные пакета - payload_length = packed_len & 0xFFFFFF - - # Проверяем размер payload - if payload_length > self.MAX_PAYLOAD_SIZE: - self.logger.warning(f"Payload слишком большой: {payload_length} B (лимит {self.MAX_PAYLOAD_SIZE})") - return None - - # Проверяем длину пакета - if len(data) < self.HEADER_SIZE + payload_length: - self.logger.warning(f"Пакет неполный: требуется {self.HEADER_SIZE + payload_length} B, получено {len(data)}") - return None - - payload_bytes = data[10 : 10 + payload_length] - payload = None - - # Декодируем данные пакета - if payload_bytes: - # Разжимаем данные пакета, если требуется - if comp_flag != 0: - compressed_data = payload_bytes - try: - payload_bytes = lz4.block.decompress( - compressed_data, - uncompressed_size=self.MAX_DECOMPRESSED_SIZE, - ) - except lz4.block.LZ4BlockError: - self.logger.warning("Ошибка декомпрессии LZ4") - return None - - # Распаковываем msgpack - payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False) - - self.logger.debug(f"Распаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload}") - - # Возвращаем - return { - "ver": ver, - "cmd": cmd, - "seq": seq, - "opcode": opcode, - "payload": payload, - } - - def pack_packet(self, ver: int = 10, cmd: int = 1, seq: int = 1, opcode: int = 6, payload: dict = None) -> bytes: - # Запаковываем заголовок - ver_b = ver.to_bytes(1, "big") - cmd_b = cmd.to_bytes(2, "big") - seq_b = seq.to_bytes(1, "big") - opcode_b = opcode.to_bytes(2, "big") - - # Запаковываем данные пакета - payload_bytes: bytes | None = msgpack.packb(payload) - if payload_bytes is None: - payload_bytes = b"" - payload_len = len(payload_bytes) & 0xFFFFFF - payload_len_b = payload_len.to_bytes(4, 'big') - - self.logger.debug(f"Упаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload}") - - # Возвращаем пакет - return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + payload_bytes - - ### Констаты протокола - CMD_OK = 0x100 - CMD_NOF = 0x200 - CMD_ERR = 0x300 - PROTO_VER = 10 - - ### Команды - PING = 1 - DEBUG = 2 - RECONNECT = 3 - LOG = 5 - SESSION_INIT = 6 - PROFILE = 16 - AUTH_REQUEST = 17 - AUTH = 18 - LOGIN = 19 - LOGOUT = 20 - SYNC = 21 - CONFIG = 22 - AUTH_CONFIRM = 23 - AUTH_CREATE_TRACK = 112 - AUTH_CHECK_PASSWORD = 113 - AUTH_LOGIN_CHECK_PASSWORD = 115 - AUTH_LOGIN_PROFILE_DELETE = 116 - AUTH_LOGIN_RESTORE_PASSWORD = 101 - AUTH_VALIDATE_PASSWORD = 107 - AUTH_VALIDATE_HINT = 108 - AUTH_VERIFY_EMAIL = 109 - AUTH_CHECK_EMAIL = 110 - AUTH_SET_2FA = 111 - AUTH_2FA_DETAILS = 104 - ASSETS_GET = 26 - ASSETS_UPDATE = 27 - ASSETS_GET_BY_IDS = 28 - ASSETS_LIST_MODIFY = 261 - ASSETS_REMOVE = 259 - ASSETS_MOVE = 260 - ASSETS_ADD = 29 - PRESET_AVATARS = 25 - CONTACT_INFO = 32 - CONTACT_INFO_BY_PHONE = 46 - CONTACT_ADD = 33 - CONTACT_UPDATE = 34 - CONTACT_PRESENCE = 35 - CONTACT_LIST = 36 - CONTACT_SEARCH = 37 - CONTACT_MUTUAL = 38 - CONTACT_PHOTOS = 39 - CONTACT_SORT = 40 - CONTACT_VERIFY = 42 - REMOVE_CONTACT_PHOTO = 43 - CHAT_INFO = 48 - CHAT_HISTORY = 49 - CHAT_MARK = 50 - CHAT_MEDIA = 51 - CHAT_DELETE = 52 - CHATS_LIST = 53 - CHAT_CLEAR = 54 - CHAT_UPDATE = 55 - CHAT_CHECK_LINK = 56 - CHAT_JOIN = 57 - CHAT_LEAVE = 58 - CHAT_MEMBERS = 59 - PUBLIC_SEARCH = 60 - CHAT_PERSONAL_CONFIG = 61 - CHAT_CREATE = 63 - REACTIONS_SETTINGS_GET_BY_CHAT_ID = 258 - CHAT_REACTIONS_SETTINGS_SET = 257 - MSG_SEND = 64 - MSG_TYPING = 65 - MSG_DELETE = 66 - MSG_EDIT = 67 - MSG_DELETE_RANGE = 92 - MSG_REACTION = 178 - MSG_CANCEL_REACTION = 179 - MSG_GET_REACTIONS = 180 - MSG_GET_DETAILED_REACTIONS = 181 - CHAT_SEARCH = 68 - MSG_SHARE_PREVIEW = 70 - MSG_GET = 71 - MSG_SEARCH_TOUCH = 72 - MSG_SEARCH = 73 - MSG_GET_STAT = 74 - CHAT_SUBSCRIBE = 75 - VIDEO_CHAT_START = 76 - VIDEO_CHAT_START_ACTIVE = 78 - CHAT_MEMBERS_UPDATE = 77 - VIDEO_CHAT_HISTORY = 79 - PHOTO_UPLOAD = 80 - STICKER_UPLOAD = 81 - VIDEO_UPLOAD = 82 - VIDEO_PLAY = 83 - VIDEO_CHAT_CREATE_JOIN_LINK = 84 - CHAT_PIN_SET_VISIBILITY = 86 - FILE_UPLOAD = 87 - FILE_DOWNLOAD = 88 - LINK_INFO = 89 - SESSIONS_INFO = 96 - SESSIONS_CLOSE = 97 - PHONE_BIND_REQUEST = 98 - PHONE_BIND_CONFIRM = 99 - GET_INBOUND_CALLS = 103 - EXTERNAL_CALLBACK = 105 - OK_TOKEN = 158 - CHAT_COMPLAIN = 117 - MSG_SEND_CALLBACK = 118 - SUSPEND_BOT = 119 - LOCATION_STOP = 124 - GET_LAST_MENTIONS = 127 - STICKER_CREATE = 193 - STICKER_SUGGEST = 194 - VIDEO_CHAT_MEMBERS = 195 - NOTIF_MESSAGE = 128 - NOTIF_TYPING = 129 - NOTIF_MARK = 130 - NOTIF_CONTACT = 131 - NOTIF_PRESENCE = 132 - NOTIF_CONFIG = 134 - NOTIF_CHAT = 135 - NOTIF_ATTACH = 136 - NOTIF_CALL_START = 137 - NOTIF_CONTACT_SORT = 139 - NOTIF_MSG_DELETE_RANGE = 140 - NOTIF_MSG_DELETE = 142 - NOTIF_MSG_REACTIONS_CHANGED = 155 - NOTIF_MSG_YOU_REACTED = 156 - NOTIF_CALLBACK_ANSWER = 143 - CHAT_BOT_COMMANDS = 144 - BOT_INFO = 145 - NOTIF_LOCATION = 147 - NOTIF_LOCATION_REQUEST = 148 - NOTIF_ASSETS_UPDATE = 150 - NOTIF_DRAFT = 152 - NOTIF_DRAFT_DISCARD = 153 - DRAFT_SAVE = 176 - DRAFT_DISCARD = 177 - CHAT_HIDE = 196 - CHAT_SEARCH_COMMON_PARTICIPANTS = 198 - NOTIF_MSG_DELAYED = 154 - NOTIF_PROFILE = 159 - PROFILE_DELETE = 199 - PROFILE_DELETE_TIME = 200 - WEB_APP_INIT_DATA = 160 - COMPLAIN = 161 - COMPLAIN_REASONS_GET = 162 - FOLDERS_GET = 272 - FOLDERS_GET_BY_ID = 273 - FOLDERS_UPDATE = 274 - FOLDERS_REORDER = 275 - FOLDERS_DELETE = 276 - NOTIF_FOLDERS = 277 - - AUTH_QR_APPROVE = 290 - NOTIF_BANNERS = 292 - CHAT_SUGGEST = 300 - AUDIO_PLAY = 301 - SEND_VOTE = 304 - VOTERS_LIST_BY_ANSWER = 305 - GET_POLL_UPDATES = 306 \ No newline at end of file diff --git a/src/tamtam_ws/__init__.py b/src/tamtam/__init__.py similarity index 100% rename from src/tamtam_ws/__init__.py rename to src/tamtam/__init__.py diff --git a/src/tamtam/config.py b/src/tamtam/config.py new file mode 100644 index 0000000..9e4efde --- /dev/null +++ b/src/tamtam/config.py @@ -0,0 +1,572 @@ +class TTConfig: + def __init__(self): + pass + + SERVER_CONFIG = { + "a-2g": 8, + "a-3g": 24, + "a-constraints": { + "googNoiseSuppression": "true", + "googHighpassFilter": "false", + "googTypingNoiseDetection": "false", + "googAudioNetworkAdaptorConfig": "ChyyARkNCtcjPBUK1yM8GKjDASCw6gEomHUwoJwBCgfKAQQIABAACgvCAQgIqMMBELiRAgosqgEpChEIuBcVzcxMPhjogQIlCtejOxIRCOgHFc3MTD4YsOoBJQrXozsYyAEKC7oBCAiw6gEQoJwB" + }, + "a-lte": 24, + "a-wifi": 34, + "account-removal-enabled": False, + "animated-emojis": { + "❤️": { + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/03.json" + }, + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/04.json" + } + }, + "👍": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-29lottie/e/16.json" + }, + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/01_m.json" + } + }, + "👎": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/17.json" + }, + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/02.json" + } + }, + "🙏": { + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/04.json" + }, + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-29lottie/e/30_ng.json" + } + }, + "😘": { + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/05.json" + }, + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/03.json" + } + }, + "🔥": { + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/06.json" + }, + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/10.json" + } + }, + "😂": { + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/07.json" + } + }, + "👏": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/56.json" + } + }, + "😮": { + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/09.json" + } + }, + "💋": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/13_v02.json" + }, + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2023-01-18lottie/r/kissing2.json" + } + }, + "🥂": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/20.json" + }, + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/20.json" + } + }, + "😳": { + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/09.json" + }, + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/02.json" + } + }, + "😔": { + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/11.json" + }, + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/05.json" + } + }, + "😍": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/07.json" + } + }, + "😯": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/08.json" + } + }, + "😉": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/09.json" + } + }, + "🌺": { + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2023-03-06lottie/flower.json" + }, + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/11.json" + } + }, + "🎂": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/14.json" + } + }, + "💩": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/15.json" + }, + "reactionAction": { + "url": "https://st.okcdn.ru/static/messages/2023-01-18lottie/r/shit_1.json" + } + }, + "🐰": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/19.json" + } + }, + "🎅": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/21.json" + } + }, + "🎄": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/23.json" + } + }, + "🎆": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/22.json" + } + }, + "❄️": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/25.json" + } + }, + "🎉": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/12.json" + } + }, + "🥗": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2022-12-29lottie/e/28.json" + } + }, + "🧡": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/31.json" + } + }, + "💔": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/32.json" + } + }, + "🎁": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/34.json" + } + }, + "🌹": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/35.json" + } + }, + "🌸": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/36.json" + } + }, + "🍒": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/37.json" + } + }, + "🥕": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/39.json" + } + }, + "🍑": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/40.json" + } + }, + "🍋": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/41.json" + } + }, + "🍃": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/42.json" + } + }, + "😺": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/43.json" + } + }, + "🐶": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/44.json" + } + }, + "🐽": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/45.json" + } + }, + "💐": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/46.json" + } + }, + "🎈": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/47.json" + } + }, + "🍾": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/48.json" + } + }, + "⚡": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/49.json" + } + }, + "⭐": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/50.json" + } + }, + "✨": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/51.json" + } + }, + "💃": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/52.json" + } + }, + "☀️": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/53.json" + } + }, + "👋": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/54.json" + } + }, + "☕": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/57.json" + } + }, + "🙂": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/58.json" + } + }, + "🤩": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-15animoji/59.json" + } + }, + "😇": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/60.json" + } + }, + "😎": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/61.json" + } + }, + "🍎": { + "emoji": { + "url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/62.json" + } + } + }, + "animated-emojis-limits": { + "low": 5, + "average": 10, + "high": 15 + }, + "animated-emojis-places": [ + "MESSAGE_NORMAL_TEXT", + "MESSAGE_BIG_EMOJI_TEXT", + "MESSAGE_INPUT", + "STICKERS_KEYBOARD", + "CHATS_LIST" + ], + "attachment-popup-click": False, + "audio-transcription-enabled": True, + "audio-transcription-locales": [ + "ru" + ], + "change-self-readmark-on-msg-send": False, + "chat-bg": False, + "chat-cancel-top-menu-enabled": False, + "chat-filter": False, + "chat-info-full-list-enabled": False, + "chat-preview-enabled": False, + "chats-edit-enabled": False, + "chats-folder-enabled": True, + "chats-list-promo-link-enabled": False, + "chats-page-size": 50, + "chats-preload-period": 15, + "close-keyboard-on-scroll": True, + "common-chats-enabled": True, + "compact-mode-enabled": False, + "congrats-banner-enabled-mob": False, + "congrats-banner-enabled-web": True, + "contact-verify-enabled": False, + "contacts-send": False, + "contacts-sort-refresh": 259200, + "copy-id-btn": False, + "debug-mode": 1, + "delayed-messages": False, + "delayed-messages-enabled": False, + "detect-share-when-send-message": True, + "dialog-priority": False, + "disconnect-timeout": 300, + "drafts-sync-enabled": True, + "edit-timeout": 86400, + "expandable-appbar-enabled": True, + "experimental": False, + "fast-chat-actions-enabled": False, + "file-upload-enabled": True, + "file-upload-max-size": 2147483648, + "file-upload-unsupported-types": [ + "exe" + ], + "gce": True, + "hashtags-enabled": True, + "html-paste": False, + "http-logs-enabled": False, + "image-height": 1680, + "image-quality": 0.800000011920929, + "image-size": 40000000, + "image-width": 1680, + "invite-header": "Приглашение в ТамТам", + "invite-link": "https://tt.me/starwear", + "invite-long": "Я общаюсь в ТамТам, присоединяйся https://tt.me/starwear", + "invite-short": "Привет! Ставь ТамТам! Жду ответа! https://tt.me/starwear", + "keep-connection": 2, + "l10n": False, + "live-location-enabled": True, + "location-enabled": True, + "logs-enabled": True, + "markdown-enabled": True, + "markdown-menu": 0, + "markdown-miui-enabled": True, + "max-audio-length": 3600, + "max-cname-length": 200, + "max-description-length": 400, + "max-favorite-chats": 5, + "max-favorite-sticker-sets": 100, + "max-favorite-stickers": 100, + "max-msg-length": 4000, + "max-participants": 20000, + "max-readmarks": 300, + "max-theme-length": 200, + "max-video-duration-download": 1200, + "mediabar-scroll-layout": True, + "mentions-enabled": True, + "mentions_entity_names_limit": 3, + "min-image-side-size": 64, + "moderated-groups": False, + "moderated-groups-filter": False, + "multiselect": True, + "music-files-enabled": False, + "muted-chat-call-enabled": False, + "nearby-timeouts": { + "enabled": "True", + "LaunchBroadcastTime": "0", + "LaunchBroadcastTimeLimit": "300", + "ContactsScreenBroadcastTime": "10", + "ChatSearchScreenBroadcastTime": "10", + "RecentContactTime": "300" + }, + "new-chats-searching": True, + "new-chats-ui": True, + "new-fcm-push": True, + "notif-images": True, + "offline-icon": False, + "often-chats": False, + "ok-avatar-icon": False, + "ok-profile-unbind-enabled": True, + "ok-tt-chat-separation": True, + "one-chat-enabled": False, + "one-chat-new-panel-sticker": False, + "peer-connection-params": { + "ebv": [ + "xiaomi", + "huawei" + ], + "ebm": [ + "oneplus a5010" + ] + }, + "phone-bind-enabled": True, + "phone-layer-enabled": True, + "play-background-listen-to-end": False, + "play-next-audio": False, + "plus-menu-enabled": False, + "prefs": 1, + "profile-autodelete-enabled": True, + "profiling-enabled": False, + "progress-diff-for-notify": 1, + "promo-contact-id": 0, + "promo-recent-contacts": True, + "promo_contact_label": "Белый Маг", + "proxy": "msgproxy.okcdn.ru", + "proxy-domains": [ + "okcdn.ru", + "mycdn.me", + "ok.ru", + "odnoklassniki.ru", + "odkl.ru", + "vk.com", + "userapi.com", + "vkuser.net", + "vkusercdn.ru" + ], + "proxy-exclude": [ + "r.mradx.net", + "ad.mail.ru" + ], + "proxy-rotation": True, + "push-alert-timeout": 604800, + "push-tracking-enabled": True, + "quick-forward-cases": [], + "react-permission": 2, + "reactions-enabled": True, + "reactions-max": 3, + "reactions-menu": [ + "👍", + "❤️", + "💩", + "😂", + "🔥", + "🙏", + "👎", + "😮" + ], + "readmark-enabled-delay-ms": 400, + "remove-profile-enable": True, + "remove-user-msg-del": True, + "retry-sig-count": 5, + "retry-sig-delay": 4, + "screen-share-enabled": False, + "screen-sharing-enabled": True, + "send-media-from-system-keyboard": False, + "send-side-bwe": True, + "send-system-keyboard-png-as-stickers": True, + "set-unread-timeout": 31536000, + "settings-use": False, + "show-invited-by": True, + "show-rm-limits": [ + 15, + 100 + ], + "show-snow": True, + "sticker-gif-enabled": True, + "sticker-sections": [ + "TOP", + "NEW" + ], + "sticker-sets-links-enabled": True, + "sticker-suggest-disabled": False, + "stickers-suggestion": [ + "RECENT", + "NEW", + "TOP" + ], + "stickers-suggestion-keywords-inline": False, + "support-account": "tt.me/support", + "support-button-enable": False, + "t-ice-reconnect": 15, + "t-incoming-call": 40, + "t-start-connect": 20, + "tam-emoji-font-url": "https://st.okcdn.ru/static/messages/2022-08-25noto/TamNotoColorEmojiCompat.ttf", + "tcp-candidates": False, + "tracer-crash-report-enabled": True, + "tracer-crash-report-host": "https://api-hprof.odkl.ru", + "tracer-crash-send-asap-enabled": True, + "tracer-crash-send-logs-enabled": True, + "tracer-crash-send-threads-dump-enabled": True, + "tracer-disk-overflow-report-threshold": 3000000000, + "tracer-disk-usage-probability": 500, + "tracer-enabled": True, + "tracer-host": "https://api-hprof.odkl.ru", + "tracer-hprof-probability": -1, + "tracer-sampled-conditions": "tag=app_start_ui_freeze_2k;probability=100000;startEvent=app_first_activity_created;interestingEvent=app_freeze;interestingDuration=2000", + "tracer-sampled-duration": 20000, + "tracer-systrace-duration": 20000, + "tracer-systrace-interesting-duration": 10000, + "tracer-systrace-probability": 10000, + "unknown-person-attention": True, + "unread-filter-enabled": False, + "update-non-contacts": 10, + "use-congrats-list-in-mass-sending": False, + "use-new-message-rendering": True, + "v-2g": 128, + "v-3g": 1024, + "v-fps": 24, + "v-fps-v8": 20, + "v-height": 720, + "v-height-vp8": 480, + "v-lte": 1024, + "v-vp8": 512, + "v-width": 1280, + "v-width-vp8": 640, + "v-wifi": 2048, + "vce": True, + "video-attach-download-enabled": False, + "video-auto-compress-enabled": True, + "video-messages": False, + "video-params": True, + "video-preview": "480x270", + "wakelock-on-push": False, + "wm-analytics-enabled": True, + "wm-workers-limit": 80, + "iceServers": [], + "has-phone": True, + "promo-constructors": [] + } \ No newline at end of file diff --git a/src/tamtam_tcp/controller.py b/src/tamtam/controller.py similarity index 56% rename from src/tamtam_tcp/controller.py rename to src/tamtam/controller.py index 7555f6b..f4cedab 100644 --- a/src/tamtam_tcp/controller.py +++ b/src/tamtam/controller.py @@ -1,22 +1,31 @@ import asyncio -from tamtam_tcp.server import TTMobileServer +from tamtam.socket import TamTamMobile +from tamtam.websocket import TamTamWS from classes.controllerbase import ControllerBase from common.config import ServerConfig -class TTMobileController(ControllerBase): +class TTController(ControllerBase): def __init__(self): self.config = ServerConfig() def launch(self, api): async def _start_all(): await asyncio.gather( - TTMobileServer( + TamTamMobile( host=self.config.host, port=self.config.tamtam_tcp_port, ssl_context=api['ssl'], db_pool=api['db'], clients=api['clients'], send_event=api['event'] + ).start(), + TamTamWS( + host=self.config.host, + port=self.config.tamtam_ws_port, + ssl_context=api['ssl'], + db_pool=api['db'], + clients=api['clients'], + send_event=api['event'] ).start() ) diff --git a/src/tamtam_tcp/models.py b/src/tamtam/models.py similarity index 62% rename from src/tamtam_tcp/models.py rename to src/tamtam/models.py index 9c58d64..63eefdf 100644 --- a/src/tamtam_tcp/models.py +++ b/src/tamtam/models.py @@ -6,7 +6,7 @@ class UserAgentModel(pydantic.BaseModel): osVersion: str timezone: str screen: str - pushDeviceType: str + pushDeviceType: str = None locale: str deviceName: str deviceLocale: str @@ -27,4 +27,18 @@ class FinalAuthPayloadModel(pydantic.BaseModel): deviceType: str tokenType: str deviceId: str - token: str \ No newline at end of file + token: str + +class LoginPayloadModel(pydantic.BaseModel): + interactive: bool + token: str + +class SearchUsersPayloadModel(pydantic.BaseModel): + contactIds: list + +class PingPayloadModel(pydantic.BaseModel): + interactive: bool + +class ChatHistoryPayloadModel(pydantic.BaseModel): + chatId: int + backward: int \ No newline at end of file diff --git a/src/tamtam/processors/__init__.py b/src/tamtam/processors/__init__.py new file mode 100644 index 0000000..de52007 --- /dev/null +++ b/src/tamtam/processors/__init__.py @@ -0,0 +1,10 @@ +from .main import MainProcessors +from .auth import AuthProcessors +from .search import SearchProcessors +from .history import HistoryProcessors + +class Processors(MainProcessors, + AuthProcessors, + SearchProcessors, + HistoryProcessors): + pass \ No newline at end of file diff --git a/src/tamtam/processors/auth.py b/src/tamtam/processors/auth.py new file mode 100644 index 0000000..f838000 --- /dev/null +++ b/src/tamtam/processors/auth.py @@ -0,0 +1,367 @@ +import hashlib +import secrets +import time +import json +import re +from classes.baseprocessor import BaseProcessor +from tamtam.models import ( + RequestCodePayloadModel, + VerifyCodePayloadModel, + FinalAuthPayloadModel, + LoginPayloadModel, +) +from tamtam.config import TTConfig + +class AuthProcessors(BaseProcessor): + def __init__(self, db_pool=None, clients=None, send_event=None, type="socket"): + super().__init__(db_pool, clients, send_event, type) + self.server_config = TTConfig().SERVER_CONFIG + + async def auth_request(self, payload, seq, writer): + """Обработчик запроса кода""" + # Валидируем данные пакета + try: + RequestCodePayloadModel.model_validate(payload) + except Exception as e: + await self._send_error(seq, self.opcodes.AUTH_REQUEST, + self.error_types.INVALID_PAYLOAD, writer) + return + + # Извлекаем телефон из пакета + phone = re.sub(r'\D', '', payload.get("phone", "")) + + # Генерируем токен с кодом + code = f"{secrets.randbelow(1_000_000):06d}" + token = secrets.token_urlsafe(128) + + # Хешируем + code_hash = hashlib.sha256(code.encode()).hexdigest() + token_hash = hashlib.sha256(token.encode()).hexdigest() + + # Срок жизни токена (5 минут) + expires = int(time.time()) + 300 + + # Ищем пользователя, и если он существует, сохраняем токен + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,)) + user = await cursor.fetchone() + + # Если пользователь существует, сохраняем токен + if user: + await cursor.execute( + "INSERT INTO auth_tokens (phone, token_hash, code_hash, expires, state) VALUES (%s, %s, %s, %s, %s)", + (phone, token_hash, code_hash, expires, "started") + ) + + # Данные пакета + payload = { + "verifyToken": token, + "retries": 5, + "codeDelay": 60, + "codeLength": 6, + "callDelay": 0, + "requestType": "SMS" + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH_REQUEST, payload=payload + ) + + # Отправляем + await self._send(writer, packet) + self.logger.debug(f"Код для {phone}: {code}") + + async def auth(self, payload, seq, writer): + """Обработчик проверки кода""" + # Валидируем данные пакета + try: + VerifyCodePayloadModel.model_validate(payload) + except Exception as e: + await self._send_error(seq, self.opcodes.AUTH, + self.error_types.INVALID_PAYLOAD, writer) + return + + # Извлекаем данные из пакета + code = payload.get("verifyCode") + token = payload.get("token") + + # Хешируем токен с кодом + hashed_code = hashlib.sha256(code.encode()).hexdigest() + hashed_token = hashlib.sha256(token.encode()).hexdigest() + + # Ищем токен с кодом + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + # Ищем токен + await cursor.execute( + "SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", + (hashed_token,) + ) + stored_token = await cursor.fetchone() + + if not stored_token: + await self._send_error(seq, self.opcodes.AUTH, + self.error_types.CODE_EXPIRED, writer) + return + + # Проверяем код + if stored_token.get("code_hash") != hashed_code: + await self._send_error(seq, self.opcodes.AUTH, + self.error_types.INVALID_CODE, writer) + return + + # Ищем аккаунт + await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),)) + account = await cursor.fetchone() + + # Обновляем состояние токена + await cursor.execute( + "UPDATE auth_tokens set state = %s WHERE token_hash = %s", + ("verified", hashed_token) + ) + + # Генерируем профиль + # Аватарка с биографией + photo_id = int(account["avatar_id"]) if account.get("avatar_id") else None + avatar_url = f"{self.config.avatar_base_url}{photo_id}" if photo_id else None + description = account.get("description") + + # Собираем данные пакета + payload = { + "profile": self.tools.generate_profile_tt( + id=account.get("id"), + phone=int(account.get("phone")), + avatarUrl=avatar_url, + photoId=photo_id, + updateTime=int(account.get("updatetime")), + firstName=account.get("firstname"), + lastName=account.get("lastname"), + options=json.loads(account.get("options")), + description=description, + username=account.get("username") + ), + "tokenAttrs": { + "AUTH": { + "token": token + } + }, + "tokenTypes": { + "AUTH": token + } + } + + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH, payload=payload + ) + + await self._send(writer, packet) + + async def auth_confirm(self, payload, seq, writer, deviceType, deviceName): + """Обработчик финальной аутентификации""" + # Валидируем данные пакета + try: + FinalAuthPayloadModel.model_validate(payload) + except Exception as e: + await self._send_error(seq, self.opcodes.AUTH_CONFIRM, + self.error_types.INVALID_PAYLOAD, writer) + return + + # Извлекаем данные из пакета + token = payload.get("token") + + if not deviceType: + deviceType = payload.get("deviceType") + + # Хешируем токен + hashed_token = hashlib.sha256(token.encode()).hexdigest() + + # Генерируем постоянный токен + login = secrets.token_urlsafe(128) + hashed_login = hashlib.sha256(login.encode()).hexdigest() + + # Ищем токен с кодом + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + # Ищем токен + await cursor.execute( + "SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", + (hashed_token,) + ) + stored_token = await cursor.fetchone() + + if stored_token is None: + await self._send_error(seq, self.opcodes.AUTH_CONFIRM, + self.error_types.INVALID_TOKEN, writer) + return + + # Если авторизация только началась - отдаем ошибку + if stored_token.get("state") == "started": + await self._send_error(seq, self.opcodes.AUTH_CONFIRM, + self.error_types.INVALID_TOKEN, writer) + return + + # Ищем аккаунт + await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),)) + account = await cursor.fetchone() + + # Удаляем токен + await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,)) + + # Создаем сессию + await cursor.execute( + "INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)", + (stored_token.get("phone"), hashed_login, deviceType, deviceName, + "Epstein Island", int(time.time())) + ) + + # Аватарка с биографией + photo_id = None if not account.get("avatar_id") else int(account.get("avatar_id")) + avatar_url = None if not photo_id else self.config.avatar_base_url + str(photo_id) + description = None if not account.get("description") else account.get("description") + + # Собираем данные пакета + payload = { + "userToken": "0", # Пока как заглушка + "profile": self.tools.generate_profile_tt( + id=account.get("id"), + phone=int(account.get("phone")), + avatarUrl=avatar_url, + photoId=photo_id, + updateTime=int(account.get("updatetime")), + firstName=account.get("firstname"), + lastName=account.get("lastname"), + options=json.loads(account.get("options")), + description=description, + username=account.get("username") + ), + "tokenType": "LOGIN", + "token": login + } + + # Создаем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH_CONFIRM, payload=payload + ) + + # Отправляем + await self._send(writer, packet) + + async def login(self, payload, seq, writer): + """Обработчик авторизации клиента на сервере""" + # Валидируем данные пакета + try: + LoginPayloadModel.model_validate(payload) + except Exception as e: + self.logger.error(f"Возникли ошибки при валидации пакета: {e}") + await self._send_error(seq, self.opcodes.LOGIN, + self.error_types.INVALID_PAYLOAD, writer) + return + + # Чаты, где состоит пользователь + chats = [] + + # Получаем данные из пакета + token = payload.get("token") + + # Хешируем токен + hashed_token = hashlib.sha256(token.encode()).hexdigest() + + # Ищем токен в бд + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT * FROM tokens WHERE token_hash = %s", (hashed_token,)) + token_data = await cursor.fetchone() + + # Если токен не найден, отправляем ошибку + if token_data is None: + await self._send_error(seq, self.opcodes.LOGIN, + self.error_types.INVALID_TOKEN, writer) + return + + # Ищем аккаунт пользователя в бд + await cursor.execute("SELECT * FROM users WHERE phone = %s", (token_data.get("phone"),)) + user = await cursor.fetchone() + + # Ищем данные пользователя в бд + await cursor.execute("SELECT * FROM user_data WHERE phone = %s", (token_data.get("phone"),)) + user_data = await cursor.fetchone() + + # Ищем все чаты, где состоит пользователь + await cursor.execute( + "SELECT * FROM chat_participants WHERE user_id = %s", + (user.get('id')) + ) + user_chats = await cursor.fetchall() + + for chat in user_chats: + chats.append( + chat.get("chat_id") + ) + + # Аватарка с биографией + photo_id = None if not user.get("avatar_id") else int(user.get("avatar_id")) + avatar_url = None if not photo_id else self.config.avatar_base_url + str(photo_id) + description = None if not user.get("description") else user.get("description") + + # Генерируем профиль + profile = self.tools.generate_profile_tt( + id=user.get("id"), + phone=int(user.get("phone")), + avatarUrl=avatar_url, + photoId=photo_id, + updateTime=int(user.get("updatetime")), + firstName=user.get("firstname"), + lastName=user.get("lastname"), + options=json.loads(user.get("options")), + description=description, + username=user.get("username") + ) + + chats = await self.tools.generate_chats( + chats, self.db_pool, user.get("id"), + include_favourites=False + ) + + # Формируем данные пакета + payload = { + "profile": profile, + "chats": chats, + "chatMarker": 0, + "messages": {}, + "contacts": [], + "presence": {}, + "config": { + "hash": "e5903aa8-0000000000000000-80000106-0000000000000001-00000001-0000000000000000-00000000-2-00000001-0000019c9559d057", + "server": self.server_config, + "user": json.loads(user_data.get("user_config")), + "chatFolders": { + "FOLDERS": [], + "ALL_FILTER_EXCLUDE": [] + } + }, + "token": token, + "calls": [], + "videoChatHistory": False, + "drafts": { + "chats": { + "discarded": {}, + "saved": {} + }, + "users": { + "discarded": {}, + "saved": {} + } + }, + "time": int(time.time() * 1000) + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOGIN, payload=payload + ) + + # Отправляем + await self._send(writer, packet) + return int(user.get("phone")), int(user.get("id")), hashed_token \ No newline at end of file diff --git a/src/tamtam/processors/history.py b/src/tamtam/processors/history.py new file mode 100644 index 0000000..4d5d74c --- /dev/null +++ b/src/tamtam/processors/history.py @@ -0,0 +1,98 @@ +import pydantic +import json +from classes.baseprocessor import BaseProcessor +from tamtam.models import ChatHistoryPayloadModel + +class HistoryProcessors(BaseProcessor): + async def chat_history(self, payload, seq, writer, senderId): + """Обработчик получения истории чата""" + # Валидируем данные пакета + try: + ChatHistoryPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer) + return + + # Извлекаем данные из пакета + chatId = payload.get("chatId") + forward = payload.get("forward", 0) + backward = payload.get("backward", 0) + from_time = payload.get("from", 0) + getMessages = payload.get("getMessages", True) + messages = [] + + # Проверяем, существует ли чат + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,)) + chat = await cursor.fetchone() + + # Выбрасываем ошибку, если чата нет + if not chat: + await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.CHAT_NOT_FOUND, writer) + return + + # Проверяем, является ли пользователь участником чата + participants = await self.tools.get_chat_participants(chatId, self.db_pool) + if int(senderId) not in participants: + await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.CHAT_NOT_ACCESS, writer) + return + + # Если запрошены сообщения + if getMessages: + if backward > 0: + await cursor.execute( + "SELECT * FROM messages WHERE chat_id = %s AND time < %s ORDER BY time ASC LIMIT %s", + (chatId, from_time, backward) + ) + + result = await cursor.fetchall() + + for row in result: + messages.append({ + "id": row.get("id"), + "time": int(row.get("time")), + "type": row.get("type"), + "sender": row.get("sender"), + "text": row.get("text"), + "attaches": json.loads(row.get("attaches")), + "elements": json.loads(row.get("elements")), + "reactionInfo": {} + }) + + if forward > 0: + await cursor.execute( + "SELECT * FROM messages WHERE chat_id = %s AND time > %s ORDER BY time ASC LIMIT %s", + (chatId, from_time, forward) + ) + + result = await cursor.fetchall() + + for row in result: + messages.append({ + "id": row.get("id"), + "time": int(row.get("time")), + "type": row.get("type"), + "sender": row.get("sender"), + "text": row.get("text"), + "attaches": json.loads(row.get("attaches")), + "elements": json.loads(row.get("elements")), + "reactionInfo": {} + }) + + # Сортируем сообщения по времени + messages.sort(key=lambda x: x["time"]) + + # Формируем ответ + payload = { + "messages": messages + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CHAT_HISTORY, payload=payload + ) + + # Отправялем + await self._send(writer, packet) \ No newline at end of file diff --git a/src/tamtam/processors/main.py b/src/tamtam/processors/main.py new file mode 100644 index 0000000..ee8d69b --- /dev/null +++ b/src/tamtam/processors/main.py @@ -0,0 +1,65 @@ +import pydantic +from classes.baseprocessor import BaseProcessor +from tamtam.models import HelloPayloadModel, PingPayloadModel + +class MainProcessors(BaseProcessor): + async def session_init(self, payload, seq, writer): + """Обработчик приветствия""" + # Валидируем данные пакета + try: + HelloPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + await self._send_error(seq, self.opcodes.SESSION_INIT, + self.error_types.INVALID_PAYLOAD, writer) + return None, None + + # Получаем данные из пакета + device_type = payload.get("userAgent").get("deviceType") + device_name = payload.get("userAgent").get("deviceName") + + # Данные пакета + payload = { + "proxy": "", + "logs-enabled": False, + "proxy-domains": [], + "location": "RU", + "libh-enabled": False, + "phone-auto-complete-enabled": False + } + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.SESSION_INIT, payload=payload + ) + + # Отправляем + await self._send(writer, packet) + return device_type, device_name + + async def ping(self, payload, seq, writer): + """Обработчик пинга""" + # Валидируем данные пакета + try: + PingPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.PING, self.error_types.INVALID_PAYLOAD, writer) + return + + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.PING, payload=None + ) + + # Отправляем + await self._send(writer, packet) + + async def log(self, payload, seq, writer): + """Обработчик лога""" + # Собираем пакет + packet = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOG, payload=None + ) + + # Отправляем + await self._send(writer, packet) \ No newline at end of file diff --git a/src/tamtam/processors/search.py b/src/tamtam/processors/search.py new file mode 100644 index 0000000..4dd4f0e --- /dev/null +++ b/src/tamtam/processors/search.py @@ -0,0 +1,63 @@ +import json, pydantic +from classes.baseprocessor import BaseProcessor +from tamtam.models import SearchUsersPayloadModel + +class SearchProcessors(BaseProcessor): + async def contact_info(self, payload, seq, writer): + """Поиск пользователей по ID""" + # Валидируем данные пакета + try: + SearchUsersPayloadModel.model_validate(payload) + except pydantic.ValidationError as error: + self.logger.error(f"Возникли ошибки при валидации пакета: {error}") + await self._send_error(seq, self.opcodes.CONTACT_INFO, self.error_types.INVALID_PAYLOAD, writer) + return + + # Итоговый список пользователей + users = [] + + # ID пользователей, которые нам предстоит найти + contactIds = payload.get("contactIds") + + # Ищем пользователей в бд + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + for contactId in contactIds: + await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,)) + user = await cursor.fetchone() + + # Если такой пользователь есть, добавляем его в список + if user: + # Аватарка с биографией + photo_id = None if not user.get("avatar_id") else int(user.get("avatar_id")) + avatar_url = None if not photo_id else self.config.avatar_base_url + photo_id + description = None if not user.get("description") else user.get("description") + + # Генерируем профиль + users.append( + self.tools.generate_profile_tt( + id=user.get("id"), + phone=int(user.get("phone")), + avatarUrl=avatar_url, + photoId=photo_id, + updateTime=int(user.get("updatetime")), + firstName=user.get("firstname"), + lastName=user.get("lastname"), + options=json.loads(user.get("options")), + description=description, + username=user.get("username") + ) + ) + + # Создаем данные пакета + payload = { + "contacts": users + } + + # Создаем пакет + response = self.proto.pack_packet( + cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_INFO, payload=payload + ) + + # Отправляем + await self._send(writer, response) \ No newline at end of file diff --git a/src/oneme_tcp/server.py b/src/tamtam/socket.py similarity index 60% rename from src/oneme_tcp/server.py rename to src/tamtam/socket.py index 75023f2..603356f 100644 --- a/src/oneme_tcp/server.py +++ b/src/tamtam/socket.py @@ -1,10 +1,14 @@ -import asyncio, logging, traceback -from oneme_tcp.proto import Proto -from oneme_tcp.processors import Processors +import asyncio +import logging +import traceback +from common.proto_tcp import MobileProto +from tamtam.processors import Processors from common.rate_limiter import RateLimiter +from common.opcodes import Opcodes +from common.tools import Tools -class OnemeMobileServer: - def __init__(self, host="0.0.0.0", port=443, ssl_context=None, db_pool=None, clients={}, send_event=None, telegram_bot=None): +class TamTamMobile: + def __init__(self, host, port, ssl_context, db_pool, clients, send_event): self.host = host self.port = port self.ssl_context = ssl_context @@ -13,10 +17,13 @@ def __init__(self, host="0.0.0.0", port=443, ssl_context=None, db_pool=None, cli self.db_pool = db_pool self.clients = clients - self.proto = Proto() - self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event, telegram_bot=telegram_bot) + self.opcodes = Opcodes() - # rate limiter anti ddos brute force protection + self.proto = MobileProto() + self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event) + self.auth_required = Tools().auth_required + + # rate limiter self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60) self.read_timeout = 300 # Таймаут чтения из сокета (секунды) @@ -37,7 +44,7 @@ async def handle_client(self, reader, writer): try: while True: - # Читаем новые данные из сокета с таймаутом + # Читаем новые данные из сокета (с таймаутом!) try: data = await asyncio.wait_for( reader.read(self.max_read_size), @@ -51,7 +58,7 @@ async def handle_client(self, reader, writer): if not data: break - + # Проверяем размер данных if len(data) > self.max_read_size: self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(data)} байт)") break @@ -59,7 +66,7 @@ async def handle_client(self, reader, writer): # Распаковываем данные packet = self.proto.unpack_packet(data) - # Скип если пакет невалидный + # Если пакет невалидный — пропускаем if packet is None: self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}") continue @@ -69,65 +76,45 @@ async def handle_client(self, reader, writer): payload = packet.get("payload") match opcode: - case self.proto.SESSION_INIT: - deviceType, deviceName = await self.processors.process_hello(payload, seq, writer) - case self.proto.AUTH_REQUEST: + case self.opcodes.SESSION_INIT: + deviceType, deviceName = await self.processors.session_init(payload, seq, writer) + case self.opcodes.PING: + await self.processors.ping(payload, seq, writer) + case self.opcodes.LOG: + await self.processors.log(payload, seq, writer) + case self.opcodes.AUTH_REQUEST: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error(seq, self.opcodes.AUTH_REQUEST, self.processors.error_types.RATE_LIMITED, writer) + else: + await self.processors.auth_request(payload, seq, writer) + case self.opcodes.AUTH: if not self.auth_rate_limiter.is_allowed(address[0]): - await self.processors._send_error(seq, self.proto.AUTH_REQUEST, self.processors.error_types.RATE_LIMITED, writer) + await self.processors._send_error(seq, self.opcodes.AUTH, self.processors.error_types.RATE_LIMITED, writer) else: - await self.processors.process_request_code(payload, seq, writer) - case self.proto.AUTH: + await self.processors.auth(payload, seq, writer) + case self.opcodes.AUTH_CONFIRM: if not self.auth_rate_limiter.is_allowed(address[0]): - await self.processors._send_error(seq, self.proto.AUTH, self.processors.error_types.RATE_LIMITED, writer) + await self.processors._send_error(seq, self.opcodes.AUTH_CONFIRM, self.processors.error_types.RATE_LIMITED, writer) else: - await self.processors.process_verify_code(payload, seq, writer, deviceType, deviceName) - case self.proto.LOGIN: + await self.processors.auth_confirm(payload, seq, writer, deviceType, deviceName) + case self.opcodes.LOGIN: if not self.auth_rate_limiter.is_allowed(address[0]): - await self.processors._send_error(seq, self.proto.LOGIN, self.processors.error_types.RATE_LIMITED, writer) + await self.processors._send_error(seq, self.opcodes.LOGIN, self.processors.error_types.RATE_LIMITED, writer) else: - userPhone, userId, hashedToken = await self.processors.process_login(payload, seq, writer) + userPhone, userId, hashedToken = await self.processors.login(payload, seq, writer) if userPhone: await self._finish_auth(writer, address, userPhone, userId) - case self.proto.LOGOUT: - await self.processors.process_logout(seq, writer, hashedToken=hashedToken) - break - case self.proto.PING: - await self.processors.process_ping(payload, seq, writer) - case self.proto.LOG: - await self.processors.process_telemetry(payload, seq, writer) - case self.proto.ASSETS_UPDATE: - await self.processors.process_get_assets(payload, seq, writer) - case self.proto.VIDEO_CHAT_HISTORY: - await self.processors.process_get_call_history(payload, seq, writer) - case self.proto.MSG_SEND: - await self.processors.process_send_message(payload, seq, writer, senderId=userId, db_pool=self.db_pool) - case self.proto.FOLDERS_GET: - await self.processors.process_get_folders(payload, seq, writer, senderPhone=userPhone) - case self.proto.SESSIONS_INFO: - await self.processors.process_get_sessions(payload, seq, writer, senderPhone=userPhone, hashedToken=hashedToken) - case self.proto.CHAT_INFO: - await self.processors.process_search_chats(payload, seq, writer, senderId=userId) - case self.proto.CONTACT_INFO_BY_PHONE: - await self.processors.process_search_by_phone(payload, seq, writer, senderId=userId) - case self.proto.OK_TOKEN: - await self.processors.process_get_call_token(payload, seq, writer) - case self.proto.MSG_TYPING: - await self.processors.process_typing(payload, seq, writer, senderId=userId) - case self.proto.CONTACT_INFO: - await self.processors.process_search_users(payload, seq, writer) - case self.proto.COMPLAIN_REASONS_GET: - await self.processors.process_complain_reasons_get(payload, seq, writer) + case self.opcodes.CONTACT_INFO: + await self.auth_required( + userPhone, self.processors.contact_info, payload, seq, writer + ) case _: self.logger.warning(f"Неизвестный опкод {opcode}") except Exception as e: self.logger.error(f"Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}") traceback.print_exc() - # Удаляем клиента из словаря - if userPhone: - await self._end_session(userId, address[0], address[1]) - writer.close() self.logger.info(f"Прекратил работать работать с клиентом {address[0]}:{address[1]}") @@ -143,7 +130,7 @@ async def _finish_auth(self, writer, addr, phone, id): "writer": writer, "ip": addr[0], "port": addr[1], - "protocol": "oneme_mobile" + "protocol": "tamtam" } ) else: @@ -155,7 +142,7 @@ async def _finish_auth(self, writer, addr, phone, id): "writer": writer, "ip": addr[0], "port": addr[1], - "protocol": "oneme_mobile" + "protocol": "tamtam" } ] } diff --git a/src/tamtam/websocket.py b/src/tamtam/websocket.py new file mode 100644 index 0000000..57d2f30 --- /dev/null +++ b/src/tamtam/websocket.py @@ -0,0 +1,171 @@ +import logging +import traceback +import websockets +from common.proto_web import WebProto +from tamtam.processors import Processors +from common.rate_limiter import RateLimiter +from common.opcodes import Opcodes +from common.tools import Tools + +class TamTamWS: + def __init__(self, host, port, clients, ssl_context, db_pool, send_event): + self.host = host + self.port = port + self.ssl_context = ssl_context + self.server = None + self.logger = logging.getLogger(__name__) + self.db_pool = db_pool + self.clients = clients + + self.opcodes = Opcodes() + + self.proto = WebProto() + self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event, type="web") + self.auth_required = Tools().auth_required + + # rate limiter + self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60) + + self.read_timeout = 300 # Таймаут чтения из websocket (секунды) + self.max_read_size = 65536 # Максимальный размер данных + + async def handle_client(self, websocket): + """Функция для обработки WebSocket подключений""" + # IP-адрес клиента + address = websocket.remote_address + self.logger.info(f"Работаю с клиентом {address[0]}:{address[1]}") + + deviceType = None + deviceName = None + + userPhone = None + userId = None + hashedToken = None + + try: + async for message in websocket: + # Проверяем размер данных + if len(message) > self.max_read_size: + self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(message)} байт)") + break + + # Распаковываем данные + packet = self.proto.unpack_packet(message) + + # Если пакет невалидный — пропускаем + if not packet: + self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}") + continue + + opcode = packet.get("opcode") + seq = packet.get("seq") + payload = packet.get("payload") + + match opcode: + case self.opcodes.SESSION_INIT: + deviceType, deviceName = await self.processors.session_init(payload, seq, websocket) + case self.opcodes.PING: + await self.processors.ping(payload, seq, websocket) + case self.opcodes.LOG: + await self.processors.log(payload, seq, websocket) + case self.opcodes.AUTH_REQUEST: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error(seq, self.opcodes.AUTH_REQUEST, self.processors.error_types.RATE_LIMITED, websocket) + else: + await self.processors.auth_request(payload, seq, websocket) + case self.opcodes.AUTH: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error(seq, self.opcodes.AUTH, self.processors.error_types.RATE_LIMITED, websocket) + else: + await self.processors.auth(payload, seq, websocket) + case self.opcodes.AUTH_CONFIRM: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error(seq, self.opcodes.AUTH_CONFIRM, self.processors.error_types.RATE_LIMITED, websocket) + else: + await self.processors.auth_confirm(payload, seq, websocket, deviceType, deviceName) + case self.opcodes.LOGIN: + if not self.auth_rate_limiter.is_allowed(address[0]): + await self.processors._send_error(seq, self.opcodes.LOGIN, self.processors.error_types.RATE_LIMITED, websocket) + else: + userPhone, userId, hashedToken = await self.processors.login(payload, seq, websocket) + + if userPhone: + await self._finish_auth(websocket, address, userPhone, userId) + case self.opcodes.CONTACT_INFO: + await self.auth_required( + userPhone, self.processors.contact_info, payload, seq, websocket + ) + case self.opcodes.CHAT_HISTORY: + await self.auth_required( + userPhone, self.processors.chat_history, payload, seq, websocket, userId + ) + case _: + self.logger.warning(f"Неизвестный опкод {opcode}") + except websockets.exceptions.ConnectionClosed: + self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}") + except Exception as e: + self.logger.error(f" Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}") + traceback.print_exc() + + # Удаляем клиента из словаря при отключении + if userId: + await self._end_session(userId, address[0], address[1]) + + self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}") + + async def _finish_auth(self, websocket, addr, phone, id): + """Завершение открытия сессии""" + # Ищем пользователя в словаре + user = self.clients.get(id) + + # Добавляем новое подключение в словарь + if user: + user["clients"].append( + { + "writer": websocket, + "ip": addr[0], + "port": addr[1], + "protocol": "tamtam" + } + ) + else: + self.clients[id] = { + "phone": phone, + "id": id, + "clients": [ + { + "writer": websocket, + "ip": addr[0], + "port": addr[1], + "protocol": "tamtam" + } + ] + } + + async def _end_session(self, id, ip, port): + """Завершение сессии""" + # Получаем пользователя в списке + user = self.clients.get(id) + if not user: + return + + # Получаем подключения пользователя + clients = user.get("clients", []) + + # Удаляем нужное подключение из словаря + for i, client in enumerate(clients): + if (client.get("ip"), client.get("port")) == (ip, port): + clients.pop(i) + + async def start(self): + """Функция для запуска WebSocket сервера""" + self.server = await websockets.serve( + self.handle_client, + self.host, + self.port, + ssl=self.ssl_context + ) + + self.logger.info(f"TT WebSocket запущен на порту {self.port}") + + await self.server.wait_closed() \ No newline at end of file diff --git a/src/tamtam_tcp/processors.py b/src/tamtam_tcp/processors.py deleted file mode 100644 index 0f7977b..0000000 --- a/src/tamtam_tcp/processors.py +++ /dev/null @@ -1,307 +0,0 @@ -import hashlib -import secrets -import time -import logging -import json -import re -from common.static import Static -from common.tools import Tools -from tamtam_tcp.proto import Proto -from tamtam_tcp.models import * - - -class Processors: - def __init__(self, db_pool=None, clients=None, send_event=None): - if clients is None: - clients = {} # Более правильная логика - self.static = Static() - self.proto = Proto() - self.tools = Tools() - self.error_types = self.static.ErrorTypes() - self.db_pool = db_pool - self.logger = logging.getLogger(__name__) - - async def _send(self, writer, packet): - try: - writer.write(packet) - await writer.drain() - except: - pass - - async def _send_error(self, seq, opcode, type, writer): - payload = self.static.ERROR_TYPES.get(type, { - "localizedMessage": "Неизвестная ошибка", - "error": "unknown.error", - "message": "Unknown error", - "title": "Неизвестная ошибка" - }) - - packet = self.proto.pack_packet( - cmd=self.proto.CMD_ERR, seq=seq, opcode=opcode, payload=payload - ) - - await self._send(writer, packet) - - async def process_hello(self, payload, seq, writer): - """Обработчик приветствия""" - # Валидируем данные пакета - try: - HelloPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.HELLO, self.error_types.INVALID_PAYLOAD, writer) - return None, None - - # Получаем данные из пакета - device_type = payload.get("userAgent").get("deviceType") - device_name = payload.get("userAgent").get("deviceName") - - # Данные пакета - payload = { - "proxy": "", - "logs-enabled": False, - "proxy-domains": [], - "location": "RU", - "libh-enabled": False, - "phone-auto-complete-enabled": False - } - - # Собираем пакет - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.HELLO, payload=payload - ) - - # Отправляем - await self._send(writer, packet) - return device_type, device_name - - async def process_request_code(self, payload, seq, writer): - """Обработчик запроса кода""" - # Валидируем данные пакета - try: - RequestCodePayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.REQUEST_CODE, self.error_types.INVALID_PAYLOAD, writer) - return - - # Извлекаем телефон из пакета - phone = re.sub(r'\D', '', payload.get("phone", "")) # Не хардкодим, через регулярки - - # Генерируем токен с кодом - code = f"{secrets.randbelow(1_000_000):06d}" # Старая версия ненадежна, могла отбросить ведущие нули или вообще интерпритировать как систему счисления с основанием 8 - token = secrets.token_urlsafe(128) - - # Хешируем - code_hash = hashlib.sha256(code.encode()).hexdigest() - token_hash = hashlib.sha256(token.encode()).hexdigest() - - # Срок жизни токена (5 минут) - expires = int(time.time()) + 300 - - # Ищем пользователя, и если он существует, сохраняем токен - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,)) - user = await cursor.fetchone() - - if not user: - await self._send_error(seq, self.proto.REQUEST_CODE, self.error_types.USER_NOT_FOUND, writer) - return - - # Сохраняем токен - await cursor.execute( - "INSERT INTO auth_tokens (phone, token_hash, code_hash, expires, state) VALUES (%s, %s, %s, %s, %s)", - (phone, token_hash, code_hash, expires, "started",)) - - # Данные пакета - payload = { - "verifyToken": token, - "retries": 5, - "codeDelay": 60, - "codeLength": 6, - "callDelay": 0, - "requestType": "SMS" - } - - # Собираем пакет - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.REQUEST_CODE, payload=payload - ) - - # Отправляем - await self._send(writer, packet) - - self.logger.debug(f"Код для {phone}: {code}") - - async def process_verify_code(self, payload, seq, writer): - """Обработчик проверки кода""" - # Валидируем данные пакета - try: - VerifyCodePayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_PAYLOAD, writer) - return - - # Извлекаем данные из пакета - code = payload.get("verifyCode") - token = payload.get("token") - - # Хешируем токен с кодом - hashed_code = hashlib.sha256(code.encode()).hexdigest() - hashed_token = hashlib.sha256(token.encode()).hexdigest() - - # Ищем токен с кодом - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - # Ищем токен - await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", - (hashed_token,)) - stored_token = await cursor.fetchone() - - if not stored_token: - await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.CODE_EXPIRED, writer) - return - - # Проверяем код - if stored_token.get("code_hash") != hashed_code: - await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_CODE, writer) - return - - # Ищем аккаунт - await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),)) - account = await cursor.fetchone() - - # Обновляем состояние токена - await cursor.execute("UPDATE auth_tokens set state = %s WHERE token_hash = %s", - ("verified", hashed_token,)) - - # # Создаем сессию - # await cursor.execute( - # "INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)", - # (stored_token.get("phone"), hashed_login, deviceType, deviceName, "Epstein Island", int(time.time()),) - # ) - - # Генерируем профиль - # Аватарка с биографией - photo_id = int(account["avatar_id"]) if account.get("avatar_id") else None - avatar_url = f"{self.config.avatar_base_url}{photo_id}" if photo_id else None - description = account.get("description") - - # Собираем данные пакета - payload = { - "profile": self.tools.generate_profile( - id=account.get("id"), - phone=int(account.get("phone")), - avatarUrl=avatar_url, - photoId=photo_id, - updateTime=int(account.get("updatetime")), - firstName=account.get("firstname"), - lastName=account.get("lastname"), - options=json.loads(account.get("options")), - description=description, - accountStatus=int(account.get("accountstatus")), - profileOptions=json.loads(account.get("profileoptions")), - includeProfileOptions=False, - username=account.get("username"), - type="TT" - ).get("contact"), - "tokenAttrs": { - "AUTH": { - "token": token - } - }, - "tokenTypes": { - "AUTH": token - } - } - - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.VERIFY_CODE, payload=payload - ) - - await self._send(writer, packet) - - async def process_final_auth(self, payload, seq, writer, deviceType, deviceName): - """Обработчик финальной аутентификации""" - # Валидируем данные пакета - try: - FinalAuthPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.FINAL_AUTH, self.error_types.INVALID_PAYLOAD, writer) - return - - # Извлекаем данные из пакета - token = payload.get("token") - - if not deviceType: - deviceType = payload.get("deviceType") - - # Хешируем токен - hashed_token = hashlib.sha256(token.encode()).hexdigest() - - # Генерируем постоянный токен - login = secrets.token_urlsafe(128) - hashed_login = hashlib.sha256(login.encode()).hexdigest() - - # Ищем токен с кодом - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - # Ищем токен - await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", - (hashed_token,)) - stored_token = await cursor.fetchone() - - if stored_token is None: - await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer) - return - - if stored_token.get("state") == "started": - await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer) - return - - # Ищем аккаунт - await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),)) - account = await cursor.fetchone() - - # Обновляем состояние токена - await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,)) - - # Создаем сессию - await cursor.execute( - "INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)", - (stored_token.get("phone"), hashed_login, deviceType, deviceName, "Epstein Island", - int(time.time()),) - ) - - # Аватарка с биографией - photo_id = None if not account.get("avatar_id") else int(account.get("avatar_id")) - avatar_url = None if not photo_id else self.config.avatar_base_url + photo_id - description = None if not account.get("description") else account.get("description") - - # Собираем данные пакета - payload = { - "userToken": "0", - "profile": self.tools.generate_profile( - id=account.get("id"), - phone=int(account.get("phone")), - avatarUrl=avatar_url, - photoId=photo_id, - updateTime=int(account.get("updatetime")), - firstName=account.get("firstname"), - lastName=account.get("lastname"), - options=json.loads(account.get("options")), - description=description, - accountStatus=int(account.get("accountstatus")), - profileOptions=json.loads(account.get("profileoptions")), - includeProfileOptions=False, - username=account.get("username"), - type="TT" - ).get("contact"), - "tokenType": "LOGIN", - "token": login - } - - packet = self.proto.pack_packet( - cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.FINAL_AUTH, payload=payload - ) - - await self._send(writer, packet) diff --git a/src/tamtam_tcp/proto.py b/src/tamtam_tcp/proto.py deleted file mode 100644 index e055224..0000000 --- a/src/tamtam_tcp/proto.py +++ /dev/null @@ -1,113 +0,0 @@ -import lz4.block, msgpack, logging, json - -class Proto: - def __init__(self) -> None: - self.logger = logging.getLogger(__name__) - - # TODO узнать какие должны быть лимиты и поменять, - # сейчас это больше заглушка - MAX_PAYLOAD_SIZE = 1048576 # 1 MB - MAX_DECOMPRESSED_SIZE = 1048576 # 1 MB - HEADER_SIZE = 10 # 1+2+1+2+4 - - ### Работа с протоколом - def unpack_packet(self, data: bytes) -> dict | None: - # Проверяем минимальный размер пакета - if len(data) < self.HEADER_SIZE: - self.logger.warning(f"Пакет слишком маленький: {len(data)} байт") - return None - - # Распаковываем заголовок - ver = int.from_bytes(data[0:1], "big") - cmd = int.from_bytes(data[1:3], "big") - seq = int.from_bytes(data[3:4], "big") - opcode = int.from_bytes(data[4:6], "big") - packed_len = int.from_bytes(data[6:10], "big") - - # Флаг упаковки - comp_flag = packed_len >> 24 - - # Парсим данные пакета - payload_length = packed_len & 0xFFFFFF - - # Проверяем размер payload - if payload_length > self.MAX_PAYLOAD_SIZE: - self.logger.warning(f"Payload слишком большой: {payload_length} B (лимит {self.MAX_PAYLOAD_SIZE})") - return None - - # Проверяем длину пакета - if len(data) < self.HEADER_SIZE + payload_length: - self.logger.warning(f"Пакет неполный: требуется {self.HEADER_SIZE + payload_length} B, получено {len(data)}") - return None - - payload_bytes = data[10 : 10 + payload_length] - payload = None - - # Декодируем данные пакета - if payload_bytes: - # Разжимаем данные пакета, если требуется - if comp_flag != 0: - compressed_data = payload_bytes - try: - payload_bytes = lz4.block.decompress( - compressed_data, - uncompressed_size=self.MAX_DECOMPRESSED_SIZE, - ) - except lz4.block.LZ4BlockError: - self.logger.warning("Ошибка декомпрессии LZ4") - return None - - # Распаковываем msgpack - payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False) - - self.logger.debug(f"Распаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload}") - - # Возвращаем - return { - "ver": ver, - "cmd": cmd, - "seq": seq, - "opcode": opcode, - "payload": payload, - } - - def pack_packet(self, ver: int = 10, cmd: int = 1, seq: int = 1, opcode: int = 6, payload: dict = None) -> bytes: - # Запаковываем заголовок - ver_b = ver.to_bytes(1, "big") - cmd_b = cmd.to_bytes(2, "big") - seq_b = seq.to_bytes(1, "big") - opcode_b = opcode.to_bytes(2, "big") - - # Запаковываем данные пакета - payload_bytes: bytes | None = msgpack.packb(payload) - if payload_bytes is None: - payload_bytes = b"" - payload_len = len(payload_bytes) & 0xFFFFFF - payload_len_b = payload_len.to_bytes(4, 'big') - - self.logger.debug(f"Упаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload}") - - # Возвращаем пакет - return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + payload_bytes - - ### Констаты протокола - CMD_OK = 0x100 - CMD_NOF = 0x200 - CMD_ERR = 0x300 - PROTO_VER = 10 - - HELLO = 6 - REQUEST_CODE = 17 - VERIFY_CODE = 18 - FINAL_AUTH = 23 - LOGIN = 19 - PING = 1 - TELEMETRY = 5 - GET_ASSETS = 27 - GET_CALL_HISTORY = 79 - SEND_MESSAGE = 64 - GET_FOLDERS = 272 - GET_SESSIONS = 96 - LOGOUT = 20 - SEARCH_CHATS = 48 - SEARCH_BY_PHONE = 46 \ No newline at end of file diff --git a/src/tamtam_tcp/server.py b/src/tamtam_tcp/server.py deleted file mode 100644 index 1fd1814..0000000 --- a/src/tamtam_tcp/server.py +++ /dev/null @@ -1,107 +0,0 @@ -import asyncio, logging, traceback -from tamtam_tcp.proto import Proto -from tamtam_tcp.processors import Processors -from common.rate_limiter import RateLimiter - -class TTMobileServer: - def __init__(self, host="0.0.0.0", port=443, ssl_context=None, db_pool=None, clients={}, send_event=None): - self.host = host - self.port = port - self.ssl_context = ssl_context - self.server = None - self.logger = logging.getLogger(__name__) - self.db_pool = db_pool - self.clients = clients - - self.proto = Proto() - self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event) - - # rate limiter - self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60) - - self.read_timeout = 300 # Таймаут чтения из сокета (секунды) - self.max_read_size = 65536 # Максимальный размер данных из сокета - - async def handle_client(self, reader, writer): - """Функция для обработки подключений""" - # IP-адрес клиента - address = writer.get_extra_info("peername") - self.logger.info(f"Работаю с клиентом {address[0]}:{address[1]}") - - deviceType = None - deviceName = None - - userPhone = None - userId = None - hashedToken = None - - try: - while True: - # Читаем новые данные из сокета (с таймаутом!) - try: - data = await asyncio.wait_for( - reader.read(self.max_read_size), - timeout=self.read_timeout - ) - except asyncio.TimeoutError: - self.logger.info(f"Таймаут соединения для {address[0]}:{address[1]}") - break - - # Если сокет закрыт - выходим из цикла - if not data: - break - - # Проверяем размер данных - if len(data) > self.max_read_size: - self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(data)} байт)") - break - - # Распаковываем данные - packet = self.proto.unpack_packet(data) - - # Если пакет невалидный — пропускаем - if packet is None: - self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}") - continue - - opcode = packet.get("opcode") - seq = packet.get("seq") - payload = packet.get("payload") - - match opcode: - case self.proto.HELLO: - deviceType, deviceName = await self.processors.process_hello(payload, seq, writer) - case self.proto.REQUEST_CODE: - if not self.auth_rate_limiter.is_allowed(address[0]): - await self.processors._send_error(seq, self.proto.REQUEST_CODE, self.processors.error_types.RATE_LIMITED, writer) - else: - await self.processors.process_request_code(payload, seq, writer) - case self.proto.VERIFY_CODE: - if not self.auth_rate_limiter.is_allowed(address[0]): - await self.processors._send_error(seq, self.proto.VERIFY_CODE, self.processors.error_types.RATE_LIMITED, writer) - else: - await self.processors.process_verify_code(payload, seq, writer) - case self.proto.FINAL_AUTH: - if not self.auth_rate_limiter.is_allowed(address[0]): - await self.processors._send_error(seq, self.proto.FINAL_AUTH, self.processors.error_types.RATE_LIMITED, writer) - else: - await self.processors.process_final_auth(payload, seq, writer, deviceType, deviceName) - case _: - self.logger.warning(f"Неизвестный опкод {opcode}") - except Exception as e: - self.logger.error(f"Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}") - traceback.print_exc() - - writer.close() - self.logger.info(f"Прекратил работать работать с клиентом {address[0]}:{address[1]}") - - async def start(self): - """Функция для запуска сервера""" - self.server = await asyncio.start_server( - self.handle_client, self.host, self.port, ssl=self.ssl_context - ) - - self.logger.info(f"Сокет запущен на порту {self.port}") - - async with self.server: - await self.server.serve_forever() \ No newline at end of file diff --git a/src/tamtam_ws/controller.py b/src/tamtam_ws/controller.py deleted file mode 100644 index 6df93ca..0000000 --- a/src/tamtam_ws/controller.py +++ /dev/null @@ -1,22 +0,0 @@ -import asyncio -from classes.controllerbase import ControllerBase -from common.config import ServerConfig -from tamtam_ws.server import TTWSServer - -class TTWSController(ControllerBase): - def __init__(self): - self.config = ServerConfig() - - def launch(self, api): - async def _start_all(): - await asyncio.gather( - TTWSServer( - host=self.config.host, - port=self.config.tamtam_ws_port, - db_pool=api['db'], - clients=api['clients'], - send_event=api['event'] - ).start() - ) - - return _start_all() \ No newline at end of file diff --git a/src/tamtam_ws/models.py b/src/tamtam_ws/models.py deleted file mode 100644 index 0f98df5..0000000 --- a/src/tamtam_ws/models.py +++ /dev/null @@ -1,27 +0,0 @@ -import pydantic - -class MessageModel(pydantic.BaseModel): - ver: int - cmd: int - seq: int - opcode: int - payload: dict = None - -class UserAgentModel(pydantic.BaseModel): - deviceType: str - appVersion: str - osVersion: str - locale: str - deviceLocale: str - deviceName: str - screen: str - headerUserAgent: str - timezone: str - -class HelloPayloadModel(pydantic.BaseModel): - userAgent: UserAgentModel - deviceId: str - -class RequestCodePayloadModel(pydantic.BaseModel): - phone: str - requestType: str diff --git a/src/tamtam_ws/processors.py b/src/tamtam_ws/processors.py deleted file mode 100644 index 94bf2e4..0000000 --- a/src/tamtam_ws/processors.py +++ /dev/null @@ -1,76 +0,0 @@ -import hashlib, secrets, random, time, logging, json -from common.static import Static -from common.tools import Tools -from tamtam_ws.proto import Proto -from tamtam_ws.models import * - -class Processors: - def __init__(self, db_pool=None, clients={}, send_event=None): - self.static = Static() - self.tools = Tools() - self.proto = Proto() - self.error_types = self.static.ErrorTypes() - self.db_pool = db_pool - self.logger = logging.getLogger(__name__) - - async def _send(self, writer, packet): - """Отправка пакета""" - await writer.send(packet) - - async def _send_error(self, seq, opcode, type, writer): - payload = self.static.ERROR_TYPES.get(type, { - "localizedMessage": "Неизвестная ошибка", - "error": "unknown.error", - "message": "Unknown error", - "title": "Неизвестная ошибка" - }) - - packet = self.proto.pack_packet( - seq=seq, opcode=opcode, payload=payload - ) - - await self._send(writer, packet) - - async def process_hello(self, payload, seq, writer): - """Обработчик приветствия""" - # Валидируем данные пакета - try: - HelloPayloadModel.model_validate(payload) - except Exception as e: - await self._send_error(seq, self.proto.SESSION_INIT, self.error_types.INVALID_PAYLOAD, writer) - return None, None - - # Получаем данные из пакета - deviceType = payload.get("userAgent").get("deviceType") - deviceName = payload.get("userAgent").get("deviceName") - - # Собираем данные ответа - payload = { - "proxy": "", - "logs-enabled": False, - "proxy-domains": [], - "location": "RU" - } - - # Создаем пакет - packet = self.proto.pack_packet(seq=seq, opcode=self.proto.SESSION_INIT, payload=payload) - - # Отправляем - await self._send(writer, packet) - return deviceType, deviceName - - async def process_ping(self, payload, seq, writer): - """Обработчик пинга""" - # Создаем пакет - packet = self.proto.pack_packet(seq=seq, opcode=self.proto.PING) - - # Отправляем - await self._send(writer, packet) - - async def process_telemetry(self, payload, seq, writer): - """Обработчик телеметрии""" - # Создаем пакет - packet = self.proto.pack_packet(seq=seq, opcode=self.proto.LOG) - - # Отправляем - await self._send(writer, packet) diff --git a/src/tamtam_ws/proto.py b/src/tamtam_ws/proto.py deleted file mode 100644 index bd7417f..0000000 --- a/src/tamtam_ws/proto.py +++ /dev/null @@ -1,157 +0,0 @@ -import json - -class Proto: - def pack_packet(self, ver=10, cmd=1, seq=0, opcode=1, payload=None): - # а разве не надо в жсон запаковывать ещё - # о всё - return json.dumps({ - "ver": ver, - "cmd": cmd, - "seq": seq, - "opcode": opcode, - "payload": payload - }) - - MAX_PACKET_SIZE = 65536 # 64 KB, заглушка, нужно узнать реальные лимиты и поменять, хотя кто будет это делать... - - def unpack_packet(self, packet): - # try catch чтобы не сыпалось всё при неверных пакетах - if isinstance(packet, (str, bytes)) and len(packet) > self.MAX_PACKET_SIZE: - return {} - - try: - parsed_packet = json.loads(packet) - except (json.JSONDecodeError, TypeError, ValueError): - return {} - - return parsed_packet - # мне кажется долго вручную всё писать - # а как еще - # ну вставить сюда целиком и потом через multiline cursor удалить лишнее - # ну ты удалишь тогда. я на тачпаде - # ладно щас другим способом удалю - # всё нахуй - # TAMTAM SOURCE LEAK 2026 - # так ну че делать будем - # так ну - - # 19 опкод сделан? - # нет сэр пошли библиотеку тамы смотреть - # мб найдем че. она без обфускации - # а ты ее видишь? - # пошли - PING = 1 - LOG = 5 - SESSION_INIT = 6 - PROFILE = 16 - AUTH_REQUEST = 17 - AUTH_CHECK_SCENARIO = 263 - AUTH = 18 - LOGIN = 19 - LOGOUT = 20 - SYNC = 21 - CONFIG = 22 - AUTH_CONFIRM = 23 - ASSETS_GET = 26 - ASSETS_UPDATE = 27 - ASSETS_GET_BY_IDS = 28 - ASSETS_ADD = 29 - ASSETS_REMOVE = 259 - ASSETS_MOVE = 260 - ASSETS_LIST_MODIFY = 261 - CONTACT_INFO = 32 - CONTACT_UPDATE = 34 - CONTACT_PRESENCE = 35 - CONTACT_LIST = 36 - CONTACT_PHOTOS = 39 - CONTACT_CREATE = 41 - REMOVE_CONTACT_PHOTO = 43 - OWN_CONTACT_SEARCH = 44 - CHAT_INFO = 48 - CHAT_HISTORY = 49 - CHAT_MARK = 50 - CHAT_MEDIA = 51 - CHAT_DELETE = 52 - CHAT_LIST = 53 - CHAT_CLEAR = 54 - CHAT_UPDATE = 55 - CHAT_CHECK_LINK = 56 - CHAT_JOIN = 57 - CHAT_LEAVE = 58 - CHAT_MEMBERS = 59 - CHAT_CLOSE = 61 - CHAT_BOT_COMMANDS = 144 - CHAT_SUBSCRIBE = 75 - PUBLIC_SEARCH = 60 - CHAT_CREATE = 63 - MSG_SEND = 64 - MSG_TYPING = 65 - MSG_DELETE = 66 - MSG_EDIT = 67 - CHAT_SEARCH = 68 - MSG_SHARE_PREVIEW = 70 - MSG_SEARCH_TOUCH = 72 - MSG_SEARCH = 73 - MSG_GET_STAT = 74 - MSG_GET = 71 - VIDEO_CHAT_START = 76 - VIDEO_CHAT_JOIN = 102 - VIDEO_CHAT_COMMAND = 78 - VIDEO_CHAT_MEMBERS = 195 - CHAT_MEMBERS_UPDATE = 77 - PHOTO_UPLOAD = 80 - STICKER_UPLOAD = 81 - VIDEO_UPLOAD = 82 - VIDEO_PLAY = 83 - MUSIC_PLAY = 84 - MUSIC_PLAY30 = 85 - FILE_UPLOAD = 87 - FILE_DOWNLOAD = 88 - CHAT_PIN_SET_VISIBILITY = 86 - LINK_INFO = 89 - MESSAGE_LINK = 90 - MSG_CONSTRUCT = 94 - SESSIONS_INFO = 96 - SESSIONS_CLOSE = 97 - PHONE_BIND_REQUEST = 98 - PHONE_BIND_CONFIRM = 99 - UNBIND_OK_PROFILE = 100 - CHAT_COMPLAIN = 117 - MSG_SEND_CALLBACK = 118 - SUSPEND_BOT = 119 - MSG_REACT = 178 - MSG_CANCEL_REACTION = 179 - MSG_GET_REACTIONS = 180 - MSG_GET_DETAILED_REACTIONS = 181 - LOCATION_STOP = 124 - LOCATION_SEND = 125 - LOCATION_REQUEST = 126 - NOTIF_MESSAGE = 128 - NOTIF_TYPING = 129 - NOTIF_MARK = 130 - NOTIF_CONTACT = 131 - NOTIF_PRESENCE = 132 - NOTIF_CONFIG = 134 - NOTIF_CHAT = 135 - NOTIF_ATTACH = 136 - NOTIF_VIDEO_CHAT_START = 137 - NOTIF_VIDEO_CHAT_COMMAND = 138 - NOTIF_CALLBACK_ANSWER = 143 - NOTIF_MSG_CONSTRUCT = 146 - NOTIF_LOCATION = 147 - NOTIF_LOCATION_REQUEST = 148 - NOTIF_ASSETS_UPDATE = 150 - NOTIF_MSG_REACTIONS_CHANGED = 155 - NOTIF_MSG_YOU_REACTED = 156 - NOTIF_DRAFT = 152 - NOTIF_DRAFT_DISCARD = 153 - NOTIF_MSG_DELAYED = 154 - AUTH_CALL_INFO = 256 - CONTACT_INFO_EXTERNAL = 45 - DRAFT_SAVE = 176 - DRAFT_DISCARD = 177 - STICKER_CREATE = 193 - STICKER_SUGGEST = 194 - CHAT_SEARCH_COUNT_MSG = 197 - CHAT_SEARCH_COMMON_PARTICIPANTS = 198 - GET_USER_SCORE = 201 \ No newline at end of file diff --git a/src/tamtam_ws/server.py b/src/tamtam_ws/server.py deleted file mode 100644 index 2e210db..0000000 --- a/src/tamtam_ws/server.py +++ /dev/null @@ -1,71 +0,0 @@ -import asyncio, logging, json -from websockets.asyncio.server import serve -from tamtam_ws.models import * -from pydantic import ValidationError -from tamtam_ws.proto import Proto -from tamtam_ws.processors import Processors - -class TTWSServer: - def __init__(self, host, port, db_pool=None, clients={}, send_event=None): - self.host = host - self.port = port - self.proto = Proto() - self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event) - self.logger = logging.getLogger(__name__) - - async def handle_client(self, websocket): - deviceType = None - deviceName = None - - async for message in websocket: - # Распаковываем пакет - packet = self.proto.unpack_packet(message) - - if not packet: - self.logger.warning("Невалидный пакет от ws клиента") - continue - - # Валидируем структуру пакета - try: - MessageModel.model_validate(packet) - except ValidationError as e: - self.logger.warning(f"Ошибка валидации пакета: {e}") - continue - - # Извлекаем данные из пакета - seq = packet['seq'] - opcode = packet['opcode'] - payload = packet['payload'] - - match opcode: - case self.proto.SESSION_INIT: - # ПРИВЕТ АНДРЕЙ МАЛАХОВ - # не не удаляй этот коммент. пусть останется на релизе аххахаха - deviceType, deviceName = await self.processors.process_hello(payload, seq, websocket) - case self.proto.PING: - await self.processors.process_ping(payload, seq, websocket) - case self.proto.LOG: - # телеметрия аааа слежка цру фсб фбр - # УДАЛЯЕМ MYTRACKER ИЗ TAMTAM ТАМ ВИРУС - # майтрекер отправляет все ваши сообщения на сервер барака обамы. немедленно удаляем!!! - await self.processors.process_telemetry(payload, seq, websocket) - # case self.proto.AUTH_REQUEST: - # await self.processors.process_auth_request(payload, seq, websocket) - # case self.proto.VERIFY_CODE: - # await self.processors.process_verify_code(payload, seq, websocket) - # case self.proto.FINAL_AUTH: - # await self.processors.process_final_auth(payload, seq, websocket, deviceType, deviceName) - - # лан я пойду. пока - # а ок - - async def start(self): - self.logger.info(f"Вебсокет запущен на порту {self.port}") - - async with serve( - self.handle_client, self.host, self.port, - max_size=65536, - open_timeout=10, - close_timeout=10, - ): - await asyncio.Future() \ No newline at end of file diff --git a/src/telegrambot/bot.py b/src/telegrambot/bot.py index c89891d..26de5c8 100644 --- a/src/telegrambot/bot.py +++ b/src/telegrambot/bot.py @@ -1,15 +1,25 @@ +import json import logging import random -import json import time -from telebot.async_telebot import AsyncTeleBot from textwrap import dedent -from common.static import Static + +from aiogram import Bot, Dispatcher, Router +from aiogram.filters import Command +from aiogram.types import Message + from common.sql_queries import SQLQueries +from common.static import Static +from common.tools import Tools + class TelegramBot: def __init__(self, token, enabled, db_pool, whitelist_ids=None): - self.bot = AsyncTeleBot(token) + self.bot = Bot(token=token) + self.dp = Dispatcher() + self.router = Router() + self.dp.include_router(self.router) + self.tools = Tools() self.enabled = enabled self.db_pool = db_pool self.whitelist_ids = whitelist_ids if whitelist_ids is not None else [] @@ -19,103 +29,104 @@ def __init__(self, token, enabled, db_pool, whitelist_ids=None): self.static = Static() self.sql_queries = SQLQueries() - @self.bot.message_handler(commands=['start']) - async def handle_start(message): - tg_id = str(message.from_user.id) - - # Ищем привязанный аккаунт пользователя - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - await cursor.execute(self.sql_queries.SELECT_USER_BY_TG_ID, (tg_id,)) - account = await cursor.fetchone() - - if account: - # Извлекаем id аккаунта с телефоном - phone = account.get('phone') - - await self.bot.send_message( - message.chat.id, - self.get_bot_message(self.msg_types.WELCOME_ALREADY_REGISTERED).format(phone=phone) - ) - else: - await self.bot.send_message( - message.chat.id, self.get_bot_message(self.msg_types.WELCOME_NEW_USER) - ) + self.router.message.register(self.handle_start, Command("start")) + self.router.message.register(self.handle_register, Command("register")) - @self.bot.message_handler(commands=['register']) - async def handle_register(message): - tg_id = str(message.from_user.id) - - # Проверка ID на наличие в белом списке - if tg_id not in self.whitelist_ids: - await self.bot.send_message(message.chat.id, self.get_bot_message(self.msg_types.ID_NOT_WHITELISTED)) - return - - async with self.db_pool.acquire() as conn: - async with conn.cursor() as cursor: - # Проверка на существование - await cursor.execute(self.sql_queries.SELECT_USER_BY_TG_ID, (tg_id,)) - if await cursor.fetchone(): - await self.bot.send_message( - message.chat.id, - self.get_bot_message(self.msg_types.ACCOUNT_ALREADY_EXISTS) - ) - return - - # Подготовка данных согласно схеме - new_phone = f"7900{random.randint(1000000, 9999999)}" - updatetime = str(int(time.time() * 1000)) - lastseen = str(int(time.time())) - - try: - # Создаем юзера - await cursor.execute( - self.sql_queries.INSERT_USER, - ( - new_phone, # phone - tg_id, # telegram_id - message.from_user.first_name[:59], # firstname - (message.from_user.last_name or "")[:59], # lastname - (message.from_user.username or "")[:60], # username - json.dumps([]), # profileoptions - json.dumps(["TT", "ONEME"]), # options - 0, # accountstatus - updatetime, - lastseen, - ) - ) - - # Добавляем данные о аккаунте - await cursor.execute( - self.sql_queries.INSERT_USER_DATA, - ( - new_phone, # phone - json.dumps([]), # chats - json.dumps([]), # contacts - json.dumps(self.static.USER_FOLDERS), # folders - json.dumps(self.static.USER_SETTINGS), # user settings - json.dumps({}), # chat_config - ) - ) - - await self.bot.send_message( - message.chat.id, - self.get_bot_message(self.msg_types.REGISTRATION_SUCCESS).format(new_phone=new_phone) - ) - except Exception as e: - self.logger.error(f"Ошибка при регистрации: {e}") - await self.bot.send_message( - message.chat.id, - self.get_bot_message(self.msg_types.INTERNAL_ERROR) - ) - def get_bot_message(self, msg_type): return dedent(self.static.BOT_MESSAGES.get(msg_type)).strip() + async def handle_start(self, message: Message): + tg_id = str(message.from_user.id) + + # Ищем привязанный аккаунт пользователя + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute(self.sql_queries.SELECT_USER_BY_TG_ID, (tg_id,)) + account = await cursor.fetchone() + + if account: + # Извлекаем id аккаунта с телефоном + phone = account.get("phone") + + await message.answer( + self.get_bot_message(self.msg_types.WELCOME_ALREADY_REGISTERED).format( + phone=phone + ) + ) + else: + await message.answer(self.get_bot_message(self.msg_types.WELCOME_NEW_USER)) + + async def handle_register(self, message: Message): + tg_id = str(message.from_user.id) + + # Проверка ID на наличие в белом списке + if tg_id not in self.whitelist_ids: + await message.answer( + self.get_bot_message(self.msg_types.ID_NOT_WHITELISTED) + ) + return + + async with self.db_pool.acquire() as conn: + async with conn.cursor() as cursor: + # Проверка на существование + await cursor.execute(self.sql_queries.SELECT_USER_BY_TG_ID, (tg_id,)) + if await cursor.fetchone(): + await message.answer( + self.get_bot_message(self.msg_types.ACCOUNT_ALREADY_EXISTS) + ) + return + + # Подготовка данных согласно схеме + new_phone = f"7900{random.randint(1000000, 9999999)}" + updatetime = str(int(time.time() * 1000)) + lastseen = str(int(time.time())) + + try: + # Создаем юзера + await cursor.execute( + self.sql_queries.INSERT_USER, + ( + self.tools.generate_id(), + new_phone, # phone + tg_id, # telegram_id + message.from_user.first_name[:59], # firstname + (message.from_user.last_name or "")[:59], # lastname + (message.from_user.username or "")[:60], # username + json.dumps([]), # profileoptions + json.dumps(["TT", "ONEME"]), # options + 0, # accountstatus + updatetime, + lastseen, + ), + ) + + # Добавляем данные о аккаунте + await cursor.execute( + self.sql_queries.INSERT_USER_DATA, + ( + new_phone, # phone + json.dumps([]), # contacts + json.dumps(self.static.USER_FOLDERS), # folders + json.dumps(self.static.USER_SETTINGS), # user settings + json.dumps({}), # chat_config + ), + ) + + await message.answer( + self.get_bot_message( + self.msg_types.REGISTRATION_SUCCESS + ).format(new_phone=new_phone) + ) + except Exception as e: + self.logger.error(f"Ошибка при регистрации: {e}") + await message.answer( + self.get_bot_message(self.msg_types.INTERNAL_ERROR) + ) + async def start(self): - if self.enabled == True: + if self.enabled: try: - await self.bot.polling() + await self.dp.start_polling(self.bot) except Exception as e: self.logger.error(f"Ошибка запуска Telegram бота: {e}") else: @@ -124,7 +135,10 @@ async def start(self): async def send_auth_code(self, chat_id, phone, code): try: await self.bot.send_message( - chat_id, self.get_bot_message(self.msg_types.INCOMING_CODE).format(phone=phone, code=code) + chat_id, + self.get_bot_message(self.msg_types.INCOMING_CODE).format( + phone=phone, code=code + ), ) except Exception as e: - self.logger.error(f"Ошибка отправки кода в Telegram: {e}") \ No newline at end of file + self.logger.error(f"Ошибка отправки кода в Telegram: {e}") diff --git a/tables.sql b/tables.sql index ead6959..e118764 100644 --- a/tables.sql +++ b/tables.sql @@ -1,5 +1,5 @@ CREATE TABLE `users` ( - `id` INT AUTO_INCREMENT PRIMARY KEY, + `id` INT PRIMARY KEY, `phone` VARCHAR(20) UNIQUE, `telegram_id` VARCHAR(64) UNIQUE, `firstname` VARCHAR(59) NOT NULL, @@ -33,8 +33,7 @@ CREATE TABLE `auth_tokens` ( ); CREATE TABLE `user_data` ( - `phone` VARCHAR(20) NOT NULL UNIQUE, - `chats` JSON NOT NULL, + `phone` VARCHAR(20) NOT NULL UNIQUE PRIMARY KEY, `contacts` JSON NOT NULL, `folders` JSON NOT NULL, `user_config` JSON NOT NULL, @@ -42,14 +41,13 @@ CREATE TABLE `user_data` ( ); CREATE TABLE `chats` ( - `id` INT AUTO_INCREMENT PRIMARY KEY, + `id` INT NOT NULL PRIMARY KEY, `owner` INT NOT NULL, - `type` VARCHAR(16) NOT NULL, - `participants` JSON NOT NULL + `type` VARCHAR(16) NOT NULL ); CREATE TABLE `messages` ( - `id` INT AUTO_INCREMENT PRIMARY KEY, + `id` INT NOT NULL PRIMARY KEY, `chat_id` INT NOT NULL, `sender` INT NOT NULL, `time` VARCHAR(32) NOT NULL, @@ -58,4 +56,11 @@ CREATE TABLE `messages` ( `cid` VARCHAR(32) NOT NULL, `elements` JSON NOT NULL, `type` VARCHAR(16) NOT NULL -); \ No newline at end of file +); + +CREATE TABLE `chat_participants` ( + `chat_id` INT NOT NULL, + `user_id` INT NOT NULL, + `joined_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`chat_id`, `user_id`) +);