Compare commits
9 Commits
28ef360d5f
...
0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| a178183251 | |||
| 4194f3da48 | |||
| 4a947cec3d | |||
| b28b7c583d | |||
|
|
f8e9a528e2 | ||
|
|
9266d8f32b | ||
|
|
d5e30f8c36 | ||
|
|
a803b80d59 | ||
|
|
8094aaab10 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.venv/
|
||||
session_storage/
|
||||
__pycache__/
|
||||
runtime/
|
||||
*.swp
|
||||
*.swo
|
||||
*.vscode
|
||||
*.json
|
||||
*.sqlite
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.venv/
|
||||
session_storage/
|
||||
__pycache__/
|
||||
runtime/
|
||||
*.swp
|
||||
*.swo
|
||||
*.vscode
|
||||
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y libmagic1 libolm3 libolm-dev git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
WORKDIR /runtime
|
||||
RUN chown -R 1000:1000 /usr/src /runtime
|
||||
|
||||
USER 1000:1000
|
||||
CMD [ "python", "/usr/src/app/main.py" ]
|
||||
89
README.md
89
README.md
@@ -4,7 +4,30 @@
|
||||
Раньше был бот с таким же функционалом, но для Telegram. Telegram больше не в
|
||||
почёте, и теперь у меня всё в локальном Matrix, поэтому бот тоже перенесён сюда.
|
||||
|
||||
## Подготовка окружения
|
||||
## Для запуска рекомендуется `docker`
|
||||
|
||||
1. [Скачайте образ](https://git.tyukalov.su/nikita/-/packages/container/2026-matrix-csonac) (измените версию на нужную вам):
|
||||
```bash
|
||||
sudo docker pull git.tyukalov.su/nikita/2026-matrix-csonac:0.4
|
||||
```
|
||||
2. Выполните первый запуск в интерактивном режиме
|
||||
```bash
|
||||
sudo mkdir runtime
|
||||
sudo chown 1000:1000 runtime
|
||||
sudo docker run -v ./runtime:/runtime -ti git.tyukalov.su/nikita/2026-matrix-csonac:0.4
|
||||
```
|
||||
3. В папке `runtime` появится файл `config.json`. Внесите туда нужные настройки
|
||||
4. Запустите ещё раз в интерактивном режиме, чтобы авторизоваться
|
||||
```bash
|
||||
sudo docker run -v ./runtime:/runtime -ti git.tyukalov.su/nikita/2026-matrix-csonac:0.4
|
||||
```
|
||||
5. Дальше можно запускать контейнер в фоне
|
||||
```bash
|
||||
sudo docker run -p 127.0.0.1:4980:4980 -v ./runtime:/runtime --detach git.tyukalov.su/nikita/2026-matrix-csonac:0.4
|
||||
```
|
||||
> Рекомендуется запускать контейнер при помощи `docker compose`.
|
||||
|
||||
## Запуск без `docker`
|
||||
|
||||
1. Клонируйте репозиторий и перейдите в его директорию
|
||||
```bash
|
||||
@@ -26,17 +49,6 @@ python main.py
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
Для запуска приложения вы можете либо активировать `venv`, либо просто использовать полный путь до интерпретатора Python:
|
||||
```bash
|
||||
~/2026-matrix-downloader/.venv/bin/python main.py
|
||||
```
|
||||
```bash
|
||||
. .venv/bin/activate
|
||||
python main.py
|
||||
```
|
||||
|
||||
## Как работает бот
|
||||
|
||||
Для каждой службы (канала уведомлений) создаётся своя собственная комната и в
|
||||
@@ -54,6 +66,7 @@ python main.py
|
||||
- `!ban <IP> <SECONDS> [REASON]` - забанить указанный IP на указанное число секунд (можно указать причину)
|
||||
- `!unban <IP>` - разбанить указанный IP адрес
|
||||
- `!bans` - получить список забаненных IP адресов
|
||||
- `!short <TOKEN>` - создать токен с указанным именем
|
||||
|
||||
## Как работает веб-сервер
|
||||
|
||||
@@ -64,33 +77,45 @@ python main.py
|
||||
> `nginx`. Это также позволит вам использовать защищённое соединение, что
|
||||
> исключит возможность применения атаки Man-in-the-Middle для перехвата токена.
|
||||
|
||||
Все запросы к веб-серверу требуют авторизации, используя HTTP заголовок
|
||||
`Authorization` и схему `Bearer`. Токен авторизации генерируется посредством
|
||||
взаимодействия с ботом в Matrix.
|
||||
Все запросы к веб-серверу являются GET-запросами и требуют авторизации.
|
||||
Авторизоваться можно двумя путями:
|
||||
1. Использовать HTTP-заголовок `Authorization` и схему `Bearer`. Например:
|
||||
```plain
|
||||
Authorization: Bearer 1234567890abcdef
|
||||
```
|
||||
2. Использовать URL параметр `token`. Например:
|
||||
```plain
|
||||
https://csonac.su/api/notify?token=f1829e94d...
|
||||
```
|
||||
Рекомендуется использовать первый способ (HTTP-заголовок), так как это позволяет
|
||||
избежать раскрытия токена в логах сервера и других местах, где можно посмотреть
|
||||
URL прошлых запросов.
|
||||
|
||||
> Для каждого сервиса, использующего бота, рекомендуется генерировать свой
|
||||
> собственный токен. Это позволит отозвать токен только для одного серсива, если
|
||||
> токен будет украден.
|
||||
|
||||
Доступные эндпоинты:
|
||||
- `POST /<channel>/notify`
|
||||
- **Описание.** Используется, чтобы отправить уведомление в указанный канал.
|
||||
Вместо `<channel>` указывается код канала, получаемый при помощи команды
|
||||
`!info`, выполненной в комнате Matrix.
|
||||
- **Тело запроса.** Тело запроса представляет собой `json` объект:
|
||||
```json
|
||||
- `GET /api/notify`
|
||||
- **Описание.** Используется, чтобы отправить уведомление в канал.
|
||||
- **Параметры запроса**
|
||||
- `channel` - код канала, получаемый при помощи команды `!info`,
|
||||
выполненной в комнате Matrix
|
||||
- `service` - имя сервиса (учитывается, только если для токена не было
|
||||
настроено имя сервиса через бота)
|
||||
- `text` - текст уведомления (форматирование не поддерживается)
|
||||
- **Ответ**
|
||||
- В случае успеха сервер вернёт `200` и JSON следующего формата:
|
||||
```json
|
||||
{
|
||||
"service": "<название сервиса; указывается, если токен это позволяет>",
|
||||
"text": "<текст уведомления>"
|
||||
"notification_id": "Notification ID will be here"
|
||||
}
|
||||
```
|
||||
- **Тело ответа.** Тело ответа представляет собой `json` объект. В случае
|
||||
успеха в нём будут все поля, перечисляемые ниже. В случае провала - только
|
||||
поле `error`, содержащее текстовое описание ошибки.
|
||||
```json
|
||||
```
|
||||
> В текущей версии `notification_id` не имеет практической пользы и его
|
||||
> формат будет меняться.
|
||||
- В случае ошибки сервер вернёт JSON следующего формата:
|
||||
```json
|
||||
{
|
||||
"error": null,
|
||||
"notification_id": "<здесь будет Notification ID>"
|
||||
"detail": "Error description in English"
|
||||
}
|
||||
```
|
||||
- ``
|
||||
```
|
||||
226
bot_callbacks.py
226
bot_callbacks.py
@@ -4,8 +4,7 @@ import html
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from mab import MatrixBot
|
||||
from nio import MatrixRoom, RoomMessageText
|
||||
from mab import * # type: ignore
|
||||
|
||||
import util
|
||||
from database import Database
|
||||
@@ -14,9 +13,21 @@ from datatypes import *
|
||||
#
|
||||
# PRIVATE
|
||||
#
|
||||
_bot: MatrixBot = None # type: ignore
|
||||
_db: Database = None # type: ignore
|
||||
|
||||
_COMMANDS = {
|
||||
"help": "Получить справку",
|
||||
"info": "Получить сведения о комнате",
|
||||
"tokens": "Получить список токенов",
|
||||
"auth": "Создать новый токен",
|
||||
"deauth": "Удалить существующий токен",
|
||||
"name": "Задать (или удалить) имя для токена",
|
||||
"ban": "Забанить IP адрес",
|
||||
"unban": "Разбанить IP адрес",
|
||||
"bans": "Получить список забаненных IP адресов",
|
||||
"short": "(Небезопасно) Создать токен-имя",
|
||||
}
|
||||
|
||||
def _generate_help_message() -> str:
|
||||
"""Generates help message in HTML markup"""
|
||||
result = "<strong><i>Как использовать</i></strong><br><ol>"
|
||||
@@ -27,75 +38,42 @@ def _generate_help_message() -> str:
|
||||
result += "</ol>"
|
||||
|
||||
result += "<br><br><strong><i>Команды</i></strong>"
|
||||
for aliases in _COMMANDS:
|
||||
result += f"<br><code>!{aliases[0]}</code> - <i>{html.escape(_COMMANDS[aliases][1])}</i>"
|
||||
for key in _COMMANDS:
|
||||
result += f"<br><code>!{key}</code> - <i>{html.escape(_COMMANDS[key])}</i>"
|
||||
return result
|
||||
|
||||
#
|
||||
# GENERIC EVENT HANDLERS
|
||||
#
|
||||
async def _on_text(room: MatrixRoom, event: RoomMessageText) -> None:
|
||||
"""This callback is called when text message is received"""
|
||||
if event.sender == _bot.get_client().user_id:
|
||||
return
|
||||
text = event.body
|
||||
parts = [p for p in text.split() if p.strip()]
|
||||
if len(parts) < 1:
|
||||
return
|
||||
cmd = parts[0].lower()
|
||||
if not cmd.startswith("!"):
|
||||
return
|
||||
cb = None
|
||||
for aliases in _COMMANDS:
|
||||
for alias in aliases:
|
||||
if alias == cmd.lstrip("!"):
|
||||
cb = _COMMANDS[aliases][0]
|
||||
break
|
||||
if cb:
|
||||
break
|
||||
if cb is None:
|
||||
await _bot.send_text_to_room(room.room_id, "<strong>Используйте <code>!help</code></strong>")
|
||||
return
|
||||
try:
|
||||
await cb(room, parts[1:])
|
||||
except:
|
||||
traceback.print_exc()
|
||||
|
||||
#
|
||||
# COMMAND HANDLERS
|
||||
#
|
||||
async def _on_cmd_help(room: MatrixRoom, args: list[str]) -> None:
|
||||
async def _on_cmd_help(ctx: EventContext) -> None:
|
||||
"""!help"""
|
||||
await _bot.send_text_to_room(room.room_id, _generate_help_message(), is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, _generate_help_message())
|
||||
|
||||
async def _on_cmd_info(room: MatrixRoom, args: list[str]) -> None:
|
||||
async def _on_cmd_info(ctx: EventContext) -> None:
|
||||
"""!info"""
|
||||
# get room info (or add it)
|
||||
room_info = await _db.room_get(room.room_id)
|
||||
room_info = await _db.room_get(ctx.room.room_id)
|
||||
if room_info is None:
|
||||
room_info = await _db.room_create(room.room_id)
|
||||
room_info = await _db.room_create(ctx.room.room_id)
|
||||
# failure
|
||||
if room_info is None:
|
||||
await _bot.send_text_to_room(
|
||||
room.room_id,
|
||||
"<strong>Нет информации о комнате</strong>",
|
||||
is_html=True
|
||||
await ctx.bot.send_text(
|
||||
ctx.room, "<strong>Нет информации о комнате</strong>"
|
||||
)
|
||||
return
|
||||
# respond
|
||||
response = "<strong><i>Сведения о комнате</i></strong><br>"
|
||||
response += f"<strong>Канал:</strong> <code>{html.escape(room_info.code)}</code>"
|
||||
await _bot.send_text_to_room(room.room_id, response, is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, response)
|
||||
|
||||
async def _on_cmd_tokens(room: MatrixRoom, args: list[str]) -> None:
|
||||
async def _on_cmd_tokens(ctx: EventContext) -> None:
|
||||
"""!info"""
|
||||
# get all tokens and check if there are none
|
||||
tokens = await _db.token_get_all()
|
||||
if not tokens:
|
||||
await _bot.send_text_to_room(
|
||||
room.room_id,
|
||||
"<strong>Нет токенов, используйте <code>!auth</code></strong>",
|
||||
is_html=True
|
||||
await ctx.bot.send_text(
|
||||
ctx.room,
|
||||
"<strong>Нет токенов, используйте <code>!auth</code></strong>"
|
||||
)
|
||||
return
|
||||
# create the list of tokens
|
||||
@@ -110,15 +88,15 @@ async def _on_cmd_tokens(room: MatrixRoom, args: list[str]) -> None:
|
||||
response += f"<li><strong>Последнее использование:</strong> <i>{util.date_to_text(token.last_access_at)}</i></li>"
|
||||
response += "</ul>"
|
||||
# respond
|
||||
await _bot.send_text_to_room(room.room_id, response, is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, response)
|
||||
|
||||
async def _on_cmd_auth(room: MatrixRoom, args: list[str]) -> None:
|
||||
async def _on_cmd_auth(ctx: EventContext) -> None:
|
||||
"""!auth"""
|
||||
# create new token
|
||||
token = await _db.token_create()
|
||||
# set the name if it is provided
|
||||
if args:
|
||||
token.name = " ".join(args)
|
||||
if ctx[CTX_CMD_ARGS]:
|
||||
token.name = " ".join(ctx[CTX_CMD_ARGS])
|
||||
await _db.token_set_name(token.code, token.name)
|
||||
# create the response
|
||||
response = "<strong>Создан новый токен</strong>"
|
||||
@@ -129,39 +107,39 @@ async def _on_cmd_auth(room: MatrixRoom, args: list[str]) -> None:
|
||||
else:
|
||||
response += "указывается в запросе"
|
||||
# respond
|
||||
await _bot.send_text_to_room(room.room_id, response, is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, response)
|
||||
|
||||
async def _on_cmd_deauth(room: MatrixRoom, args: list[str]) -> None:
|
||||
async def _on_cmd_deauth(ctx: EventContext) -> None:
|
||||
"""!deauth"""
|
||||
# no token provided
|
||||
if len(args) != 1:
|
||||
await _bot.send_text_to_room(room.room_id, "<strong>Укажите токен, который надо удалить (должен быть ровно один аргумент)</strong>", is_html=True)
|
||||
if len(ctx[CTX_CMD_ARGS]) != 1:
|
||||
await ctx.bot.send_text(ctx.room, "<strong>Укажите токен, который надо удалить (должен быть ровно один аргумент)</strong>")
|
||||
return
|
||||
token = args[0]
|
||||
token = ctx[CTX_CMD_ARGS][0]
|
||||
# check if token does not exist
|
||||
if await _db.token_get(token) is None:
|
||||
await _bot.send_text_to_room(room.room_id, "<strong>Токен не найден</strong>", is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, "<strong>Токен не найден</strong>")
|
||||
return
|
||||
# remove the token
|
||||
await _db.token_delete(token)
|
||||
# respond
|
||||
response = f"<strong>Удалён токен <code>{token}</code></strong>"
|
||||
await _bot.send_text_to_room(room.room_id, response, is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, response)
|
||||
|
||||
async def _on_cmd_name(room: MatrixRoom, args: list[str]) -> None:
|
||||
async def _on_cmd_name(ctx: EventContext) -> None:
|
||||
"""!name"""
|
||||
# check arguments
|
||||
if len(args) < 1:
|
||||
if len(ctx[CTX_CMD_ARGS]) < 1:
|
||||
error = "<strong>Требуется указать как минимум токен. Если хотите убрать имя, то имя указывать не надо. Если имя нужно назначить или изменить, то после токена укажите новое имя.</strong>"
|
||||
await _bot.send_text_to_room(room.room_id, error, is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, error)
|
||||
return
|
||||
token = args[0]
|
||||
new_name = " ".join(args[1:])
|
||||
token = ctx[CTX_CMD_ARGS][0]
|
||||
new_name = " ".join(ctx[CTX_CMD_ARGS][1:])
|
||||
if not new_name.strip():
|
||||
new_name = None
|
||||
# check if token exists
|
||||
if await _db.token_get(token) is None:
|
||||
await _bot.send_text_to_room(room.room_id, "<strong>Токен не существует</strong>", is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, "<strong>Токен не существует</strong>")
|
||||
return
|
||||
# set new name
|
||||
await _db.token_set_name(token, new_name)
|
||||
@@ -171,51 +149,51 @@ async def _on_cmd_name(room: MatrixRoom, args: list[str]) -> None:
|
||||
else:
|
||||
response = f"<strong>Удалено имя для токена <code>{token}</code></strong>"
|
||||
# respond
|
||||
await _bot.send_text_to_room(room.room_id, response, is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, response)
|
||||
|
||||
async def _on_cmd_ban(room: MatrixRoom, args: list[str]) -> None:
|
||||
async def _on_cmd_ban(ctx: EventContext) -> None:
|
||||
"""!ban"""
|
||||
# check arguments
|
||||
if len(args) < 2:
|
||||
if len(ctx[CTX_CMD_ARGS]) < 2:
|
||||
error = "<strong>Формат: <code>!ban <IP> <SECONDS> [REASON]</code></strong>"
|
||||
await _bot.send_text_to_room(room.room_id, error, is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, error)
|
||||
return
|
||||
# get args
|
||||
# get event.command_args
|
||||
try:
|
||||
ip = args[0]
|
||||
duration = float(args[1])
|
||||
reason = " ".join(args[2:]) if args[2:] else "Manual ban"
|
||||
ip = ctx[CTX_CMD_ARGS][0]
|
||||
duration = float(ctx[CTX_CMD_ARGS][1])
|
||||
reason = " ".join(ctx[CTX_CMD_ARGS][2:]) if ctx[CTX_CMD_ARGS][2:] else "Manual ban"
|
||||
except:
|
||||
error = "<strong>Возникла ошибка. Наверняка неправильно указаны секунды.</strong>"
|
||||
await _bot.send_text_to_room(room.room_id, error, is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, error)
|
||||
return
|
||||
# ban
|
||||
try:
|
||||
await _db.ban_create(ip, time.time() + duration, reason)
|
||||
await _bot.send_text_to_room(room.room_id, "<strong>IP адрес заблокирован</strong>", is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, "<strong>IP адрес заблокирован</strong>")
|
||||
except:
|
||||
await _bot.send_text_to_room(room.room_id, "<strong>Возникла ошибка</strong>", is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, "<strong>Возникла ошибка</strong>")
|
||||
|
||||
async def _on_cmd_unban(room: MatrixRoom, args: list[str]) -> None:
|
||||
async def _on_cmd_unban(ctx: EventContext) -> None:
|
||||
"""!unban"""
|
||||
# check arguments
|
||||
if len(args) != 1:
|
||||
if len(ctx[CTX_CMD_ARGS]) != 1:
|
||||
error = "<strong>Формат: <code>!ban <IP></code></strong>"
|
||||
await _bot.send_text_to_room(room.room_id, error, is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, error)
|
||||
return
|
||||
# unban
|
||||
try:
|
||||
await _db.ban_delete(args[0])
|
||||
await _bot.send_text_to_room(room.room_id, "<strong>IP адрес разблокирован (если он был заблокирован)</strong>", is_html=True)
|
||||
await _db.ban_delete(ctx[CTX_CMD_ARGS][0])
|
||||
await ctx.bot.send_text(ctx.room, "<strong>IP адрес разблокирован (если он был заблокирован)</strong>")
|
||||
except:
|
||||
await _bot.send_text_to_room(room.room_id, "<strong>Возникла ошибка</strong>", is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, "<strong>Возникла ошибка</strong>")
|
||||
|
||||
async def _on_cmd_bans(room: MatrixRoom, args: list[str]) -> None:
|
||||
async def _on_cmd_bans(ctx: EventContext) -> None:
|
||||
"""!bans"""
|
||||
# list
|
||||
bans = _db.ban_get_all()
|
||||
if not bans:
|
||||
await _bot.send_text_to_room(room.room_id, "<strong>Нет заблокированных IP адресов</strong>", is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, "<strong>Нет заблокированных IP адресов</strong>")
|
||||
return
|
||||
# create the response
|
||||
result = "<strong>Список заблокированных IP</strong><br><ul>"
|
||||
@@ -225,27 +203,73 @@ async def _on_cmd_bans(room: MatrixRoom, args: list[str]) -> None:
|
||||
result += f"<li><strong>Причина:</strong> <code>{html.escape(ban["reason"])}</code></li>"
|
||||
result += "</ul></li>"
|
||||
result += "</ul>"
|
||||
await _bot.send_text_to_room(room.room_id, result, is_html=True)
|
||||
await ctx.bot.send_text(ctx.room, result)
|
||||
|
||||
async def _on_cmd_short(ctx: EventContext) -> None:
|
||||
"""!auth"""
|
||||
# get token to use
|
||||
if len(ctx[CTX_CMD_ARGS]) != 1:
|
||||
error = "<strong>Формат: <code>!short <token></code></strong>"
|
||||
await ctx.bot.send_text(ctx.room, error)
|
||||
return
|
||||
# create new token
|
||||
try:
|
||||
token = await _db.token_create(ctx[CTX_CMD_ARGS][0])
|
||||
except Exception as e:
|
||||
await ctx.bot.send_text(ctx.room, f"Не удалось создать токен: {" ".join(e.args)}")
|
||||
return
|
||||
# create the response
|
||||
response = "<strong>Создан новый токен</strong>"
|
||||
response += f"<br><strong>Код:</strong> <code>{token.code}</code>"
|
||||
response += f"<br><strong>Имя сервиса:</strong> указывается в запросе"
|
||||
# respond
|
||||
await ctx.bot.send_text(ctx.room, response)
|
||||
|
||||
_COMMANDS = {
|
||||
("help", "h", "?"): (_on_cmd_help, "Получить справку"),
|
||||
("info", "room", "i", "r"): (_on_cmd_info, "Получить сведения о комнате"),
|
||||
("tokens", "t"): (_on_cmd_tokens, "Получить список токенов"),
|
||||
("auth", "create", "a", "c"): (_on_cmd_auth, "Создать новый токен"),
|
||||
("deauth", "delete", "d"): (_on_cmd_deauth, "Удалить существующий токен"),
|
||||
("name", "n"): (_on_cmd_name, "Задать (или удалить) имя для токена"),
|
||||
("ban", "b"): (_on_cmd_ban, "Забанить IP адрес"),
|
||||
("unban", "u"): (_on_cmd_unban, "Разбанить IP адрес"),
|
||||
("bans", "l"): (_on_cmd_bans, "Получить список забаненных IP адресов"),
|
||||
}
|
||||
|
||||
#
|
||||
# PUBLIC
|
||||
#
|
||||
def setup(bot: MatrixBot, db: Database) -> None:
|
||||
"""Setup the callbacks"""
|
||||
global _bot, _db
|
||||
global _db
|
||||
_db = db
|
||||
_bot = bot
|
||||
_bot.add_event_callback(_on_text, RoomMessageText) # type: ignore
|
||||
bot.add_callback(
|
||||
~SenderIsBotFilter() & BodyCommandFilter(["info", "room", "i", "r"]),
|
||||
_on_cmd_info
|
||||
)
|
||||
bot.add_callback(
|
||||
~SenderIsBotFilter() & BodyCommandFilter(["tokens", "t"]),
|
||||
_on_cmd_tokens
|
||||
)
|
||||
bot.add_callback(
|
||||
~SenderIsBotFilter() & BodyCommandFilter(["auth", "create", "a", "c"]),
|
||||
_on_cmd_auth
|
||||
)
|
||||
bot.add_callback(
|
||||
~SenderIsBotFilter() & BodyCommandFilter(["deauth", "delete", "d"]),
|
||||
_on_cmd_deauth
|
||||
)
|
||||
bot.add_callback(
|
||||
~SenderIsBotFilter() & BodyCommandFilter(["name", "n"]),
|
||||
_on_cmd_name
|
||||
)
|
||||
bot.add_callback(
|
||||
~SenderIsBotFilter() & BodyCommandFilter(["ban", "b"]),
|
||||
_on_cmd_ban
|
||||
)
|
||||
bot.add_callback(
|
||||
~SenderIsBotFilter() & BodyCommandFilter(["unban", "u"]),
|
||||
_on_cmd_unban
|
||||
)
|
||||
bot.add_callback(
|
||||
~SenderIsBotFilter() & BodyCommandFilter(["bans", "l"]),
|
||||
_on_cmd_bans
|
||||
)
|
||||
bot.add_callback(
|
||||
~SenderIsBotFilter() & BodyCommandFilter(["short", "s"]),
|
||||
_on_cmd_short
|
||||
)
|
||||
bot.add_callback(
|
||||
~SenderIsBotFilter() & NewMessageFilter(),
|
||||
_on_cmd_help
|
||||
)
|
||||
@@ -11,7 +11,11 @@ import util
|
||||
DEFAULT_CONFIG = {
|
||||
"matrix_homeserver": "https://matrix.domain.net",
|
||||
"matrix_user": "short_username",
|
||||
"store_dir": "session_storage"
|
||||
"store_dir": "session_storage",
|
||||
"web_ip": "0.0.0.0",
|
||||
"web_port": 4980,
|
||||
"fails_to_ban": 50,
|
||||
"ban_duration": 600
|
||||
}
|
||||
|
||||
|
||||
@@ -50,4 +54,5 @@ async def load_config(path: str = "config.json") -> AppConfig | None:
|
||||
cfg = AppConfig(**j)
|
||||
return cfg
|
||||
except:
|
||||
traceback.print_exc()
|
||||
return None
|
||||
77
database.py
77
database.py
@@ -84,20 +84,33 @@ class Database:
|
||||
wait_task = asyncio.create_task(
|
||||
asyncio.sleep(self.BACKGROUND_ROUTINE_PERIOD)
|
||||
)
|
||||
if self._connection is None:
|
||||
continue
|
||||
# get current time
|
||||
current_time = time.time()
|
||||
# delete old bans
|
||||
try:
|
||||
if self._connection is not None:
|
||||
statement = "DELETE FROM bans WHERE expires_at <= ? RETURNING ip"
|
||||
async with self._connection.execute(statement, (time.time(),)) as cursor:
|
||||
async for row in cursor:
|
||||
ip = row["ip"]
|
||||
if ip in self._bans:
|
||||
del self._bans[ip]
|
||||
self._logger.debug(f"IP {ip} is not banned anymore")
|
||||
await self._connection.commit()
|
||||
statement = "DELETE FROM bans WHERE expires_at <= ? RETURNING ip"
|
||||
async with self._connection.execute(statement, (current_time,)) as cursor:
|
||||
async for row in cursor:
|
||||
ip = row["ip"]
|
||||
if ip in self._bans:
|
||||
del self._bans[ip]
|
||||
self._logger.debug(f"IP {ip} is not banned anymore")
|
||||
await self._connection.commit()
|
||||
self._logger.debug("Performed banned IPs cleanup")
|
||||
except:
|
||||
self._logger.error(traceback.format_exc())
|
||||
# delete expired fails
|
||||
try:
|
||||
for ip in dict(self._fails):
|
||||
self._fails[ip] = list(
|
||||
filter(lambda x: x > current_time, self._fails[ip])
|
||||
)
|
||||
if not self._fails[ip]:
|
||||
del self._fails[ip]
|
||||
except:
|
||||
self._logger.error(traceback.format_exc())
|
||||
self._logger.debug("Stopped background worker")
|
||||
|
||||
async def _load_bans_from_database(self) -> None:
|
||||
@@ -115,6 +128,12 @@ class Database:
|
||||
self._logger.error(traceback.format_exc())
|
||||
return
|
||||
|
||||
def _get_fails_for_ip(self, ip) -> int:
|
||||
if ip not in self._fails:
|
||||
return 0
|
||||
t = time.time()
|
||||
return sum([(1 if expires_at > t else 0) for expires_at in self._fails[ip]])
|
||||
|
||||
#
|
||||
# PUBLIC
|
||||
#
|
||||
@@ -127,6 +146,7 @@ class Database:
|
||||
self._background_stop_event: asyncio.Event | None = None
|
||||
self._background_task: asyncio.Task | None = None
|
||||
self._bans: dict = {}
|
||||
self._fails: dict[str, list[float]] = {}
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to the database. Returns False on failure."""
|
||||
@@ -137,6 +157,7 @@ class Database:
|
||||
self._connection.row_factory = Row
|
||||
await self._setup_tables(self._connection)
|
||||
self._bans = {}
|
||||
self._fails = {}
|
||||
await self._load_bans_from_database()
|
||||
self._background_stop_event = asyncio.Event()
|
||||
self._background_task = asyncio.create_task(
|
||||
@@ -231,11 +252,17 @@ class Database:
|
||||
return None
|
||||
|
||||
|
||||
async def token_create(self) -> ObjectToken:
|
||||
async def token_create(self, code: str | None = None) -> ObjectToken:
|
||||
"""Create a token."""
|
||||
if self._connection is None:
|
||||
raise RuntimeError("Not connected to the database")
|
||||
code = get_hash(str(time.time()).encode() + os.urandom(64))
|
||||
if code is None:
|
||||
code = get_hash(str(time.time()).encode() + os.urandom(64))
|
||||
else:
|
||||
if len(code) < 2 or len(code) > 16:
|
||||
raise RuntimeError("Token must be 2-16 symbols long")
|
||||
if not all(ord(c) < 128 and (c.islower() or c.isdigit()) for c in code):
|
||||
raise RuntimeError("Only digits and ASCII lowercase letters are allowed")
|
||||
create_time = time.time()
|
||||
statement = """
|
||||
INSERT INTO tokens (code, name, created_at, last_access_at)
|
||||
@@ -308,6 +335,17 @@ class Database:
|
||||
except:
|
||||
self._logger.error(traceback.format_exc())
|
||||
|
||||
async def token_set_last_access(self, code: str, timestamp: float) -> None:
|
||||
"""Set last_access_at for the token."""
|
||||
if self._connection is None:
|
||||
raise RuntimeError("Not connected to the database")
|
||||
try:
|
||||
statement = "UPDATE tokens SET last_access_at=? WHERE code=?"
|
||||
await self._connection.execute(statement, (timestamp, code))
|
||||
await self._connection.commit()
|
||||
except:
|
||||
self._logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
async def ban_create(self, ip: str, expires_at: float, reason: str) -> None:
|
||||
"""Save information about banned IP address. Replaces existing IPs."""
|
||||
@@ -347,3 +385,20 @@ class Database:
|
||||
del self._bans[ip]
|
||||
except:
|
||||
self._logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
def fail_create(self, ip: str, expires_at: float) -> int:
|
||||
"""Adds failed access attempt. Returns count of failed attempts for IP."""
|
||||
if self._connection is None:
|
||||
raise RuntimeError("Not connected to the database")
|
||||
if ip not in self._fails:
|
||||
self._fails[ip] = []
|
||||
self._fails[ip].append(expires_at)
|
||||
return self._get_fails_for_ip(ip)
|
||||
|
||||
def fail_clear(self, ip: str) -> None:
|
||||
"""Clears failed attempts counter."""
|
||||
if self._connection is None:
|
||||
raise RuntimeError("Not connected to the database")
|
||||
if ip in self._fails:
|
||||
del self._fails[ip]
|
||||
@@ -10,6 +10,10 @@ class AppConfig:
|
||||
matrix_homeserver: str
|
||||
matrix_user: str
|
||||
store_dir: str
|
||||
web_ip: str
|
||||
web_port: int
|
||||
fails_to_ban: int
|
||||
ban_duration: float
|
||||
|
||||
@dataclass
|
||||
class ObjectRoom:
|
||||
|
||||
7
main.py
7
main.py
@@ -14,6 +14,7 @@ import database
|
||||
import config
|
||||
import util
|
||||
import bot_callbacks
|
||||
import web
|
||||
|
||||
bot: MatrixBot = None # type: ignore
|
||||
|
||||
@@ -47,17 +48,21 @@ async def main() -> None:
|
||||
db = database.Database(Path("database.sqlite"))
|
||||
# setup the callbacks
|
||||
bot_callbacks.setup(bot, db)
|
||||
# setup the web server
|
||||
web_server = web.Web(cfg, bot, db)
|
||||
|
||||
# start the app
|
||||
if not await db.connect():
|
||||
util.log_error("Can't connect to the database!")
|
||||
return
|
||||
bot.start()
|
||||
await bot.start()
|
||||
await web_server.start()
|
||||
|
||||
# wait for Ctrl+C
|
||||
await util.get_app_stop_event().wait()
|
||||
|
||||
# stop the app
|
||||
await web_server.stop()
|
||||
await bot.stop()
|
||||
await db.disconnect()
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
agent-detector==1.1.0
|
||||
aioconsole==0.8.2
|
||||
aiofiles==25.1.0
|
||||
aiohappyeyeballs==2.7.1
|
||||
@@ -5,29 +6,72 @@ aiohttp==3.14.3
|
||||
aiohttp_socks==0.12.0
|
||||
aiosignal==1.4.0
|
||||
aiosqlite==0.22.1
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.14.2
|
||||
atomicwrites==1.4.1
|
||||
attrs==26.1.0
|
||||
build==1.5.0
|
||||
cachetools==7.1.7
|
||||
certifi==2026.7.22
|
||||
click==8.5.0
|
||||
detect-installer==0.1.0
|
||||
dnspython==2.8.0
|
||||
email-validator==2.3.0
|
||||
fastapi==0.141.1
|
||||
fastapi-cli==0.0.32
|
||||
fastapi-cloud-cli==0.24.0
|
||||
fastar==0.12.0
|
||||
frozenlist==1.8.0
|
||||
h11==0.16.0
|
||||
h2==4.4.1
|
||||
hpack==4.2.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.8.0
|
||||
httpx==0.28.1
|
||||
hyperframe==6.1.0
|
||||
idna==3.19
|
||||
Jinja2==3.1.6
|
||||
jsonschema==4.26.0
|
||||
jsonschema-specifications==2025.9.1
|
||||
mab @ git+https://git.tyukalov.su/nikita/mab@c3046307c7bc4e65113aab62b07bad2a148b49e5
|
||||
mab @ git+https://git.tyukalov.su/nikita/mab@v0.5.0
|
||||
markdown-it-py==4.2.0
|
||||
MarkupSafe==3.0.3
|
||||
matrix-nio==0.26.0
|
||||
mdurl==0.1.2
|
||||
multidict==6.7.1
|
||||
packaging==26.3
|
||||
peewee==3.19.0
|
||||
pillow==12.3.0
|
||||
propcache==0.5.2
|
||||
pycryptodome==3.23.0
|
||||
pydantic==2.13.5
|
||||
pydantic-extra-types==2.11.1
|
||||
pydantic-settings==2.15.0
|
||||
pydantic_core==2.46.5
|
||||
Pygments==2.21.0
|
||||
pyproject_hooks==1.2.0
|
||||
python-dotenv==1.2.3
|
||||
python-magic==0.4.27
|
||||
python-multipart==0.0.32
|
||||
python-socks==3.0.0
|
||||
PyYAML==6.0.3
|
||||
referencing==0.37.0
|
||||
rich==15.0.0
|
||||
rich-toolkit==0.20.3
|
||||
rignore==0.8.1
|
||||
rpds-py==2026.6.3
|
||||
sentry-sdk==2.68.1
|
||||
shellingham==1.5.4
|
||||
starlette==1.6.0
|
||||
typer==0.27.2
|
||||
typing-inspection==0.4.4
|
||||
typing_extensions==4.16.0
|
||||
unpaddedbase64==2.1.0
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.52.4
|
||||
uvloop==0.22.1
|
||||
vodozemac==0.10.0
|
||||
watchfiles==1.2.0
|
||||
websockets==17.1
|
||||
yarl==1.24.5
|
||||
|
||||
73
web.py
Normal file
73
web.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""This module implements API"""
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
import time
|
||||
import html
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from mab import MatrixBot
|
||||
import uvicorn
|
||||
|
||||
from datatypes import AppConfig
|
||||
from database import Database
|
||||
|
||||
import web_routes
|
||||
import web_middleware
|
||||
|
||||
class Web:
|
||||
#
|
||||
# PRIVATE
|
||||
#
|
||||
async def _cb_exception(self, request: Request, exc: Exception):
|
||||
traceback.print_exception(exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal Server Error"}
|
||||
)
|
||||
|
||||
#
|
||||
# PUBLIC
|
||||
#
|
||||
def __init__(self, app_config: AppConfig, bot: MatrixBot, db: Database):
|
||||
self._api = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
self._api.state.database = db
|
||||
self._api.state.matrix = bot
|
||||
self._api.state.app_config = app_config
|
||||
|
||||
self._api.include_router(web_routes.router)
|
||||
self._api.add_middleware(web_middleware.AuthMiddleware, ["/api"])
|
||||
self._api.add_middleware(web_middleware.BanCheckMiddleware, ["/api"])
|
||||
self._api.add_middleware(web_middleware.RealIpResolver)
|
||||
self._api.add_exception_handler(Exception, self._cb_exception)
|
||||
|
||||
self._server_config = uvicorn.Config(
|
||||
self._api,
|
||||
host=app_config.web_ip,
|
||||
port=app_config.web_port
|
||||
)
|
||||
self._server: uvicorn.Server | None = None
|
||||
self._server_task: asyncio.Task | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the API."""
|
||||
if self._server is not None or self._server_task is not None:
|
||||
raise RuntimeError("The server is already started")
|
||||
self._server = uvicorn.Server(self._server_config)
|
||||
self._server_task = asyncio.create_task(self._server.serve())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the API server."""
|
||||
if self._server is None or self._server_task is None:
|
||||
raise RuntimeError("The server is not started yet")
|
||||
self._server_task.cancel()
|
||||
try:
|
||||
await self._server_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except:
|
||||
traceback.print_exc()
|
||||
self._server_task = None
|
||||
self._server = None
|
||||
136
web_middleware.py
Normal file
136
web_middleware.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import time
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi import status
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from database import Database
|
||||
from datatypes import ObjectToken, AppConfig
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware for authorization"""
|
||||
def _is_request_protected(self, request: Request) -> bool:
|
||||
"""Returns True if request URL starts with one of private prefixes"""
|
||||
for pp in self.private_prefixes:
|
||||
if request.url.path.startswith(pp):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _fail_and_ban_if_needed(self, request: Request) -> None:
|
||||
"""Adds a fail and bans the IP if too much failures are recorded."""
|
||||
app_config: AppConfig = request.app.state.app_config
|
||||
database: Database = request.app.state.database
|
||||
current_time: float = time.time()
|
||||
ip: str = request.state.ip
|
||||
total_fails = database.fail_create(ip, current_time + 10.0)
|
||||
if total_fails >= app_config.fails_to_ban:
|
||||
await database.ban_create(
|
||||
ip,
|
||||
current_time + app_config.ban_duration,
|
||||
"Banned by AuthMiddleware"
|
||||
)
|
||||
database.fail_clear(ip)
|
||||
|
||||
def __init__(self, app, private_prefixes: list[str]):
|
||||
super().__init__(app)
|
||||
self.private_prefixes = private_prefixes
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# do not check authorization for public endpoints
|
||||
if not self._is_request_protected(request):
|
||||
return await call_next(request)
|
||||
token = None
|
||||
# look for the token in headers
|
||||
if "Authorization" in request.headers:
|
||||
v = request.headers["Authorization"]
|
||||
if not v.lower().startswith("bearer"):
|
||||
await self._fail_and_ban_if_needed(request)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "Invalid authorization scheme"}
|
||||
)
|
||||
parts = [p for p in v.split(" ") if p.strip()]
|
||||
if len(parts) != 2:
|
||||
await self._fail_and_ban_if_needed(request)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "No credentials provided in HTTP header"}
|
||||
)
|
||||
token = parts[1]
|
||||
# look for the token in query parameters
|
||||
elif "token" in request.query_params:
|
||||
token = request.query_params.get("token")
|
||||
if not token:
|
||||
await self._fail_and_ban_if_needed(request)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "The token is empry"}
|
||||
)
|
||||
# no token found
|
||||
else:
|
||||
await self._fail_and_ban_if_needed(request)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "No token provided"}
|
||||
)
|
||||
# try to authorize
|
||||
database: Database = request.app.state.database
|
||||
token_data: ObjectToken | None = await database.token_get(token)
|
||||
if not token_data:
|
||||
await self._fail_and_ban_if_needed(request)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "No such token"}
|
||||
)
|
||||
await database.token_set_last_access(token, time.time())
|
||||
request.state.token = token_data
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class BanCheckMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware for checking if the IP is banned."""
|
||||
def _is_request_protected(self, request: Request) -> bool:
|
||||
"""Returns True if request URL starts with one of private prefixes"""
|
||||
for pp in self.private_prefixes:
|
||||
if request.url.path.startswith(pp):
|
||||
return True
|
||||
return False
|
||||
|
||||
def __init__(self, app, private_prefixes: list[str]):
|
||||
super().__init__(app)
|
||||
self.private_prefixes = private_prefixes
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# do not check authorization for public endpoints
|
||||
if not self._is_request_protected(request):
|
||||
return await call_next(request)
|
||||
database: Database = request.app.state.database
|
||||
if database.ban_check(request.state.ip):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "You are temporarily banned"}
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class RealIpResolver(BaseHTTPMiddleware):
|
||||
"""Middleware that gets real IP address of the client."""
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# assign None by default
|
||||
request.state.ip = None
|
||||
# look for IP address
|
||||
x_forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if x_forwarded_for:
|
||||
request.state.ip = x_forwarded_for.split(",")[0].strip()
|
||||
else:
|
||||
x_real_ip = request.headers.get("x-real-ip")
|
||||
if x_real_ip:
|
||||
request.state.ip = x_real_ip
|
||||
else:
|
||||
request.state.ip = request.client.host if request.client else None
|
||||
return await call_next(request)
|
||||
70
web_routes.py
Normal file
70
web_routes.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""API Routes"""
|
||||
|
||||
import html
|
||||
import traceback
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mab import MatrixBot
|
||||
from database import Database
|
||||
from datatypes import *
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
#
|
||||
# MODELS
|
||||
#
|
||||
class ModelGetNotify(BaseModel):
|
||||
channel: str = Field(
|
||||
...,
|
||||
min_length=11,
|
||||
max_length=11,
|
||||
description="Notification channel (use `!info` bot command)"
|
||||
)
|
||||
text: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=1024,
|
||||
description="Text to use as notification body"
|
||||
)
|
||||
service: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=1024,
|
||||
description="Service name to use (ignored if token has name)"
|
||||
)
|
||||
|
||||
#
|
||||
# ENDPOINTS
|
||||
#
|
||||
@router.get("/notify")
|
||||
async def _get_notify(request: Request, params: ModelGetNotify = Depends()):
|
||||
# improve readability
|
||||
database: Database = request.app.state.database
|
||||
matrix: MatrixBot = request.app.state.matrix
|
||||
token: ObjectToken = request.state.token
|
||||
# check if room exists
|
||||
room = await database.room_get(params.channel)
|
||||
if room is None:
|
||||
raise HTTPException(status.HTTP_200_OK, "No such channel")
|
||||
# prepare service name
|
||||
service = token.name or params.service or "Unnamed"
|
||||
# prepare text of the notification
|
||||
text = f"<strong>Уведомление от службы <code>{html.escape(service)}</code></strong>"
|
||||
text += "<br>" * 2
|
||||
text += html.escape(params.text)
|
||||
# try to send the notification
|
||||
try:
|
||||
notification_id = await matrix.send_text(
|
||||
room.matrix_id,
|
||||
text,
|
||||
is_html=True
|
||||
)
|
||||
except:
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status.HTTP_200_OK, "Failed to send matrix message")
|
||||
# success
|
||||
return {"notification_id": notification_id}
|
||||
Reference in New Issue
Block a user