Compare commits

...

93 Commits

Author SHA1 Message Date
Alexey Polyakov
c0e23840b5 maybe fixes 2026-05-14 17:30:57 +03:00
Alexey Polyakov
c5721f3f9e Вроде как нормальные заглушки под ассеты 2026-05-13 18:56:27 +03:00
Alexey Polyakov
03cffc24aa TT: регистрация через клиент 2026-05-13 18:42:00 +03:00
Alexey Polyakov
87f22a3feb MAX & TT: теперь полноценный чёрный список 2026-05-13 15:58:02 +03:00
zavolo
7d2e070d1f fix(chat history): фикс 2026-05-11 00:47:04 +03:00
zavolo
24b0123185 fix(chat history): фикс 2026-05-11 00:38:04 +03:00
zavolo
31844c7fa2 fix(chat history): фикс 2026-05-11 00:26:31 +03:00
zavolo
9b60b15538 fix(chat history): фикс 2026-05-11 00:11:24 +03:00
zavolo
0d91f6542e fix(chat history): фикс 2026-05-10 23:39:08 +03:00
zavolo
77d6ca8cc0 fix(chat history): фикс 2026-05-10 23:27:13 +03:00
zavolo
3bf8bc5770 fix(chat history): фикс 2026-05-10 23:21:07 +03:00
zavolo
861b75eb1c MAX: bootstrap-история в LOGIN — клиент перестал думать что всё уже синканулось
В ответе LOGIN сервер слал messages: {} и chatMarker: 0. Десктопный
клиент в этом случае считает, что локальная история уже синхронизирована
со старого запуска, и НЕ отправляет CHAT_HISTORY (49) при открытии чата.
В окне видно только lastMessage из chats[], а вся реальная переписка —
ничерта.

- src/common/tools.py: collect_bootstrap_history(chatIds, ...) —
  собирает карту {chatId: [последние N сообщений]}, в т.ч. избранное
  под клиентским id = senderId ^ senderId.
- src/oneme/processors/auth.py: подсовываем эту карту в
  payload.messages, chatMarker = текущее время вместо 0.
2026-05-10 22:27:42 +03:00
zavolo
fa0ed34adc MAX: история таки заработала — cid/link/reactionInfo обязательны в схеме
Десктопный MAX подключается через TCP (mobile-протокол) и парсит
msgpack по фиксированной схеме. Если в сообщении выпадает любое из
полей — клиент молча обрывает соединение. После 87cfc19 как раз
такие условные `if elements: ...` / `if link: ...` (а link и
reaction_info там всегда были `{}`, то есть falsy) вырезали поля
из ответа CHAT_HISTORY и MSG_SEND, чем и сломали историю.

- src/common/tools.py: новый build_message_dict() — единая сборка
  тела сообщения, где все поля (id, cid, time, type, sender, text,
  attaches, elements, reactionInfo, link) присутствуют ВСЕГДА.
  get_last_message переписан через него.
- src/oneme/processors/history.py: chat_history использует
  build_message_dict вместо ручной логики с условными if-ками.
- src/oneme/processors/messages.py: msg_send.bodyMessage теперь
  отдает cid / reactionInfo / link даже пустыми и приводит id
  к int для mobile, str для web.

Цепная польза: auth.py LOGIN bootstrap (через generate_chats →
get_last_message) и search.py тоже теперь шлют корректную схему.
2026-05-10 22:17:18 +03:00
Alexey Polyakov
87cfc1932e Попытка починить историю (спойлер, нихуя не получилось) 2026-05-10 19:04:51 +03:00
Alexey Polyakov
17245f44d0 Фикс сборки избранного 2026-05-09 18:55:49 +03:00
Alexey Polyakov
b1a37bfa24 update sql scheme 2026-05-09 18:54:53 +03:00
Alexey Polyakov
d81eec5532 Генерируем айди пользователей рандомно (опять, да) 2026-05-09 18:16:32 +03:00
Alexey Polyakov
ddb810589f MAX: исправление уязвимости с избранными 2026-05-09 17:55:39 +03:00
Alexey Polyakov
dff6937da8 MAX: добавление контакта по номеру телефона 2026-05-09 15:50:47 +03:00
Alexey Polyakov
ac40cc53c9 MAX && TT: обновление контакта 2026-05-08 16:15:33 +03:00
Alexey Polyakov
756956d8a0 TT: тоже самое что в макс 2026-05-08 16:04:03 +03:00
Alexey Polyakov
00071c80be oops 2026-05-08 15:58:29 +03:00
Alexey Polyakov
a045457128 MAX: блокировка и разблокировка контакта 2026-05-08 15:57:24 +03:00
Alexey Polyakov
4d51c70f8e Вынес sqlite в отдельный модуль 2026-05-08 15:27:05 +03:00
Alexey Polyakov
2d3b9285bf MAX: теперь для избранного не сравниваем айди с нулём 2026-05-08 15:24:49 +03:00
Alexey Polyakov
6bb0d52419 nothing 2026-05-06 21:27:01 +03:00
Alexey Polyakov
911008c0a1 oops 2026-05-06 20:46:22 +03:00
Alexey Polyakov
b8472821eb MAX: web fix 2 2026-05-06 20:42:33 +03:00
Alexey Polyakov
f1c1639d9f MAX: web fix 2026-05-06 20:33:09 +03:00
Alexey Polyakov
7426e83914 nothing 2026-05-06 19:34:14 +03:00
Alexey Polyakov
8dc3ef1731 MAX: Почистил серверный конфиг 2026-05-06 17:40:11 +03:00
Alexey Polyakov
f1ff4fd062 MAX && TT: общение в таме, и корректировки под веб морду 2026-05-06 15:58:27 +03:00
Alexey Polyakov
0b6eda6178 TG Bot: fix username 2026-05-06 15:53:14 +03:00
Alexey Polyakov
02df98cdbd TG Bot: fix 2026-05-06 15:43:30 +03:00
Alexey Polyakov
49d73200b0 fix 2026-05-06 15:39:22 +03:00
Alexey Polyakov
389a08ebce nothing 2026-05-06 15:35:23 +03:00
Alexey Polyakov
613e1b96cd Решение проблемы обработки sigterm 2026-05-06 15:25:07 +03:00
Alexey Polyakov
0f2d946b98 TG Bot: возможность включения белого списка 2026-05-06 00:51:56 +03:00
Alexey Polyakov
1ff974dfce oops 2026-05-05 23:08:02 +03:00
Alexey Polyakov
bcd94b3a57 TT: ну вроде шире поддержка, а вообще обратная совместимость с максом клас 2026-05-05 23:06:50 +03:00
Alexey Polyakov
89f1fefa31 MAX & TT: теперь в качестве страны локации используется настоящая страна пользователя, а также зафиксировал версии библиотек в зависимостях 2026-04-28 18:22:16 +03:00
Alexey Polyakov
c716520ca4 MAX: добавление/удаление контактов, статусы 2026-04-28 06:56:29 +03:00
Alexey Polyakov
ff46e417f4 MAX: oops 2026-04-27 17:41:51 +03:00
Alexey Polyakov
bd95755db4 MAX: создание папок 2026-04-27 17:40:28 +03:00
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
Alexey Polyakov
1ec1d49424 Merge branch 'master' into dev/0.1.0 2026-03-11 20:44:19 +03:00
Alexey Polyakov
9034485408 Различные фиксы (#14)
* Исключаем только ошибку валидации

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

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

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

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

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

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

View File

@@ -17,10 +17,16 @@ 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"
telegram_whitelist_enabled = "1"
telegram_whitelist_ids = "1,2,3"
origins="http://127.0.0.1,https://web.openmax.su"
sms_gateway_url = "http://127.0.0.1:8100/sms-gateway"
firebase_credentials_path = ""
geo_db_path = ""

5
.gitignore vendored
View File

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

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

View File

@@ -1,29 +0,0 @@
# Описание протокола TamTam по Websocket
## Основная информация
В веб версии мессенджера ТамТам используется протокол, работающий поверх Websocket.
Пакеты в этом протоколе являются текстовыми JSON данными.
Структура пакета:
```
{
ver: int,
cmd: int,
seq: int,
opcode: int,
payload: {}
}
```
* ver - версия протокола
* cmd - определяет, от кого отправлен пакет. клиент - 0, сервер - 1
* seq - порядковый номер пакета (сервер дублирует его из запроса клиента)
* opcode - команда
* payload - полезная нагрузка команды
## Команды протокола
### PING (1)
Клиент периодически отправляет пакет с командой PING и пустой нагрузкой серверу.
Сервер отвечает ему тем же.

View File

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

View File

@@ -22,3 +22,26 @@
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`

View File

@@ -3,7 +3,7 @@
> Проект находится на ранней стадии разработки и вероятно полон багов.
>
> Использование в профессиональных средах не рекомендовано.
>
# OpenMAX
Эмулятор сервера MAX и ТамТам

View File

@@ -1,8 +1,12 @@
pyTelegramBotAPI
aiomysql
msgpack
lz4
websockets
pydantic
aiosqlite
python-dotenv
aiogram==3.26.0
aiomysql==0.3.2
msgpack==1.1.2
lz4==4.4.5
websockets==16.0
pydantic==2.12.5
aiosqlite==0.22.1
aiohttp==3.13.5
python-dotenv==1.2.2
cryptography==46.0.6
firebase-admin==7.4.0
geoip2==5.2.0

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

@@ -43,5 +43,18 @@ class ServerConfig:
### Telegram bot
telegram_bot_token = os.getenv("telegram_bot_token") or "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
telegram_bot_enabled = bool(os.getenv("telegram_bot_enabled")) or True
telegram_whitelist_ids = [x.strip() for x in os.getenv("telegram_whitelist_ids", "").split(",") if x.strip()]
telegram_bot_enabled = bool(int(os.getenv("telegram_bot_enabled", 0)))
telegram_whitelist_ids = [x.strip() for x in os.getenv("telegram_whitelist_ids", "").split(",") if x.strip()]
telegram_whitelist_enabled = bool(int(os.getenv("telegram_whitelist_enabled", 0)))
### origins
origins = [x.strip() for x in os.getenv("origins", "").split(",") if x.strip()] if os.getenv("origins") else None
### sms шлюз
sms_gateway_url = os.getenv("sms_gateway_url", "")
### Firebase
firebase_credentials_path = os.getenv("firebase_credentials_path", "")
### Путь к гео бд
geo_db_path = os.getenv("geo_db_path", "")

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

@@ -0,0 +1,157 @@
class Opcodes:
def __init__(self):
pass
PING = 1
DEBUG = 2
RECONNECT = 3
LOG = 5
SESSION_INIT = 6
PROFILE = 16
AUTH_REQUEST = 17
AUTH = 18
LOGIN = 19
LOGOUT = 20
SYNC = 21
CONFIG = 22
AUTH_CONFIRM = 23
AUTH_CREATE_TRACK = 112
AUTH_CHECK_PASSWORD = 113
AUTH_LOGIN_CHECK_PASSWORD = 115
AUTH_LOGIN_PROFILE_DELETE = 116
AUTH_LOGIN_RESTORE_PASSWORD = 101
AUTH_VALIDATE_PASSWORD = 107
AUTH_VALIDATE_HINT = 108
AUTH_VERIFY_EMAIL = 109
AUTH_CHECK_EMAIL = 110
AUTH_SET_2FA = 111
AUTH_2FA_DETAILS = 104
ASSETS_GET = 26
ASSETS_UPDATE = 27
ASSETS_GET_BY_IDS = 28
ASSETS_LIST_MODIFY = 261
ASSETS_REMOVE = 259
ASSETS_MOVE = 260
ASSETS_ADD = 29
PRESET_AVATARS = 25
CONTACT_INFO = 32
CONTACT_INFO_BY_PHONE = 46
CONTACT_ADD = 33
CONTACT_UPDATE = 34
CONTACT_PRESENCE = 35
CONTACT_LIST = 36
CONTACT_SEARCH = 37
CONTACT_MUTUAL = 38
CONTACT_PHOTOS = 39
CONTACT_SORT = 40
CONTACT_ADD_BY_PHONE = 41
CONTACT_VERIFY = 42
REMOVE_CONTACT_PHOTO = 43
CHAT_INFO = 48
CHAT_HISTORY = 49
CHAT_MARK = 50
CHAT_MEDIA = 51
CHAT_DELETE = 52
CHATS_LIST = 53
CHAT_CLEAR = 54
CHAT_UPDATE = 55
CHAT_CHECK_LINK = 56
CHAT_JOIN = 57
CHAT_LEAVE = 58
CHAT_MEMBERS = 59
PUBLIC_SEARCH = 60
CHAT_PERSONAL_CONFIG = 61
CHAT_CREATE = 63
REACTIONS_SETTINGS_GET_BY_CHAT_ID = 258
CHAT_REACTIONS_SETTINGS_SET = 257
MSG_SEND = 64
MSG_TYPING = 65
MSG_DELETE = 66
MSG_EDIT = 67
MSG_DELETE_RANGE = 92
MSG_REACTION = 178
MSG_CANCEL_REACTION = 179
MSG_GET_REACTIONS = 180
MSG_GET_DETAILED_REACTIONS = 181
CHAT_SEARCH = 68
MSG_SHARE_PREVIEW = 70
MSG_GET = 71
MSG_SEARCH_TOUCH = 72
MSG_SEARCH = 73
MSG_GET_STAT = 74
CHAT_SUBSCRIBE = 75
VIDEO_CHAT_START = 76
VIDEO_CHAT_START_ACTIVE = 78
CHAT_MEMBERS_UPDATE = 77
VIDEO_CHAT_HISTORY = 79
PHOTO_UPLOAD = 80
STICKER_UPLOAD = 81
VIDEO_UPLOAD = 82
VIDEO_PLAY = 83
VIDEO_CHAT_CREATE_JOIN_LINK = 84
CHAT_PIN_SET_VISIBILITY = 86
FILE_UPLOAD = 87
FILE_DOWNLOAD = 88
LINK_INFO = 89
SESSIONS_INFO = 96
SESSIONS_CLOSE = 97
PHONE_BIND_REQUEST = 98
PHONE_BIND_CONFIRM = 99
GET_INBOUND_CALLS = 103
EXTERNAL_CALLBACK = 105
OK_TOKEN = 158
CHAT_COMPLAIN = 117
MSG_SEND_CALLBACK = 118
SUSPEND_BOT = 119
LOCATION_STOP = 124
GET_LAST_MENTIONS = 127
STICKER_CREATE = 193
STICKER_SUGGEST = 194
VIDEO_CHAT_MEMBERS = 195
NOTIF_MESSAGE = 128
NOTIF_TYPING = 129
NOTIF_MARK = 130
NOTIF_CONTACT = 131
NOTIF_PRESENCE = 132
NOTIF_CONFIG = 134
NOTIF_CHAT = 135
NOTIF_ATTACH = 136
NOTIF_CALL_START = 137
NOTIF_CONTACT_SORT = 139
NOTIF_MSG_DELETE_RANGE = 140
NOTIF_MSG_DELETE = 142
NOTIF_MSG_REACTIONS_CHANGED = 155
NOTIF_MSG_YOU_REACTED = 156
NOTIF_CALLBACK_ANSWER = 143
CHAT_BOT_COMMANDS = 144
BOT_INFO = 145
NOTIF_LOCATION = 147
NOTIF_LOCATION_REQUEST = 148
NOTIF_ASSETS_UPDATE = 150
NOTIF_DRAFT = 152
NOTIF_DRAFT_DISCARD = 153
DRAFT_SAVE = 176
DRAFT_DISCARD = 177
CHAT_HIDE = 196
CHAT_SEARCH_COMMON_PARTICIPANTS = 198
NOTIF_MSG_DELAYED = 154
NOTIF_PROFILE = 159
PROFILE_DELETE = 199
PROFILE_DELETE_TIME = 200
WEB_APP_INIT_DATA = 160
COMPLAIN = 161
COMPLAIN_REASONS_GET = 162
FOLDERS_GET = 272
FOLDERS_GET_BY_ID = 273
FOLDERS_UPDATE = 274
FOLDERS_REORDER = 275
FOLDERS_DELETE = 276
NOTIF_FOLDERS = 277
AUTH_QR_APPROVE = 290
NOTIF_BANNERS = 292
CHAT_SUGGEST = 300
AUDIO_PLAY = 301
SEND_VOTE = 304
VOTERS_LIST_BY_ANSWER = 305
GET_POLL_UPDATES = 306

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

@@ -1,10 +1,12 @@
import time, logging
import logging
import time
class RateLimiter:
"""
ip rate limiter using sliding window algorithm
"""
def __init__(self, max_attempts=5, window_seconds=60):
self.max_attempts = max_attempts
self.window_seconds = window_seconds
@@ -21,7 +23,9 @@ class RateLimiter:
self.attempts[ip] = [t for t in self.attempts[ip] if t > cutoff]
if len(self.attempts[ip]) >= self.max_attempts:
self.logger.warning(f"request limit exceeded for {ip}: {len(self.attempts[ip])}/{self.max_attempts}")
self.logger.warning(
f"request limit exceeded for {ip}: {len(self.attempts[ip])}/{self.max_attempts}"
)
return False
self.attempts[ip].append(now)

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)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
INSERT INTO users
(id, phone, telegram_id, firstname, lastname, username,
profileoptions, options, accountstatus, updatetime, lastseen)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
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)
"""

63
src/common/sqlite.py Normal file
View File

@@ -0,0 +1,63 @@
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
async def commit(self):
await self.connection.commit()
def cursor(self):
return SQLiteCursorCompat(self.connection)
class SQLitePoolCompat:
def __init__(self, connection):
self.connection = connection
def acquire(self):
return SQLiteConnectionCompat(self.connection)

View File

@@ -13,6 +13,9 @@ class Static:
CHAT_NOT_FOUND = "chat_not_found"
CHAT_NOT_ACCESS = "chat_not_access"
RATE_LIMITED = "rate_limited"
CONTACT_NOT_FOUND = "contact_not_found"
CONTACT_ALREADY_ADDED = "contact_already_added"
CONTACT_BLOCKED = "contact_blocked"
class ChatTypes:
DIALOG = "DIALOG"
@@ -80,7 +83,25 @@ class Static:
"error": "error.rate_limited",
"message": "Too many attempts. Please try again later",
"title": "Слишком много попыток"
}
},
"contact_not_found": {
"localizedMessage": "Контакт не найден",
"error": "contact.not.found",
"message": "Contact not found",
"title": "Контакт не найден"
},
"contact_already_added": {
"localizedMessage": "Контакт уже добавлен",
"error": "contact.already.added",
"message": "Contact already added",
"title": "Контакт уже добавлен"
},
"contact_blocked": {
"localizedMessage": "Вы не можете написать этому пользователю",
"error": "contact.blocked",
"message": "Contact is blocked",
"title": "Вы не можете написать этому пользователю"
},
}
### Сообщения бота
@@ -116,36 +137,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,
@@ -176,3 +247,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,63 @@
import json, time
import json
import random
import secrets
import time
import geoip2.database
class Tools:
def __init__(self):
pass
def build_message_dict(self, row, protocol_type="mobile"):
"""Сборка тела сообщения"""
try:
attaches = json.loads(row.get("attaches") or "[]")
except (TypeError, ValueError):
attaches = []
try:
elements = json.loads(row.get("elements") or "[]")
except (TypeError, ValueError):
elements = []
message = {
"id": row.get("id") if protocol_type == "mobile" else str(row.get("id")),
"cid": int(row.get("cid") or 0),
"chatId": int(row.get("chat_id") or 0),
"time": int(row.get("time")),
"type": row.get("type") or "USER",
"sender": row.get("sender"),
"text": row.get("text") or "",
"attaches": attaches,
"elements": elements,
"reactionInfo": {},
"link": {}
}
return message
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=None,
phone=None,
avatarUrl=None,
photoId=None,
updateTime=None,
firstName=None,
lastName=None,
options=None,
description=None,
accountStatus=None,
profileOptions=None,
includeProfileOptions=True,
username=None,
# для контактов, собственно
custom_firstname=None,
custom_lastname=None,
blocked=False
):
contact = {
"id": id,
@@ -20,14 +68,15 @@ class Tools:
"name": firstName,
"firstName": firstName,
"lastName": lastName,
"type": "ONEME"
"type": "ONEME",
}
],
"options": options,
"accountStatus": accountStatus
"accountStatus": accountStatus,
"location": "RU",
"registrationTime": int(time.time() * 1000)
}
if avatarUrl:
contact["photoId"] = photoId
contact["baseUrl"] = avatarUrl
@@ -39,20 +88,99 @@ 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_chat(self, id, owner, type, participants, lastMessage, lastEventTime):
def generate_profile_tt(
self,
id=None,
phone=None,
avatarUrl=None,
photoId=None,
updateTime=None,
firstName=None,
lastName=None,
options=None,
description=None,
username=None,
custom_firstname=None,
custom_lastname=None,
blocked=None
):
# Так как TT не поддерживает фамилию, и если нам ее не передали в функцию
# то используем только имя, чтобы избежать None в фамилии
if firstName and lastName:
name = f"{firstName} {lastName}"
else:
name = firstName
# Используем такой же костыль, как и выше
if custom_firstname and custom_lastname:
custom_name = f"{custom_firstname} {custom_lastname}"
elif custom_firstname:
custom_name = custom_firstname
else:
custom_name = None
contact = {
"id": id,
"updateTime": updateTime,
"phone": phone,
"names": [{"name": name, "type": "TT"}],
"options": options,
}
if avatarUrl:
contact["photoId"] = photoId
contact["baseUrl"] = avatarUrl
contact["baseRawUrl"] = avatarUrl
if description:
contact["description"] = description
# NOTE: официальный сервер вроде как отдавал tt.me, но клиент примет любую ссылку
# можно потом как нибудь сделать возможность редактирования этого момента, но это
# позже, так как по юзернейму искать пока нельзя
if username:
contact["link"] = "https://tt.me/" + username
if custom_firstname:
contact["names"].append(
{"name": custom_name, "type": "CUSTOM"}
)
if blocked:
contact["status"] = "BLOCKED"
return contact
def generate_chat(
self, id, owner, type, participants, lastMessage, lastEventTime, prevMessageId=0
):
"""Генерация чата"""
# Генерируем список участников
result_participants = {
str(participant): 0 for participant in participants
}
if isinstance(participants, dict):
result_participants = {
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
@@ -69,14 +197,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 = []
@@ -86,19 +223,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(
@@ -108,78 +256,385 @@ 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:
# Получаем последнее сообщение из избранного
favouriteChatId = -senderId
message, messageTime = await self.get_last_message(
favouriteChatId, db_pool, protocol_type=protocol_type
)
# ID избранного для клиента
chatId = senderId ^ senderId
# Получаем последнюю активность в избранном
participants = await self.get_participant_last_activity(
favouriteChatId, [senderId], db_pool
)
# Получаем ID предыдущего сообщения для избранного
prevMessageId = await self.get_previous_message_id(
favouriteChatId, db_pool, protocol_type=protocol_type
)
# Хардкодим в лист чатов избранное
chats.append(
self.generate_chat(
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 AND is_blocked = FALSE",
(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) # время отправки сообщения
# Генерируем ID сообщения
message_id = int(time.time() * 1000000) * 1000 + random.randint(100, 999)
# Вносим новое сообщение в таблицу
await cursor.execute(
"INSERT INTO `messages` (chat_id, sender, time, text, attaches, cid, elements, type) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
(chatId, senderId, int(time.time() * 1000), text, json.dumps(attaches), cid, json.dumps(elements), type)
"INSERT INTO `messages` (id, chat_id, sender, time, text, attaches, cid, elements, type) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
(
message_id,
chatId,
senderId,
message_time,
text,
json.dumps(attaches),
cid,
json.dumps(elements),
type,
),
)
message_id = cursor.lastrowid # id сообщения
# Возвращаем айдишки
return int(message_id), int(last_message_id)
return int(message_id), int(last_message_id), message_time
async def get_last_message(self, chatId, db_pool):
async def get_last_message(self, chatId, db_pool, protocol_type="mobile"):
"""Получение последнего сообщения в чате"""
async with db_pool.acquire() as db_connection:
async with db_connection.cursor() as cursor:
# Получаем id последнего сообщения в чате
await cursor.execute("SELECT * FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1", (chatId,))
await cursor.execute(
"SELECT * FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT 1",
(chatId,),
)
row = await cursor.fetchone()
# Если нет результатов - возвращаем None
if not row:
return None, None
# Собираем сообщение
message = {
"id": row.get("id"),
"time": int(row.get("time")),
"type": row.get("type"),
"sender": row.get("sender"),
"cid": int(row.get("cid")),
"text": row.get("text"),
"attaches": json.loads(row.get("attaches")),
"elements": json.loads(row.get("elements")),
"reactionInfo": {}
}
# Возвращаем
return message, int(row.get("time"))
return self.build_message_dict(row, protocol_type), 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
async def collect_presence(self, contact_ids, clients, db_pool):
"""Собирает статусы пользователей"""
now = int(time.time())
presence = {}
# Список тех, кого нужно поискать в базе данных
db_lookup_ids = []
# Проходимся по всем айдишникам,
# которые передал нам клиент
for contact_id in contact_ids:
contact_id = int(contact_id)
client = clients.get(contact_id)
# Если пользователь онлайн
if client and client.get("status") == 2:
presence[str(contact_id)] = {"seen": now, "status": 2}
# Если пользователь подключен,
# но не взаимодействует с клиентом
elif client and client.get("last_seen"):
presence[str(contact_id)] = {"seen": client.get("last_seen")}
# А если никакое условие не подошло, то добавляем его в лист,
# а позже посмотрим в базе данных
else:
db_lookup_ids.append(contact_id)
# Проходимся по листу и добавляем недостающих,
# если такие существуют конечно
async with db_pool.acquire() as conn:
async with conn.cursor() as cursor:
for contact_id in db_lookup_ids:
await cursor.execute(
"SELECT lastseen FROM users WHERE id = %s",
(contact_id,)
)
row = await cursor.fetchone()
if row:
lastseen = row.get("lastseen")
presence[int(contact_id)] = {"seen": int(lastseen)}
return presence
def get_geo(self, ip, db_path):
"""
Получение страны пользователя по его айпи адресу
Используется во время запуска сессии
"""
try:
with geoip2.database.Reader(db_path) as reader:
response = reader.country(ip)
return response.country.name or "Localhost Federation"
except Exception:
return "Localhost Federation"
async def generate_user_id(self, db_pool):
"""Генерация id пользователя"""
async with db_pool.acquire() as conn:
async with conn.cursor() as cursor:
while True:
user_id = secrets.randbelow(2_147_383_647) + 100_000
await cursor.execute("SELECT id FROM users WHERE id = %s", (user_id,))
if not await cursor.fetchone():
return user_id
async def contact_is_blocked(self, owner_id, contact_id, db_pool):
"""
По изначальной задумке, данная функция должна проверять, заблокирован ли контакт
На сервере долгое время не был доделан черный список, хотя управление им было реализовано
(на деле, это я поленился)
Вернёт вам true, если контакт заблокирован, иначе false
"""
# Проверяем наличие контакта
async with 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 AND is_blocked = %s", (owner_id, contact_id, True))
row = await cursor.fetchone()
# Есди контакт существует и заблокирован, возвращаем true,
if row:
return True
else: # в ином случае false
return False

View File

@@ -1,10 +1,18 @@
# Импортирование библиотек
import ssl, logging, asyncio
import asyncio
import logging
import signal
import ssl
import sys
import traceback
from common.config import ServerConfig
from oneme_tcp.controller import OnemeMobileController
from common.push import PushService
from common.sqlite import SQLitePoolCompat
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()
@@ -15,6 +23,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 +31,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
@@ -45,7 +57,7 @@ def set_logging():
"""Настройка уровня логирования"""
# Настройка уровня логирования
log_level = server_config.log_level
if log_level == "debug":
logging.basicConfig(level=logging.DEBUG)
elif log_level == "info":
@@ -53,40 +65,87 @@ 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
"event": api_event,
"origins": server_config.origins,
}
controllers = {
"oneme_mobile": OnemeMobileController(),
"tamtam_mobile": TTMobileController(),
"tamtam_ws": TTWSController(),
"telegrambot": TelegramBotController()
"oneme": OnemeController(),
"tamtam": TTController(),
"telegrambot": TelegramBotController(),
}
api["telegram_bot"] = controllers["telegrambot"]
tasks = [
controller.launch(api)
for controller in controllers.values()
]
loop = asyncio.get_running_loop()
running_tasks = []
def _shutdown(sig):
logging.info(f"Получен сигнал {sig}, завершаем все задачи...")
for task in running_tasks:
task.cancel()
if sys.platform != "win32":
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _shutdown, sig)
coros = [controller.launch(api) for controller in controllers.values()]
running_tasks.extend(asyncio.create_task(coro) for coro in coros)
# Запускаем контроллеры
await asyncio.gather(*tasks)
try:
await asyncio.gather(*running_tasks)
except asyncio.CancelledError:
logging.info("Все задачи завершены")
except Exception as e:
logging.error(
f"Произошла неизвестная ошибка: {e}"
)
traceback.print_exc()
finally:
if hasattr(db, 'close'):
db.close()
await db.wait_closed()
elif hasattr(db, 'connection') and hasattr(db.connection, 'close'):
await db.connection.close()
if __name__ == "__main__":
asyncio.run(main())
asyncio.run(main())

View File

@@ -2,7 +2,6 @@ class OnemeConfig:
def __init__(self):
pass
# TODO: почистить вообще надо, и настройки потыкать
SERVER_CONFIG = {
"account-nickname-enabled": False,
"account-removal-enabled": False,
@@ -43,7 +42,7 @@ class OnemeConfig:
},
"callDontUseVpnForRtp": False,
"callEnableIceRenomination": False,
"calls-endpoint": "https://calls.okcdn.ru/",
"calls-endpoint": "",
"calls-sdk-am-speaker-fix": True,
"calls-sdk-audio-dynamic-redundancy": {
"mab": 16,
@@ -135,9 +134,9 @@ class OnemeConfig:
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",
"invite-link": "",
"invite-long": "",
"invite-short": "",
"join-requests": True,
"js-download-delegate": False,
"keep-connection": 2,
@@ -179,7 +178,7 @@ class OnemeConfig:
"moscow-theme-enabled": True,
"msg-get-reactions-page-size": 40,
"music-files-enabled": False,
"mytracker-enabled": False,
"mytracker-enabled": True,
"net-client-dns-enabled": True,
"net-session-suppress-bad-disconnected-state": True,
"net-stat-config": [
@@ -268,43 +267,14 @@ class OnemeConfig:
"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": "Каналы"
}
]
"items": []
},
"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
}
]
}
],
"settings-entry-banners": [],
"show-reactions-on-multiselect": True,
"show-warning-links": True,
"speedy-upload": True,
@@ -347,28 +317,11 @@ class OnemeConfig:
"stuck": 10,
"hang": 60
},
"webapp-exc": [
63602953,
8250447
],
"webapp-exc": [],
"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"
],
"welcome-sticker-ids": [],
"white-list-links": [],
"wm-analytics-enabled": True,
"wm-workers-limit": 80,
"wud": False,
@@ -380,4 +333,4 @@ class OnemeConfig:
"logoDark": "https://st.max.ru/icons/ya_maps_logo_dark.webp"
},
"has-phone": True
}
}

130
src/oneme/controller.py Normal file
View File

@@ -0,0 +1,130 @@
import asyncio
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 OnemeController(ControllerBase):
def __init__(self):
self.config = ServerConfig()
self.proto_tcp = MobileProto()
self.proto_web = WebProto()
self.opcodes = Opcodes()
async def event(self, target, client, eventData):
# Извлекаем тип события и врайтер
eventType = eventData.get("eventType")
writer = client.get("writer")
is_web = client.get("type") == "web"
# Выбираем протокол в зависимости от типа подключения
proto = self.proto_web if is_web else self.proto_tcp
packet = None
# Не отправляем событие самому себе
if writer == eventData.get("writer"):
return
# Обрабатываем событие
if eventType == "new_msg":
# Данные сообщения
chatId = eventData.get("chatId")
message = eventData.get("message")
prevMessageId = eventData.get("prevMessageId")
time = eventData.get("time")
# Данные пакета
payload = {
"chatId": chatId,
"message": message,
"prevMessageId": prevMessageId,
"ttl": False,
"unread": 0,
"mark": time
}
# Создаем пакет
packet = proto.pack_packet(
cmd=0, seq=1, opcode=self.opcodes.NOTIF_MESSAGE, payload=payload
)
elif eventType == "typing":
# Данные события
chatId = eventData.get("chatId")
userId = eventData.get("userId")
type = eventData.get("type")
# Данные пакета
payload = {
"chatId": chatId,
"userId": userId,
"type": type
}
# Создаем пакет
packet = proto.pack_packet(
cmd=0, seq=1, opcode=self.opcodes.NOTIF_TYPING, payload=payload
)
elif eventType == "profile_updated":
# Данные события
profile = eventData.get("profile")
# Данные пакета
payload = {
"profile": profile
}
# Создаем пакет
packet = proto.pack_packet(
cmd=0, seq=1, opcode=self.opcodes.NOTIF_PROFILE, payload=payload
)
elif eventType == "presence":
userId = eventData.get("userId")
presence = eventData.get("presence")
event_time = eventData.get("time")
payload = {
"userId": userId,
"presence": presence,
"time": event_time
}
packet = proto.pack_packet(
cmd=0, seq=1, opcode=self.opcodes.NOTIF_PRESENCE, payload=payload
)
if not packet:
return
if is_web:
await writer.send(packet)
else:
writer.write(packet)
await writer.drain()
def launch(self, api):
async def _start_all():
await asyncio.gather(
OnemeMobile(
host=self.config.host,
port=self.config.oneme_tcp_port,
ssl_context=api['ssl'],
db_pool=api['db'],
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,
ssl_context=api['ssl'],
db_pool=api['db'],
clients=api['clients'],
send_event=api['event'],
telegram_bot=api.get('telegram_bot'),
).start()
)
return _start_all()

182
src/oneme/models.py Normal file
View File

@@ -0,0 +1,182 @@
import pydantic
class UserAgentModel(pydantic.BaseModel):
deviceType: str
appVersion: str
osVersion: str
timezone: str
release: int = None
screen: str
pushDeviceType: str = None
arch: str = None
locale: str
buildNumber: int = None
deviceName: str
deviceLocale: str
class HelloPayloadModel(pydantic.BaseModel):
clientSessionId: int = None
mt_instanceid: str = None
userAgent: UserAgentModel
deviceId: str
class RequestCodePayloadModel(pydantic.BaseModel):
phone: str
type: str
@pydantic.field_validator('phone')
def validate_phone(cls, v):
"""Валидация номера телефона"""
if not v.replace("+", "").replace(" ", "").replace("-", "").isdigit():
raise ValueError('phone must be digits')
return v
@pydantic.field_validator('type')
def validate_type(cls, v):
"""Валидация типа запроса"""
if not v in ("START_AUTH", "RESEND"):
raise ValueError('type must be valid')
return v
class VerifyCodePayloadModel(pydantic.BaseModel):
verifyCode: str
authTokenType: str
token: str
class LoginPayloadModel(pydantic.BaseModel):
interactive: bool
token: str
class PingPayloadModel(pydantic.BaseModel):
interactive: bool = None
class AssetsPayloadModel(pydantic.BaseModel):
sync: int
type: str
class AssetsGetPayloadModel(pydantic.BaseModel):
type: str
count: int = 100
query: str = None
class AssetsGetByIdsPayloadModel(pydantic.BaseModel):
type: str
ids: list
class AssetsAddPayloadModel(pydantic.BaseModel):
type: str
id: int = None
class AssetsRemovePayloadModel(pydantic.BaseModel):
type: str
ids: list
class AssetsMovePayloadModel(pydantic.BaseModel):
type: str
id: int
position: int
class AssetsListModifyPayloadModel(pydantic.BaseModel):
type: str
ids: list
class GetCallHistoryPayloadModel(pydantic.BaseModel):
forward: bool
count: int
class MessageModel(pydantic.BaseModel):
isLive: bool = None
detectShare: bool = None
elements: list = None
attaches: list = None
cid: int = None
text: str = None
class SendMessagePayloadModel(pydantic.BaseModel):
# TODO: пишем сервер макса в 2 ночи и не понимаем как это валидировать (блять)
userId: int = None
chatId: int = None
message: MessageModel
class SyncFoldersPayloadModel(pydantic.BaseModel):
folderSync: int
class CreateFolderPayloadModel(pydantic.BaseModel):
id: str
title: str
filters: list = []
include: list = []
class SearchChatsPayloadModel(pydantic.BaseModel):
chatIds: list
class SearchByPhonePayloadModel(pydantic.BaseModel):
phone: str
class GetCallTokenPayloadModel(pydantic.BaseModel):
userId: int
value: str
class TypingPayloadModel(pydantic.BaseModel):
chatId: int
type: str = None
class SearchUsersPayloadModel(pydantic.BaseModel):
contactIds: list
class ComplainReasonsGetPayloadModel(pydantic.BaseModel):
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
class ContactPresencePayloadModel(pydantic.BaseModel):
contactIds: list
class ContactAddByPhonePayloadModel(pydantic.BaseModel):
phone: str
firstName: str
class ContactUpdatePayloadModel(pydantic.BaseModel):
action: str
contactId: int
firstName: str = None
lastName: str = None

View File

@@ -0,0 +1,28 @@
from .assets import AssetsProcessors
from .auth import AuthProcessors
from .calls import CallsProcessors
from .chats import ChatsProcessors
from .complaints import ComplaintsProcessors
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,
ComplaintsProcessors,
ContactsProcessors,
FoldersProcessors,
HistoryProcessors,
MainProcessors,
MessagesProcessors,
SearchProcessors,
SessionsProcessors
):
pass

View File

@@ -0,0 +1,167 @@
import pydantic
import time
from classes.baseprocessor import BaseProcessor
from oneme.models import (
AssetsPayloadModel,
AssetsGetPayloadModel,
AssetsGetByIdsPayloadModel,
AssetsAddPayloadModel,
AssetsRemovePayloadModel,
AssetsMovePayloadModel,
AssetsListModifyPayloadModel,
)
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
response = {
"sync": int(time.time() * 1000),
"stickerSetsUpdates": {},
"stickersUpdates": {},
"stickersOrder": [
"RECENT",
"FAVORITE_STICKERS",
"FAVORITE_STICKER_SETS",
"TOP",
"NEW",
"NEW_STICKER_SETS",
],
"sections": [
{
"id": "RECENT",
"type": "RECENTS",
"recentsList": [],
},
{
"id": "FAVORITE_STICKERS",
"type": "STICKERS",
"stickers": [],
"marker": None,
},
{
"id": "FAVORITE_STICKER_SETS",
"type": "STICKER_SETS",
"stickerSets": [],
"marker": None,
},
{
"id": "TOP",
"type": "STICKERS",
"stickers": [],
"marker": None,
},
{
"id": "NEW",
"type": "STICKERS",
"stickers": [],
"marker": None,
},
{
"id": "NEW_STICKER_SETS",
"type": "STICKER_SETS",
"stickerSets": [],
"marker": None,
},
],
}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_UPDATE, payload=response
)
await self._send(writer, packet)
async def assets_get(self, payload, seq, writer):
try:
data = AssetsGetPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_GET, self.error_types.INVALID_PAYLOAD, writer)
return
asset_type = data.type
if asset_type == "STICKER_SET":
response = {"stickerSets": [], "marker": None}
else:
response = {"stickers": [], "marker": None}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_GET, payload=response
)
await self._send(writer, packet)
async def assets_get_by_ids(self, payload, seq, writer):
try:
data = AssetsGetByIdsPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_GET_BY_IDS, self.error_types.INVALID_PAYLOAD, writer)
return
asset_type = data.type
if asset_type == "STICKER_SET":
response = {"stickerSets": []}
else:
response = {"stickers": []}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_GET_BY_IDS, payload=response
)
await self._send(writer, packet)
async def assets_add(self, payload, seq, writer):
try:
AssetsAddPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_ADD, self.error_types.INVALID_PAYLOAD, writer)
return
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_ADD, payload={}
)
await self._send(writer, packet)
async def assets_remove(self, payload, seq, writer):
try:
AssetsRemovePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_REMOVE, self.error_types.INVALID_PAYLOAD, writer)
return
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_REMOVE, payload={}
)
await self._send(writer, packet)
async def assets_move(self, payload, seq, writer):
try:
AssetsMovePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_MOVE, self.error_types.INVALID_PAYLOAD, writer)
return
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_MOVE, payload={}
)
await self._send(writer, packet)
async def assets_list_modify(self, payload, seq, writer):
try:
AssetsListModifyPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_LIST_MODIFY, self.error_types.INVALID_PAYLOAD, writer)
return
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_LIST_MODIFY, payload={}
)
await self._send(writer, packet)

View File

@@ -0,0 +1,666 @@
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, ip):
"""Обработчик проверки кода"""
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,
self.tools.get_geo(
ip=ip, db_path=self.config.geo_db_path
),
int(time.time() * 1000),
), # весь покрытый зеленью, абсолютно весь, остров невезения в океане есть
)
# Генерируем профиль
# Аватарка с биографией
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 + str(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, ip):
"""Обработчик подтверждения регистрации нового пользователя"""
# Валидируем данные пакета
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())
# Генерируем ID пользователя
user_id = await self.tools.generate_user_id(self.db_pool)
# Создаем пользователя
await cursor.execute(
"""
INSERT INTO users
(id, phone, telegram_id, firstname, lastname, username,
profileoptions, options, accountstatus, updatetime, lastseen)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
user_id,
phone,
None,
first_name,
last_name,
None,
json.dumps([]),
json.dumps(["TT", "ONEME"]),
0,
str(now_ms),
str(now_s),
),
)
# Добавляем данные аккаунта
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",
self.tools.get_geo(
ip=ip, db_path=self.config.geo_db_path
),
now_ms,
),
)
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 None, None, None
# Чаты, где состоит пользователь
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 None, None, None
# Ищем аккаунт пользователя в бд
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 + str(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
)
# Собираем статусы контактов
contact_ids = [c.get("id") for c in contacts if c.get("id") is not None]
presence = await self.tools.collect_presence(contact_ids, self.clients, self.db_pool)
# Формируем данные пакета
payload = {
"profile": profile,
"chats": chats,
"chatMarker": 0,
"messages": {},
"contacts": contacts,
"presence": presence,
"config": {
"hash": "0",
"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 ComplaintsProcessors(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,412 @@
import pydantic
import json
import time
from classes.baseprocessor import BaseProcessor
from oneme.models import ContactAddByPhonePayloadModel, ContactListPayloadModel, ContactPresencePayloadModel, ContactUpdatePayloadModel
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)
async def contact_update(self, payload, seq, writer, userId):
"""
Обработчик опкода какого-то там
(их хуй запомнишь, даже в мриме команды помню, бля)
Отвечает за добавку, удаление, блокировку и разблокировку контакта
"""
# Валидируем данные пакета
try:
ContactUpdatePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.INVALID_PAYLOAD, writer)
return
action = payload.get("action")
contactId = payload.get("contactId")
firstName = payload.get("firstName")
lastName = payload.get("lastName", "")
if action == "ADD":
# Проверяем, существует ли пользователь с таким ID
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,))
user = await cursor.fetchone()
if not user:
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.CONTACT_NOT_FOUND, writer)
return
# Проверяем, не добавлен ли уже контакт
await cursor.execute(
"SELECT * FROM contacts WHERE owner_id = %s AND contact_id = %s",
(userId, contactId)
)
row = await cursor.fetchone()
# Если контакта не существует, то можем продолжать,
if not row:
# Добавляем контакт
await cursor.execute(
"INSERT INTO contacts (owner_id, contact_id, custom_firstname, custom_lastname, is_blocked) VALUES (%s, %s, %s, %s, FALSE)",
(userId, contactId, firstName, lastName)
)
# Создаем диалог, если его нет
chatId = userId ^ contactId
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, userId, "DIALOG")
)
for uid in [int(userId), int(contactId)]:
await cursor.execute(
"INSERT INTO chat_participants (chat_id, user_id) VALUES (%s, %s)",
(chatId, uid)
)
# а если уже существует, отправляем ошибку
else:
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.CONTACT_ALREADY_ADDED, 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 + str(photoId)
contact = 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")),
accountStatus=int(user.get("accountstatus")),
includeProfileOptions=False,
custom_firstname=firstName,
custom_lastname=lastName,
)
response_payload = {
"contact": contact
}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_UPDATE, payload=response_payload
)
await self._send(writer, packet)
elif action == "REMOVE":
# Удаляем контакт
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"DELETE FROM contacts WHERE owner_id = %s AND contact_id = %s",
(userId, contactId)
)
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_UPDATE, payload=None
)
await self._send(writer, packet)
elif action == "BLOCK":
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",
(userId, contactId)
)
row = await cursor.fetchone()
# Обновляем существующий контакт, если такой есть
if row:
await cursor.execute(
"UPDATE contacts SET is_blocked = TRUE WHERE owner_id = %s AND contact_id = %s",
(userId, contactId)
)
else: # В ином случае добавляем новую запись в бд
await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,))
user = await cursor.fetchone()
if not user:
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.USER_NOT_FOUND, writer)
return
await cursor.execute(
"INSERT INTO contacts (owner_id, contact_id, custom_firstname, custom_lastname, is_blocked) VALUES (%s, %s, %s, %s, TRUE)",
(userId, contactId, firstName, lastName)
)
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_UPDATE, payload=None
)
await self._send(writer, packet)
elif action == "UNBLOCK":
# Разблокируем контакт
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",
(userId, contactId)
)
row = await cursor.fetchone()
# Обновляем контакт, если он есть
if row:
await cursor.execute(
"UPDATE contacts SET is_blocked = FALSE WHERE owner_id = %s AND contact_id = %s",
(userId, contactId)
)
else: # В ином случае отправляем ошибку
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.CONTACT_NOT_FOUND, writer)
return
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_UPDATE, payload=None
)
await self._send(writer, packet)
elif action == "UPDATE":
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",
(userId, contactId)
)
row = await cursor.fetchone()
# Если контакта нет, отдаем ошибку
if not row:
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.CONTACT_NOT_FOUND, writer)
return
# Обновляем контакт
await cursor.execute(
"UPDATE contacts SET custom_firstname = %s, custom_lastname = %s WHERE owner_id = %s AND contact_id = %s",
(firstName, lastName, userId, contactId)
)
# Получаем данные пользователя
await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,))
user = 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 + str(photoId)
contact = 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")),
accountStatus=int(user.get("accountstatus")),
description=user.get("description"),
includeProfileOptions=False,
custom_firstname=firstName,
custom_lastname=lastName,
)
response_payload = {
"contact": contact
}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_UPDATE, payload=response_payload
)
await self._send(writer, packet)
async def contact_add_by_phone(self, payload, seq, writer, userId):
"""Добавление контакта по номеру телефона"""
# Валидируем данные пакета
try:
ContactAddByPhonePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CONTACT_ADD_BY_PHONE, self.error_types.INVALID_PAYLOAD, writer)
return
phone = payload.get("phone")
firstName = payload.get("firstName")
lastName = payload.get("lastName")
# Ищем пользователя по номеру телефона
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM users WHERE phone = %s", (int(phone),))
user = await cursor.fetchone()
if not user:
await self._send_error(seq, self.opcodes.CONTACT_ADD_BY_PHONE, self.error_types.CONTACT_NOT_FOUND, writer)
return
contactId = user.get("id")
# Проверяем, не добавлен ли уже контакт
await cursor.execute(
"SELECT * FROM contacts WHERE owner_id = %s AND contact_id = %s",
(userId, contactId)
)
existing_contact = await cursor.fetchone()
is_new = existing_contact is None
if is_new:
# Добавляем контакт
await cursor.execute(
"INSERT INTO contacts (owner_id, contact_id, custom_firstname, custom_lastname) VALUES (%s, %s, %s, %s)",
(userId, contactId, firstName, lastName)
)
# Создаем диалог, если его нет
chatId = userId ^ contactId
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, userId, "DIALOG")
)
for uid in [int(userId), int(contactId)]:
await cursor.execute(
"INSERT INTO chat_participants (chat_id, user_id) VALUES (%s, %s)",
(chatId, uid)
)
# Генерируем профиль
photoId = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photoId else self.config.avatar_base_url + str(photoId)
contact = 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")),
accountStatus=int(user.get("accountstatus")),
description=user.get("description"),
includeProfileOptions=False,
custom_firstname=firstName,
custom_lastname=lastName,
username=user.get("username"),
)
response_payload = {
"new": is_new,
"contact": contact
}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_ADD_BY_PHONE, payload=response_payload
)
await self._send(writer, packet)
async def contact_presence(self, payload, seq, writer):
"""Обработчик получения статуса контактов"""
# Валидируем данные пакета
try:
ContactPresencePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CONTACT_PRESENCE, self.error_types.INVALID_PAYLOAD, writer)
return
contact_ids = payload.get("contactIds", [])
now_ms = int(time.time() * 1000)
presence = await self.tools.collect_presence(contact_ids, self.clients, self.db_pool)
response_payload = {
"presence": presence,
"time": now_ms
}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_PRESENCE, payload=response_payload
)
await self._send(writer, packet)

View File

@@ -0,0 +1,133 @@
import pydantic
import json
import time
from classes.baseprocessor import BaseProcessor
from oneme.models import SyncFoldersPayloadModel, CreateFolderPayloadModel
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, `include`, 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"]),
"include": json.loads(folder["include"]),
"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)
async def folders_update(self, payload, seq, writer, senderPhone):
"""Создание папки"""
# Валидируем данные пакета
try:
CreateFolderPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.FOLDERS_UPDATE, self.error_types.INVALID_PAYLOAD, writer)
return
update_time = int(time.time() * 1000)
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"SELECT COALESCE(MAX(sort_order), -1) as max_order FROM user_folders WHERE phone = %s",
(int(senderPhone),)
)
row = await cursor.fetchone()
next_order = row["max_order"] + 1
# Создаем новую папку
await cursor.execute(
"INSERT INTO user_folders (id, phone, title, filters, `include`, options, source_id, update_time, sort_order) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
(
payload.get("id"),
int(senderPhone),
payload.get("title"),
json.dumps(payload.get("filters")),
json.dumps(payload.get("include", [])),
json.dumps([]),
1,
update_time,
next_order,
)
)
await conn.commit()
# Получаем обновленный порядок папок
await cursor.execute(
"SELECT id FROM user_folders WHERE phone = %s ORDER BY sort_order",
(int(senderPhone),)
)
all_folders = await cursor.fetchall()
folders_order = [f["id"] for f in all_folders]
# Формируем данные пакета
response_payload = {
"folder": {
"id": payload.get("id"),
"title": payload.get("title"),
"include": payload.get("include"),
"filters": payload.get("filters"),
"updateTime": update_time,
"options": [],
"sourceId": 1,
},
"folderSync": update_time,
"foldersOrder": folders_order,
}
# Формируем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.FOLDERS_UPDATE, payload=response_payload
)
await self._send(writer, packet)
# Разработчики протокола, объяснитесь, что за хеш !!! а еще подарите нам способ его формирования
notify_about_hash = self.proto.pack_packet(
cmd=0, seq=1, opcode=self.opcodes.NOTIF_CONFIG,
payload={"config": {"hash": "0"}}
)
await self._send(writer, notify_about_hash)

View File

@@ -0,0 +1,94 @@
import pydantic
import json
import time
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)
getChat = payload.get("getChat", False)
messages = []
# Если пользователь хочет получить историю из избранного,
# то выставляем в качестве ID чата отрицательный ID отправителя
isFavourite = chatId == (senderId ^ senderId)
if isFavourite:
chatId = -senderId
# Проверяем, существует ли чат
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
# Проверяем состоит ли пользователь в чате,
# только в случае того, если это не избранное
if not isFavourite:
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(self.tools.build_message_dict(row, self.type))
backward_count = len(result)
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(self.tools.build_message_dict(row, self.type))
forward_count = len(result)
# Сортируем сообщения по времени
messages.sort(key=lambda x: x["time"])
payload = {
"messages": messages
}
if getChat:
payload["chat"] = {}
# Собираем пакет
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,244 @@
import pydantic
import json
import time
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 = {
"callsSeed": int(time.time() * 1000),
"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, userId=None):
"""Обработчик пинга"""
# Валидируем данные пакета
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
# Обновляем статус пользователя, если он авторизован
# и в пакете отправлен интерактив
interactive = payload.get("interactive") if payload else None
if userId and interactive is not None:
now = int(time.time())
user = self.clients.get(userId)
if user:
if interactive:
user["status"] = 2
user["last_seen"] = now
else:
user["status"] = 0
user["last_seen"] = now
# Сохраняем последнее время посещения
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"UPDATE users SET lastseen = %s WHERE id = %s",
(str(now), userId)
)
# Рассылаем статус контактам пользователя
now_ms = int(time.time() * 1000)
if interactive:
presence_data = {"on": "ON", "seen": now, "status": 1}
else:
presence_data = {"seen": now}
# Находим всех, у кого этот пользователь в контактах
async with self.db_pool.acquire() as conn2:
async with conn2.cursor() as cursor2:
await cursor2.execute(
"SELECT owner_id FROM contacts WHERE contact_id = %s",
(userId,)
)
contact_owners = await cursor2.fetchall()
# Рассылаем
for row in contact_owners:
owner_id = int(row.get("owner_id"))
if owner_id in self.clients:
await self.event(
owner_id,
{
"eventType": "presence",
"userId": userId,
"presence": presence_data,
"time": now_ms,
}
)
# Собираем пакет
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 + str(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,181 @@
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 == (senderId ^ senderId):
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
# Проверяем блокировку собеседника
if chat.get("type") == "DIALOG":
contactid = [p for p in participants if p != int(senderId)][0]
# Проверяем, заблокировал ли отправитель собеседника
if await self.tools.contact_is_blocked(contactid, senderId, db_pool):
await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CONTACT_BLOCKED, 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
)
# Готовое тело сообщения. Поля cid / elements / reactionInfo / link
# должны присутствовать ВСЕГДА (даже пустые) — десктопный MAX
# ожидает фиксированную msgpack-схему и обрывает соединение
# при отсутствии любого из них (см. регрессию из 87cfc19).
bodyMessage = {
"id": messageId if self.type == "mobile" else str(messageId),
"cid": int(cid or 0),
"time": messageTime,
"type": "USER",
"sender": senderId,
"text": text,
"attaches": attaches if isinstance(attaches, list) else [],
"elements": elements if isinstance(elements, list) else [],
"reactionInfo": {},
"link": {},
}
# Отправляем событие всем участникам чата
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 + str(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 + str(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)

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

@@ -0,0 +1,496 @@
import asyncio
import logging
import time
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=15, 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, address[0]
)
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, address[0]
)
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, userId)
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.ASSETS_GET:
await self.auth_required(
userPhone,
self.processors.assets_get,
payload,
seq,
writer,
)
case self.opcodes.ASSETS_GET_BY_IDS:
await self.auth_required(
userPhone,
self.processors.assets_get_by_ids,
payload,
seq,
writer,
)
case self.opcodes.ASSETS_ADD:
await self.auth_required(
userPhone,
self.processors.assets_add,
payload,
seq,
writer,
)
case self.opcodes.ASSETS_REMOVE:
await self.auth_required(
userPhone,
self.processors.assets_remove,
payload,
seq,
writer,
)
case self.opcodes.ASSETS_MOVE:
await self.auth_required(
userPhone,
self.processors.assets_move,
payload,
seq,
writer,
)
case self.opcodes.ASSETS_LIST_MODIFY:
await self.auth_required(
userPhone,
self.processors.assets_list_modify,
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.FOLDERS_UPDATE:
await self.auth_required(
userPhone,
self.processors.folders_update,
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.opcodes.CONTACT_UPDATE:
await self.auth_required(
userPhone,
self.processors.contact_update,
payload,
seq,
writer,
userId,
)
case self.opcodes.CONTACT_ADD_BY_PHONE:
await self.auth_required(
userPhone,
self.processors.contact_add_by_phone,
payload,
seq,
writer,
userId,
)
case self.opcodes.CONTACT_PRESENCE:
await self.auth_required(
userPhone,
self.processors.contact_presence,
payload,
seq,
writer
)
case _:
self.logger.warning(f"Неизвестный опкод {opcode}")
except Exception as e:
self.logger.error(
f"Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}"
)
traceback.print_exc()
# Удаляем клиента из словаря
if userPhone:
await self._end_session(userId, address[0], address[1])
writer.close()
self.logger.info(
f"Прекратил работать работать с клиентом {address[0]}:{address[1]}"
)
async def _finish_auth(self, writer, addr, phone, id):
"""Завершение открытия сессии"""
# Ищем пользователя в словаре
user = self.clients.get(id)
# Добавляем новое подключение в словарь
if user:
user["clients"].append(
{"writer": writer, "ip": addr[0], "port": addr[1], "protocol": "oneme"}
)
else:
self.clients[id] = {
"phone": phone,
"id": id,
"status": 2,
"last_seen": 0,
"clients": [
{
"writer": writer,
"ip": addr[0],
"port": addr[1],
"protocol": "oneme",
}
],
}
await self._broadcast_presence(id, True)
async def _broadcast_presence(self, userId, online):
now = int(time.time())
now_ms = int(time.time() * 1000)
if online:
presence_data = {"on": "ON", "seen": now, "status": 1}
else:
presence_data = {"seen": now}
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"SELECT owner_id FROM contacts WHERE contact_id = %s",
(userId,)
)
contact_owners = await cursor.fetchall()
for row in contact_owners:
owner_id = int(row.get("owner_id"))
if owner_id in self.clients:
await self.processors.event(
owner_id,
{
"eventType": "presence",
"userId": userId,
"presence": presence_data,
"time": now_ms,
}
)
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)
if not clients:
now = int(time.time())
user["status"] = 0
user["last_seen"] = now
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"UPDATE users SET lastseen = %s WHERE id = %s",
(str(now), id)
)
await self._broadcast_presence(id, False)
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}")
try:
async with self.server:
await self.server.serve_forever()
except asyncio.CancelledError:
self.server.close()
await self.server.wait_closed()

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

@@ -0,0 +1,486 @@
import logging
import time
import traceback
import websockets
import asyncio
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, 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.opcodes = Opcodes()
self.proto = WebProto()
self.processors = Processors(
db_pool=db_pool,
clients=clients,
send_event=send_event,
telegram_bot=telegram_bot,
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, address[0]
)
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, address[0]
)
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, 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, userId)
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.ASSETS_GET:
await self.auth_required(
userPhone,
self.processors.assets_get,
payload,
seq,
websocket,
)
case self.opcodes.ASSETS_GET_BY_IDS:
await self.auth_required(
userPhone,
self.processors.assets_get_by_ids,
payload,
seq,
websocket,
)
case self.opcodes.ASSETS_ADD:
await self.auth_required(
userPhone,
self.processors.assets_add,
payload,
seq,
websocket,
)
case self.opcodes.ASSETS_REMOVE:
await self.auth_required(
userPhone,
self.processors.assets_remove,
payload,
seq,
websocket,
)
case self.opcodes.ASSETS_MOVE:
await self.auth_required(
userPhone,
self.processors.assets_move,
payload,
seq,
websocket,
)
case self.opcodes.ASSETS_LIST_MODIFY:
await self.auth_required(
userPhone,
self.processors.assets_list_modify,
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.FOLDERS_UPDATE:
await self.auth_required(
userPhone,
self.processors.folders_update,
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.opcodes.CONTACT_UPDATE:
await self.auth_required(
userPhone,
self.processors.contact_update,
payload,
seq,
websocket,
userId,
)
case self.opcodes.CONTACT_ADD_BY_PHONE:
await self.auth_required(
userPhone,
self.processors.contact_add_by_phone,
payload,
seq,
websocket,
userId,
)
case self.opcodes.CONTACT_PRESENCE:
await self.auth_required(
userPhone,
self.processors.contact_presence,
payload,
seq,
websocket
)
case _:
self.logger.warning(f"Неизвестный опкод {opcode}")
except websockets.exceptions.ConnectionClosed:
self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}")
except Exception as e:
self.logger.error(f" Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}")
traceback.print_exc()
# Удаляем клиента из словаря при отключении
if userId:
await self._end_session(userId, address[0], address[1])
self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}")
async def _finish_auth(self, websocket, addr, phone, id):
"""Завершение открытия сессии"""
# Ищем пользователя в словаре
user = self.clients.get(id)
# Добавляем новое подключение в словарь
if user:
user["clients"].append(
{
"writer": websocket,
"ip": addr[0],
"port": addr[1],
"protocol": "oneme",
"type": "web"
}
)
else:
self.clients[id] = {
"phone": phone,
"id": id,
"status": 2,
"last_seen": 0,
"clients": [
{
"writer": websocket,
"ip": addr[0],
"port": addr[1],
"protocol": "oneme",
"type": "web"
}
]
}
await self._broadcast_presence(id, True)
async def _broadcast_presence(self, userId, online):
now = int(time.time())
now_ms = int(time.time() * 1000)
if online:
presence_data = {"on": "ON", "seen": now, "status": 1}
else:
presence_data = {"seen": now}
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"SELECT owner_id FROM contacts WHERE contact_id = %s",
(userId,)
)
contact_owners = await cursor.fetchall()
for row in contact_owners:
owner_id = int(row.get("owner_id"))
if owner_id in self.clients:
await self.processors.event(
owner_id,
{
"eventType": "presence",
"userId": userId,
"presence": presence_data,
"time": now_ms,
}
)
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)
if not clients:
now = int(time.time())
user["status"] = 0
user["last_seen"] = now
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"UPDATE users SET lastseen = %s WHERE id = %s",
(str(now), id)
)
await self._broadcast_presence(id, False)
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}")
try:
await self.server.wait_closed()
except asyncio.CancelledError:
self.server.close()
await self.server.wait_closed()
raise

View File

@@ -1,75 +0,0 @@
import asyncio
from oneme_tcp.server import OnemeMobileServer
from oneme_tcp.proto import Proto
from classes.controllerbase import ControllerBase
from common.config import ServerConfig
class OnemeMobileController(ControllerBase):
def __init__(self):
self.config = ServerConfig()
self.proto = Proto()
async def event(self, target, client, eventData):
# Извлекаем тип события и врайтер
eventType = eventData.get("eventType")
writer = client.get("writer")
# Обрабатываем событие
if eventType == "new_msg":
# Данные сообщения
chatId = eventData.get("chatId")
message = eventData.get("message")
prevMessageId = eventData.get("prevMessageId")
time = eventData.get("time")
# Данные пакета
payload = {
"chatId": chatId,
"message": message,
"prevMessageId": prevMessageId,
"ttl": False,
"unread": 0,
"mark": time
}
# Создаем пакет
packet = self.proto.pack_packet(
cmd=0, seq=1, opcode=self.proto.NOTIF_MESSAGE, payload=payload
)
elif eventType == "typing":
# Данные события
chatId = eventData.get("chatId")
userId = eventData.get("userId")
type = eventData.get("type")
# Данные пакета
payload = {
"chatId": chatId,
"userId": userId,
"type": type
}
# Создаем пакет
packet = self.proto.pack_packet(
cmd=0, seq=1, opcode=self.proto.NOTIF_TYPING, payload=payload
)
# Отправляем пакет
writer.write(packet)
await writer.drain()
def launch(self, api):
async def _start_all():
await asyncio.gather(
OnemeMobileServer(
host=self.config.host,
port=self.config.oneme_tcp_port,
ssl_context=api['ssl'],
db_pool=api['db'],
clients=api['clients'],
send_event=api['event'],
telegram_bot=api.get('telegram_bot'),
).start()
)
return _start_all()

View File

@@ -1,96 +0,0 @@
import pydantic
class UserAgentModel(pydantic.BaseModel):
deviceType: str
appVersion: str
osVersion: str
timezone: str
release: int = None
screen: str
pushDeviceType: str
arch: str = None
locale: str
buildNumber: int
deviceName: str
deviceLocale: str
class HelloPayloadModel(pydantic.BaseModel):
clientSessionId: int
mt_instanceid: str = None
userAgent: UserAgentModel
deviceId: str
class RequestCodePayloadModel(pydantic.BaseModel):
phone: str
type: str
@pydantic.field_validator('phone')
def validate_phone(cls, v):
"""Валидация номера телефона"""
if not v.replace("+", "").replace(" ", "").replace("-", "").isdigit():
raise ValueError('phone must be digits')
return v
@pydantic.field_validator('type')
def validate_type(cls, v):
"""Валидация типа запроса"""
if not v in ("START_AUTH", "RESEND"):
raise ValueError('type must be valid')
return v
class VerifyCodePayloadModel(pydantic.BaseModel):
verifyCode: str
authTokenType: str
token: str
class LoginPayloadModel(pydantic.BaseModel):
interactive: bool
token: str
class PingPayloadModel(pydantic.BaseModel):
interactive: bool
class AssetsPayloadModel(pydantic.BaseModel):
sync: int
type: str
class GetCallHistoryPayloadModel(pydantic.BaseModel):
forward: bool
count: int
class MessageModel(pydantic.BaseModel):
isLive: bool
detectShare: bool
elements: list
attaches: list = None
cid: int
text: str = None
class SendMessagePayloadModel(pydantic.BaseModel):
# TODO: пишем сервер макса в 2 ночи и не понимаем как это валидировать (блять)
userId: int = None
chatId: int = None
message: MessageModel
class SyncFoldersPayloadModel(pydantic.BaseModel):
folderSync: int
class SearchChatsPayloadModel(pydantic.BaseModel):
chatIds: list
class SearchByPhonePayloadModel(pydantic.BaseModel):
phone: str
class GetCallTokenPayloadModel(pydantic.BaseModel):
userId: int
value: str
class TypingPayloadModel(pydantic.BaseModel):
chatId: int
type: str = None
class SearchUsersPayloadModel(pydantic.BaseModel):
contactIds: list
class ComplainReasonsGetPayloadModel(pydantic.BaseModel):
complainSync: int

View File

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

View File

@@ -1,251 +0,0 @@
import lz4.block, msgpack, logging, json
class Proto:
def __init__(self) -> None:
self.logger = logging.getLogger(__name__)
# TODO узнать какие должны быть лимиты и поменять,
# сейчас это больше заглушка
MAX_PAYLOAD_SIZE = 1048576 # 1 MB
MAX_DECOMPRESSED_SIZE = 1048576 # 1 MB
HEADER_SIZE = 10 # 1+2+1+2+4
### Работа с протоколом
def unpack_packet(self, data: bytes) -> dict | None:
# Проверяем минимальный размер пакета
if len(data) < self.HEADER_SIZE:
self.logger.warning(f"Пакет слишком маленький: {len(data)} байт")
return None
# Распаковываем заголовок
ver = int.from_bytes(data[0:1], "big")
cmd = int.from_bytes(data[1:3], "big")
seq = int.from_bytes(data[3:4], "big")
opcode = int.from_bytes(data[4:6], "big")
packed_len = int.from_bytes(data[6:10], "big")
# Флаг упаковки
comp_flag = packed_len >> 24
# Парсим данные пакета
payload_length = packed_len & 0xFFFFFF
# Проверяем размер payload
if payload_length > self.MAX_PAYLOAD_SIZE:
self.logger.warning(f"Payload слишком большой: {payload_length} B (лимит {self.MAX_PAYLOAD_SIZE})")
return None
# Проверяем длину пакета
if len(data) < self.HEADER_SIZE + payload_length:
self.logger.warning(f"Пакет неполный: требуется {self.HEADER_SIZE + payload_length} B, получено {len(data)}")
return None
payload_bytes = data[10 : 10 + payload_length]
payload = None
# Декодируем данные пакета
if payload_bytes:
# Разжимаем данные пакета, если требуется
if comp_flag != 0:
compressed_data = payload_bytes
try:
payload_bytes = lz4.block.decompress(
compressed_data,
uncompressed_size=self.MAX_DECOMPRESSED_SIZE,
)
except lz4.block.LZ4BlockError:
self.logger.warning("Ошибка декомпрессии LZ4")
return None
# Распаковываем msgpack
payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False)
self.logger.debug(f"Распаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload}")
# Возвращаем
return {
"ver": ver,
"cmd": cmd,
"seq": seq,
"opcode": opcode,
"payload": payload,
}
def pack_packet(self, ver: int = 10, cmd: int = 1, seq: int = 1, opcode: int = 6, payload: dict = None) -> bytes:
# Запаковываем заголовок
ver_b = ver.to_bytes(1, "big")
cmd_b = cmd.to_bytes(2, "big")
seq_b = seq.to_bytes(1, "big")
opcode_b = opcode.to_bytes(2, "big")
# Запаковываем данные пакета
payload_bytes: bytes | None = msgpack.packb(payload)
if payload_bytes is None:
payload_bytes = b""
payload_len = len(payload_bytes) & 0xFFFFFF
payload_len_b = payload_len.to_bytes(4, 'big')
self.logger.debug(f"Упаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload}")
# Возвращаем пакет
return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + payload_bytes
### Констаты протокола
CMD_OK = 0x100
CMD_NOF = 0x200
CMD_ERR = 0x300
PROTO_VER = 10
### Команды
PING = 1
DEBUG = 2
RECONNECT = 3
LOG = 5
SESSION_INIT = 6
PROFILE = 16
AUTH_REQUEST = 17
AUTH = 18
LOGIN = 19
LOGOUT = 20
SYNC = 21
CONFIG = 22
AUTH_CONFIRM = 23
AUTH_CREATE_TRACK = 112
AUTH_CHECK_PASSWORD = 113
AUTH_LOGIN_CHECK_PASSWORD = 115
AUTH_LOGIN_PROFILE_DELETE = 116
AUTH_LOGIN_RESTORE_PASSWORD = 101
AUTH_VALIDATE_PASSWORD = 107
AUTH_VALIDATE_HINT = 108
AUTH_VERIFY_EMAIL = 109
AUTH_CHECK_EMAIL = 110
AUTH_SET_2FA = 111
AUTH_2FA_DETAILS = 104
ASSETS_GET = 26
ASSETS_UPDATE = 27
ASSETS_GET_BY_IDS = 28
ASSETS_LIST_MODIFY = 261
ASSETS_REMOVE = 259
ASSETS_MOVE = 260
ASSETS_ADD = 29
PRESET_AVATARS = 25
CONTACT_INFO = 32
CONTACT_INFO_BY_PHONE = 46
CONTACT_ADD = 33
CONTACT_UPDATE = 34
CONTACT_PRESENCE = 35
CONTACT_LIST = 36
CONTACT_SEARCH = 37
CONTACT_MUTUAL = 38
CONTACT_PHOTOS = 39
CONTACT_SORT = 40
CONTACT_VERIFY = 42
REMOVE_CONTACT_PHOTO = 43
CHAT_INFO = 48
CHAT_HISTORY = 49
CHAT_MARK = 50
CHAT_MEDIA = 51
CHAT_DELETE = 52
CHATS_LIST = 53
CHAT_CLEAR = 54
CHAT_UPDATE = 55
CHAT_CHECK_LINK = 56
CHAT_JOIN = 57
CHAT_LEAVE = 58
CHAT_MEMBERS = 59
PUBLIC_SEARCH = 60
CHAT_PERSONAL_CONFIG = 61
CHAT_CREATE = 63
REACTIONS_SETTINGS_GET_BY_CHAT_ID = 258
CHAT_REACTIONS_SETTINGS_SET = 257
MSG_SEND = 64
MSG_TYPING = 65
MSG_DELETE = 66
MSG_EDIT = 67
MSG_DELETE_RANGE = 92
MSG_REACTION = 178
MSG_CANCEL_REACTION = 179
MSG_GET_REACTIONS = 180
MSG_GET_DETAILED_REACTIONS = 181
CHAT_SEARCH = 68
MSG_SHARE_PREVIEW = 70
MSG_GET = 71
MSG_SEARCH_TOUCH = 72
MSG_SEARCH = 73
MSG_GET_STAT = 74
CHAT_SUBSCRIBE = 75
VIDEO_CHAT_START = 76
VIDEO_CHAT_START_ACTIVE = 78
CHAT_MEMBERS_UPDATE = 77
VIDEO_CHAT_HISTORY = 79
PHOTO_UPLOAD = 80
STICKER_UPLOAD = 81
VIDEO_UPLOAD = 82
VIDEO_PLAY = 83
VIDEO_CHAT_CREATE_JOIN_LINK = 84
CHAT_PIN_SET_VISIBILITY = 86
FILE_UPLOAD = 87
FILE_DOWNLOAD = 88
LINK_INFO = 89
SESSIONS_INFO = 96
SESSIONS_CLOSE = 97
PHONE_BIND_REQUEST = 98
PHONE_BIND_CONFIRM = 99
GET_INBOUND_CALLS = 103
EXTERNAL_CALLBACK = 105
OK_TOKEN = 158
CHAT_COMPLAIN = 117
MSG_SEND_CALLBACK = 118
SUSPEND_BOT = 119
LOCATION_STOP = 124
GET_LAST_MENTIONS = 127
STICKER_CREATE = 193
STICKER_SUGGEST = 194
VIDEO_CHAT_MEMBERS = 195
NOTIF_MESSAGE = 128
NOTIF_TYPING = 129
NOTIF_MARK = 130
NOTIF_CONTACT = 131
NOTIF_PRESENCE = 132
NOTIF_CONFIG = 134
NOTIF_CHAT = 135
NOTIF_ATTACH = 136
NOTIF_CALL_START = 137
NOTIF_CONTACT_SORT = 139
NOTIF_MSG_DELETE_RANGE = 140
NOTIF_MSG_DELETE = 142
NOTIF_MSG_REACTIONS_CHANGED = 155
NOTIF_MSG_YOU_REACTED = 156
NOTIF_CALLBACK_ANSWER = 143
CHAT_BOT_COMMANDS = 144
BOT_INFO = 145
NOTIF_LOCATION = 147
NOTIF_LOCATION_REQUEST = 148
NOTIF_ASSETS_UPDATE = 150
NOTIF_DRAFT = 152
NOTIF_DRAFT_DISCARD = 153
DRAFT_SAVE = 176
DRAFT_DISCARD = 177
CHAT_HIDE = 196
CHAT_SEARCH_COMMON_PARTICIPANTS = 198
NOTIF_MSG_DELAYED = 154
NOTIF_PROFILE = 159
PROFILE_DELETE = 199
PROFILE_DELETE_TIME = 200
WEB_APP_INIT_DATA = 160
COMPLAIN = 161
COMPLAIN_REASONS_GET = 162
FOLDERS_GET = 272
FOLDERS_GET_BY_ID = 273
FOLDERS_UPDATE = 274
FOLDERS_REORDER = 275
FOLDERS_DELETE = 276
NOTIF_FOLDERS = 277
AUTH_QR_APPROVE = 290
NOTIF_BANNERS = 292
CHAT_SUGGEST = 300
AUDIO_PLAY = 301
SEND_VOTE = 304
VOTERS_LIST_BY_ANSWER = 305
GET_POLL_UPDATES = 306

View File

@@ -1,187 +0,0 @@
import asyncio, logging, traceback
from oneme_tcp.proto import Proto
from oneme_tcp.processors import Processors
from common.rate_limiter import RateLimiter
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.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event, telegram_bot=telegram_bot)
# rate limiter anti ddos brute force protection
self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60)
self.read_timeout = 300 # Таймаут чтения из сокета (секунды)
self.max_read_size = 65536 # Максимальный размер данных из сокета
async def handle_client(self, reader, writer):
"""Функция для обработки подключений"""
# IP-адрес клиента
address = writer.get_extra_info("peername")
self.logger.info(f"Работаю с клиентом {address[0]}:{address[1]}")
deviceType = None
deviceName = None
userPhone = None
userId = None
hashedToken = None
try:
while True:
# Читаем новые данные из сокета с таймаутом
try:
data = await asyncio.wait_for(
reader.read(self.max_read_size),
timeout=self.read_timeout
)
except asyncio.TimeoutError:
self.logger.info(f"Таймаут соединения для {address[0]}:{address[1]}")
break
# Если сокет закрыт - выходим из цикла
if not data:
break
if len(data) > self.max_read_size:
self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(data)} байт)")
break
# Распаковываем данные
packet = self.proto.unpack_packet(data)
# Скип если пакет невалидный
if packet is None:
self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}")
continue
opcode = packet.get("opcode")
seq = packet.get("seq")
payload = packet.get("payload")
match opcode:
case self.proto.SESSION_INIT:
deviceType, deviceName = await self.processors.process_hello(payload, seq, writer)
case self.proto.AUTH_REQUEST:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.proto.AUTH_REQUEST, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_request_code(payload, seq, writer)
case self.proto.AUTH:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.proto.AUTH, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_verify_code(payload, seq, writer, deviceType, deviceName)
case self.proto.LOGIN:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.proto.LOGIN, self.processors.error_types.RATE_LIMITED, writer)
else:
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.processors.process_get_assets(payload, seq, writer)
case self.proto.VIDEO_CHAT_HISTORY:
await self.processors.process_get_call_history(payload, seq, writer)
case self.proto.MSG_SEND:
await self.processors.process_send_message(payload, seq, writer, senderId=userId, db_pool=self.db_pool)
case self.proto.FOLDERS_GET:
await self.processors.process_get_folders(payload, seq, writer, senderPhone=userPhone)
case self.proto.SESSIONS_INFO:
await self.processors.process_get_sessions(payload, seq, writer, senderPhone=userPhone, hashedToken=hashedToken)
case self.proto.CHAT_INFO:
await self.processors.process_search_chats(payload, seq, writer, senderId=userId)
case self.proto.CONTACT_INFO_BY_PHONE:
await self.processors.process_search_by_phone(payload, seq, writer, senderId=userId)
case self.proto.OK_TOKEN:
await self.processors.process_get_call_token(payload, seq, writer)
case self.proto.MSG_TYPING:
await self.processors.process_typing(payload, seq, writer, senderId=userId)
case self.proto.CONTACT_INFO:
await self.processors.process_search_users(payload, seq, writer)
case self.proto.COMPLAIN_REASONS_GET:
await self.processors.process_complain_reasons_get(payload, seq, writer)
case _:
self.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()

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

@@ -0,0 +1,252 @@
class TTConfig:
def __init__(self):
pass
SERVER_CONFIG = {
"a-2g": 8,
"a-3g": 24,
"a-constraints": {
"googNoiseSuppression": "true",
"googHighpassFilter": "false",
"googTypingNoiseDetection": "false",
"googAudioNetworkAdaptorConfig": ""
},
"a-lte": 24,
"a-wifi": 34,
"account-removal-enabled": False,
"animated-emojis": {},
"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": "",
"invite-long": "",
"invite-short": "",
"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": False,
"promo_contact_label": "",
"proxy": "",
"proxy-domains": [],
"proxy-exclude": [],
"proxy-rotation": False,
"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": "",
"support-button-enable": False,
"t-ice-reconnect": 15,
"t-incoming-call": 40,
"t-start-connect": 20,
"tam-emoji-font-url": "",
"tcp-candidates": False,
"tracer-crash-report-enabled": False,
"tracer-crash-report-host": "",
"tracer-crash-send-asap-enabled": False,
"tracer-crash-send-logs-enabled": False,
"tracer-crash-send-threads-dump-enabled": False,
"tracer-disk-overflow-report-threshold": 3000000000,
"tracer-disk-usage-probability": 500,
"tracer-enabled": False,
"tracer-host": "",
"tracer-hprof-probability": -1,
"tracer-sampled-conditions": "",
"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": []
}

128
src/tamtam/controller.py Normal file
View File

@@ -0,0 +1,128 @@
import asyncio
from tamtam.socket import TamTamMobile
from tamtam.websocket import TamTamWS
from classes.controllerbase import ControllerBase
from common.config import ServerConfig
from common.proto_tcp import MobileProto
from common.proto_web import WebProto
from common.opcodes import Opcodes
class TTController(ControllerBase):
def __init__(self):
self.config = ServerConfig()
self.proto_tcp = MobileProto()
self.proto_web = WebProto()
self.opcodes = Opcodes()
async def event(self, target, client, eventData):
# Извлекаем тип события и врайтер
eventType = eventData.get("eventType")
writer = client.get("writer")
is_web = client.get("type") == "web"
# Выбираем протокол в зависимости от типа подключения
proto = self.proto_web if is_web else self.proto_tcp
packet = None
# Не отправляем событие самому себе
if writer == eventData.get("writer"):
return
# Обрабатываем событие
if eventType == "new_msg":
# Данные сообщения
chatId = eventData.get("chatId")
message = eventData.get("message")
prevMessageId = eventData.get("prevMessageId")
time = eventData.get("time")
# Данные пакета
payload = {
"chatId": chatId,
"message": message,
"prevMessageId": prevMessageId,
"ttl": False,
"unread": 0,
"mark": time
}
# Создаем пакет
packet = proto.pack_packet(
cmd=0, seq=1, opcode=self.opcodes.NOTIF_MESSAGE, payload=payload
)
elif eventType == "typing":
# Данные события
chatId = eventData.get("chatId")
userId = eventData.get("userId")
type = eventData.get("type")
# Данные пакета
payload = {
"chatId": chatId,
"userId": userId,
"type": type
}
# Создаем пакет
packet = proto.pack_packet(
cmd=0, seq=1, opcode=self.opcodes.NOTIF_TYPING, payload=payload
)
elif eventType == "profile_updated":
# Данные события
profile = eventData.get("profile")
# Данные пакета
payload = {
"profile": profile
}
# Создаем пакет
packet = proto.pack_packet(
cmd=0, seq=1, opcode=self.opcodes.NOTIF_PROFILE, payload=payload
)
elif eventType == "presence":
userId = eventData.get("userId")
presence = eventData.get("presence")
event_time = eventData.get("time")
payload = {
"userId": userId,
"presence": presence,
"time": event_time
}
packet = proto.pack_packet(
cmd=0, seq=1, opcode=self.opcodes.NOTIF_PRESENCE, payload=payload
)
if not packet:
return
if is_web:
await writer.send(packet)
else:
writer.write(packet)
await writer.drain()
def launch(self, api):
async def _start_all():
await asyncio.gather(
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()
)
return _start_all()

139
src/tamtam/models.py Normal file
View File

@@ -0,0 +1,139 @@
import pydantic
class UserAgentModel(pydantic.BaseModel):
deviceType: str
appVersion: str
osVersion: str = None
timezone: str = None
screen: str = None
pushDeviceType: str = None
locale: str = None
deviceName: str
deviceLocale: str = None
class HelloPayloadModel(pydantic.BaseModel):
userAgent: UserAgentModel
deviceId: str = None
class RequestCodePayloadModel(pydantic.BaseModel):
phone: str
class VerifyCodePayloadModel(pydantic.BaseModel):
verifyCode: str
authTokenType: str = None
token: str
class FinalAuthPayloadModel(pydantic.BaseModel):
deviceType: str
tokenType: str
deviceId: str
token: str
class LoginPayloadModel(pydantic.BaseModel):
interactive: bool = None
token: str
class SearchUsersPayloadModel(pydantic.BaseModel):
contactIds: list
class PingPayloadModel(pydantic.BaseModel):
interactive: bool
class ChatHistoryPayloadModel(pydantic.BaseModel):
chatId: int
backward: int
class UpdateProfilePayloadModel(pydantic.BaseModel):
pass
class SearchChatsPayloadModel(pydantic.BaseModel):
chatIds: list
class AssetsPayloadModel(pydantic.BaseModel):
sync: int
type: str = None
userId: int = None
class AssetsGetPayloadModel(pydantic.BaseModel):
type: str
count: int = 100
query: str = None
class AssetsGetByIdsPayloadModel(pydantic.BaseModel):
type: str
ids: list
class AssetsAddPayloadModel(pydantic.BaseModel):
type: str
id: int = None
class AssetsRemovePayloadModel(pydantic.BaseModel):
type: str
ids: list
class AssetsMovePayloadModel(pydantic.BaseModel):
type: str
id: int
position: int
class AssetsListModifyPayloadModel(pydantic.BaseModel):
type: str
ids: list
class GetCallTokenPayloadModel(pydantic.BaseModel):
userId: int
value: str
class GetCallHistoryPayloadModel(pydantic.BaseModel):
forward: bool
count: int
class ChatSubscribePayloadModel(pydantic.BaseModel):
chatId: int
subscribe: bool
class ContactListPayloadModel(pydantic.BaseModel):
status: str
count: int = None
class ContactPresencePayloadModel(pydantic.BaseModel):
contactIds: list
class ContactUpdatePayloadModel(pydantic.BaseModel):
action: str
contactId: int
firstName: str = None
lastName: str = None
class TypingPayloadModel(pydantic.BaseModel):
chatId: int
type: str = None
class MessageModel(pydantic.BaseModel):
isLive: bool = None
detectShare: bool = None
elements: list = None
attaches: list = None
cid: int = None
text: str = None
class SendMessagePayloadModel(pydantic.BaseModel):
userId: int = None
chatId: int = None
message: MessageModel
class AuthConfirmRegisterPayloadModel(pydantic.BaseModel):
token: str
name: str
tokenType: str
deviceType: str
deviceId: str = None
@pydantic.field_validator('name')
def validate_name(cls, v):
v = v.strip()
if not v:
raise ValueError('name must not be empty')
if len(v) > 59:
raise ValueError('name too long')
return v

View File

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

View File

@@ -0,0 +1,167 @@
import pydantic
import time
from classes.baseprocessor import BaseProcessor
from tamtam.models import (
AssetsPayloadModel,
AssetsGetPayloadModel,
AssetsGetByIdsPayloadModel,
AssetsAddPayloadModel,
AssetsRemovePayloadModel,
AssetsMovePayloadModel,
AssetsListModifyPayloadModel,
)
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
response = {
"sync": int(time.time() * 1000),
"stickerSetsUpdates": {},
"stickersUpdates": {},
"stickersOrder": [
"RECENT",
"FAVORITE_STICKERS",
"FAVORITE_STICKER_SETS",
"TOP",
"NEW",
"NEW_STICKER_SETS",
],
"sections": [
{
"id": "RECENT",
"type": "RECENTS",
"recentsList": [],
},
{
"id": "FAVORITE_STICKERS",
"type": "STICKERS",
"stickers": [],
"marker": None,
},
{
"id": "FAVORITE_STICKER_SETS",
"type": "STICKER_SETS",
"stickerSets": [],
"marker": None,
},
{
"id": "TOP",
"type": "STICKERS",
"stickers": [],
"marker": None,
},
{
"id": "NEW",
"type": "STICKERS",
"stickers": [],
"marker": None,
},
{
"id": "NEW_STICKER_SETS",
"type": "STICKER_SETS",
"stickerSets": [],
"marker": None,
},
],
}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_UPDATE, payload=response
)
await self._send(writer, packet)
async def assets_get(self, payload, seq, writer):
try:
data = AssetsGetPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_GET, self.error_types.INVALID_PAYLOAD, writer)
return
asset_type = data.type
if asset_type == "STICKER_SET":
response = {"stickerSets": [], "marker": None}
else:
response = {"stickers": [], "marker": None}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_GET, payload=response
)
await self._send(writer, packet)
async def assets_get_by_ids(self, payload, seq, writer):
try:
data = AssetsGetByIdsPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_GET_BY_IDS, self.error_types.INVALID_PAYLOAD, writer)
return
asset_type = data.type
if asset_type == "STICKER_SET":
response = {"stickerSets": []}
else:
response = {"stickers": []}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_GET_BY_IDS, payload=response
)
await self._send(writer, packet)
async def assets_add(self, payload, seq, writer):
try:
AssetsAddPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_ADD, self.error_types.INVALID_PAYLOAD, writer)
return
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_ADD, payload={}
)
await self._send(writer, packet)
async def assets_remove(self, payload, seq, writer):
try:
AssetsRemovePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_REMOVE, self.error_types.INVALID_PAYLOAD, writer)
return
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_REMOVE, payload={}
)
await self._send(writer, packet)
async def assets_move(self, payload, seq, writer):
try:
AssetsMovePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_MOVE, self.error_types.INVALID_PAYLOAD, writer)
return
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_MOVE, payload={}
)
await self._send(writer, packet)
async def assets_list_modify(self, payload, seq, writer):
try:
AssetsListModifyPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.ASSETS_LIST_MODIFY, self.error_types.INVALID_PAYLOAD, writer)
return
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.ASSETS_LIST_MODIFY, payload={}
)
await self._send(writer, packet)

View File

@@ -0,0 +1,607 @@
import hashlib
import secrets
import time
import json
import re
from classes.baseprocessor import BaseProcessor
from common.sms import send_sms_code
from tamtam.models import (
RequestCodePayloadModel,
VerifyCodePayloadModel,
FinalAuthPayloadModel,
AuthConfirmRegisterPayloadModel,
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 _finish_auth(self, payload, seq, writer, cursor, phone, hashed_token, hashed_login, account, deviceType, deviceName, ip, login):
"""Завершение существующего пользователя"""
# Валидируем данные пакета
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 None
# Удаляем токен
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,
deviceName,
self.tools.get_geo(
ip=ip, db_path=self.config.geo_db_path
),
int(time.time() * 1000)
)
)
# Аватарка с биографией
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")
# Собираем данные пакета
return {
"userToken": str(account.get("id")),
"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
}
async def _finish_reg(self, payload, seq, writer, cursor, phone, hashed_token, hashed_login, deviceType, deviceName, ip, login):
"""Регистрация пользователя во время авторизации"""
# Валидируем данные пакета
try:
AuthConfirmRegisterPayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.opcodes.AUTH_CONFIRM,
self.error_types.INVALID_PAYLOAD, writer)
return None
name = payload.get("name", "").strip()
now_ms = int(time.time() * 1000)
now_s = int(time.time())
# Генерируем ID пользователя
user_id = await self.tools.generate_user_id(self.db_pool)
# Создаем пользователя
# NOTE: На бумаге у нас как бы полная поддержка ТТ (ну, все функции, в которые может макс),
# а клиенты тамтама не знают, что такое фамилия в аккаунтах тамтама (оно предназначено только для ОК)
# по этому просто не писать указывать фамилию в бд, ее клиент и так не отдаст
await cursor.execute(
"""
INSERT INTO users
(id, phone, telegram_id, firstname, lastname, username,
profileoptions, options, accountstatus, updatetime, lastseen)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
user_id,
phone,
None,
name,
None,
None,
json.dumps([]),
json.dumps(["TT", "ONEME"]),
0,
str(now_ms),
str(now_s),
),
)
# Добавляем данные аккаунта
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",
self.tools.get_geo(
ip=ip, db_path=self.config.geo_db_path
),
now_ms,
),
)
# Генерируем профиль
profile = self.tools.generate_profile_tt(
id=user_id,
phone=int(phone),
avatarUrl=None,
photoId=None,
updateTime=now_ms,
firstName=name,
lastName="",
options=["TT", "ONEME"],
description=None,
username=None,
)
self.logger.info(
f"Новый пользователь зарегистрирован: phone={phone} id={user_id} name={name}"
)
# Собираем данные пакета
return {
"userToken": "0",
"profile": profile,
"tokenType": "LOGIN",
"token": login,
}
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", ""))
# Генерируем токен
token = secrets.token_urlsafe(128)
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Срок жизни токена (5 минут)
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 = f"{secrets.randbelow(1_000_000):06d}"
local_fallback_code = True
else:
code = f"{secrets.randbelow(1_000_000):06d}"
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, state) VALUES (%s, %s, %s, %s, %s)",
(phone, token_hash, code_hash, expires, "started")
)
else:
# Пользователь не найден - сохраняем токен в 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 = {
"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} (существующий={user_exists})")
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
# Если это новый пользователь - переводим токен в verified
# и отдаём клиенту NEW токен, чтобы он показал экран ввода имени
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": {"NEW": {"token": token}},
"tokenTypes": {"NEW": token},
},
)
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(
"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, ip):
"""Обработчик финальной аутентификации / регистрации"""
# Извлекаем данные из пакета
token = payload.get("token")
if not deviceType:
deviceType = payload.get("deviceType")
if not deviceName:
deviceName = "Unknown device"
# Хешируем токен
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" or stored_token.get("state") == "register":
await self._send_error(seq, self.opcodes.AUTH_CONFIRM,
self.error_types.INVALID_TOKEN, writer)
return
phone = stored_token.get("phone")
# Проверяем, существует ли пользователь
await cursor.execute("SELECT * FROM users WHERE phone = %s", (phone,))
account = await cursor.fetchone()
# Если пользователь есть, производим создание сессии
if account:
resp_payload = await self._finish_auth(
payload, seq, writer, cursor, phone, hashed_token,
hashed_login, account, deviceType, deviceName, ip, login
)
else: # в ином случае производим регистрацию
resp_payload = await self._finish_reg(
payload, seq, writer, cursor, phone, hashed_token,
hashed_login, deviceType, deviceName, ip, login
)
if resp_payload is None:
return
# Создаем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.AUTH_CONFIRM, payload=resp_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 None, None, None
# Чаты, где состоит пользователь
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 None, None, None
# Ищем аккаунт пользователя в бд
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
)
# Генерируем список контактов
contacts = await self.tools.collect_user_contacts(
user.get("id"), self.db_pool, self.config.avatar_base_url
)
# Собираем статусы контактов
contact_ids = [c.get("id") for c in contacts if c.get("id") is not None]
presence = await self.tools.collect_presence(contact_ids, self.clients, self.db_pool)
# Формируем данные пакета
payload = {
"profile": profile,
"chats": chats,
"chatMarker": 0,
"messages": {},
"contacts": contacts,
"presence": presence,
"config": {
"hash": "0",
"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)
}
# print(
# json.dumps(payload, indent=4)
# )
# Собираем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOGIN, payload=payload
)
# Отправляем
await self._send(writer, packet)
return int(user.get("phone")), int(user.get("id")), hashed_token
async def logout(self, seq, writer, hashedToken):
"""Обработчик завершения сессии"""
# Удаляем токен из бд
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"DELETE FROM tokens WHERE token_hash = %s", (hashedToken,)
)
# ⣿⡇⣽⣿⣿⣿⣧⠘⣿⣿⠠⣤⣍⡛⢿⣿⣿⠏⣰⣿⣿⣿⣿⣿⡆⢿
# ⣿⢀⣿⣿⣿⣿⣿⣷⡈⢿⡄⢿⣿⣿⣦⡙⠏⣰⣿⣿⣿⣿⣿⣿⡇⣿
# ⣿⢸⣿⣿⣿⣿⣿⣿⡿⠄⣡⣤⣿⣿⣿⣿⣄⣿⣿⣿⣿⣿⣿⣿⡇⣿
# ⣿⢸⣿⣿⣿⣿⣿⣿⣤⣬⣭⣬⣬⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣿
# ⣿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⣿
# ⣿⡀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠿⠿⣿⣿⡿⢠⣿
# ⣿⣧⠸⣿⣧⠀⣴⡆⣤⠀⢸⣿⣿⣿⣿⠀⠀⢸⣿⡌⣶⣿⠟⢁⣾⣿
# ⠙⠛⠂⠹⡿⢸⣿⡇⢸⠁⢸⣿⣿⣿⣿⠀⠀⢈⣿⡇⢸⣯⣤⣤⠀⣿
# ⣆⠙⣿⣿⣇⢸⣿⣇⠀⢀⣾⡿⢿⣿⣿⣀⣀⣼⣿⡇⣸⣿⡿⢁⣾⣿
# ⣿⣷⢀⡟⡉⠞⢻⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⣿⠋⠘⣹⣿⡄⢻⣿⣿
# ⣿⡇⣼⣿⣧⣶⣿⣿⣿⣟⠻⢋⣍⣉⣋⣼⣿⣿⣿⣶⢿⣿⣿⡄⢻⣿
# ⣿⣧⣭⣭⣄⡙⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠛⣡⣤⣭⣤⣴⣾⣿
# ⣿⣿⣿⣿⣿⣿⣇⠠⣬⣭⣽⣿⣿⣿⣿⣿⣷⡈⢿⣿⣿⣿⣿⣿⣿⣿
# ⣿⣿⣿⣿⣿⣿⣿⣦⠙⣿⣿⣿⣿⣿⣿⣿⣿⣷⡈⣿⣿⣿⣿⣿⣿⣿
# ⣿⣿⣿⣿⣿⣿⣿⠃⠼⢿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠹⣿⣿⣿⣿⣿⣿
# ⣿⣿⣿⣿⣿⣿⣿⣶⠆⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⣿⣿⣿⣿⣿⣿
# ⣿⣿⣿⣿⣿⣿⣿⣿⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢹⣿⣿⣿⣿⣿
# Создаем пакет
response = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.LOGOUT, payload=None
)
# Отправляем
await self._send(writer, response)

View File

@@ -0,0 +1,20 @@
import pydantic
from classes.baseprocessor import BaseProcessor
from tamtam.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,301 @@
import pydantic
import json
import time
from classes.baseprocessor import BaseProcessor
from tamtam.models import ContactListPayloadModel, ContactPresencePayloadModel, ContactUpdatePayloadModel
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)
async def contact_update(self, payload, seq, writer, userId):
"""
Обработчик опкода какого-то там
(их хуй запомнишь, даже в мриме команды помню, бля)
Отвечает за добавку, удаление, блокировку и разблокировку контакта
"""
# Валидируем данные пакета
try:
ContactUpdatePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.INVALID_PAYLOAD, writer)
return
action = payload.get("action")
contactId = payload.get("contactId")
firstName = payload.get("firstName")
lastName = payload.get("lastName", "")
if action == "ADD":
# Проверяем, существует ли пользователь с таким ID
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,))
user = await cursor.fetchone()
if not user:
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.USER_NOT_FOUND, writer)
return
# Проверяем, не добавлен ли уже контакт
await cursor.execute(
"SELECT * FROM contacts WHERE owner_id = %s AND contact_id = %s",
(userId, contactId)
)
row = await cursor.fetchone()
# Если контакта не существует, то можем продолжать,
if not row:
# Добавляем контакт
await cursor.execute(
"INSERT INTO contacts (owner_id, contact_id, custom_firstname, custom_lastname, is_blocked) VALUES (%s, %s, %s, %s, FALSE)",
(userId, contactId, firstName, lastName)
)
# а если уже существует, отправляем ошибку
else:
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.CONTACT_ALREADY_ADDED, 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 + str(photoId)
contact = 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")),
accountStatus=int(user.get("accountstatus")),
includeProfileOptions=False,
custom_firstname=firstName,
custom_lastname=lastName,
)
response_payload = {
"contact": contact
}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_UPDATE, payload=response_payload
)
await self._send(writer, packet)
elif action == "REMOVE":
# Удаляем контакт
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"DELETE FROM contacts WHERE owner_id = %s AND contact_id = %s",
(userId, contactId)
)
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_UPDATE, payload=None
)
await self._send(writer, packet)
elif action == "BLOCK":
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",
(userId, contactId)
)
row = await cursor.fetchone()
# Обновляем существующий контакт, если такой есть
if row:
await cursor.execute(
"UPDATE contacts SET is_blocked = TRUE WHERE owner_id = %s AND contact_id = %s",
(userId, contactId)
)
else: # В ином случае добавляем новую запись в бд
await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,))
user = await cursor.fetchone()
if not user:
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.USER_NOT_FOUND, writer)
return
await cursor.execute(
"INSERT INTO contacts (owner_id, contact_id, custom_firstname, custom_lastname, is_blocked) VALUES (%s, %s, %s, %s, TRUE)",
(userId, contactId, firstName, lastName)
)
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_UPDATE, payload=None
)
await self._send(writer, packet)
elif action == "UNBLOCK":
# Разблокируем контакт
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",
(userId, contactId)
)
row = await cursor.fetchone()
# Обновляем контакт, если он есть
if row:
await cursor.execute(
"UPDATE contacts SET is_blocked = FALSE WHERE owner_id = %s AND contact_id = %s",
(userId, contactId)
)
else: # В ином случае отправляем ошибку
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.CONTACT_NOT_FOUND, writer)
return
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_UPDATE, payload=None
)
await self._send(writer, packet)
elif action == "UPDATE":
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",
(userId, contactId)
)
row = await cursor.fetchone()
# Если контакта нет, отдаем ошибку
if not row:
await self._send_error(seq, self.opcodes.CONTACT_UPDATE, self.error_types.CONTACT_NOT_FOUND, writer)
return
# Обновляем контакт
await cursor.execute(
"UPDATE contacts SET custom_firstname = %s, custom_lastname = %s WHERE owner_id = %s AND contact_id = %s",
(firstName, lastName, userId, contactId)
)
# Получаем данные пользователя
await cursor.execute("SELECT * FROM users WHERE id = %s", (contactId,))
user = await cursor.fetchone()
# Генерируем профиль
photo_id = None if not user.get("avatar_id") else int(user.get("avatar_id"))
avatar_url = None if not photo_id else self.config.avatar_base_url + str(photo_id)
contact = 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=user.get("description"),
username=user.get("username")
),
response_payload = {
"contact": contact
}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_UPDATE, payload=response_payload
)
await self._send(writer, packet)
async def contact_presence(self, payload, seq, writer):
"""Обработчик получения статуса контактов"""
# Валидируем данные пакета
try:
ContactPresencePayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.CONTACT_PRESENCE, self.error_types.INVALID_PAYLOAD, writer)
return
contact_ids = payload.get("contactIds", [])
now_ms = int(time.time() * 1000)
presence = await self.tools.collect_presence(contact_ids, self.clients, self.db_pool)
response_payload = {
"presence": presence,
"time": now_ms
}
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.CONTACT_PRESENCE, payload=response_payload
)
await self._send(writer, packet)

View File

@@ -0,0 +1,133 @@
import pydantic
import json
import time
from classes.baseprocessor import BaseProcessor
from tamtam.models import SyncFoldersPayloadModel, CreateFolderPayloadModel
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, `include`, 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"]),
"include": json.loads(folder["include"]),
"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)
async def folders_update(self, payload, seq, writer, senderPhone):
"""Создание папки"""
# Валидируем данные пакета
try:
CreateFolderPayloadModel.model_validate(payload)
except pydantic.ValidationError as error:
self.logger.error(f"Возникли ошибки при валидации пакета: {error}")
await self._send_error(seq, self.opcodes.FOLDERS_UPDATE, self.error_types.INVALID_PAYLOAD, writer)
return
update_time = int(time.time() * 1000)
async with self.db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"SELECT COALESCE(MAX(sort_order), -1) as max_order FROM user_folders WHERE phone = %s",
(int(senderPhone),)
)
row = await cursor.fetchone()
next_order = row["max_order"] + 1
# Создаем новую папку
await cursor.execute(
"INSERT INTO user_folders (id, phone, title, filters, `include`, options, source_id, update_time, sort_order) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)",
(
payload.get("id"),
int(senderPhone),
payload.get("title"),
json.dumps(payload.get("filters")),
json.dumps(payload.get("include", [])),
json.dumps([]),
1,
update_time,
next_order,
)
)
await conn.commit()
# Получаем обновленный порядок папок
await cursor.execute(
"SELECT id FROM user_folders WHERE phone = %s ORDER BY sort_order",
(int(senderPhone),)
)
all_folders = await cursor.fetchall()
folders_order = [f["id"] for f in all_folders]
# Формируем данные пакета
response_payload = {
"folder": {
"id": payload.get("id"),
"title": payload.get("title"),
"include": payload.get("include"),
"filters": payload.get("filters"),
"updateTime": update_time,
"options": [],
"sourceId": 1,
},
"folderSync": update_time,
"foldersOrder": folders_order,
}
# Формируем пакет
packet = self.proto.pack_packet(
cmd=self.proto.CMD_OK, seq=seq, opcode=self.opcodes.FOLDERS_UPDATE, payload=response_payload
)
await self._send(writer, packet)
# Разработчики протокола, объяснитесь, что за хеш !!! а еще подарите нам способ его формирования
notify_about_hash = self.proto.pack_packet(
cmd=0, seq=1, opcode=self.opcodes.NOTIF_CONFIG,
payload={"config": {"hash": "0"}}
)
await self._send(writer, notify_about_hash)

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,167 @@
import json
import pydantic
from classes.baseprocessor import BaseProcessor
from tamtam.models import HelloPayloadModel, PingPayloadModel
from tamtam.models import UpdateProfilePayloadModel
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 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
# Аватарка с биографией
photo_id = int(user["avatar_id"]) if user.get("avatar_id") else None
avatar_url = f"{self.config.avatar_base_url}{photo_id}" if photo_id else None
description = 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")
)
# Создаем данные пакета
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):
"""Обработчик обновления настроек и пуш-токена"""
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)
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,168 @@
import pydantic
from classes.baseprocessor import BaseProcessor
from tamtam.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
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
# Проверяем блокировку собеседника
if chat.get("type") == "DIALOG":
contactid = [p for p in participants if p != int(senderId)][0]
# Проверяем, заблокировал ли отправитель собеседника
if await self.tools.contact_is_blocked(contactid, senderId, db_pool):
await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CONTACT_BLOCKED, 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": chatId,
"message": bodyMessage,
"prevMessageId": lastMessageId,
"time": messageTime,
"writer": writer
}
)
# Данные пакета
payload = {
"chatId": 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,120 @@
import json, pydantic
from classes.baseprocessor import BaseProcessor
from tamtam.models import SearchUsersPayloadModel
from tamtam.models import SearchChatsPayloadModel
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)
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:
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)
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
)
)
# Создаем данные пакета
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"TamTam {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)

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

@@ -0,0 +1,273 @@
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=15, 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, address[0])
case self.opcodes.LOGIN:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.opcodes.LOGIN, self.processors.error_types.RATE_LIMITED, writer)
else:
userPhone, userId, hashedToken = await self.processors.login(payload, seq, writer)
if userPhone:
await self._finish_auth(writer, address, userPhone, userId)
case self.opcodes.LOGOUT:
await self.processors.logout(
seq, writer, hashedToken=hashedToken
)
break
case self.opcodes.CONTACT_INFO:
await self.auth_required(
userPhone, self.processors.contact_info, payload, seq, writer
)
case self.opcodes.CHAT_HISTORY:
await self.auth_required(
userPhone, self.processors.chat_history, payload, seq, writer, userId
)
case self.opcodes.ASSETS_UPDATE:
await self.auth_required(
userPhone, self.processors.assets_update, payload, seq, writer
)
case self.opcodes.ASSETS_GET:
await self.auth_required(
userPhone, self.processors.assets_get, payload, seq, writer
)
case self.opcodes.ASSETS_GET_BY_IDS:
await self.auth_required(
userPhone, self.processors.assets_get_by_ids, payload, seq, writer
)
case self.opcodes.ASSETS_ADD:
await self.auth_required(
userPhone, self.processors.assets_add, payload, seq, writer
)
case self.opcodes.ASSETS_REMOVE:
await self.auth_required(
userPhone, self.processors.assets_remove, payload, seq, writer
)
case self.opcodes.ASSETS_MOVE:
await self.auth_required(
userPhone, self.processors.assets_move, payload, seq, writer
)
case self.opcodes.ASSETS_LIST_MODIFY:
await self.auth_required(
userPhone, self.processors.assets_list_modify, 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.MSG_TYPING:
await self.auth_required(
userPhone, self.processors.msg_typing, payload, seq, writer, userId
)
case self.opcodes.FOLDERS_GET:
await self.auth_required(
userPhone, self.processors.folders_get, payload, seq, writer, userPhone
)
case self.opcodes.FOLDERS_UPDATE:
await self.auth_required(
userPhone, self.processors.folders_update, 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.CONTACT_LIST:
await self.auth_required(
userPhone, self.processors.contact_list, payload, seq, writer, userId
)
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.opcodes.CONTACT_UPDATE:
await self.auth_required(
userPhone, self.processors.contact_update, payload, seq, writer, userId
)
case self.opcodes.CONTACT_PRESENCE:
await self.auth_required(
userPhone, self.processors.contact_presence, 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 userId:
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": "tamtam",
"type": "tcp"
}
)
else:
self.clients[id] = {
"phone": phone,
"id": id,
"clients": [
{
"writer": writer,
"ip": addr[0],
"port": addr[1],
"protocol": "tamtam",
"type": "tcp"
}
]
}
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}")
try:
async with self.server:
await self.server.serve_forever()
except asyncio.CancelledError:
self.server.close()
await self.server.wait_closed()

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

@@ -0,0 +1,264 @@
import logging
import traceback
import websockets
import asyncio
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, address[0])
case self.opcodes.LOGIN:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.opcodes.LOGIN, self.processors.error_types.RATE_LIMITED, websocket)
else:
userPhone, userId, hashedToken = await self.processors.login(payload, seq, websocket)
if userPhone:
await self._finish_auth(websocket, address, userPhone, userId)
case self.opcodes.LOGOUT:
await self.processors.logout(
seq, websocket, hashedToken=hashedToken
)
break
case self.opcodes.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.opcodes.ASSETS_UPDATE:
await self.auth_required(
userPhone, self.processors.assets_update, payload, seq, websocket
)
case self.opcodes.ASSETS_GET:
await self.auth_required(
userPhone, self.processors.assets_get, payload, seq, websocket
)
case self.opcodes.ASSETS_GET_BY_IDS:
await self.auth_required(
userPhone, self.processors.assets_get_by_ids, payload, seq, websocket
)
case self.opcodes.ASSETS_ADD:
await self.auth_required(
userPhone, self.processors.assets_add, payload, seq, websocket
)
case self.opcodes.ASSETS_REMOVE:
await self.auth_required(
userPhone, self.processors.assets_remove, payload, seq, websocket
)
case self.opcodes.ASSETS_MOVE:
await self.auth_required(
userPhone, self.processors.assets_move, payload, seq, websocket
)
case self.opcodes.ASSETS_LIST_MODIFY:
await self.auth_required(
userPhone, self.processors.assets_list_modify, 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.MSG_TYPING:
await self.auth_required(
userPhone, self.processors.msg_typing, payload, seq, websocket, userId
)
case self.opcodes.FOLDERS_GET:
await self.auth_required(
userPhone, self.processors.folders_get, payload, seq, websocket, userPhone
)
case self.opcodes.FOLDERS_UPDATE:
await self.auth_required(
userPhone, self.processors.folders_update, 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.CONTACT_LIST:
await self.auth_required(
userPhone, self.processors.contact_list, payload, seq, websocket, userId
)
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.opcodes.CONTACT_UPDATE:
await self.auth_required(
userPhone, self.processors.contact_update, payload, seq, websocket, userId
)
case self.opcodes.CONTACT_PRESENCE:
await self.auth_required(
userPhone, self.processors.contact_presence, payload, seq, websocket
)
case _:
self.logger.warning(f"Неизвестный опкод {opcode}")
except websockets.exceptions.ConnectionClosed:
self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}")
except Exception as e:
self.logger.error(f" Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}")
traceback.print_exc()
# Удаляем клиента из словаря при отключении
if userId:
await self._end_session(userId, address[0], address[1])
self.logger.info(f"Прекратил работать с клиентом {address[0]}:{address[1]}")
async def _finish_auth(self, websocket, addr, phone, id):
"""Завершение открытия сессии"""
# Ищем пользователя в словаре
user = self.clients.get(id)
# Добавляем новое подключение в словарь
if user:
user["clients"].append(
{
"writer": websocket,
"ip": addr[0],
"port": addr[1],
"protocol": "tamtam",
"type": "web"
}
)
else:
self.clients[id] = {
"phone": phone,
"id": id,
"clients": [
{
"writer": websocket,
"ip": addr[0],
"port": addr[1],
"protocol": "tamtam",
"type": "web"
}
]
}
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}")
try:
await self.server.wait_closed()
except asyncio.CancelledError:
self.server.close()
await self.server.wait_closed()
raise

View File

@@ -1,23 +0,0 @@
import asyncio
from tamtam_tcp.server import TTMobileServer
from classes.controllerbase import ControllerBase
from common.config import ServerConfig
class TTMobileController(ControllerBase):
def __init__(self):
self.config = ServerConfig()
def launch(self, api):
async def _start_all():
await asyncio.gather(
TTMobileServer(
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()
)
return _start_all()

View File

@@ -1,30 +0,0 @@
import pydantic
class UserAgentModel(pydantic.BaseModel):
deviceType: str
appVersion: str
osVersion: str
timezone: str
screen: str
pushDeviceType: str
locale: str
deviceName: str
deviceLocale: str
class HelloPayloadModel(pydantic.BaseModel):
userAgent: UserAgentModel
deviceId: str
class RequestCodePayloadModel(pydantic.BaseModel):
phone: str
class VerifyCodePayloadModel(pydantic.BaseModel):
verifyCode: str
authTokenType: str
token: str
class FinalAuthPayloadModel(pydantic.BaseModel):
deviceType: str
tokenType: str
deviceId: str
token: str

View File

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

View File

@@ -1,113 +0,0 @@
import lz4.block, msgpack, logging, json
class Proto:
def __init__(self) -> None:
self.logger = logging.getLogger(__name__)
# TODO узнать какие должны быть лимиты и поменять,
# сейчас это больше заглушка
MAX_PAYLOAD_SIZE = 1048576 # 1 MB
MAX_DECOMPRESSED_SIZE = 1048576 # 1 MB
HEADER_SIZE = 10 # 1+2+1+2+4
### Работа с протоколом
def unpack_packet(self, data: bytes) -> dict | None:
# Проверяем минимальный размер пакета
if len(data) < self.HEADER_SIZE:
self.logger.warning(f"Пакет слишком маленький: {len(data)} байт")
return None
# Распаковываем заголовок
ver = int.from_bytes(data[0:1], "big")
cmd = int.from_bytes(data[1:3], "big")
seq = int.from_bytes(data[3:4], "big")
opcode = int.from_bytes(data[4:6], "big")
packed_len = int.from_bytes(data[6:10], "big")
# Флаг упаковки
comp_flag = packed_len >> 24
# Парсим данные пакета
payload_length = packed_len & 0xFFFFFF
# Проверяем размер payload
if payload_length > self.MAX_PAYLOAD_SIZE:
self.logger.warning(f"Payload слишком большой: {payload_length} B (лимит {self.MAX_PAYLOAD_SIZE})")
return None
# Проверяем длину пакета
if len(data) < self.HEADER_SIZE + payload_length:
self.logger.warning(f"Пакет неполный: требуется {self.HEADER_SIZE + payload_length} B, получено {len(data)}")
return None
payload_bytes = data[10 : 10 + payload_length]
payload = None
# Декодируем данные пакета
if payload_bytes:
# Разжимаем данные пакета, если требуется
if comp_flag != 0:
compressed_data = payload_bytes
try:
payload_bytes = lz4.block.decompress(
compressed_data,
uncompressed_size=self.MAX_DECOMPRESSED_SIZE,
)
except lz4.block.LZ4BlockError:
self.logger.warning("Ошибка декомпрессии LZ4")
return None
# Распаковываем msgpack
payload = msgpack.unpackb(payload_bytes, raw=False, strict_map_key=False)
self.logger.debug(f"Распаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload}")
# Возвращаем
return {
"ver": ver,
"cmd": cmd,
"seq": seq,
"opcode": opcode,
"payload": payload,
}
def pack_packet(self, ver: int = 10, cmd: int = 1, seq: int = 1, opcode: int = 6, payload: dict = None) -> bytes:
# Запаковываем заголовок
ver_b = ver.to_bytes(1, "big")
cmd_b = cmd.to_bytes(2, "big")
seq_b = seq.to_bytes(1, "big")
opcode_b = opcode.to_bytes(2, "big")
# Запаковываем данные пакета
payload_bytes: bytes | None = msgpack.packb(payload)
if payload_bytes is None:
payload_bytes = b""
payload_len = len(payload_bytes) & 0xFFFFFF
payload_len_b = payload_len.to_bytes(4, 'big')
self.logger.debug(f"Упаковал - ver={ver} cmd={cmd} seq={seq} opcode={opcode} payload={payload}")
# Возвращаем пакет
return ver_b + cmd_b + seq_b + opcode_b + payload_len_b + payload_bytes
### Констаты протокола
CMD_OK = 0x100
CMD_NOF = 0x200
CMD_ERR = 0x300
PROTO_VER = 10
HELLO = 6
REQUEST_CODE = 17
VERIFY_CODE = 18
FINAL_AUTH = 23
LOGIN = 19
PING = 1
TELEMETRY = 5
GET_ASSETS = 27
GET_CALL_HISTORY = 79
SEND_MESSAGE = 64
GET_FOLDERS = 272
GET_SESSIONS = 96
LOGOUT = 20
SEARCH_CHATS = 48
SEARCH_BY_PHONE = 46

View File

@@ -1,107 +0,0 @@
import asyncio, logging, traceback
from tamtam_tcp.proto import Proto
from tamtam_tcp.processors import Processors
from common.rate_limiter import RateLimiter
class TTMobileServer:
def __init__(self, host="0.0.0.0", port=443, ssl_context=None, db_pool=None, clients={}, send_event=None):
self.host = host
self.port = port
self.ssl_context = ssl_context
self.server = None
self.logger = logging.getLogger(__name__)
self.db_pool = db_pool
self.clients = clients
self.proto = Proto()
self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event)
# rate limiter
self.auth_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60)
self.read_timeout = 300 # Таймаут чтения из сокета (секунды)
self.max_read_size = 65536 # Максимальный размер данных из сокета
async def handle_client(self, reader, writer):
"""Функция для обработки подключений"""
# IP-адрес клиента
address = writer.get_extra_info("peername")
self.logger.info(f"Работаю с клиентом {address[0]}:{address[1]}")
deviceType = None
deviceName = None
userPhone = None
userId = None
hashedToken = None
try:
while True:
# Читаем новые данные из сокета (с таймаутом!)
try:
data = await asyncio.wait_for(
reader.read(self.max_read_size),
timeout=self.read_timeout
)
except asyncio.TimeoutError:
self.logger.info(f"Таймаут соединения для {address[0]}:{address[1]}")
break
# Если сокет закрыт - выходим из цикла
if not data:
break
# Проверяем размер данных
if len(data) > self.max_read_size:
self.logger.warning(f"Пакет от {address[0]}:{address[1]} превышает лимит ({len(data)} байт)")
break
# Распаковываем данные
packet = self.proto.unpack_packet(data)
# Если пакет невалидный — пропускаем
if packet is None:
self.logger.warning(f"Невалидный пакет от {address[0]}:{address[1]}")
continue
opcode = packet.get("opcode")
seq = packet.get("seq")
payload = packet.get("payload")
match opcode:
case self.proto.HELLO:
deviceType, deviceName = await self.processors.process_hello(payload, seq, writer)
case self.proto.REQUEST_CODE:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.proto.REQUEST_CODE, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_request_code(payload, seq, writer)
case self.proto.VERIFY_CODE:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.proto.VERIFY_CODE, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_verify_code(payload, seq, writer)
case self.proto.FINAL_AUTH:
if not self.auth_rate_limiter.is_allowed(address[0]):
await self.processors._send_error(seq, self.proto.FINAL_AUTH, self.processors.error_types.RATE_LIMITED, writer)
else:
await self.processors.process_final_auth(payload, seq, writer, deviceType, deviceName)
case _:
self.logger.warning(f"Неизвестный опкод {opcode}")
except Exception as e:
self.logger.error(f"Произошла ошибка при работе с клиентом {address[0]}:{address[1]}: {e}")
traceback.print_exc()
writer.close()
self.logger.info(f"Прекратил работать работать с клиентом {address[0]}:{address[1]}")
async def start(self):
"""Функция для запуска сервера"""
self.server = await asyncio.start_server(
self.handle_client, self.host, self.port, ssl=self.ssl_context
)
self.logger.info(f"Сокет запущен на порту {self.port}")
async with self.server:
await self.server.serve_forever()

View File

@@ -1,22 +0,0 @@
import asyncio
from classes.controllerbase import ControllerBase
from common.config import ServerConfig
from tamtam_ws.server import TTWSServer
class TTWSController(ControllerBase):
def __init__(self):
self.config = ServerConfig()
def launch(self, api):
async def _start_all():
await asyncio.gather(
TTWSServer(
host=self.config.host,
port=self.config.tamtam_ws_port,
db_pool=api['db'],
clients=api['clients'],
send_event=api['event']
).start()
)
return _start_all()

View File

@@ -1,27 +0,0 @@
import pydantic
class MessageModel(pydantic.BaseModel):
ver: int
cmd: int
seq: int
opcode: int
payload: dict = None
class UserAgentModel(pydantic.BaseModel):
deviceType: str
appVersion: str
osVersion: str
locale: str
deviceLocale: str
deviceName: str
screen: str
headerUserAgent: str
timezone: str
class HelloPayloadModel(pydantic.BaseModel):
userAgent: UserAgentModel
deviceId: str
class RequestCodePayloadModel(pydantic.BaseModel):
phone: str
requestType: str

View File

@@ -1,76 +0,0 @@
import hashlib, secrets, random, time, logging, json
from common.static import Static
from common.tools import Tools
from tamtam_ws.proto import Proto
from tamtam_ws.models import *
class Processors:
def __init__(self, db_pool=None, clients={}, send_event=None):
self.static = Static()
self.tools = Tools()
self.proto = Proto()
self.error_types = self.static.ErrorTypes()
self.db_pool = db_pool
self.logger = logging.getLogger(__name__)
async def _send(self, writer, packet):
"""Отправка пакета"""
await writer.send(packet)
async def _send_error(self, seq, opcode, type, writer):
payload = self.static.ERROR_TYPES.get(type, {
"localizedMessage": "Неизвестная ошибка",
"error": "unknown.error",
"message": "Unknown error",
"title": "Неизвестная ошибка"
})
packet = self.proto.pack_packet(
seq=seq, opcode=opcode, payload=payload
)
await self._send(writer, packet)
async def process_hello(self, payload, seq, writer):
"""Обработчик приветствия"""
# Валидируем данные пакета
try:
HelloPayloadModel.model_validate(payload)
except Exception as e:
await self._send_error(seq, self.proto.SESSION_INIT, self.error_types.INVALID_PAYLOAD, writer)
return None, None
# Получаем данные из пакета
deviceType = payload.get("userAgent").get("deviceType")
deviceName = payload.get("userAgent").get("deviceName")
# Собираем данные ответа
payload = {
"proxy": "",
"logs-enabled": False,
"proxy-domains": [],
"location": "RU"
}
# Создаем пакет
packet = self.proto.pack_packet(seq=seq, opcode=self.proto.SESSION_INIT, payload=payload)
# Отправляем
await self._send(writer, packet)
return deviceType, deviceName
async def process_ping(self, payload, seq, writer):
"""Обработчик пинга"""
# Создаем пакет
packet = self.proto.pack_packet(seq=seq, opcode=self.proto.PING)
# Отправляем
await self._send(writer, packet)
async def process_telemetry(self, payload, seq, writer):
"""Обработчик телеметрии"""
# Создаем пакет
packet = self.proto.pack_packet(seq=seq, opcode=self.proto.LOG)
# Отправляем
await self._send(writer, packet)

View File

@@ -1,157 +0,0 @@
import json
class Proto:
def pack_packet(self, ver=10, cmd=1, seq=0, opcode=1, payload=None):
# а разве не надо в жсон запаковывать ещё
# о всё
return json.dumps({
"ver": ver,
"cmd": cmd,
"seq": seq,
"opcode": opcode,
"payload": payload
})
MAX_PACKET_SIZE = 65536 # 64 KB, заглушка, нужно узнать реальные лимиты и поменять, хотя кто будет это делать...
def unpack_packet(self, packet):
# try catch чтобы не сыпалось всё при неверных пакетах
if isinstance(packet, (str, bytes)) and len(packet) > self.MAX_PACKET_SIZE:
return {}
try:
parsed_packet = json.loads(packet)
except (json.JSONDecodeError, TypeError, ValueError):
return {}
return parsed_packet
# мне кажется долго вручную всё писать
# а как еще
# ну вставить сюда целиком и потом через multiline cursor удалить лишнее
# ну ты удалишь тогда. я на тачпаде
# ладно щас другим способом удалю
# всё нахуй
# TAMTAM SOURCE LEAK 2026
# так ну че делать будем
# так ну
# 19 опкод сделан?
# нет сэр пошли библиотеку тамы смотреть
# мб найдем че. она без обфускации
# а ты ее видишь?
# пошли
PING = 1
LOG = 5
SESSION_INIT = 6
PROFILE = 16
AUTH_REQUEST = 17
AUTH_CHECK_SCENARIO = 263
AUTH = 18
LOGIN = 19
LOGOUT = 20
SYNC = 21
CONFIG = 22
AUTH_CONFIRM = 23
ASSETS_GET = 26
ASSETS_UPDATE = 27
ASSETS_GET_BY_IDS = 28
ASSETS_ADD = 29
ASSETS_REMOVE = 259
ASSETS_MOVE = 260
ASSETS_LIST_MODIFY = 261
CONTACT_INFO = 32
CONTACT_UPDATE = 34
CONTACT_PRESENCE = 35
CONTACT_LIST = 36
CONTACT_PHOTOS = 39
CONTACT_CREATE = 41
REMOVE_CONTACT_PHOTO = 43
OWN_CONTACT_SEARCH = 44
CHAT_INFO = 48
CHAT_HISTORY = 49
CHAT_MARK = 50
CHAT_MEDIA = 51
CHAT_DELETE = 52
CHAT_LIST = 53
CHAT_CLEAR = 54
CHAT_UPDATE = 55
CHAT_CHECK_LINK = 56
CHAT_JOIN = 57
CHAT_LEAVE = 58
CHAT_MEMBERS = 59
CHAT_CLOSE = 61
CHAT_BOT_COMMANDS = 144
CHAT_SUBSCRIBE = 75
PUBLIC_SEARCH = 60
CHAT_CREATE = 63
MSG_SEND = 64
MSG_TYPING = 65
MSG_DELETE = 66
MSG_EDIT = 67
CHAT_SEARCH = 68
MSG_SHARE_PREVIEW = 70
MSG_SEARCH_TOUCH = 72
MSG_SEARCH = 73
MSG_GET_STAT = 74
MSG_GET = 71
VIDEO_CHAT_START = 76
VIDEO_CHAT_JOIN = 102
VIDEO_CHAT_COMMAND = 78
VIDEO_CHAT_MEMBERS = 195
CHAT_MEMBERS_UPDATE = 77
PHOTO_UPLOAD = 80
STICKER_UPLOAD = 81
VIDEO_UPLOAD = 82
VIDEO_PLAY = 83
MUSIC_PLAY = 84
MUSIC_PLAY30 = 85
FILE_UPLOAD = 87
FILE_DOWNLOAD = 88
CHAT_PIN_SET_VISIBILITY = 86
LINK_INFO = 89
MESSAGE_LINK = 90
MSG_CONSTRUCT = 94
SESSIONS_INFO = 96
SESSIONS_CLOSE = 97
PHONE_BIND_REQUEST = 98
PHONE_BIND_CONFIRM = 99
UNBIND_OK_PROFILE = 100
CHAT_COMPLAIN = 117
MSG_SEND_CALLBACK = 118
SUSPEND_BOT = 119
MSG_REACT = 178
MSG_CANCEL_REACTION = 179
MSG_GET_REACTIONS = 180
MSG_GET_DETAILED_REACTIONS = 181
LOCATION_STOP = 124
LOCATION_SEND = 125
LOCATION_REQUEST = 126
NOTIF_MESSAGE = 128
NOTIF_TYPING = 129
NOTIF_MARK = 130
NOTIF_CONTACT = 131
NOTIF_PRESENCE = 132
NOTIF_CONFIG = 134
NOTIF_CHAT = 135
NOTIF_ATTACH = 136
NOTIF_VIDEO_CHAT_START = 137
NOTIF_VIDEO_CHAT_COMMAND = 138
NOTIF_CALLBACK_ANSWER = 143
NOTIF_MSG_CONSTRUCT = 146
NOTIF_LOCATION = 147
NOTIF_LOCATION_REQUEST = 148
NOTIF_ASSETS_UPDATE = 150
NOTIF_MSG_REACTIONS_CHANGED = 155
NOTIF_MSG_YOU_REACTED = 156
NOTIF_DRAFT = 152
NOTIF_DRAFT_DISCARD = 153
NOTIF_MSG_DELAYED = 154
AUTH_CALL_INFO = 256
CONTACT_INFO_EXTERNAL = 45
DRAFT_SAVE = 176
DRAFT_DISCARD = 177
STICKER_CREATE = 193
STICKER_SUGGEST = 194
CHAT_SEARCH_COUNT_MSG = 197
CHAT_SEARCH_COMMON_PARTICIPANTS = 198
GET_USER_SCORE = 201

View File

@@ -1,71 +0,0 @@
import asyncio, logging, json
from websockets.asyncio.server import serve
from tamtam_ws.models import *
from pydantic import ValidationError
from tamtam_ws.proto import Proto
from tamtam_ws.processors import Processors
class TTWSServer:
def __init__(self, host, port, db_pool=None, clients={}, send_event=None):
self.host = host
self.port = port
self.proto = Proto()
self.processors = Processors(db_pool=db_pool, clients=clients, send_event=send_event)
self.logger = logging.getLogger(__name__)
async def handle_client(self, websocket):
deviceType = None
deviceName = None
async for message in websocket:
# Распаковываем пакет
packet = self.proto.unpack_packet(message)
if not packet:
self.logger.warning("Невалидный пакет от ws клиента")
continue
# Валидируем структуру пакета
try:
MessageModel.model_validate(packet)
except ValidationError as e:
self.logger.warning(f"Ошибка валидации пакета: {e}")
continue
# Извлекаем данные из пакета
seq = packet['seq']
opcode = packet['opcode']
payload = packet['payload']
match opcode:
case self.proto.SESSION_INIT:
# ПРИВЕТ АНДРЕЙ МАЛАХОВ
# не не удаляй этот коммент. пусть останется на релизе аххахаха
deviceType, deviceName = await self.processors.process_hello(payload, seq, websocket)
case self.proto.PING:
await self.processors.process_ping(payload, seq, websocket)
case self.proto.LOG:
# телеметрия аааа слежка цру фсб фбр
# УДАЛЯЕМ MYTRACKER ИЗ TAMTAM ТАМ ВИРУС
# майтрекер отправляет все ваши сообщения на сервер барака обамы. немедленно удаляем!!!
await self.processors.process_telemetry(payload, seq, websocket)
# case self.proto.AUTH_REQUEST:
# await self.processors.process_auth_request(payload, seq, websocket)
# case self.proto.VERIFY_CODE:
# await self.processors.process_verify_code(payload, seq, websocket)
# case self.proto.FINAL_AUTH:
# await self.processors.process_final_auth(payload, seq, websocket, deviceType, deviceName)
# лан я пойду. пока
# а ок
async def start(self):
self.logger.info(f"Вебсокет запущен на порту {self.port}")
async with serve(
self.handle_client, self.host, self.port,
max_size=65536,
open_timeout=10,
close_timeout=10,
):
await asyncio.Future()

View File

@@ -1,121 +1,145 @@
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)
def __init__(self, token, enabled, db_pool, whitelist_ids=None, whitelist_enabled=False):
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 []
self.whitelist_enabled = whitelist_enabled
self.logger = logging.getLogger(__name__)
self.msg_types = Static().BotMessageTypes()
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_enabled:
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()))
firstname = message.from_user.first_name[:59]
lastname = (message.from_user.last_name or "")[:59]
username = (message.from_user.username or f"user{int(time.time() * 1000)}")[:60]
try:
# Генерируем ID пользователя
user_id = await self.tools.generate_user_id(self.db_pool)
# Создаем юзера
await cursor.execute(
self.sql_queries.INSERT_USER,
(
user_id, # id
new_phone, # phone
tg_id, # telegram_id
firstname, # firstname
lastname, # lastname
username, # 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, handle_signals=False)
except Exception as e:
self.logger.error(f"Ошибка запуска Telegram бота: {e}")
else:
@@ -124,7 +148,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

@@ -19,7 +19,8 @@ class TelegramBotController(ControllerBase):
token=self.config.telegram_bot_token,
enabled=self.config.telegram_bot_enabled,
db_pool=api['db'],
whitelist_ids=self.config.telegram_whitelist_ids
whitelist_ids=self.config.telegram_whitelist_ids,
whitelist_enabled=self.config.telegram_whitelist_enabled,
)
return _start_all()

View File

@@ -1,7 +1,7 @@
CREATE TABLE `users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`id` INT NOT NULL,
`phone` VARCHAR(20) UNIQUE,
`telegram_id` VARCHAR(64) UNIQUE,
`telegram_id` VARCHAR(64),
`firstname` VARCHAR(59) NOT NULL,
`lastname` VARCHAR(59),
`description` VARCHAR(400),
@@ -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` BIGINT NOT NULL,
`chat_id` INT NOT NULL,
`sender` INT NOT NULL,
`time` VARCHAR(32) NOT NULL,
@@ -57,5 +59,53 @@ 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 ('[]'),
`include` 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`)
);