Basic database function implemented

This commit is contained in:
Nikita Tyukalov, ASUS, Linux
2026-08-25 21:13:27 +03:00
parent ff6656fdb0
commit ddc1c36c72
7 changed files with 360 additions and 63 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ __pycache__/
*.swo *.swo
*.vscode *.vscode
*.json *.json
*.sqlite

View File

@@ -8,22 +8,25 @@ from mab import MatrixBot
from nio import MatrixRoom, RoomMessageText from nio import MatrixRoom, RoomMessageText
import util import util
from database import Database
from datatypes import *
# #
# PRIVATE # PRIVATE
# #
_bot: MatrixBot = None # type: ignore _bot: MatrixBot = None # type: ignore
_db: Database = None # type: ignore
def _generate_help_message() -> str: def _generate_help_message() -> str:
"""Generates help message in HTML markup""" """Generates help message in HTML markup"""
result = "<b><i>Как использовать</i></b><br><ol>" result = "<strong><i>Как использовать</i></strong><br><ol>"
result += "<li>Используя <code>!auth</code>, создайте токен</li>" result += "<li>Используя <code>!auth</code>, создайте токен</li>"
result += "<li>Добавьте бота в комнату для уведомлений</li>" result += "<li>Добавьте бота в комнату для уведомлений</li>"
result += "<li>Выполните <code>!info</code> в комнате, чтобы узнать код канала уведомений</li>" result += "<li>Выполните <code>!info</code> в комнате, чтобы узнать код канала уведомений</li>"
result += "<li>Используя полученные токен и код канала, отправьте уведомление через веб-запрос</li>" result += "<li>Используя полученные токен и код канала, отправьте уведомление через веб-запрос</li>"
result += "</ol>" result += "</ol>"
result += "<br><br><b><i>Команды</i></b>" result += "<br><br><strong><i>Команды</i></strong>"
for aliases in _COMMANDS: for aliases in _COMMANDS:
result += f"<br><code>!{aliases[0]}</code> - <i>{html.escape(_COMMANDS[aliases][1])}</i>" result += f"<br><code>!{aliases[0]}</code> - <i>{html.escape(_COMMANDS[aliases][1])}</i>"
return result return result
@@ -51,7 +54,7 @@ async def _on_text(room: MatrixRoom, event: RoomMessageText) -> None:
if cb: if cb:
break break
if cb is None: if cb is None:
await _bot.send_text_to_room(room.room_id, "<b>Используйте <code>!help</code></b>") await _bot.send_text_to_room(room.room_id, "<strong>Используйте <code>!help</code></strong>")
return return
try: try:
await cb(room, parts[1:]) await cb(room, parts[1:])
@@ -67,93 +70,112 @@ async def _on_cmd_help(room: MatrixRoom, args: list[str]) -> None:
async def _on_cmd_info(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_info(room: MatrixRoom, args: list[str]) -> None:
"""!info""" """!info"""
response = "<b><i>Сведения о комнате</i></b><br>" # get room info (or add it)
response += f"<b>channel: <b><code>{html.escape(room.room_id)}</code>" room_info = await _db.room_get(room.room_id)
if room_info is None:
room_info = await _db.room_create(room.room_id)
# failure
if room_info is None:
await _bot.send_text_to_room(
room.room_id,
"<strong>Нет информации о комнате</strong>",
is_html=True
)
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 _bot.send_text_to_room(room.room_id, response, is_html=True)
async def _on_cmd_tokens(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_tokens(room: MatrixRoom, args: list[str]) -> None:
"""!info""" """!info"""
TEST_LIST = { # get all tokens and check if there are none
"t0K3n": { tokens = await _db.token_get_all()
"created": time.time() - 60, if not tokens:
"last_use": time.time() - 30, await _bot.send_text_to_room(
"total_uses": 534, room.room_id,
"service_name": "Резервное копирование", "<strong>Нет токенов, используйте <code>!auth</code></strong>",
}, is_html=True
"T35t_t0KeN": { )
"created": time.time() - 12341, return
"last_use": time.time() - 123, # create the list of tokens
"total_uses": 342, response = "<strong><i>Список токенов</i></strong>"
"service_name": None, for token in tokens:
}, response += f"<br><strong><code>{html.escape(token.code)}</code></strong><br><ul>"
} if token.name is not None:
response += f"<li><strong>Имя службы:</strong> <code>{token.name}</code></li>"
response = "<b><i>Список токенов</i></b>"
for token in TEST_LIST:
data = TEST_LIST[token]
response += f"<br><b><code>{html.escape(token)}</code></b><br><ul>"
if data["service_name"] is not None:
response += f"<li><b>Имя службы:</b> <code>{data["service_name"]}</code></li>"
else: else:
response += f"<li><b>Имя службы:</b> <i>указывается в запросе</i></li>" response += f"<li><strong>Имя службы:</strong> <i>указывается в запросе</i></li>"
response += f"<li><b>Создан:</b> <i>{util.date_to_text(data["created"])}</i></li>" response += f"<li><strong>Создан:</strong> <i>{util.date_to_text(token.created_at)}</i></li>"
response += f"<li><b>Последнее использование:</b> <i>{util.date_to_text(data["last_use"])}</i></li>" response += f"<li><strong>Последнее использование:</strong> <i>{util.date_to_text(token.last_access_at)}</i></li>"
response += f"<li><b>Всего использований:</b> <i>{data["total_uses"]}</i></li>"
response += "</ul>" response += "</ul>"
# respond
await _bot.send_text_to_room(room.room_id, response, is_html=True) await _bot.send_text_to_room(room.room_id, response, is_html=True)
async def _on_cmd_auth(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_auth(room: MatrixRoom, args: list[str]) -> None:
"""!auth""" """!auth"""
new_token = "inr3i4evfds" # create new token
new_token_name = " ".join(args) if args else None token = await _db.token_create()
if type(new_token_name) is str and not new_token_name.strip(): # set the name if it is provided
new_token_name = None if args:
token.name = " ".join(args)
response = "<b>Создан новый токен</b>" await _db.token_set_name(token.code, token.name)
response += f"<br><b>Код:</b> <code>{new_token}</code>" # create the response
response += f"<br><b>Имя сервиса:</b> " response = "<strong>Создан новый токен</strong>"
if new_token_name: response += f"<br><strong>Код:</strong> <code>{token.code}</code>"
response += f"<code>{html.escape(new_token_name)}</code>" response += f"<br><strong>Имя сервиса:</strong> "
if token.name:
response += f"<code>{html.escape(token.name)}</code>"
else: else:
response += "указывается в запросе" response += "указывается в запросе"
# respond
await _bot.send_text_to_room(room.room_id, response, is_html=True) await _bot.send_text_to_room(room.room_id, response, is_html=True)
async def _on_cmd_deauth(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_deauth(room: MatrixRoom, args: list[str]) -> None:
"""!deauth""" """!deauth"""
# no token provided
if len(args) != 1: if len(args) != 1:
await _bot.send_text_to_room(room.room_id, "<b>Укажите токен, который надо удалить (должен быть ровно один аргумент)</b>", is_html=True) await _bot.send_text_to_room(room.room_id, "<strong>Укажите токен, который надо удалить (должен быть ровно один аргумент)</strong>", is_html=True)
return return
token = args[0] token = args[0]
# TODO # check if token does not exist
response = f"<b>Удалён токен <code>{token}</code></b>" if await _db.token_get(token) is None:
await _bot.send_text_to_room(room.room_id, "<strong>Токен не найден</strong>", is_html=True)
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 _bot.send_text_to_room(room.room_id, response, is_html=True)
async def _on_cmd_name(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_name(room: MatrixRoom, args: list[str]) -> None:
"""!name""" """!name"""
# check arguments
if len(args) < 1: if len(args) < 1:
error = "<b>Требуется указать как минимум токен. Если хотите убрать имя, то имя указывать не надо. Если имя нужно назначить или изменить, то после токена укажите новое имя.</b>" error = "<strong>Требуется указать как минимум токен. Если хотите убрать имя, то имя указывать не надо. Если имя нужно назначить или изменить, то после токена укажите новое имя.</strong>"
await _bot.send_text_to_room(room.room_id, error, is_html=True) await _bot.send_text_to_room(room.room_id, error, is_html=True)
return return
token = args[0] token = args[0]
new_name = " ".join(args[1:]) new_name = " ".join(args[1:])
if not new_name.strip(): if not new_name.strip():
new_name = None new_name = None
# check if token exists
# TODO if await _db.token_get(token) is None:
await _bot.send_text_to_room(room.room_id, "<strong>Токен не существует</strong>", is_html=True)
return
# set new name
await _db.token_set_name(token, new_name)
# prepare the response
if new_name: if new_name:
response = f"<b>Новое имя для токена <code>{token}</code>: <code>{new_name}</code></b>" response = f"<strong>Новое имя для токена <code>{token}</code>: <code>{new_name}</code></strong>"
else: else:
response = f"<b>Удалено имя для токена <code>{token}</code></b>" response = f"<strong>Удалено имя для токена <code>{token}</code></strong>"
# respond
await _bot.send_text_to_room(room.room_id, response, is_html=True) await _bot.send_text_to_room(room.room_id, response, is_html=True)
async def _on_cmd_leave(room: MatrixRoom, args: list[str]) -> None: async def _on_cmd_leave(room: MatrixRoom, args: list[str]) -> None:
"""!leave""" """!leave"""
await _bot.send_text_to_room(room.room_id, "<b>Команда не реализована</b>", is_html=True) await _bot.send_text_to_room(room.room_id, "<strong>Команда не реализована</strong>", is_html=True)
_COMMANDS = { _COMMANDS = {
("help", "h", "?"): (_on_cmd_help, "Получить справку"), ("help", "h", "?"): (_on_cmd_help, "Получить справку"),
@@ -168,8 +190,9 @@ _COMMANDS = {
# #
# PUBLIC # PUBLIC
# #
def setup(bot: MatrixBot) -> None: def setup(bot: MatrixBot, db: Database) -> None:
"""Setup the callbacks""" """Setup the callbacks"""
global _bot global _bot, _db
_db = db
_bot = bot _bot = bot
_bot.add_event_callback(_on_text, RoomMessageText) # type: ignore _bot.add_event_callback(_on_text, RoomMessageText) # type: ignore

View File

@@ -0,0 +1,249 @@
"""This module implements database operations."""
import logging
import traceback
import time
import os
from pathlib import Path
from typing import Any
import aiosqlite
from aiosqlite import Connection, Row
from datatypes import *
from util import get_hash
class Database:
#
# PRIVATE
#
@staticmethod
async def _setup_tables(conn: Connection) -> None:
SETUP_SQL_SCRIPT = """
CREATE TABLE IF NOT EXISTS rooms (
code TEXT PRIMARY KEY,
matrix_id TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS tokens (
code TEXT PRIMARY KEY,
name TEXT,
created_at REAL,
last_access_at REAL
);
CREATE TABLE IF NOT EXISTS fails (
ip TEXT PRIMARY KEY,
timestamp INTEGER,
score INTEGER
);
CREATE TABLE IF NOT EXISTS bans (
ip TEXT PRIMARY KEY,
expires_at INTEGER,
reason TEXT
);
"""
await conn.executescript(SETUP_SQL_SCRIPT)
await conn.commit()
@staticmethod
async def _select_by_anded_kwargs(conn: Connection, table_name: str, **kwargs) -> list[dict[str, Any]]:
# prepare keys and values
keys = tuple(k for k in kwargs)
values = tuple(kwargs[k] for k in keys)
# prepare the statement
statement = f"SELECT * FROM {table_name}"
# add `WHERE` part if there kwargs
if keys:
statement += " WHERE "
statement += " AND ".join([f"{k}=?" for k in keys])
else:
values = None
# execute and enumerate
result = []
async with conn.execute(statement, values) as cursor:
async for row in cursor:
entry = {}
for k in row.keys():
entry[k] = row[k]
result.append(entry)
return result
#
# PUBLIC
#
def __init__(self, path: Path):
self._path = path
self._logger = logging.getLogger("database")
self._connection: Connection | None = None
async def connect(self) -> bool:
"""Connect to the database. Returns False on failure."""
if self._connection is not None:
return False
try:
self._connection = await aiosqlite.connect(self._path)
self._connection.row_factory = Row
await self._setup_tables(self._connection)
except:
self._logger.error(traceback.format_exc())
return False
return True
async def disconnect(self) -> None:
"""Disconnect from the database."""
if self._connection is None:
return
await self._connection.close()
self._connection = None
async def room_create(self, matrix_id: str) -> ObjectRoom | None:
"""Create a room with specified matrix_id. Returns None if the room exists."""
if self._connection is None:
raise RuntimeError("Not connected to the database")
# room exists
if await self.room_get(matrix_id) is not None:
return None
# create the code
code = None
string_to_hash = matrix_id
while code is None or (await self.room_get(code)) is not None:
code = get_hash(string_to_hash)[-10:]
string_to_hash += "A"
# code must always start with q
code = f"q{code}"
# add
try:
statement = "INSERT INTO rooms (code, matrix_id) VALUES (?, ?)"
await self._connection.execute(statement, (code, matrix_id))
await self._connection.commit()
self._logger.info(f"Added new room with code {code}")
except:
self._logger.error(traceback.format_exc())
return None
# return the added object
return ObjectRoom(
code=code,
matrix_id=matrix_id
)
async def room_get_all(self) -> list[ObjectRoom]:
"""Get room by code."""
if self._connection is None:
raise RuntimeError("Not connected to the database")
try:
rooms = await self._select_by_anded_kwargs(
self._connection,
"rooms"
)
result = []
for r in rooms:
result.append(ObjectRoom(
code=r["code"],
matrix_id=r["matrix_id"]
))
return result
except:
self._logger.error(traceback.format_exc())
return []
async def room_get(self, identifier: str) -> ObjectRoom | None:
"""Get room by identifier (either `code` or `matrix_id`)."""
if self._connection is None:
raise RuntimeError("Not connected to the database")
try:
kwargs = {}
kwargs["code" if identifier[0] == "q" else "matrix_id"] = identifier
rooms = await self._select_by_anded_kwargs(
self._connection,
"rooms",
**kwargs
)
if not rooms:
self._logger.debug(f"Room `{identifier}` is not found")
return None
return ObjectRoom(**rooms[0])
except:
self._logger.error(traceback.format_exc())
return None
async def token_create(self) -> 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))
create_time = time.time()
statement = """
INSERT INTO tokens (code, name, created_at, last_access_at)
VALUES (?, ?, ?, ?)
"""
result = ObjectToken(
code=code,
name=None,
created_at=create_time,
last_access_at=create_time
)
await self._connection.execute(
statement,
(result.code, result.name, result.created_at, result.last_access_at)
)
await self._connection.commit()
return result
async def token_delete(self, code: str) -> None:
"""Delete a token."""
if self._connection is None:
raise RuntimeError("Not connected to the database")
try:
await self._connection.execute("DELETE FROM tokens WHERE code=?", (code,))
await self._connection.commit()
except:
self._logger.error(traceback.format_exc())
async def token_get(self, code: str) -> ObjectToken | None:
"""Get token by its code."""
if self._connection is None:
raise RuntimeError("Not connected to the database")
try:
result = await self._select_by_anded_kwargs(
self._connection,
"tokens",
code=code
)
if not result:
return None
result = result[0]
return ObjectToken(**result)
except:
self._logger.error(traceback.format_exc())
return None
async def token_get_all(self) -> list[ObjectToken]:
"""Get list of all tokens."""
if self._connection is None:
raise RuntimeError("Not connected to the database")
try:
result = await self._select_by_anded_kwargs(
self._connection,
"tokens"
)
result = [ObjectToken(**r) for r in result]
return result
except:
self._logger.error(traceback.format_exc())
return []
async def token_set_name(self, code: str, name: str | None) -> None:
"""Set name for the token."""
if self._connection is None:
raise RuntimeError("Not connected to the database")
try:
statement = "UPDATE tokens SET name=? WHERE code=?"
await self._connection.execute(statement, (name, code))
await self._connection.commit()
except:
self._logger.error(traceback.format_exc())

View File

@@ -11,5 +11,14 @@ class AppConfig:
matrix_user: str matrix_user: str
store_dir: str store_dir: str
class MessageType(Enum): @dataclass
TEXT = "m.text" class ObjectRoom:
code: str
matrix_id: str
@dataclass
class ObjectToken:
code: str
name: str | None
created_at: float
last_access_at: float

10
main.py
View File

@@ -10,6 +10,7 @@ from pathlib import Path
from mab import MatrixBot, MatrixBotConfig from mab import MatrixBot, MatrixBotConfig
from datatypes import AppConfig from datatypes import AppConfig
import database
import config import config
import util import util
import bot_callbacks import bot_callbacks
@@ -42,9 +43,15 @@ async def main() -> None:
storage_directory=Path(cfg.store_dir) storage_directory=Path(cfg.store_dir)
) )
bot = MatrixBot(matrix_bot_config) bot = MatrixBot(matrix_bot_config)
bot_callbacks.setup(bot) # setup the database
db = database.Database(Path("database.sqlite"))
# setup the callbacks
bot_callbacks.setup(bot, db)
# start the app # start the app
if not await db.connect():
util.log_error("Can't connect to the database!")
return
bot.start() bot.start()
# wait for Ctrl+C # wait for Ctrl+C
@@ -52,6 +59,7 @@ async def main() -> None:
# stop the app # stop the app
await bot.stop() await bot.stop()
await db.disconnect()
if __name__ == "__main__": if __name__ == "__main__":
try: try:

View File

@@ -4,6 +4,7 @@ aiohappyeyeballs==2.7.1
aiohttp==3.14.3 aiohttp==3.14.3
aiohttp_socks==0.12.0 aiohttp_socks==0.12.0
aiosignal==1.4.0 aiosignal==1.4.0
aiosqlite==0.22.1
atomicwrites==1.4.1 atomicwrites==1.4.1
attrs==26.1.0 attrs==26.1.0
build==1.5.0 build==1.5.0

10
util.py
View File

@@ -8,6 +8,7 @@ import os
import logging import logging
import traceback import traceback
import datetime import datetime
import hashlib
import aioconsole import aioconsole
@@ -75,7 +76,7 @@ async def get_password() -> str | None:
return None return None
return await ainput("Matrix password: ") return await ainput("Matrix password: ")
def date_to_text(date: datetime.datetime, dow: bool = True, seconds: bool = True) -> str: def date_to_text(date: datetime.datetime | float, dow: bool = True, seconds: bool = True) -> str:
''' Returns date as formatted string. ''' Returns date as formatted string.
Day of week can be added. Day of week can be added.
Seconds can be added. Seconds can be added.
@@ -102,9 +103,14 @@ def date_to_text(date: datetime.datetime, dow: bool = True, seconds: bool = True
('Sat', 'Сб'), ('Sat', 'Сб'),
('Sun', 'Вс') ('Sun', 'Вс')
] ]
result = date.strftime(format_string) result = date.strftime(format_string) # type: ignore
for en, ru in translate_map: for en, ru in translate_map:
if en in result: if en in result:
result = result.replace(en, ru) result = result.replace(en, ru)
break break
return result return result
def get_hash(data: bytes | str) -> str:
if type(data) is str:
data = data.encode(errors="ignore")
return hashlib.sha256(data).hexdigest() # type: ignore