Compare commits

..

18 Commits

Author SHA1 Message Date
Alexey Polyakov
11b2e2748d Удалил tamtam.proto, поскольку нигде больше не используется 2026-03-19 01:16:35 +03:00
Alexey Polyakov
6c05b5f1b5 Швырнул архитектуру, чтобы позже объединить контроллеры веба и сокета в одно, а также разделить процессоры 2026-03-19 01:13:12 +03:00
Alexey Polyakov
2dab853569 Доделал авторизацию в ТамТаме 2026-03-19 00:10:21 +03:00
Alexey Polyakov
c7eace4648 Дополнил немного 48 опкод, и починил отдачу истории в избранном (избранное все равно не работает) 2026-03-18 22:16:31 +03:00
Alexey Polyakov
09c1f8b6f6 История сообщений (вроде как даже работает) 2026-03-18 21:55:24 +03:00
Alexey Polyakov
9c3038bc84 Починил изменение профиля 2026-03-18 21:06:33 +03:00
Alexey Polyakov
91453d7173 Серверный конфиг снова сломался 2026-03-18 20:54:59 +03:00
WowInceptionGood
d5ea45cb96 SQLite: Ошибка
И вообще не юзайте sqlite пожалуйста, он баганный
2026-03-16 00:11:50 +03:00
zavolo
2d09f52c2e feat: 23 опкод для регистрации, смс шлюз, докер 2026-03-15 13:25:40 -04:00
zavolo
d4d5dd5530 feat: 16 опкод для обновления профиля 2026-03-15 11:39:32 +03:00
Alexey Polyakov
81f5fb762f Update deviceId description in tamtam_ws.md 2026-03-12 19:52:38 +03:00
WowInceptionGood
301e55be05 Документация: описал про SESSION_INIT 2026-03-12 16:49:52 +00:00
WowInceptionGood
db3b7323d9 Merge branch 'dev/0.1.0' of https://github.com/openmax-server/server into dev/0.1.0 2026-03-12 16:34:45 +00:00
WowInceptionGood
66fb40a1fd Merge branch 'master' into dev/0.1.0 2026-03-12 16:33:29 +00:00
Alexey Polyakov
9004566652 Merge branch 'master' into dev/0.1.0 2026-03-12 19:30:37 +03:00
Alexey Polyakov
07dd71b0ad Теперь все должно работать исправно 2026-03-12 19:25:09 +03:00
Alexey Polyakov
1ec1d49424 Merge branch 'master' into dev/0.1.0 2026-03-11 20:44:19 +03:00
Alexey Polyakov
9034485408 Различные фиксы (#14)
* Исключаем только ошибку валидации

* Небольшие правки вебсокета тамтама

* Теперь номера брутить чутка сложнее

* Авторизация теперь проверяется для некоторых команд

* Теперь проверяется orign у вебсокета тамтама

* Дополнил пример env

* Починил немного сокет тамтама
2026-03-11 15:21:49 +03:00
57 changed files with 2216 additions and 1394 deletions

View File

@@ -17,10 +17,13 @@ db_name = "openmax"
db_file = "" db_file = ""
certfile = "cert.pem" certfile = "/certs/cert.pem"
keyfile = "key.pem" keyfile = "/certs/key.pem"
domain = "openmax.su"
avatar_base_url = "http://127.0.0.1/avatar/" avatar_base_url = "http://127.0.0.1/avatar/"
telegram_bot_token = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ" telegram_bot_token = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
telegram_bot_enabled = "1" telegram_bot_enabled = "1"
telegram_whitelist_ids = "1,2,3" 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"

16
Dockerfile Normal file
View File

@@ -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"]

39
docker-compose.yml Normal file
View File

@@ -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:

1
docs/proto/oneme_tcp.md Normal file
View File

@@ -0,0 +1 @@
TODO

69
docs/proto/tamtam_ws.md Normal file
View File

@@ -0,0 +1,69 @@
# Описание протокола TamTam по Websocket
## Основная информация
В веб версии мессенджера ТамТам используется протокол, работающий поверх Websocket.
Пакеты в этом протоколе являются текстовыми JSON данными.
Структура пакета:
```
{
ver: int,
cmd: int,
seq: int,
opcode: int,
payload: {}
}
```
* ver - версия протокола
* cmd - определяет, от кого отправлен пакет. клиент - 0, сервер - 1
* seq - порядковый номер пакета (сервер дублирует его из запроса клиента)
* opcode - команда
* payload - полезная нагрузка команды
## Команды протокола
### PING (1)
Клиент периодически отправляет пакет с командой PING и нагрузкой "{"interactive": true}".
Сервер отвечает ему тем же.
### SESSION_INIT (6)
Первый пакет, который клиент отправляет на сервер после подключения. Полезная нагрузка:
```
{
"userAgent": {
"deviceType": "WEB",
"appVersion": "версия приложения",
"osVersion": "операционная система",
"locale": "язык приложения",
"deviceLocale": "язык устройства",
"deviceName": "название устройства",
"screen": "размер экрана..?",
"headerUserAgent": "юзерагент устройства",
"timezone": "часовой пояс"
},
"deviceId": "ID устройства"
}
```
Сервер отвечает ему пакетом с тем же опкодом, но другой нагрузкой:
```
{
"proxy": "msgproxy.okcdn.ru",
"logs-enabled": false,
"proxy-domains": [
"okcdn.ru",
"mycdn.me",
"ok.ru",
"odnoklassniki.ru",
"odkl.ru",
"vk.com",
"userapi.com",
"vkuser.net",
"vkusercdn.ru"
],
"location": "RU",
"libh-enabled": true
}
```

View File

@@ -1,18 +1,80 @@
# Установка # Установка
## Вручную
1. Склонируйте репозиторий 1. Склонируйте репозиторий
2. Установите зависимости 2. Установите зависимости
```bash ```bash
pip install -r requirements.txt 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 ```bash
python3 main.py python3 main.py
``` ```
6. Создайте пользователя 7. Создайте пользователя через Telegram бот (`/register`)
7. Зайдите со своего любимого клиента 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"
```

View File

@@ -22,6 +22,3 @@
2. Открываем консоль в той же директории и производим декомпиляцию: `apktool d <имя apk> -o max` 2. Открываем консоль в той же директории и производим декомпиляцию: `apktool d <имя apk> -o max`
3. Заходим в папку проекта и заменяем во всех классах "api.oneme.ru" на свой адрес сервера 3. Заходим в папку проекта и заменяем во всех классах "api.oneme.ru" на свой адрес сервера
4. Производим повторную сборку с помощью команды: `apktool b max -o max_modified.apk` 4. Производим повторную сборку с помощью команды: `apktool b max -o max_modified.apk`
> [!WARNING]
> Если у вас возникает ошибка при при повторной сборке, попробуйте декомпилировать клиент с параметром -r

View File

@@ -1,8 +0,0 @@
# Патч MAX для IOS
1) Распаковываете IPA
2) Находите файл MAX в Payload/Max.app
3) Открываете hex-редактор и находите api.oneme.ru и меняете на свой
При желании можете поменять другие URL
URL не должен привышать количество символов которое было у изначального URL!
4) Открываете IPA как архив и добавляете патченный файл MAX в Payload/Max.app

View File

@@ -1,8 +1,7 @@
# Навигация по faq # Навигация по faq
## Работа с сервером ## Работа с сервером
- [Установка сервера](install.md) [Установка сервера](install.md)
## Патчинг клиентов ## Патчинг клиентов
- [Патч мобильного клиента для Android](patch_apk.md) [Патч apk](patch_apk.md)
- [Патч мобильного клиента для IOS](patch_ipa.md)

View File

@@ -1,11 +0,0 @@
> [!Caution]
>
> На данной странице представлены инстансы от сторонних разработчиков.
>
> Используйте на свой страх и риск
>
# Открытые сервера
* [JustMAX](https://t.me/justmax_official)
* [mox.nyako.tk](https://t.me/opengrame/296), порт 443 (регистрация по реальному номеру)

View File

@@ -1,9 +1,9 @@
> [!Caution] > [!Caution]
> >
> Проект находится на ранней стадии разработки и, вероятно, полон багов. > Проект находится на ранней стадии разработки и вероятно полон багов.
> >
> Использование в профессиональных средах не рекомендовано. > Использование в профессиональных средах не рекомендовано.
>
# OpenMAX # OpenMAX
Эмулятор сервера MAX и ТамТам Эмулятор сервера MAX и ТамТам
@@ -13,7 +13,7 @@ https://t.me/openmax_alerts
# Требования # Требования
- Python 3.12+ (поддержка версий ниже не гарантирована) - Python 3.12+ (поддержка версий ниже не гарантирована)
- MariaDB, MySQL или SQLite (использование последнего не рекомендуется, так как поддержка ещё в разработке) - MariaDB, MySQL или SQLite
- Уметь патчить клиент MAX или собирать Komet из исходного кода (естественно с заменой сервера) - Уметь патчить клиент MAX или собирать Komet из исходного кода (естественно с заменой сервера)
- Сертификат и приватный ключ X.509 (для тестирования сервера можно сгенерировать самоподписанный: ```openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365```) - Сертификат и приватный ключ X.509 (для тестирования сервера можно сгенерировать самоподписанный: ```openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365```)
@@ -22,6 +22,4 @@ https://t.me/openmax_alerts
Клиент может быть практически любым, главное условие - чтобы он был совместим с официальным сервером (`api.oneme.ru` / `api.tamtam.chat`). Клиент может быть практически любым, главное условие - чтобы он был совместим с официальным сервером (`api.oneme.ru` / `api.tamtam.chat`).
# Дополнительная информация # Дополнительная информация
- [FAQ](faq/readme.md) [Faq](faq/readme.md)
- [Документация проекта](https://github.com/openmax-server/docs)
- [Публичные сервера](faq/servers.md)

View File

@@ -5,4 +5,6 @@ lz4
websockets websockets
pydantic pydantic
aiosqlite aiosqlite
aiohttp
python-dotenv python-dotenv
cryptography

12
sms-gateway/Dockerfile Normal file
View File

@@ -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"]

90
sms-gateway/README.md Normal file
View File

@@ -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`

79
sms-gateway/app/config.py Normal file
View File

@@ -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()

20
sms-gateway/app/deps.py Normal file
View File

@@ -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

41
sms-gateway/app/main.py Normal file
View File

@@ -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"}

View File

@@ -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

View File

@@ -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

View File

@@ -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"},
)

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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()
]

View File

@@ -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}

View File

@@ -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,
)

View File

@@ -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

34
sms-gateway/config.yaml Normal file
View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -45,3 +45,9 @@ class ServerConfig:
telegram_bot_token = os.getenv("telegram_bot_token") or "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ" telegram_bot_token = os.getenv("telegram_bot_token") or "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
telegram_bot_enabled = bool(os.getenv("telegram_bot_enabled")) or True 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()] 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"

156
src/common/opcodes.py Normal file
View File

@@ -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

View File

@@ -1,6 +1,6 @@
import lz4.block, msgpack, logging, json import lz4.block, msgpack, logging, json
class Proto: class MobileProto:
def __init__(self) -> None: def __init__(self) -> None:
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@@ -95,19 +95,3 @@ class Proto:
CMD_NOF = 0x200 CMD_NOF = 0x200
CMD_ERR = 0x300 CMD_ERR = 0x300
PROTO_VER = 10 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

48
src/common/proto_web.py Normal file
View File

@@ -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

34
src/common/sms.py Normal file
View File

@@ -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

View File

@@ -47,12 +47,48 @@ class Tools:
else: else:
return contact 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 = { if isinstance(participants, dict):
str(participant): 0 for participant in participants 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 result = None
@@ -69,6 +105,7 @@ class Tools:
"lastDelayedUpdateTime": 0, "lastDelayedUpdateTime": 0,
"lastFireDelayedErrorTime": 0, "lastFireDelayedErrorTime": 0,
"created": 1, "created": 1,
"prevMessageId": prevMessageId,
"joinTime": 1, "joinTime": 1,
"modified": lastEventTime "modified": lastEventTime
} }
@@ -95,11 +132,14 @@ class Tools:
chatId, db_pool chatId, db_pool
) )
# Формируем список участников # Формируем список участников с временем последней активности
participants = { participant_ids = json.loads(row.get("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)
# Выносим результат в лист # Выносим результат в лист
chats.append( chats.append(
self.generate_chat( self.generate_chat(
@@ -108,7 +148,8 @@ class Tools:
row.get("type"), row.get("type"),
participants, participants,
message, message,
messageTime messageTime,
prevMessageId
) )
) )
@@ -120,15 +161,23 @@ class Tools:
# ID избранного # ID избранного
chatId = senderId ^ senderId 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)
# Хардкодим в лист чатов избранное # Хардкодим в лист чатов избранное
chats.append( chats.append(
self.generate_chat( self.generate_chat(
chatId, chatId,
senderId, senderId,
"DIALOG", "DIALOG",
[senderId], participants,
message, message,
messageTime messageTime,
prevMessageId
) )
) )
@@ -183,3 +232,55 @@ class Tools:
# Возвращаем # Возвращаем
return message, int(row.get("time")) return message, int(row.get("time"))
async def get_previous_message_id(self, chatId, db_pool):
"""Получение 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 int(row.get("id"))
# В ином случае возвращаем 0
return 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 auth_required(self, userPhone, coro, *args):
if userPhone:
await coro(*args)

View File

@@ -1,10 +1,9 @@
# Импортирование библиотек # Импортирование библиотек
import ssl, logging, asyncio import ssl, logging, asyncio
from common.config import ServerConfig from common.config import ServerConfig
from oneme_tcp.controller import OnemeMobileController from oneme.controller import OnemeMobileController
from telegrambot.controller import TelegramBotController from telegrambot.controller import TelegramBotController
from tamtam_tcp.controller import TTMobileController from tamtam.controller import TTMobileController
from tamtam_ws.controller import TTWSController
# Конфиг сервера # Конфиг сервера
server_config = ServerConfig() server_config = ServerConfig()
@@ -27,7 +26,7 @@ async def init_db():
elif server_config.db_type == "sqlite": elif server_config.db_type == "sqlite":
import aiosqlite import aiosqlite
raw_db = await aiosqlite.connect(server_config.db_file) raw_db = await aiosqlite.connect(server_config.db_file)
db["acquire"] = raw_db db["acquire"] = lambda: raw_db
# Возвращаем # Возвращаем
return db return db
@@ -68,13 +67,13 @@ async def main():
"db": db, "db": db,
"ssl": ssl_context, "ssl": ssl_context,
"clients": clients, "clients": clients,
"event": api_event "event": api_event,
"origins": server_config.origins
} }
controllers = { controllers = {
"oneme_mobile": OnemeMobileController(), "oneme_mobile": OnemeMobileController(),
"tamtam_mobile": TTMobileController(), "tamtam_mobile": TTMobileController(),
"tamtam_ws": TTWSController(),
"telegrambot": TelegramBotController() "telegrambot": TelegramBotController()
} }

7
src/oneme/config.py Normal file
View File

@@ -0,0 +1,7 @@
class OnemeConfig:
def __init__(self):
pass
SERVER_CONFIG = {
}

View File

@@ -1,13 +1,16 @@
import asyncio import asyncio
from oneme_tcp.server import OnemeMobileServer from oneme.socket import OnemeMobileServer
from oneme_tcp.proto import Proto from common.proto_tcp import MobileProto
from common.proto_web import WebProto
from classes.controllerbase import ControllerBase from classes.controllerbase import ControllerBase
from common.config import ServerConfig from common.config import ServerConfig
from common.opcodes import Opcodes
class OnemeMobileController(ControllerBase): class OnemeMobileController(ControllerBase):
def __init__(self): def __init__(self):
self.config = ServerConfig() self.config = ServerConfig()
self.proto = Proto() self.proto = MobileProto()
self.opcodes = Opcodes()
async def event(self, target, client, eventData): async def event(self, target, client, eventData):
# Извлекаем тип события и врайтер # Извлекаем тип события и врайтер
@@ -34,7 +37,7 @@ class OnemeMobileController(ControllerBase):
# Создаем пакет # Создаем пакет
packet = self.proto.pack_packet( 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": elif eventType == "typing":
# Данные события # Данные события
@@ -51,7 +54,20 @@ class OnemeMobileController(ControllerBase):
# Создаем пакет # Создаем пакет
packet = self.proto.pack_packet( 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
) )
# Отправляем пакет # Отправляем пакет

View File

@@ -94,3 +94,36 @@ class SearchUsersPayloadModel(pydantic.BaseModel):
class ComplainReasonsGetPayloadModel(pydantic.BaseModel): class ComplainReasonsGetPayloadModel(pydantic.BaseModel):
complainSync: int 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

View File

@@ -1,17 +1,24 @@
import json, secrets, hashlib, time, logging import json
from oneme_tcp.models import * import secrets
from oneme_tcp.proto import Proto import hashlib
from oneme_tcp.config import OnemeConfig import time
import logging
from oneme.models import *
from common.proto_tcp import MobileProto
from common.proto_web import WebProto
from common.opcodes import Opcodes
from oneme.config import OnemeConfig
from common.tools import Tools from common.tools import Tools
from common.config import ServerConfig from common.config import ServerConfig
from common.static import Static from common.static import Static
from common.sms import send_sms_code
class Processors: class Processors:
def __init__(self, db_pool=None, clients={}, send_event=None, telegram_bot=None): def __init__(self, db_pool=None, clients={}, send_event=None, telegram_bot=None, type="socket"):
self.proto = Proto()
self.tools = Tools() self.tools = Tools()
self.config = ServerConfig() self.config = ServerConfig()
self.static = Static() self.static = Static()
self.opcodes = Opcodes()
self.server_config = OnemeConfig().SERVER_CONFIG self.server_config = OnemeConfig().SERVER_CONFIG
self.error_types = self.static.ErrorTypes() self.error_types = self.static.ErrorTypes()
self.chat_types = self.static.ChatTypes() self.chat_types = self.static.ChatTypes()
@@ -22,6 +29,11 @@ class Processors:
self.telegram_bot = telegram_bot self.telegram_bot = telegram_bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
if type == "socket":
self.proto = MobileProto()
elif type == "web":
self.proto = WebProto()
async def _send(self, writer, packet): async def _send(self, writer, packet):
try: try:
writer.write(packet) writer.write(packet)
@@ -43,13 +55,14 @@ class Processors:
await self._send(writer, packet) await self._send(writer, packet)
async def process_hello(self, payload, seq, writer): async def session_init(self, payload, seq, writer):
"""Обработчик приветствия""" """Обработчик приветствия"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
HelloPayloadModel.model_validate(payload) HelloPayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.HELLO, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.SESSION_INIT, self.error_types.INVALID_PAYLOAD, writer)
return None, None return None, None
# Получаем данные из пакета # Получаем данные из пакета
@@ -74,82 +87,103 @@ class Processors:
# Собираем пакет # Собираем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.SESSION_INIT, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.SESSION_INIT, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
return deviceType, deviceName return deviceType, deviceName
async def process_ping(self, payload, seq, writer): async def ping(self, payload, seq, writer):
"""Обработчик пинга""" """Обработчик пинга"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
PingPayloadModel.model_validate(payload) PingPayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.PING, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.PING, self.error_types.INVALID_PAYLOAD, writer)
return return
# Собираем пакет # Собираем пакет
response = self.proto.pack_packet( response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.PING, payload=None cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.PING, payload=None
) )
# Отправляем # Отправляем
await self._send(writer, response) await self._send(writer, response)
async def process_telemetry(self, payload, seq, writer): async def log(self, payload, seq, writer):
"""Обработчик телеметрии""" """Обработчик телеметрии"""
# TODO: можно было бы реализовать валидацию телеметрии, но сейчас это не особо важно # TODO: можно было бы реализовать валидацию телеметрии, но сейчас это не особо важно
# Собираем пакет # Собираем пакет
response = self.proto.pack_packet( response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.LOG, payload=None cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOG, payload=None
) )
# Отправляем # Отправляем
await self._send(writer, response) await self._send(writer, response)
async def process_request_code(self, payload, seq, writer): async def auth_request(self, payload, seq, writer):
"""Обработчик запроса кода""" """Обработчик запроса кода"""
# Валидируем данные пакета
try: try:
RequestCodePayloadModel.model_validate(payload) RequestCodePayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.AUTH_REQUEST, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.AUTH_REQUEST, self.error_types.INVALID_PAYLOAD, writer)
return return
# Извлекаем телефон из пакета # Извлекаем телефон из пакета
phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "") phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "")
# Генерируем токен с кодом (безопасность прежде всего) # Генерируем токен
code = str(secrets.randbelow(900000) + 100000)
token = secrets.token_urlsafe(128) token = secrets.token_urlsafe(128)
# Хешируем
code_hash = hashlib.sha256(code.encode()).hexdigest()
token_hash = hashlib.sha256(token.encode()).hexdigest() token_hash = hashlib.sha256(token.encode()).hexdigest()
# Время истечения токена # Время истечения токена
expires = int(time.time()) + 300 expires = int(time.time()) + 300
# Ищем пользователя, и если он существует, сохраняем токен user_exists = False
# Ищем пользователя
async with self.db_pool.acquire() as conn: async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor: async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,)) await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,))
user = await cursor.fetchone() user = await cursor.fetchone()
# Если пользователя нет - отдаем ошибку # Получаем код через SMS шлюз или генерируем локально (безопасность прежде всего)
if user is None: if self.config.sms_gateway_url:
await self._send_error(seq, self.proto.AUTH_REQUEST, self.error_types.USER_NOT_FOUND, writer) code = await send_sms_code(self.config.sms_gateway_url, phone)
return
# Сохраняем токен if code is None:
await cursor.execute("INSERT INTO auth_tokens (phone, token_hash, code_hash, expires) VALUES (%s, %s, %s, %s)", (phone, token_hash, code_hash, expires,)) code = str(secrets.randbelow(900000) + 100000)
else:
code = str(secrets.randbelow(900000) + 100000)
# Если тг бот включен, и тг привязан к аккаунту - отправляем туда сообщение # Хешируем
if self.telegram_bot and user.get("telegram_id"): code_hash = hashlib.sha256(code.encode()).hexdigest()
await self.telegram_bot.send_code(chat_id=int(user.get("telegram_id")), phone=phone, code=code)
# Сохраняем токен и если нужно отправляем код через тг
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 = { payload = {
@@ -162,20 +196,20 @@ class Processors:
# Собираем пакет # Собираем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.AUTH_REQUEST, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH_REQUEST, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
self.logger.debug(f"Код для {phone}: {code}") self.logger.debug(f"Код для {phone}: {code} (существующий={user_exists})")
async def process_verify_code(self, payload, seq, writer, deviceType, deviceName): async def auth(self, payload, seq, writer, deviceType, deviceName):
"""Обработчик проверки кода""" """Обработчик проверки кода"""
# Валидируем данные пакета
try: try:
VerifyCodePayloadModel.model_validate(payload) VerifyCodePayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.AUTH, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.AUTH, self.error_types.INVALID_PAYLOAD, writer)
return return
# Извлекаем данные из пакета # Извлекаем данные из пакета
@@ -194,17 +228,41 @@ class Processors:
async with self.db_pool.acquire() as conn: async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor: async with conn.cursor() as cursor:
# Ищем токен # Ищем токен
await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", (hashed_token,)) await cursor.execute(
"SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()",
(hashed_token,)
)
stored_token = await cursor.fetchone() stored_token = await cursor.fetchone()
# Если токен просрочен, или его нет - отправляем ошибку # Если токен просрочен, или его нет - отправляем ошибку
if stored_token is None: if stored_token is None:
await self._send_error(seq, self.proto.AUTH, self.error_types.CODE_EXPIRED, writer) await self._send_error(seq, self.opcodes.AUTH, self.error_types.CODE_EXPIRED, writer)
return return
# Проверяем код # Проверяем код
if stored_token.get("code_hash") != hashed_code: if stored_token.get("code_hash") != hashed_code:
await self._send_error(seq, self.proto.AUTH, self.error_types.INVALID_CODE, writer) 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 return
# Ищем аккаунт # Ищем аккаунт
@@ -252,19 +310,143 @@ class Processors:
# Создаем пакет # Создаем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.AUTH, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
async def process_login(self, payload, seq, writer): 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
(phone, telegram_id, firstname, lastname, username,
profileoptions, options, accountstatus, updatetime, lastseen)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
phone, None, first_name, last_name, None,
json.dumps([]), json.dumps(["ONEME"]),
0, str(now_ms), str(now_s),
)
)
user_id = cursor.lastrowid
# Добавляем данные аккаунта
await cursor.execute(
"""
INSERT INTO user_data
(phone, chats, contacts, folders, user_config, chat_config)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
phone,
json.dumps([]), 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: try:
LoginPayloadModel.model_validate(payload) LoginPayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.LOGIN, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.LOGIN, self.error_types.INVALID_PAYLOAD, writer)
return return
# Получаем данные из пакета # Получаем данные из пакета
@@ -281,7 +463,7 @@ class Processors:
# Если токен не найден, отправляем ошибку # Если токен не найден, отправляем ошибку
if token_data is None: if token_data is None:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer) await self._send_error(seq, self.opcodes.LOGIN, self.error_types.INVALID_TOKEN, writer)
return return
# Ищем аккаунт пользователя в бд # Ищем аккаунт пользователя в бд
@@ -338,14 +520,14 @@ class Processors:
# Собираем пакет # Собираем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.LOGIN, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOGIN, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
return int(user.get("phone")), int(user.get("id")), hashed_token return int(user.get("phone")), int(user.get("id")), hashed_token
async def process_logout(self, seq, writer, hashedToken): async def logout(self, seq, writer, hashedToken):
"""Обработчик завершения сессии""" """Обработчик завершения сессии"""
# Удаляем токен из бд # Удаляем токен из бд
async with self.db_pool.acquire() as conn: async with self.db_pool.acquire() as conn:
@@ -354,19 +536,20 @@ class Processors:
# Создаем пакет # Создаем пакет
response = self.proto.pack_packet( response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.LOGOUT, payload=None cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOGOUT, payload=None
) )
# Отправляем # Отправляем
await self._send(writer, response) await self._send(writer, response)
async def process_get_assets(self, payload, seq, writer): async def assets_update(self, payload, seq, writer):
"""Обработчик запроса ассетов клиента на сервере""" """Обработчик запроса ассетов клиента на сервере"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
AssetsPayloadModel.model_validate(payload) AssetsPayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.ASSETS_UPDATE, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_UPDATE, self.error_types.INVALID_PAYLOAD, writer)
return return
# TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию # TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию
@@ -379,19 +562,20 @@ class Processors:
# Собираем пакет # Собираем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.ASSETS_UPDATE, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_UPDATE, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
async def process_get_call_history(self, payload, seq, writer): async def video_chat_history(self, payload, seq, writer):
"""Обработчик получения истории звонков""" """Обработчик получения истории звонков"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
GetCallHistoryPayloadModel.model_validate(payload) GetCallHistoryPayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.VIDEO_CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.VIDEO_CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer)
return return
# TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию # TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию
@@ -406,19 +590,20 @@ class Processors:
# Собираем пакет # Собираем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.VIDEO_CHAT_HISTORY, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.VIDEO_CHAT_HISTORY, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
async def process_send_message(self, payload, seq, writer, senderId, db_pool): async def msg_send(self, payload, seq, writer, senderId, db_pool):
"""Функция отправки сообщения""" """Функция отправки сообщения"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
SendMessagePayloadModel.model_validate(payload) SendMessagePayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.MSG_SEND, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.INVALID_PAYLOAD, writer)
return return
# Извлекаем данные из пакета # Извлекаем данные из пакета
@@ -431,11 +616,6 @@ class Processors:
cid = message.get("cid") or 0 cid = message.get("cid") or 0
text = message.get("text") or "" 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) messageTime = int(time.time() * 1000)
@@ -460,7 +640,7 @@ class Processors:
# Если нет такого чата - выбрасываем ошибку # Если нет такого чата - выбрасываем ошибку
if not chat: if not chat:
await self._send_error(seq, self.proto.MSG_SEND, self.error_types.CHAT_NOT_FOUND, writer) await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CHAT_NOT_FOUND, writer)
return return
# Список участников # Список участников
@@ -468,7 +648,7 @@ class Processors:
# Проверяем, является ли отправитель участником чата # Проверяем, является ли отправитель участником чата
if int(senderId) not in participants: if int(senderId) not in participants:
await self._send_error(seq, self.proto.MSG_SEND, self.error_types.CHAT_NOT_ACCESS, writer) await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CHAT_NOT_ACCESS, writer)
return return
# Добавляем сообщение в историю # Добавляем сообщение в историю
@@ -518,19 +698,20 @@ class Processors:
# Собираем пакет # Собираем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.MSG_SEND, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.MSG_SEND, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
async def process_get_folders(self, payload, seq, writer, senderPhone): async def folders_get(self, payload, seq, writer, senderPhone):
"""Синхронизация папок с сервером""" """Синхронизация папок с сервером"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
SyncFoldersPayloadModel.model_validate(payload) SyncFoldersPayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.FOLDERS_GET, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.FOLDERS_GET, self.error_types.INVALID_PAYLOAD, writer)
return return
# Ищем папки в бд # Ищем папки в бд
@@ -550,13 +731,13 @@ class Processors:
# Собираем пакет # Собираем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.FOLDERS_GET, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.FOLDERS_GET, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
async def process_get_sessions(self, payload, seq, writer, senderPhone, hashedToken): async def sessions_info(self, payload, seq, writer, senderPhone, hashedToken):
"""Получение активных сессий на аккаунте""" """Получение активных сессий на аккаунте"""
# Готовый список сессий # Готовый список сессий
sessions = [] sessions = []
@@ -586,19 +767,20 @@ class Processors:
# Создаем пакет # Создаем пакет
response = self.proto.pack_packet( response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.SESSIONS_INFO, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.SESSIONS_INFO, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, response) await self._send(writer, response)
async def process_search_users(self, payload, seq, writer): async def contact_info(self, payload, seq, writer):
"""Поиск пользователей по ID""" """Поиск пользователей по ID"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
SearchUsersPayloadModel.model_validate(payload) SearchUsersPayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.CONTACT_INFO, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CONTACT_INFO, self.error_types.INVALID_PAYLOAD, writer)
return return
# Итоговый список пользователей # Итоговый список пользователей
@@ -647,19 +829,20 @@ class Processors:
# Создаем пакет # Создаем пакет
response = self.proto.pack_packet( response = self.proto.pack_packet(
seq=seq, opcode=self.proto.CONTACT_INFO, payload=payload seq=seq, opcode=self.opcodes.CONTACT_INFO, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, response) await self._send(writer, response)
async def process_search_chats(self, payload, seq, writer, senderId): async def chat_info(self, payload, seq, writer, senderId):
"""Поиск чатов по ID""" """Поиск чатов по ID"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
SearchChatsPayloadModel.model_validate(payload) SearchChatsPayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.CHAT_INFO, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CHAT_INFO, self.error_types.INVALID_PAYLOAD, writer)
return return
# Итоговый список чатов # Итоговый список чатов
@@ -722,19 +905,20 @@ class Processors:
# Собираем пакет # Собираем пакет
response = self.proto.pack_packet( response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.CHAT_INFO, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CHAT_INFO, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, response) await self._send(writer, response)
async def process_search_by_phone(self, payload, seq, writer, senderId): async def contact_info_by_phone(self, payload, seq, writer, senderId):
"""Поиск по номеру телефона""" """Поиск по номеру телефона"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
SearchByPhonePayloadModel.model_validate(payload) SearchByPhonePayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.CONTACT_INFO_BY_PHONE, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CONTACT_INFO_BY_PHONE, self.error_types.INVALID_PAYLOAD, writer)
return return
# Ищем пользователя в бд # Ищем пользователя в бд
@@ -745,7 +929,7 @@ class Processors:
# Если пользователь не найден, отправляем ошибку # Если пользователь не найден, отправляем ошибку
if not user: if not user:
await self._send_error(seq, self.proto.CONTACT_INFO_BY_PHONE, self.error_types.USER_NOT_FOUND, writer) await self._send_error(seq, self.opcodes.CONTACT_INFO_BY_PHONE, self.error_types.USER_NOT_FOUND, writer)
return return
# ID чата # ID чата
@@ -791,32 +975,34 @@ class Processors:
# Создаем пакет # Создаем пакет
response = self.proto.pack_packet( response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.CONTACT_INFO_BY_PHONE, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_INFO_BY_PHONE, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, response) await self._send(writer, response)
async def process_get_call_token(self, payload, seq, writer): async def ok_token(self, payload, seq, writer):
"""Получение токена для звонка""" """Получение токена для звонка"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
GetCallTokenPayloadModel.model_validate(payload) GetCallTokenPayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.OK_TOKEN, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.OK_TOKEN, self.error_types.INVALID_PAYLOAD, writer)
return return
# TODO: когда-то взяться за звонки # TODO: когда-то взяться за звонки
await self._send_error(seq, self.proto.OK_TOKEN, self.error_types.NOT_IMPLEMENTED, writer) await self._send_error(seq, self.opcodes.OK_TOKEN, self.error_types.NOT_IMPLEMENTED, writer)
async def process_typing(self, payload, seq, writer, senderId): async def msg_typing(self, payload, seq, writer, senderId):
"""Обработчик события печатания""" """Обработчик события печатания"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
TypingPayloadModel.model_validate(payload) TypingPayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.MSG_TYPING, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.MSG_TYPING, self.error_types.INVALID_PAYLOAD, writer)
return return
# Извлекаем данные из пакета # Извлекаем данные из пакета
@@ -831,7 +1017,7 @@ class Processors:
# Если чат не найден, отправляем ошибку # Если чат не найден, отправляем ошибку
if not chat: if not chat:
await self._send_error(seq, self.proto.MSG_TYPING, self.error_types.CHAT_NOT_FOUND, writer) await self._send_error(seq, self.opcodes.MSG_TYPING, self.error_types.CHAT_NOT_FOUND, writer)
return return
# Участники чата # Участники чата
@@ -839,7 +1025,7 @@ class Processors:
# Проверяем, является ли отправитель участником чата # Проверяем, является ли отправитель участником чата
if int(senderId) not in participants: if int(senderId) not in participants:
await self._send_error(seq, self.proto.MSG_TYPING, self.error_types.CHAT_NOT_ACCESS, writer) await self._send_error(seq, self.opcodes.MSG_TYPING, self.error_types.CHAT_NOT_ACCESS, writer)
return return
# Рассылаем событие участникам чата # Рассылаем событие участникам чата
@@ -858,19 +1044,20 @@ class Processors:
# Создаем пакет # Создаем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
seq=seq, opcode=self.proto.MSG_TYPING seq=seq, opcode=self.opcodes.MSG_TYPING
) )
# Отправляем пакет # Отправляем пакет
await self._send(writer, packet) await self._send(writer, packet)
async def process_complain_reasons_get(self, payload, seq, writer): async def complain_reasons_get(self, payload, seq, writer):
"""Обработчик получения причин жалоб""" """Обработчик получения причин жалоб"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
ComplainReasonsGetPayloadModel.model_validate(payload) ComplainReasonsGetPayloadModel.model_validate(payload)
except Exception as e: except pydantic.ValidationError as error:
await self._send_error(seq, self.proto.COMPLAIN_REASONS_GET, self.error_types.INVALID_PAYLOAD, writer) self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.COMPLAIN_REASONS_GET, self.error_types.INVALID_PAYLOAD, writer)
return return
# Собираем данные пакета # Собираем данные пакета
@@ -881,8 +1068,184 @@ class Processors:
# Создаем пакет # Создаем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
seq=seq, opcode=self.proto.COMPLAIN_REASONS_GET, payload=payload seq=seq, opcode=self.opcodes.COMPLAIN_REASONS_GET, payload=payload
) )
# Отправляем пакет # Отправляем пакет
await self._send(writer, packet) await self._send(writer, packet)
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 = json.loads(chat.get("participants"))
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 id DESC 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 id 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": {}
})
# Формируем ответ
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)
async def profile(self, payload, seq, writer, userId, userPhone):
# Валидируем входные данные
try:
UpdateProfilePayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.opcodes.PROFILE, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем поля из пакета (каждое может быть None)
description = payload.get("description")
firstName = payload.get("firstName")
lastName = payload.get("lastName")
# Обновляем только те поля, которые пришли в запросе
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
if description is not None:
# При изменении описания также обновляем время последнего изменения профиля
await cursor.execute(
"UPDATE users SET description = %s, updatetime = %s WHERE id = %s",
(description, int(time.time() * 1000), userId)
)
if firstName is not None:
await cursor.execute(
"UPDATE users SET firstname = %s WHERE id = %s",
(firstName, userId)
)
if lastName is not None:
await cursor.execute(
"UPDATE users SET lastname = %s WHERE id = %s",
(lastName, userId)
)
# Получаем актуальные данные пользователя после обновления
await cursor.execute("SELECT * FROM users WHERE id = %s", (userId,))
user = await cursor.fetchone()
# Формируем URL аватарки если она есть
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 + str(photoId)
# Генерируем профиль для отправки клиенту
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=user.get("description"),
accountStatus=int(user.get("accountstatus")),
profileOptions=json.loads(user.get("profileoptions")),
includeProfileOptions=True,
username=user.get("username")
)
# Данные пакета
payload = {
"profile": profile
}
# Отправляем ответ на запрос (CMD_OK)
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.PROFILE, payload=payload
)
await self._send(writer, packet)
# Отправляем всем сессиям о изменении профиля
await self.event(
user.get('id'),
{
"eventType": "profile_updated",
"profile": profile
}
)

228
src/oneme/socket.py Normal file
View File

@@ -0,0 +1,228 @@
import asyncio, logging, traceback
from common.proto_tcp import MobileProto
from oneme.processors import Processors
from common.rate_limiter import RateLimiter
from common.tools import Tools
from common.opcodes import Opcodes
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):
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, userPhone=userPhone
)
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_mobile"
}
)
else:
self.clients[id] = {
"phone": phone,
"id": id,
"clients": [
{
"writer": writer,
"ip": addr[0],
"port": addr[1],
"protocol": "oneme_mobile"
}
]
}
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()

View File

@@ -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
}

View File

@@ -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

View File

@@ -1,5 +1,5 @@
import asyncio import asyncio
from tamtam_tcp.server import TTMobileServer from tamtam.socket import TTMobileServer
from classes.controllerbase import ControllerBase from classes.controllerbase import ControllerBase
from common.config import ServerConfig from common.config import ServerConfig

View File

@@ -28,3 +28,7 @@ class FinalAuthPayloadModel(pydantic.BaseModel):
tokenType: str tokenType: str
deviceId: str deviceId: str
token: str token: str
class LoginPayloadModel(pydantic.BaseModel):
interactive: bool
token: str

View File

@@ -6,21 +6,30 @@ import json
import re import re
from common.static import Static from common.static import Static
from common.tools import Tools from common.tools import Tools
from tamtam_tcp.proto import Proto
from tamtam_tcp.models import * from common.proto_tcp import MobileProto
from common.proto_web import WebProto
from common.opcodes import Opcodes
from tamtam.models import *
class Processors: class Processors:
def __init__(self, db_pool=None, clients=None, send_event=None): def __init__(self, db_pool=None, clients=None, send_event=None, type="socket"):
if clients is None: if clients is None:
clients = {} # Более правильная логика clients = {} # Более правильная логика
self.static = Static() self.static = Static()
self.proto = Proto()
self.tools = Tools() self.tools = Tools()
self.opcodes = Opcodes()
self.error_types = self.static.ErrorTypes() self.error_types = self.static.ErrorTypes()
self.db_pool = db_pool self.db_pool = db_pool
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
if type == "socket":
self.proto = MobileProto()
elif type == "web":
self.proto = WebProto()
async def _send(self, writer, packet): async def _send(self, writer, packet):
try: try:
writer.write(packet) writer.write(packet)
@@ -42,13 +51,13 @@ class Processors:
await self._send(writer, packet) await self._send(writer, packet)
async def process_hello(self, payload, seq, writer): async def session_init(self, payload, seq, writer):
"""Обработчик приветствия""" """Обработчик приветствия"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
HelloPayloadModel.model_validate(payload) HelloPayloadModel.model_validate(payload)
except Exception as e: except Exception as e:
await self._send_error(seq, self.proto.HELLO, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.opcodes.SESSION_INIT, self.error_types.INVALID_PAYLOAD, writer)
return None, None return None, None
# Получаем данные из пакета # Получаем данные из пакета
@@ -67,20 +76,20 @@ class Processors:
# Собираем пакет # Собираем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.HELLO, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.SESSION_INIT, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
return device_type, device_name return device_type, device_name
async def process_request_code(self, payload, seq, writer): async def auth_request(self, payload, seq, writer):
"""Обработчик запроса кода""" """Обработчик запроса кода"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
RequestCodePayloadModel.model_validate(payload) RequestCodePayloadModel.model_validate(payload)
except Exception as e: except Exception as e:
await self._send_error(seq, self.proto.REQUEST_CODE, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.opcodes.AUTH_REQUEST, self.error_types.INVALID_PAYLOAD, writer)
return return
# Извлекаем телефон из пакета # Извлекаем телефон из пакета
@@ -103,14 +112,10 @@ class Processors:
await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,)) await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,))
user = await cursor.fetchone() user = await cursor.fetchone()
if not user: # Если пользователь существует, сохраняем токен
await self._send_error(seq, self.proto.REQUEST_CODE, self.error_types.USER_NOT_FOUND, writer) if user:
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",))
# Сохраняем токен
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 = { payload = {
@@ -124,21 +129,20 @@ class Processors:
# Собираем пакет # Собираем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.REQUEST_CODE, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH_REQUEST, payload=payload
) )
# Отправляем # Отправляем
await self._send(writer, packet) await self._send(writer, packet)
self.logger.debug(f"Код для {phone}: {code}") self.logger.debug(f"Код для {phone}: {code}")
async def process_verify_code(self, payload, seq, writer): async def auth(self, payload, seq, writer):
"""Обработчик проверки кода""" """Обработчик проверки кода"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
VerifyCodePayloadModel.model_validate(payload) VerifyCodePayloadModel.model_validate(payload)
except Exception as e: except Exception as e:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.opcodes.AUTH, self.error_types.INVALID_PAYLOAD, writer)
return return
# Извлекаем данные из пакета # Извлекаем данные из пакета
@@ -158,12 +162,12 @@ class Processors:
stored_token = await cursor.fetchone() stored_token = await cursor.fetchone()
if not stored_token: if not stored_token:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.CODE_EXPIRED, writer) await self._send_error(seq, self.opcodes.AUTH, self.error_types.CODE_EXPIRED, writer)
return return
# Проверяем код # Проверяем код
if stored_token.get("code_hash") != hashed_code: if stored_token.get("code_hash") != hashed_code:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_CODE, writer) await self._send_error(seq, self.opcodes.AUTH, self.error_types.INVALID_CODE, writer)
return return
# Ищем аккаунт # Ищем аккаунт
@@ -171,14 +175,7 @@ class Processors:
account = await cursor.fetchone() account = await cursor.fetchone()
# Обновляем состояние токена # Обновляем состояние токена
await cursor.execute("UPDATE auth_tokens set state = %s WHERE token_hash = %s", await cursor.execute("UPDATE auth_tokens set state = %s WHERE token_hash = %s", ("verified", hashed_token,))
("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()),)
# )
# Генерируем профиль # Генерируем профиль
# Аватарка с биографией # Аватарка с биографией
@@ -188,7 +185,7 @@ class Processors:
# Собираем данные пакета # Собираем данные пакета
payload = { payload = {
"profile": self.tools.generate_profile( "profile": self.tools.generate_profile_tt(
id=account.get("id"), id=account.get("id"),
phone=int(account.get("phone")), phone=int(account.get("phone")),
avatarUrl=avatar_url, avatarUrl=avatar_url,
@@ -198,12 +195,8 @@ class Processors:
lastName=account.get("lastname"), lastName=account.get("lastname"),
options=json.loads(account.get("options")), options=json.loads(account.get("options")),
description=description, description=description,
accountStatus=int(account.get("accountstatus")), username=account.get("username")
profileOptions=json.loads(account.get("profileoptions")), ),
includeProfileOptions=False,
username=account.get("username"),
type="TT"
).get("contact"),
"tokenAttrs": { "tokenAttrs": {
"AUTH": { "AUTH": {
"token": token "token": token
@@ -215,18 +208,18 @@ class Processors:
} }
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.VERIFY_CODE, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH, payload=payload
) )
await self._send(writer, packet) await self._send(writer, packet)
async def process_final_auth(self, payload, seq, writer, deviceType, deviceName): async def auth_confirm(self, payload, seq, writer, deviceType, deviceName):
"""Обработчик финальной аутентификации""" """Обработчик финальной аутентификации"""
# Валидируем данные пакета # Валидируем данные пакета
try: try:
FinalAuthPayloadModel.model_validate(payload) FinalAuthPayloadModel.model_validate(payload)
except Exception as e: except Exception as e:
await self._send_error(seq, self.proto.FINAL_AUTH, self.error_types.INVALID_PAYLOAD, writer) await self._send_error(seq, self.opcodes.AUTH_CONFIRM, self.error_types.INVALID_PAYLOAD, writer)
return return
# Извлекаем данные из пакета # Извлекаем данные из пакета
@@ -251,18 +244,19 @@ class Processors:
stored_token = await cursor.fetchone() stored_token = await cursor.fetchone()
if stored_token is None: if stored_token is None:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer) await self._send_error(seq, self.opcodes.AUTH_CONFIRM, self.error_types.INVALID_TOKEN, writer)
return return
# Если авторизация только началась - отдаем ошибку
if stored_token.get("state") == "started": if stored_token.get("state") == "started":
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer) await self._send_error(seq, self.opcodes.AUTH_CONFIRM, self.error_types.INVALID_TOKEN, writer)
return return
# Ищем аккаунт # Ищем аккаунт
await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),)) await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),))
account = await cursor.fetchone() account = await cursor.fetchone()
# Обновляем состояние токена # Удаляем токен
await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,)) await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,))
# Создаем сессию # Создаем сессию
@@ -279,8 +273,8 @@ class Processors:
# Собираем данные пакета # Собираем данные пакета
payload = { payload = {
"userToken": "0", "userToken": "0", # Пока как заглушка
"profile": self.tools.generate_profile( "profile": self.tools.generate_profile_tt(
id=account.get("id"), id=account.get("id"),
phone=int(account.get("phone")), phone=int(account.get("phone")),
avatarUrl=avatar_url, avatarUrl=avatar_url,
@@ -290,18 +284,117 @@ class Processors:
lastName=account.get("lastname"), lastName=account.get("lastname"),
options=json.loads(account.get("options")), options=json.loads(account.get("options")),
description=description, description=description,
accountStatus=int(account.get("accountstatus")), username=account.get("username")
profileOptions=json.loads(account.get("profileoptions")), ),
includeProfileOptions=False,
username=account.get("username"),
type="TT"
).get("contact"),
"tokenType": "LOGIN", "tokenType": "LOGIN",
"token": login "token": login
} }
# Создаем пакет
packet = self.proto.pack_packet( packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.FINAL_AUTH, payload=payload cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH_CONFIRM, payload=payload
) )
# Отправялем
await self._send(writer, packet) await self._send(writer, packet)
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
# Получаем данные из пакета
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()
# Аватарка с биографией
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")
# Генерируем профиль
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(
json.loads(user_data.get("chats")),
self.db_pool, user.get("id")
)
# Формируем данные пакета
payload = {
"profile": profile,
"chats": chats,
"chatMarker": 0,
"messages": {},
"contacts": [],
"presence": {},
"config": {
"hash": "0",
"server": {},
"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

View File

@@ -1,10 +1,11 @@
import asyncio, logging, traceback import asyncio, logging, traceback
from oneme_tcp.proto import Proto from common.proto_tcp import MobileProto
from oneme_tcp.processors import Processors from tamtam.processors import Processors
from common.rate_limiter import RateLimiter from common.rate_limiter import RateLimiter
from common.opcodes import Opcodes
class OnemeMobileServer: class TTMobileServer:
def __init__(self, host="0.0.0.0", port=443, ssl_context=None, db_pool=None, clients={}, send_event=None, telegram_bot=None): def __init__(self, host="0.0.0.0", port=443, ssl_context=None, db_pool=None, clients={}, send_event=None):
self.host = host self.host = host
self.port = port self.port = port
self.ssl_context = ssl_context self.ssl_context = ssl_context
@@ -13,10 +14,12 @@ class OnemeMobileServer:
self.db_pool = db_pool self.db_pool = db_pool
self.clients = clients self.clients = clients
self.proto = Proto() self.opcodes = Opcodes()
self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event, telegram_bot=telegram_bot)
# rate limiter anti ddos brute force protection self.proto = MobileProto()
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.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60)
self.read_timeout = 300 # Таймаут чтения из сокета (секунды) self.read_timeout = 300 # Таймаут чтения из сокета (секунды)
@@ -37,7 +40,7 @@ class OnemeMobileServer:
try: try:
while True: while True:
# Читаем новые данные из сокета с таймаутом # Читаем новые данные из сокета (с таймаутом!)
try: try:
data = await asyncio.wait_for( data = await asyncio.wait_for(
reader.read(self.max_read_size), reader.read(self.max_read_size),
@@ -51,7 +54,7 @@ class OnemeMobileServer:
if not data: if not data:
break break
# Проверяем размер данных
if len(data) > self.max_read_size: if len(data) > self.max_read_size:
self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(data)} байт)") self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(data)} байт)")
break break
@@ -59,7 +62,7 @@ class OnemeMobileServer:
# Распаковываем данные # Распаковываем данные
packet = self.proto.unpack_packet(data) packet = self.proto.unpack_packet(data)
# Скип если пакет невалидный # Если пакет невалидный — пропускаем
if packet is None: if packet is None:
self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}") self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}")
continue continue
@@ -69,65 +72,37 @@ class OnemeMobileServer:
payload = packet.get("payload") payload = packet.get("payload")
match opcode: match opcode:
case self.proto.SESSION_INIT: case self.opcodes.SESSION_INIT:
deviceType, deviceName = await self.processors.process_hello(payload, seq, writer) deviceType, deviceName = await self.processors.session_init(payload, seq, writer)
case self.proto.AUTH_REQUEST: case self.opcodes.AUTH_REQUEST:
if not self.auth_rate_limiter.is_allowed(address[0]): 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_REQUEST, self.processors.error_types.RATE_LIMITED, writer)
else: else:
await self.processors.process_request_code(payload, seq, writer) await self.processors.auth_request(payload, seq, writer)
case self.proto.AUTH: case self.opcodes.AUTH:
if not self.auth_rate_limiter.is_allowed(address[0]): 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, self.processors.error_types.RATE_LIMITED, writer)
else: else:
await self.processors.process_verify_code(payload, seq, writer, deviceType, deviceName) await self.processors.auth(payload, seq, writer)
case self.proto.LOGIN: case self.opcodes.AUTH_CONFIRM:
if not self.auth_rate_limiter.is_allowed(address[0]): 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.AUTH_CONFIRM, self.processors.error_types.RATE_LIMITED, writer)
else: else:
userPhone, userId, hashedToken = await self.processors.process_login(payload, seq, writer) 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.opcodes.LOGIN, self.processors.error_types.RATE_LIMITED, writer)
else:
userPhone, userId, hashedToken = await self.processors.login(payload, seq, writer)
if userPhone: if userPhone:
await self._finish_auth(writer, address, userPhone, userId) 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 _: case _:
self.logger.warning(f"Неизвестный опкод {opcode}") self.logger.warning(f"Неизвестный опкод {opcode}")
except Exception as e: except Exception as e:
self.logger.error(f"Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}") self.logger.error(f"Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}")
traceback.print_exc() traceback.print_exc()
# Удаляем клиента из словаря
if userPhone:
await self._end_session(userId, address[0], address[1])
writer.close() writer.close()
self.logger.info(f"Прекратил работать работать с клиентом {address[0]}:{address[1]}") self.logger.info(f"Прекратил работать работать с клиентом {address[0]}:{address[1]}")

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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()