Compare commits

..

56 Commits

Author SHA1 Message Date
Alexey Polyakov
cb058f6ab6 Теперь ID пользователей идут по порядку, как и раньше 2026-04-25 12:10:44 +03:00
Alexey Polyakov
cd9ba981ae MAX: отдача черного списка 2026-04-24 23:31:55 +03:00
Alexey Polyakov
810d480dbd MAX: фикс версий < 25.8.0 2026-04-24 21:30:02 +03:00
Alexey Polyakov
227f90c3c3 MAX: Рефактор папок 2026-04-24 20:54:28 +03:00
Alexey Polyakov
56133416e3 MAX: пуши через firebase (особо не тестил, вроде работает) 2026-04-24 19:46:08 +03:00
Alexey Polyakov
35a4101608 MAX: обновление настроек приватности 2026-04-24 17:17:33 +03:00
Alexey Polyakov
9fcba1af86 MAX: Рабочие баннеры 2026-04-24 15:51:01 +03:00
Alexey Polyakov
4abe6de885 oops 2026-04-20 22:25:38 +03:00
Alexey Polyakov
4121bd0e1d MAX: заглушка для баннеров, правка пакета со списком жалоб, отдача контактов и прочие улучшения 2026-04-20 22:22:02 +03:00
Aleksandr Kosachev
d9798a6fc6 Fix OpenMAX mobile compatibility and Telegram auth fallback (#30)
* Fix OpenMAX mobile compatibility and Telegram auth fallback

* Common: Убрал скобку в конфиге

---------

Co-authored-by: Alexey Polyakov <starwear3000@mail.ru>
2026-04-14 20:05:34 +03:00
Alexey Polyakov
d9cbafc4e3 TT: фикс вебсокета 2026-04-12 07:15:09 +03:00
WowInceptionGood
448b854bdb Исправление конфликтов 2026-04-10 18:27:42 +03:00
Alexey Polyakov
dcbcb0a600 Common: добавил сжатие lz4 2026-04-10 17:43:35 +03:00
Alexey Polyakov
0ffc649dd9 Начальная реализация транспорта ws для max web и прочие улучшения 2026-04-07 12:36:30 +03:00
Alexey Polyakov
52949602af Common: фикс года 2026-04-03 23:02:49 +03:00
Alexey Polyakov
0f5c06f80c TG Bot: переписал на aiogram 2026-03-30 22:34:09 +03:00
Alexey Polyakov
e5607adb9b MAX: Разделил процессоры 2026-03-29 11:04:45 +03:00
Alexey Polyakov
4014bab5c9 Правка контроллеров небольшая 2026-03-27 19:35:59 +03:00
Alexey Polyakov
0b7282b284 TamTam && MAX: история (в мохе она вроде теперь получше работает) 2026-03-27 19:26:20 +03:00
Alexey Polyakov
7a2e5a20d6 TamTam: websocket transport for web version 2026-03-27 19:00:14 +03:00
Alexey Polyakov
ac76015d08 Common: поправил 1 прикол 2026-03-27 17:41:09 +03:00
Alexey Polyakov
f4ea147a5f MAX: заглушка в 158 опкоде 2026-03-22 19:28:52 +03:00
Alexey Polyakov
5fcc56950f TT: oops 2026-03-22 18:56:50 +03:00
Alexey Polyakov
a8732b5d7f ТамТам: починил 32 опкод и добавил серверный конфиг. Избранное больше не добавляется в список чатов
MAX: починил 32 опкод
2026-03-22 18:26:04 +03:00
Alexey Polyakov
aa6411b75d ТамТам: обработчик 5 и 1 опкода 2026-03-22 17:09:20 +03:00
Alexey Polyakov
f2e7e37592 ТамТам: реализация 32 опкода. Надеюсь, будет работать 2026-03-22 16:59:13 +03:00
Alexey Polyakov
8d7a1dacb5 Теперь мы не должны отправлять 128 опкод самому себе. вроде 2026-03-22 16:51:31 +03:00
Alexey Polyakov
ef512b060f Вынес список участников чата в отдельную таблицу 2026-03-21 15:05:38 +03:00
Alexey Polyakov
2cf18b878a Генерируем айди, за вместо того, чтобы писать его попорядку 2026-03-19 23:13:27 +03:00
Alexey Polyakov
bbee49d2d8 Коды стран 2026-03-19 16:48:59 +03:00
Alexey Polyakov
9bc6c15d82 Поделил процессоры в таме 2026-03-19 16:21:48 +03:00
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
whymequestion
e5c7a7baac Security + minor fixes (#16)
* implement ip rate limiting

* fix: secure генерация кода для входа

* fix: possible slowloris and dos attacks

* fix: убрать лишний импорт, не давать сообщения из чата незнакомцам, географически верные названия в дб...

* fix device name не использовался

* refactor: убрал лишние импорты

* refactor: вернул dotenv

* убрал импорт после c642434
2026-03-12 19:22:58 +03:00
Alexey Polyakov
1ec1d49424 Merge branch 'master' into dev/0.1.0 2026-03-11 20:44:19 +03:00
BetaAcccc
582c0f571c Добавление faq (#6)
* Create patch_apk.md

* Create install.md

* Update patch_apk.md

* remove space

* Create readme.md

* Update readme.md

* Update readme.md

* Update patch_apk.md

* Подправил инструкцию по патчу

Сделал чище и постарался сделать грамотнее

---------

Co-authored-by: Alexey Polyakov <starwear3000@mail.ru>
Co-authored-by: WowInceptionGood <143893762+WowInceptionGood@users.noreply.github.com>
2026-03-11 01:00:25 +03:00
WowInceptionGood
fb46d06aab Ладно последнее исправление 2026-03-10 21:43:25 +00:00
WowInceptionGood
fbb451cd39 Исправление 2026-03-10 21:41:57 +00:00
WowInceptionGood
573825e195 Начал писать документацию прото ТамТама 2026-03-10 21:40:49 +00:00
relyay
4d82f55b79 Fix (#11)
* Улучшена генерация кода, пояснения в некоторых участках, очистка номера телефона через регулярные выражения :>

* Именовать переменные snake_case стоит везде, даже если ты достаешь заголовки в такомСтиле

if not object использовать предпочтительнее, т.к. он обрабатывает более широкие случаи, когда достать данные не получилось
2026-03-10 21:59:44 +03:00
devreal95
917db80460 Update config.py (#10) 2026-03-10 21:42:11 +03:00
relyay
cab75a58f8 Merge pull request #9 from relyay/fix
Some fixes
2026-03-10 21:19:54 +03:00
84 changed files with 6276 additions and 2621 deletions

View File

@@ -17,11 +17,14 @@ db_name = "openmax"
db_file = ""
certfile = "cert.pem"
keyfile = "key.pem"
certfile = "/certs/cert.pem"
keyfile = "/certs/key.pem"
domain = "openmax.su"
avatar_base_url = "http://127.0.0.1/avatar/"
telegram_bot_token = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
telegram_bot_enabled = "1"
telegram_whitelist_ids = "1,2,3"
origins="http://127.0.0.1,https://web.openmax.su"
origins="http://127.0.0.1,https://web.openmax.su"
sms_gateway_url = "http://127.0.0.1:8100/sms-gateway"
firebase_credentials_path = ""

4
.gitignore vendored
View File

@@ -1,4 +1,6 @@
__pycache__
.env
*.pem
*.sqlite
*.sqlite
*.crt
*-adminsdk-*.json

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:

View File

@@ -1 +0,0 @@
TODO

80
faq/install.md Normal file
View File

@@ -0,0 +1,80 @@
# Установка
## Вручную
1. Склонируйте репозиторий
2. Установите зависимости
```bash
pip install -r requirements.txt
```
3. Сгенерируйте сертификат
Для тестирования (самоподписанный):
```bash
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365
```
Для прода — [Let's Encrypt](https://certbot.eff.org/):
```bash
apt install certbot
certbot certonly --standalone -d openmax.su
```
4. Настройте сервер (пример в `.env.example`)
5. Импортируйте схему таблиц в свою базу данных из `tables.sql`
6. Запустите сервер
```bash
python3 main.py
```
7. Создайте пользователя через Telegram бот (`/register`)
8. Зайдите со своего любимого клиента
---
## Docker
1. Склонируйте репозиторий
2. Настройте `.env` (пример в `.env.example`), укажите `db_user` отличный от `root`
3. Получите сертификат Let's Encrypt:
```bash
apt install certbot
certbot certonly --standalone -d openmax.su
```
Укажите домен и пути в `.env`:
```
certfile=/certs/cert.pem
keyfile=/certs/key.pem
domain=openmax.su
```
4. Запустите
```bash
docker compose up -d
```
База данных инициализируется автоматически из `tables.sql`.
5. Создайте пользователя через Telegram бот (`/register`)
6. Зайдите со своего любимого клиента
---
## SMS-шлюз
По умолчанию коды авторизации доставляются через Telegram бот. Если вы хотите принимать пользователей с произвольными номерами без привязки к Telegram — поднимите [SMS Gateway](https://github.com/openmax-server/server/sms-gateway), укажите его адрес в `.env` и отключите Telegram бот:
```
telegram_bot_enabled=false
sms_gateway_url=http://localhost:8100/sms-gateway
```
Клиент MAX ожидает 6-значный код. Если ваш SMS-провайдер отправляет 5-значные коды и не поддерживает настройку длины — сервер автоматически дублирует последнюю цифру: `26541``265411`. Пользователь получает SMS с 5 цифрами и вводит их дважды последнюю: `2-6-5-4-1-1`.
---
## Автопродление сертификата
```bash
certbot renew --deploy-hook "docker compose -f /opt/server/docker-compose.yml restart app"
```

47
faq/patch_apk.md Normal file
View File

@@ -0,0 +1,47 @@
# Смена сервера в мобильном клиенте
> [!Caution]
> Инструкция может быть недостаточной, если вы используете самоподписанный сертификат или сертификат, которому система не доверяет. Вам, возможно, потребуется выполнить дополнительные действия в модификации клиента для успешного входа.
# MT Manager
1. Открываем apk файл клиента, который желаете пропатчить
2. Нажимаем на любой dex файл
3. Выбираем в качестве редактора "Редактор dex+"
4. Выбираем все dex файлы при появлении окна выбора "MultiDex"
5. В поиске выбираем тип Smali, а в поле поиска пишем "api.oneme.ru"
6. Проходимся по каждому результату и заменяем сервер на свой
# ApkTool M
1. Декомпилируем приложение, обязательно поставьте галочку у пункта "Декомпилировать classes*.dex"
2. В папке проекта нажимаем на "лупу"
3. Ставим поиск по содержимому с заменой
4. В поле поиска пишем "api.oneme.ru", а в поле замены ваш адрес сервера
5. После замены нажимаем на "Собрать проект"
# ApkTool
1. Помещаем apk в рабочую директорию
2. Открываем консоль в той же директории и производим декомпиляцию: `apktool d <имя apk> -o max`
3. Заходим в папку проекта и заменяем во всех классах "api.oneme.ru" на свой адрес сервера
4. Производим повторную сборку с помощью команды: `apktool b max -o max_modified.apk`
---
# Патчинг Firebase для push-уведомлений
> [!Important]
> Без замены Firebase-конфига пуши от вашего сервера не будут работать.
1. Создайте проект в [Firebase Console](https://console.firebase.google.com/) и добавьте Android-приложение с пакетом `ru.oneme.app`
2. Скачайте `google-services.json`
3. В декомпилированном APK откройте `res/values/strings.xml` и замените следующие строки на значения из вашего `google-services.json`:
| Строка | Оригинал | Откуда взять |
|---|---|---|
| `google_api_key` | `AIzaSyABuDYeeDXIOrKTXLkUj30Ii143ofPe63Q` | `client[0].api_key[0].current_key` |
| `google_app_id` | `1:659634599081:android:9605285443b661167225b8` | `client[0].client_info.mobilesdk_app_id` |
| `gcm_defaultSenderId` | `659634599081` | `project_info.project_number` |
| `project_id` | `max-messenger-app` | `project_info.project_id` |
| `google_crash_reporting_api_key` | `AIzaSyABuDYeeDXIOrKTXLkUj30Ii143ofPe63Q` | `client[0].api_key[0].current_key` |
| `google_storage_bucket` | `max-messenger-app.firebasestorage.app` | `project_info.storage_bucket` |
4. Соберите и подпишите APK
5. В настройках проекта Firebase создайте сервисный аккаунт и укажите путь в `.env`

7
faq/readme.md Normal file
View File

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

View File

@@ -21,23 +21,5 @@ https://t.me/openmax_alerts
Клиент может быть практически любым, главное условие - чтобы он был совместим с официальным сервером (`api.oneme.ru` / `api.tamtam.chat`).
На данный момент с сервером может работать последняя версия MAX (26.7.1), однако все тесты проходят на версии 26.5.0.
# Установка
1. Склонируйте репозиторий
2. Установите зависимости
```bash
pip install -r requirements.txt
```
3. Настройте сервер (пример в `.env.example`)
4. Импортируйте схему таблиц в свою базу данных из `tables.sql`
5. Запустите сервер
```bash
python3 main.py
```
6. Создайте пользователя
7. Зайдите со своего любимого клиента
# Дополнительная информация
[Faq](faq/readme.md)

View File

@@ -1,9 +1,11 @@
pyTelegramBotAPI
aiogram
aiomysql
python-dotenv
msgpack
lz4
websockets
pydantic
aiosqlite
aiohttp
aiosqlite
python-dotenv
cryptography
firebase-admin

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

@@ -0,0 +1,58 @@
import logging
from common.config import ServerConfig
from common.opcodes import Opcodes
from common.proto_tcp import MobileProto
from common.proto_web import WebProto
from common.static import Static
from common.tools import Tools
class BaseProcessor:
def __init__(self, db_pool=None, clients=None, send_event=None, type="socket"):
if clients is None:
clients = {}
self.config = ServerConfig()
self.static = Static()
self.tools = Tools()
self.opcodes = Opcodes()
self.error_types = self.static.ErrorTypes()
self.db_pool = db_pool
self.clients = clients
self.event = send_event
self.logger = logging.getLogger(__name__)
self.type = "mobile" if type == "socket" else type
if type == "socket":
self.proto = MobileProto()
elif type == "web":
self.proto = WebProto()
async def _send(self, writer, packet):
try:
# Если объектом является вебсокет, то используем функцию send для отправки
if hasattr(writer, "send"):
await writer.send(packet)
else: # В ином случае отправляем как в обычный сокет
writer.write(packet)
await writer.drain()
except Exception:
pass
async def _send_error(self, seq, opcode, error_type, writer):
payload = self.static.ERROR_TYPES.get(
error_type,
{
"localizedMessage": "Неизвестная ошибка",
"error": "unknown.error",
"message": "Unknown error",
"title": "Неизвестная ошибка",
},
)
packet = self.proto.pack_packet(
cmd=self.proto.CMD_ERR, seq=seq, opcode=opcode, payload=payload
)
await self._send(writer, packet)

View File

@@ -1,5 +1,6 @@
import os
from dotenv import load_dotenv
load_dotenv()
class ServerConfig:
@@ -46,4 +47,10 @@ class ServerConfig:
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
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", "")
### Firebase
firebase_credentials_path = os.getenv("firebase_credentials_path", "")

View File

@@ -1,80 +1,7 @@
import lz4.block, msgpack, logging, json
class Opcodes:
def __init__(self):
pass
class Proto:
def __init__(self) -> None:
self.logger = logging.getLogger(__name__)
### Работа с протоколом
def unpack_packet(self, data: bytes) -> dict | 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_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=99999,
)
except lz4.block.LZ4BlockError:
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

147
src/common/proto_tcp.py Normal file
View File

@@ -0,0 +1,147 @@
import logging
import lz4.block
import msgpack
class MobileProto:
def __init__(self) -> None:
self.logger = logging.getLogger(__name__)
# TODO узнать какие должны быть лимиты и поменять,
# сейчас это больше заглушка
MAX_PAYLOAD_SIZE = 1048576 # 1 MB
MAX_DECOMPRESSED_SIZE = 1048576 # 1 MB
HEADER_SIZE = 10 # 1+2+1+2+4
### Работа с протоколом
def unpack_packet(self, data: bytes) -> dict | None:
# Проверяем минимальный размер пакета
if len(data) < self.HEADER_SIZE:
self.logger.warning(f"Пакет слишком маленький: {len(data)} байт")
return None
# Распаковываем заголовок
ver = int.from_bytes(data[0:1], "big", signed=False)
cmd = int.from_bytes(data[1:2], "big", signed=False)
seq = int.from_bytes(data[2:4], "big", signed=False)
opcode = int.from_bytes(data[4:6], "big", signed=False)
packed_len = int.from_bytes(data[6:10], "big", signed=False)
# Флаг упаковки
comp_flag = packed_len >> 24
# Парсим данные пакета
payload_length = packed_len & 0xFFFFFF
# Проверяем размер payload
if payload_length > self.MAX_PAYLOAD_SIZE:
self.logger.warning(
f"Payload слишком большой: {payload_length} B (лимит {self.MAX_PAYLOAD_SIZE})"
)
return None
# Проверяем длину пакета
if len(data) < self.HEADER_SIZE + payload_length:
self.logger.warning(
f"Пакет неполный: требуется {self.HEADER_SIZE + payload_length} B, получено {len(data)}"
)
return None
payload_bytes = data[10 : 10 + payload_length]
payload = None
# Декодируем данные пакета
if payload_bytes:
# Разжимаем данные пакета, если требуется
if comp_flag != 0:
compressed_data = payload_bytes
try:
payload_bytes = lz4.block.decompress(
compressed_data,
uncompressed_size=self.MAX_DECOMPRESSED_SIZE,
)
except lz4.block.LZ4BlockError:
self.logger.warning("Ошибка декомпрессии LZ4")
return None
# Распаковываем msgpack
payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False)
self.logger.debug(
f"Распаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload} comp_flag={comp_flag}"
)
# Возвращаем
return {
"ver": ver,
"cmd": cmd,
"seq": seq,
"opcode": opcode,
"payload": payload,
}
def pack_packet(
self,
ver: int = 10,
cmd: int = 1,
seq: int = 1,
opcode: int = 6,
payload: dict = {},
) -> bytes:
# Запаковываем заголовок
ver_b = ver.to_bytes(1, "big")
cmd_b = cmd.to_bytes(1, "big")
seq_b = seq.to_bytes(2, "big")
opcode_b = opcode.to_bytes(2, "big")
# Запаковываем данные пакета
payload_bytes: bytes | None = msgpack.packb(payload)
if payload_bytes is None:
payload_bytes = b""
# Флаг сжатия
comp_flag = 0
# Пробуем сжать данные пакета
try:
payload_comp = lz4.block.compress(
payload_bytes,
mode='high_compression',
store_size=False,
)
# Если сжатие нам выгодно, то используем его
if len(payload_bytes) > len(payload_comp):
final_payload = payload_comp
# Официальный сервер MAX отправлял мне в качестве
# флага сжатия 2, поэтому думаю стоит использовать ее
comp_flag = 2
else:
# В случае если сжатие нам не выгодно, используем
# только запакованные данные через msgpack
final_payload = payload_bytes
except Exception as e:
self.logger.warning(f"Ошибка сжатия LZ4: {e}")
# В случае ошибки сжатия используем
# только запакованные данные через msgpack
final_payload = payload_bytes
payload_len = len(final_payload) & 0xFFFFFF
packed_len = (comp_flag << 24) | payload_len
payload_len_b = packed_len.to_bytes(4, "big")
self.logger.debug(
f"Упаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload} comp_flag={comp_flag}"
)
# Возвращаем пакет
return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + final_payload
### Констаты протокола
CMD_OK = 1
CMD_NOF = 2
CMD_ERR = 3
PROTO_VER = 10

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

92
src/common/push.py Normal file
View File

@@ -0,0 +1,92 @@
import asyncio
import logging
import time
import firebase_admin
from firebase_admin import credentials, messaging
class PushService:
def __init__(self, credentials_path):
self.logger = logging.getLogger(__name__)
if not credentials_path:
self.logger.warning("Огненная база сегодня не работает, укажите путь к файлу с ключами")
self.enabled = False
return
cred = credentials.Certificate(credentials_path)
firebase_admin.initialize_app(cred)
self.enabled = True
self.logger.info("Огненная база инициализирована")
async def send(self, push_token, data):
"""Отправка пуша"""
if not self.enabled:
return None
str_data = {k: str(v) for k, v in data.items() if v is not None}
message = messaging.Message(
data=str_data,
token=push_token,
android=messaging.AndroidConfig(
priority="high",
),
)
try:
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, messaging.send, message)
self.logger.debug(f"Отправил пуш: {response}")
return response
except messaging.UnregisteredError:
self.logger.warning(f"Пуш-токен не зарегистрирован: {push_token}")
return None
except Exception as e:
self.logger.error(f"Не удалось отправить пуш: {e}")
return None
async def send_to_user(self, db_pool, phone, sender_id=None, msg_id=None,
chat_id=None, text="", is_group=False):
"""Отправка пушей на все устройства пользователя"""
if not self.enabled:
return
# Получаем имя отправителя
user_name = ""
if sender_id:
async with db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"SELECT firstname, lastname FROM users WHERE id = %s",
(sender_id,)
)
sender = await cursor.fetchone()
if sender:
firstname = sender.get("firstname", "")
lastname = sender.get("lastname", "")
user_name = f"{firstname} {lastname}".strip()
now_ms = str(int(time.time() * 1000))
msg_type = "ChatMessage" if is_group else "Message"
data = {
"type": msg_type,
"msgid": str(msg_id) if msg_id else "0",
"suid": str(sender_id) if sender_id else None,
"mc": str(chat_id) if chat_id else None,
"msg": text,
"userName": user_name,
"ttime": now_ms,
"ctime": now_ms,
}
# Получаем все пуш-токены пользователя
async with db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"SELECT push_token FROM tokens WHERE phone = %s AND push_token IS NOT NULL",
(phone,)
)
rows = await cursor.fetchall()
for row in rows:
await self.send(row["push_token"], data)

View File

@@ -0,0 +1,55 @@
import logging
import time
class RateLimiter:
"""
ip rate limiter using sliding window algorithm
"""
def __init__(self, max_attempts=5, window_seconds=60):
self.max_attempts = max_attempts
self.window_seconds = window_seconds
self.attempts = {} # {ip: [timestamp, ...]}
self.logger = logging.getLogger(__name__)
def is_allowed(self, ip: str) -> bool:
now = time.monotonic()
cutoff = now - self.window_seconds
if ip not in self.attempts:
self.attempts[ip] = []
self.attempts[ip] = [t for t in self.attempts[ip] if t > cutoff]
if len(self.attempts[ip]) >= self.max_attempts:
self.logger.warning(
f"request limit exceeded for {ip}: {len(self.attempts[ip])}/{self.max_attempts}"
)
return False
self.attempts[ip].append(now)
return True
def remaining(self, ip: str) -> int:
now = time.monotonic()
cutoff = now - self.window_seconds
entries = self.attempts.get(ip, [])
active = [t for t in entries if t > cutoff]
return max(0, self.max_attempts - len(active))
def retry_after(self, ip: str) -> int:
entries = self.attempts.get(ip, [])
if not entries:
return 0
now = time.monotonic()
cutoff = now - self.window_seconds
active = [t for t in entries if t > cutoff]
if len(active) < self.max_attempts:
return 0
oldest = min(active)
return max(0, int(oldest + self.window_seconds - now) + 1)

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

@@ -5,14 +5,20 @@ class SQLQueries:
SELECT_USER_BY_TG_ID = "SELECT * FROM users WHERE telegram_id = %s"
INSERT_USER = """
INSERT INTO users
(phone, telegram_id, firstname, lastname, username,
profileoptions, options, accountstatus, updatetime, lastseen)
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)
"""
INSERT_USER_DATA = """
INSERT INTO user_data
(phone, chats, contacts, folders, user_config, chat_config)
VALUES (%s, %s, %s, %s, %s, %s)
"""
(phone, user_config, chat_config)
VALUES (%s, %s, %s)
"""
INSERT_DEFAULT_FOLDER = """
INSERT INTO user_folders
(id, phone, title, sort_order)
VALUES ('all.chat.folder', %s, 'Все', 0)
"""

View File

@@ -12,6 +12,7 @@ class Static:
INVALID_TOKEN = "invalid_token"
CHAT_NOT_FOUND = "chat_not_found"
CHAT_NOT_ACCESS = "chat_not_access"
RATE_LIMITED = "rate_limited"
class ChatTypes:
DIALOG = "DIALOG"
@@ -73,6 +74,12 @@ class Static:
"error": "chat.not.access",
"message": "Chat not access",
"title": "Нет доступа к чату"
},
"rate_limited": {
"localizedMessage": "Слишком много попыток. Повторите позже",
"error": "error.rate_limited",
"message": "Too many attempts. Please try again later",
"title": "Слишком много попыток"
}
}
@@ -109,36 +116,86 @@ class Static:
### Причины для жалоб
COMPLAIN_REASONS = [
"Порнография или эротика",
"Экстремизм или терроризм",
"Фейк",
"Мошенничество",
"Нарушение авторского права",
"Шокирующий контент",
"Персональные данные",
"Незаконная услуга",
"Это законно, но надо удалить"
{"typeId": 5, "reasons": [
{"reasonTitle": "Мошенничество", "reasonId": 8},
{"reasonTitle": "Спам", "reasonId": 9},
{"reasonTitle": "Порнографический контент", "reasonId": 23},
{"reasonTitle": "Насилие", "reasonId": 18},
{"reasonTitle": "Оскорбления", "reasonId": 11},
{"reasonTitle": "Экстремизм", "reasonId": 20},
{"reasonTitle": "Запрещенные товары", "reasonId": 21},
{"reasonTitle": "Мне не нравится", "reasonId": 22},
{"reasonTitle": "Другое", "reasonId": 7},
]},
{"typeId": 4, "reasons": [
{"reasonTitle": "Мошенничество", "reasonId": 8},
{"reasonTitle": "Спам", "reasonId": 9},
{"reasonTitle": "Порнографический контент", "reasonId": 23},
{"reasonTitle": "Насилие", "reasonId": 18},
{"reasonTitle": "Оскорбления", "reasonId": 11},
{"reasonTitle": "Экстремизм", "reasonId": 20},
{"reasonTitle": "Запрещенные товары", "reasonId": 21},
{"reasonTitle": "Другое", "reasonId": 7},
]},
{"typeId": 3, "reasons": [
{"reasonTitle": "Мошенничество", "reasonId": 8},
{"reasonTitle": "Спам", "reasonId": 9},
{"reasonTitle": "Порнографический контент", "reasonId": 23},
{"reasonTitle": "Насилие", "reasonId": 18},
{"reasonTitle": "Оскорбления", "reasonId": 11},
{"reasonTitle": "Экстремизм", "reasonId": 20},
{"reasonTitle": "Запрещенные товары", "reasonId": 21},
{"reasonTitle": "Другое", "reasonId": 7},
]},
{"typeId": 7, "reasons": [
{"reasonTitle": "Мошенничество", "reasonId": 8},
{"reasonTitle": "Спам", "reasonId": 9},
{"reasonTitle": "Порнографический контент", "reasonId": 23},
{"reasonTitle": "Насилие", "reasonId": 18},
{"reasonTitle": "Оскорбления", "reasonId": 11},
{"reasonTitle": "Экстремизм", "reasonId": 20},
{"reasonTitle": "Запрещенные товары", "reasonId": 21},
{"reasonTitle": "Другое", "reasonId": 7},
]},
{"typeId": 8, "reasons": [
{"reasonTitle": "Спам", "reasonId": 9},
{"reasonTitle": "Шантаж", "reasonId": 10},
{"reasonTitle": "Оскорбления", "reasonId": 11},
{"reasonTitle": "Другое", "reasonId": 7},
]},
{"typeId": 2, "reasons": [
{"reasonTitle": "Мошенничество", "reasonId": 8},
{"reasonTitle": "Спам", "reasonId": 9},
{"reasonTitle": "Порнографический контент", "reasonId": 23},
{"reasonTitle": "Насилие", "reasonId": 18},
{"reasonTitle": "Оскорбления", "reasonId": 11},
{"reasonTitle": "Экстремизм", "reasonId": 20},
{"reasonTitle": "Запрещенные товары", "reasonId": 21},
{"reasonTitle": "Мне не нравится", "reasonId": 22},
{"reasonTitle": "Другое", "reasonId": 7},
]},
{"typeId": 6, "reasons": [
{"reasonTitle": "Мошенничество", "reasonId": 8},
{"reasonTitle": "Спам", "reasonId": 9},
{"reasonTitle": "Порнографический контент", "reasonId": 23},
{"reasonTitle": "Насилие", "reasonId": 18},
{"reasonTitle": "Оскорбления", "reasonId": 11},
{"reasonTitle": "Экстремизм", "reasonId": 20},
{"reasonTitle": "Запрещенные товары", "reasonId": 21},
{"reasonTitle": "Другое", "reasonId": 7},
]},
{"typeId": 1, "reasons": [
{"reasonTitle": "Мошенничество", "reasonId": 8},
{"reasonTitle": "Спам", "reasonId": 9},
{"reasonTitle": "Порнографический контент", "reasonId": 23},
{"reasonTitle": "Насилие", "reasonId": 18},
{"reasonTitle": "Оскорбления", "reasonId": 11},
{"reasonTitle": "Экстремизм", "reasonId": 20},
{"reasonTitle": "Запрещенные товары", "reasonId": 21},
{"reasonTitle": "Другое", "reasonId": 7},
]},
]
### Заглушка для папок
ALL_CHAT_FOLDER = [{
"id": "all.chat.folder",
"title": "Все",
"filters": [],
"updateTime": 0,
"options": [],
"sourceId": 1
}]
ALL_CHAT_FOLDER_ORDER = ["all.chat.folder"]
### Стандартные папки с настройками пользователя
USER_FOLDERS = {
"folders": [],
"foldersOrder": [],
"allFilterExcludeFolders": []
}
USER_SETTINGS = {
"CHATS_PUSH_NOTIFICATION": "ON",
"PUSH_DETAILS": True,
@@ -169,3 +226,23 @@ class Static:
"M_CALL_PUSH_NOTIFICATION": "ON",
"QUICK_REPLY": False
}
### Коды стран, которым разрешён вход
REG_COUNTRY_CODES = ['AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW',
'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN',
'BO', 'BR', 'BS', 'BT', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI',
'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ',
'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM',
'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP',
'GQ', 'GR', 'GT', 'GU', 'GW', 'GY', 'HK', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL',
'IM', 'IS', 'IN', 'IO', 'IQ', 'IR', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH',
'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR',
'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML',
'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ',
'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA',
'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA',
'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SK',
'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TG',
'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG',
'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'XK', 'YE',
'YT', 'ZA', 'ZM', 'ZW']

View File

@@ -1,15 +1,32 @@
import json, time
import json
import time
class Tools:
def __init__(self):
pass
def generate_profile(
self, id=1, phone=70000000000, avatarUrl=None,
photoId=None, updateTime=0,
firstName="Test", lastName="Account", options=[],
description=None, accountStatus=0, profileOptions=[],
includeProfileOptions=True, username=None
self,
id=1,
phone=70000000000,
avatarUrl=None,
photoId=None,
updateTime=0,
firstName="Test",
lastName="Account",
options=[],
description=None,
accountStatus=0,
profileOptions=[],
includeProfileOptions=True,
username=None,
# для контактов, собственно
custom_firstname=None,
custom_lastname=None,
blocked=False
):
contact = {
"id": id,
@@ -20,14 +37,13 @@ class Tools:
"name": firstName,
"firstName": firstName,
"lastName": lastName,
"type": "ONEME"
"type": "ONEME",
}
],
"options": options,
"accountStatus": accountStatus
"accountStatus": accountStatus,
}
if avatarUrl:
contact["photoId"] = photoId
contact["baseUrl"] = avatarUrl
@@ -39,31 +55,43 @@ class Tools:
if username:
contact["link"] = "https://max.ru/" + username
if includeProfileOptions == True:
return {
"contact": contact,
"profileOptions": profileOptions
}
if custom_firstname:
contact["names"].append(
{
"name": custom_firstname,
"firstName": custom_firstname,
"lastName": custom_lastname,
"type": "CUSTOM"
}
)
if blocked:
contact["status"] = "BLOCKED"
if includeProfileOptions:
return {"contact": contact, "profileOptions": profileOptions}
else:
return contact
def generate_profile_tt(
self, id=1, phone=70000000000, avatarUrl=None,
photoId=None, updateTime=0,
firstName="Test", lastName="Account", options=[],
description=None, username=None
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
"names": [{"name": f"{firstName} {lastName}", "type": "TT"}],
"options": options,
}
if avatarUrl:
@@ -78,13 +106,19 @@ class Tools:
contact["link"] = "https://tamtam.chat/" + username
return contact
def generate_chat(self, id, owner, type, participants, lastMessage, lastEventTime):
def generate_chat(
self, id, owner, type, participants, lastMessage, lastEventTime, prevMessageId=0
):
"""Генерация чата"""
# Генерируем список участников
result_participants = {
str(participant): 0 for participant in participants
}
if isinstance(participants, dict):
result_participants = {
int(k): int(v) if v is not None else 0 for k, v in participants.items()
}
else:
# assume list
result_participants = {int(participant): 0 for participant in participants}
result = None
@@ -101,14 +135,23 @@ class Tools:
"lastDelayedUpdateTime": 0,
"lastFireDelayedErrorTime": 0,
"created": 1,
"cid": id,
"prevMessageId": prevMessageId,
"joinTime": 1,
"modified": lastEventTime
"modified": lastEventTime,
}
# Возвращаем
return result
async def generate_chats(self, chatIds, db_pool, senderId):
async def generate_chats(
self,
chatIds,
db_pool,
senderId,
include_favourites=True,
protocol_type="mobile",
):
"""Генерирует чаты для отдачи клиенту"""
# Готовый список с чатами
chats = []
@@ -118,19 +161,30 @@ class Tools:
async with db_pool.acquire() as db_connection:
async with db_connection.cursor() as cursor:
# Получаем чат по id
await cursor.execute("SELECT * FROM `chats` WHERE id = %s", (chatId,))
await cursor.execute(
"SELECT * FROM `chats` WHERE id = %s", (chatId,)
)
row = await cursor.fetchone()
if row:
# Получаем последнее сообщение из чата
message, messageTime = await self.get_last_message(
chatId, db_pool, protocol_type=protocol_type
)
# Формируем список участников с временем последней активности
participant_ids = await self.get_chat_participants(
chatId, db_pool
)
# Формируем список участников
participants = {
str(participant): 0 for participant in row.get("participants")
}
participants = await self.get_participant_last_activity(
chatId, participant_ids, db_pool
)
# Получаем ID предыдущего сообщения
prevMessageId = await self.get_previous_message_id(
chatId, db_pool, protocol_type=protocol_type
)
# Выносим результат в лист
chats.append(
@@ -140,69 +194,220 @@ class Tools:
row.get("type"),
participants,
message,
messageTime
messageTime,
prevMessageId,
)
)
# Получаем последнее сообщение из избранного
message, messageTime = await self.get_last_message(
senderId, db_pool
)
# ID избранного
chatId = senderId ^ senderId
# Хардкодим в лист чатов избранное
chats.append(
self.generate_chat(
chatId,
senderId,
"DIALOG",
[senderId],
message,
messageTime
if include_favourites:
# Получаем последнее сообщение из избранного
message, messageTime = await self.get_last_message(
senderId, db_pool, protocol_type=protocol_type
)
# ID избранного
chatId = senderId ^ senderId
# Получаем последнюю активность участника (отправителя) в избранном
participants = await self.get_participant_last_activity(
senderId, [senderId], db_pool
)
# Получаем ID предыдущего сообщения для избранного (чат ID = senderId)
prevMessageId = await self.get_previous_message_id(
senderId, db_pool, protocol_type=protocol_type
)
# Хардкодим в лист чатов избранное
chats.append(
self.generate_chat(
chatId if protocol_type == "mobile" else str(chatId),
senderId,
"DIALOG",
participants,
message,
messageTime,
prevMessageId,
)
)
)
return chats
async def insert_message(self, chatId, senderId, text, attaches, elements, cid, type, db_pool):
async def generate_contacts(
self,
contacts,
db_pool,
avatar_base_url="",
):
"""
Генерация контакт-листа для отдачи клиенту
[notes]
В contacts должен поступать список вида
[
{
"firstname": "test",
"lastname": "testovich",
"id": 4323
}
]
А формировать мы должны его до вызова функции,
ибо я хочу вынести контакты в отдельную таблицу,
по моему мнению так будет намного практичнее и лучше
"""
# Готовый список с контакт-листом
contact_list = []
# Формируем список контактов
for contact in contacts:
# ID контакта
contact_id = contact.get("id")
# Имя и фамилия которые указал юзер для контакта
firstname = contact.get("firstname")
lastname = contact.get("lastname")
blocked = contact.get("blocked", False)
async with db_pool.acquire() as db_connection:
async with db_connection.cursor() as cursor:
# Получаем контакт по id
await cursor.execute(
"SELECT * FROM `users` WHERE id = %s", (contact_id,)
)
user = await cursor.fetchone()
if user:
# Аватарка с биографией
photoId = (
None
if not user.get("avatar_id")
else int(user.get("avatar_id"))
)
avatar_url = (
None
if not photoId
else avatar_base_url + str(photoId)
)
description = (
None
if not user.get("description")
else user.get("description")
)
# Создаем профиль
contact = self.generate_profile(
id=user.get("id"),
phone=int(user.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(user.get("updatetime")),
firstName=user.get("firstname"),
lastName=user.get("lastname"),
options=json.loads(user.get("options")),
description=description,
accountStatus=int(user.get("accountstatus")),
includeProfileOptions=False,
username=user.get("username"),
custom_firstname=firstname,
custom_lastname=lastname,
blocked=blocked,
)
# Выносим результат в лист
contact_list.append(contact)
return contact_list
async def collect_user_contacts(
self,
owner_id,
db_pool,
avatar_base_url="",
):
"""Собирает все контакты пользователя и возвращает готовый контакт-лист"""
contacts = []
async with db_pool.acquire() as db_connection:
async with db_connection.cursor() as cursor:
await cursor.execute(
"SELECT * FROM `contacts` WHERE owner_id = %s",
(owner_id,),
)
rows = await cursor.fetchall()
for row in rows:
contacts.append(
{
"id": int(row.get("contact_id")),
"firstname": row.get("custom_firstname"),
"lastname": row.get("custom_lastname"),
"blocked": bool(row.get("is_blocked")),
}
)
return await self.generate_contacts(
contacts, db_pool, avatar_base_url=avatar_base_url
)
async def insert_message(
self, chatId, senderId, text, attaches, elements, cid, type, db_pool
):
"""Добавление сообщения в историю"""
async with db_pool.acquire() as db_connection:
async with db_connection.cursor() as cursor:
# Получаем id последнего сообщения в чате
await cursor.execute("SELECT id FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1", (chatId,))
await cursor.execute(
"SELECT id FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1",
(chatId,),
)
row = await cursor.fetchone() or {}
last_message_id = row.get("id") or 0 # последнее id сообщения в чате
last_message_id = row.get("id") or 0 # последнее id сообщения в чате
message_time = int(time.time() * 1000) # время отправки сообщения
# Вносим новое сообщение в таблицу
await cursor.execute(
"INSERT INTO `messages` (chat_id, sender, time, text, attaches, cid, elements, type) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
(chatId, senderId, int(time.time() * 1000), text, json.dumps(attaches), cid, json.dumps(elements), type)
(
chatId,
senderId,
message_time,
text,
json.dumps(attaches),
cid,
json.dumps(elements),
type,
),
)
message_id = cursor.lastrowid # id сообщения
message_id = cursor.lastrowid
# Возвращаем айдишки
return int(message_id), int(last_message_id)
return int(message_id), int(last_message_id), message_time
async def get_last_message(self, chatId, db_pool):
async def get_last_message(self, chatId, db_pool, protocol_type="mobile"):
"""Получение последнего сообщения в чате"""
async with db_pool.acquire() as db_connection:
async with db_connection.cursor() as cursor:
# Получаем id последнего сообщения в чате
await cursor.execute("SELECT * FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1", (chatId,))
await cursor.execute(
"SELECT * FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1",
(chatId,),
)
row = await cursor.fetchone()
# Если нет результатов - возвращаем None
if not row:
return None, None
# Собираем сообщение
message = {
"id": row.get("id"),
"id": row.get("id")
if protocol_type == "mobile"
else str(row.get("id")),
"time": int(row.get("time")),
"type": row.get("type"),
"sender": row.get("sender"),
@@ -210,12 +415,89 @@ class Tools:
"text": row.get("text"),
"attaches": json.loads(row.get("attaches")),
"elements": json.loads(row.get("elements")),
"reactionInfo": {}
"reactionInfo": {},
}
# Возвращаем
return message, int(row.get("time"))
async def get_previous_message_id(self, chatId, db_pool, protocol_type="mobile"):
"""Получение ID предыдущего сообщения (второго с конца) в чате."""
async with db_pool.acquire() as db_connection:
async with db_connection.cursor() as cursor:
await cursor.execute(
"SELECT id FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1 OFFSET 1",
(chatId,),
)
row = await cursor.fetchone()
# Если результат есть, возвращаем его
if row:
return (
row.get("id")
if protocol_type == "mobile"
else str(row.get("id"))
)
# В ином случае возвращаем 0
return 0 if protocol_type == "mobile" else "0"
async def get_participant_last_activity(self, chatId, participant_ids, db_pool):
"""Возвращает словарь {participant_id: last_activity_time} для участников чата."""
if not participant_ids:
return {}
async with db_pool.acquire() as db_connection:
async with db_connection.cursor() as cursor:
# Собираем всех участников
placeholders = ",".join(["%s"] * len(participant_ids))
query = f"""
SELECT sender, MAX(time) as last_time
FROM messages
WHERE chat_id = %s AND sender IN ({placeholders})
GROUP BY sender
"""
params = (chatId,) + tuple(participant_ids)
await cursor.execute(query, params)
rows = await cursor.fetchall()
# Собираем список участников без времени последней активности в чате
result = {int(pid): 0 for pid in participant_ids}
# Обновляем для каждого участника время последней активности в чате
for row in rows:
sender = int(row["sender"])
last_time = row["last_time"]
if last_time is not None:
result[sender] = int(last_time)
return result
async def get_chat_participants(self, chatId, db_pool):
"""Возвращает список ID участников чата из таблицы chat_participants."""
async with db_pool.acquire() as db_connection:
async with db_connection.cursor() as cursor:
await cursor.execute(
"SELECT user_id FROM chat_participants WHERE chat_id = %s",
(chatId,),
)
rows = await cursor.fetchall()
return [int(row["user_id"]) for row in rows]
async def auth_required(self, userPhone, coro, *args):
if userPhone:
await coro(*args)
async def update_user_config(self, cursor, phone, user_settings, default_settings):
"""Функция для обновления юзер конфига из бд в случае его изменения"""
user_config = json.loads(user_settings)
updated_config = {**default_settings, **user_config}
if updated_config != user_config:
await cursor.execute(
"UPDATE user_data SET user_config = %s WHERE phone = %s",
(json.dumps(updated_config), phone),
)
return updated_config

View File

@@ -1,13 +1,82 @@
# Импортирование библиотек
import ssl, logging, asyncio
import asyncio
import logging
import ssl
from common.config import ServerConfig
from oneme_tcp.controller import OnemeMobileController
from common.push import PushService
from oneme.controller import OnemeController
from tamtam.controller import TTController
from telegrambot.controller import TelegramBotController
from tamtam_tcp.controller import TTMobileController
from tamtam_ws.controller import TTWSController
# Конфиг сервера
server_config = ServerConfig()
class SQLiteCursorCompat:
def __init__(self, connection):
self.connection = connection
self.cursor = None
async def __aenter__(self):
self.cursor = await self.connection.cursor()
return self
async def __aexit__(self, exc_type, exc, tb):
if self.cursor is not None:
await self.cursor.close()
self.cursor = None
@property
def lastrowid(self):
return None if self.cursor is None else self.cursor.lastrowid
def _normalize_query(self, query):
return query.replace("%s", "?").replace(
"UNIX_TIMESTAMP()", "CAST(strftime('%s','now') AS INTEGER)"
)
async def execute(self, query, params=()):
normalized_query = self._normalize_query(query)
if params is None:
params = ()
elif not isinstance(params, (tuple, list, dict)):
params = (params,)
await self.cursor.execute(normalized_query, params)
async def fetchone(self):
row = await self.cursor.fetchone()
if row is None:
return None
return dict(row)
async def fetchall(self):
rows = await self.cursor.fetchall()
return [dict(row) for row in rows]
class SQLiteConnectionCompat:
def __init__(self, connection):
self.connection = connection
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def cursor(self):
return SQLiteCursorCompat(self.connection)
class SQLitePoolCompat:
def __init__(self, connection):
self.connection = connection
def acquire(self):
return SQLiteConnectionCompat(self.connection)
async def init_db():
"""Инициализация базы данных"""
@@ -15,6 +84,7 @@ async def init_db():
if server_config.db_type == "mysql":
import aiomysql
db = await aiomysql.create_pool(
host=server_config.db_host,
port=server_config.db_port,
@@ -22,16 +92,19 @@ async def init_db():
password=server_config.db_password,
db=server_config.db_name,
cursorclass=aiomysql.DictCursor,
autocommit=True
autocommit=True,
)
elif server_config.db_type == "sqlite":
import aiosqlite
raw_db = await aiosqlite.connect(server_config.db_file)
db["acquire"] = raw_db
raw_db = await aiosqlite.connect(server_config.db_file, isolation_level=None)
raw_db.row_factory = aiosqlite.Row
db = SQLitePoolCompat(raw_db)
# Возвращаем
return db
def init_ssl():
"""Создание контекста SSL"""
# Создаем контекст SSL
@@ -41,11 +114,12 @@ def init_ssl():
# Возвращаем
return ssl_context
def set_logging():
"""Настройка уровня логирования"""
# Настройка уровня логирования
log_level = server_config.log_level
if log_level == "debug":
logging.basicConfig(level=logging.DEBUG)
elif log_level == "info":
@@ -53,41 +127,59 @@ def set_logging():
else:
logging.basicConfig(level=None)
async def main():
"""Запуск сервера"""
async def api_event(target, eventData):
for client in api.get("clients").get(target, {}).get("clients", {}):
await controllers[client["protocol"]].event(target, client, eventData)
set_logging()
db = await init_db()
ssl_context = init_ssl()
clients = {}
push_service = PushService(server_config.firebase_credentials_path)
async def api_event(target, eventData):
target_clients = api.get("clients", {}).get(target, {}).get("clients", [])
for client in target_clients:
await controllers[client["protocol"]].event(target, client, eventData)
# Если у пользователя нет активных подключений
# и это новое сообщение - отсылаем пуш
if not target_clients and eventData.get("eventType") == "new_msg":
message = eventData.get("message", {})
sender_id = message.get("sender")
text = message.get("text", "")
chat_id = eventData.get("chatId", "")
msg_id = message.get("id", 0)
await push_service.send_to_user(
db, target,
sender_id=sender_id,
msg_id=msg_id,
chat_id=chat_id,
text=text,
)
api = {
"db": db,
"ssl": ssl_context,
"clients": clients,
"event": api_event,
"origins": server_config.origins
"origins": server_config.origins,
}
controllers = {
"oneme_mobile": OnemeMobileController(),
"tamtam_mobile": TTMobileController(),
"tamtam_ws": TTWSController(),
"telegrambot": TelegramBotController()
"oneme": OnemeController(),
"tamtam": TTController(),
"telegrambot": TelegramBotController(),
}
api["telegram_bot"] = controllers["telegrambot"]
tasks = [
controller.launch(api)
for controller in controllers.values()
]
tasks = [controller.launch(api) for controller in controllers.values()]
# Запускаем контроллеры
await asyncio.gather(*tasks)
if __name__ == "__main__":
asyncio.run(main())
asyncio.run(main())

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

@@ -0,0 +1,122 @@
class OnemeConfig:
def __init__(self):
pass
SERVER_CONFIG = {
"async-tracer": 0,
"presence-ttl": 300,
"non-contact-sync-time": 86400,
"contact-batching-variant": 0,
"account-nickname-enabled": True,
"web-ad-banner": {
"enabled": False
},
"edit-timeout": 0,
"reactions-menu": [],
"invite-long": "",
"calls-endpoint": "",
"calls-test-domain": "",
"max-readmarks": 100,
"max-cname-length": 200,
"max-description-length": 400,
"new-avatar-gradient-colors-enabled": True,
"max-msg-length": 4000,
"file-upload-unsupported-types": [],
"file-upload-max-size": 4294967296,
"image-quality": 0.8,
"image-width": 1920,
"image-height": 1920,
"image-size": 10000000,
"max-favorite-chats": 5,
"bot-complaint-enabled": True,
"reactions-max": 8,
"welcome-sticker-ids": [],
"edit-chat-type-screen-enabled": True,
"edit-channel-type-screen-enabled": True,
"esia-verify-botId": 0,
"official-org": False,
"esia-enabled": False,
"calls-debug-mode": False,
"channels-suggests-folder": True,
"delete-msg-fys-large-chat-disabled": False,
"calls-web-download-logs": False,
"calls-web-upload-logs": False,
"calls-video-zoom": False,
"calls-fullscreen-mode": False,
"group-call-part-limit": 100,
"call-chat-members-load-config": {},
"cfs": False,
"cse": False,
"calls-hotkeys": True,
"gc-link-pre-settings": False,
"gc-from-p2p": False,
"call-rate": {},
"channels-enabled": True,
"max-participants": 20000,
"max-added-participants": 100,
"saved-messages-aliases": [],
"author-visibility-forward-enabled": False,
"official-bot-naming-enabled": False,
"search-webapps-showcase": {
"items": []
},
"settings-entry-banners": [],
"settings-business": "https://telegram.org/blog/telegram-business",
"appearance-multi-theme-screen-enabled": True,
"moscow-theme-enabled": True,
"creation-2fa-config": {
"enabled": False,
"pass_min_len": 6,
"pass_max_len": 64,
"hint_max_len": 30
},
"lebedev-theme-enabled": True,
"quotes-enabled": True,
"channels-complaint-enabled": True,
"reactions-settings-enabled": True,
"channel-statistics-botid": 0,
"enable-unknown-contact-bottom-sheet": 0,
"informer-enabled": True,
"family-protection-botid": 0,
"new-year-theme-2026": True,
"scheduled-messages-enabled": True,
"scheduled-posts-enabled": True,
"scheduled-faves-enabled": True,
"non-contact-complaints-enabled": True,
"join-requests": True,
"web-persistent-cache": False,
"create-channel-type-screen": True,
"show-warning-links": True,
"white-list-links": [],
"february-23-26-theme": True,
"march-8-26-theme": True,
"audio-play-cmd": False,
"audio-play-opus": False,
"bots-channel-adding": True,
"stickers-botid": 0,
"sticker-set-edit-enabled": True,
"calls-new-history-enabled": True,
"y-map": {
"tile": "",
"geocoder": "",
"static": ""
},
"enable-audio-messages-transcription": True,
"enable-video-messages-transcription": True,
"retry-transcribe-attempt": 5,
"retry-transcribe-timeout": 2000,
"org-profile": False,
"media-not-ready-retry-delay": 2000,
"polls-in-chats": True,
"polls-in-channels": True,
"render-polls": True,
"poll-ttl": {
"chat": 5000,
"bigchat": 15000,
"channel": 25000
},
"new-collage": False,
"channel-profile-invite-link": False,
"rename-profile-to-settings": True,
"live-streams": True
}

View File

@@ -1,19 +1,27 @@
import asyncio
from oneme_tcp.server import OnemeMobileServer
from oneme_tcp.proto import Proto
from oneme.socket import OnemeMobile
from oneme.websocket import OnemeWS
from common.proto_tcp import MobileProto
from common.proto_web import WebProto
from classes.controllerbase import ControllerBase
from common.config import ServerConfig
from common.opcodes import Opcodes
class OnemeMobileController(ControllerBase):
class OnemeController(ControllerBase):
def __init__(self):
self.config = ServerConfig()
self.proto = Proto()
self.proto = MobileProto()
self.opcodes = Opcodes()
async def event(self, target, client, eventData):
# Извлекаем тип события и врайтер
eventType = eventData.get("eventType")
writer = client.get("writer")
# Не отправляем событие самому себе
if writer == eventData.get("writer"):
return
# Обрабатываем событие
if eventType == "new_msg":
# Данные сообщения
@@ -34,7 +42,7 @@ class OnemeMobileController(ControllerBase):
# Создаем пакет
packet = self.proto.pack_packet(
cmd=0, seq=1, opcode=self.proto.NOTIF_MESSAGE, payload=payload
cmd=0, seq=1, opcode=self.opcodes.NOTIF_MESSAGE, payload=payload
)
elif eventType == "typing":
# Данные события
@@ -51,7 +59,20 @@ class OnemeMobileController(ControllerBase):
# Создаем пакет
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
)
# Отправляем пакет
@@ -61,7 +82,7 @@ class OnemeMobileController(ControllerBase):
def launch(self, api):
async def _start_all():
await asyncio.gather(
OnemeMobileServer(
OnemeMobile(
host=self.config.host,
port=self.config.oneme_tcp_port,
ssl_context=api['ssl'],
@@ -69,6 +90,14 @@ class OnemeMobileController(ControllerBase):
clients=api['clients'],
send_event=api['event'],
telegram_bot=api.get('telegram_bot'),
).start(),
OnemeWS(
host=self.config.host,
port=self.config.oneme_ws_port,
clients=api['clients'],
ssl_context=api['ssl'],
db_pool=api['db'],
send_event=api['event']
).start()
)

View File

@@ -7,15 +7,15 @@ class UserAgentModel(pydantic.BaseModel):
timezone: str
release: int = None
screen: str
pushDeviceType: str
pushDeviceType: str = None
arch: str = None
locale: str
buildNumber: int
buildNumber: int = None
deviceName: str
deviceLocale: str
class HelloPayloadModel(pydantic.BaseModel):
clientSessionId: int
clientSessionId: int = None
mt_instanceid: str = None
userAgent: UserAgentModel
deviceId: str
@@ -48,7 +48,7 @@ class LoginPayloadModel(pydantic.BaseModel):
token: str
class PingPayloadModel(pydantic.BaseModel):
interactive: bool
interactive: bool = None
class AssetsPayloadModel(pydantic.BaseModel):
sync: int
@@ -59,11 +59,11 @@ class GetCallHistoryPayloadModel(pydantic.BaseModel):
count: int
class MessageModel(pydantic.BaseModel):
isLive: bool
detectShare: bool
elements: list
isLive: bool = None
detectShare: bool = None
elements: list = None
attaches: list = None
cid: int
cid: int = None
text: str = None
class SendMessagePayloadModel(pydantic.BaseModel):
@@ -93,4 +93,45 @@ class SearchUsersPayloadModel(pydantic.BaseModel):
contactIds: list
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
class ChatSubscribePayloadModel(pydantic.BaseModel):
chatId: int
subscribe: bool
class ContactListPayloadModel(pydantic.BaseModel):
status: str
count: int = None

View File

@@ -0,0 +1,28 @@
from .assets import AssetsProcessors
from .auth import AuthProcessors
from .calls import CallsProcessors
from .chats import ChatsProcessors
from .complains import ComplainsProcessors
from .contacts import ContactsProcessors
from .folders import FoldersProcessors
from .history import HistoryProcessors
from .main import MainProcessors
from .messages import MessagesProcessors
from .search import SearchProcessors
from .sessions import SessionsProcessors
class Processors(
AssetsProcessors,
AuthProcessors,
CallsProcessors,
ChatsProcessors,
ComplainsProcessors,
ContactsProcessors,
FoldersProcessors,
HistoryProcessors,
MainProcessors,
MessagesProcessors,
SearchProcessors,
SessionsProcessors
):
pass

View File

@@ -0,0 +1,31 @@
import pydantic
import time
from classes.baseprocessor import BaseProcessor
from oneme.models import AssetsPayloadModel
class AssetsProcessors(BaseProcessor):
async def assets_update(self, payload, seq, writer):
"""Обработчик запроса ассетов клиента на сервере"""
# Валидируем данные пакета
try:
AssetsPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_UPDATE, self.error_types.INVALID_PAYLOAD, writer)
return
# TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию
# Данные пакета
payload = {
"sections": [],
"sync": int(time.time() * 1000)
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_UPDATE, payload=payload
)
# Отправляем
await self._send(writer, packet)

View File

@@ -0,0 +1,655 @@
import hashlib
import json
import logging
import secrets
import time
import pydantic
from classes.baseprocessor import BaseProcessor
from common.sms import send_sms_code
from oneme.config import OnemeConfig
from oneme.models import (
AuthConfirmRegisterPayloadModel,
LoginPayloadModel,
RequestCodePayloadModel,
VerifyCodePayloadModel,
)
class AuthProcessors(BaseProcessor):
def __init__(
self,
db_pool=None,
clients=None,
send_event=None,
telegram_bot=None,
type="socket",
):
super().__init__(db_pool, clients, send_event, type)
self.server_config = OnemeConfig().SERVER_CONFIG
self.telegram_bot = telegram_bot
def _check_legacy_version(self, app_version):
"""
Функция определения легаси версий клиентов,
для которых потребуются некоторые корректировки ответов сервера
Сейчас данная функция используется для форматирования ответов
под версии ниже 25.8.0
Функция вернет True, если версия слишком старая,
или False в противном случае
[fun fact] С 25.8.0, похоже, начали корректировать протокол, поскольку это
самая старая версия, которая работала без всяких корректировок сервера
"""
return tuple(int(v) for v in app_version.split(".")) < (25, 8, 0)
async def _send_banners(self, writer):
"""Функция отправки баннеров клиенту"""
# Итоговый список баннеров для отдачи клиенту
banners = []
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Собираем все баннеры, которые есть в бд
await cursor.execute(
"SELECT * FROM banners WHERE enabled = TRUE"
)
rows = await cursor.fetchall()
# Добавляем каждый баннер в лист
for row in rows:
banner = {
"description": row.get("description"),
"title": row.get("title"),
"priority": row.get("priority"),
"type": row.get("type"),
"hideCloseButton": bool(row.get("hide_close_button")),
"rerun": row.get("rerun"),
"url": row.get("url"),
"animojiId": row.get("animoji_id"),
"repeat": row.get("repeat"),
"hideOnClick": bool(row.get("hide_on_click")),
"id": row.get("id"),
"isTitleAnimated": bool(row.get("is_title_animated")),
}
banners.append(banner)
# Собираем данные пакета
payload = {
"showTime": 86400000,
"updateTime": int(time.time() * 1000),
"banners": banners
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=0, opcode=self.opcodes.NOTIF_BANNERS, payload=payload
)
# Отправляет
await self._send(writer, packet)
async def auth_request(self, payload, seq, writer):
"""Обработчик запроса кода"""
try:
RequestCodePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(
seq, self.opcodes.AUTH_REQUEST, self.error_types.INVALID_PAYLOAD, writer
)
return
# Извлекаем телефон из пакета
phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "")
# Генерируем токен
token = secrets.token_urlsafe(102)
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Время истечения токена
expires = int(time.time()) + 300
user_exists = False
# Ищем пользователя
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,))
user = await cursor.fetchone()
# Получаем код через SMS шлюз или генерируем локально (безопасность прежде всего)
local_fallback_code = False
if self.config.sms_gateway_url:
code = await send_sms_code(self.config.sms_gateway_url, phone)
if code is None:
code = str(secrets.randbelow(900000) + 100000)
local_fallback_code = True
else:
code = str(secrets.randbelow(900000) + 100000)
local_fallback_code = True
# Хешируем
code_hash = hashlib.sha256(code.encode()).hexdigest()
# Сохраняем токен и если нужно отправляем код через тг
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
if user:
user_exists = True
# Сохраняем токен
await cursor.execute(
"INSERT INTO auth_tokens (phone, token_hash, code_hash, expires) VALUES (%s, %s, %s, %s)",
(
phone,
token_hash,
code_hash,
expires,
),
)
# Если код был сгенерирован локально, а тг привязан к аккаунту - отправляем туда сообщение
if (
local_fallback_code
and self.telegram_bot
and user.get("telegram_id")
):
await self.telegram_bot.send_code(
chat_id=int(user.get("telegram_id")), phone=phone, code=code
)
else:
# Пользователь не найден - сохраняем токен со state='register'
# чтобы после верификации кода направить на экран регистрации
await cursor.execute(
"INSERT INTO auth_tokens (phone, token_hash, code_hash, expires, state) VALUES (%s, %s, %s, %s, %s)",
(
phone,
token_hash,
code_hash,
expires,
"register",
),
)
# Данные пакета
payload = {
"token": token,
"codeLength": 6,
"requestMaxDuration": 60000,
"requestCountLeft": 10,
"altActionDuration": 60000,
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK,
seq=seq,
opcode=self.opcodes.AUTH_REQUEST,
payload=payload,
)
# Отправляем
await self._send(writer, packet)
self.logger.debug(f"Код для {phone}: {code} (существующий={user_exists})")
async def auth(self, payload, seq, writer, deviceType, deviceName, appVersion):
"""Обработчик проверки кода"""
try:
VerifyCodePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(
seq, self.opcodes.AUTH, self.error_types.INVALID_PAYLOAD, writer
)
return
# Извлекаем данные из пакета
code = payload.get("verifyCode")
token = payload.get("token")
# Хешируем токен с кодом
hashed_code = hashlib.sha256(code.encode()).hexdigest()
hashed_token = hashlib.sha256(token.encode()).hexdigest()
# Генерируем постоянный токен
login = secrets.token_urlsafe(128)
hashed_login = hashlib.sha256(login.encode()).hexdigest()
# Ищем токен с кодом
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Ищем токен
await cursor.execute(
"SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()",
(hashed_token,),
)
stored_token = await cursor.fetchone()
# Если токен просрочен, или его нет - отправляем ошибку
if stored_token is None:
await self._send_error(
seq, self.opcodes.AUTH, self.error_types.CODE_EXPIRED, writer
)
return
# Проверяем код
if stored_token.get("code_hash") != hashed_code:
await self._send_error(
seq, self.opcodes.AUTH, self.error_types.INVALID_CODE, writer
)
return
# Если это новый пользователь - переводим токен в state='verified'
# и отдаём клиенту REGISTER токен, чтобы он показал экран ввода имени
if stored_token.get("state") == "register":
await cursor.execute(
"UPDATE auth_tokens SET state = %s WHERE token_hash = %s",
(
"verified",
hashed_token,
),
)
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK,
seq=seq,
opcode=self.opcodes.AUTH,
payload={
"tokenAttrs": {"REGISTER": {"token": token}},
"presetAvatars": [],
},
)
await self._send(writer, packet)
return
# Ищем аккаунт
await cursor.execute(
"SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),)
)
account = await cursor.fetchone()
# Удаляем токен
await cursor.execute(
"DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,)
)
# Создаем сессию
await cursor.execute(
"INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)",
(
stored_token.get("phone"),
hashed_login,
deviceType,
deviceName,
"Little Saint James Island",
int(time.time()),
), # весь покрытый зеленью, абсолютно весь, остров невезения в океане есть
)
# Генерируем профиль
# Аватарка с биографией
photoId = (
None if not account.get("avatar_id") else int(account.get("avatar_id"))
)
avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = (
None if not account.get("description") else account.get("description")
)
if self._check_legacy_version(appVersion):
include_profile_options = False
else:
include_profile_options = True
# Собираем данные пакета
payload = {
"tokenAttrs": {"LOGIN": {"token": login}},
"profile": self.tools.generate_profile(
id=account.get("id"),
phone=int(account.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(account.get("updatetime")),
firstName=account.get("firstname"),
lastName=account.get("lastname"),
options=json.loads(account.get("options")),
description=description,
accountStatus=int(account.get("accountstatus")),
profileOptions=json.loads(account.get("profileoptions")),
includeProfileOptions=include_profile_options,
username=account.get("username"),
),
}
# Создаем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH, payload=payload
)
# Отправляем
await self._send(writer, packet)
async def auth_confirm(self, payload, seq, writer, deviceType, deviceName, appVersion):
"""Обработчик подтверждения регистрации нового пользователя"""
# Валидируем данные пакета
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(["TT", "ONEME"]),
0,
str(now_ms),
str(now_s),
),
)
user_id = cursor.lastrowid
# Добавляем данные аккаунта
await cursor.execute(
"""
INSERT INTO user_data
(phone, user_config, chat_config)
VALUES (%s, %s, %s)
""",
(
phone,
json.dumps(self.static.USER_SETTINGS),
json.dumps({}),
),
)
# Добавляем дефолтную папку
await cursor.execute(
"""
INSERT INTO user_folders
(id, phone, title, sort_order)
VALUES ('all.chat.folder', %s, 'Все', 0)
""",
(phone,),
)
# Удаляем токен
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,
),
)
if self._check_legacy_version(appVersion):
include_profile_options = False
else:
include_profile_options = True
# Генерируем профиль
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=include_profile_options,
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, appVersion):
"""Обработчик авторизации клиента на сервере"""
# Валидируем данные пакета
try:
LoginPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(
seq, self.opcodes.LOGIN, self.error_types.INVALID_PAYLOAD, writer
)
return
# Чаты, где состоит пользователь
chats = []
# Получаем данные из пакета
token = payload.get("token")
# Хешируем токен
hashed_token = hashlib.sha256(token.encode()).hexdigest()
# Ищем токен в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"SELECT * FROM tokens WHERE token_hash = %s", (hashed_token,)
)
token_data = await cursor.fetchone()
# Если токен не найден, отправляем ошибку
if token_data is None:
await self._send_error(
seq, self.opcodes.LOGIN, self.error_types.INVALID_TOKEN, writer
)
return
# Ищем аккаунт пользователя в бд
await cursor.execute(
"SELECT * FROM users WHERE phone = %s", (token_data.get("phone"),)
)
user = await cursor.fetchone()
# Ищем данные пользователя в бд
await cursor.execute(
"SELECT * FROM user_data WHERE phone = %s",
(token_data.get("phone"),),
)
user_data = await cursor.fetchone()
# Ищем все чаты, где состоит пользователь
await cursor.execute(
"SELECT * FROM chat_participants WHERE user_id = %s",
(user.get("id")),
)
user_chats = await cursor.fetchall()
for chat in user_chats:
chats.append(chat.get("chat_id"))
# Обновляем юзер конфиг
updated_user_config = await self.tools.update_user_config(
cursor, token_data.get("phone"),
user_data.get("user_config"), self.static.USER_SETTINGS
)
# Аватарка с биографией
photoId = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = None if not user.get("description") else user.get("description")
if self._check_legacy_version(appVersion):
include_profile_options = False
else:
include_profile_options = True
# Генерируем профиль
profile = self.tools.generate_profile(
id=user.get("id"),
phone=int(user.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(user.get("updatetime")),
firstName=user.get("firstname"),
lastName=user.get("lastname"),
options=json.loads(user.get("options")),
description=description,
accountStatus=int(user.get("accountstatus")),
profileOptions=json.loads(user.get("profileoptions")),
includeProfileOptions=include_profile_options,
username=user.get("username"),
)
# Генерируем список чатов
chats = await self.tools.generate_chats(
chats, self.db_pool, user.get("id"), protocol_type=self.type
)
# Генерируем список контактов
contacts = await self.tools.collect_user_contacts(
user.get("id"), self.db_pool, self.config.avatar_base_url
)
# Формируем данные пакета
payload = {
"profile": profile,
"chats": chats,
"chatMarker": 0,
"messages": {},
"contacts": contacts,
"presence": {},
"config": {
"server": self.server_config,
"user": updated_user_config,
},
"token": token,
"videoChatHistory": False,
"time": int(time.time() * 1000),
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOGIN, payload=payload
)
# Отправляем
await self._send(writer, packet)
# Отправляем баннеры
await self._send_banners(writer)
return int(user.get("phone")), int(user.get("id")), hashed_token
async def logout(self, seq, writer, hashedToken):
"""Обработчик завершения сессии"""
# Удаляем токен из бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"DELETE FROM tokens WHERE token_hash = %s", (hashedToken,)
)
# Создаем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOGOUT, payload=None
)
# Отправляем
await self._send(writer, response)

View File

@@ -0,0 +1,64 @@
import pydantic
import secrets
import time
from classes.baseprocessor import BaseProcessor
from oneme.models import (
GetCallTokenPayloadModel,
GetCallHistoryPayloadModel
)
class CallsProcessors(BaseProcessor):
async def ok_token(self, payload, seq, writer):
"""Получение токена для звонка"""
# Валидируем данные пакета
try:
GetCallTokenPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.OK_TOKEN, self.error_types.INVALID_PAYLOAD, writer)
return
# TODO: когда-то взяться за звонки
# Данные пакета
payload = {
"token": secrets.token_urlsafe(128),
"token_lifetime_ts": int(time.time() * 1000),
"token_refresh_ts": int(time.time() * 1000)
}
# Создаем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.OK_TOKEN, payload=payload
)
# Отправляем
await self._send(writer, packet)
async def video_chat_history(self, payload, seq, writer):
"""Обработчик получения истории звонков"""
# Валидируем данные пакета
try:
GetCallHistoryPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.VIDEO_CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer)
return
# TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию
# Данные пакета
payload = {
"hasMore": False,
"history": [],
"backwardMarker": 0,
"forwardMarker": 0
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.VIDEO_CHAT_HISTORY, payload=payload
)
# Отправляем
await self._send(writer, packet)

View File

@@ -0,0 +1,20 @@
import pydantic
from classes.baseprocessor import BaseProcessor
from oneme.models import ChatSubscribePayloadModel
class ChatsProcessors(BaseProcessor):
async def chat_subscribe(self, payload, seq, writer):
# Валидируем входные данные
try:
ChatSubscribePayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.opcodes.CHAT_SUBSCRIBE, self.error_types.INVALID_PAYLOAD, writer)
return
# Созадаем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CHAT_SUBSCRIBE, payload=None
)
# Отправялем
await self._send(writer, packet)

View File

@@ -0,0 +1,29 @@
import pydantic
import time
from classes.baseprocessor import BaseProcessor
from oneme.models import ComplainReasonsGetPayloadModel
class ComplainsProcessors(BaseProcessor):
async def complain_reasons_get(self, payload, seq, writer):
"""Обработчик получения причин жалоб"""
# Валидируем данные пакета
try:
ComplainReasonsGetPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.COMPLAIN_REASONS_GET, self.error_types.INVALID_PAYLOAD, writer)
return
# Собираем данные пакета
payload = {
"complains": self.static.COMPLAIN_REASONS,
"complainSync": int(time.time())
}
# Создаем пакет
packet = self.proto.pack_packet(
seq=seq, opcode=self.opcodes.COMPLAIN_REASONS_GET, payload=payload
)
# Отправляем пакет
await self._send(writer, packet)

View File

@@ -0,0 +1,66 @@
import pydantic
from classes.baseprocessor import BaseProcessor
from oneme.models import ContactListPayloadModel
class ContactsProcessors(BaseProcessor):
async def contact_list(self, payload, seq, writer, userId):
"""Обработчик получения контактов"""
# Валидируем данные пакета
try:
ContactListPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CONTACT_LIST, self.error_types.INVALID_PAYLOAD, writer)
return
status = payload.get("status")
count = payload.get("count")
# Итоговый контакт-лист
contact_list = []
if status == "BLOCKED":
# Собираем контакты, которые в черном списке
blocked = []
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
if count:
await cursor.execute(
"SELECT * FROM contacts WHERE owner_id = %s AND is_blocked = TRUE LIMIT %s",
(userId, count),
)
else:
await cursor.execute(
"SELECT * FROM contacts WHERE owner_id = %s AND is_blocked = TRUE",
(userId,),
)
rows = await cursor.fetchall()
for row in rows:
blocked.append(
{
"id": int(row.get("contact_id")),
"firstname": row.get("custom_firstname"),
"lastname": row.get("custom_lastname"),
"blocked": True,
}
)
# Генерируем контакт-лист
contact_list = await self.tools.generate_contacts(
blocked, self.db_pool, avatar_base_url=self.config.avatar_base_url
)
# Собираем данные пакета
response_payload = {
"contacts": contact_list
}
# Создаем пакет
packet = self.proto.pack_packet(
seq=seq, opcode=self.opcodes.CONTACT_LIST, payload=response_payload
)
# Отправляем пакет
await self._send(writer, packet)

View File

@@ -0,0 +1,54 @@
import pydantic
import json
import time
from classes.baseprocessor import BaseProcessor
from oneme.models import SyncFoldersPayloadModel
class FoldersProcessors(BaseProcessor):
async def folders_get(self, payload, seq, writer, senderPhone):
"""Синхронизация папок с сервером"""
# Валидируем данные пакета
try:
SyncFoldersPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.FOLDERS_GET, self.error_types.INVALID_PAYLOAD, writer)
return
# Ищем папки в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"SELECT id, title, filters, options, update_time, source_id "
"FROM user_folders WHERE phone = %s ORDER BY sort_order",
(int(senderPhone),)
)
result_folders = await cursor.fetchall()
folders = [
{
"id": folder["id"],
"title": folder["title"],
"filters": json.loads(folder["filters"]),
"updateTime": folder["update_time"],
"options": json.loads(folder["options"]),
"sourceId": folder["source_id"],
}
for folder in result_folders
]
# Создаем данные пакета
payload = {
"folderSync": int(time.time() * 1000),
"folders": folders,
"foldersOrder": [folder["id"] for folder in result_folders],
"allFilterExcludeFolders": []
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.FOLDERS_GET, payload=payload
)
# Отправляем
await self._send(writer, packet)

View File

@@ -0,0 +1,108 @@
import pydantic
import json
from classes.baseprocessor import BaseProcessor
from oneme.models import ChatHistoryPayloadModel
class HistoryProcessors(BaseProcessor):
async def chat_history(self, payload, seq, writer, senderId):
"""Обработчик получения истории чата"""
# Валидируем данные пакета
try:
ChatHistoryPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
chatId = payload.get("chatId")
forward = payload.get("forward", 0)
backward = payload.get("backward", 0)
from_time = payload.get("from", 0)
getMessages = payload.get("getMessages", True)
messages = []
# Если пользователь хочет получить историю из избранного,
# то выставляем в качестве ID чата его ID
if chatId == 0:
chatId = senderId
# Проверяем, существует ли чат
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Проверяем состоит ли пользователь в чате,
# только в случае того, если это не избранное
if chatId != senderId:
await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,))
chat = await cursor.fetchone()
# Выбрасываем ошибку, если чата нет
if not chat:
await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.CHAT_NOT_FOUND, writer)
return
# Проверяем, является ли пользователь участником чата
participants = await self.tools.get_chat_participants(chatId, self.db_pool)
if int(senderId) not in participants:
await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.CHAT_NOT_ACCESS, writer)
return
# Если запрошены сообщения
if getMessages:
if backward > 0:
await cursor.execute(
"SELECT * FROM messages WHERE chat_id = %s AND time < %s ORDER BY time ASC LIMIT %s",
(chatId, from_time, backward)
)
result = await cursor.fetchall()
for row in result:
# TODO: Сборку тела сообщения нужно вынести в отдельную функцию
messages.append({
"id": row.get("id") if self.type == 'mobile' else str(row.get('id')),
"time": int(row.get("time")),
"type": row.get("type"),
"sender": row.get("sender"),
"text": row.get("text"),
"attaches": json.loads(row.get("attaches")),
"elements": json.loads(row.get("elements")),
"reactionInfo": {},
"options": 1,
})
if forward > 0:
await cursor.execute(
"SELECT * FROM messages WHERE chat_id = %s AND time > %s ORDER BY time ASC LIMIT %s",
(chatId, from_time, forward)
)
result = await cursor.fetchall()
for row in result:
messages.append({
"id": row.get("id"),
"time": int(row.get("time")),
"type": row.get("type"),
"sender": row.get("sender"),
"text": row.get("text"),
"attaches": json.loads(row.get("attaches")),
"elements": json.loads(row.get("elements")),
"reactionInfo": {}
})
# Сортируем сообщения по времени
messages.sort(key=lambda x: x["time"])
# Формируем ответ
payload = {
"messages": messages
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CHAT_HISTORY, payload=payload
)
# Отправялем
await self._send(writer, packet)

View File

@@ -0,0 +1,190 @@
import pydantic
import json
from classes.baseprocessor import BaseProcessor
from oneme.models import (
HelloPayloadModel,
PingPayloadModel,
UpdateProfilePayloadModel
)
class MainProcessors(BaseProcessor):
def __init__(self, db_pool=None, clients=None, send_event=None, type="socket"):
super().__init__(db_pool, clients, send_event, type)
async def session_init(self, payload, seq, writer):
"""Обработчик приветствия"""
# Валидируем данные пакета
try:
HelloPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.SESSION_INIT, self.error_types.INVALID_PAYLOAD, writer)
return None, None, None
# Получаем данные из пакета
userAgent = payload.get("userAgent")
deviceType = userAgent.get("deviceType")
deviceName = userAgent.get("deviceName")
appVersion = userAgent.get("appVersion")
# Данные пакета
payload = {
"location": "RU",
"app-update-type": 0, # 1 = принудительное обновление
"reg-country-code": self.static.REG_COUNTRY_CODES,
"phone-auto-complete-enabled": False,
"qr-auth-enabled": False,
"lang": True
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.SESSION_INIT, payload=payload
)
# Отправляем
await self._send(writer, packet)
return deviceType, deviceName, appVersion
async def ping(self, payload, seq, writer):
"""Обработчик пинга"""
# Валидируем данные пакета
try:
PingPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.PING, self.error_types.INVALID_PAYLOAD, writer)
return
# Собираем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.PING, payload=None
)
# Отправляем
await self._send(writer, response)
async def log(self, payload, seq, writer):
"""Обработчик телеметрии"""
# TODO: можно было бы реализовать валидацию телеметрии, но сейчас это не особо важно
# Собираем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOG, payload=None
)
# Отправляем
await self._send(writer, response)
async def profile(self, payload, seq, writer, userId):
# Валидируем входные данные
try:
UpdateProfilePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.PROFILE, self.error_types.INVALID_PAYLOAD, writer)
return
# Ищем пользователя в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM users WHERE id = %s", (userId,))
user = await cursor.fetchone()
# Если пользователь не найден
if not user:
await self._send_error(seq, self.opcodes.PROFILE, self.error_types.USER_NOT_FOUND, writer)
return
# Аватарка с биографией
photoId = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = None if not user.get("description") else user.get("description")
# Генерируем профиль
profile = self.tools.generate_profile(
id=user.get("id"),
phone=int(user.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(user.get("updatetime")),
firstName=user.get("firstname"),
lastName=user.get("lastname"),
options=json.loads(user.get("options")),
description=description,
accountStatus=int(user.get("accountstatus")),
profileOptions=json.loads(user.get("profileoptions")),
includeProfileOptions=True,
username=user.get("username")
)
# Создаем данные пакета
payload = {
"profile": profile
}
# Собираем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.PROFILE, payload=payload
)
# Отправляем
await self._send(writer, response)
async def update_config(self, payload, seq, writer, userPhone, hashedToken=None):
"""
Обработчик 22 опкода (config)
Он отвечает за обновление настроек приватности
и пуш токена для пушей
"""
# Пейлоад, который отдадим клиенту
# а отдавать его нужно только при изменении настроек приватности
result_payload = None
if payload.get("pushToken"):
push_token = payload.get("pushToken")
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"UPDATE tokens SET push_token = %s WHERE phone = %s AND token_hash = %s",
(push_token, str(userPhone), hashedToken)
)
elif payload.get("settings") and payload.get("settings").get("user"):
"""Обновление настроек приватности"""
new_settings = payload.get("settings").get("user")
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Получаем текущий конфиг
await cursor.execute(
"SELECT user_config FROM user_data WHERE phone = %s", (userPhone,)
)
row = await cursor.fetchone()
if row:
current_config = json.loads(row.get("user_config"))
# Обновляем настройки
for key, value in new_settings.items():
if key in current_config:
current_config[key] = value
# Сохраняем обновлённый конфиг
await cursor.execute(
"UPDATE user_data SET user_config = %s WHERE phone = %s",
(json.dumps(current_config), userPhone)
)
result_payload = {
"user": current_config,
"hash": "0"
}
# Собираем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONFIG, payload=result_payload
)
# Отправляем
await self._send(writer, response)

View File

@@ -0,0 +1,169 @@
import pydantic
from classes.baseprocessor import BaseProcessor
from oneme.models import (
TypingPayloadModel,
SendMessagePayloadModel
)
class MessagesProcessors(BaseProcessor):
async def msg_typing(self, payload, seq, writer, senderId):
"""Обработчик события печатания"""
# Валидируем данные пакета
try:
TypingPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.MSG_TYPING, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
chatId = payload.get("chatId")
type = payload.get("type") or "TYPING"
# Ищем чат в базе данных
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,))
chat = await cursor.fetchone()
# Если чат не найден, отправляем ошибку
if not chat:
await self._send_error(seq, self.opcodes.MSG_TYPING, self.error_types.CHAT_NOT_FOUND, writer)
return
# Участники чата
participants = await self.tools.get_chat_participants(chatId, self.db_pool)
# Проверяем, является ли отправитель участником чата
if int(senderId) not in participants:
await self._send_error(seq, self.opcodes.MSG_TYPING, self.error_types.CHAT_NOT_ACCESS, writer)
return
# Рассылаем событие участникам чата
for participant in participants:
if participant != senderId:
# Если участник не является отправителем, отправляем
await self.event(
participant,
{
"eventType": "typing",
"chatId": chatId,
"type": type,
"userId": senderId,
"writer": writer,
}
)
# Создаем пакет
packet = self.proto.pack_packet(
seq=seq, opcode=self.opcodes.MSG_TYPING
)
# Отправляем пакет
await self._send(writer, packet)
async def msg_send(self, payload, seq, writer, senderId, db_pool):
"""Функция отправки сообщения"""
# Валидируем данные пакета
try:
SendMessagePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
userId = payload.get("userId")
chatId = payload.get("chatId")
message = payload.get("message")
elements = message.get("elements") or []
attaches = message.get("attaches") or []
cid = message.get("cid") or 0
text = message.get("text") or ""
# Вычисляем ID чата по ID пользователя и ID отправителя,
# в случае отсутствия ID чата
if chatId is None:
chatId = userId ^ senderId
# Если клиент хочет отправить сообщение в избранное,
# то выставляем в качестве ID чата ID отправителя
# (А ещё используем это, если клиент вообще ничего не указал)
if chatId == 0 or not chatId:
chatId = senderId
participants = [senderId]
else:
# Если все таки клиент хочет отправить сообщение в нормальный чат,
# то ищем его в базе данных (извлекать список участников все таки тоже надо)
async with db_pool.acquire() as db_connection:
async with db_connection.cursor() as cursor:
await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,))
chat = await cursor.fetchone()
# Если нет такого чата - выбрасываем ошибку
if not chat:
await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CHAT_NOT_FOUND, writer)
return
# Список участников
participants = await self.tools.get_chat_participants(chatId, db_pool)
# Проверяем, является ли отправитель участником чата
if int(senderId) not in participants:
await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CHAT_NOT_ACCESS, writer)
return
# Добавляем сообщение в историю
messageId, lastMessageId, messageTime = await self.tools.insert_message(
chatId=chatId,
senderId=senderId,
text=text,
attaches=attaches,
elements=elements,
cid=cid,
type="USER",
db_pool=self.db_pool
)
# Готовое тело сообщения
bodyMessage = {
"id": messageId,
"time": messageTime,
"type": "USER",
"sender": senderId,
"cid": cid,
"text": text,
"attaches": attaches,
"elements": elements
}
# Отправляем событие всем участникам чата
for participant in participants:
await self.event(
participant,
{
"eventType": "new_msg",
"chatId": 0 if chatId == senderId else chatId,
"message": bodyMessage,
"prevMessageId": lastMessageId,
"time": messageTime,
"writer": writer
}
)
# Данные пакета
payload = {
"chatId": 0 if chatId == senderId else chatId,
"message": bodyMessage,
"unread": 0,
"mark": messageTime
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.MSG_SEND, payload=payload
)
# Отправляем
await self._send(writer, packet)

View File

@@ -0,0 +1,254 @@
import json
import pydantic
from classes.baseprocessor import BaseProcessor
from oneme.models import (
SearchUsersPayloadModel,
SearchChatsPayloadModel,
SearchByPhonePayloadModel
)
class SearchProcessors(BaseProcessor):
async def contact_info(self, payload, seq, writer, senderId):
"""Поиск пользователей по ID"""
# Валидируем данные пакета
try:
SearchUsersPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CONTACT_INFO, self.error_types.INVALID_PAYLOAD, writer)
return
# Итоговый список пользователей
users = []
# ID пользователей, которые нам предстоит найти
contactIds = payload.get("contactIds")
# Ищем пользователей в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
for contactId in contactIds:
await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,))
user = await cursor.fetchone()
# Если такой пользователь есть, добавляем его в список
if user:
# Аватарка с биографией
photoId = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = None if not user.get("description") else user.get("description")
# Получаем данные контакта
await cursor.execute(
"SELECT * FROM contacts WHERE owner_id = %s AND contact_id = %s",
(senderId, contactId),
)
contact_row = await cursor.fetchone()
custom_firstname = contact_row.get("custom_firstname") if contact_row else None
custom_lastname = contact_row.get("custom_lastname") if contact_row else None
blocked = bool(contact_row.get("is_blocked")) if contact_row else False
# Генерируем профиль
users.append(
self.tools.generate_profile(
id=user.get("id"),
phone=int(user.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(user.get("updatetime")),
firstName=user.get("firstname"),
lastName=user.get("lastname"),
options=json.loads(user.get("options")),
description=description,
accountStatus=int(user.get("accountstatus")),
profileOptions=json.loads(user.get("profileoptions")),
includeProfileOptions=False,
username=user.get("username"),
custom_firstname=custom_firstname,
custom_lastname=custom_lastname,
blocked=blocked,
)
)
# Создаем данные пакета
payload = {
"contacts": users
}
# Создаем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_INFO, payload=payload
)
# Отправляем
await self._send(writer, response)
async def contact_info_by_phone(self, payload, seq, writer, senderId):
"""Поиск по номеру телефона"""
# Валидируем данные пакета
try:
SearchByPhonePayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.opcodes.CONTACT_INFO_BY_PHONE, self.error_types.INVALID_PAYLOAD, writer)
return
# Ищем пользователя в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM users WHERE phone = %s", (int(payload.get("phone")),))
user = await cursor.fetchone()
# Если пользователь не найден, отправляем ошибку
if not user:
await self._send_error(seq, self.opcodes.CONTACT_INFO_BY_PHONE, self.error_types.USER_NOT_FOUND, writer)
return
# ID чата
chatId = senderId ^ user.get("id")
# Ищем диалог в бд
await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,))
chat = await cursor.fetchone()
# Если диалога нет - создаем
if not chat:
await cursor.execute(
"INSERT INTO chats (id, owner, type) VALUES (%s, %s, %s)",
(chatId, senderId, "DIALOG")
)
# Добавляем участников в таблицу chat_participants
participants = [int(senderId), int(user.get("id"))]
for user_id in participants:
await cursor.execute(
"INSERT INTO chat_participants (chat_id, user_id) VALUES (%s, %s)",
(chatId, user_id)
)
# Аватарка с биографией
photoId = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = None if not user.get("description") else user.get("description")
# Получаем данные контакта
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"SELECT * FROM contacts WHERE owner_id = %s AND contact_id = %s",
(senderId, user.get("id")),
)
contact_row = await cursor.fetchone()
custom_firstname = contact_row.get("custom_firstname") if contact_row else None
custom_lastname = contact_row.get("custom_lastname") if contact_row else None
blocked = bool(contact_row.get("is_blocked")) if contact_row else False
# Генерируем профиль
profile = self.tools.generate_profile(
id=user.get("id"),
phone=int(user.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(user.get("updatetime")),
firstName=user.get("firstname"),
lastName=user.get("lastname"),
options=json.loads(user.get("options")),
description=description,
accountStatus=int(user.get("accountstatus")),
profileOptions=json.loads(user.get("profileoptions")),
includeProfileOptions=False,
username=user.get("username"),
custom_firstname=custom_firstname,
custom_lastname=custom_lastname,
blocked=blocked,
)
# Создаем данные пакета
payload = {
"contact": profile
}
# Создаем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_INFO_BY_PHONE, payload=payload
)
# Отправляем
await self._send(writer, response)
async def chat_info(self, payload, seq, writer, senderId):
"""Поиск чатов по ID"""
# Валидируем данные пакета
try:
SearchChatsPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CHAT_INFO, self.error_types.INVALID_PAYLOAD, writer)
return
# Итоговый список чатов
chats = []
# ID чатов, которые нам предстоит найти
chatIds = payload.get("chatIds")
# Ищем чаты в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
for chatId in chatIds:
if chatId != 0:
await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,))
chat = await cursor.fetchone()
if chat:
# Проверяем, является ли пользователь участником чата
participants = await self.tools.get_chat_participants(chatId, self.db_pool)
# (в max нельзя смотреть и отправлять сообщения в чат, в котором ты не участник, в отличие от tg (например, комментарии в каналах),
# так что надо тоже так делать)
if int(senderId) not in participants:
continue
# Получаем последнее сообщение из чата
message, messageTime = await self.tools.get_last_message(
chatId, self.db_pool, protocol_type=self.type
)
# Добавляем чат в список
chats.append(
self.tools.generate_chat(
chatId, chat.get("owner"),
chat.get("type"), participants,
message, messageTime
)
)
else:
# Получаем последнее сообщение из чата
message, messageTime = await self.tools.get_last_message(
senderId, self.db_pool, protocol_type=self.type
)
# ID избранного
chatId = senderId ^ senderId
# Добавляем чат в список
chats.append(
self.tools.generate_chat(
chatId, senderId,
"DIALOG", [senderId],
message, messageTime
)
)
# Создаем данные пакета
payload = {
"chats": chats
}
# Собираем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CHAT_INFO, payload=payload
)
# Отправляем
await self._send(writer, response)

View File

@@ -0,0 +1,38 @@
from classes.baseprocessor import BaseProcessor
class SessionsProcessors(BaseProcessor):
async def sessions_info(self, payload, seq, writer, senderPhone, hashedToken):
"""Получение активных сессий на аккаунте"""
# Готовый список сессий
sessions = []
# Ищем сессии в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM tokens WHERE phone = %s", (str(senderPhone),))
user_sessions = await cursor.fetchall()
# Собираем сессии в список
for session in user_sessions:
sessions.append(
{
"time": int(session.get("time")),
"client": f"MAX {session.get('device_type')}",
"info": session.get("device_name"),
"location": session.get("location"),
"current": True if session.get("token_hash") == hashedToken else False
}
)
# Создаем данные пакета
payload = {
"sessions": sessions
}
# Создаем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.SESSIONS_INFO, payload=payload
)
# Отправляем
await self._send(writer, response)

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

@@ -0,0 +1,360 @@
import asyncio
import logging
import traceback
from common.opcodes import Opcodes
from common.proto_tcp import MobileProto
from common.rate_limiter import RateLimiter
from common.tools import Tools
from oneme.processors import Processors
class OnemeMobile:
def __init__(
self, host, port, ssl_context, db_pool, clients, send_event, telegram_bot
):
self.host = host
self.port = port
self.ssl_context = ssl_context
self.server = None
self.logger = logging.getLogger(__name__)
self.db_pool = db_pool
self.clients = clients
self.proto = MobileProto()
self.auth_required = Tools().auth_required
self.processors = Processors(
db_pool=db_pool,
clients=clients,
send_event=send_event,
telegram_bot=telegram_bot,
)
self.opcodes = Opcodes()
# rate limiter anti ddos brute force protection
self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60)
self.read_timeout = 300 # Таймаут чтения из сокета (секунды)
self.max_read_size = 65536 # Максимальный размер данных из сокета
async def handle_client(self, reader, writer):
"""Функция для обработки подключений"""
# IP-адрес клиента
address = writer.get_extra_info("peername")
self.logger.info(f"Работаю с клиентом {address[0]}:{address[1]}")
deviceType = None
deviceName = None
appVersion = 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, appVersion = 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, appVersion
)
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, appVersion
)
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, appVersion)
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,
userId,
)
case self.opcodes.CONTACT_LIST:
await self.auth_required(
userPhone,
self.processors.contact_list,
payload,
seq,
writer,
userId,
)
case self.opcodes.COMPLAIN_REASONS_GET:
await self.auth_required(
userPhone,
self.processors.complain_reasons_get,
payload,
seq,
writer,
)
case self.opcodes.PROFILE:
await self.processors.profile(
payload, seq, writer, userId=userId
)
case self.opcodes.CHAT_SUBSCRIBE:
await self.auth_required(
userPhone,
self.processors.chat_subscribe,
payload,
seq,
writer,
)
case self.opcodes.CONFIG:
await self.auth_required(
userPhone,
self.processors.update_config,
payload,
seq,
writer,
userPhone,
hashedToken,
)
case _:
self.logger.warning(f"Неизвестный опкод {opcode}")
except Exception as e:
self.logger.error(
f"Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}"
)
traceback.print_exc()
# Удаляем клиента из словаря
if userPhone:
await self._end_session(userId, address[0], address[1])
writer.close()
self.logger.info(
f"Прекратил работать работать с клиентом {address[0]}:{address[1]}"
)
async def _finish_auth(self, writer, addr, phone, id):
"""Завершение открытия сессии"""
# Ищем пользователя в словаре
user = self.clients.get(id)
# Добавляем новое подключение в словарь
if user:
user["clients"].append(
{"writer": writer, "ip": addr[0], "port": addr[1], "protocol": "oneme"}
)
else:
self.clients[id] = {
"phone": phone,
"id": id,
"clients": [
{
"writer": writer,
"ip": addr[0],
"port": addr[1],
"protocol": "oneme",
}
],
}
async def _end_session(self, id, ip, port):
"""Завершение сессии"""
# Получаем пользователя в списке
user = self.clients.get(id)
if not user:
return
# Получаем подключения пользователя
clients = user.get("clients", [])
# Удаляем нужное подключение из словаря
for i, client in enumerate(clients):
if (client.get("ip"), client.get("port")) == (ip, port):
clients.pop(i)
async def start(self):
"""Функция для запуска сервера"""
self.server = await asyncio.start_server(
self.handle_client, self.host, self.port, ssl=self.ssl_context
)
self.logger.info(f"Сокет запущен на порту {self.port}")
async with self.server:
await self.server.serve_forever()

338
src/oneme/websocket.py Normal file
View File

@@ -0,0 +1,338 @@
import logging
import traceback
import websockets
from common.proto_web import WebProto
from oneme.processors import Processors
from common.rate_limiter import RateLimiter
from common.opcodes import Opcodes
from common.tools import Tools
class OnemeWS:
def __init__(self, host, port, clients, ssl_context, db_pool, send_event):
self.host = host
self.port = port
self.ssl_context = ssl_context
self.server = None
self.logger = logging.getLogger(__name__)
self.db_pool = db_pool
self.clients = clients
self.opcodes = Opcodes()
self.proto = WebProto()
self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event, type="web")
self.auth_required = Tools().auth_required
# rate limiter
self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60)
self.read_timeout = 300 # Таймаут чтения из websocket (секунды)
self.max_read_size = 65536 # Максимальный размер данных
async def handle_client(self, websocket):
"""Функция для обработки WebSocket подключений"""
# IP-адрес клиента
address = websocket.remote_address
self.logger.info(f"Работаю с клиентом {address[0]}:{address[1]}")
deviceType = None
deviceName = None
appVersion = None
userPhone = None
userId = None
hashedToken = None
try:
async for message in websocket:
# Проверяем размер данных
if len(message) > self.max_read_size:
self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(message)} байт)")
break
# Распаковываем данные
packet = self.proto.unpack_packet(message)
# Если пакет невалидный — пропускаем
if not packet:
self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}")
continue
opcode = packet.get("opcode")
seq = packet.get("seq")
payload = packet.get("payload")
match opcode:
case self.opcodes.SESSION_INIT:
deviceType, deviceName, appVersion = await self.processors.session_init(
payload, seq, websocket
)
case self.opcodes.AUTH_REQUEST:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(
seq,
self.opcodes.AUTH_REQUEST,
self.processors.error_types.RATE_LIMITED,
websocket,
)
else:
await self.processors.auth_request(payload, seq, websocket)
case self.opcodes.AUTH:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(
seq,
self.opcodes.AUTH,
self.processors.error_types.RATE_LIMITED,
websocket,
)
else:
await self.processors.auth(
payload, seq, websocket, deviceType, deviceName, appVersion
)
case self.opcodes.AUTH_CONFIRM:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(
seq,
self.opcodes.AUTH_CONFIRM,
self.processors.error_types.RATE_LIMITED,
websocket,
)
elif payload and payload.get("tokenType") == "REGISTER":
await self.processors.auth_confirm(
payload, seq, websocket, deviceType, deviceName, appVersion
)
else:
self.logger.warning(
f"AUTH_CONFIRM с неизвестным tokenType: {payload}"
)
case self.opcodes.LOGIN:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(
seq,
self.opcodes.LOGIN,
self.processors.error_types.RATE_LIMITED,
websocket,
)
else:
(
userPhone,
userId,
hashedToken,
) = await self.processors.login(payload, seq, websocket, deviceType, appVersion)
if userPhone:
await self._finish_auth(
websocket, address, userPhone, userId
)
case self.opcodes.LOGOUT:
await self.processors.logout(
seq, websocket, hashedToken=hashedToken
)
break
case self.opcodes.PING:
await self.processors.ping(payload, seq, websocket)
case self.opcodes.LOG:
await self.processors.log(payload, seq, websocket)
case self.opcodes.ASSETS_UPDATE:
await self.auth_required(
userPhone,
self.processors.assets_update,
payload,
seq,
websocket,
)
case self.opcodes.VIDEO_CHAT_HISTORY:
await self.auth_required(
userPhone,
self.processors.video_chat_history,
payload,
seq,
websocket,
)
case self.opcodes.MSG_SEND:
await self.auth_required(
userPhone,
self.processors.msg_send,
payload,
seq,
websocket,
userId,
self.db_pool,
)
case self.opcodes.FOLDERS_GET:
await self.auth_required(
userPhone,
self.processors.folders_get,
payload,
seq,
websocket,
userPhone,
)
case self.opcodes.SESSIONS_INFO:
await self.auth_required(
userPhone,
self.processors.sessions_info,
payload,
seq,
websocket,
userPhone,
hashedToken,
)
case self.opcodes.CHAT_INFO:
await self.auth_required(
userPhone,
self.processors.chat_info,
payload,
seq,
websocket,
userId,
)
case self.opcodes.CHAT_HISTORY:
await self.auth_required(
userPhone,
self.processors.chat_history,
payload,
seq,
websocket,
userId,
)
case self.opcodes.CONTACT_INFO_BY_PHONE:
await self.auth_required(
userPhone,
self.processors.contact_info_by_phone,
payload,
seq,
websocket,
userId,
)
case self.opcodes.OK_TOKEN:
await self.auth_required(
userPhone, self.processors.ok_token, payload, seq, websocket
)
case self.opcodes.MSG_TYPING:
await self.auth_required(
userPhone,
self.processors.msg_typing,
payload,
seq,
websocket,
userId,
)
case self.opcodes.CONTACT_INFO:
await self.auth_required(
userPhone,
self.processors.contact_info,
payload,
seq,
websocket,
userId,
)
case self.opcodes.CONTACT_LIST:
await self.auth_required(
userPhone,
self.processors.contact_list,
payload,
seq,
websocket,
userId,
)
case self.opcodes.COMPLAIN_REASONS_GET:
await self.auth_required(
userPhone,
self.processors.complain_reasons_get,
payload,
seq,
websocket,
)
case self.opcodes.PROFILE:
await self.processors.profile(
payload, seq, websocket, userId=userId
)
case self.opcodes.CHAT_SUBSCRIBE:
await self.auth_required(
userPhone,
self.processors.chat_subscribe,
payload,
seq,
websocket,
)
case self.opcodes.CONFIG:
await self.auth_required(
userPhone,
self.processors.update_config,
payload,
seq,
websocket,
userPhone,
hashedToken,
)
case _:
self.logger.warning(f"Неизвестный опкод {opcode}")
except websockets.exceptions.ConnectionClosed:
self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}")
except Exception as e:
self.logger.error(f" Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}")
traceback.print_exc()
# Удаляем клиента из словаря при отключении
if userId:
await self._end_session(userId, address[0], address[1])
self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}")
async def _finish_auth(self, websocket, addr, phone, id):
"""Завершение открытия сессии"""
# Ищем пользователя в словаре
user = self.clients.get(id)
# Добавляем новое подключение в словарь
if user:
user["clients"].append(
{
"writer": websocket,
"ip": addr[0],
"port": addr[1],
"protocol": "oneme"
}
)
else:
self.clients[id] = {
"phone": phone,
"id": id,
"clients": [
{
"writer": websocket,
"ip": addr[0],
"port": addr[1],
"protocol": "oneme"
}
]
}
async def _end_session(self, id, ip, port):
"""Завершение сессии"""
# Получаем пользователя в списке
user = self.clients.get(id)
if not user:
return
# Получаем подключения пользователя
clients = user.get("clients", [])
# Удаляем нужное подключение из словаря
for i, client in enumerate(clients):
if (client.get("ip"), client.get("port")) == (ip, port):
clients.pop(i)
async def start(self):
"""Функция для запуска WebSocket сервера"""
self.server = await websockets.serve(
self.handle_client,
self.host,
self.port,
ssl=self.ssl_context
)
self.logger.info(f"WebSocket запущен на порту {self.port}")
await self.server.wait_closed()

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": True,
"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,898 +0,0 @@
import json, random, secrets, hashlib, time, logging
from oneme_tcp.models import *
from oneme_tcp.proto import Proto
from oneme_tcp.config import OnemeConfig
from common.tools import Tools
from common.config import ServerConfig
from common.static import Static
class Processors:
def __init__(self, db_pool=None, clients={}, send_event=None, telegram_bot=None):
self.proto = Proto()
self.tools = Tools()
self.config = ServerConfig()
self.static = Static()
self.server_config = OnemeConfig().SERVER_CONFIG
self.error_types = self.static.ErrorTypes()
self.chat_types = self.static.ChatTypes()
self.db_pool = db_pool
self.event = send_event
self.clients = clients
self.telegram_bot = telegram_bot
self.logger = logging.getLogger(__name__)
async def _send(self, writer, packet):
try:
writer.write(packet)
await writer.drain()
except Exception as error:
self.logger.error(f"Ошибка при отправке пакета - {error}")
async def _send_error(self, seq, opcode, type, writer):
payload = self.static.ERROR_TYPES.get(type, {
"localizedMessage": "Неизвестная ошибка",
"error": "unknown.error",
"message": "Unknown error",
"title": "Неизвестная ошибка"
})
packet = self.proto.pack_packet(
cmd=self.proto.CMD_ERR, seq=seq, opcode=opcode, payload=payload
)
await self._send(writer, packet)
async def process_hello(self, payload, seq, writer):
"""Обработчик приветствия"""
# Валидируем данные пакета
try:
HelloPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
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 = {
"location": "RU",
"app-update-type": 0, # 1 = принудительное обновление
"reg-country-code": [
# Список стран, который отдает официальный сервер
"AZ", "AM", "KZ", "KG", "MD", "TJ", "UZ", "GE", "TH", "TR",
"TM", "AE", "LA", "MY", "ID", "CU", "KH", "VN",
# Список стран, который приделали уже мы
"US", "CA", "UA"
],
"phone-auto-complete-enabled": False,
"lang": True
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.SESSION_INIT, payload=payload
)
# Отправляем
await self._send(writer, packet)
return deviceType, deviceName
async def process_ping(self, payload, seq, writer):
"""Обработчик пинга"""
# Валидируем данные пакета
try:
PingPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.PING, self.error_types.INVALID_PAYLOAD, writer)
return
# Собираем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.PING, payload=None
)
# Отправляем
await self._send(writer, response)
async def process_telemetry(self, payload, seq, writer):
"""Обработчик телеметрии"""
# TODO: можно было бы реализовать валидацию телеметрии, но сейчас это не особо важно
# Собираем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.LOG, payload=None
)
# Отправляем
await self._send(writer, response)
async def process_request_code(self, payload, seq, writer):
"""Обработчик запроса кода"""
# Валидируем данные пакета
try:
RequestCodePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.AUTH_REQUEST, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем телефон из пакета
phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "")
# Генерируем токен с кодом
code = str(random.randint(100000, 999999))
token = secrets.token_urlsafe(128)
# Хешируем
code_hash = hashlib.sha256(code.encode()).hexdigest()
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Время истечения токена
expires = int(time.time()) + 300
# Ищем пользователя, и если он существует, сохраняем токен
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,))
user = await cursor.fetchone()
# Если пользователя найден - сохраняем токен и отправляем код
if user:
# Сохраняем токен
await cursor.execute("INSERT INTO auth_tokens (phone, token_hash, code_hash, expires) VALUES (%s, %s, %s, %s)", (phone, token_hash, code_hash, expires,))
# Если тг бот включен, и тг привязан к аккаунту - отправляем туда сообщение
if self.telegram_bot and user.get("telegram_id"):
await self.telegram_bot.send_code(chat_id=int(user.get("telegram_id")), phone=phone, code=code)
# Данные пакета
payload = {
"requestMaxDuration": 60000,
"requestCountLeft": 10,
"altActionDuration": 60000,
"codeLength": 6,
"token": token
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.AUTH_REQUEST, payload=payload
)
# Отправляем
await self._send(writer, packet)
self.logger.debug(f"Код для {phone}: {code}")
async def process_verify_code(self, payload, seq, writer, deviceType, deviceName):
"""Обработчик проверки кода"""
# Валидируем данные пакета
try:
VerifyCodePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.AUTH, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
code = payload.get("verifyCode")
token = payload.get("token")
# Хешируем токен с кодом
hashed_code = hashlib.sha256(code.encode()).hexdigest()
hashed_token = hashlib.sha256(token.encode()).hexdigest()
# Генерируем постоянный токен
login = secrets.token_urlsafe(128)
hashed_login = hashlib.sha256(login.encode()).hexdigest()
# Ищем токен с кодом
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Ищем токен
await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", (hashed_token,))
stored_token = await cursor.fetchone()
# Если токен просрочен, или его нет - отправляем ошибку
if stored_token is None:
await self._send_error(seq, self.proto.AUTH, self.error_types.CODE_EXPIRED, writer)
return
# Проверяем код
if stored_token.get("code_hash") != hashed_code:
await self._send_error(seq, self.proto.AUTH, self.error_types.INVALID_CODE, writer)
return
# Ищем аккаунт
await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),))
account = await cursor.fetchone()
# Удаляем токен
await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,))
# Создаем сессию
await cursor.execute(
"INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)",
(stored_token.get("phone"), hashed_login, deviceType, deviceName, "Epstein Island", int(time.time()),)
)
# Генерируем профиль
# Аватарка с биографией
photoId = None if not account.get("avatar_id") else int(account.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = None if not account.get("description") else account.get("description")
# Собираем данные пакета
payload = {
"tokenAttrs": {
"LOGIN": {
"token": login
}
},
"profile": self.tools.generate_profile(
id=account.get("id"),
phone=int(account.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(account.get("updatetime")),
firstName=account.get("firstname"),
lastName=account.get("lastname"),
options=json.loads(account.get("options")),
description=description,
accountStatus=int(account.get("accountstatus")),
profileOptions=json.loads(account.get("profileoptions")),
includeProfileOptions=True,
username=account.get("username")
)
}
# Создаем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.AUTH, payload=payload
)
# Отправляем
await self._send(writer, packet)
async def process_login(self, payload, seq, writer):
"""Обработчик авторизации клиента на сервере"""
# Валидируем данные пакета
try:
LoginPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.LOGIN, self.error_types.INVALID_PAYLOAD, writer)
return
# Получаем данные из пакета
token = payload.get("token")
# Хешируем токен
hashed_token = hashlib.sha256(token.encode()).hexdigest()
# Ищем токен в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM tokens WHERE token_hash = %s", (hashed_token,))
token_data = await cursor.fetchone()
# Если токен не найден, отправляем ошибку
if token_data is None:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer)
return
# Ищем аккаунт пользователя в бд
await cursor.execute("SELECT * FROM users WHERE phone = %s", (token_data.get("phone"),))
user = await cursor.fetchone()
# Ищем данные пользователя в бд
await cursor.execute("SELECT * FROM user_data WHERE phone = %s", (token_data.get("phone"),))
user_data = await cursor.fetchone()
# Аватарка с биографией
photoId = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = None if not user.get("description") else user.get("description")
# Генерируем профиль
profile = self.tools.generate_profile(
id=user.get("id"),
phone=int(user.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(user.get("updatetime")),
firstName=user.get("firstname"),
lastName=user.get("lastname"),
options=json.loads(user.get("options")),
description=description,
accountStatus=int(user.get("accountstatus")),
profileOptions=json.loads(user.get("profileoptions")),
includeProfileOptions=True,
username=user.get("username")
)
chats = await self.tools.generate_chats(
json.loads(user_data.get("chats")),
self.db_pool, user.get("id")
)
# Формируем данные пакета
payload = {
"profile": profile,
"chats": chats,
"chatMarker": 0,
"messages": {},
"contacts": [],
"presence": {},
"config": {
"server": self.server_config,
"user": json.loads(user_data.get("user_config"))
},
"token": token,
"videoChatHistory": False,
"time": int(time.time() * 1000)
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.LOGIN, payload=payload
)
# Отправляем
await self._send(writer, packet)
return int(user.get("phone")), int(user.get("id")), hashed_token
async def process_logout(self, seq, writer, hashedToken):
"""Обработчик завершения сессии"""
# Удаляем токен из бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("DELETE FROM tokens WHERE token_hash = %s", (hashedToken,))
# Создаем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.LOGOUT, payload=None
)
# Отправляем
await self._send(writer, response)
async def process_get_assets(self, payload, seq, writer):
"""Обработчик запроса ассетов клиента на сервере"""
# Валидируем данные пакета
try:
AssetsPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.ASSETS_UPDATE, self.error_types.INVALID_PAYLOAD, writer)
return
# TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию
# Данные пакета
payload = {
"sections": [],
"sync": int(time.time() * 1000)
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.ASSETS_UPDATE, payload=payload
)
# Отправляем
await self._send(writer, packet)
async def process_get_call_history(self, payload, seq, writer):
"""Обработчик получения истории звонков"""
# Валидируем данные пакета
try:
GetCallHistoryPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.VIDEO_CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer)
return
# TODO: сейчас это заглушка, а попозже нужно сделать полноценную реализацию
# Данные пакета
payload = {
"hasMore": False,
"history": [],
"backwardMarker": 0,
"forwardMarker": 0
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.VIDEO_CHAT_HISTORY, payload=payload
)
# Отправляем
await self._send(writer, packet)
async def process_send_message(self, payload, seq, writer, senderId, db_pool):
"""Функция отправки сообщения"""
# Валидируем данные пакета
try:
SendMessagePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.MSG_SEND, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
userId = payload.get("userId")
chatId = payload.get("chatId")
message = payload.get("message")
elements = message.get("elements") or []
attaches = message.get("attaches") or []
cid = message.get("cid") or 0
text = message.get("text") or ""
# Если клиент вообще ничего не указал в пакете, то выбрасываем ошибку
if not all([userId, chatId, elements, attaches, cid, text]):
await self._send_error(seq, self.proto.MSG_SEND, self.error_types.INVALID_PAYLOAD, writer)
return
# Время отправки сообщения
messageTime = int(time.time() * 1000)
# Вычисляем ID чата по ID пользователя и ID отправителя,
# в случае отсутствия ID чата
if not chatId:
chatId = userId ^ senderId
# Если клиент хочет отправить сообщение в избранное,
# то выставляем в качестве ID чата ID отправителя
# (А ещё используем это, если клиент вообще ничего не указал)
if chatId == 0 or not chatId:
chatId = senderId
participants = [senderId]
else:
# Если все таки клиент хочет отправить сообщение в нормальный чат,
# то ищем его в базе данных (извлекать список участников все таки тоже надо)
async with db_pool.acquire() as db_connection:
async with db_connection.cursor() as cursor:
await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,))
chat = await cursor.fetchone()
# Если нет такого чата - выбрасываем ошибку
if not chat:
await self._send_error(seq, self.proto.MSG_SEND, self.error_types.CHAT_NOT_FOUND, writer)
return
# Список участников
participants = json.loads(chat.get("participants"))
# Проверяем, является ли отправитель участником чата
if int(senderId) not in participants:
await self._send_error(seq, self.proto.MSG_SEND, self.error_types.CHAT_NOT_ACCESS, writer)
return
# Добавляем сообщение в историю
messageId, lastMessageId = await self.tools.insert_message(
chatId=chatId,
senderId=senderId,
text=text,
attaches=attaches,
elements=elements,
cid=cid,
type="USER",
db_pool=self.db_pool
)
# Готовое тело сообщения
bodyMessage = {
"id": messageId,
"time": messageTime,
"type": "USER",
"sender": senderId,
"cid": cid,
"text": text,
"attaches": attaches,
"elements": elements
}
# Отправляем событие всем участникам чата
for participant in participants:
await self.event(
participant,
{
"eventType": "new_msg",
"chatId": 0 if chatId == senderId else chatId,
"message": bodyMessage,
"prevMessageId": lastMessageId,
"time": messageTime
}
)
# Данные пакета
payload = {
"chatId": 0 if chatId == senderId else chatId,
"message": bodyMessage,
"unread": 0,
"mark": messageTime
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.MSG_SEND, payload=payload
)
# Отправляем
await self._send(writer, packet)
async def process_get_folders(self, payload, seq, writer, senderPhone):
"""Синхронизация папок с сервером"""
# Валидируем данные пакета
try:
SyncFoldersPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.FOLDERS_GET, self.error_types.INVALID_PAYLOAD, writer)
return
# Ищем папки в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT folders FROM user_data WHERE phone = %s", (int(senderPhone),))
result_folders = await cursor.fetchone()
user_folders = json.loads(result_folders.get("folders"))
# Создаем данные пакета
payload = {
"folderSync": int(time.time() * 1000),
"folders": self.static.ALL_CHAT_FOLDER + user_folders.get("folders"),
"foldersOrder": self.static.ALL_CHAT_FOLDER_ORDER + user_folders.get("foldersOrder"),
"allFilterExcludeFolders": user_folders.get("allFilterExcludeFolders")
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.FOLDERS_GET, payload=payload
)
# Отправляем
await self._send(writer, packet)
async def process_get_sessions(self, payload, seq, writer, senderPhone, hashedToken):
"""Получение активных сессий на аккаунте"""
# Готовый список сессий
sessions = []
# Ищем сессии в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM tokens WHERE phone = %s", (str(senderPhone),))
user_sessions = await cursor.fetchall()
# Собираем сессии в список
for session in user_sessions:
sessions.append(
{
"time": int(session.get("time")),
"client": f"MAX {session.get('device_type')}",
"info": session.get("device_name"),
"location": session.get("location"),
"current": True if session.get("token_hash") == hashedToken else False
}
)
# Создаем данные пакета
payload = {
"sessions": sessions
}
# Создаем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.SESSIONS_INFO, payload=payload
)
# Отправляем
await self._send(writer, response)
async def process_search_users(self, payload, seq, writer):
"""Поиск пользователей по ID"""
# Валидируем данные пакета
try:
SearchUsersPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.CONTACT_INFO, self.error_types.INVALID_PAYLOAD, writer)
return
# Итоговый список пользователей
users = []
# ID пользователей, которые нам предстоит найти
contactIds = payload.get("contactIds")
# Ищем пользователей в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
for contactId in contactIds:
await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,))
user = await cursor.fetchone()
# Если такой пользователь есть, добавляем его в список
if user:
# Аватарка с биографией
photoId = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = None if not user.get("description") else user.get("description")
# Генерируем профиль
users.append(
self.tools.generate_profile(
id=user.get("id"),
phone=int(user.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(user.get("updatetime")),
firstName=user.get("firstname"),
lastName=user.get("lastname"),
options=json.loads(user.get("options")),
description=description,
accountStatus=int(user.get("accountstatus")),
profileOptions=json.loads(user.get("profileoptions")),
includeProfileOptions=False,
username=user.get("username")
)
)
# Создаем данные пакета
payload = {
"contacts": users
}
# Создаем пакет
response = self.proto.pack_packet(
seq=seq, opcode=self.proto.CONTACT_INFO, payload=payload
)
# Отправляем
await self._send(writer, response)
async def process_search_chats(self, payload, seq, writer, senderId):
"""Поиск чатов по ID"""
# Валидируем данные пакета
try:
SearchChatsPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.CHAT_INFO, self.error_types.INVALID_PAYLOAD, writer)
return
# Итоговый список чатов
chats = []
# ID чатов, которые нам предстоит найти
chatIds = payload.get("chatIds")
# Ищем чаты в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
for chatId in chatIds:
if chatId != 0:
await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,))
chat = await cursor.fetchone()
if chat:
# Если чат - диалог, и пользователь в нем не состоит,
# то продолжаем без добавления результата
if chat.get("type") == self.chat_types.DIALOG and senderId not in json.loads(chat.get("participants")):
continue
# Получаем последнее сообщение из чата
message, messageTime = await self.tools.get_last_message(
chatId, self.db_pool
)
# Добавляем чат в список
chats.append(
self.tools.generate_chat(
chatId, chat.get("owner"),
chat.get("type"), json.loads(chat.get("participants")),
message, messageTime
)
)
else:
# Получаем последнее сообщение из чата
message, messageTime = await self.tools.get_last_message(
senderId, self.db_pool
)
# ID избранного
chatId = senderId ^ senderId
# Добавляем чат в список
chats.append(
self.tools.generate_chat(
chatId, senderId,
"DIALOG", [senderId],
message, messageTime
)
)
# Создаем данные пакета
payload = {
"chats": chats
}
# Собираем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.CHAT_INFO, payload=payload
)
# Отправляем
await self._send(writer, response)
async def process_search_by_phone(self, payload, seq, writer, senderId):
"""Поиск по номеру телефона"""
# Валидируем данные пакета
try:
SearchByPhonePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.CONTACT_INFO_BY_PHONE, self.error_types.INVALID_PAYLOAD, writer)
return
# Ищем пользователя в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM users WHERE phone = %s", (int(payload.get("phone")),))
user = await cursor.fetchone()
# Если пользователь не найден, отправляем ошибку
if not user:
await self._send_error(seq, self.proto.CONTACT_INFO_BY_PHONE, self.error_types.USER_NOT_FOUND, writer)
return
# ID чата
chatId = senderId ^ user.get("id")
# Ищем диалог в бд
await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,))
chat = await cursor.fetchone()
# Если диалога нет - создаем
if not chat:
await cursor.execute(
"INSERT INTO chats (id, owner, type, participants) VALUES (%s, %s, %s, %s)",
(chatId, senderId, "DIALOG", json.dumps([int(senderId), int(user.get("id"))]))
)
# Аватарка с биографией
photoId = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = None if not user.get("description") else user.get("description")
# Генерируем профиль
profile = self.tools.generate_profile(
id=user.get("id"),
phone=int(user.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(user.get("updatetime")),
firstName=user.get("firstname"),
lastName=user.get("lastname"),
options=json.loads(user.get("options")),
description=description,
accountStatus=int(user.get("accountstatus")),
profileOptions=json.loads(user.get("profileoptions")),
includeProfileOptions=False,
username=user.get("username")
)
# Создаем данные пакета
payload = {
"contact": profile
}
# Создаем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.CONTACT_INFO_BY_PHONE, payload=payload
)
# Отправляем
await self._send(writer, response)
async def process_get_call_token(self, payload, seq, writer):
"""Получение токена для звонка"""
# Валидируем данные пакета
try:
GetCallTokenPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.OK_TOKEN, self.error_types.INVALID_PAYLOAD, writer)
return
# TODO: когда-то взяться за звонки
await self._send_error(seq, self.proto.OK_TOKEN, self.error_types.NOT_IMPLEMENTED, writer)
async def process_typing(self, payload, seq, writer, senderId):
"""Обработчик события печатания"""
# Валидируем данные пакета
try:
TypingPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.MSG_TYPING, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
chatId = payload.get("chatId")
type = payload.get("type") or "TYPING"
# Ищем чат в базе данных
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,))
chat = await cursor.fetchone()
# Если чат не найден, отправляем ошибку
if not chat:
await self._send_error(seq, self.proto.MSG_TYPING, self.error_types.CHAT_NOT_FOUND, writer)
return
# Участники чата
participants = json.loads(chat.get("participants"))
# Проверяем, является ли отправитель участником чата
if int(senderId) not in participants:
await self._send_error(seq, self.proto.MSG_TYPING, self.error_types.CHAT_NOT_ACCESS, writer)
return
# Рассылаем событие участникам чата
for participant in participants:
if participant != senderId:
# Если участник не является отправителем, отправляем
await self.event(
participant,
{
"eventType": "typing",
"chatId": chatId,
"type": type,
"userId": senderId
}
)
# Создаем пакет
packet = self.proto.pack_packet(
seq=seq, opcode=self.proto.MSG_TYPING
)
# Отправляем пакет
await self._send(writer, packet)
async def process_complain_reasons_get(self, payload, seq, writer):
"""Обработчик получения причин жалоб"""
# Валидируем данные пакета
try:
ComplainReasonsGetPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.proto.COMPLAIN_REASONS_GET, self.error_types.INVALID_PAYLOAD, writer)
return
# Собираем данные пакета
payload = {
"complains": self.static.COMPLAIN_REASONS,
"complainSync": int(time.time())
}
# Создаем пакет
packet = self.proto.pack_packet(
seq=seq, opcode=self.proto.COMPLAIN_REASONS_GET, payload=payload
)
# Отправляем пакет
await self._send(writer, packet)

View File

@@ -1,179 +0,0 @@
import asyncio, logging, traceback
from oneme_tcp.proto import Proto
from oneme_tcp.processors import Processors
from common.tools import Tools
class OnemeMobileServer:
def __init__(self, host="0.0.0.0", port=443, ssl_context=None, db_pool=None, clients={}, send_event=None, telegram_bot=None):
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.auth_required = Tools().auth_required
self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event, telegram_bot=telegram_bot)
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:
# Читаем новые данные из сокета
data = await reader.read(4098)
# Если сокет закрыт - выходим из цикла
if not data:
break
# Распаковываем данные
packet = self.proto.unpack_packet(data)
opcode = packet.get("opcode")
seq = packet.get("seq")
payload = packet.get("payload")
match opcode:
case self.proto.SESSION_INIT:
deviceType, deviceName = await self.processors.process_hello(payload, seq, writer)
case self.proto.AUTH_REQUEST:
await self.processors.process_request_code(payload, seq, writer)
case self.proto.AUTH:
await self.processors.process_verify_code(payload, seq, writer, deviceType, deviceName)
case self.proto.LOGIN:
userPhone, userId, hashedToken = await self.processors.process_login(payload, seq, writer)
# Если авторизация на сервере успешная - можем завершить авторизацию
if userPhone:
await self._finish_auth(writer, address, userPhone, userId)
case self.proto.LOGOUT:
await self.processors.process_logout(seq, writer, hashedToken=hashedToken)
break
case self.proto.PING:
await self.processors.process_ping(payload, seq, writer)
case self.proto.LOG:
await self.processors.process_telemetry(payload, seq, writer)
case self.proto.ASSETS_UPDATE:
await self.auth_required(
userPhone, self.processors.process_get_assets, payload, seq, writer
)
case self.proto.VIDEO_CHAT_HISTORY:
await self.auth_required(
userPhone, self.processors.process_get_call_history, payload, seq, writer
)
case self.proto.MSG_SEND:
await self.auth_required(
userPhone, self.processors.process_send_message, payload, seq, writer, senderId=userId, db_pool=self.db_pool
)
case self.proto.FOLDERS_GET:
await self.auth_required(
userPhone, self.processors.process_get_folders, payload, seq, writer, senderPhone=userPhone
)
case self.proto.SESSIONS_INFO:
await self.auth_required(
userPhone, self.processors.process_get_sessions, payload, seq, writer, senderPhone=userPhone, hashedToken=hashedToken
)
case self.proto.CHAT_INFO:
await self.auth_required(
userPhone, self.processors.process_search_chats, payload, seq, writer, senderId=userId
)
case self.proto.CONTACT_INFO_BY_PHONE:
await self.auth_required(
userPhone, self.processors.process_search_by_phone, payload, seq, writer, senderId=userId
)
case self.proto.OK_TOKEN:
await self.auth_required(
userPhone, self.processors.process_get_call_token, payload, seq, writer
)
case self.proto.MSG_TYPING:
await self.auth_required(
userPhone, self.processors.process_typing, payload, seq, writer, senderId=userId
)
case self.proto.CONTACT_INFO:
await self.auth_required(
userPhone, self.processors.process_search_users, payload, seq, writer
)
case self.proto.COMPLAIN_REASONS_GET:
await self.auth_required(
userPhone, self.processors.process_complain_reasons_get, payload, seq, writer
)
case _:
self.logger.warning(f"Неизвестный опкод {opcode}")
except Exception as e:
self.logger.error(f"Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}")
traceback.print_exc()
# Удаляем клиента из словаря
if userPhone:
await self._end_session(userId, address[0], address[1])
writer.close()
self.logger.info(f"Прекратил работать работать с клиентом {address[0]}:{address[1]}")
async def _finish_auth(self, writer, addr, phone, id):
"""Завершение открытия сессии"""
# Ищем пользователя в словаре
user = self.clients.get(id)
# Добавляем новое подключение в словарь
if user:
user["clients"].append(
{
"writer": writer,
"ip": addr[0],
"port": addr[1],
"protocol": "oneme_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()

572
src/tamtam/config.py Normal file
View File

@@ -0,0 +1,572 @@
class TTConfig:
def __init__(self):
pass
SERVER_CONFIG = {
"a-2g": 8,
"a-3g": 24,
"a-constraints": {
"googNoiseSuppression": "true",
"googHighpassFilter": "false",
"googTypingNoiseDetection": "false",
"googAudioNetworkAdaptorConfig": "ChyyARkNCtcjPBUK1yM8GKjDASCw6gEomHUwoJwBCgfKAQQIABAACgvCAQgIqMMBELiRAgosqgEpChEIuBcVzcxMPhjogQIlCtejOxIRCOgHFc3MTD4YsOoBJQrXozsYyAEKC7oBCAiw6gEQoJwB"
},
"a-lte": 24,
"a-wifi": 34,
"account-removal-enabled": False,
"animated-emojis": {
"❤️": {
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/03.json"
},
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/04.json"
}
},
"👍": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-29lottie/e/16.json"
},
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/01_m.json"
}
},
"👎": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/17.json"
},
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/02.json"
}
},
"🙏": {
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/04.json"
},
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-29lottie/e/30_ng.json"
}
},
"😘": {
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/05.json"
},
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/03.json"
}
},
"🔥": {
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/06.json"
},
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/10.json"
}
},
"😂": {
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/07.json"
}
},
"👏": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/56.json"
}
},
"😮": {
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/09.json"
}
},
"💋": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/13_v02.json"
},
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2023-01-18lottie/r/kissing2.json"
}
},
"🥂": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/20.json"
},
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/20.json"
}
},
"😳": {
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/09.json"
},
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/02.json"
}
},
"😔": {
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/r/11.json"
},
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/05.json"
}
},
"😍": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/07.json"
}
},
"😯": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/08.json"
}
},
"😉": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/09.json"
}
},
"🌺": {
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2023-03-06lottie/flower.json"
},
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/11.json"
}
},
"🎂": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/14.json"
}
},
"💩": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/15.json"
},
"reactionAction": {
"url": "https://st.okcdn.ru/static/messages/2023-01-18lottie/r/shit_1.json"
}
},
"🐰": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/19.json"
}
},
"🎅": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/21.json"
}
},
"🎄": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/23.json"
}
},
"🎆": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/22.json"
}
},
"❄️": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/25.json"
}
},
"🎉": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-28lottie/e/12.json"
}
},
"🥗": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2022-12-29lottie/e/28.json"
}
},
"🧡": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/31.json"
}
},
"💔": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/32.json"
}
},
"🎁": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/34.json"
}
},
"🌹": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/35.json"
}
},
"🌸": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/36.json"
}
},
"🍒": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/37.json"
}
},
"🥕": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/39.json"
}
},
"🍑": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/40.json"
}
},
"🍋": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/41.json"
}
},
"🍃": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/42.json"
}
},
"😺": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/43.json"
}
},
"🐶": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/44.json"
}
},
"🐽": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-07-20animojie/45.json"
}
},
"💐": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/46.json"
}
},
"🎈": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/47.json"
}
},
"🍾": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/48.json"
}
},
"": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/49.json"
}
},
"": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/50.json"
}
},
"": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/51.json"
}
},
"💃": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/52.json"
}
},
"☀️": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/53.json"
}
},
"👋": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/54.json"
}
},
"": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/57.json"
}
},
"🙂": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/58.json"
}
},
"🤩": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-15animoji/59.json"
}
},
"😇": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/60.json"
}
},
"😎": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/61.json"
}
},
"🍎": {
"emoji": {
"url": "https://st.okcdn.ru/static/messages/2023-08-14animoji/62.json"
}
}
},
"animated-emojis-limits": {
"low": 5,
"average": 10,
"high": 15
},
"animated-emojis-places": [
"MESSAGE_NORMAL_TEXT",
"MESSAGE_BIG_EMOJI_TEXT",
"MESSAGE_INPUT",
"STICKERS_KEYBOARD",
"CHATS_LIST"
],
"attachment-popup-click": False,
"audio-transcription-enabled": True,
"audio-transcription-locales": [
"ru"
],
"change-self-readmark-on-msg-send": False,
"chat-bg": False,
"chat-cancel-top-menu-enabled": False,
"chat-filter": False,
"chat-info-full-list-enabled": False,
"chat-preview-enabled": False,
"chats-edit-enabled": False,
"chats-folder-enabled": True,
"chats-list-promo-link-enabled": False,
"chats-page-size": 50,
"chats-preload-period": 15,
"close-keyboard-on-scroll": True,
"common-chats-enabled": True,
"compact-mode-enabled": False,
"congrats-banner-enabled-mob": False,
"congrats-banner-enabled-web": True,
"contact-verify-enabled": False,
"contacts-send": False,
"contacts-sort-refresh": 259200,
"copy-id-btn": False,
"debug-mode": 1,
"delayed-messages": False,
"delayed-messages-enabled": False,
"detect-share-when-send-message": True,
"dialog-priority": False,
"disconnect-timeout": 300,
"drafts-sync-enabled": True,
"edit-timeout": 86400,
"expandable-appbar-enabled": True,
"experimental": False,
"fast-chat-actions-enabled": False,
"file-upload-enabled": True,
"file-upload-max-size": 2147483648,
"file-upload-unsupported-types": [
"exe"
],
"gce": True,
"hashtags-enabled": True,
"html-paste": False,
"http-logs-enabled": False,
"image-height": 1680,
"image-quality": 0.800000011920929,
"image-size": 40000000,
"image-width": 1680,
"invite-header": "Приглашение в ТамТам",
"invite-link": "https://tt.me/starwear",
"invite-long": "Я общаюсь в ТамТам, присоединяйся https://tt.me/starwear",
"invite-short": "Привет! Ставь ТамТам! Жду ответа! https://tt.me/starwear",
"keep-connection": 2,
"l10n": False,
"live-location-enabled": True,
"location-enabled": True,
"logs-enabled": True,
"markdown-enabled": True,
"markdown-menu": 0,
"markdown-miui-enabled": True,
"max-audio-length": 3600,
"max-cname-length": 200,
"max-description-length": 400,
"max-favorite-chats": 5,
"max-favorite-sticker-sets": 100,
"max-favorite-stickers": 100,
"max-msg-length": 4000,
"max-participants": 20000,
"max-readmarks": 300,
"max-theme-length": 200,
"max-video-duration-download": 1200,
"mediabar-scroll-layout": True,
"mentions-enabled": True,
"mentions_entity_names_limit": 3,
"min-image-side-size": 64,
"moderated-groups": False,
"moderated-groups-filter": False,
"multiselect": True,
"music-files-enabled": False,
"muted-chat-call-enabled": False,
"nearby-timeouts": {
"enabled": "True",
"LaunchBroadcastTime": "0",
"LaunchBroadcastTimeLimit": "300",
"ContactsScreenBroadcastTime": "10",
"ChatSearchScreenBroadcastTime": "10",
"RecentContactTime": "300"
},
"new-chats-searching": True,
"new-chats-ui": True,
"new-fcm-push": True,
"notif-images": True,
"offline-icon": False,
"often-chats": False,
"ok-avatar-icon": False,
"ok-profile-unbind-enabled": True,
"ok-tt-chat-separation": True,
"one-chat-enabled": False,
"one-chat-new-panel-sticker": False,
"peer-connection-params": {
"ebv": [
"xiaomi",
"huawei"
],
"ebm": [
"oneplus a5010"
]
},
"phone-bind-enabled": True,
"phone-layer-enabled": True,
"play-background-listen-to-end": False,
"play-next-audio": False,
"plus-menu-enabled": False,
"prefs": 1,
"profile-autodelete-enabled": True,
"profiling-enabled": False,
"progress-diff-for-notify": 1,
"promo-contact-id": 0,
"promo-recent-contacts": True,
"promo_contact_label": "Белый Маг",
"proxy": "msgproxy.okcdn.ru",
"proxy-domains": [
"okcdn.ru",
"mycdn.me",
"ok.ru",
"odnoklassniki.ru",
"odkl.ru",
"vk.com",
"userapi.com",
"vkuser.net",
"vkusercdn.ru"
],
"proxy-exclude": [
"r.mradx.net",
"ad.mail.ru"
],
"proxy-rotation": True,
"push-alert-timeout": 604800,
"push-tracking-enabled": True,
"quick-forward-cases": [],
"react-permission": 2,
"reactions-enabled": True,
"reactions-max": 3,
"reactions-menu": [
"👍",
"❤️",
"💩",
"😂",
"🔥",
"🙏",
"👎",
"😮"
],
"readmark-enabled-delay-ms": 400,
"remove-profile-enable": True,
"remove-user-msg-del": True,
"retry-sig-count": 5,
"retry-sig-delay": 4,
"screen-share-enabled": False,
"screen-sharing-enabled": True,
"send-media-from-system-keyboard": False,
"send-side-bwe": True,
"send-system-keyboard-png-as-stickers": True,
"set-unread-timeout": 31536000,
"settings-use": False,
"show-invited-by": True,
"show-rm-limits": [
15,
100
],
"show-snow": True,
"sticker-gif-enabled": True,
"sticker-sections": [
"TOP",
"NEW"
],
"sticker-sets-links-enabled": True,
"sticker-suggest-disabled": False,
"stickers-suggestion": [
"RECENT",
"NEW",
"TOP"
],
"stickers-suggestion-keywords-inline": False,
"support-account": "tt.me/support",
"support-button-enable": False,
"t-ice-reconnect": 15,
"t-incoming-call": 40,
"t-start-connect": 20,
"tam-emoji-font-url": "https://st.okcdn.ru/static/messages/2022-08-25noto/TamNotoColorEmojiCompat.ttf",
"tcp-candidates": False,
"tracer-crash-report-enabled": True,
"tracer-crash-report-host": "https://api-hprof.odkl.ru",
"tracer-crash-send-asap-enabled": True,
"tracer-crash-send-logs-enabled": True,
"tracer-crash-send-threads-dump-enabled": True,
"tracer-disk-overflow-report-threshold": 3000000000,
"tracer-disk-usage-probability": 500,
"tracer-enabled": True,
"tracer-host": "https://api-hprof.odkl.ru",
"tracer-hprof-probability": -1,
"tracer-sampled-conditions": "tag=app_start_ui_freeze_2k;probability=100000;startEvent=app_first_activity_created;interestingEvent=app_freeze;interestingDuration=2000",
"tracer-sampled-duration": 20000,
"tracer-systrace-duration": 20000,
"tracer-systrace-interesting-duration": 10000,
"tracer-systrace-probability": 10000,
"unknown-person-attention": True,
"unread-filter-enabled": False,
"update-non-contacts": 10,
"use-congrats-list-in-mass-sending": False,
"use-new-message-rendering": True,
"v-2g": 128,
"v-3g": 1024,
"v-fps": 24,
"v-fps-v8": 20,
"v-height": 720,
"v-height-vp8": 480,
"v-lte": 1024,
"v-vp8": 512,
"v-width": 1280,
"v-width-vp8": 640,
"v-wifi": 2048,
"vce": True,
"video-attach-download-enabled": False,
"video-auto-compress-enabled": True,
"video-messages": False,
"video-params": True,
"video-preview": "480x270",
"wakelock-on-push": False,
"wm-analytics-enabled": True,
"wm-workers-limit": 80,
"iceServers": [],
"has-phone": True,
"promo-constructors": []
}

View File

@@ -1,22 +1,31 @@
import asyncio
from tamtam_tcp.server import TTMobileServer
from tamtam.socket import TamTamMobile
from tamtam.websocket import TamTamWS
from classes.controllerbase import ControllerBase
from common.config import ServerConfig
class TTMobileController(ControllerBase):
class TTController(ControllerBase):
def __init__(self):
self.config = ServerConfig()
def launch(self, api):
async def _start_all():
await asyncio.gather(
TTMobileServer(
TamTamMobile(
host=self.config.host,
port=self.config.tamtam_tcp_port,
ssl_context=api['ssl'],
db_pool=api['db'],
clients=api['clients'],
send_event=api['event']
).start(),
TamTamWS(
host=self.config.host,
port=self.config.tamtam_ws_port,
ssl_context=api['ssl'],
db_pool=api['db'],
clients=api['clients'],
send_event=api['event']
).start()
)

View File

@@ -6,7 +6,7 @@ class UserAgentModel(pydantic.BaseModel):
osVersion: str
timezone: str
screen: str
pushDeviceType: str
pushDeviceType: str = None
locale: str
deviceName: str
deviceLocale: str
@@ -27,4 +27,18 @@ class FinalAuthPayloadModel(pydantic.BaseModel):
deviceType: str
tokenType: str
deviceId: str
token: str
token: str
class LoginPayloadModel(pydantic.BaseModel):
interactive: bool
token: str
class SearchUsersPayloadModel(pydantic.BaseModel):
contactIds: list
class PingPayloadModel(pydantic.BaseModel):
interactive: bool
class ChatHistoryPayloadModel(pydantic.BaseModel):
chatId: int
backward: int

View File

@@ -0,0 +1,10 @@
from .main import MainProcessors
from .auth import AuthProcessors
from .search import SearchProcessors
from .history import HistoryProcessors
class Processors(MainProcessors,
AuthProcessors,
SearchProcessors,
HistoryProcessors):
pass

View File

@@ -0,0 +1,373 @@
import hashlib
import secrets
import time
import json
import re
from classes.baseprocessor import BaseProcessor
from tamtam.models import (
RequestCodePayloadModel,
VerifyCodePayloadModel,
FinalAuthPayloadModel,
LoginPayloadModel,
)
from tamtam.config import TTConfig
class AuthProcessors(BaseProcessor):
def __init__(self, db_pool=None, clients=None, send_event=None, type="socket"):
super().__init__(db_pool, clients, send_event, type)
self.server_config = TTConfig().SERVER_CONFIG
async def auth_request(self, payload, seq, writer):
"""Обработчик запроса кода"""
# Валидируем данные пакета
try:
RequestCodePayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.opcodes.AUTH_REQUEST,
self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем телефон из пакета
phone = re.sub(r'\D', '', payload.get("phone", ""))
# Генерируем токен с кодом
code = f"{secrets.randbelow(1_000_000):06d}"
token = secrets.token_urlsafe(128)
# Хешируем
code_hash = hashlib.sha256(code.encode()).hexdigest()
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Срок жизни токена (5 минут)
expires = int(time.time()) + 300
# Ищем пользователя, и если он существует, сохраняем токен
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,))
user = await cursor.fetchone()
# Если пользователь существует, сохраняем токен
if user:
await cursor.execute(
"INSERT INTO auth_tokens (phone, token_hash, code_hash, expires, state) VALUES (%s, %s, %s, %s, %s)",
(phone, token_hash, code_hash, expires, "started")
)
# Данные пакета
payload = {
"verifyToken": token,
"retries": 5,
"codeDelay": 60,
"codeLength": 6,
"callDelay": 0,
"requestType": "SMS"
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH_REQUEST, payload=payload
)
# Отправляем
await self._send(writer, packet)
self.logger.debug(f"Код для {phone}: {code}")
async def auth(self, payload, seq, writer):
"""Обработчик проверки кода"""
# Валидируем данные пакета
try:
VerifyCodePayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.opcodes.AUTH,
self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
code = payload.get("verifyCode")
token = payload.get("token")
# Хешируем токен с кодом
hashed_code = hashlib.sha256(code.encode()).hexdigest()
hashed_token = hashlib.sha256(token.encode()).hexdigest()
# Ищем токен с кодом
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Ищем токен
await cursor.execute(
"SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()",
(hashed_token,)
)
stored_token = await cursor.fetchone()
if not stored_token:
await self._send_error(seq, self.opcodes.AUTH,
self.error_types.CODE_EXPIRED, writer)
return
# Проверяем код
if stored_token.get("code_hash") != hashed_code:
await self._send_error(seq, self.opcodes.AUTH,
self.error_types.INVALID_CODE, writer)
return
# Ищем аккаунт
await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),))
account = await cursor.fetchone()
# Обновляем состояние токена
await cursor.execute(
"UPDATE auth_tokens set state = %s WHERE token_hash = %s",
("verified", hashed_token)
)
# Генерируем профиль
# Аватарка с биографией
photo_id = int(account["avatar_id"]) if account.get("avatar_id") else None
avatar_url = f"{self.config.avatar_base_url}{photo_id}" if photo_id else None
description = account.get("description")
# Собираем данные пакета
payload = {
"profile": self.tools.generate_profile_tt(
id=account.get("id"),
phone=int(account.get("phone")),
avatarUrl=avatar_url,
photoId=photo_id,
updateTime=int(account.get("updatetime")),
firstName=account.get("firstname"),
lastName=account.get("lastname"),
options=json.loads(account.get("options")),
description=description,
username=account.get("username")
),
"tokenAttrs": {
"AUTH": {
"token": token
}
},
"tokenTypes": {
"AUTH": token
}
}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH, payload=payload
)
await self._send(writer, packet)
async def auth_confirm(self, payload, seq, writer, deviceType, deviceName):
"""Обработчик финальной аутентификации"""
# Валидируем данные пакета
try:
FinalAuthPayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.opcodes.AUTH_CONFIRM,
self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
token = payload.get("token")
if not deviceType:
deviceType = payload.get("deviceType")
# Хешируем токен
hashed_token = hashlib.sha256(token.encode()).hexdigest()
# Генерируем постоянный токен
login = secrets.token_urlsafe(128)
hashed_login = hashlib.sha256(login.encode()).hexdigest()
# Ищем токен с кодом
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Ищем токен
await cursor.execute(
"SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()",
(hashed_token,)
)
stored_token = await cursor.fetchone()
if stored_token is None:
await self._send_error(seq, self.opcodes.AUTH_CONFIRM,
self.error_types.INVALID_TOKEN, writer)
return
# Если авторизация только началась - отдаем ошибку
if stored_token.get("state") == "started":
await self._send_error(seq, self.opcodes.AUTH_CONFIRM,
self.error_types.INVALID_TOKEN, writer)
return
# Ищем аккаунт
await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),))
account = await cursor.fetchone()
# Удаляем токен
await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,))
# Создаем сессию
await cursor.execute(
"INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)",
(stored_token.get("phone"), hashed_login, deviceType, deviceName,
"Epstein Island", int(time.time()))
)
# Аватарка с биографией
photo_id = None if not account.get("avatar_id") else int(account.get("avatar_id"))
avatar_url = None if not photo_id else self.config.avatar_base_url + str(photo_id)
description = None if not account.get("description") else account.get("description")
# Собираем данные пакета
payload = {
"userToken": "0", # Пока как заглушка
"profile": self.tools.generate_profile_tt(
id=account.get("id"),
phone=int(account.get("phone")),
avatarUrl=avatar_url,
photoId=photo_id,
updateTime=int(account.get("updatetime")),
firstName=account.get("firstname"),
lastName=account.get("lastname"),
options=json.loads(account.get("options")),
description=description,
username=account.get("username")
),
"tokenType": "LOGIN",
"token": login
}
# Создаем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH_CONFIRM, payload=payload
)
# Отправляем
await self._send(writer, packet)
async def login(self, payload, seq, writer):
"""Обработчик авторизации клиента на сервере"""
# Валидируем данные пакета
try:
LoginPayloadModel.model_validate(payload)
except Exception as e:
self.logger.error(f"Возникли ошибки при валидации пакета: {e}")
await self._send_error(seq, self.opcodes.LOGIN,
self.error_types.INVALID_PAYLOAD, writer)
return
# Чаты, где состоит пользователь
chats = []
# Получаем данные из пакета
token = payload.get("token")
# Хешируем токен
hashed_token = hashlib.sha256(token.encode()).hexdigest()
# Ищем токен в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM tokens WHERE token_hash = %s", (hashed_token,))
token_data = await cursor.fetchone()
# Если токен не найден, отправляем ошибку
if token_data is None:
await self._send_error(seq, self.opcodes.LOGIN,
self.error_types.INVALID_TOKEN, writer)
return
# Ищем аккаунт пользователя в бд
await cursor.execute("SELECT * FROM users WHERE phone = %s", (token_data.get("phone"),))
user = await cursor.fetchone()
# Ищем данные пользователя в бд
await cursor.execute("SELECT * FROM user_data WHERE phone = %s", (token_data.get("phone"),))
user_data = await cursor.fetchone()
# Ищем все чаты, где состоит пользователь
await cursor.execute(
"SELECT * FROM chat_participants WHERE user_id = %s",
(user.get('id'))
)
user_chats = await cursor.fetchall()
for chat in user_chats:
chats.append(
chat.get("chat_id")
)
# Обновляем юзер конфиг
updated_user_config = await self.tools.update_user_config(
cursor, token_data.get("phone"),
user_data.get("user_config"), self.static.USER_SETTINGS
)
# Аватарка с биографией
photo_id = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photo_id else self.config.avatar_base_url + str(photo_id)
description = None if not user.get("description") else user.get("description")
# Генерируем профиль
profile = self.tools.generate_profile_tt(
id=user.get("id"),
phone=int(user.get("phone")),
avatarUrl=avatar_url,
photoId=photo_id,
updateTime=int(user.get("updatetime")),
firstName=user.get("firstname"),
lastName=user.get("lastname"),
options=json.loads(user.get("options")),
description=description,
username=user.get("username")
)
chats = await self.tools.generate_chats(
chats, self.db_pool, user.get("id"),
include_favourites=False
)
# Формируем данные пакета
payload = {
"profile": profile,
"chats": chats,
"chatMarker": 0,
"messages": {},
"contacts": [],
"presence": {},
"config": {
"hash": "e5903aa8-0000000000000000-80000106-0000000000000001-00000001-0000000000000000-00000000-2-00000001-0000019c9559d057",
"server": self.server_config,
"user": updated_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

@@ -0,0 +1,98 @@
import pydantic
import json
from classes.baseprocessor import BaseProcessor
from tamtam.models import ChatHistoryPayloadModel
class HistoryProcessors(BaseProcessor):
async def chat_history(self, payload, seq, writer, senderId):
"""Обработчик получения истории чата"""
# Валидируем данные пакета
try:
ChatHistoryPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
chatId = payload.get("chatId")
forward = payload.get("forward", 0)
backward = payload.get("backward", 0)
from_time = payload.get("from", 0)
getMessages = payload.get("getMessages", True)
messages = []
# Проверяем, существует ли чат
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM chats WHERE id = %s", (chatId,))
chat = await cursor.fetchone()
# Выбрасываем ошибку, если чата нет
if not chat:
await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.CHAT_NOT_FOUND, writer)
return
# Проверяем, является ли пользователь участником чата
participants = await self.tools.get_chat_participants(chatId, self.db_pool)
if int(senderId) not in participants:
await self._send_error(seq, self.opcodes.CHAT_HISTORY, self.error_types.CHAT_NOT_ACCESS, writer)
return
# Если запрошены сообщения
if getMessages:
if backward > 0:
await cursor.execute(
"SELECT * FROM messages WHERE chat_id = %s AND time < %s ORDER BY time ASC LIMIT %s",
(chatId, from_time, backward)
)
result = await cursor.fetchall()
for row in result:
messages.append({
"id": row.get("id"),
"time": int(row.get("time")),
"type": row.get("type"),
"sender": row.get("sender"),
"text": row.get("text"),
"attaches": json.loads(row.get("attaches")),
"elements": json.loads(row.get("elements")),
"reactionInfo": {}
})
if forward > 0:
await cursor.execute(
"SELECT * FROM messages WHERE chat_id = %s AND time > %s ORDER BY time ASC LIMIT %s",
(chatId, from_time, forward)
)
result = await cursor.fetchall()
for row in result:
messages.append({
"id": row.get("id"),
"time": int(row.get("time")),
"type": row.get("type"),
"sender": row.get("sender"),
"text": row.get("text"),
"attaches": json.loads(row.get("attaches")),
"elements": json.loads(row.get("elements")),
"reactionInfo": {}
})
# Сортируем сообщения по времени
messages.sort(key=lambda x: x["time"])
# Формируем ответ
payload = {
"messages": messages
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CHAT_HISTORY, payload=payload
)
# Отправялем
await self._send(writer, packet)

View File

@@ -0,0 +1,65 @@
import pydantic
from classes.baseprocessor import BaseProcessor
from tamtam.models import HelloPayloadModel, PingPayloadModel
class MainProcessors(BaseProcessor):
async def session_init(self, payload, seq, writer):
"""Обработчик приветствия"""
# Валидируем данные пакета
try:
HelloPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
await self._send_error(seq, self.opcodes.SESSION_INIT,
self.error_types.INVALID_PAYLOAD, writer)
return None, None
# Получаем данные из пакета
device_type = payload.get("userAgent").get("deviceType")
device_name = payload.get("userAgent").get("deviceName")
# Данные пакета
payload = {
"proxy": "",
"logs-enabled": False,
"proxy-domains": [],
"location": "RU",
"libh-enabled": False,
"phone-auto-complete-enabled": False
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.SESSION_INIT, payload=payload
)
# Отправляем
await self._send(writer, packet)
return device_type, device_name
async def ping(self, payload, seq, writer):
"""Обработчик пинга"""
# Валидируем данные пакета
try:
PingPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.PING, self.error_types.INVALID_PAYLOAD, writer)
return
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.PING, payload=None
)
# Отправляем
await self._send(writer, packet)
async def log(self, payload, seq, writer):
"""Обработчик лога"""
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOG, payload=None
)
# Отправляем
await self._send(writer, packet)

View File

@@ -0,0 +1,63 @@
import json, pydantic
from classes.baseprocessor import BaseProcessor
from tamtam.models import SearchUsersPayloadModel
class SearchProcessors(BaseProcessor):
async def contact_info(self, payload, seq, writer):
"""Поиск пользователей по ID"""
# Валидируем данные пакета
try:
SearchUsersPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CONTACT_INFO, self.error_types.INVALID_PAYLOAD, writer)
return
# Итоговый список пользователей
users = []
# ID пользователей, которые нам предстоит найти
contactIds = payload.get("contactIds")
# Ищем пользователей в бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
for contactId in contactIds:
await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,))
user = await cursor.fetchone()
# Если такой пользователь есть, добавляем его в список
if user:
# Аватарка с биографией
photo_id = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photo_id else self.config.avatar_base_url + photo_id
description = None if not user.get("description") else user.get("description")
# Генерируем профиль
users.append(
self.tools.generate_profile_tt(
id=user.get("id"),
phone=int(user.get("phone")),
avatarUrl=avatar_url,
photoId=photo_id,
updateTime=int(user.get("updatetime")),
firstName=user.get("firstname"),
lastName=user.get("lastname"),
options=json.loads(user.get("options")),
description=description,
username=user.get("username")
)
)
# Создаем данные пакета
payload = {
"contacts": users
}
# Создаем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_INFO, payload=payload
)
# Отправляем
await self._send(writer, response)

174
src/tamtam/socket.py Normal file
View File

@@ -0,0 +1,174 @@
import asyncio
import logging
import traceback
from common.proto_tcp import MobileProto
from tamtam.processors import Processors
from common.rate_limiter import RateLimiter
from common.opcodes import Opcodes
from common.tools import Tools
class TamTamMobile:
def __init__(self, host, port, ssl_context, db_pool, clients, send_event):
self.host = host
self.port = port
self.ssl_context = ssl_context
self.server = None
self.logger = logging.getLogger(__name__)
self.db_pool = db_pool
self.clients = clients
self.opcodes = Opcodes()
self.proto = MobileProto()
self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event)
self.auth_required = Tools().auth_required
# rate limiter
self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60)
self.read_timeout = 300 # Таймаут чтения из сокета (секунды)
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.PING:
await self.processors.ping(payload, seq, writer)
case self.opcodes.LOG:
await self.processors.log(payload, seq, writer)
case self.opcodes.AUTH_REQUEST:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.opcodes.AUTH_REQUEST, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.auth_request(payload, seq, writer)
case self.opcodes.AUTH:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.opcodes.AUTH, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.auth(payload, seq, writer)
case self.opcodes.AUTH_CONFIRM:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.opcodes.AUTH_CONFIRM, self.processors.error_types.RATE_LIMITED, writer)
else:
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:
await self._finish_auth(writer, address, userPhone, userId)
case self.opcodes.CONTACT_INFO:
await self.auth_required(
userPhone, self.processors.contact_info, payload, seq, writer
)
case _:
self.logger.warning(f"Неизвестный опкод {opcode}")
except Exception as e:
self.logger.error(f"Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}")
traceback.print_exc()
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": "tamtam"
}
)
else:
self.clients[id] = {
"phone": phone,
"id": id,
"clients": [
{
"writer": writer,
"ip": addr[0],
"port": addr[1],
"protocol": "tamtam"
}
]
}
async def _end_session(self, id, ip, port):
"""Завершение сессии"""
# Получаем пользователя в списке
user = self.clients.get(id)
if not user:
return
# Получаем подключения пользователя
clients = user.get("clients", [])
# Удаляем нужное подключение из словаря
for i, client in enumerate(clients):
if (client.get("ip"), client.get("port")) == (ip, port):
clients.pop(i)
async def start(self):
"""Функция для запуска сервера"""
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()

171
src/tamtam/websocket.py Normal file
View File

@@ -0,0 +1,171 @@
import logging
import traceback
import websockets
from common.proto_web import WebProto
from tamtam.processors import Processors
from common.rate_limiter import RateLimiter
from common.opcodes import Opcodes
from common.tools import Tools
class TamTamWS:
def __init__(self, host, port, clients, ssl_context, db_pool, send_event):
self.host = host
self.port = port
self.ssl_context = ssl_context
self.server = None
self.logger = logging.getLogger(__name__)
self.db_pool = db_pool
self.clients = clients
self.opcodes = Opcodes()
self.proto = WebProto()
self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event, type="web")
self.auth_required = Tools().auth_required
# rate limiter
self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60)
self.read_timeout = 300 # Таймаут чтения из websocket (секунды)
self.max_read_size = 65536 # Максимальный размер данных
async def handle_client(self, websocket):
"""Функция для обработки WebSocket подключений"""
# IP-адрес клиента
address = websocket.remote_address
self.logger.info(f"Работаю с клиентом {address[0]}:{address[1]}")
deviceType = None
deviceName = None
userPhone = None
userId = None
hashedToken = None
try:
async for message in websocket:
# Проверяем размер данных
if len(message) > self.max_read_size:
self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(message)} байт)")
break
# Распаковываем данные
packet = self.proto.unpack_packet(message)
# Если пакет невалидный — пропускаем
if not packet:
self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}")
continue
opcode = packet.get("opcode")
seq = packet.get("seq")
payload = packet.get("payload")
match opcode:
case self.opcodes.SESSION_INIT:
deviceType, deviceName = await self.processors.session_init(payload, seq, websocket)
case self.opcodes.PING:
await self.processors.ping(payload, seq, websocket)
case self.opcodes.LOG:
await self.processors.log(payload, seq, websocket)
case self.opcodes.AUTH_REQUEST:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.opcodes.AUTH_REQUEST, self.processors.error_types.RATE_LIMITED, websocket)
else:
await self.processors.auth_request(payload, seq, websocket)
case self.opcodes.AUTH:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.opcodes.AUTH, self.processors.error_types.RATE_LIMITED, websocket)
else:
await self.processors.auth(payload, seq, websocket)
case self.opcodes.AUTH_CONFIRM:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.opcodes.AUTH_CONFIRM, self.processors.error_types.RATE_LIMITED, websocket)
else:
await self.processors.auth_confirm(payload, seq, websocket, deviceType, deviceName)
case self.opcodes.LOGIN:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.opcodes.LOGIN, self.processors.error_types.RATE_LIMITED, websocket)
else:
userPhone, userId, hashedToken = await self.processors.login(payload, seq, websocket)
if userPhone:
await self._finish_auth(websocket, address, userPhone, userId)
case self.opcodes.CONTACT_INFO:
await self.auth_required(
userPhone, self.processors.contact_info, payload, seq, websocket
)
case self.opcodes.CHAT_HISTORY:
await self.auth_required(
userPhone, self.processors.chat_history, payload, seq, websocket, userId
)
case _:
self.logger.warning(f"Неизвестный опкод {opcode}")
except websockets.exceptions.ConnectionClosed:
self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}")
except Exception as e:
self.logger.error(f" Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}")
traceback.print_exc()
# Удаляем клиента из словаря при отключении
if userId:
await self._end_session(userId, address[0], address[1])
self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}")
async def _finish_auth(self, websocket, addr, phone, id):
"""Завершение открытия сессии"""
# Ищем пользователя в словаре
user = self.clients.get(id)
# Добавляем новое подключение в словарь
if user:
user["clients"].append(
{
"writer": websocket,
"ip": addr[0],
"port": addr[1],
"protocol": "tamtam"
}
)
else:
self.clients[id] = {
"phone": phone,
"id": id,
"clients": [
{
"writer": websocket,
"ip": addr[0],
"port": addr[1],
"protocol": "tamtam"
}
]
}
async def _end_session(self, id, ip, port):
"""Завершение сессии"""
# Получаем пользователя в списке
user = self.clients.get(id)
if not user:
return
# Получаем подключения пользователя
clients = user.get("clients", [])
# Удаляем нужное подключение из словаря
for i, client in enumerate(clients):
if (client.get("ip"), client.get("port")) == (ip, port):
clients.pop(i)
async def start(self):
"""Функция для запуска WebSocket сервера"""
self.server = await websockets.serve(
self.handle_client,
self.host,
self.port,
ssl=self.ssl_context
)
self.logger.info(f"TT WebSocket запущен на порту {self.port}")
await self.server.wait_closed()

View File

@@ -1,280 +0,0 @@
import hashlib, secrets, random, time, logging, json
from common.static import Static
from common.tools import Tools
from tamtam_tcp.proto import Proto
from tamtam_tcp.models import *
class Processors:
def __init__(self, db_pool=None, clients={}, send_event=None):
self.static = Static()
self.proto = Proto()
self.tools = Tools()
self.error_types = self.static.ErrorTypes()
self.db_pool = db_pool
self.logger = logging.getLogger(__name__)
async def _send(self, writer, packet):
try:
writer.write(packet)
await writer.drain()
except:
pass
async def _send_error(self, seq, opcode, type, writer):
payload = self.static.ERROR_TYPES.get(type, {
"localizedMessage": "Неизвестная ошибка",
"error": "unknown.error",
"message": "Unknown error",
"title": "Неизвестная ошибка"
})
packet = self.proto.pack_packet(
cmd=self.proto.CMD_ERR, seq=seq, opcode=opcode, payload=payload
)
await self._send(writer, packet)
async def process_hello(self, payload, seq, writer):
"""Обработчик приветствия"""
# Валидируем данные пакета
try:
HelloPayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.proto.HELLO, self.error_types.INVALID_PAYLOAD, writer)
return None, None
# Получаем данные из пакета
deviceType = payload.get("userAgent").get("deviceType")
deviceName = payload.get("userAgent").get("deviceName")
# Данные пакета
payload = {
"proxy": "",
"logs-enabled": False,
"proxy-domains": [],
"location": "RU",
"libh-enabled": False,
"phone-auto-complete-enabled": False
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.HELLO, payload=payload
)
# Отправляем
await self._send(writer, packet)
return deviceType, deviceName
async def process_request_code(self, payload, seq, writer):
"""Обработчик запроса кода"""
# Валидируем данные пакета
try:
RequestCodePayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.proto.REQUEST_CODE, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем телефон из пакета
phone = payload.get("phone").replace("+", "").replace(" ", "").replace("-", "")
# Генерируем токен с кодом
code = str(random.randint(000000, 999999))
token = secrets.token_urlsafe(128)
# Хешируем
code_hash = hashlib.sha256(code.encode()).hexdigest()
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Время истечения токена
expires = int(time.time()) + 300
# Ищем пользователя, и если он существует, сохраняем токен
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,))
user = await cursor.fetchone()
# Если пользователь существует, сохраняем токен
if user:
# Сохраняем токен
await cursor.execute("INSERT INTO auth_tokens (phone, token_hash, code_hash, expires, state) VALUES (%s, %s, %s, %s, %s)", (phone, token_hash, code_hash, expires, "started",))
# Данные пакета
payload = {
"verifyToken": token,
"retries": 5,
"codeDelay": 60,
"codeLength": 6,
"callDelay": 0,
"requestType": "SMS"
}
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.REQUEST_CODE, payload=payload
)
# Отправляем
await self._send(writer, packet)
self.logger.debug(f"Код для {phone}: {code}")
async def process_verify_code(self, payload, seq, writer):
"""Обработчик проверки кода"""
# Валидируем данные пакета
try:
VerifyCodePayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
code = payload.get("verifyCode")
token = payload.get("token")
# Хешируем токен с кодом
hashed_code = hashlib.sha256(code.encode()).hexdigest()
hashed_token = hashlib.sha256(token.encode()).hexdigest()
# Ищем токен с кодом
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Ищем токен
await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", (hashed_token,))
stored_token = await cursor.fetchone()
if stored_token is None:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.CODE_EXPIRED, writer)
return
# Проверяем код
if stored_token.get("code_hash") != hashed_code:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_CODE, writer)
return
# Ищем аккаунт
await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),))
account = await cursor.fetchone()
# Обновляем состояние токена
await cursor.execute("UPDATE auth_tokens set state = %s WHERE token_hash = %s", ("verified", hashed_token,))
# Генерируем профиль
# Аватарка с биографией
photoId = None if not account.get("avatar_id") else int(account.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = None if not account.get("description") else account.get("description")
# Собираем данные пакета
payload = {
"profile": self.tools.generate_profile_tt(
id=account.get("id"),
phone=int(account.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(account.get("updatetime")),
firstName=account.get("firstname"),
lastName=account.get("lastname"),
options=json.loads(account.get("options")),
description=description,
username=account.get("username")
),
"tokenAttrs": {
"AUTH": {
"token": token
}
},
"tokenTypes": {
"AUTH": token
}
}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.VERIFY_CODE, payload=payload
)
await self._send(writer, packet)
async def process_final_auth(self, payload, seq, writer, deviceType, deviceName):
"""Обработчик финальной аутентификации"""
# Валидируем данные пакета
try:
FinalAuthPayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.proto.FINAL_AUTH, self.error_types.INVALID_PAYLOAD, writer)
return
# Извлекаем данные из пакета
token = payload.get("token")
if not deviceType:
deviceType = payload.get("deviceType")
# Хешируем токен
hashed_token = hashlib.sha256(token.encode()).hexdigest()
# Генерируем постоянный токен
login = secrets.token_urlsafe(128)
hashed_login = hashlib.sha256(login.encode()).hexdigest()
# Ищем токен с кодом
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Ищем токен
await cursor.execute("SELECT * FROM auth_tokens WHERE token_hash = %s AND expires > UNIX_TIMESTAMP()", (hashed_token,))
stored_token = await cursor.fetchone()
if stored_token is None:
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer)
return
# Если авторизация только началась - отдаем ошибку
if stored_token.get("state") == "started":
await self._send_error(seq, self.proto.VERIFY_CODE, self.error_types.INVALID_TOKEN, writer)
return
# Ищем аккаунт
await cursor.execute("SELECT * FROM users WHERE phone = %s", (stored_token.get("phone"),))
account = await cursor.fetchone()
# Удаляем токен
await cursor.execute("DELETE FROM auth_tokens WHERE token_hash = %s", (hashed_token,))
# Создаем сессию
await cursor.execute(
"INSERT INTO tokens (phone, token_hash, device_type, device_name, location, time) VALUES (%s, %s, %s, %s, %s, %s)",
(stored_token.get("phone"), hashed_login, deviceType, deviceName, "Epstein Island", int(time.time()),)
)
# Аватарка с биографией
photoId = None if not account.get("avatar_id") else int(account.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + photoId
description = None if not account.get("description") else account.get("description")
# Собираем данные пакета
payload = {
"userToken": "0", # Пока как заглушка
"profile": self.tools.generate_profile_tt(
id=account.get("id"),
phone=int(account.get("phone")),
avatarUrl=avatar_url,
photoId=photoId,
updateTime=int(account.get("updatetime")),
firstName=account.get("firstname"),
lastName=account.get("lastname"),
options=json.loads(account.get("options")),
description=description,
username=account.get("username")
),
"tokenType": "LOGIN",
"token": login
}
# Создаем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.proto.FINAL_AUTH, payload=payload
)
# Отправялем
await self._send(writer, packet)

View File

@@ -1,91 +0,0 @@
import lz4.block, msgpack, logging, json
class Proto:
def __init__(self) -> None:
self.logger = logging.getLogger(__name__)
### Работа с протоколом
def unpack_packet(self, data: bytes) -> dict | 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_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=99999,
)
except lz4.block.LZ4BlockError:
return None
# Распаковываем msgpack
payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False)
self.logger.debug(f"Распаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload}")
# Возвращаем
return {
"ver": ver,
"cmd": cmd,
"seq": seq,
"opcode": opcode,
"payload": payload,
}
def pack_packet(self, ver: int = 10, cmd: int = 1, seq: int = 1, opcode: int = 6, payload: dict = None) -> bytes:
# Запаковываем заголовок
ver_b = ver.to_bytes(1, "big")
cmd_b = cmd.to_bytes(2, "big")
seq_b = seq.to_bytes(1, "big")
opcode_b = opcode.to_bytes(2, "big")
# Запаковываем данные пакета
payload_bytes: bytes | None = msgpack.packb(payload)
if payload_bytes is None:
payload_bytes = b""
payload_len = len(payload_bytes) & 0xFFFFFF
payload_len_b = payload_len.to_bytes(4, 'big')
self.logger.debug(f"Упаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload}")
# Возвращаем пакет
return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + payload_bytes
### Констаты протокола
CMD_OK = 0x100
CMD_NOF = 0x200
CMD_ERR = 0x300
PROTO_VER = 10
HELLO = 6
REQUEST_CODE = 17
VERIFY_CODE = 18
FINAL_AUTH = 23
LOGIN = 19
PING = 1
TELEMETRY = 5
GET_ASSETS = 27
GET_CALL_HISTORY = 79
SEND_MESSAGE = 64
GET_FOLDERS = 272
GET_SESSIONS = 96
LOGOUT = 20
SEARCH_CHATS = 48
SEARCH_BY_PHONE = 46

View File

@@ -1,74 +0,0 @@
import asyncio, logging, traceback
from tamtam_tcp.proto import Proto
from tamtam_tcp.processors import Processors
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)
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:
# Читаем новые данные из сокета
data = await reader.read(4098)
# Если сокет закрыт - выходим из цикла
if not data:
break
# Распаковываем данные
packet = self.proto.unpack_packet(data)
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:
await self.processors.process_request_code(payload, seq, writer)
case self.proto.VERIFY_CODE:
await self.processors.process_verify_code(payload, seq, writer)
case self.proto.FINAL_AUTH:
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,80 +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):
"""Отправка пакета"""
try:
await writer.send(packet)
except Exception as error:
self.logger.error(f"Ошибка при отправке пакета - {error}")
async def _send_error(self, seq, opcode, type, writer):
payload = self.static.ERROR_TYPES.get(type, {
"localizedMessage": "Неизвестная ошибка",
"error": "unknown.error",
"message": "Unknown error",
"title": "Неизвестная ошибка"
})
packet = self.proto.pack_packet(
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 pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
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,161 +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
})
def unpack_packet(self, packet):
# нужно try catch сделать
# чтобы не сыпалось всё при неверных пакетах
try:
parsed_packet = json.loads(packet)
except:
return None
return parsed_packet
# мне кажется долго вручную всё писать
# а как еще
# ну вставить сюда целиком и потом через multiline cursor удалить лишнее
# ну ты удалишь тогда. я на тачпаде
# ладно щас другим способом удалю
# всё нахуй
# TAMTAM SOURCE LEAK 2026
# так ну че делать будем
# так ну
# 19 опкод сделан?
# нет сэр пошли библиотеку тамы смотреть
# мб найдем че. она без обфускации
# а ты ее видишь?
# пошли
### Констаты протокола
CMD_OK = 1
CMD_NOF = 2
CMD_ERR = 3
PROTO_VER = 10
### Команды
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,65 +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, origins=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__)
self.origins = origins
async def handle_client(self, websocket):
deviceType = None
deviceName = None
async for message in websocket:
# Распаковываем пакет
packet = self.proto.unpack_packet(message)
# Если ничего не извлекли
if packet is None:
self.logger.error(f"Не удалось распаковать пакет - {message}")
return
# Валидируем структуру пакета
try:
MessageModel.model_validate(packet)
except ValidationError as error:
self.logger.error(f"Произошла ошибка при валидации структуры пакета: {error}")
return
# Извлекаем данные из пакета
seq = packet['seq']
opcode = packet['opcode']
payload = packet['payload']
match opcode:
case self.proto.SESSION_INIT:
# ПРИВЕТ АНДРЕЙ МАЛАХОВ
# не не удаляй этот коммент. пусть останется на релизе аххахаха
deviceType, deviceType = 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)
# лан я пойду. пока
# а ок
async def start(self):
self.logger.info(f"Вебсокет запущен на порту {self.port}")
async with serve(handler=self.handle_client,
host=self.host,
port=self.port,
origins=self.origins):
await asyncio.Future()

View File

@@ -1,15 +1,25 @@
import json
import logging
import random
import json
import time
from telebot.async_telebot import AsyncTeleBot
from textwrap import dedent
from common.static import Static
from aiogram import Bot, Dispatcher, Router
from aiogram.filters import Command
from aiogram.types import Message
from common.sql_queries import SQLQueries
from common.static import Static
from common.tools import Tools
class TelegramBot:
def __init__(self, token, enabled, db_pool, whitelist_ids=None):
self.bot = AsyncTeleBot(token)
self.bot = Bot(token=token)
self.dp = Dispatcher()
self.router = Router()
self.dp.include_router(self.router)
self.tools = Tools()
self.enabled = enabled
self.db_pool = db_pool
self.whitelist_ids = whitelist_ids if whitelist_ids is not None else []
@@ -19,103 +29,107 @@ class TelegramBot:
self.static = Static()
self.sql_queries = SQLQueries()
@self.bot.message_handler(commands=['start'])
async def handle_start(message):
tg_id = str(message.from_user.id)
self.router.message.register(self.handle_start, Command("start"))
self.router.message.register(self.handle_register, Command("register"))
# Ищем привязанный аккаунт пользователя
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(self.sql_queries.SELECT_USER_BY_TG_ID, (tg_id,))
account = await cursor.fetchone()
if account:
# Извлекаем id аккаунта с телефоном
phone = account.get('phone')
await self.bot.send_message(
message.chat.id,
self.get_bot_message(self.msg_types.WELCOME_ALREADY_REGISTERED).format(phone=phone)
)
else:
await self.bot.send_message(
message.chat.id, self.get_bot_message(self.msg_types.WELCOME_NEW_USER)
)
@self.bot.message_handler(commands=['register'])
async def handle_register(message):
tg_id = str(message.from_user.id)
# Проверка ID на наличие в белом списке
if tg_id not in self.whitelist_ids:
await self.bot.send_message(message.chat.id, self.get_bot_message(self.msg_types.ID_NOT_WHITELISTED))
return
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Проверка на существование
await cursor.execute(self.sql_queries.SELECT_USER_BY_TG_ID, (tg_id,))
if await cursor.fetchone():
await self.bot.send_message(
message.chat.id,
self.get_bot_message(self.msg_types.ACCOUNT_ALREADY_EXISTS)
)
return
# Подготовка данных согласно схеме
new_phone = f"7900{random.randint(1000000, 9999999)}"
updatetime = str(int(time.time() * 1000))
lastseen = str(int(time.time()))
try:
# Создаем юзера
await cursor.execute(
self.sql_queries.INSERT_USER,
(
new_phone, # phone
tg_id, # telegram_id
message.from_user.first_name[:59], # firstname
(message.from_user.last_name or "")[:59], # lastname
(message.from_user.username or "")[:60], # username
json.dumps([]), # profileoptions
json.dumps(["TT", "ONEME"]), # options
0, # accountstatus
updatetime,
lastseen,
)
)
# Добавляем данные о аккаунте
await cursor.execute(
self.sql_queries.INSERT_USER_DATA,
(
new_phone, # phone
json.dumps([]), # chats
json.dumps([]), # contacts
json.dumps(self.static.USER_FOLDERS), # folders
json.dumps(self.static.USER_SETTINGS), # user settings
json.dumps({}), # chat_config
)
)
await self.bot.send_message(
message.chat.id,
self.get_bot_message(self.msg_types.REGISTRATION_SUCCESS).format(new_phone=new_phone)
)
except Exception as e:
self.logger.error(f"Ошибка при регистрации: {e}")
await self.bot.send_message(
message.chat.id,
self.get_bot_message(self.msg_types.INTERNAL_ERROR)
)
def get_bot_message(self, msg_type):
return dedent(self.static.BOT_MESSAGES.get(msg_type)).strip()
async def handle_start(self, message: Message):
tg_id = str(message.from_user.id)
# Ищем привязанный аккаунт пользователя
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(self.sql_queries.SELECT_USER_BY_TG_ID, (tg_id,))
account = await cursor.fetchone()
if account:
# Извлекаем id аккаунта с телефоном
phone = account.get("phone")
await message.answer(
self.get_bot_message(self.msg_types.WELCOME_ALREADY_REGISTERED).format(
phone=phone
)
)
else:
await message.answer(self.get_bot_message(self.msg_types.WELCOME_NEW_USER))
async def handle_register(self, message: Message):
tg_id = str(message.from_user.id)
# Проверка ID на наличие в белом списке
if self.whitelist_ids and tg_id not in self.whitelist_ids:
await message.answer(
self.get_bot_message(self.msg_types.ID_NOT_WHITELISTED)
)
return
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Проверка на существование
await cursor.execute(self.sql_queries.SELECT_USER_BY_TG_ID, (tg_id,))
if await cursor.fetchone():
await message.answer(
self.get_bot_message(self.msg_types.ACCOUNT_ALREADY_EXISTS)
)
return
# Подготовка данных согласно схеме
new_phone = f"7900{random.randint(1000000, 9999999)}"
updatetime = str(int(time.time() * 1000))
lastseen = str(int(time.time()))
try:
# Создаем юзера
await cursor.execute(
self.sql_queries.INSERT_USER,
(
new_phone, # phone
tg_id, # telegram_id
message.from_user.first_name[:59], # firstname
(message.from_user.last_name or "")[:59], # lastname
(message.from_user.username or "")[:60], # username
json.dumps([]), # profileoptions
json.dumps(["TT", "ONEME"]), # options
0, # accountstatus
updatetime,
lastseen,
),
)
# Добавляем данные о аккаунте
await cursor.execute(
self.sql_queries.INSERT_USER_DATA,
(
new_phone, # phone
json.dumps(self.static.USER_SETTINGS), # user settings
json.dumps({}), # chat_config
),
)
# Добавляем дефолтную папку
await cursor.execute(
self.sql_queries.INSERT_DEFAULT_FOLDER,
(new_phone,),
)
await message.answer(
self.get_bot_message(
self.msg_types.REGISTRATION_SUCCESS
).format(new_phone=new_phone)
)
except Exception as e:
self.logger.error(f"Ошибка при регистрации: {e}")
await message.answer(
self.get_bot_message(self.msg_types.INTERNAL_ERROR)
)
async def start(self):
if self.enabled == True:
if self.enabled:
try:
await self.bot.polling()
await self.dp.start_polling(self.bot)
except Exception as e:
self.logger.error(f"Ошибка запуска Telegram бота: {e}")
else:
@@ -124,7 +138,10 @@ class TelegramBot:
async def send_auth_code(self, chat_id, phone, code):
try:
await self.bot.send_message(
chat_id, self.get_bot_message(self.msg_types.INCOMING_CODE).format(phone=phone, code=code)
chat_id,
self.get_bot_message(self.msg_types.INCOMING_CODE).format(
phone=phone, code=code
),
)
except Exception as e:
self.logger.error(f"Ошибка отправки кода в Telegram: {e}")
self.logger.error(f"Ошибка отправки кода в Telegram: {e}")

View File

@@ -1,5 +1,5 @@
CREATE TABLE `users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`id` INT NOT NULL AUTO_INCREMENT,
`phone` VARCHAR(20) UNIQUE,
`telegram_id` VARCHAR(64) UNIQUE,
`firstname` VARCHAR(59) NOT NULL,
@@ -12,7 +12,8 @@ CREATE TABLE `users` (
`options` JSON NOT NULL,
`accountstatus` VARCHAR(16) NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`username` VARCHAR(60) UNIQUE
`username` VARCHAR(60) UNIQUE,
PRIMARY KEY (`id`)
);
CREATE TABLE `tokens` (
@@ -21,7 +22,9 @@ CREATE TABLE `tokens` (
`device_type` VARCHAR(256) NOT NULL,
`device_name` VARCHAR(256) NOT NULL,
`location` VARCHAR(256) NOT NULL,
`time` VARCHAR(16) NOT NULL
`time` VARCHAR(16) NOT NULL,
`push_token` VARCHAR(512) DEFAULT NULL,
PRIMARY KEY (`phone`, `token_hash`)
);
CREATE TABLE `auth_tokens` (
@@ -29,27 +32,26 @@ CREATE TABLE `auth_tokens` (
`token_hash` VARCHAR(64) NOT NULL,
`code_hash` VARCHAR(64) NOT NULL,
`expires` VARCHAR(16) NOT NULL,
`state` VARCHAR(16)
`state` VARCHAR(16),
PRIMARY KEY (`phone`, `token_hash`)
);
CREATE TABLE `user_data` (
`phone` VARCHAR(20) NOT NULL UNIQUE,
`chats` JSON NOT NULL,
`contacts` JSON NOT NULL,
`folders` JSON NOT NULL,
`user_config` JSON NOT NULL,
`chat_config` JSON NOT NULL
`chat_config` JSON NOT NULL,
PRIMARY KEY (`phone`)
);
CREATE TABLE `chats` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`id` INT NOT NULL,
`owner` INT NOT NULL,
`type` VARCHAR(16) NOT NULL,
`participants` JSON NOT NULL
PRIMARY KEY (`id`)
);
CREATE TABLE `messages` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`id` INT NOT NULL AUTO_INCREMENT,
`chat_id` INT NOT NULL,
`sender` INT NOT NULL,
`time` VARCHAR(32) NOT NULL,
@@ -57,5 +59,52 @@ CREATE TABLE `messages` (
`attaches` JSON NOT NULL,
`cid` VARCHAR(32) NOT NULL,
`elements` JSON NOT NULL,
`type` VARCHAR(16) NOT NULL
);
`type` VARCHAR(16) NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `chat_participants` (
`chat_id` INT NOT NULL,
`user_id` INT NOT NULL,
`joined_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`chat_id`, `user_id`)
);
CREATE TABLE `contacts` (
`owner_id` INT NOT NULL,
`contact_id` INT NOT NULL,
`custom_firstname` VARCHAR(64),
`custom_lastname` VARCHAR(64),
`is_blocked` BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (`owner_id`, `contact_id`)
);
CREATE TABLE `banners` (
`id` VARCHAR(64) NOT NULL,
`title` VARCHAR(256) NOT NULL,
`description` VARCHAR(512) NOT NULL,
`url` VARCHAR(512) NOT NULL,
`type` INT NOT NULL DEFAULT 1,
`priority` INT NOT NULL DEFAULT 0,
`animoji_id` INT NOT NULL DEFAULT 0,
`repeat` INT NOT NULL DEFAULT 1,
`rerun` BIGINT NOT NULL DEFAULT 0,
`hide_close_button` BOOLEAN NOT NULL DEFAULT FALSE,
`hide_on_click` BOOLEAN NOT NULL DEFAULT FALSE,
`is_title_animated` BOOLEAN NOT NULL DEFAULT FALSE,
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
);
CREATE TABLE `user_folders` (
`id` VARCHAR(64) NOT NULL,
`phone` VARCHAR(20) NOT NULL,
`title` VARCHAR(128) NOT NULL,
`filters` JSON NOT NULL DEFAULT ('[]'),
`options` JSON NOT NULL DEFAULT ('[]'),
`source_id` INT NOT NULL DEFAULT 1,
`update_time` BIGINT NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`, `phone`)
);