Add inline search

This commit is contained in:
洛水居室 2022-12-04 19:56:39 +08:00 committed by GitHub
parent bfab1ec213
commit e20e20111d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 440 additions and 22 deletions

10
core/search/__init__.py Normal file
View File

@ -0,0 +1,10 @@
from core.service import init_service
from .services import SearchServices as _SearchServices
__all__ = []
@init_service
def create_search_service():
_service = _SearchServices()
return _service

73
core/search/models.py Normal file
View File

@ -0,0 +1,73 @@
from abc import abstractmethod
from typing import Optional, List
from pydantic import BaseModel
__all__ = ["BaseEntry", "WeaponEntry", "WeaponsEntry", "StrategyEntry", "StrategyEntryList"]
from thefuzz import fuzz
class BaseEntry(BaseModel):
"""所有可搜索条目的基类。
Base class for all searchable entries."""
key: str # 每个条目的Key必须唯一
title: str
description: str
tags: Optional[List[str]] = []
caption: Optional[str] = None
parse_mode: Optional[str] = None
photo_url: Optional[str] = None
photo_file_id: Optional[str] = None
@abstractmethod
def compare_to_query(self, search_query: str) -> float:
"""返回一个数字 ∈[0,100] 描述搜索查询与此条目的相似程度。
Gives a number [0,100] describing how similar the search query is to this entry."""
class WeaponEntry(BaseEntry):
def compare_to_query(self, search_query: str) -> float:
score = 0.0
if search_query == self.title:
return 100
if self.tags:
if search_query in self.tags:
return 99
for tag in self.tags:
_score = fuzz.partial_token_set_ratio(tag, search_query)
if _score >= score:
score = _score
if score >= 90:
return score * 0.99
if self.description:
_score = fuzz.partial_token_set_ratio(self.description, search_query)
if _score >= score:
return _score
return score
class WeaponsEntry(BaseModel):
data: Optional[List[WeaponEntry]]
class StrategyEntry(BaseEntry):
def compare_to_query(self, search_query: str) -> float:
score = 0.0
if search_query == self.title:
return 100
if self.tags:
if search_query in self.tags:
return 99
for tag in self.tags:
_score = fuzz.partial_token_set_ratio(tag, search_query)
if _score >= score:
score = _score
return score
class StrategyEntryList(BaseModel):
data: Optional[List[StrategyEntry]]

148
core/search/services.py Normal file
View File

@ -0,0 +1,148 @@
import asyncio
import heapq
import itertools
import json
import os
import time
from pathlib import Path
from typing import Tuple, List, Optional, Dict
import aiofiles
from async_lru import alru_cache
from core.search.models import WeaponEntry, BaseEntry, WeaponsEntry, StrategyEntry, StrategyEntryList
from utils.const import PROJECT_ROOT
ENTRY_DAYA_PATH = PROJECT_ROOT.joinpath("data", "entry")
ENTRY_DAYA_PATH.mkdir(parents=True, exist_ok=True)
class SearchServices:
def __init__(self):
self._lock = asyncio.Lock() # 访问和修改操作成员变量必须加锁操作
self.weapons: List[WeaponEntry] = []
self.strategy: List[StrategyEntry] = []
self.entry_data_path: Path = ENTRY_DAYA_PATH
self.weapons_entry_data_path = self.entry_data_path / "weapon.json"
self.strategy_entry_data_path = self.entry_data_path / "strategy.json"
self.replace_time: Dict[str, float] = {}
@staticmethod
async def load_json(path):
async with aiofiles.open(path, "r", encoding="utf-8") as f:
return json.loads(await f.read())
@staticmethod
async def save_json(path, data):
async with aiofiles.open(path, "w", encoding="utf-8") as f:
await f.write(data)
async def load_data(self):
async with self._lock:
if self.weapons_entry_data_path.exists():
weapon_json = await self.load_json(self.weapons_entry_data_path)
weapons = WeaponsEntry.parse_obj(weapon_json)
for weapon in weapons.data:
self.weapons.append(weapon.copy())
if self.strategy_entry_data_path.exists():
strategy_json = await self.load_json(self.strategy_entry_data_path)
strategy = StrategyEntryList.parse_obj(strategy_json)
for strategy in strategy.data:
self.strategy.append(strategy.copy())
async def save_entry(self) -> None:
"""保存条目
:return: None
"""
async with self._lock:
if len(self.weapons) > 0:
weapons = WeaponsEntry(data=self.weapons)
await self.save_json(self.weapons_entry_data_path, weapons.json())
if len(self.strategy) > 0:
strategy = StrategyEntryList(data=self.strategy)
await self.save_json(self.strategy_entry_data_path, strategy.json())
async def add_entry(self, entry: BaseEntry, update: bool = False, ttl: int = 3600):
"""添加条目
:param entry: 条目数据
:param update: 如果条目存在是否覆盖
:param ttl: 条目存在时需要多久时间覆盖
:return: None
"""
async with self._lock:
replace_time = self.replace_time.get(entry.key)
if replace_time and replace_time <= time.time() + ttl:
return
if isinstance(entry, WeaponEntry):
for index, value in enumerate(self.weapons):
if value.key == entry.key:
if update:
self.replace_time[entry.key] = time.time()
self.weapons[index] = entry
break
else:
self.weapons.append(entry)
elif isinstance(entry, StrategyEntry):
for index, value in enumerate(self.strategy):
if value.key == entry.key:
if update:
self.replace_time[entry.key] = time.time()
self.strategy[index] = entry
break
else:
self.strategy.append(entry)
async def remove_all_entry(self):
"""移除全部条目
:return: None
"""
async with self._lock:
self.weapons = []
if self.weapons_entry_data_path.exists():
os.remove(self.weapons_entry_data_path)
self.strategy = []
if self.strategy_entry_data_path.exists():
os.remove(self.strategy_entry_data_path)
@staticmethod
def _sort_key(entry: BaseEntry, search_query: str) -> float:
return entry.compare_to_query(search_query)
@alru_cache(maxsize=64)
async def multi_search_combinations(self, search_queries: Tuple[str], results_per_query: int = 3):
"""多个关键词搜索
:param search_queries: 搜索文本
:param results_per_query: 约定返回的数目
:return: 搜索结果
"""
results = {}
effective_queries = list(dict.fromkeys(search_queries))
for query in effective_queries:
if res := await self.search(search_query=query, amount=results_per_query):
results[query] = res
@alru_cache(maxsize=64)
async def search(self, search_query: Optional[str], amount: int = None) -> Optional[List[BaseEntry]]:
"""在所有可用条目中搜索适当的结果
:param search_query: 搜索文本
:param amount: 约定返回的数目
:return: 搜索结果
"""
# search_entries: Iterable[BaseEntry] = []
async with self._lock:
search_entries = itertools.chain(self.weapons, self.strategy)
if not search_query:
return search_entries if isinstance(search_entries, list) else list(search_entries)
if not amount:
return sorted(
search_entries,
key=lambda entry: self._sort_key(entry, search_query), # type: ignore
reverse=True,
)
return heapq.nlargest(
amount,
search_entries,
key=lambda entry: self._sort_key(entry, search_query), # type: ignore[arg-type]
)

View File

@ -1,10 +1,11 @@
from __future__ import annotations
import functools
from typing import List
from metadata.genshin import WEAPON_DATA
__all__ = ["roles", "weapons", "roleToId", "roleToName", "weaponToName", "weaponToId", "not_real_roles"]
__all__ = ["roles", "weapons", "roleToId", "roleToName", "weaponToName", "weaponToId", "not_real_roles", "roleToTag"]
# noinspection SpellCheckingInspection
roles = {
@ -393,3 +394,10 @@ def weaponToName(shortname: str) -> str:
def weaponToId(name: str) -> int | None:
"""获取武器ID"""
return next((int(key) for key, value in WEAPON_DATA.items() if weaponToName(name) in value["name"]), None)
# noinspection PyPep8Naming
@functools.lru_cache()
def roleToTag(role_name: str) -> List[str]:
"""通过角色名获取TAG"""
return next((value for value in roles.values() if value[0] == role_name), [role_name])

View File

@ -8,7 +8,9 @@ from telegram.ext import MessageHandler, filters
from core.baseplugin import BasePlugin
from core.game.services import GameStrategyService
from core.plugin import Plugin, handler
from metadata.shortname import roleToName
from core.search.models import StrategyEntry
from core.search.services import SearchServices
from metadata.shortname import roleToName, roleToTag
from utils.bot import get_args
from utils.decorators.error import error_callable
from utils.decorators.restricts import restricts
@ -21,8 +23,13 @@ class StrategyPlugin(Plugin, BasePlugin):
KEYBOARD = [[InlineKeyboardButton(text="查看角色攻略列表并查询", switch_inline_query_current_chat="查看角色攻略列表并查询")]]
def __init__(self, game_strategy_service: GameStrategyService = None):
def __init__(
self,
game_strategy_service: GameStrategyService = None,
search_service: SearchServices = None,
):
self.game_strategy_service = game_strategy_service
self.search_service = search_service
@handler(CommandHandler, command="strategy", block=False)
@handler(MessageHandler, filters=filters.Regex("^角色攻略查询(.*)"), block=False)
@ -53,11 +60,24 @@ class StrategyPlugin(Plugin, BasePlugin):
logger.info(f"用户 {user.full_name}[{user.id}] 查询角色攻略命令请求 || 参数 {character_name}")
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
file_path = await url_to_file(url, return_path=True)
caption = "From 米游社 西风驿站 " f"查看 [原图]({url})"
await message.reply_photo(
caption = f"From 米游社 西风驿站 查看<a href='{url}'>原图</a>"
reply_photo = await message.reply_photo(
photo=open(file_path, "rb"),
caption=caption,
filename=f"{character_name}.png",
allow_sending_without_reply=True,
parse_mode=ParseMode.MARKDOWN_V2,
parse_mode=ParseMode.HTML,
)
if reply_photo.photo:
tags = roleToTag(character_name)
photo_file_id = reply_photo.photo[0].file_id
entry = StrategyEntry(
key=f"plugin:strategy:{character_name}",
title=character_name,
description=f"{character_name} 角色攻略",
tags=tags,
caption=caption,
parse_mode="HTML",
photo_file_id=photo_file_id,
)
await self.search_service.add_entry(entry)

View File

@ -5,10 +5,12 @@ from telegram.ext import CallbackContext, CommandHandler, MessageHandler, filter
from core.base.assets import AssetsService, AssetsCouldNotFound
from core.baseplugin import BasePlugin
from core.plugin import Plugin, handler
from core.search.models import WeaponEntry
from core.search.services import SearchServices
from core.template import TemplateService
from core.wiki.services import WikiService
from metadata.genshin import honey_id_to_game_id
from metadata.shortname import weaponToName
from metadata.shortname import weaponToName, weapons as _weapons_data
from modules.wiki.weapon import Weapon
from utils.bot import get_args
from utils.decorators.error import error_callable
@ -27,10 +29,12 @@ class WeaponPlugin(Plugin, BasePlugin):
template_service: TemplateService = None,
wiki_service: WikiService = None,
assets_service: AssetsService = None,
search_service: SearchServices = None,
):
self.wiki_service = wiki_service
self.template_service = template_service
self.assets_service = assets_service
self.search_service = search_service
@handler(CommandHandler, command="weapon", block=False)
@handler(MessageHandler, filters=filters.Regex("^武器查询(.*)"), block=False)
@ -122,8 +126,19 @@ class WeaponPlugin(Plugin, BasePlugin):
"genshin/weapon/weapon.html", template_data, {"width": 540, "height": 540}, ttl=31 * 24 * 60 * 60
)
await message.reply_chat_action(ChatAction.UPLOAD_PHOTO)
await png_data.reply_photo(
reply_photo = await png_data.reply_photo(
message,
filename=f"{template_data['weapon_name']}.png",
allow_sending_without_reply=True,
)
if reply_photo.photo:
photo_file_id = reply_photo.photo[0].file_id
tags = _weapons_data.get(weapon_name)
entry = WeaponEntry(
key=f"plugin:weapon:{weapon_name}",
title=weapon_name,
description=weapon_data.story,
tags=tags,
photo_file_id=photo_file_id,
)
await self.search_service.add_entry(entry)

View File

@ -2,13 +2,20 @@ import asyncio
from typing import cast, Dict, Awaitable, List
from uuid import uuid4
from telegram import InlineQueryResultArticle, InputTextMessageContent, Update, InlineQuery
from telegram import (
InlineQueryResultArticle,
InputTextMessageContent,
Update,
InlineQuery,
InlineQueryResultCachedPhoto,
)
from telegram.constants import ParseMode
from telegram.error import BadRequest
from telegram.ext import CallbackContext, InlineQueryHandler
from core.base.assets import AssetsService, AssetsCouldNotFound
from core.plugin import handler, Plugin
from core.search.services import SearchServices
from core.wiki import WikiService
from utils.decorators.error import error_callable
from utils.log import logger
@ -21,12 +28,14 @@ class Inline(Plugin):
self,
wiki_service: WikiService = None,
assets_service: AssetsService = None,
search_service: SearchServices = None,
):
self.assets_service = assets_service
self.wiki_service = wiki_service
self.weapons_list: List[Dict[str, str]] = []
self.characters_list: List[Dict[str, str]] = []
self.refresh_task: List[Awaitable] = []
self.search_service = search_service
async def __async_init__(self):
# todo: 整合进 wiki 或者单独模块 从Redis中读取
@ -74,7 +83,22 @@ class Inline(Plugin):
results_list = []
args = query.split(" ")
if args[0] == "":
pass
results_list.append(
InlineQueryResultArticle(
id=str(uuid4()),
title="武器图鉴查询",
description="输入武器名称即可查询武器图鉴",
input_message_content=InputTextMessageContent("武器图鉴查询"),
)
)
results_list.append(
InlineQueryResultArticle(
id=str(uuid4()),
title="角色攻略查询",
description="输入角色名即可查询角色攻略",
input_message_content=InputTextMessageContent("角色攻略查询"),
)
)
else:
if "查看武器列表并查询" == args[0]:
for weapon in self.weapons_list:
@ -119,6 +143,33 @@ class Inline(Plugin):
),
)
)
else:
simple_search_results = await self.search_service.search(args[0])
if simple_search_results:
results_list.append(
InlineQueryResultArticle(
id=str(uuid4()),
title=f"当前查询内容为 {args[0]}",
description="如果无查看图片描述 这是正常的 客户端问题",
thumb_url="https://www.miyoushe.com/_nuxt/img/game-ys.dfc535b.jpg",
input_message_content=InputTextMessageContent(f"当前查询内容为 {args[0]}\n如果无查看图片描述 这是正常的 客户端问题"),
)
)
for simple_search_result in simple_search_results:
if simple_search_result.photo_file_id:
description = simple_search_result.description
if len(description) >= 10:
description = description[:10]
results_list.append(
InlineQueryResultCachedPhoto(
id=str(uuid4()),
title=simple_search_result.title,
photo_file_id=simple_search_result.photo_file_id,
description=description,
caption=simple_search_result.caption,
parse_mode=simple_search_result.parse_mode,
)
)
if not results_list:
results_list.append(

64
plugins/system/search.py Normal file
View File

@ -0,0 +1,64 @@
import asyncio
import datetime
from telegram import Update
from telegram.ext import CallbackContext
from core.plugin import handler, Plugin, job
from core.search.services import SearchServices
from utils.decorators.admins import bot_admins_rights_check
from utils.decorators.restricts import restricts
from utils.log import logger
__all__ = []
class SearchPlugin(Plugin):
def __init__(self, search: SearchServices = None):
self.search = search
self._lock = asyncio.Lock()
async def __async_init__(self):
async def load_data():
logger.info("Search 插件模块正在加载搜索条目")
async with self._lock:
await self.search.load_data()
logger.success("Search 插件加载模块搜索条目成功")
asyncio.create_task(load_data())
@job.run_repeating(interval=datetime.timedelta(hours=1), name="SaveEntryJob")
async def save_entry_job(self, _: CallbackContext):
if self._lock.locked():
logger.warning("条目数据正在保存 跳过本次定时任务")
else:
async with self._lock:
logger.info("条目数据正在自动保存")
await self.search.save_entry()
logger.success("条目数据自动保存成功")
@handler.command("save_entry", block=False)
@bot_admins_rights_check
@restricts()
async def save_entry(self, update: Update, _: CallbackContext):
user = update.effective_user
message = update.effective_message
logger.info("用户 %s[%s] 保存条目数据命令请求", user.full_name, user.id)
if self._lock.locked():
await message.reply_text("条目数据正在保存 请稍后重试")
else:
async with self._lock:
reply_text = await message.reply_text("正在保存数据")
await self.search.save_entry()
await reply_text.edit_text("数据保存成功")
@handler.command("remove_all_entry", block=False)
@bot_admins_rights_check
@restricts()
async def remove_all_entry(self, update: Update, _: CallbackContext):
user = update.effective_user
message = update.effective_message
logger.info("用户 %s[%s] 删除全部条目数据命令请求", user.full_name, user.id)
reply_text = await message.reply_text("正在删除全部条目数据")
await self.search.remove_all_entry()
await reply_text.edit_text("删除全部条目数据成功")

51
poetry.lock generated
View File

@ -131,6 +131,14 @@ typing-extensions = "*"
[package.extras]
test = ["pytest", "pytest-rerunfailures"]
[[package]]
name = "async-lru"
version = "1.0.3"
description = "Simple lru_cache for asyncio"
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "async-timeout"
version = "4.0.2"
@ -159,7 +167,7 @@ python-versions = ">=3.5"
dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"]
docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"]
tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"]
tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"]
[[package]]
name = "backports.zoneinfo"
@ -234,7 +242,7 @@ optional = false
python-versions = ">=3.6.0"
[package.extras]
unicode-backport = ["unicodedata2"]
unicode_backport = ["unicodedata2"]
[[package]]
name = "click"
@ -951,7 +959,7 @@ falcon = ["falcon (>=1.4)"]
fastapi = ["fastapi (>=0.79.0)"]
flask = ["blinker (>=1.1)", "flask (>=0.11)"]
httpx = ["httpx (>=0.16.0)"]
pure-eval = ["asttokens", "executing", "pure-eval"]
pure_eval = ["asttokens", "executing", "pure-eval"]
pymongo = ["pymongo (>=3.1)"]
pyspark = ["pyspark (>=2.4.4)"]
quart = ["blinker (>=1.1)", "quart (>=0.16.1)"]
@ -1030,19 +1038,19 @@ aiomysql = ["aiomysql", "greenlet (!=0.4.17)"]
aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"]
asyncio = ["greenlet (!=0.4.17)"]
asyncmy = ["asyncmy (>=0.2.3,!=0.2.4)", "greenlet (!=0.4.17)"]
mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2)"]
mariadb_connector = ["mariadb (>=1.0.1,!=1.1.2)"]
mssql = ["pyodbc"]
mssql-pymssql = ["pymssql"]
mssql-pyodbc = ["pyodbc"]
mssql_pymssql = ["pymssql"]
mssql_pyodbc = ["pyodbc"]
mypy = ["mypy (>=0.910)", "sqlalchemy2-stubs"]
mysql = ["mysqlclient (>=1.4.0)", "mysqlclient (>=1.4.0,<2)"]
mysql-connector = ["mysql-connector-python"]
mysql_connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=7)", "cx_oracle (>=7,<8)"]
postgresql = ["psycopg2 (>=2.7)"]
postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
postgresql-pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"]
postgresql-psycopg2binary = ["psycopg2-binary"]
postgresql-psycopg2cffi = ["psycopg2cffi"]
postgresql_asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
postgresql_pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"]
postgresql_psycopg2binary = ["psycopg2-binary"]
postgresql_psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql", "pymysql (<1)"]
sqlcipher = ["sqlcipher3_binary"]
@ -1093,6 +1101,17 @@ category = "main"
optional = true
python-versions = "~=3.7"
[[package]]
name = "thefuzz"
version = "0.19.0"
description = "Fuzzy string matching in python"
category = "main"
optional = false
python-versions = "*"
[package.extras]
speedup = ["python-levenshtein (>=0.12)"]
[[package]]
name = "tomli"
version = "2.0.1"
@ -1274,7 +1293,7 @@ test = ["pytest", "pytest-asyncio", "flaky"]
[metadata]
lock-version = "1.1"
python-versions = "^3.8"
content-hash = "0c5cb6b738281012aa5ade35a22efc33159c38c4ddf58140c48a148aa70cb912"
content-hash = "dda8009b7611580764e09bafcb38c2e32804975869364e017fbea69e38524d73"
[metadata.files]
aiofiles = [
@ -1398,6 +1417,10 @@ arko-wrapper = [
{file = "arko-wrapper-0.2.4.tar.gz", hash = "sha256:92b7771c72f3b18d5193b8b4375f1b0f52cb94d3933b00118c281d6e582819ae"},
{file = "arko_wrapper-0.2.4-py3-none-any.whl", hash = "sha256:703dcfeeb95c43631d8cba02eebbc57973a3f0bdca7353dfc57b38b257a94774"},
]
async-lru = [
{file = "async-lru-1.0.3.tar.gz", hash = "sha256:c2cb9b2915eb14e6cf3e717154b40f715bf90e596d73623677affd0d1fbcd32a"},
{file = "async_lru-1.0.3-py3-none-any.whl", hash = "sha256:ea692c303feb6211ff260d230dae1583636f13e05c9ae616eada77855b7f415c"},
]
async-timeout = [
{file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
{file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"},
@ -2280,6 +2303,10 @@ tgcrypto = [
{file = "TgCrypto-1.2.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6b0c2dc84e632ce7b3d0b767cfe20967e557ad7d71ea5dbd7df2dd544323181"},
{file = "TgCrypto-1.2.5.tar.gz", hash = "sha256:9bc2cac6fb9a12ef5b08f3dd500174fe374d89b660cce981f57e3138559cb682"},
]
thefuzz = [
{file = "thefuzz-0.19.0-py2.py3-none-any.whl", hash = "sha256:4fcdde8e40f5ca5e8106bc7665181f9598a9c8b18b0a4d38c41a095ba6788972"},
{file = "thefuzz-0.19.0.tar.gz", hash = "sha256:6f7126db2f2c8a54212b05e3a740e45f4291c497d75d20751728f635bb74aa3d"},
]
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},

View File

@ -39,6 +39,8 @@ uvicorn = {extras = ["standard"], version = "^0.19.0"}
sentry-sdk = "^1.11.0"
GitPython = "^3.1.29"
openpyxl = "^3.0.10"
async-lru = "^1.0.3"
thefuzz = "^0.19.0"
[tool.poetry.extras]
pyro = ["Pyrogram", "TgCrypto"]