diff --git a/core/search/__init__.py b/core/search/__init__.py new file mode 100644 index 0000000..1d8d1b2 --- /dev/null +++ b/core/search/__init__.py @@ -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 diff --git a/core/search/models.py b/core/search/models.py new file mode 100644 index 0000000..524e42d --- /dev/null +++ b/core/search/models.py @@ -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]] diff --git a/core/search/services.py b/core/search/services.py new file mode 100644 index 0000000..94d3de7 --- /dev/null +++ b/core/search/services.py @@ -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] + ) diff --git a/metadata/shortname.py b/metadata/shortname.py index 2ee65c4..e2dbf01 100644 --- a/metadata/shortname.py +++ b/metadata/shortname.py @@ -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]) diff --git a/plugins/genshin/strategy.py b/plugins/genshin/strategy.py index de926bd..6424f9d 100644 --- a/plugins/genshin/strategy.py +++ b/plugins/genshin/strategy.py @@ -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 米游社 西风驿站 查看原图" + 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) diff --git a/plugins/genshin/weapon.py b/plugins/genshin/weapon.py index e6c69c7..e4778b9 100644 --- a/plugins/genshin/weapon.py +++ b/plugins/genshin/weapon.py @@ -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) diff --git a/plugins/system/inline.py b/plugins/system/inline.py index c6b2330..cd280a6 100644 --- a/plugins/system/inline.py +++ b/plugins/system/inline.py @@ -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( diff --git a/plugins/system/search.py b/plugins/system/search.py new file mode 100644 index 0000000..99f5b8e --- /dev/null +++ b/plugins/system/search.py @@ -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("删除全部条目数据成功") diff --git a/poetry.lock b/poetry.lock index 269ae2c..2dbbb42 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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"}, diff --git a/pyproject.toml b/pyproject.toml index d9adff4..e02ce74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]