4 Commits
0.1 ... 0.4

Author SHA1 Message Date
a178183251 Fixed Dockerfile, improved README 2026-09-13 00:20:56 +03:00
4194f3da48 mab update, !short command, last_access fix
- Updated mab to v0.5.0
- Added `!short` command to add named tokens
- Fixed `last_access_at` field not being updated
2026-09-13 00:12:00 +03:00
4a947cec3d Updated mab version; added filters support 2026-09-02 18:34:56 +03:00
b28b7c583d Updated mab version; improved README 2026-09-02 14:51:27 +03:00
8 changed files with 157 additions and 112 deletions

View File

@@ -1,6 +1,6 @@
FROM python:3.12-slim FROM python:3.12-slim
RUN apt-get update && apt-get install --no-install-recommends -y libolm3 libolm-dev git && rm -rf /var/lib/apt/lists/* 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 WORKDIR /usr/src/app

View File

@@ -6,24 +6,24 @@
## Для запуска рекомендуется `docker` ## Для запуска рекомендуется `docker`
1. Соберите образ: 1. [Скачайте образ](https://git.tyukalov.su/nikita/-/packages/container/2026-matrix-csonac) (измените версию на нужную вам):
```bash ```bash
sudo docker build -t csonac . sudo docker pull git.tyukalov.su/nikita/2026-matrix-csonac:0.4
``` ```
2. Выполните первый запуск в интерактивном режиме 2. Выполните первый запуск в интерактивном режиме
```bash ```bash
sudo mkdir runtime sudo mkdir runtime
sudo chown 1000:1000 runtime sudo chown 1000:1000 runtime
sudo docker run -v ./runtime:/runtime -ti csonac:latest sudo docker run -v ./runtime:/runtime -ti git.tyukalov.su/nikita/2026-matrix-csonac:0.4
``` ```
3. В папке `runtime` появится файл `config.json`. Внесите туда нужные настройки 3. В папке `runtime` появится файл `config.json`. Внесите туда нужные настройки
4. Запустите ещё раз в интерактивном режиме, чтобы авторизоваться 4. Запустите ещё раз в интерактивном режиме, чтобы авторизоваться
```bash ```bash
sudo docker run -v ./runtime:/runtime -ti csonac:latest sudo docker run -v ./runtime:/runtime -ti git.tyukalov.su/nikita/2026-matrix-csonac:0.4
``` ```
5. Дальше можно запускать контейнер в фоне 5. Дальше можно запускать контейнер в фоне
```bash ```bash
sudo docker run -p 127.0.0.1:4980:4980 -v ./runtime:/runtime --detach csonac:latest 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 compose`.
@@ -66,6 +66,7 @@ python main.py
- `!ban <IP> <SECONDS> [REASON]` - забанить указанный IP на указанное число секунд (можно указать причину) - `!ban <IP> <SECONDS> [REASON]` - забанить указанный IP на указанное число секунд (можно указать причину)
- `!unban <IP>` - разбанить указанный IP адрес - `!unban <IP>` - разбанить указанный IP адрес
- `!bans` - получить список забаненных IP адресов - `!bans` - получить список забаненных IP адресов
- `!short <TOKEN>` - создать токен с указанным именем
## Как работает веб-сервер ## Как работает веб-сервер

View File

@@ -4,8 +4,7 @@ import html
import time import time
import traceback import traceback
from mab import MatrixBot from mab import * # type: ignore
from nio import MatrixRoom, RoomMessageText
import util import util
from database import Database from database import Database
@@ -14,9 +13,21 @@ from datatypes import *
# #
# PRIVATE # PRIVATE
# #
_bot: MatrixBot = None # type: ignore
_db: Database = 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: def _generate_help_message() -> str:
"""Generates help message in HTML markup""" """Generates help message in HTML markup"""
result = "<strong><i>Как использовать</i></strong><br><ol>" result = "<strong><i>Как использовать</i></strong><br><ol>"
@@ -27,75 +38,42 @@ def _generate_help_message() -> str:
result += "</ol>" result += "</ol>"
result += "<br><br><strong><i>Команды</i></strong>" result += "<br><br><strong><i>Команды</i></strong>"
for aliases in _COMMANDS: for key in _COMMANDS:
result += f"<br><code>!{aliases[0]}</code> - <i>{html.escape(_COMMANDS[aliases][1])}</i>" result += f"<br><code>!{key}</code> - <i>{html.escape(_COMMANDS[key])}</i>"
return result 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 # COMMAND HANDLERS
# #
async def _on_cmd_help(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_help(ctx: EventContext) -> None:
"""!help""" """!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""" """!info"""
# get room info (or add it) # 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: 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 # failure
if room_info is None: if room_info is None:
await _bot.send_text_to_room( await ctx.bot.send_text(
room.room_id, ctx.room, "<strong>Нет информации о комнате</strong>"
"<strong>Нет информации о комнате</strong>",
is_html=True
) )
return return
# respond # respond
response = "<strong><i>Сведения о комнате</i></strong><br>" response = "<strong><i>Сведения о комнате</i></strong><br>"
response += f"<strong>Канал:</strong> <code>{html.escape(room_info.code)}</code>" 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""" """!info"""
# get all tokens and check if there are none # get all tokens and check if there are none
tokens = await _db.token_get_all() tokens = await _db.token_get_all()
if not tokens: if not tokens:
await _bot.send_text_to_room( await ctx.bot.send_text(
room.room_id, ctx.room,
"<strong>Нет токенов, используйте <code>!auth</code></strong>", "<strong>Нет токенов, используйте <code>!auth</code></strong>"
is_html=True
) )
return return
# create the list of tokens # 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 += f"<li><strong>Последнее использование:</strong> <i>{util.date_to_text(token.last_access_at)}</i></li>"
response += "</ul>" response += "</ul>"
# respond # 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""" """!auth"""
# create new token # create new token
token = await _db.token_create() token = await _db.token_create()
# set the name if it is provided # set the name if it is provided
if args: if ctx[CTX_CMD_ARGS]:
token.name = " ".join(args) token.name = " ".join(ctx[CTX_CMD_ARGS])
await _db.token_set_name(token.code, token.name) await _db.token_set_name(token.code, token.name)
# create the response # create the response
response = "<strong>Создан новый токен</strong>" response = "<strong>Создан новый токен</strong>"
@@ -129,39 +107,39 @@ async def _on_cmd_auth(room: MatrixRoom, args: list[str]) -> None:
else: else:
response += "указывается в запросе" response += "указывается в запросе"
# respond # 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""" """!deauth"""
# no token provided # no token provided
if len(args) != 1: if len(ctx[CTX_CMD_ARGS]) != 1:
await _bot.send_text_to_room(room.room_id, "<strong>Укажите токен, который надо удалить (должен быть ровно один аргумент)</strong>", is_html=True) await ctx.bot.send_text(ctx.room, "<strong>Укажите токен, который надо удалить (должен быть ровно один аргумент)</strong>")
return return
token = args[0] token = ctx[CTX_CMD_ARGS][0]
# check if token does not exist # check if token does not exist
if await _db.token_get(token) is None: 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 return
# remove the token # remove the token
await _db.token_delete(token) await _db.token_delete(token)
# respond # respond
response = f"<strong>Удалён токен <code>{token}</code></strong>" 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""" """!name"""
# check arguments # check arguments
if len(args) < 1: if len(ctx[CTX_CMD_ARGS]) < 1:
error = "<strong>Требуется указать как минимум токен. Если хотите убрать имя, то имя указывать не надо. Если имя нужно назначить или изменить, то после токена укажите новое имя.</strong>" 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 return
token = args[0] token = ctx[CTX_CMD_ARGS][0]
new_name = " ".join(args[1:]) new_name = " ".join(ctx[CTX_CMD_ARGS][1:])
if not new_name.strip(): if not new_name.strip():
new_name = None new_name = None
# check if token exists # check if token exists
if await _db.token_get(token) is None: 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 return
# set new name # set new name
await _db.token_set_name(token, 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: else:
response = f"<strong>Удалено имя для токена <code>{token}</code></strong>" response = f"<strong>Удалено имя для токена <code>{token}</code></strong>"
# respond # 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""" """!ban"""
# check arguments # check arguments
if len(args) < 2: if len(ctx[CTX_CMD_ARGS]) < 2:
error = "<strong>Формат: <code>!ban &lt;IP&gt; &lt;SECONDS&gt; [REASON]</code></strong>" error = "<strong>Формат: <code>!ban &lt;IP&gt; &lt;SECONDS&gt; [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 return
# get args # get event.command_args
try: try:
ip = args[0] ip = ctx[CTX_CMD_ARGS][0]
duration = float(args[1]) duration = float(ctx[CTX_CMD_ARGS][1])
reason = " ".join(args[2:]) if args[2:] else "Manual ban" reason = " ".join(ctx[CTX_CMD_ARGS][2:]) if ctx[CTX_CMD_ARGS][2:] else "Manual ban"
except: except:
error = "<strong>Возникла ошибка. Наверняка неправильно указаны секунды.</strong>" 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 return
# ban # ban
try: try:
await _db.ban_create(ip, time.time() + duration, reason) 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: 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""" """!unban"""
# check arguments # check arguments
if len(args) != 1: if len(ctx[CTX_CMD_ARGS]) != 1:
error = "<strong>Формат: <code>!ban &lt;IP&gt;</code></strong>" error = "<strong>Формат: <code>!ban &lt;IP&gt;</code></strong>"
await _bot.send_text_to_room(room.room_id, error, is_html=True) await ctx.bot.send_text(ctx.room, error)
return return
# unban # unban
try: try:
await _db.ban_delete(args[0]) await _db.ban_delete(ctx[CTX_CMD_ARGS][0])
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: 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""" """!bans"""
# list # list
bans = _db.ban_get_all() bans = _db.ban_get_all()
if not bans: 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 return
# create the response # create the response
result = "<strong>Список заблокированных IP</strong><br><ul>" 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 += f"<li><strong>Причина:</strong> <code>{html.escape(ban["reason"])}</code></li>"
result += "</ul></li>" result += "</ul></li>"
result += "</ul>" 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 &lt;token&gt;</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 # PUBLIC
# #
def setup(bot: MatrixBot, db: Database) -> None: def setup(bot: MatrixBot, db: Database) -> None:
"""Setup the callbacks""" """Setup the callbacks"""
global _bot, _db global _db
_db = db _db = db
_bot = bot bot.add_callback(
_bot.add_event_callback(_on_text, RoomMessageText) # type: ignore ~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
)

View File

@@ -252,11 +252,17 @@ class Database:
return None return None
async def token_create(self) -> ObjectToken: async def token_create(self, code: str | None = None) -> ObjectToken:
"""Create a token.""" """Create a token."""
if self._connection is None: if self._connection is None:
raise RuntimeError("Not connected to the database") 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() create_time = time.time()
statement = """ statement = """
INSERT INTO tokens (code, name, created_at, last_access_at) INSERT INTO tokens (code, name, created_at, last_access_at)
@@ -329,6 +335,17 @@ class Database:
except: except:
self._logger.error(traceback.format_exc()) 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: async def ban_create(self, ip: str, expires_at: float, reason: str) -> None:
"""Save information about banned IP address. Replaces existing IPs.""" """Save information about banned IP address. Replaces existing IPs."""

View File

@@ -55,7 +55,7 @@ async def main() -> None:
if not await db.connect(): if not await db.connect():
util.log_error("Can't connect to the database!") util.log_error("Can't connect to the database!")
return return
bot.start() await bot.start()
await web_server.start() await web_server.start()
# wait for Ctrl+C # wait for Ctrl+C

View File

@@ -34,7 +34,7 @@ idna==3.19
Jinja2==3.1.6 Jinja2==3.1.6
jsonschema==4.26.0 jsonschema==4.26.0
jsonschema-specifications==2025.9.1 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 markdown-it-py==4.2.0
MarkupSafe==3.0.3 MarkupSafe==3.0.3
matrix-nio==0.26.0 matrix-nio==0.26.0
@@ -42,6 +42,7 @@ mdurl==0.1.2
multidict==6.7.1 multidict==6.7.1
packaging==26.3 packaging==26.3
peewee==3.19.0 peewee==3.19.0
pillow==12.3.0
propcache==0.5.2 propcache==0.5.2
pycryptodome==3.23.0 pycryptodome==3.23.0
pydantic==2.13.5 pydantic==2.13.5
@@ -51,6 +52,7 @@ pydantic_core==2.46.5
Pygments==2.21.0 Pygments==2.21.0
pyproject_hooks==1.2.0 pyproject_hooks==1.2.0
python-dotenv==1.2.3 python-dotenv==1.2.3
python-magic==0.4.27
python-multipart==0.0.32 python-multipart==0.0.32
python-socks==3.0.0 python-socks==3.0.0
PyYAML==6.0.3 PyYAML==6.0.3

View File

@@ -84,6 +84,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "No such token"} content={"detail": "No such token"}
) )
await database.token_set_last_access(token, time.time())
request.state.token = token_data request.state.token = token_data
return await call_next(request) return await call_next(request)

View File

@@ -58,7 +58,7 @@ async def _get_notify(request: Request, params: ModelGetNotify = Depends()):
text += html.escape(params.text) text += html.escape(params.text)
# try to send the notification # try to send the notification
try: try:
notification_id = await matrix.send_text_to_room( notification_id = await matrix.send_text(
room.matrix_id, room.matrix_id,
text, text,
is_html=True is_html=True