Compare commits

...

23 commits

Author SHA1 Message Date
3a83e55343 chore: change status gen charts if users not exists db
Some checks are pending
Create and publish a Docker image / build-and-push-image (push) Waiting to run
2025-05-18 11:24:38 +03:00
c0e9152f4f chore: delete garbadge line 2025-05-17 18:24:45 +03:00
d3fcc06b68 fix: fix position lang 2025-05-17 18:22:46 +03:00
ae695d6f48 chore: optimization code 2025-05-17 12:34:10 +03:00
f92e24816d chore: wrap lines to 79 chars for PEP8 compliance and improved readability, no logic changes 2025-05-17 12:29:50 +03:00
6f415c2378 fix: fixed a bug with the message not changing 2025-05-17 12:26:29 +03:00
ff8d23330b chore: added localization files 2025-05-17 11:50:26 +03:00
8569a0a924 feat: bot customization 2025-05-17 11:50:08 +03:00
01b8d01285 chore: made localization in the inline command to get the course of the command 2025-05-17 11:49:57 +03:00
5fe0a5ae3a chore: added a new field for period customization 2025-05-17 00:35:27 +03:00
722b79b523 chore: Added translation for the start command, code link made as a button 2025-05-17 00:34:56 +03:00
6bf8b4c049 chore: a class is written to get and work with localization 2025-05-17 00:34:28 +03:00
fd6f0b699e chore: charts are now as a picture natively, not a link 2025-05-17 00:34:05 +03:00
9b3ed607b0 chore: fix import 2025-05-15 15:06:53 +03:00
1ae3ec9aec chore: the link in the post has been replaced with a button 2025-05-15 15:06:40 +03:00
210c60552f chore: fix import 2025-05-15 15:03:21 +03:00
757c74f779 chore: the database is now not deleted when the container is stopped 2025-05-15 14:55:08 +03:00
b671981bb3 chore: added new updates and moved the database instance to another file 2025-05-15 14:54:39 +03:00
b5d8edcc38 chore: added a new field to customize the schedule period 2025-05-15 14:53:52 +03:00
b4eba633b9 chore: the commands have been organized into files 2025-05-15 10:53:55 +03:00
4aa4742cd2 feat: add sqlite database 2025-05-15 01:06:03 +03:00
e170b5bff2 chore: set return type is none for start command 2025-05-15 00:34:37 +03:00
86085e2feb chore: made line breaks 2025-04-26 14:04:27 +03:00
14 changed files with 875 additions and 143 deletions

13
bot.py Normal file
View file

@ -0,0 +1,13 @@
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
import yaml
from database.server import Database
db = Database('/data/shirino.db')
config = yaml.safe_load(open('../config.yaml', 'r', encoding='utf-8'))
bot = Bot(
token=config['telegram_token'],
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)

117
commands/currency.py Normal file
View file

@ -0,0 +1,117 @@
import hashlib
from aiogram import types, Router
from aiogram.filters import Command
from bot import bot, db
from functions.convert import Converter
from functions.create_chart import create_chart
from utils.format_number import format_number
from utils.inline_query import reply
from i18n.localization import I18n
router = Router()
i18n = I18n()
@router.inline_query()
async def currency(query: types.InlineQuery) -> None:
text = query.query.lower()
args = text.split()
result_id = hashlib.md5(text.encode()).hexdigest()
get_bot = await bot.get_me()
data = await db.fetch(
'SELECT lang, chart, chart_period '
'FROM users WHERE user_id = ?',
query.from_user.id
)
lang = data.get('lang')
locale = i18n.get_locale(lang)
currency_example = locale["currency_example"].format(
bot_username=get_bot.username
)
if len(args) < 2:
await reply(
result_id,
[(locale["error_not_enough_args"], currency_example, None, None)],
query
)
return
conv = Converter()
from_currency, conv_currency = '', ''
if len(args) == 3:
try:
conv.amount = float(args[0].replace(',', '.'))
if conv.amount < 0:
await reply(
result_id,
[(locale["error_negative_amount"], None, None)],
query
)
return
except ValueError:
await reply(
result_id,
[(locale["error_invalid_number"], currency_example, None, None)],
query
)
return
from_currency = args[1]
conv_currency = args[2]
elif len(args) == 2:
from_currency = args[0]
conv_currency = args[1]
else:
await reply(
result_id,
[(locale["error_unknown_currency"], None, None)],
query,
)
return
conv.from_currency = from_currency.upper()
conv.conv_currency = conv_currency.upper()
try:
await conv.convert()
except RuntimeError:
await reply(
result_id,
[(locale["error_currency_rate"], None, None)],
query
)
return
chart = None
if bool(data.get('chart', 1)):
chart = await create_chart(
from_currency,
conv_currency,
data.get('chart_period', 'month')
)
message = (
f"{format_number(conv.amount)} {conv.from_currency} = "
f"{conv.conv_amount} {conv.conv_currency}"
)
results = [(message, None, None)]
if chart:
results.insert(
0,
(
message,
None,
chart
)
)
await reply(result_id, results, query)

347
commands/settings.py Normal file
View file

@ -0,0 +1,347 @@
from typing import Optional, Tuple, List
from aiogram import Router, types
from aiogram.filters import Command
from aiogram.types import (
InlineKeyboardMarkup,
InlineKeyboardButton,
CallbackQuery,
)
import json
from bot import db
from i18n.localization import I18n
router = Router()
i18n = I18n()
LangOption = Tuple[str, str]
PeriodOption = Tuple[str, str]
LANG_OPTIONS: List[LangOption] = [
("en", "English"),
("ru", "Русский"),
]
PERIOD_OPTIONS: List[PeriodOption] = [
("week", "week"),
("month", "month"),
("quarter", "quarter"),
("year", "year"),
]
async def get_user_locale(user_id: int) -> dict:
data = await db.fetch(
'SELECT lang FROM users WHERE user_id = $1', user_id
)
if not data:
await db.insert(
'INSERT INTO users (user_id) VALUES (?)', user_id
)
data = await db.fetch(
'SELECT lang FROM users WHERE user_id = $1', user_id
)
return i18n.get_locale(data.get('lang', 'en'))
def build_options_keyboard(
options: List[Tuple[str, str]],
current_value: str,
callback_prefix: str,
locale: dict,
buttons_per_row: int = 2,
back_callback: Optional[str] = None,
) -> InlineKeyboardMarkup:
buttons: List[List[InlineKeyboardButton]] = []
row: List[InlineKeyboardButton] = []
for code, label_key in options:
label = locale.get(label_key, label_key)
text = (
f"[X] {label}" if code == current_value else label
)
row.append(
InlineKeyboardButton(
text=text, callback_data=f"{callback_prefix}_{code}"
)
)
if len(row) == buttons_per_row:
buttons.append(row)
row = []
if row:
buttons.append(row)
if back_callback:
buttons.append(
[
InlineKeyboardButton(
text=locale.get("back"),
callback_data=back_callback,
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_chart_toggle_keyboard(
chart_enabled: bool, locale: dict
) -> InlineKeyboardMarkup:
toggle_text = (
locale.get("chart_disable")
if chart_enabled
else locale.get("chart_enable")
)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=toggle_text, callback_data="chart_toggle"
),
InlineKeyboardButton(
text=locale.get("chart_period"),
callback_data="chart_period",
),
],
[
InlineKeyboardButton(
text=locale.get("back"),
callback_data="back_to_settings",
),
],
]
)
def markup_to_json(
markup: Optional[InlineKeyboardMarkup],
) -> Optional[str]:
if markup is None:
return None
return json.dumps(markup.model_dump(), sort_keys=True)
async def safe_edit_message_text(
callback: CallbackQuery,
new_text: str,
new_reply_markup: Optional[InlineKeyboardMarkup] = None,
parse_mode: Optional[str] = None,
) -> None:
message = callback.message
current_text = (message.text or "").strip()
new_text_clean = new_text.strip()
is_text_same = current_text == new_text_clean
is_markup_same = (
markup_to_json(message.reply_markup)
== markup_to_json(new_reply_markup)
)
if is_text_same and is_markup_same:
await callback.answer()
return
await message.edit_text(
new_text, reply_markup=new_reply_markup, parse_mode=parse_mode
)
await callback.answer()
@router.message(Command("settings"))
async def settings_handler(message: types.Message):
locale = await get_user_locale(message.from_user.id)
settings_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=locale.get("setting_chart"),
callback_data="setting_chart",
),
InlineKeyboardButton(
text=locale.get("setting_lang"),
callback_data="setting_lang",
),
],
]
)
await message.answer(
locale.get("settings_title"), reply_markup=settings_keyboard
)
@router.callback_query(lambda c: c.data == "setting_lang")
async def show_language_menu(callback: CallbackQuery):
locale = await get_user_locale(callback.from_user.id)
data = await db.fetch(
'SELECT lang FROM users WHERE user_id = $1',
callback.from_user.id
)
current_lang = data.get('lang', 'en')
keyboard = build_options_keyboard(
options=LANG_OPTIONS,
current_value=current_lang,
callback_prefix="lang",
locale=locale,
back_callback="back_to_settings",
)
await safe_edit_message_text(
callback, locale.get("choose_language"), keyboard
)
@router.callback_query(lambda c: c.data and c.data.startswith("lang_"))
async def language_selected(callback: CallbackQuery):
lang = callback.data.split("_")[1]
await db.update(
'UPDATE users SET lang = $1 WHERE user_id = $2',
lang,
callback.from_user.id,
)
locale = i18n.get_locale(lang)
keyboard = build_options_keyboard(
options=LANG_OPTIONS,
current_value=lang,
callback_prefix="lang",
locale=locale,
back_callback="back_to_settings",
)
await safe_edit_message_text(
callback, locale.get("choose_language"), keyboard
)
await callback.answer(
locale.get("language_set").format(lang=lang)
)
@router.callback_query(lambda c: c.data == "back_to_settings")
async def back_to_settings(callback: CallbackQuery):
locale = await get_user_locale(callback.from_user.id)
settings_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=locale.get("setting_chart"),
callback_data="setting_chart",
),
InlineKeyboardButton(
text=locale.get("setting_lang"),
callback_data="setting_lang",
),
],
]
)
await safe_edit_message_text(
callback, locale.get("settings_title"), settings_keyboard
)
@router.callback_query(lambda c: c.data == "setting_chart")
async def show_chart_settings(callback: CallbackQuery):
data = await db.fetch(
'SELECT chart, chart_period, lang FROM users WHERE user_id = $1',
callback.from_user.id,
)
lang = data.get("lang", "en")
locale = i18n.get_locale(lang)
chart_status = bool(data.get("chart", 1))
period = data.get("chart_period")
status_text = locale.get("enabled") \
if chart_status \
else locale.get("disabled")
period_text = locale.get(period, period)
text = (
f"{locale.get('chart_settings')}\n"
f"{locale.get('status')}: {status_text}\n"
f"{locale.get('period')}: {period_text}"
)
keyboard = get_chart_toggle_keyboard(chart_status, locale)
await safe_edit_message_text(callback, text, keyboard)
@router.callback_query(lambda c: c.data == "chart_toggle")
async def toggle_chart(callback: CallbackQuery):
data = await db.fetch(
'SELECT chart, lang FROM users WHERE user_id = $1',
callback.from_user.id
)
lang = data.get("lang", "en")
locale = i18n.get_locale(lang)
current_status = data.get("chart", True)
new_status = not current_status
await db.update(
'UPDATE users SET chart = $1 WHERE user_id = $2',
new_status, callback.from_user.id
)
await callback.answer(
locale.get(
f"chart_now_{'enabled' if new_status else 'disabled'}",
f"Chart now {'enabled' if new_status else 'disabled'}",
)
)
await show_chart_settings(callback)
@router.callback_query(lambda c: c.data == "chart_period")
async def change_chart_period(callback: CallbackQuery):
data = await db.fetch(
'SELECT chart_period, lang FROM users WHERE user_id = $1',
callback.from_user.id
)
lang = data.get("lang", "en")
locale = i18n.get_locale(lang)
current_period = data.get("chart_period")
keyboard = build_options_keyboard(
options=PERIOD_OPTIONS,
current_value=current_period,
callback_prefix="period",
locale=locale,
back_callback="setting_chart",
)
await safe_edit_message_text(
callback,
locale.get("choose_period"),
keyboard
)
@router.callback_query(lambda c: c.data and c.data.startswith("period_"))
async def set_chart_period(callback: CallbackQuery):
period = callback.data.split("_")[1]
await db.update(
'UPDATE users SET chart_period = $1 WHERE user_id = $2',
period, callback.from_user.id
)
locale = await get_user_locale(callback.from_user.id)
keyboard = build_options_keyboard(
options=PERIOD_OPTIONS,
current_value=period,
callback_prefix="period",
locale=locale,
back_callback="setting_chart",
)
await safe_edit_message_text(
callback,
locale.get("choose_period"),
keyboard
)
await callback.answer(locale.get("period_set").format(period=period))

42
commands/start.py Normal file
View file

@ -0,0 +1,42 @@
from aiogram import types, Router
from aiogram.filters import CommandStart
import re
from bot import bot, db
from i18n.localization import I18n
router = Router()
i18n = I18n()
def escape_md_v2(text: str) -> str:
return re.sub(r'([_*\[\]()~#+\-=|{}.!\\])', r'\\\1', text)
@router.message(CommandStart())
async def start(message: types.Message) -> None:
get_bot = await bot.get_me()
data = await db.fetch(
'SELECT lang FROM users WHERE user_id = $1',
message.from_user.id
)
locale = i18n.get_locale(data.get('lang'))
raw_template = locale.get("start_message")
raw_text = raw_template.format(bot_username=get_bot.username)
text = escape_md_v2(raw_text)
button_text = locale.get("source_code_button")
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=button_text,
url="https://github.com/redume/shirino")
]
])
await message.reply(
text,
parse_mode="MarkdownV2",
reply_markup=keyboard
)

View file

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY,
chart BOOLEAN DEFAULT 1,
chart_period TEXT DEFAULT 'month',
lang TEXT DEFAULT 'en'
);

189
database/server.py Normal file
View file

@ -0,0 +1,189 @@
import json
from datetime import date, datetime
from pathlib import Path
from typing import Optional, List, Dict, Any
import aiosqlite
import yaml
config = yaml.safe_load(open('../config.yaml', 'r', encoding='utf-8'))
def custom_encoder(obj):
"""
Custom JSON encoder for objects not serializable by default.
Converts date and datetime objects to ISO 8601 string format.
Raises TypeError for unsupported types.
"""
if isinstance(obj, (date, datetime)):
return obj.isoformat()
raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable")
class Database:
"""
Asynchronous SQLite database handler using aiosqlite.
This class manages a single asynchronous connection to an SQLite database file
and provides common methods for executing queries, including:
- Connecting and disconnecting to the database.
- Fetching a single row or multiple rows.
- Inserting data and returning the last inserted row ID.
- Updating or deleting data and returning the count of affected rows.
Attributes:
db_path (str): Path to the SQLite database file.
conn (Optional[aiosqlite.Connection]): The active database connection, or None if disconnected.
Methods:
connect(): Asynchronously open a connection to the database.
disconnect(): Asynchronously close the database connection.
fetch(query, *args): Execute a query and return a single row as a dictionary.
fetchmany(query, *args): Execute a query and return multiple rows as a list of dictionaries.
insert(query, *args): Execute an INSERT query and return the last inserted row ID.
update(query, *args): Execute an UPDATE or DELETE query and return the number of affected rows.
Raises:
RuntimeError: If any method is called before the database connection is established.
Example:
```python
db = Database("example.db")
await db.connect()
user = await db.fetch("SELECT * FROM users WHERE id = ?", user_id)
await db.disconnect()
```
"""
def __init__(self, db_path: str):
"""
Initialize Database instance.
Args:
db_path (str): Path to SQLite database file.
"""
self.db_path = db_path
self.conn: Optional[aiosqlite.Connection] = None
async def _create_table(self) -> None:
"""
Create table from SQL file using aiosqlite.
Reads SQL commands from 'schemas/data.sql'
and executes them as a script.
"""
sql_file = Path(__file__).parent / "schemas" / "data.sql"
sql = sql_file.read_text(encoding='utf-8')
async with self.conn.execute("BEGIN"):
await self.conn.executescript(sql)
await self.conn.commit()
async def connect(self) -> None:
"""
Open SQLite database connection asynchronously.
"""
self.conn = await aiosqlite.connect(self.db_path)
self.conn.row_factory = aiosqlite.Row # return dict-like rows
async def disconnect(self) -> None:
"""
Close SQLite database connection asynchronously.
"""
if self.conn:
await self.conn.close()
self.conn = None
async def fetch(self, query: str, *args) -> Dict[str, Any]:
"""
Execute a query and fetch a single row as a dictionary.
Args:
query (str): SQL query string.
*args: Parameters for the SQL query.
Returns:
Dict[str, Any]: The first row returned by the query as a dict,
or an empty dict if no row is found.
Raises:
RuntimeError: If the database connection is not initialized.
"""
if not self.conn:
raise RuntimeError("Database connection is not initialized.")
async with self.conn.execute(query, args) as cursor:
row = await cursor.fetchone()
if row:
return json.loads(json.dumps(dict(row), default=custom_encoder))
return {}
async def fetchmany(self, query: str, *args) -> List[Dict[str, Any]]:
"""
Execute a query and fetch multiple rows as a list of dictionaries.
Args:
query (str): SQL query string.
*args: Parameters for the SQL query.
Returns:
List[Dict[str, Any]]: List of rows as dictionaries,
or empty list if no rows found.
Raises:
RuntimeError: If the database connection is not initialized.
"""
if not self.conn:
raise RuntimeError("Database connection is not initialized.")
async with self.conn.execute(query, args) as cursor:
rows = await cursor.fetchall()
return json.loads(
json.dumps(
[dict(row) for row in rows],
default=custom_encoder
)
) if rows else []
async def insert(self, query: str, *args) -> Dict[str, Any]:
"""
Execute an INSERT query and return the last inserted row ID.
Args:
query (str): SQL INSERT query string.
*args: Parameters for the SQL query.
Returns:
Dict[str, Any]: Dictionary containing the last inserted row ID.
Raises:
RuntimeError: If the database connection is not initialized.
"""
if not self.conn:
raise RuntimeError("Database connection is not initialized.")
async with self.conn.execute(query, args) as cursor:
await self.conn.commit()
return {"last_row_id": cursor.lastrowid}
async def update(self, query: str, *args) -> Dict[str, Any]:
"""
Execute an UPDATE or DELETE query and return affected rows count.
Args:
query (str): SQL UPDATE or DELETE query string.
*args: Parameters for the SQL query.
Returns:
Dict[str, Any]: Dictionary containing the number of affected rows.
Raises:
RuntimeError: If the database connection is not initialized.
"""
if not self.conn:
raise RuntimeError("Database connection is not initialized.")
async with self.conn.execute(query, args) as cursor:
await self.conn.commit()
return {"rows_affected": cursor.rowcount}

View file

@ -5,6 +5,7 @@ services:
dockerfile: Dockerfile
volumes:
- './config.yaml:/config.yaml'
- 'shirino-db:/data'
nginx:
image: nginx:latest
@ -18,3 +19,4 @@ services:
volumes:
shirino:
driver: local
shirino-db:

View file

@ -5,10 +5,16 @@ import aiohttp
config = yaml.safe_load(open('../config.yaml', 'r', encoding='utf-8'))
async def create_chart(from_currency: str, conv_currency: str) -> (dict, None):
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=3)) as session:
async def create_chart(
from_currency: str,
conv_currency: str,
period: str) -> (dict, None):
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=3)
) as session:
async with session.get(
f'{config["kekkai_instance"]}/api/getChart/month/', params={
f'{config["kekkai_instance"]}/api/getChart/{period}/',
params={
'from_currency': from_currency,
'conv_currency': conv_currency
}) as res:

42
i18n/locales/en.yaml Normal file
View file

@ -0,0 +1,42 @@
# /commands/start.py
start_message: |
Shirino is a telegram bot for converting fiat or cryptocurrency.
The example of use occurs via inline query:
`@{bot_username} USD RUB`
`@{bot_username} 12 USD RUB`
source_code_button: "Source Code"
# /commands/currency.py
currency_example: |
@{bot_username} USD RUB
@{bot_username} 12 USD RUB
error_not_enough_args: 2 or 3 arguments are required.
error_invalid_number: "Please enter a valid number for the amount."
error_negative_amount: "Negative amounts are not supported."
error_currency_rate: "The currency exchange rate could not be determined."
error_unknown_currency: "The source and target currency could not be determined."
chart: "Chart"
# /commands/settings.py
settings_title: "Bot Settings"
setting_chart: "Chart Settings"
setting_lang: "Change Language"
back: "Back"
choose_language: "Choose language:"
language_set: "Language set: {lang}"
chart_settings: "Chart Settings"
status: "Status"
period: "Period"
enabled: "Enabled"
disabled: "Disabled"
chart_disable: "Disable Chart"
chart_enable: "Enable Chart"
chart_period: "Chart Period"
chart_now_enabled: "Chart is now enabled"
chart_now_disabled: "Chart is now disabled"
choose_period: "Choose chart period:"
period_set: "Chart period set to: {period}"
week: "Week"
month: "Month"
quarter: "Quarter"
year: "Year"

42
i18n/locales/ru.yaml Normal file
View file

@ -0,0 +1,42 @@
# /commads/start.py
start_message: |
Shirino — Telegram-бот для конвертации фиатных и криптовалют.
Пример использования через inline-запрос:
`@{bot_username} USD RUB`
`@{bot_username} 12 USD RUB`
source_code_button: "Исходный код"
# /commands/currency.py
currency_example: |
@{bot_username} USD RUB
@{bot_username} 12 USD RUB
error_not_enough_args: Необходимо ввести 2 или 3 аргумента.
error_invalid_number: "Пожалуйста, введите правильное число для суммы."
error_negative_amount: "Отрицательные суммы не поддерживаются."
error_currency_rate: "Не удалось определить курс обмена валют."
error_unknown_currency: "Не удалось определить исходную и целевую валюту."
chart: "График"
# /commands/settings.py
settings_title: "Настройки бота"
setting_chart: "Настройка графиков"
setting_lang: "Смена языка"
back: "Назад"
choose_language: "Выберите язык:"
language_set: "Язык установлен: {lang}"
chart_settings: "Настройки графика"
status: "Статус"
period: "Период"
enabled: "Включён"
disabled: "Выключен"
chart_disable: "Выключить график"
chart_enable: "Включить график"
chart_period: "Период графика"
chart_now_enabled: "График теперь включён"
chart_now_disabled: "График теперь выключен"
choose_period: "Выберите период графика:"
period_set: "Период графика установлен: {period}"
week: "Неделя"
month: "Месяц"
quarter: "Квартал"
year: "Год"

24
i18n/localization.py Normal file
View file

@ -0,0 +1,24 @@
import yaml
from pathlib import Path
class I18n:
"""Load every YAML file in i18n/locales and let you pull out a single-language dict."""
def __init__(self, locales_dir: str = "i18n/locales", default_lang: str = "en"):
base_path = Path(__file__).parent.parent
self.locales_dir = base_path / locales_dir
self.default_lang = default_lang
self.translations: dict[str, dict] = {}
self._load_translations()
def _load_translations(self) -> None:
for file in self.locales_dir.glob("*.yaml"):
lang = file.stem.lower()
with file.open(encoding="utf-8") as f:
self.translations[lang] = yaml.safe_load(f)
def get_locale(self, lang: str | None = None) -> dict:
"""Return the whole dictionary for one language (fallback → default_lang)."""
lang = (lang or self.default_lang).lower()[:2]
return self.translations.get(lang, self.translations[self.default_lang])

138
main.py
View file

@ -1,144 +1,38 @@
import hashlib
import yaml
from aiohttp import web
from aiogram import Bot, Dispatcher, Router, types
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram import Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiogram.filters import CommandStart
from functions.convert import Converter
from functions.create_chart import create_chart
from utils.format_number import format_number
from utils.inline_query import reply
from commands import currency, start, settings
from bot import bot, db
config = yaml.safe_load(open('../config.yaml', 'r', encoding='utf-8'))
bot = Bot(
token=config['telegram_token'],
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
router = Router()
@router.message(CommandStart())
async def start(message: types.Message):
get_bot = await bot.get_me()
await message.reply(
'Shirino is a telegram bot for converting fiat or cryptocurrency. '
'The example of use occurs via inline query:\n'
f'`@{get_bot.username} USD RUB` \n'
f'`@{get_bot.username} 12 USD RUB` \n\n'
'[Source Code](https://github.com/Redume/Shirino)',
parse_mode='markdown'
)
@router.inline_query()
async def currency(query: types.InlineQuery) -> None:
text = query.query.lower()
args = text.split()
result_id = hashlib.md5(text.encode()).hexdigest()
get_bot = await bot.get_me()
if len(args) < 2:
return await reply(result_id,
[("2 or 3 arguments are required.",
f'@{get_bot.username} USD RUB \n'
f'@{get_bot.username} 12 USD RUB',
None, None)],
query)
conv = Converter()
from_currency, conv_currency = '', ''
if len(args) == 3:
try:
conv.amount = float(args[0].replace(',', '.'))
if conv.amount < 0:
return await reply(
result_id,
[
("Negative amounts are not supported.", None, None)
],
query
)
except ValueError:
return await reply(
result_id,
[
(
"Please enter a valid number for the amount.",
f'@{get_bot.username} USD RUB \n'
f'@{get_bot.username} 12 USD RUB',
None, None
)
],
query)
from_currency = args[1]
conv_currency = args[2]
elif len(args) == 2:
from_currency = args[0]
conv_currency = args[1]
else:
return await reply(
result_id,
[
(
'The source and target currency could not be determined.',
None, None
)
],
query
)
conv.from_currency = from_currency.upper()
conv.conv_currency = conv_currency.upper()
try:
await conv.convert()
except RuntimeError:
return await reply(
result_id,
[
(
'The currency exchange rate could not be determined',
None, None
)
],
query
)
chart = await create_chart(from_currency, conv_currency)
message = f'{format_number(conv.amount)} {conv.from_currency} ' \
f'= {conv.conv_amount} {conv.conv_currency}'
results = [(message, None, None)]
if chart:
results.insert(0, (f'{message}\n[Chart]({chart})', None, chart))
await reply(result_id, results, query)
async def on_startup(bot: Bot) -> None:
async def on_startup(bot: bot) -> None:
await db.connect()
await db._create_table()
await bot.set_webhook(
f"{config['webhook']['base_url']}{config['webhook']['path']}",
secret_token=config['webhook']['secret_token'],
allowed_updates=['inline_query', 'message']
allowed_updates=['inline_query', 'message', 'callback_query']
)
async def on_shutdown():
await db.disconnect()
def main() -> None:
dp = Dispatcher()
dp.include_router(router)
dp.include_router(currency.router)
dp.include_router(start.router)
dp.include_router(settings.router)
dp.startup.register(on_startup)
dp.shutdown.register(on_shutdown)
app = web.Application()
webhook_requests_handler = SimpleRequestHandler(

View file

@ -1,3 +1,4 @@
aiohttp~=3.9.5
PyYAML~=6.0.1
aiogram~=3.15.0
aiosqlite~=0.21.0

View file

@ -2,6 +2,9 @@ import re
from aiogram import types
def esc_md(text: str) -> str:
return re.sub(r'([_*\[\]()~`>#+\-=|{}.!\\])', r'\\\1', text)
async def reply(result_id: str, args: list, query: types.InlineQuery) -> None:
if not args:
return
@ -13,23 +16,27 @@ async def reply(result_id: str, args: list, query: types.InlineQuery) -> None:
description = arg[1] if arg[1] else None
img = arg[2] if arg[2] else None
if img:
article = types.InlineQueryResultPhoto(
id=f"{result_id}_{idx}",
photo_url=img,
thumbnail_url=img,
title=title,
description=description,
caption=esc_md(title),
parse_mode="MarkdownV2"
)
else:
article = types.InlineQueryResultArticle(
id=f"{result_id}_{idx}",
title=re.sub(r'\bChart\b|\[([^\]]+)\]\([^)]+\)', '', title, flags=re.IGNORECASE),
thumbnail_url=img,
title=title,
description=description,
input_message_content=types.InputTextMessageContent(
message_text=title,
parse_mode='markdown'
)
message_text=esc_md(title),
parse_mode="MarkdownV2",
),
)
articles.append(article)
await query.answer(
results=articles,
parse_mode='markdown',
cache_time=0,
is_personal=True
)
await query.answer(results=articles, cache_time=0, is_personal=True)