├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── api ├── __init__.py ├── bot_api_schema │ ├── __init__.py │ └── bot_api_schema.json └── get_data.py ├── assets └── GitHub Social Logo Draft.xcf ├── botkit ├── __init__.py ├── abstractions │ ├── __init__.py │ ├── _asyncloadunload.py │ ├── _named.py │ └── _registerable.py ├── agnostic │ ├── __init__.py │ ├── _pyrogram_update_type_inference.py │ ├── annotations.py │ ├── library_checks.py │ ├── pyrogram_chat_resolver.py │ └── pyrogram_view_sender.py ├── builders │ ├── __init__.py │ ├── callbackbuilder.py │ ├── htmlbuilder.py │ ├── menubuilder.py │ ├── metabuilder.py │ ├── quizbuilder.py │ ├── replymarkupbuilder.py │ └── text │ │ ├── __init__.py │ │ ├── basetextbuilder.py │ │ ├── emoji.py │ │ ├── htmltextbuilder.py │ │ ├── iconographybuilder.py │ │ ├── mdformat.py │ │ ├── telegram_entity_builder.py │ │ └── typographybuilder.py ├── builtin_modules │ ├── __init__.py │ ├── module_manager │ │ ├── __init__.py │ │ ├── paged_module_view.py │ │ ├── pagination_model.py │ │ └── view_models.py │ └── system │ │ ├── __init__.py │ │ ├── status_pings.py │ │ ├── system_tests.py │ │ └── sytem_management_module.py ├── builtin_services │ ├── __init__.py │ ├── bettermarkdown │ │ ├── __init__.py │ │ ├── _base.py │ │ ├── aliases.py │ │ ├── bettermarkdown.py │ │ └── config.py │ ├── eventing │ │ ├── __init__.py │ │ └── botkit_event_bus.py │ ├── lookupservice.py │ ├── nlu │ │ ├── __init__.py │ │ ├── dialogflowconfig.py │ │ ├── messageunderstanding.py │ │ └── nluservice.py │ ├── options │ │ ├── __init__.py │ │ └── base.py │ └── sr │ │ ├── __init__.py │ │ └── speechrecognition.py ├── clients │ ├── __init__.py │ ├── client.py │ └── mixins │ │ └── __init__.py ├── commands │ ├── TODO.md │ ├── __init__.py │ └── command.py ├── components │ ├── __init__.py │ └── questionnaire.py ├── configuration │ ├── __init__.py │ └── client_config.py ├── core │ ├── __init__.py │ ├── components.py │ ├── modules │ │ ├── __init__.py │ │ ├── _module.py │ │ ├── _moduledecorator.py │ │ ├── _registration.py │ │ └── activation │ │ │ ├── __init__.py │ │ │ ├── _di.py │ │ │ ├── _hmr.py │ │ │ ├── _module_activator.py │ │ │ ├── _module_loader.py │ │ │ └── _module_status.py │ ├── services │ │ ├── __init__.py │ │ ├── _base.py │ │ ├── _decorator.py │ │ └── _decorator.pyi │ └── startup.py ├── dispatching │ ├── __init__.py │ ├── callbackqueryactiondispatcher.py │ ├── deeplinkstartactiondispatcher.py │ ├── dispatcher.py │ └── types.py ├── inlinequeries │ ├── __init__.py │ ├── contexts.py │ ├── inlineresultcontainer.py │ ├── inlineresultgenerator.py │ └── resultaggregator.py ├── models │ ├── __init__.py │ ├── _interfaces.py │ └── _statemodel.py ├── persistence │ ├── __init__.py │ ├── callback_store │ │ ├── __init__.py │ │ ├── _base.py │ │ ├── _local.py │ │ ├── _redis.py │ │ └── _simple.py │ └── data_store │ │ ├── __init__.py │ │ ├── data_store_base.py │ │ └── memory_data_store.py ├── routing │ ├── __init__.py │ ├── dialogs │ │ ├── __init__.py │ │ ├── history.py │ │ ├── state.py │ │ └── testing.py │ ├── pipelines │ │ ├── __init__.py │ │ ├── collector.py │ │ ├── executionplan.py │ │ ├── factory_types.py │ │ ├── filters.py │ │ ├── gatherer.py │ │ ├── reducer.py │ │ ├── steps │ │ │ ├── __init__.py │ │ │ ├── _base.py │ │ │ ├── call_step_factory.py │ │ │ ├── collect_step_factory.py │ │ │ ├── commit_rendered_view_step_factory.py │ │ │ ├── gather_step_factory.py │ │ │ ├── helpers │ │ │ │ ├── __init__.py │ │ │ │ └── state_generators.py │ │ │ ├── initialize_context_step.py │ │ │ ├── invoke_component_step_factory.py │ │ │ ├── reduce_step_factory.py │ │ │ ├── remove_trigger_step_factory.py │ │ │ └── render_view_step_factory.py │ │ └── updates │ │ │ └── update_pipeline_factory.py │ ├── route.py │ ├── route_builder │ │ ├── __init__.py │ │ ├── action_expression_base.py │ │ ├── builder.py │ │ ├── expressions │ │ │ ├── __init__.py │ │ │ ├── _base.py │ │ │ ├── _split_this_up.py │ │ │ └── route_builder_context.py │ │ ├── has_route_collection.py │ │ ├── publish_expression.py │ │ ├── route_builder_base.py │ │ ├── route_collection.py │ │ ├── state_machine_mixin.py │ │ ├── state_route_builder.py │ │ ├── types.py │ │ └── webhook_action_expression.py │ ├── triggers.py │ ├── types.py │ ├── update_types │ │ ├── __init__.py │ │ ├── update_type_inference.py │ │ └── updatetype.py │ └── user_error.py ├── services │ ├── __init__.py │ ├── companionbotservice.py │ └── historyservice.py ├── settings.py ├── testing │ ├── __init__.py │ └── module_test_factory.py ├── tghelpers │ ├── __init__.py │ ├── direct_links.py │ ├── emoji_utils │ │ ├── __init__.py │ │ └── flags.py │ ├── entities │ │ ├── __init__.py │ │ └── message_entities.py │ └── names.py ├── types │ └── helpers.py ├── uncategorized │ ├── __init__.py │ └── buttons.py ├── unstable_experiments │ ├── __init__.py │ ├── declarative_definitions │ │ ├── __init__.py │ │ └── commands.py │ └── html_views │ │ ├── ViewSchema.xsd │ │ ├── __init__.py │ │ ├── experiment.xml │ │ └── view.py ├── utils │ ├── __init__.py │ ├── botkit_logging │ │ ├── __init__.py │ │ ├── chatlogger.py │ │ └── setup.py │ ├── cached_property.py │ ├── dataclass_helpers.py │ ├── datetime_utils.py │ ├── decorators.py │ ├── easy_expressions.py │ ├── legacy.py │ ├── nameof.py │ ├── scheduling.py │ ├── sentinel.py │ ├── strutils.py │ ├── timer.py │ ├── tracing.py │ └── typed_callable.py ├── views │ ├── __init__.py │ ├── base.py │ ├── botkit_context.py │ ├── functional_views.py │ ├── rendered_messages.py │ ├── sender_interface.py │ ├── success_view.py │ ├── types.py │ └── views.py └── widgets │ ├── DESIGN.md │ ├── WIDGET-IDEAS.md │ ├── __init__.py │ ├── _base │ ├── __init__.py │ ├── html_widget.py │ ├── menu_widget.py │ └── meta_widget.py │ ├── collapse │ └── __init__.py │ ├── list │ └── __init__.py │ └── pagination │ └── __init__.py ├── cli ├── __init__.py └── main.py ├── docs ├── Conversational UI.md ├── Conversational UI.pdf ├── Conversational UI.png ├── Makefile ├── TODO.md ├── Terminology.md ├── conf.py └── index.rst ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── tests ├── __init__.py ├── builders │ ├── callbackbuilder │ │ └── test_callbackbuilder.py │ └── test_menubuilder.py ├── commands │ ├── __init__.py │ └── test_command.py ├── configuration │ ├── __init__.py │ ├── client_config_test_data.py │ └── test_client_config.py ├── conftest.py ├── docker │ ├── DOCKERFILE │ ├── __init__.py │ ├── pyproject.toml │ └── test_docker.py ├── inlinequeries │ ├── __init__.py │ └── test_prefixbasedinlinequeryhandler.py ├── logging │ └── test_logging.py ├── module_tests │ ├── __init__.py │ └── test_func_module.py ├── routing │ ├── __init__.py │ ├── pipelines │ │ ├── __init__.py │ │ └── factories │ │ │ ├── __init__.py │ │ │ └── steps │ │ │ ├── __init__.py │ │ │ └── test_evaluate_send_target.py │ ├── plan │ │ ├── __init__.py │ │ └── test_update_types.py │ ├── test_publish_expression.py │ ├── test_routing.py │ └── test_state_machines.py ├── utils │ ├── __init__.py │ ├── test_easy_expressions.py │ └── test_typed_callables.py ├── views │ ├── __init__.py │ ├── test_functional_views.py │ └── test_view_validation.py └── widgets │ ├── __init__.py │ └── _base │ ├── __init__.py │ └── test_widget_types.py └── typings ├── cached_property └── __init__.pyi └── haps ├── __init__.pyi ├── application.pyi ├── config.pyi ├── container.pyi ├── exceptions.pyi └── scopes ├── __init__.pyi ├── instance.pyi └── singleton.pyi /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Botkit 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | repository_dispatch: 7 | types: [ publish_pypi ] 8 | workflow_dispatch: 9 | 10 | 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Cancel Previous Runs 16 | uses: styfle/cancel-workflow-action@0.5.0 17 | with: 18 | access_token: ${{ github.token }} 19 | - uses: actions/checkout@v2 20 | - uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.8 23 | - name: "Install Poetry" 24 | uses: Gr1N/setup-poetry@v3 25 | with: 26 | poetry-version: 1.0 27 | - name: Poetry install 28 | run: poetry install 29 | - name: Poetry publish 30 | run: poetry publish --build -u ${{ secrets.PYPI_USERNAME }} -p ${{ secrets.PYPI_PASSWORD }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | # Pyre type checker 116 | .pyre/ 117 | .idea 118 | /botkit.egg-info/ 119 | /tests/configuration/*.session 120 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # https://pre-commit.com/ 2 | default_language_version: 3 | python: python3.8 4 | exclude: ^botkit/utils/legacy 5 | repos: 6 | - repo: https://gitlab.com/PyCQA/flake8 7 | rev: 3.8.3 8 | hooks: 9 | - id: flake8 10 | args: ["--count", "--select=E9,F63,F7", "--show-source", "--statistics", "--max-complexity=10", "--max-line-length=127"] 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: v2.3.0 13 | hooks: 14 | - id: check-yaml 15 | - id: end-of-file-fixer 16 | - id: trailing-whitespace 17 | - repo: https://github.com/psf/black 18 | rev: 20.8b1 19 | hooks: 20 | - id: black 21 | - repo: https://github.com/pre-commit/mirrors-mypy 22 | rev: 'v0.770' 23 | hooks: 24 | - id: mypy 25 | files: ^telegram/.*\.py$ 26 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | formats: all 7 | 8 | python: 9 | version: 3.8 10 | install: 11 | - requirements: docs/requirements.txt 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.linting.enabled": true, 3 | "python.testing.pytestArgs": [ 4 | "tests" 5 | ], 6 | "python.testing.unittestEnabled": false, 7 | "python.testing.nosetestsEnabled": false, 8 | "python.testing.pytestEnabled": true, 9 | "python.pythonPath": "c:\\Users\\INT002327\\AppData\\Local\\pypoetry\\Cache\\virtualenvs\\botkit-H3ZeT93G-py3.8\\Scripts\\python.exe", 10 | "python.formatting.provider": "black", 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Autogram Botkit 2 | 3 | **Warning:** At this point, the framework is very much in an alpha stage and many parts will be subject to change. Do not depend on it in production. As of now, this is just the basis of my various bot projects, but I am very actively working on smoothing 4 | out the rough parts and delivering a comprehensive demo project using the framework. 5 | 6 | 28 | 29 | 30 | 31 | ## When is this framework for you? 32 | 33 | 1. You want to work on regular bots, userbots, or especially a _combination_ thereof (a.k.a. "companion bots") in 34 | Python. Botkit makes it easy to combine multiple Bot API or MTProto clients from different (!) libraries. 35 | 36 | 2. You have some experience with another Telegram bot library that you would like to supercharge with more tools, testability, and best practices. 37 | 38 | 3. You **care about clean, maintainable code** and tend to work on large code bases over long stretches of time 39 | 40 | 4. You know the basics of **asyncio**. 41 | 42 | 5. You're not afraid of Python type annotations and using Pydantic (https://pydantic-docs.helpmanual.io/) sounds like a good idea to you. 43 | 44 | 6. You use a Python IDE that supports autocompletion (and this is a must)! Botkit is built from the ground up to provide fluent builder patterns that only work well if you can discover what features you have at your disposal. 45 | 46 | 47 | ## Roadmap 48 | 49 | ### Implemented features 50 | 51 | - [ ] One config to rule them all: [Pyrogram and Telethon clients can be instantiated from a common `ClientConfig`](https://github.com/autogram/Botkit/blob/81cf9ec49ca0bde1a541605b62ca0bf9e2b055ef/botkit/configuration/client_config.py) 52 | 53 | ### In progress 54 | 55 | - [ ] 56 | 57 | ### In design phase 58 | 59 | - [ ] 60 | 61 | ### Under consideration 62 | 63 | - [ ] 64 | 65 | 97 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/api/__init__.py -------------------------------------------------------------------------------- /api/bot_api_schema/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import List, Dict 4 | 5 | 6 | with open(Path(__file__).parent / "bot_api_schema.json", "r", encoding="utf-8") as fp: 7 | bot_api_schema: List[Dict] = json.load(fp) 8 | 9 | 10 | # def get_methods(): 11 | # return [x for x in ] 12 | -------------------------------------------------------------------------------- /api/get_data.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Any, Callable 3 | 4 | # from pyrogram.client.methods import Methods 5 | # 6 | # 7 | # class APIMethods(Methods): 8 | # pass 9 | 10 | 11 | def format_method_sig(name: str, method: Callable): 12 | sig = inspect.signature(method) 13 | return f"{method.__name__}{str(sig).replace('self, ', '')}" 14 | 15 | 16 | def flt(m: Any) -> bool: 17 | if not inspect.isfunction(m): 18 | return False 19 | if m.__name__.startswith("_"): 20 | return False 21 | return True 22 | 23 | 24 | if __name__ == "__main__": 25 | for m in inspect.getmembers(APIMethods, predicate=flt): 26 | print(format_method_sig(*m)) 27 | -------------------------------------------------------------------------------- /assets/GitHub Social Logo Draft.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/assets/GitHub Social Logo Draft.xcf -------------------------------------------------------------------------------- /botkit/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import sys 4 | 5 | from importlib.metadata import version 6 | 7 | try: 8 | __version__ = version(__name__) 9 | except: 10 | __version__ = None 11 | 12 | -------------------------------------------------------------------------------- /botkit/abstractions/__init__.py: -------------------------------------------------------------------------------- 1 | from ._asyncloadunload import IAsyncLoadUnload 2 | from ._registerable import IRegisterable 3 | 4 | 5 | __all__ = ["IAsyncLoadUnload", "IRegisterable"] 6 | -------------------------------------------------------------------------------- /botkit/abstractions/_asyncloadunload.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Any, Iterable, NoReturn, Union 3 | 4 | 5 | class IAsyncLoadUnload(ABC): 6 | async def load(self) -> Union[NoReturn, Any]: 7 | """ 8 | Performs some asynchronous work on system startup. Telegram client instances will have been registered at this 9 | point, so running some initial startup tasks using live data is possible. 10 | 11 | Possible use-cases for `load`: 12 | - Starting worker futures on the event loop 13 | - Initializing caches 14 | - Anything that should happen on system start but requires async execution 15 | 16 | If you want an asynchronous load result to be available during module registration, you can return it here and 17 | it will be available under the `load_result` property of the route builder in the synchronous `Module.register`. 18 | If you decide to do so, make sure to annotate the generic `RouteBuilder` with the appropriate type. For 19 | example, if `load` returns an instance of `MyLoadResultType`, you want to annotate your module's `register` 20 | method with as `def register(self, routes: RouteBuilder[MyLoadResultType]): ...`. 21 | 22 | Returns: 23 | Either `None`, or any object that should be available under `routes.load_result`. 24 | """ 25 | 26 | async def unload(self) -> NoReturn: 27 | """ 28 | Reverts the actions taken by the `load` method. This is important for modules to deactivate correctly, as 29 | otherwise running workers might not terminate even if they should shut down together with the module. 30 | If you don't care about activation and deactivation of modules at runtime, you don't need this. 31 | """ 32 | 33 | def _get_loadable_members(self) -> "Iterable[IAsyncLoadUnload]": 34 | ... 35 | -------------------------------------------------------------------------------- /botkit/abstractions/_named.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class INamed(ABC): 5 | @property 6 | @abstractmethod 7 | def unique_name(self) -> str: 8 | ... 9 | -------------------------------------------------------------------------------- /botkit/abstractions/_registerable.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import TYPE_CHECKING, TypeVar 3 | 4 | if TYPE_CHECKING: 5 | from botkit.routing.route_builder.builder import RouteBuilder 6 | else: 7 | RouteBuilder = TypeVar("RouteBuilder") 8 | 9 | 10 | class IRegisterable(ABC): 11 | @classmethod 12 | def register(cls, routes: RouteBuilder): 13 | pass 14 | -------------------------------------------------------------------------------- /botkit/agnostic/__init__.py: -------------------------------------------------------------------------------- 1 | from botkit.agnostic.annotations import ( 2 | HandlerSignature, 3 | ReturnType, 4 | TArg, 5 | TCallbackQuery, 6 | TClient, 7 | TDeletedMessages, 8 | TInlineQuery, 9 | TMessage, 10 | TPoll, 11 | ) 12 | 13 | from .pyrogram_view_sender import PyrogramViewSender 14 | 15 | __all__ = [ 16 | "TClient", 17 | "TMessage", 18 | "TCallbackQuery", 19 | "TInlineQuery", 20 | "TPoll", 21 | "TDeletedMessages", 22 | "ReturnType", 23 | "TArg", 24 | "HandlerSignature", 25 | "PyrogramViewSender", 26 | ] 27 | -------------------------------------------------------------------------------- /botkit/agnostic/_pyrogram_update_type_inference.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import warnings 3 | from typing import Any, Dict, List, Set, Type 4 | 5 | import pyrogram 6 | from boltons.iterutils import flatten 7 | from pyrogram.handlers.handler import Handler 8 | 9 | from botkit.routing.update_types.updatetype import UpdateType 10 | from botkit.utils.typed_callable import TypedCallable 11 | 12 | PYROGRAM_UPDATE_TYPES: Dict[Type[pyrogram.types.Update], UpdateType] = { 13 | pyrogram.types.Message: UpdateType.message, 14 | pyrogram.types.CallbackQuery: UpdateType.callback_query, 15 | pyrogram.types.InlineQuery: UpdateType.inline_query, 16 | pyrogram.types.Poll: UpdateType.poll, 17 | # pyrogram.??? TODO: there is no one type to indicate user status 18 | } 19 | 20 | # noinspection PyUnresolvedReferences 21 | PYROGRAM_HANDLER_TYPES: Dict[UpdateType, Type[Handler]] = { 22 | UpdateType.message: pyrogram.handlers.MessageHandler, 23 | UpdateType.callback_query: pyrogram.handlers.CallbackQueryHandler, 24 | UpdateType.inline_query: pyrogram.handlers.InlineQueryHandler, 25 | UpdateType.poll: pyrogram.handlers.PollHandler, 26 | UpdateType.user_status: pyrogram.handlers.UserStatusHandler, 27 | } 28 | 29 | 30 | def determine_pyrogram_handler_update_types(handler: TypedCallable) -> Set[UpdateType]: 31 | used_arg_types = set(handler.type_hints.values()) 32 | if not used_arg_types: 33 | raise ValueError(f"No type hints specified for handler {handler}.") 34 | 35 | used_update_types = list(flatten([_get_update_types_from_union(a) for a in used_arg_types])) 36 | 37 | # Early return if no subclasses of `Update` are found 38 | if not any(used_update_types): 39 | warnings.warn( 40 | f"The signature of {handler} does not appear to be a Pyrogram handler function (no update type found in " 41 | f"type annotations)." 42 | ) 43 | 44 | # Find the concrete update type 45 | found_arg_types: Set[UpdateType] = set() 46 | for arg in used_update_types: 47 | for pyro_update_type, update_type in PYROGRAM_UPDATE_TYPES.items(): 48 | if issubclass(arg, pyro_update_type): 49 | found_arg_types.add(update_type) 50 | break # inner 51 | 52 | if not found_arg_types: 53 | raise ValueError( 54 | f"No matching update type found for handler {handler} with signature {handler.type_hints}." 55 | ) 56 | 57 | return found_arg_types 58 | 59 | 60 | def _get_update_types_from_union(t: Any) -> List[Type[pyrogram.types.Update]]: 61 | if _is_pyrogram_update_type(t): 62 | return [t] # Direct subclass 63 | else: 64 | # Get classes out of Union type 65 | if (args := getattr(t, "__args__", None)) is not None: 66 | return [x for x in args if _is_pyrogram_update_type(x)] 67 | 68 | return [] 69 | 70 | 71 | def _is_pyrogram_update_type(t: Any) -> bool: 72 | return inspect.isclass(t) and issubclass(t, pyrogram.types.Update) 73 | -------------------------------------------------------------------------------- /botkit/agnostic/annotations.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Any, 3 | Awaitable, 4 | Callable, 5 | Iterable, 6 | Optional, 7 | TYPE_CHECKING, 8 | TypeVar, 9 | Union, 10 | ) 11 | 12 | from botkit.agnostic.library_checks import is_installed 13 | from botkit.views.botkit_context import Context 14 | 15 | if TYPE_CHECKING: 16 | from botkit.clients.client import IClient 17 | else: 18 | IClient = None 19 | 20 | 21 | if is_installed("pyrogram"): 22 | from pyrogram import Client 23 | from pyrogram.types import CallbackQuery, InlineQuery, Message, Poll 24 | 25 | TClient = TypeVar("TClient", bound=Client) 26 | TMessage = TypeVar("TMessage", bound=Message) 27 | TCallbackQuery = TypeVar("TCallbackQuery", bound=CallbackQuery) 28 | TInlineQuery = TypeVar("TInlineQuery", bound=InlineQuery) 29 | TPoll = TypeVar("TPoll", bound=Poll) 30 | TDeletedMessages = TypeVar("TDeletedMessages", bound=Iterable[Message]) 31 | 32 | ReturnType = Optional[Union["_ViewBase", Any]] 33 | 34 | TArg = Union[IClient, TClient, TMessage, TCallbackQuery, TInlineQuery, TPoll] 35 | 36 | HandlerSignature = Union[ 37 | # Plain library handler signatures 38 | Callable[[TArg, TArg], Awaitable[ReturnType]], 39 | Callable[[TArg, TArg, TArg, TArg], Awaitable[ReturnType]], 40 | # Library routes with botkit context as last arg 41 | Callable[[TArg, TArg, Context], Awaitable[ReturnType]], 42 | Callable[[TArg, TArg, TArg, TArg, Context], Awaitable[ReturnType]], 43 | ] 44 | 45 | 46 | else: 47 | raise ValueError("No supported Python bot library found in installed modules.") 48 | -------------------------------------------------------------------------------- /botkit/agnostic/library_checks.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | import sys 4 | from typing import Literal 5 | from typing import * 6 | 7 | SupportedLibraryName = Literal["pyrogram", "telethon"] 8 | SUPPORTED_LIBRARIES = ["pyrogram", "telethon"] 9 | 10 | # noinspection PydanticTypeChecker 11 | library_client_types: Dict[SupportedLibraryName, Type] = {} 12 | 13 | try: 14 | from pyrogram import Client 15 | 16 | # noinspection PydanticTypeChecker 17 | library_client_types["pyrogram"] = Client 18 | except: 19 | pass 20 | try: 21 | from telethon import TelegramClient 22 | 23 | # noinspection PydanticTypeChecker 24 | library_client_types["telethon"] = TelegramClient 25 | except: 26 | pass 27 | 28 | __is_installed = lambda lib: lib in sys.modules 29 | supported_library_installations = {lib: __is_installed(lib) for lib in SUPPORTED_LIBRARIES} 30 | 31 | 32 | def is_installed(library: SupportedLibraryName) -> bool: 33 | ensure_supported(library) 34 | return supported_library_installations[library] 35 | 36 | 37 | def ensure_installed(library: SupportedLibraryName) -> NoReturn: 38 | if not is_installed(library): 39 | raise ImportError(f"You cannot use {library} as it is not installed.") 40 | 41 | 42 | def ensure_supported(library: SupportedLibraryName) -> NoReturn: 43 | if library not in SUPPORTED_LIBRARIES: 44 | raise KeyError(f"Sorry, the library '{library}' is not yet supported by Botkit.") 45 | 46 | 47 | def client_is_instance(client: Any, library: SupportedLibraryName) -> bool: 48 | ensure_supported(library) 49 | if is_installed(library): 50 | client_cls = library_client_types[library] 51 | return isinstance(client, client_cls) 52 | return False 53 | -------------------------------------------------------------------------------- /botkit/agnostic/pyrogram_chat_resolver.py: -------------------------------------------------------------------------------- 1 | from typing import Pattern, cast 2 | 3 | from botkit.utils.botkit_logging.setup import create_logger 4 | from tgtypes.identities.chat_identity import ChatIdentity, ChatType 5 | from tgtypes.interfaces.chatresolver import IChatResolver 6 | from tgtypes.primitives import Username 7 | from tgtypes.utils.async_lazy_dict import AsyncLazyDict 8 | 9 | try: 10 | # TODO: Turn this into a contextmanager, `with lib_check('Pyrogram'): import ...` 11 | from pyrogram import Client as PyrogramClient 12 | from pyrogram.types import Message, User 13 | except ImportError as e: 14 | raise ImportError( 15 | "The Pyrogram library does not seem to be installed, so using Botkit in Pyrogram flavor is not possible. " 16 | ) from e 17 | 18 | log = create_logger("chat_resolver") 19 | 20 | 21 | class PyrogramChatResolver(IChatResolver): 22 | def __init__(self, client: PyrogramClient): 23 | self.client = client 24 | self.context = AsyncLazyDict() 25 | 26 | async def resolve_chat_by_username(self, username: Username) -> ChatIdentity: 27 | chat = await self.context.setdefault_lazy("chat", self.client.get_chat(username)) 28 | return ChatIdentity(type=cast(ChatType, chat.type), peers=chat.id) 29 | 30 | async def resolve_chat_by_chat_id(self, chat_id: int) -> ChatIdentity: 31 | chat = await self.context.setdefault_lazy("chat", self.client.get_chat(chat_id)) 32 | return ChatIdentity(type=cast(ChatType, chat.type), peers=chat.id) 33 | 34 | async def resolve_chat_by_title_regex(self, title_regex: Pattern) -> ChatIdentity: 35 | LIMIT = 1000 36 | 37 | async for d in self.client.iter_dialogs(limit=LIMIT): 38 | # noinspection PyUnboundLocalVariable 39 | if ( 40 | (chat := getattr(d, "chat", None)) 41 | and (title := getattr(chat, "title", None)) 42 | and title_regex.match(title) 43 | ): 44 | return ChatIdentity(type=cast(ChatType, chat.type), peers=chat.id) 45 | 46 | raise ValueError( 47 | f"No chat found matching pattern {title_regex} in the uppermost {LIMIT} dialogs." 48 | ) 49 | -------------------------------------------------------------------------------- /botkit/builders/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Any, TYPE_CHECKING 2 | 3 | from haps import Container, inject 4 | 5 | from .callbackbuilder import CallbackBuilder 6 | 7 | if TYPE_CHECKING: 8 | from botkit.widgets import Widget 9 | 10 | from .htmlbuilder import HtmlBuilder 11 | from .menubuilder import MenuBuilder 12 | from .metabuilder import MetaBuilder 13 | from ..persistence.callback_store import ICallbackStore 14 | from ..settings import botkit_settings 15 | from ..views.rendered_messages import RenderedMessage, RenderedTextMessage 16 | 17 | 18 | class ViewBuilder: 19 | html: HtmlBuilder 20 | menu: MenuBuilder 21 | meta: MetaBuilder 22 | 23 | def __init__(self, callback_builder: CallbackBuilder): 24 | self.html = HtmlBuilder(callback_builder) 25 | self.menu = MenuBuilder(callback_builder) 26 | self.meta = MetaBuilder() 27 | 28 | def add(self, widget: "Widget"): 29 | self.html.add(widget) 30 | self.menu.add(widget) 31 | self.meta.add(widget) 32 | widget.render_html(self.html) 33 | 34 | @property 35 | def is_dirty(self) -> bool: 36 | return any((x.is_dirty for x in [self.html, self.menu, self.meta])) 37 | 38 | def render(self) -> RenderedMessage: 39 | # TODO: implement the other message types aswell 40 | html_text = self.html.render_html() 41 | rendered_menu = self.menu.render() 42 | return RenderedTextMessage( 43 | text=html_text, 44 | inline_buttons=rendered_menu, 45 | title=self.meta.title, 46 | description=self.meta.description, 47 | ) 48 | 49 | 50 | # def _determine_message_type(msg: RenderedMessageMarkup) -> MessageType: 51 | # if isinstance(msg, RenderedMessage): 52 | # if msg.media and msg.sticker: # keep this check updated with new values! 53 | # raise ValueError("Ambiguous message type.") 54 | # if msg.sticker: 55 | # return MessageType.sticker 56 | # elif msg.media: 57 | # return MessageType.media 58 | # return MessageType.text 59 | # elif isinstance(msg, RenderedPollMessage): 60 | # return MessageType.poll 61 | -------------------------------------------------------------------------------- /botkit/builders/callbackbuilder.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Any, Literal, Optional 3 | 4 | from botkit.abstractions._named import INamed 5 | from botkit.core.services import service 6 | from botkit.dispatching.types import CallbackActionType 7 | from botkit.persistence.callback_store import CallbackActionContext, ICallbackStore 8 | from botkit.routing.types import TViewState 9 | 10 | 11 | @service 12 | class CallbackBuilder: 13 | _SEPARATOR = "##" 14 | 15 | def __init__(self, state: TViewState, callback_store: ICallbackStore): 16 | self.state = state 17 | self._callback_store = callback_store 18 | self._prefix_parts = [] 19 | 20 | def create_callback( 21 | self, 22 | action_id: CallbackActionType, 23 | triggered_by: Literal["button", "command"], 24 | notification: Optional[str] = None, 25 | show_alert: bool = False, 26 | payload: Optional[Any] = None, 27 | ) -> str: 28 | context = CallbackActionContext( 29 | action=self._format_action(action_id), 30 | state=self.state, 31 | triggered_by=triggered_by, 32 | notification=notification, 33 | show_alert=show_alert, 34 | payload=payload, 35 | ) 36 | return self._callback_store.create_callback(context) 37 | 38 | def _format_action(self, action_id: CallbackActionType) -> str: 39 | action_id = self.__validate_action_id(action_id) 40 | if not self._prefix_parts: 41 | return action_id 42 | return f"{self._current_action_prefix}{self._SEPARATOR}{action_id}" 43 | 44 | def __validate_action_id(self, id_: str): 45 | if self._SEPARATOR in id_: 46 | raise ValueError( 47 | f"Sorry, but botkit uses the action substring '{self._SEPARATOR}' internally, so you cannot " 48 | f"use it." 49 | ) 50 | return id_.strip().replace(" ", "") 51 | 52 | @property 53 | def _current_action_prefix(self): 54 | return self._SEPARATOR.join(self._prefix_parts) 55 | 56 | @contextmanager 57 | def scope(self, entity: INamed): 58 | self.push_scope(entity) 59 | yield 60 | self.pop_scope() 61 | 62 | def push_scope(self, entity: INamed): 63 | """ 64 | Pushes an additional action prefix on the stack that all following builder methods will use until it is 65 | popped again using `pop_scope(entity)`. 66 | """ 67 | self._prefix_parts.append(self.__validate_action_id(entity.unique_name)) 68 | 69 | def pop_scope(self): 70 | """ 71 | Removes the most recently added action prefix from the stack. 72 | """ 73 | self._prefix_parts.pop() 74 | -------------------------------------------------------------------------------- /botkit/builders/htmlbuilder.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, NoReturn, TYPE_CHECKING, Union 2 | 3 | from botkit.builders.text.basetextbuilder import TState 4 | from botkit.builders.text.telegram_entity_builder import EntityBuilder 5 | from botkit.builders.text.typographybuilder import TypographyBuilder 6 | 7 | if TYPE_CHECKING: 8 | from botkit.widgets import HtmlWidget 9 | 10 | 11 | class HtmlBuilder(TypographyBuilder, EntityBuilder): 12 | def add(self, widget: "HtmlWidget") -> "HtmlBuilder": 13 | with self.callback_builder.scope(widget): 14 | widget.render_html(self) 15 | return self 16 | 17 | 18 | HtmlRenderer = Callable[[TState, HtmlBuilder], Union[NoReturn, Any]] 19 | -------------------------------------------------------------------------------- /botkit/builders/metabuilder.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from botkit.widgets import MetaWidget 5 | 6 | 7 | class MetaBuilder: 8 | def __init__(self): 9 | self.description = None 10 | self.title = None 11 | 12 | @property 13 | def is_dirty(self) -> bool: 14 | return self.description or self.title 15 | 16 | def add(self, widget: "MetaWidget") -> "MetaBuilder": 17 | widget.render_meta(self) 18 | return self 19 | -------------------------------------------------------------------------------- /botkit/builders/quizbuilder.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Dict, Any 2 | 3 | 4 | class QuizBuilder: 5 | def __init__(self): 6 | self.question: str = "Untitled Quiz" 7 | self._options: List[str] = [] 8 | self._is_anonymous: bool = True 9 | self._correct_option_id: Optional[int] = None 10 | 11 | def add_option(self, name: str, is_correct_answer: bool = None): 12 | if is_correct_answer is True: 13 | self._correct_option_id = len(self._options) 14 | self._options.append(name) 15 | return self 16 | 17 | def set_anonymous(self): 18 | self._is_anonymous = True 19 | return self 20 | 21 | def set_public(self): 22 | self._is_anonymous = False 23 | return self 24 | 25 | def set_question(self, title: str): 26 | self.question = title 27 | return self 28 | 29 | @property 30 | def title(self): 31 | return self.question 32 | 33 | @title.setter 34 | def title(self, value): 35 | self.question = value 36 | 37 | def render(self) -> Dict[str, Any]: 38 | return dict( 39 | question=self.question, 40 | options=self._options, 41 | is_anonymous=self._is_anonymous, 42 | allows_multiple_answers=False, 43 | type="quiz", 44 | correct_option_id=self._correct_option_id, 45 | ) 46 | -------------------------------------------------------------------------------- /botkit/builders/replymarkupbuilder.py: -------------------------------------------------------------------------------- 1 | class ReplyMarkupBuilder: 2 | pass 3 | -------------------------------------------------------------------------------- /botkit/builders/text/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/builders/text/__init__.py -------------------------------------------------------------------------------- /botkit/builders/text/basetextbuilder.py: -------------------------------------------------------------------------------- 1 | from pprint import pprint 2 | from haps import Container 3 | from typing import Any, Optional, TypeVar 4 | 5 | from pyrogram.parser import Parser 6 | from pyrogram.types.messages_and_media.message import Str 7 | 8 | from botkit.builders.callbackbuilder import CallbackBuilder 9 | from botkit.persistence.callback_store import ICallbackStore 10 | from botkit.settings import botkit_settings 11 | 12 | TState = TypeVar("TState") 13 | 14 | 15 | class BaseTextBuilder: 16 | def __init__(self, callback_builder: CallbackBuilder): # TODO: make non-optional 17 | self.parts = [] 18 | self.callback_builder = callback_builder 19 | 20 | @property 21 | def is_dirty(self) -> bool: 22 | return bool(self.parts) 23 | 24 | def raw(self, text: str, end=""): 25 | return self._append_with_end(text, end) 26 | 27 | def spc(self): 28 | self.parts.append(" ") 29 | return self 30 | 31 | def br(self, count: int = 1): 32 | self.parts.append("\n" * count) 33 | return self 34 | 35 | def para(self): 36 | self.parts.append("\n\n") 37 | return self 38 | 39 | def _append(self, text: str): 40 | self.parts.append(text) 41 | return self 42 | 43 | def _append_with_end(self, text: str, end: Optional[str]): 44 | self.parts.append(self._apply_end(text, end)) 45 | return self 46 | 47 | @staticmethod 48 | def _apply_end(text: str, end: Optional[str]) -> str: 49 | if text is None: 50 | raise ValueError("Trying to append None value.") 51 | 52 | text = str(text) 53 | 54 | if text is None: 55 | raise ValueError(f"Cannot append '{text}' to message.") 56 | if end in ["", None]: 57 | return text 58 | else: 59 | return text + str(end) 60 | 61 | def render_html(self) -> str: 62 | result = "".join(self.parts) 63 | if not result or result.isspace(): 64 | return "\xad" # zero-width char 65 | return result 66 | 67 | async def render_and_parse(self) -> Str: 68 | res = await (Parser(None)).parse(self.render_html(), "html") 69 | return Str(res["message"]).init(res["entities"]) 70 | -------------------------------------------------------------------------------- /botkit/builders/text/iconographybuilder.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from botkit.builders.text.basetextbuilder import BaseTextBuilder 4 | from botkit.builders.text.emoji import replace_aliases 5 | 6 | 7 | class Iconography: 8 | ZERO_WIDTH_WHITESPACE = "\xad" 9 | EMOJI_NUMBERS = "0️⃣1️⃣2️⃣3️⃣4️⃣5️⃣6️⃣7️⃣8️⃣9️⃣" 10 | 11 | 12 | class IconographyBuilder(BaseTextBuilder): 13 | def emoji_spc(self): 14 | """ Renders the horizontal width of an emoji as two `en` whitespace characters (U+2002) """ 15 | self.parts.append("    ") 16 | return self 17 | 18 | def zero_width_whitespace_1(self): 19 | self.parts.append(Iconography.ZERO_WIDTH_WHITESPACE) 20 | return self 21 | 22 | def emojize(self, alias: str): 23 | self.parts.append(replace_aliases(alias)) 24 | return self 25 | 26 | def dash_long(self, end: Optional[str] = " "): 27 | return self._append_with_end("—", end) 28 | 29 | @classmethod 30 | def as_number_emoji(cls, n: int) -> str: 31 | idx = str(n) 32 | result = [] 33 | 34 | for char in idx: 35 | i = (int(char)) * 3 36 | result += Iconography.EMOJI_NUMBERS[i : i + 3] 37 | 38 | return "".join(result) 39 | 40 | def number_emoji(self, num: int): 41 | self.parts.append(self.as_number_emoji(num)) 42 | return self 43 | -------------------------------------------------------------------------------- /botkit/builders/text/mdformat.py: -------------------------------------------------------------------------------- 1 | MAX_LINE_CHARACTERS = 31 2 | 3 | 4 | def smallcaps(text): 5 | SMALLCAPS_CHARS = "ᴀʙᴄᴅᴇғɢʜɪᴊᴋʟᴍɴᴏᴘǫʀsᴛᴜᴠᴡxʏᴢ" 6 | lowercase_ord = 96 7 | uppercase_ord = 64 8 | 9 | result = "" 10 | for i in text: 11 | index = ord(i) 12 | if 122 >= index >= 97: 13 | result += SMALLCAPS_CHARS[index - lowercase_ord - 1] 14 | elif 90 >= index >= 65: 15 | result += SMALLCAPS_CHARS[index - uppercase_ord - 1] 16 | elif index == 32: 17 | result += " " 18 | 19 | return result 20 | 21 | 22 | def strikethrough(text: str): 23 | SPEC = "̶" 24 | return "".join([x + SPEC if x != " " else " " for x in text]) 25 | 26 | 27 | UNICODE_NUMBERS = "➊ ➋ ➌ ➍ ➎ ➏ ➐ ➑ ➒ ➓" 28 | 29 | 30 | def number_as_unicode(n): 31 | if n not in range(1, 10): 32 | raise ValueError("Sorry, can't do anything with this number.") 33 | 34 | return UNICODE_NUMBERS[int(n) * 2 - 2] 35 | 36 | 37 | def centered(text): 38 | result = "\n".join([line.center(MAX_LINE_CHARACTERS) for line in text.splitlines()]) 39 | return result 40 | 41 | 42 | # def success(text): 43 | # return '{} {}'.format(Emoji.WHITE_HEAVY_CHECK_MARK, text, hide_keyboard=True) 44 | # 45 | # 46 | # def love(text): 47 | # return '💖 {}'.format(text, hide_keyboard=True) 48 | # 49 | # 50 | # def failure(text): 51 | # return '{} {}'.format(Emoji.CROSS_MARK, text) 52 | # 53 | # 54 | # def action_hint(text): 55 | # return '💬 {}'.format(text) 56 | # 57 | # 58 | # def none_action(text): 59 | # return '{} {}'.format(Emoji.NEGATIVE_SQUARED_CROSS_MARK, text) 60 | -------------------------------------------------------------------------------- /botkit/builtin_modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/builtin_modules/__init__.py -------------------------------------------------------------------------------- /botkit/builtin_modules/module_manager/__init__.py: -------------------------------------------------------------------------------- 1 | from haps import Inject 2 | from pyrogram.filters import command 3 | 4 | from botkit.core.modules import Module 5 | from botkit.core.modules.activation import ModuleLoader 6 | from botkit.routing.route_builder.builder import RouteBuilder 7 | from botkit.services.companionbotservice import CompanionBotService 8 | from .paged_module_view import PagedModuleView 9 | from .view_models import ModuleInfo, ModuleInfosCollectionModel 10 | from botkit.clients.client import IClient 11 | 12 | 13 | class ModuleManagerModule(Module): 14 | module_loader: ModuleLoader = Inject() 15 | 16 | def __init__(self, user_client: IClient, bot_client: IClient): 17 | self.user_client = user_client 18 | self.bot_client = bot_client 19 | self.companion = CompanionBotService(user_client=user_client, bot_client=bot_client) 20 | 21 | def register(self, routes: RouteBuilder): 22 | with routes.using(self.user_client): 23 | ( 24 | routes.on(command("modules", prefixes=["#", "/"])) 25 | .gather(self.get_modules) 26 | .then_send(PagedModuleView, via_bot=self.bot_client) 27 | ) 28 | 29 | with routes.using(self.bot_client): 30 | ( 31 | routes.on_action("page_back") 32 | .mutate(ModuleInfosCollectionModel.flip_previous_page) 33 | .then_update(PagedModuleView) 34 | ) 35 | ( 36 | routes.on_action("page_forward") 37 | .mutate(ModuleInfosCollectionModel.flip_next_page) 38 | .then_update(PagedModuleView) 39 | ) 40 | ( 41 | routes.on_action("deactivate") 42 | .mutate(self.deactivate_module) 43 | .then_update(PagedModuleView) 44 | ) 45 | ( 46 | routes.on_action("activate") 47 | .mutate(self.activate_module) 48 | .then_update(PagedModuleView) 49 | ) 50 | 51 | def get_modules(self) -> ModuleInfosCollectionModel: 52 | return ModuleInfosCollectionModel( 53 | all_items=[ 54 | ModuleInfo.from_module(m, self.module_loader) 55 | for m in self.module_loader.modules 56 | if m.route_collection 57 | ] 58 | ) 59 | 60 | async def activate_module(self, module_info: ModuleInfosCollectionModel): 61 | module_to_enable = module_info.page_items[0] 62 | module_name = module_to_enable.name 63 | module = self.module_loader.get_module_by_name(module_name) 64 | await self.module_loader.try_activate_module_async(module) 65 | module_to_enable.module_state = self.module_loader.get_module_status(module) 66 | return module_info 67 | 68 | async def deactivate_module(self, module_info: ModuleInfosCollectionModel): 69 | module_to_disable = module_info.page_items[0] 70 | module_name = module_to_disable.name 71 | module = self.module_loader.get_module_by_name(module_name) 72 | await self.module_loader.deactivate_module_async(module) 73 | module_to_disable.module_state = self.module_loader.get_module_status(module) 74 | return module_info 75 | -------------------------------------------------------------------------------- /botkit/builtin_modules/module_manager/paged_module_view.py: -------------------------------------------------------------------------------- 1 | from botkit.builders.htmlbuilder import HtmlBuilder 2 | from botkit.builders.menubuilder import MenuBuilder 3 | from .view_models import ModuleInfosCollectionModel, ModuleInlineContext 4 | from botkit.views.views import TextView 5 | from ...builders import CallbackBuilder 6 | 7 | 8 | class PagedModuleView(TextView[ModuleInfosCollectionModel]): 9 | def render_body(self, builder: HtmlBuilder) -> None: 10 | for m in self.state.page_items: 11 | builder.bold(m.name) 12 | if not m.is_active: 13 | builder.spc().italic("(deactivated)") 14 | 15 | builder.enforce_min_width(70) 16 | 17 | if m.route_descriptions: 18 | builder.text("Routes: ") 19 | 20 | for r in m.route_descriptions: 21 | builder.br().list_item("") 22 | 23 | if m.is_active: 24 | builder.text(r) 25 | else: 26 | builder.strike(r) 27 | else: 28 | builder.text("No routes.") 29 | 30 | def render_markup(self, builder: MenuBuilder) -> None: 31 | row_builder = builder.rows[99] 32 | if self.state.has_previous_page: 33 | row_builder.action_button("⬅️", "page_back", self.state) 34 | row_builder.switch_inline_button( 35 | str(self.state.current_page_number), ModuleInlineContext("Module") 36 | ) 37 | if self.state.has_next_page: 38 | row_builder.action_button("➡️", "page_forward", self.state) 39 | 40 | for n, info in enumerate(self.state.page_items): 41 | if info.name not in "ModuleManagerModule": 42 | caption = HtmlBuilder(None) 43 | caption.text("Deactivate" if info.is_active else "Activate") 44 | caption.spc().text(info.name) 45 | builder.rows[n + 1].action_button( 46 | caption.render_html(), 47 | "deactivate" if info.is_active else "activate", 48 | self.state, 49 | ) 50 | -------------------------------------------------------------------------------- /botkit/builtin_modules/module_manager/pagination_model.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, Generic, Iterable, List 2 | 3 | from pydantic import BaseModel 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | class PaginationModel(Generic[T]): 9 | def __init__(self, all_items: Iterable[T] = None, items_per_page: int = 1): 10 | self.all_items = list(all_items or []) 11 | self._current_page = 0 12 | self.items_per_page = items_per_page 13 | 14 | @property 15 | def _begin(self): 16 | return self._current_page * self.items_per_page 17 | 18 | @property 19 | def _end(self): 20 | return min((self._current_page + 1) * self.items_per_page, self.item_count) 21 | 22 | @property 23 | def item_count(self): 24 | return len(self.all_items) 25 | 26 | @property 27 | def page_items(self) -> List[T]: 28 | return self.all_items[self._begin : self._end] 29 | 30 | @property 31 | def current_page_number(self) -> int: 32 | return self._current_page + 1 33 | 34 | @property 35 | def has_next_page(self) -> bool: 36 | return self._end < self.item_count 37 | 38 | @property 39 | def has_previous_page(self) -> bool: 40 | return self._begin > 0 41 | 42 | def flip_next_page(self): 43 | self._current_page += 1 44 | 45 | def flip_previous_page(self): 46 | self._current_page -= 1 47 | -------------------------------------------------------------------------------- /botkit/builtin_modules/module_manager/view_models.py: -------------------------------------------------------------------------------- 1 | from boltons.iterutils import flatten 2 | from pydantic import BaseModel 3 | from typing import List, Iterable 4 | 5 | from botkit.core.modules._module import Module 6 | from botkit.builtin_modules.module_manager.pagination_model import PaginationModel 7 | from botkit.core.modules.activation import ModuleLoader, ModuleStatus 8 | from botkit.inlinequeries.contexts import PrefixBasedInlineModeContext 9 | 10 | 11 | class ModuleInfo(BaseModel): 12 | name: str 13 | route_descriptions: List[str] 14 | module_state: ModuleStatus 15 | 16 | @classmethod 17 | def from_module(cls, module: Module, loader: ModuleLoader) -> "ModuleInfo": 18 | return ModuleInfo( 19 | name=module.get_name(), 20 | route_descriptions=[ 21 | m.description for m in flatten(module.route_collection.routes_by_client.values()) 22 | ], 23 | module_state=loader.get_module_status(module), 24 | ) 25 | 26 | @property 27 | def is_active(self): 28 | return self.module_state == ModuleStatus.active 29 | 30 | 31 | class ModuleInfosCollectionModel(PaginationModel[ModuleInfo]): 32 | def __init__(self, all_items: Iterable[ModuleInfo]): 33 | super().__init__(all_items, items_per_page=1) 34 | 35 | 36 | class ModuleInlineContext(PrefixBasedInlineModeContext): 37 | pass 38 | -------------------------------------------------------------------------------- /botkit/builtin_modules/system/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/builtin_modules/system/__init__.py -------------------------------------------------------------------------------- /botkit/builtin_modules/system/system_tests.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from unittest.mock import Mock 3 | 4 | from haps import Container, Inject 5 | from pyrogram.types import Chat, Message, User 6 | 7 | from botkit.core.modules import Module 8 | from botkit.core.modules.activation import ModuleLoader 9 | from botkit.persistence.callback_store import ICallbackStore 10 | from botkit.routing.route import RouteDefinition, RouteHandler 11 | from botkit.routing.route_builder.builder import RouteBuilder 12 | from botkit.routing.update_types.updatetype import UpdateType 13 | from botkit.settings import botkit_settings 14 | from botkit.clients.client import IClient 15 | 16 | 17 | def notests(func): 18 | func.notests = True 19 | return func 20 | 21 | 22 | class SelftestModule(Module): 23 | loader: ModuleLoader = Inject() 24 | 25 | def register(self, routes: RouteBuilder): 26 | pass 27 | 28 | async def load(self) -> None: 29 | try: 30 | Container().get_object(ICallbackStore, botkit_settings.callback_manager_qualifier) 31 | except Exception as ex: 32 | self.log.exception("Callback manager could not be instantiated.") 33 | if botkit_settings.callback_manager_qualifier != "memory": 34 | self.log.warning("Falling back to `memory` callback manager.") 35 | botkit_settings.callback_manager_qualifier = "memory" 36 | 37 | return # TODO: implement 38 | for m in self.loader.modules: 39 | 40 | if not m.route_collection: 41 | continue 42 | 43 | for client, routes in m.route_collection.routes_by_client.items(): 44 | await self.test_module_routes(routes) 45 | 46 | async def unload(self) -> None: 47 | return await super().unload() 48 | 49 | async def test_module_routes(self, routes: List[RouteDefinition]): 50 | for route in routes: 51 | for update_type, route_wrapper in route.handler_by_update_type.items(): 52 | await self.fire_request(update_type, route_wrapper) 53 | 54 | async def fire_request(self, update_type: UpdateType, route: RouteHandler): 55 | try: 56 | # noinspection PyUnresolvedReferences 57 | should_not_test = route.callback.notests 58 | return 59 | except AttributeError: 60 | pass 61 | 62 | client = Mock(IClient) 63 | if update_type == UpdateType.message: 64 | message = Mock(Message) 65 | (user := Mock(User)).configure_mock() 66 | (chat := Mock(Chat)).configure_mock(id=12345) 67 | message.configure_mock( 68 | message_id=12345, 69 | command="test", 70 | from_user=user, 71 | chat=chat, 72 | text="test", 73 | forward_from=None, 74 | reply_to_message=None, 75 | ) 76 | try: 77 | res = await route.callback(client, message) 78 | print(res) 79 | except Exception as ex: 80 | self.log.exception(ex) 81 | -------------------------------------------------------------------------------- /botkit/builtin_services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/builtin_services/__init__.py -------------------------------------------------------------------------------- /botkit/builtin_services/bettermarkdown/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/builtin_services/bettermarkdown/__init__.py -------------------------------------------------------------------------------- /botkit/builtin_services/bettermarkdown/_base.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/builtin_services/bettermarkdown/_base.py -------------------------------------------------------------------------------- /botkit/builtin_services/bettermarkdown/config.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/builtin_services/bettermarkdown/config.py -------------------------------------------------------------------------------- /botkit/builtin_services/eventing/__init__.py: -------------------------------------------------------------------------------- 1 | from .botkit_event_bus import BotkitEventBus, BotkitCommandBus 2 | 3 | event_bus = BotkitEventBus() 4 | command_bus = BotkitCommandBus() 5 | -------------------------------------------------------------------------------- /botkit/builtin_services/eventing/botkit_event_bus.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import inspect 4 | 5 | import logging 6 | from buslane.commands import CommandBus, Command, CommandHandler 7 | from buslane.events import EventBus, EventHandler, Event 8 | 9 | from botkit.core.services import service 10 | 11 | 12 | @service 13 | class BotkitEventBus(EventBus): 14 | def __init__(self) -> None: 15 | super().__init__() 16 | self.log = logging.getLogger(self.__class__.__name__) 17 | 18 | def handle(self, event: Event, handler: EventHandler) -> None: 19 | self.log.info(f"Handling event {event} by {handler}") 20 | res = handler.handle(event) 21 | if inspect.isawaitable(res): 22 | asyncio.ensure_future(res) 23 | 24 | 25 | @service 26 | class BotkitCommandBus(CommandBus): 27 | def __init__(self) -> None: 28 | super().__init__() 29 | self.log = logging.getLogger(self.__class__.__name__) 30 | 31 | def handle(self, command: Command, handler: CommandHandler) -> None: 32 | self.log.info(f"Handling event {command} by {handler}") 33 | res = handler.handle(command) 34 | if inspect.isawaitable(res): 35 | asyncio.ensure_future(res) 36 | -------------------------------------------------------------------------------- /botkit/builtin_services/nlu/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/builtin_services/nlu/__init__.py -------------------------------------------------------------------------------- /botkit/builtin_services/nlu/dialogflowconfig.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class DialogflowConfig: 8 | project_id: str 9 | json_credentials_file: Path or str 10 | 11 | KEY = "dialogflow_config" 12 | -------------------------------------------------------------------------------- /botkit/builtin_services/nlu/messageunderstanding.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Dict 3 | 4 | from dataclasses import dataclass 5 | 6 | 7 | @dataclass 8 | class MessageUnderstanding: 9 | """ 10 | Dataclass for an incoming utterance, enriched with NLU information. 11 | """ 12 | 13 | text: str 14 | language_code: str 15 | intent: str 16 | action: str 17 | parameters: Dict[str, str] = None 18 | contexts: Dict[str, str] = None 19 | date: datetime = None 20 | confidence: float = None 21 | -------------------------------------------------------------------------------- /botkit/builtin_services/options/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/builtin_services/options/__init__.py -------------------------------------------------------------------------------- /botkit/builtin_services/sr/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/builtin_services/sr/__init__.py -------------------------------------------------------------------------------- /botkit/builtin_services/sr/speechrecognition.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | try: 5 | import ffmpy 6 | import speech_recognition as sr 7 | except ImportError: 8 | ffmpy = None 9 | sr = None 10 | 11 | 12 | class VoiceRecognitionClient: 13 | def __init__(self): 14 | self.recognizer = sr.Recognizer() 15 | 16 | @staticmethod 17 | def convert_audio_ffmpeg(in_file, out_file): 18 | if os.path.exists(out_file): 19 | os.remove(out_file) 20 | ff = ffmpy.FFmpeg(inputs={in_file: None}, outputs={out_file: None}) 21 | ff.run(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 22 | return out_file 23 | 24 | def recognize(self, filepath, language=None): 25 | with sr.AudioFile(filepath) as source: 26 | audio = self.recognizer.record(source) # read the entire audio file 27 | return self.recognizer.recognize_google_cloud(audio, None, language=language) 28 | -------------------------------------------------------------------------------- /botkit/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/clients/__init__.py -------------------------------------------------------------------------------- /botkit/clients/client.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Generic, Iterable, Optional, Protocol, TypeVar, Union 3 | 4 | from botkit.views.sender_interface import IViewSender 5 | 6 | 7 | class IdentifiableUser(Protocol): 8 | id: int 9 | username: str 10 | 11 | 12 | Message = TypeVar("Message") 13 | 14 | 15 | class IClient(IViewSender[Message], ABC, Generic[Message]): 16 | own_user_id: int 17 | own_username: Optional[str] 18 | 19 | @property 20 | @abstractmethod 21 | def is_bot(self) -> bool: 22 | ... 23 | 24 | @property 25 | @abstractmethod 26 | def is_user(self) -> bool: 27 | ... 28 | 29 | @abstractmethod 30 | async def get_me(self) -> IdentifiableUser: 31 | ... 32 | 33 | # TODO: mark as @abstractmethod 34 | async def delete_messages( 35 | self, 36 | chat_id: Union[int, str], 37 | message_ids: Union[int, Message, Iterable[Union[int, Message]]], 38 | revoke: bool = True, 39 | ) -> bool: 40 | ... 41 | 42 | # TODO: mark as @abstractmethod 43 | async def get_inline_bot_results(self, bot_username, query_text): 44 | pass 45 | -------------------------------------------------------------------------------- /botkit/clients/mixins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/clients/mixins/__init__.py -------------------------------------------------------------------------------- /botkit/commands/TODO.md: -------------------------------------------------------------------------------- 1 | # Conversation Proxy 2 | 3 | # Commands, Actions, QuickActions 4 | 5 | ## How it's gonna work 6 | Commands from all modules get registered in a central place. 7 | A singleton (compound, all types) internal handler goes through all registered commands and builds a list 8 | `quick_action_matches: List[CommandDefinition]`. 9 | Then it replies with reply keyboard buttons. `botkit.use_quick_actions()` ?? 10 | 11 | modulemanager 12 | 13 | ``` 14 | routes.register_command() 15 | ``` 16 | 17 | ## TODO 18 | - [ ] add convenient way of constructing CommandDefinition 19 | - [ ] "exclusive" status, where only this single quick action will apply (based on priority over others) 20 | - [ ] debounce the responses (1 second) and allow merges like stickerstorybot (messagemerger) 21 | - [ ] Make it possible to configure the chats in which quick actions trigger: 22 | - `FindChat(title=r'.*Ideas.*Thoughts$', mine=True)` 23 | - `FindChat(title=r'.*Todo$', mine=True)` 24 | - `FindChat(username='@josxabot')` 25 | - `FindChat(title='sovorel sprachen')` 26 | 27 | ## Quick Action Registry (Ideas) 28 | 29 | ### filters.text 30 | (Most common. Needs a good hierarchical menu) 31 | - **Use in inline query** -> "Which bot @?" - @letmebot -> Share button 32 | - **Remind me** 33 | -> `ChoiceView("Who to remind?", ["Just me", "Someone else", "Me and others"])` 34 | -> `DateTimePickerView("When?")` 35 | - **Edit** -> `send_as_user(...)` (so that user can edit) 36 | - **Share** -> `ShareView(...)` 37 | 38 | ### filters.text (multiple) 39 | "Does this message belong to the others?" yes/no 40 | - **Merge** -> 41 | - **Delete** (if forwarded and user admin) 42 | -> "This will delete messages 1-3, 7, and 8 from {origin_chat_title}" (yes/no) 43 | - **Open in VSCode** 44 | 45 | ### filters.link 46 | - **Open in browser** -> `ChoiceView(links) if len(links) > 1 else links[0]` 47 | - **Add to Pocket** -> `ChoiceView(links, multiple=True) if len(links) > 1 else links[0]` 48 | 49 | ### Integrations 50 | 51 | - Todoist 52 | - Add as Task 53 | - Notion 54 | - Add to project -> `ChoiceView(...)` 55 | 56 | ### filters.sticker 57 | - Add to pack 58 | - Optimize 59 | 60 | ### filters.sticker (multiple) 61 | - Merge 62 | 63 | ### filters.command 64 | - Choose bot 65 | 66 | ### filters.contains_emoji 67 | - Explain emojis 68 | 69 | # Editor 70 | 71 | The `EditorView` consists of a message with quick actions above and the content to edit in its own message below: 72 | ``` 73 | **Actions** 74 | [Share] [Append] [Open in VSCode] 75 | ``` 76 | 77 | ## Actions 78 | - Apply a template -> `ChoiceView(...)` 79 | - Share -> `ShareView(...)` 80 | - As draft 81 | 82 | 83 | 84 | # For Nukesor 85 | 86 | ## Prototype of hierarchical settings module 87 | 88 | Click on setting -> opens group of related settings (and back button) 89 | -------------------------------------------------------------------------------- /botkit/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/commands/__init__.py -------------------------------------------------------------------------------- /botkit/commands/command.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from mypy_extensions import TypedDict 4 | from pyrogram.filters import Filter 5 | from pyrogram.types import Update 6 | from typing import Union, Dict, List, Tuple, Optional 7 | from typing_extensions import Literal 8 | import pyrogram 9 | 10 | from botkit.utils.dataclass_helpers import default_field 11 | 12 | default_prefixes: List[str] = ["/", "#", "."] 13 | 14 | CommandCategory = TypedDict("CommandCategory", {"privacy": Literal["self", "public"]}) 15 | 16 | command_categories: Dict[str, CommandCategory] = { 17 | "user": {"privacy": "self"}, 18 | "bot": {"privacy": "public"}, 19 | } 20 | 21 | 22 | # trigger_dict = {"private": True, "contains_url": True} 23 | # 24 | # trigger_text = "private & containsUrl" 25 | # 26 | # command_definition_yaml_example = """ 27 | # browse: 28 | # quick_action_filter: 29 | # - private 30 | # - contains_url 31 | # quick_action_prompt: yes 32 | # """ 33 | 34 | 35 | # CommandDefinition( 36 | # name="browse", 37 | # quick_action=QuickAction( 38 | # trigger=EntityFilters.url & filters.private, 39 | # ), 40 | # quick_action_prompt=True, # actions get included in quick actions reply keyboard 41 | # delete_query=True 42 | # ) 43 | 44 | 45 | @dataclass 46 | class CommandParser: 47 | name: str 48 | aliases: List[str] = field(default_factory=list) 49 | prefixes: List[str] = default_field(default_prefixes) 50 | 51 | @classmethod 52 | def from_declaration( 53 | cls, value: Union[str, List[str], Tuple[str], "CommandParser"] 54 | ) -> "CommandParser": 55 | if isinstance(value, CommandParser): 56 | return value # for when user already provides initialized object 57 | if isinstance(value, (tuple, list)): 58 | return CommandParser(name=value[0], aliases=list(value[1:])) 59 | else: 60 | return CommandParser(name=value) 61 | 62 | 63 | @dataclass 64 | class QuickAction: 65 | trigger: Filter 66 | prominence: int = 0 # or "priority", or "importance"..? 67 | 68 | 69 | @dataclass 70 | class CommandDefinition: 71 | name: str 72 | parser: CommandParser 73 | 74 | quick_action: Optional[QuickAction] = None 75 | 76 | 77 | # region Routing 78 | 79 | QUICK_ACTION_UPDATE_TYPES = (pyrogram.types.Message, pyrogram.types.Poll) 80 | 81 | 82 | async def match_quick_actions(commands: List[CommandDefinition], update: Update): 83 | if not isinstance(update, QUICK_ACTION_UPDATE_TYPES): 84 | return 85 | 86 | 87 | # endregion 88 | -------------------------------------------------------------------------------- /botkit/components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/components/__init__.py -------------------------------------------------------------------------------- /botkit/components/questionnaire.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Any, Type, Union, Optional 2 | 3 | from botkit.utils.sentinel import NotSet, Sentinel 4 | from pydantic import BaseModel 5 | 6 | from botkit.core.components import Component 7 | from botkit.routing.route_builder.builder import RouteBuilder 8 | from botkit.views.botkit_context import Context 9 | 10 | 11 | class Questionnaire(BaseModel): 12 | pass 13 | 14 | 15 | class Slot(object): 16 | def __init__(self, query: str = None): 17 | self.query = query 18 | 19 | def __get__(self, instance: Any, objtype: Type): 20 | print("getting", instance, objtype) 21 | pass 22 | 23 | def __set__(self, instance, val): 24 | print("setting", instance, val) 25 | pass 26 | 27 | 28 | class QuestionnaireComponent(Component): 29 | def __init__(self, name: str, slots: Iterable[Slot]): 30 | self.name = name 31 | self._slots = slots 32 | 33 | def register(self, routes: RouteBuilder): 34 | pass 35 | 36 | async def invoke(self, context: Context): 37 | for s in self._slots: 38 | pass 39 | -------------------------------------------------------------------------------- /botkit/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | from botkit.configuration.client_config import ( 2 | BotToken, 3 | ClientConfig, 4 | ClientConfigurationError, 5 | ClientType, 6 | PhoneNumber, 7 | APIConfig, 8 | ) 9 | -------------------------------------------------------------------------------- /botkit/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/core/__init__.py -------------------------------------------------------------------------------- /botkit/core/components.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from abc import ABC, abstractmethod 3 | from typing import Any, Generic, Optional, TypeVar 4 | 5 | import loguru 6 | 7 | from botkit.abstractions import IAsyncLoadUnload, IRegisterable 8 | from botkit.routing.types import TViewState 9 | from botkit.views.botkit_context import Context 10 | 11 | # TODO: make sure components get properly destroyed/garbage collected when they're not needed anymore 12 | # TODO: components can only have parameterless constructor..??? 13 | 14 | TCompState = TypeVar("TCompState") 15 | 16 | 17 | class Component(Generic[TViewState, TCompState], IAsyncLoadUnload, IRegisterable, ABC): 18 | _logger: Optional[loguru.Logger] 19 | _is_registered: bool 20 | 21 | _unique_index: Optional[int] = None 22 | _index_counter: int = 0 23 | 24 | def __new__(cls, *args, **kwargs) -> Any: 25 | Component._index_counter += 1 26 | instance: Component = super().__new__(cls, *args, **kwargs) 27 | instance._is_registered = False 28 | instance._unique_index = Component._index_counter 29 | return instance 30 | 31 | @abstractmethod 32 | async def invoke(self, context: Context): 33 | ... 34 | 35 | @property 36 | def log(self) -> loguru.Logger: 37 | return loguru.logger 38 | 39 | @property 40 | def logger(self) -> loguru.Logger: 41 | return loguru.logger 42 | -------------------------------------------------------------------------------- /botkit/core/modules/__init__.py: -------------------------------------------------------------------------------- 1 | from ._module import Module 2 | from ._moduledecorator import module 3 | from ._registration import register_module_with_route_builder 4 | 5 | __all__ = ["module", "Module", "activation", "register_module_with_route_builder"] 6 | -------------------------------------------------------------------------------- /botkit/core/modules/_module.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import abstractmethod 4 | from typing import Any, Optional 5 | 6 | import loguru 7 | from haps import base 8 | from loguru._logger import Logger 9 | 10 | from botkit.abstractions import IAsyncLoadUnload 11 | from botkit.routing.route_builder.builder import RouteBuilder 12 | from botkit.routing.route_builder.route_collection import RouteCollection 13 | 14 | 15 | @base 16 | class Module(IAsyncLoadUnload): # Not marked as ABC to allow dynamic creation 17 | _logger: Optional[Logger] 18 | 19 | # Properties assigned by the ModuleLoader 20 | route_collection: Optional[RouteCollection] = None 21 | index: Optional[int] = None 22 | 23 | _index_counter: int = 0 24 | 25 | def __new__(cls, *args, **kwargs) -> Any: 26 | Module._index_counter += 1 27 | instance = super().__new__(cls) 28 | instance.index = Module._index_counter 29 | return instance 30 | 31 | @abstractmethod 32 | def register(self, routes: RouteBuilder): 33 | pass 34 | 35 | @classmethod 36 | def get_name(cls) -> str: 37 | return cls.__name__ 38 | 39 | @property 40 | def log(self) -> loguru.Logger: 41 | return loguru.logger 42 | 43 | @property 44 | def logger(self) -> loguru.Logger: 45 | return loguru.logger 46 | -------------------------------------------------------------------------------- /botkit/core/modules/_moduledecorator.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, List, Type, TypeVar, Union, cast 2 | 3 | import decorators 4 | from haps import Egg, egg 5 | 6 | from botkit.routing.route_builder.builder import RouteBuilder 7 | from ._module import Module 8 | 9 | # region types 10 | 11 | egg.factories = cast(List[Egg], egg.factories) 12 | 13 | 14 | class ModuleDecorator(decorators.Decorator): 15 | def __call__(self, *args, **kwargs) -> Type[Module]: 16 | return super().__call__(*args, **kwargs) 17 | 18 | def decorate_class(self, cls: Type[Module], *dec_args, **dec_kwargs) -> Type[Module]: 19 | if not issubclass(cls, Module): 20 | raise TypeError( 21 | f"Can only use the @module decorator on classes that inherit from {Module.__name__}." 22 | ) 23 | egg.factories.append( 24 | Egg(type_=cls, qualifier=cls.__name__, egg_=cls, base_=Module, profile=None) 25 | ) 26 | return cls 27 | 28 | def decorate_func( 29 | self, func: Callable[[RouteBuilder], Type[Module]], name: str = None, *args, **dec_kwargs 30 | ) -> Type[Module]: 31 | class_name = name if name else format_module_name(func.__name__) 32 | module_cls = type( 33 | class_name, (Module,), {"register": lambda self, routes: func(routes)}, 34 | ) 35 | egg.factories.append( 36 | Egg( 37 | type_=module_cls, 38 | qualifier=class_name, 39 | egg_=module_cls, 40 | base_=Module, 41 | profile=None, 42 | ) 43 | ) 44 | return func 45 | 46 | 47 | """ Decorator for marking a register function or a `Module` class as a botkit module. """ 48 | module = ModuleDecorator 49 | 50 | 51 | def format_module_name(any_str: str): 52 | return _snake_to_upper_camel(any_str) 53 | 54 | 55 | def _snake_to_upper_camel(word: str): 56 | return "".join(x.capitalize() or "_" for x in word.split("_")) 57 | -------------------------------------------------------------------------------- /botkit/core/modules/_registration.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from botkit.core.modules import Module 4 | from botkit.routing.route_builder.builder import RouteBuilder 5 | from botkit.routing.route_builder.route_collection import RouteCollection 6 | 7 | 8 | def register_module_with_route_builder( 9 | module: Module, route_builder_type: Type[RouteBuilder] 10 | ) -> RouteCollection: 11 | builder = route_builder_type() 12 | module.register(builder) 13 | module.route_collection = builder._route_collection 14 | return builder._route_collection 15 | -------------------------------------------------------------------------------- /botkit/core/modules/activation/__init__.py: -------------------------------------------------------------------------------- 1 | from ._di import haps_disambiguate_module_eggs, resolve_modules 2 | from ._module_activator import ModuleActivator 3 | from ._module_loader import ModuleLoader 4 | from ._module_status import ModuleStatus 5 | 6 | haps_disambiguate_module_eggs() 7 | 8 | __all__ = [ 9 | "haps_disambiguate_module_eggs", 10 | "resolve_modules", 11 | "ModuleLoader", 12 | "ModuleActivator", 13 | "ModuleStatus", 14 | ] 15 | -------------------------------------------------------------------------------- /botkit/core/modules/activation/_di.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, List 2 | 3 | 4 | 5 | from haps import Container, Egg, SINGLETON_SCOPE, egg 6 | from haps.config import Configuration 7 | 8 | from botkit.core.modules._module import Module 9 | from botkit.utils.botkit_logging.setup import create_logger 10 | 11 | logger = create_logger() 12 | 13 | # noinspection PyTypeHints 14 | egg.factories: List[Egg] 15 | 16 | 17 | def haps_disambiguate_module_eggs() -> List[Egg]: 18 | """ 19 | Make all modules unambiguous to haps by settings its qualifier to the class name 20 | """ 21 | eggs: List[Egg] = [m for m in egg.factories if m.base_ is Module] 22 | 23 | for e in eggs: 24 | e.qualifier = e.type_.__name__ 25 | 26 | return eggs 27 | 28 | 29 | @Configuration.resolver("modules") 30 | def resolve_modules() -> List[Module]: 31 | return list(discover_modules(Container())) 32 | 33 | 34 | 35 | def discover_modules(container: Container) -> Iterable[Module]: 36 | eggs: Iterable[Egg] = [m for m in container.config if m.base_ is Module] 37 | 38 | for e in eggs: 39 | try: 40 | scope = container.scopes[SINGLETON_SCOPE] 41 | with container._lock: 42 | yield scope.get_object(e.egg) 43 | except: 44 | logger.exception("Could not retrieve object from scope") 45 | -------------------------------------------------------------------------------- /botkit/core/modules/activation/_module_status.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | 4 | class ModuleStatus(Enum): 5 | inactive = auto() 6 | active = auto() 7 | disabled = auto() 8 | failed = auto() 9 | -------------------------------------------------------------------------------- /botkit/core/services/__init__.py: -------------------------------------------------------------------------------- 1 | from ._decorator import service 2 | -------------------------------------------------------------------------------- /botkit/core/services/_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | from botkit.abstractions import IAsyncLoadUnload 4 | 5 | 6 | class Service(IAsyncLoadUnload, ABC): 7 | pass 8 | -------------------------------------------------------------------------------- /botkit/core/services/_decorator.py: -------------------------------------------------------------------------------- 1 | from haps import base, egg, SINGLETON_SCOPE, scope as haps_scope 2 | import decorators 3 | from typing import Any, Callable, TypeVar, no_type_check, no_type_check_decorator, overload 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | class _ServiceDecorator(decorators.ClassDecorator): 9 | 10 | """ 11 | Decorator for marking a class as an injectable service. 12 | Defaults to SINGLETON_SCOPE as opposed to INSTANCE_SCOPE. 13 | 14 | The following structure: 15 | 16 | ``` 17 | @service 18 | class MyService: ... 19 | ``` 20 | 21 | is equivalent to: 22 | 23 | ``` 24 | @haps.base 25 | @haps.egg 26 | @haps.scope(haps.SINGLETON_SCOPE) 27 | class MyService: ... 28 | ``` 29 | """ 30 | 31 | def decorate(self, klass, scope=SINGLETON_SCOPE, **kwargs) -> Any: 32 | base(klass) 33 | egg(klass) 34 | haps_scope(scope)(klass) 35 | return klass 36 | 37 | 38 | service = _ServiceDecorator 39 | -------------------------------------------------------------------------------- /botkit/core/services/_decorator.pyi: -------------------------------------------------------------------------------- 1 | from haps import base, egg, scope as haps_scope 2 | from typing import Any, Callable, TypeVar, overload 3 | 4 | _F = TypeVar("_F", bound=Any) 5 | 6 | 7 | @overload 8 | def service(class_: _F) -> _F: 9 | ... 10 | 11 | 12 | @overload 13 | def service(*, mode: str) -> Callable[[_F], _F]: 14 | ... 15 | 16 | 17 | 18 | @service(mode="abc") 19 | class Lala: 20 | x: int = 3 21 | 22 | 23 | x: Lala = Lala() 24 | -------------------------------------------------------------------------------- /botkit/dispatching/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /botkit/dispatching/deeplinkstartactiondispatcher.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any, Dict, Union 3 | 4 | from cached_property import cached_property 5 | from haps import Container 6 | from pyrogram import filters 7 | from pyrogram.handlers import MessageHandler 8 | from pyrogram.types import Message 9 | 10 | from botkit.persistence.callback_store import ICallbackStore 11 | from botkit.routing.route import RouteHandler 12 | from botkit.routing.triggers import ActionIdType 13 | from botkit.routing.update_types.updatetype import UpdateType 14 | from botkit.settings import botkit_settings 15 | from botkit.clients.client import IClient 16 | from botkit.utils.botkit_logging.setup import create_logger 17 | from botkit.views.botkit_context import Context 18 | 19 | START_WITH_UUID4_ARG_REGEX = re.compile(r"^/start ([0-9a-f-]{36})$", re.MULTILINE) 20 | 21 | log = create_logger("deep_link_start_action_dispatcher") 22 | 23 | 24 | class DeepLinkStartActionDispatcher: 25 | def __init__(self): 26 | self._action_routes: Dict[ActionIdType, RouteHandler] = dict() 27 | 28 | def add_action_route(self, route: RouteHandler): 29 | assert route.action_id is not None 30 | 31 | if route.action_id in self._action_routes: 32 | raise ValueError(f"Action ID {route.action_id} is not unique.") 33 | 34 | self._action_routes[route.action_id] = route 35 | 36 | @property 37 | def pyrogram_handler(self) -> MessageHandler: 38 | return MessageHandler( 39 | self.handle, filters=filters.text & filters.regex(START_WITH_UUID4_ARG_REGEX) 40 | ) 41 | 42 | async def handle(self, client: IClient, message: Message) -> Union[bool, Any]: 43 | command_args_str = message.matches[0].group(1) 44 | cb_ctx = self.callback_manager.lookup_callback( 45 | command_args_str.strip() 46 | ) # asserted by regex 47 | 48 | if not cb_ctx: 49 | return False 50 | 51 | route = self._action_routes[cb_ctx.action] 52 | 53 | context: Context = Context( 54 | client=client, 55 | update=message, 56 | update_type=UpdateType.message, 57 | view_state=cb_ctx.state, 58 | action=cb_ctx.action, 59 | payload=cb_ctx.payload, 60 | ) 61 | 62 | return await route.callback(client, message, context) 63 | 64 | @cached_property 65 | def callback_manager(self) -> ICallbackStore: 66 | return Container().get_object(ICallbackStore, botkit_settings.callback_manager_qualifier) 67 | -------------------------------------------------------------------------------- /botkit/dispatching/types.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from typing import Union 3 | 4 | CallbackActionType = Union[str, int, IntEnum] 5 | -------------------------------------------------------------------------------- /botkit/inlinequeries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/inlinequeries/__init__.py -------------------------------------------------------------------------------- /botkit/inlinequeries/contexts.py: -------------------------------------------------------------------------------- 1 | import re 2 | from abc import ABC 3 | 4 | from abc import ABCMeta, abstractmethod 5 | from typing import Optional, Union, Any 6 | 7 | 8 | class IInlineModeContext(ABC): 9 | @abstractmethod 10 | def format_query(self, user_input: Optional[str] = None) -> str: 11 | pass 12 | 13 | @abstractmethod 14 | def matches(self, query: str) -> Any: 15 | pass 16 | 17 | @abstractmethod 18 | def parse_input( 19 | self, query: str, match_result: Any = None 20 | ) -> Optional[Union[bool, str]]: 21 | pass 22 | 23 | 24 | class DefaultInlineModeContext(IInlineModeContext): 25 | def format_query(self, user_input: Optional[str] = None) -> str: 26 | return user_input or "" 27 | 28 | def matches(self, query: str) -> Any: 29 | return True 30 | 31 | def parse_input(self, query: str, match_result: Any = None) -> Optional[str]: 32 | return query 33 | 34 | 35 | class HashtagInlineModeContext(IInlineModeContext): 36 | def format_query(self, user_input: Optional[str] = None) -> str: 37 | return user_input or "" 38 | 39 | def matches(self, query: str) -> Any: 40 | return self.tag in query 41 | 42 | def parse_input(self, query: str, match_result: Any = None) -> Optional[str]: 43 | return query 44 | 45 | 46 | class PrefixBasedInlineModeContext(IInlineModeContext): 47 | def __init__(self, prefix: str, delimiter: str = ": "): 48 | self.prefix = prefix.rstrip(delimiter).strip() 49 | self.delimiter = delimiter 50 | re_delimiter = re.escape(delimiter.strip()) 51 | re_prefix = re.escape(self.prefix) 52 | self._pattern = re.compile( 53 | f"^{re_prefix}\\s*{re_delimiter}\\s*(.*)", re.IGNORECASE | re.DOTALL 54 | ) 55 | 56 | def format_query(self, user_input: Optional[str] = None) -> str: 57 | return f"{self.prefix}{self.delimiter}{(user_input or '').strip()}" 58 | 59 | def matches(self, query: str) -> Union[bool, str]: 60 | match = self._pattern.match(query) 61 | if not match: 62 | return False 63 | return match.group(1).strip() 64 | 65 | def parse_input(self, query: str, match_result: Any = None) -> Union[bool, str]: 66 | return match_result 67 | -------------------------------------------------------------------------------- /botkit/inlinequeries/inlineresultcontainer.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pyrogram.types import InlineQuery, InlineQueryResult 4 | 5 | 6 | class InlineResultContainer: 7 | def __init__(self, inline_query: InlineQuery): 8 | self._inline_query = inline_query 9 | 10 | # TODO: automatic offset calculation 11 | self.next_offset: str = "" 12 | 13 | self.results: List[InlineQueryResult] = [] 14 | self.maximum_cache_time: int = 300 15 | self.contains_personal_results: bool = False 16 | self.requires_gallery: bool = False 17 | self.switch_pm_text: str = "" 18 | self.switch_pm_parameter: str = "" 19 | 20 | async def answer(self): 21 | return await self._inline_query.answer( 22 | self.results, 23 | cache_time=self.maximum_cache_time, 24 | is_gallery=self.requires_gallery, 25 | is_personal=self.contains_personal_results, 26 | next_offset=self.next_offset, 27 | switch_pm_text=self.switch_pm_text, 28 | switch_pm_parameter=self.switch_pm_parameter, 29 | ) 30 | -------------------------------------------------------------------------------- /botkit/inlinequeries/inlineresultgenerator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | from abc import ABCMeta, abstractmethod 4 | from decouple import config 5 | from loguru import logger 6 | 7 | from botkit.inlinequeries.inlineresultcontainer import InlineResultContainer 8 | 9 | 10 | class InlineResultGenerator(ABC): 11 | # def matches(self, inline_query: InlineQuery): TODO: move to IoC 12 | # return self.context.matches(inline_query.query) 13 | 14 | @abstractmethod 15 | async def generate_results(self, container: InlineResultContainer, user_input: str) -> bool: 16 | pass 17 | 18 | async def generate(self, container: InlineResultContainer, user_input: str) -> None: 19 | 20 | # remove_trigger_setting = self.context.parse_input( 21 | # query=inline_query.query, match_result=match_result 22 | # ).strip() 23 | 24 | logger.debug(f"Generating results for {self.__class__.__name__}...") 25 | if user_input: 26 | logger.debug(user_input) 27 | 28 | if config("DEBUG", cast=bool): 29 | container.maximum_cache_time = 1 30 | 31 | await self.generate_results(container, user_input) 32 | -------------------------------------------------------------------------------- /botkit/inlinequeries/resultaggregator.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from pyrogram.types import InlineQuery 4 | 5 | from botkit.inlinequeries.inlineresultcontainer import InlineResultContainer 6 | from botkit.inlinequeries.inlineresultgenerator import InlineResultGenerator 7 | 8 | 9 | async def aggregate_results( 10 | inline_query: InlineQuery, generators: Iterable[InlineResultGenerator] 11 | ) -> InlineResultContainer: 12 | 13 | container = InlineResultContainer(inline_query) 14 | for generator in generators: 15 | await generator.generate(container, "user_input") 16 | return container 17 | -------------------------------------------------------------------------------- /botkit/models/__init__.py: -------------------------------------------------------------------------------- 1 | from botkit.models._statemodel import StateModel 2 | from botkit.models._interfaces import IGatherer 3 | 4 | __all__ = ["StateModel", "IGatherer"] 5 | -------------------------------------------------------------------------------- /botkit/models/_interfaces.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from botkit.views.botkit_context import Context 4 | from typing import ( 5 | Any, 6 | Callable, 7 | ClassVar, 8 | Generic, 9 | Optional, 10 | Tuple, 11 | Type, 12 | TypeVar, 13 | Union, 14 | AbstractSet, 15 | Hashable, 16 | Iterable, 17 | Iterator, 18 | Mapping, 19 | MutableMapping, 20 | MutableSequence, 21 | MutableSet, 22 | Sequence, 23 | AsyncIterator, 24 | AsyncIterable, 25 | Coroutine, 26 | Collection, 27 | AsyncGenerator, 28 | Deque, 29 | Dict, 30 | List, 31 | Set, 32 | FrozenSet, 33 | NamedTuple, 34 | Generator, 35 | cast, 36 | overload, 37 | TYPE_CHECKING, 38 | ) 39 | from typing_extensions import TypedDict 40 | 41 | StateModel = TypeVar("StateModel") 42 | 43 | 44 | class IGatherer(ABC): 45 | @classmethod 46 | @abstractmethod 47 | def create_from_context(cls, ctx: Context) -> "StateModel": 48 | ... 49 | -------------------------------------------------------------------------------- /botkit/models/_statemodel.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | from pydantic import BaseModel 4 | 5 | from ._interfaces import IGatherer 6 | from botkit.views.botkit_context import Context 7 | 8 | 9 | class StateModel(BaseModel, IGatherer, ABC): 10 | @classmethod 11 | def create_from_context(cls, ctx: Context) -> "StateModel": 12 | pass 13 | -------------------------------------------------------------------------------- /botkit/persistence/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/persistence/__init__.py -------------------------------------------------------------------------------- /botkit/persistence/callback_store/__init__.py: -------------------------------------------------------------------------------- 1 | from ._base import ICallbackStore, CallbackActionContext 2 | 3 | # TODO: add proper try except for opt-in install of redis for callback management 4 | import redis_collections 5 | 6 | from ._redis import RedisCallbackStore 7 | from ._local import MemoryDictCallbackStore 8 | from ._simple import create_callback, lookup_callback 9 | 10 | 11 | __all__ = [ 12 | "ICallbackStore", 13 | "RedisCallbackStore", 14 | "MemoryDictCallbackStore", 15 | "lookup_callback", 16 | "create_callback", 17 | ] 18 | -------------------------------------------------------------------------------- /botkit/persistence/callback_store/_base.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID, uuid4 2 | from typing import Literal, Union, Optional 3 | from abc import abstractmethod 4 | from haps import base 5 | from abc import ABC 6 | 7 | from datetime import datetime 8 | from typing import Any, Generic, Optional, TypeVar 9 | 10 | from pydantic import BaseModel, Field 11 | 12 | from botkit.dispatching.types import CallbackActionType 13 | from botkit.routing.update_types.updatetype import UpdateType 14 | 15 | TViewState = TypeVar("TViewState") 16 | 17 | 18 | class CallbackActionContext(BaseModel, Generic[TViewState]): 19 | action: CallbackActionType 20 | state: TViewState 21 | triggered_by: Literal["button", "command"] 22 | created: datetime = Field(default_factory=datetime.utcnow) 23 | notification: Optional[str] 24 | show_alert: bool = False 25 | payload: Optional[Any] = None 26 | 27 | 28 | TRIGGERED_BY_UPDATE_TYPES = {"button": UpdateType.callback_query, "command": UpdateType.message} 29 | 30 | 31 | @base 32 | class ICallbackStore(ABC): 33 | @abstractmethod 34 | def create_callback(self, context: CallbackActionContext) -> str: 35 | ... 36 | 37 | @abstractmethod 38 | def lookup_callback(self, id_: Union[str, UUID]) -> Optional[CallbackActionContext]: 39 | ... 40 | 41 | @abstractmethod 42 | def clear(self): 43 | ... 44 | 45 | @abstractmethod 46 | def force_sync(self): 47 | ... 48 | 49 | @abstractmethod 50 | def remove_outdated(self, days: int = 7): 51 | ... 52 | 53 | 54 | def generate_id() -> str: 55 | return str(uuid4()) 56 | -------------------------------------------------------------------------------- /botkit/persistence/callback_store/_local.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | from uuid import UUID 3 | 4 | from haps import egg 5 | 6 | from ._base import ICallbackStore 7 | from ._base import CallbackActionContext 8 | from ._simple import ( 9 | create_callback, 10 | lookup_callback, 11 | ) 12 | 13 | 14 | @egg(qualifier="memory") 15 | class MemoryDictCallbackStore(ICallbackStore): 16 | def create_callback(self, context: CallbackActionContext) -> str: 17 | return create_callback(context) 18 | 19 | def lookup_callback(self, id_: Union[str, UUID]) -> Optional[CallbackActionContext]: 20 | return lookup_callback(id_) 21 | 22 | def clear(self): 23 | pass 24 | 25 | def force_sync(self): 26 | pass 27 | 28 | def remove_outdated(self, days: int = 7): 29 | pass 30 | -------------------------------------------------------------------------------- /botkit/persistence/callback_store/_simple.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Optional, 3 | Union, 4 | Dict, 5 | ) 6 | from uuid import UUID 7 | 8 | from ._base import generate_id 9 | from ._base import CallbackActionContext 10 | 11 | __callbacks: Dict[str, Dict] = dict() 12 | 13 | 14 | def create_callback(context: CallbackActionContext) -> str: 15 | id_ = generate_id() 16 | 17 | __callbacks[id_] = context.dict() 18 | 19 | return id_ 20 | 21 | 22 | def lookup_callback(id_: Union[str, UUID]) -> Optional[CallbackActionContext]: 23 | context: Optional[Dict] = __callbacks.get(str(id_)) 24 | if context is None: 25 | return None 26 | return CallbackActionContext(**context) 27 | -------------------------------------------------------------------------------- /botkit/persistence/data_store/__init__.py: -------------------------------------------------------------------------------- 1 | from botkit.persistence.data_store.data_store_base import DataStoreBase 2 | from botkit.persistence.data_store.memory_data_store import MemoryDataStore 3 | 4 | __all__ = ["DataStoreBase", "MemoryDataStore"] 5 | -------------------------------------------------------------------------------- /botkit/persistence/data_store/data_store_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | 4 | from haps import base 5 | 6 | from botkit.views.botkit_context import Context 7 | from tgtypes.identities.chat_identity import ChatIdentity 8 | from tgtypes.identities.message_identity import MessageIdentity 9 | 10 | 11 | @base 12 | class DataStoreBase(ABC): 13 | @abstractmethod 14 | async def retrieve_message_data(self, message_identity: Optional[MessageIdentity]): 15 | ... 16 | 17 | @abstractmethod 18 | async def retrieve_chat_data(self, chat_identity: Optional[ChatIdentity]): 19 | ... 20 | 21 | @abstractmethod 22 | async def retrieve_user_data(self, user_id: Optional[int]): 23 | ... 24 | 25 | @abstractmethod 26 | async def synchronize_context_data(self, context: Context): 27 | pass # TODO 28 | -------------------------------------------------------------------------------- /botkit/persistence/data_store/memory_data_store.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Optional 3 | 4 | from haps import SINGLETON_SCOPE, egg, scope 5 | 6 | from botkit.persistence.data_store import DataStoreBase 7 | from botkit.views.botkit_context import Context 8 | from tgtypes.identities.chat_identity import ChatIdentity 9 | from tgtypes.identities.message_identity import MessageIdentity 10 | 11 | 12 | @egg 13 | @scope(SINGLETON_SCOPE) 14 | class MemoryDataStore(DataStoreBase): 15 | def __init__(self): 16 | self._data = dict() 17 | 18 | def default_factory(): 19 | return dict() 20 | 21 | self._data.setdefault("messages", defaultdict(default_factory)) 22 | self._data.setdefault("users", defaultdict(default_factory)) 23 | self._data.setdefault("chats", defaultdict(default_factory)) 24 | self._data.setdefault("message_links", defaultdict(default_factory)) 25 | 26 | async def retrieve_message_data(self, message_identity: Optional[MessageIdentity]): 27 | if not message_identity: 28 | return None 29 | return self._data["messages"][message_identity] 30 | 31 | async def retrieve_chat_data(self, chat_identity: Optional[ChatIdentity]): 32 | if not chat_identity: 33 | return None 34 | return self._data["chats"][chat_identity] 35 | 36 | async def retrieve_user_data(self, user_id: Optional[int]): 37 | if not user_id: 38 | return None 39 | return self._data["users"][user_id] 40 | 41 | async def synchronize_context_data(self, context: Context): 42 | # TODO: Add error handling when context is assigned to but that key does not exist (..though.. does it 43 | # always..??) 44 | if user := context.user: 45 | self._data["users"][user.id] = context.user_state 46 | if chat := context.chat: 47 | chat_identity = ChatIdentity.from_chat_and_user(chat, user, context.client.own_user_id) 48 | self._data["chats"][chat_identity] = context.chat_state 49 | if message_identity := context.message_identity: 50 | self._data["messages"][message_identity] = context.message_state 51 | -------------------------------------------------------------------------------- /botkit/routing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/routing/__init__.py -------------------------------------------------------------------------------- /botkit/routing/dialogs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/routing/dialogs/__init__.py -------------------------------------------------------------------------------- /botkit/routing/dialogs/history.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | from dataclasses import dataclass 4 | 5 | import sys 6 | import threading 7 | from typing import Any, Coroutine, List, Hashable, Tuple 8 | 9 | 10 | class CoroutineHistoryWrapper: 11 | __slots__ = ("coro", "history") 12 | 13 | def __init__(self, coro, history: List[HistoryItem]): 14 | self.coro: Coroutine = coro 15 | self.history = history 16 | 17 | def __await__(self): 18 | i = 0 19 | next_ret = None 20 | try: 21 | while True: 22 | try: 23 | request = self.coro.send(next_ret) 24 | except StopIteration as e: 25 | return e.value 26 | except Exception as e: 27 | yield self.coro.throw(*sys.exc_info()) 28 | else: 29 | if i < len(self.history): 30 | history_item = self.history[i] 31 | assert history_item[0] == request 32 | next_ret = history_item[1] 33 | else: 34 | next_ret = yield request 35 | self._add_history(request, next_ret) 36 | finally: 37 | yield self.coro.close() 38 | 39 | def _add_history(self, request, result): 40 | self.history.append((_hash_coroutine(request), result)) 41 | 42 | 43 | def _hash_coroutine(coroutine): 44 | return _hash_code(coroutine.cr_code) 45 | 46 | 47 | def _hash_function(function): 48 | return _hash_code(function.co_code) 49 | 50 | 51 | def _hash_code(code): 52 | return hash(code.replace(co_name="")) 53 | 54 | 55 | @dataclass 56 | class _DevVersion: 57 | hash: int 58 | 59 | 60 | @dataclass 61 | class HistoryItem: 62 | hash: Any 63 | 64 | 65 | async def test(): 66 | async def func(value): 67 | a = 15 68 | print(1) 69 | b = 27 70 | await asyncio.sleep(1) 71 | print(a, b) 72 | print(2) 73 | print(3) 74 | 75 | return value 76 | 77 | await CoroutineHistoryWrapper(func(3), History(None, [])) 78 | 79 | 80 | if __name__ == "__main__": 81 | asyncio.get_event_loop().run_until_complete(test()) 82 | -------------------------------------------------------------------------------- /botkit/routing/dialogs/state.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import sys 3 | import threading 4 | 5 | _trace_state = threading.local() 6 | 7 | 8 | async def _call_and_jump(func, target, coro_locals, *args, kwargs): 9 | def _trace_global(frame, event, arg): 10 | # print(frame, event, arg) 11 | if event == "call" and frame.f_code is func.__code__: 12 | return _trace_local 13 | 14 | def _trace_local(frame, event, arg): 15 | # print(frame, event, arg) 16 | if event == "line" and frame.f_code is func.__code__: 17 | if getattr(_trace_state, "first", True): 18 | frame.f_lineno = frame.f_lineno + target - 1 19 | _trace_state.first = False 20 | return None 21 | else: 22 | _trace_state.first = True 23 | return _trace_local 24 | 25 | oldtrace = sys.gettrace() 26 | sys.settrace(_trace_global) 27 | inspect.currentframe().f_trace = _trace_global 28 | try: 29 | ret = func(*args, **kwargs) 30 | try: 31 | fut = ret.send(None) 32 | except StopIteration: 33 | fut = None 34 | finally: 35 | sys.settrace(oldtrace) 36 | inspect.currentframe().f_trace = oldtrace 37 | return ret, fut 38 | -------------------------------------------------------------------------------- /botkit/routing/dialogs/testing.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from botkit.routing.dialogs.history import CoroutineHistoryWrapper 4 | from botkit.routing.dialogs.state import _call_and_jump 5 | 6 | 7 | async def func(value): 8 | a = 15 9 | print(1) 10 | b = 27 11 | await asyncio.sleep(1) 12 | print(a, b) 13 | print(2) 14 | print(3) 15 | return value 16 | 17 | 18 | async def wrap(coro): 19 | return await CoroutineHistoryWrapper(*await coro) 20 | 21 | 22 | print(asyncio.run(wrap(_call_and_jump(func, 2, dict(a=15), kwargs={"value": 5})))) 23 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/routing/pipelines/__init__.py -------------------------------------------------------------------------------- /botkit/routing/pipelines/collector.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable, Union 2 | 3 | from botkit.views.botkit_context import Context 4 | 5 | CollectorSignature = Callable[[Context], Union[None, Awaitable[None]]] 6 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/factory_types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from dataclasses import astuple, dataclass 5 | from typing import ( 6 | Any, 7 | Callable, 8 | Generic, 9 | List, 10 | NamedTuple, 11 | Optional, 12 | Protocol, 13 | Set, 14 | Tuple, 15 | TypeVar, 16 | ) 17 | 18 | from boltons.typeutils import classproperty 19 | 20 | from botkit.routing.update_types.updatetype import UpdateType 21 | from botkit.utils.typed_callable import TypedCallable 22 | 23 | TViewState = TypeVar("TViewState") 24 | 25 | TData = TypeVar("TData") 26 | TResult = TypeVar("TResult") 27 | 28 | TFunc = TypeVar("TFunc", bound=Callable) 29 | 30 | 31 | class MaybeAsyncPipelineStep(Tuple[Optional[TFunc], Optional[bool]], Generic[TFunc]): 32 | ... 33 | 34 | 35 | class IPipelineStep(ABC): 36 | async def __call__(self, *args, **kwargs) -> Any: 37 | pass 38 | 39 | 40 | # @abstractmethod 41 | # async def __call__(self, *args, **kwargs): 42 | # pass 43 | 44 | 45 | class IStepFactory(Generic[TData, TResult], ABC): 46 | """ 47 | Interface to be implemented by any pipeline step factory that pregenerates a function to handle some functionality 48 | based on input parameters. 49 | """ 50 | 51 | @classmethod 52 | @abstractmethod 53 | def create_step(cls, data: Optional[TData]) -> TResult: 54 | ... 55 | 56 | @classproperty 57 | def applicable_update_types(cls) -> Set[UpdateType]: 58 | return UpdateType.all 59 | 60 | 61 | class ICallbackStepFactory( 62 | IStepFactory[TypedCallable[TFunc], MaybeAsyncPipelineStep[TFunc]], Generic[TFunc], ABC 63 | ): 64 | """ 65 | Interface to be implemented by pipeline steps that operate on some callable, which needs to be wrapped in a 66 | `TypedCallable`. 67 | """ 68 | 69 | @classmethod 70 | @abstractmethod 71 | def create_step(cls, func: Optional[TypedCallable[TFunc]]) -> MaybeAsyncPipelineStep[TFunc]: 72 | """ 73 | Generates a pipeline step based on a (possibly user-defined) callable. If the callable is async, the second 74 | tuple return value will indicate this. 75 | :param func: 76 | :type func: 77 | :return: 78 | :rtype: 79 | """ 80 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/filters.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable 2 | 3 | from pyrogram.types import Update 4 | 5 | from botkit.clients.client import IClient 6 | 7 | UpdateFilterSignature = Callable[[IClient, Update], Awaitable[bool]] 8 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/gatherer.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, Type, Union 2 | 3 | from botkit.routing.types import TViewState 4 | 5 | # noinspection PyMissingTypeHints 6 | from botkit.views.botkit_context import Context 7 | 8 | GathererSignature = Union[ 9 | Callable[[], Union[Any, Awaitable[TViewState]]], 10 | Callable[[Context], Union[Any, Awaitable[TViewState]]], 11 | Type, # A view_state type to be instantiated (parameterless) 12 | ] 13 | GathererSignatureExamplesStr = """ 14 | - def () -> TViewState 15 | - def (context: Context) -> TViewState 16 | - async def () -> TViewState 17 | - async def (context: Context) -> TViewState 18 | """.strip() 19 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/reducer.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Awaitable, Callable, Union, List 3 | 4 | from botkit.routing.types import TViewState 5 | from botkit.views.botkit_context import Context 6 | 7 | ReducerSignature = Union[ 8 | Callable[[TViewState, Context], Union[TViewState, Awaitable[TViewState]]], 9 | Callable[[TViewState], Union[TViewState, Awaitable[TViewState]]], 10 | Callable[[TViewState, Context], Union[None, Awaitable]], 11 | Callable[[TViewState], Union[None, Awaitable]], 12 | ] 13 | ReducerSignatureExamplesStr = str(ReducerSignature) 14 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/steps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/routing/pipelines/steps/__init__.py -------------------------------------------------------------------------------- /botkit/routing/pipelines/steps/_base.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Generic, Type, TypeVar, Union 3 | 4 | from botkit.utils.typed_callable import TypedCallable 5 | 6 | T = TypeVar("T") 7 | 8 | 9 | class StepError(Exception, Generic[T]): 10 | def __init__(self, inner_exception: Exception): 11 | 12 | # TODO: Remove check 13 | assert not inspect.isclass(inner_exception) 14 | 15 | self.inner_exception = inner_exception 16 | super(StepError, self).__init__(inner_exception) 17 | self.with_traceback(inner_exception.__traceback__) 18 | 19 | @property 20 | def should_ignore_and_continue(self) -> bool: 21 | return isinstance(self.inner_exception, Continue) 22 | 23 | 24 | class Break(Exception): 25 | """ 26 | Can be raised in intermediate steps of the pipeline in order to cancel the execution silently. 27 | """ 28 | 29 | def __init__(self, reason: str = None): 30 | self.reason = reason 31 | 32 | 33 | class Continue(Break): 34 | """ 35 | Can be raised in intermediate steps of the pipeline in order to cancel the execution silently. 36 | """ 37 | 38 | def __init__(self, reason: str = None): 39 | super(Continue, self).__init__(reason=reason) 40 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/steps/call_step_factory.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from typing import Any, Awaitable, Callable, Optional 3 | 4 | from botkit.agnostic.annotations import HandlerSignature 5 | from botkit.routing.pipelines.factory_types import IStepFactory 6 | from botkit.routing.pipelines.steps._base import StepError 7 | from botkit.utils.typed_callable import TypedCallable 8 | from botkit.views.base import ModelViewBase 9 | from botkit.views.botkit_context import Context 10 | 11 | 12 | class HandleStepError(StepError[HandlerSignature]): 13 | pass 14 | 15 | 16 | class CallStepFactory( 17 | IStepFactory[TypedCallable[HandlerSignature], Optional[Callable[[Context], Awaitable[Any]]]] 18 | ): 19 | @classmethod 20 | def create_step(cls, handler): 21 | if not handler: 22 | return None, None 23 | 24 | is_coroutine = handler.is_coroutine 25 | 26 | if is_coroutine: 27 | 28 | async def call_async(update, context): 29 | # TODO: Use paraminjector library to make all args optional 30 | args = ( 31 | (context.client, update, context) 32 | if handler.num_parameters == 3 33 | else (context.client, update) 34 | ) 35 | 36 | try: 37 | result = await handler.func(*args) 38 | except Exception as e: 39 | raise HandleStepError(e) 40 | 41 | if isinstance(result, ModelViewBase): 42 | # TODO 43 | warnings.warn( 44 | "Would be cool if handlers could directly return a view, right? " 45 | "Well, drop me a note on GitHub that you'd also love this feature " 46 | "and I'll make it happen :)" 47 | ) 48 | 49 | return result 50 | 51 | return call_async, is_coroutine 52 | else: 53 | 54 | def call(update, context): 55 | # TODO: Use paraminjector library to make all args optional 56 | args = ( 57 | (context.client, update, context) 58 | if handler.num_parameters == 3 59 | else (context.client, update) 60 | ) 61 | 62 | try: 63 | result = handler.func(*args) 64 | except Exception as e: 65 | raise HandleStepError(e) from e 66 | 67 | if isinstance(result, ModelViewBase): 68 | # TODO 69 | warnings.warn( 70 | "Would be cool if handlers could directly return a view, right? " 71 | "Well, drop me a note on GitHub that you'd also love this feature " 72 | "and I'll make it happen :)" 73 | ) 74 | 75 | return result 76 | 77 | return call, is_coroutine 78 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/steps/collect_step_factory.py: -------------------------------------------------------------------------------- 1 | from functools import update_wrapper 2 | 3 | from botkit.routing.pipelines.factory_types import ICallbackStepFactory 4 | from botkit.routing.pipelines.steps._base import StepError 5 | from botkit.routing.pipelines.reducer import ReducerSignature 6 | from botkit.routing.pipelines.steps.helpers.state_generators import update_view_state_if_applicable 7 | from botkit.utils.botkit_logging.setup import create_logger 8 | 9 | 10 | class CollectStepError(StepError[ReducerSignature]): 11 | pass 12 | 13 | 14 | # noinspection PyMissingTypeHints 15 | class CollectStepFactory(ICallbackStepFactory[ReducerSignature]): 16 | @classmethod 17 | def create_step(cls, collector): 18 | if not collector: 19 | return None, None 20 | 21 | log = create_logger("collector") 22 | is_coroutine = collector.is_coroutine 23 | 24 | if is_coroutine: 25 | 26 | async def postprocess_data_async(context): 27 | try: 28 | log.debug(f"Postprocessing state using collector {collector.name}") 29 | return await collector.func(context) 30 | except Exception as e: 31 | raise CollectStepError(e) 32 | 33 | return postprocess_data_async, is_coroutine 34 | else: 35 | 36 | def postprocess_data(context): 37 | try: 38 | log.debug(f"Postprocessing state using collector {collector.name}") 39 | return collector.func(context) 40 | except Exception as e: 41 | raise CollectStepError(e) 42 | 43 | return postprocess_data, is_coroutine 44 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/steps/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/routing/pipelines/steps/helpers/__init__.py -------------------------------------------------------------------------------- /botkit/routing/pipelines/steps/helpers/state_generators.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from botkit.utils.botkit_logging.setup import create_logger 4 | from botkit.views.botkit_context import Context 5 | 6 | log = create_logger("state_generation") 7 | 8 | 9 | def update_view_state_if_applicable(state_generation_result: Any, context: Context) -> bool: 10 | if state_generation_result: 11 | context.view_state = state_generation_result 12 | return True 13 | return False 14 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/steps/initialize_context_step.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from botkit.agnostic.annotations import IClient 4 | from botkit.persistence.data_store import DataStoreBase 5 | from botkit.routing.pipelines.factory_types import IPipelineStep 6 | from typing import ( 7 | Any, 8 | Optional, 9 | ) 10 | 11 | from botkit.routing.update_types.updatetype import UpdateType 12 | from botkit.utils.botkit_logging.setup import create_logger 13 | from botkit.views.botkit_context import Context 14 | 15 | 16 | class InitializeContextStep(IPipelineStep): 17 | def __init__(self, update_type: UpdateType, data_store: DataStoreBase): 18 | self.update_type = update_type 19 | self.data_store = data_store 20 | 21 | self.log = create_logger("context_initializer") 22 | 23 | async def __call__(self, client: IClient, update: Any, context: Optional[Context]) -> Context: 24 | if not context: 25 | # Create new context 26 | context = Context( 27 | client=client, update=update, update_type=self.update_type, view_state=None 28 | ) 29 | else: 30 | self.log.debug("Passing on existing context") 31 | 32 | await self.fill_context_data(context) 33 | 34 | if context.message_state: 35 | self.log.debug(f"Carrying message_state of type {type(context.message_state)}") 36 | if context.user_state: 37 | self.log.debug(f"Carrying user_state of type {type(context.user_state)}") 38 | if context.chat_state: 39 | self.log.debug(f"Carrying chat_state of type {type(context.chat_state)}") 40 | 41 | return context 42 | 43 | async def fill_context_data(self, context: Context): 44 | tasks = [ 45 | self.data_store.retrieve_user_data(context.user_id), 46 | self.data_store.retrieve_chat_data(context.chat_identity), 47 | self.data_store.retrieve_message_data(context.message_identity), 48 | ] 49 | res = await asyncio.gather(*tasks) 50 | 51 | user_data, chat_data, message_data = res 52 | 53 | context.user_state = user_data 54 | context.chat_state = chat_data 55 | context.message_state = message_data 56 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/steps/invoke_component_step_factory.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable, Optional 2 | 3 | from botkit.core.components import Component 4 | from botkit.routing.pipelines.factory_types import IStepFactory 5 | from botkit.routing.pipelines.steps._base import StepError 6 | from botkit.utils.botkit_logging.setup import create_logger 7 | from botkit.views.botkit_context import Context 8 | 9 | 10 | class InvokeComponentStepError(StepError): 11 | pass 12 | 13 | 14 | class InvokeComponentStepFactory( 15 | IStepFactory[Component, Optional[Callable[[Context], Awaitable[None]]]] 16 | ): 17 | @classmethod 18 | def create_step(cls, component: Component): 19 | if component is None: 20 | return None 21 | 22 | log = create_logger("invoker") 23 | 24 | async def invoke_component(context: Context) -> None: 25 | try: 26 | log.debug(f"Invoking component {component}") 27 | await component.invoke(context) 28 | except Exception as e: 29 | raise InvokeComponentStepError(e) 30 | 31 | return invoke_component 32 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/steps/reduce_step_factory.py: -------------------------------------------------------------------------------- 1 | from functools import update_wrapper 2 | 3 | from botkit.routing.pipelines.factory_types import ICallbackStepFactory 4 | from botkit.routing.pipelines.steps._base import StepError 5 | from botkit.routing.pipelines.reducer import ReducerSignature 6 | from botkit.routing.pipelines.steps.helpers.state_generators import update_view_state_if_applicable 7 | from botkit.utils.botkit_logging.setup import create_logger 8 | 9 | 10 | class ReduceStepError(StepError[ReducerSignature]): 11 | pass 12 | 13 | 14 | # noinspection PyMissingTypeHints 15 | class ReduceStepFactory(ICallbackStepFactory[ReducerSignature]): 16 | @classmethod 17 | def create_step(cls, reducer): 18 | if not reducer: 19 | return None, None 20 | 21 | log = create_logger("reducer") 22 | is_coroutine = reducer.is_coroutine 23 | 24 | if is_coroutine: 25 | 26 | async def mutate_previous_state_async(previous_state, context): 27 | reducer_args = ( 28 | (previous_state,) if reducer.num_parameters == 1 else (previous_state, context) 29 | ) 30 | 31 | try: 32 | log.debug(f"Mutating state asynchronously using reducer {reducer.name}") 33 | result = await reducer.func(*reducer_args) 34 | except Exception as e: 35 | raise ReduceStepError(e) 36 | 37 | if update_view_state_if_applicable(result, context): 38 | log.debug("View state mutated") 39 | else: 40 | log.debug("No state transition required") 41 | return result 42 | 43 | update_wrapper(mutate_previous_state_async, ReduceStepFactory) 44 | return mutate_previous_state_async, is_coroutine 45 | else: 46 | 47 | def mutate_previous_state(previous_state, context): 48 | reducer_args = ( 49 | (previous_state,) if reducer.num_parameters == 1 else (previous_state, context) 50 | ) 51 | 52 | try: 53 | log.debug(f"Mutating state using reducer {reducer.name}") 54 | result = reducer.func(*reducer_args) 55 | except Exception as e: 56 | raise ReduceStepError(e) 57 | 58 | if update_view_state_if_applicable(result, context): 59 | log.debug("View state mutated") 60 | else: 61 | log.debug("No state transition required") 62 | return result 63 | 64 | update_wrapper(mutate_previous_state, ReduceStepFactory) 65 | return mutate_previous_state, is_coroutine 66 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/steps/remove_trigger_step_factory.py: -------------------------------------------------------------------------------- 1 | from typing import Awaitable, Callable, List, Optional 2 | 3 | from botkit.routing.pipelines.executionplan import RemoveTrigger, RemoveTriggerParameters 4 | from botkit.routing.pipelines.factory_types import IStepFactory 5 | from botkit.routing.update_types.updatetype import UpdateType 6 | from botkit.utils.botkit_logging.setup import create_logger 7 | from botkit.views.botkit_context import Context 8 | 9 | 10 | class RemoveTriggerStepFactory( 11 | IStepFactory[Optional[RemoveTriggerParameters], Optional[Callable[[Context], Awaitable[None]]]] 12 | ): 13 | @property 14 | def applicable_update_types(self) -> List[UpdateType]: 15 | return [UpdateType.message] 16 | 17 | @classmethod 18 | def create_step(cls, remove_trigger_setting: Optional[RemoveTriggerParameters]): 19 | if not remove_trigger_setting: 20 | return None 21 | 22 | log = create_logger("remove_trigger") 23 | 24 | async def delete_trigger_message_async(context: Context) -> None: 25 | try: 26 | if remove_trigger_setting.strategy == RemoveTrigger.only_for_me: 27 | if (await context.client.get_me()).id != context.user_id: 28 | return 29 | 30 | if hasattr(context.update, "delete"): 31 | await context.update.delete() 32 | elif hasattr(context.client, "delete_message"): 33 | raise NotImplementedError( 34 | "The delete_message function has not been implemented for trigger removals." 35 | ) 36 | elif hasattr(context.client, "delete_messages"): 37 | raise NotImplementedError( 38 | "The delete_messages function has not been implemented for trigger removals." 39 | ) 40 | else: 41 | log.warning( 42 | "It was not possible to delete a trigger message, as neither the update nor the client had any " 43 | "delete methods." 44 | ) 45 | except: 46 | log.exception("Could not delete a trigger message.") 47 | 48 | return delete_trigger_message_async 49 | -------------------------------------------------------------------------------- /botkit/routing/pipelines/steps/render_view_step_factory.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import warnings 3 | from typing import Callable, List, Optional, cast 4 | 5 | from haps import Container 6 | 7 | from botkit.persistence.callback_store import ICallbackStore 8 | from botkit.routing.pipelines.executionplan import ViewParameters 9 | from botkit.routing.pipelines.factory_types import IStepFactory 10 | from botkit.routing.pipelines.steps._base import StepError 11 | from botkit.routing.update_types.updatetype import UpdateType 12 | from botkit.settings import botkit_settings 13 | from botkit.utils.botkit_logging.setup import create_logger 14 | from botkit.views.botkit_context import Context 15 | from botkit.views.functional_views import ( 16 | quacks_like_view_render_func, 17 | render_functional_view, 18 | ) 19 | from botkit.views.rendered_messages import RenderedMessageBase 20 | from botkit.views.views import MessageViewBase 21 | 22 | 23 | class RenderViewStepError(StepError): 24 | pass 25 | 26 | 27 | class RenderViewStepFactory( 28 | IStepFactory[ViewParameters, Optional[Callable[[Context], RenderedMessageBase]]] 29 | ): 30 | @property 31 | def applicable_update_types(self) -> List[UpdateType]: 32 | return [UpdateType.message, UpdateType.callback_query, UpdateType.poll] 33 | 34 | # TODO: something breaks PyCharm's type inference here... 35 | @classmethod 36 | def create_step(cls, view_params: ViewParameters): 37 | if view_params is None: 38 | return None 39 | 40 | is_view_render_func = quacks_like_view_render_func(view_params.view) 41 | create_view_instance_dynamically = inspect.isclass(view_params.view) 42 | view_renderer_name = ( 43 | view_params.view.__name__ 44 | if hasattr(view_params.view, "__name__") 45 | else str(view_params.view) 46 | ) 47 | 48 | callback_store = Container().get_object( 49 | ICallbackStore, botkit_settings.callback_manager_qualifier 50 | ) 51 | 52 | log = create_logger("renderer") 53 | 54 | def render_view(context: Context) -> RenderedMessageBase: 55 | log.debug(f"Rendering view using {view_renderer_name}") 56 | 57 | try: 58 | if is_view_render_func: 59 | return render_functional_view( 60 | view_params.view, context.view_state, callback_store 61 | ) 62 | 63 | elif create_view_instance_dynamically: 64 | view_instance = view_params.view(context.view_state) 65 | 66 | if context.view_state is None: 67 | # First try to instantiate the view, then warn if that succeeded without an exception despite 68 | # a `None`-view_state. 69 | warnings.warn( 70 | f"`None` state is being passed to view {view_params.view}. Check your state generation " 71 | f"and/or mutations." 72 | ) 73 | 74 | return view_instance.render() 75 | 76 | else: 77 | return cast(MessageViewBase, view_params.view).render() 78 | except Exception as e: 79 | raise RenderViewStepError(e) 80 | 81 | return render_view 82 | -------------------------------------------------------------------------------- /botkit/routing/route_builder/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/routing/route_builder/__init__.py -------------------------------------------------------------------------------- /botkit/routing/route_builder/action_expression_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | from typing import Union, Optional, Callable, Awaitable 3 | 4 | from botkit.routing.route_builder.route_collection import RouteCollection 5 | from botkit.routing.triggers import RouteTriggers 6 | from abc import ABC 7 | 8 | 9 | class ActionExpressionBase(ABC): 10 | def __init__( 11 | self, 12 | routes: RouteCollection, 13 | action: Union[int, str], 14 | condition: Optional[Callable[[], Union[bool, Awaitable[bool]]]] = None, 15 | ): 16 | self._routes = routes 17 | self._triggers = RouteTriggers(action=action, filters=None, condition=condition) 18 | -------------------------------------------------------------------------------- /botkit/routing/route_builder/builder.py: -------------------------------------------------------------------------------- 1 | from typing import Generic 2 | 3 | from botkit.routing.route_builder.expressions import TLoadResult 4 | from .route_builder_base import RouteBuilderBase 5 | from .state_machine_mixin import StateMachineMixin 6 | 7 | 8 | class RouteBuilder(RouteBuilderBase, StateMachineMixin, Generic[TLoadResult]): 9 | pass 10 | -------------------------------------------------------------------------------- /botkit/routing/route_builder/expressions/__init__.py: -------------------------------------------------------------------------------- 1 | from ._split_this_up import RouteExpression 2 | from ._split_this_up import StateGenerationExpression 3 | from ._split_this_up import ActionExpression 4 | from ._split_this_up import CommandExpression 5 | from ._split_this_up import PlayGameExpression 6 | from ._split_this_up import ConditionsExpression 7 | 8 | from .route_builder_context import TLoadResult 9 | from .route_builder_context import RouteBuilderContext 10 | -------------------------------------------------------------------------------- /botkit/routing/route_builder/expressions/_base.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol, TYPE_CHECKING 2 | 3 | from botkit.agnostic import HandlerSignature 4 | from botkit.routing.route_builder.has_route_collection import IRouteCollection 5 | from botkit.routing.triggers import RouteTriggers 6 | 7 | if TYPE_CHECKING: 8 | from botkit.routing.route_builder.expressions import RouteExpression 9 | from botkit.routing.route_builder.route_collection import RouteCollection 10 | 11 | 12 | class IExpressionWithCallMethod(IRouteCollection, Protocol): 13 | _triggers: RouteTriggers 14 | 15 | def call(self, handler: HandlerSignature) -> "RouteExpression": 16 | pass 17 | -------------------------------------------------------------------------------- /botkit/routing/route_builder/expressions/route_builder_context.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Generic, Optional, TypeVar 3 | 4 | TLoadResult = TypeVar("TLoadResult") 5 | 6 | 7 | @dataclass 8 | class RouteBuilderContext(Generic[TLoadResult]): 9 | load_result: Optional[Any] = None 10 | """ 11 | Contains the return value of a module's asynchronous `load()` method so that the synchronous `register` has 12 | access to data that should be retrieved from a coroutine. 13 | """ 14 | -------------------------------------------------------------------------------- /botkit/routing/route_builder/has_route_collection.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | from botkit.routing.route_builder.route_collection import RouteCollection 4 | 5 | 6 | class IRouteCollection(Protocol): 7 | _route_collection: "RouteCollection" 8 | -------------------------------------------------------------------------------- /botkit/routing/route_builder/publish_expression.py: -------------------------------------------------------------------------------- 1 | from botkit.routing.route_builder.action_expression_base import ActionExpressionBase 2 | 3 | from buslane.events import Event 4 | 5 | from botkit.routing.route_builder.action_expression_base import ActionExpressionBase 6 | 7 | 8 | # @dataclass 9 | # class RegisterUserEvent(Event): 10 | # email: str 11 | # password: str 12 | # 13 | # 14 | # class RegisterUserCommandHandler(EventHandler[RegisterUserEvent]): 15 | # 16 | # def handle(self, command: RegisterUserEvent) -> None: 17 | # pass # TODO 18 | 19 | 20 | # event_bus.register(handler=RegisterUserCommandHandler()) 21 | # event_bus.publish(command=RegisterUserEvent( 22 | # email='john@lennon.com', 23 | # password='secret', 24 | # )) 25 | 26 | 27 | class PublishActionExpressionMixin(ActionExpressionBase): 28 | # _event_bus: = Inject() 29 | 30 | def publish(self, event: Event): 31 | raise NotImplementedError() 32 | # self._routes.add_for_current_client() 33 | # pass 34 | 35 | 36 | # async def build_handler() 37 | -------------------------------------------------------------------------------- /botkit/routing/route_builder/route_collection.py: -------------------------------------------------------------------------------- 1 | from pyrogram.filters import Filter 2 | 3 | from botkit.core.components import Component 4 | from botkit.routing.route import RouteDefinition 5 | from botkit.clients.client import IClient 6 | from typing import ( 7 | Optional, 8 | Dict, 9 | List, 10 | ) 11 | 12 | 13 | class RouteCollection: 14 | def __init__(self, current_client: IClient = None): 15 | self.current_client = current_client 16 | self.routes_by_client: Dict[IClient, List[RouteDefinition]] = dict() 17 | self.default_filters: Optional[Filter] = None 18 | 19 | def add_for_current_client(self, route: RouteDefinition): 20 | if self.current_client is None: 21 | raise ValueError( 22 | "Please assign a client to the builder first by declaring `routes.use(client)` or using the " 23 | "contextmanager `with routes.using(client): ...`" 24 | ) 25 | self._merge_route_trigger_with_default_filters(route) 26 | self.routes_by_client.setdefault(self.current_client, list()).append(route) 27 | 28 | def _merge_route_trigger_with_default_filters(self, route: RouteDefinition) -> None: 29 | 30 | if not self.default_filters: 31 | return 32 | 33 | if not (route_filters := route.triggers.filters): 34 | return 35 | 36 | route.triggers.filters = route_filters & self.default_filters 37 | 38 | @property 39 | def components_by_client(self) -> Dict[IClient, List[Component]]: 40 | results: Dict[IClient, List[Component]] = dict() 41 | 42 | for client, client_routes in self.routes_by_client.items(): 43 | for route in client_routes: 44 | if component := route.plan._handling_component: 45 | results.setdefault(client, list()).append(component) 46 | 47 | return results 48 | -------------------------------------------------------------------------------- /botkit/routing/route_builder/state_route_builder.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID, uuid4 2 | 3 | from botkit.routing.route_builder.route_builder_base import RouteBuilderBase 4 | from botkit.routing.route_builder.route_collection import RouteCollection 5 | 6 | 7 | class StateRouteBuilder(RouteBuilderBase): 8 | def __init__(self, machine_guid: UUID, index: int, routes: RouteCollection, name: str = None): 9 | super().__init__(routes) 10 | self.state_guid = uuid4() 11 | self.machine_guid = machine_guid 12 | self.index = index 13 | self.name = name 14 | self._route_collection = routes 15 | -------------------------------------------------------------------------------- /botkit/routing/route_builder/types.py: -------------------------------------------------------------------------------- 1 | from typing import Type, TypeVar, Union 2 | 3 | from botkit.views.base import InlineResultViewBase 4 | from botkit.views.views import MessageViewBase 5 | 6 | V = TypeVar("V", bound=InlineResultViewBase, covariant=True) 7 | 8 | TView = Union[V, Type[MessageViewBase]] 9 | -------------------------------------------------------------------------------- /botkit/routing/route_builder/webhook_action_expression.py: -------------------------------------------------------------------------------- 1 | try: 2 | from httpx import AsyncClient 3 | except: 4 | AsyncClient = None 5 | 6 | from typing import Any 7 | from typing_extensions import Literal 8 | 9 | from botkit.routing.route_builder.action_expression_base import ActionExpressionBase 10 | 11 | # client = AsyncClient() 12 | 13 | 14 | # async def execute_request(url: str, method: Literal["POST", "GET"], view_state: Any, payload: Any): 15 | # await client.request(method, url, data=data) 16 | 17 | 18 | class WebhookActionExpressionMixin(ActionExpressionBase): 19 | def post(self, url: str): 20 | return 21 | # plan = ExecutionPlan() 22 | # plan.add_handler() 23 | # route = Route(triggers=self._triggers, execution_plan=plan) 24 | # self._routes.add_for_current_client(route) 25 | # future = asyncio.ensure_future(self.execute_request(method="POST", choices=)) 26 | 27 | def get(self, url: str): 28 | pass 29 | 30 | def _create_payload(self): 31 | pass 32 | 33 | 34 | # async def build_handler() 35 | -------------------------------------------------------------------------------- /botkit/routing/triggers.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | 3 | from typing import Optional, Callable, Set, Union, Awaitable 4 | 5 | from boltons.iterutils import is_collection 6 | from pyrogram.filters import Filter 7 | 8 | from botkit.routing.update_types.updatetype import UpdateType 9 | 10 | ActionIdType = Union[int, str] 11 | 12 | 13 | @dataclass 14 | class RouteTriggers: 15 | filters: Optional[Filter] = None 16 | action: Optional[ActionIdType] = None 17 | condition: Optional[Callable[[], Union[bool, Awaitable[bool]]]] = None 18 | -------------------------------------------------------------------------------- /botkit/routing/types.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | TViewState = TypeVar("TViewState") 4 | -------------------------------------------------------------------------------- /botkit/routing/update_types/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/routing/update_types/__init__.py -------------------------------------------------------------------------------- /botkit/routing/update_types/update_type_inference.py: -------------------------------------------------------------------------------- 1 | from typing import Set 2 | 3 | from botkit.agnostic._pyrogram_update_type_inference import determine_pyrogram_handler_update_types 4 | from botkit.routing.update_types.updatetype import UpdateType 5 | from botkit.utils.typed_callable import TypedCallable 6 | 7 | 8 | def infer_update_types(handler: TypedCallable) -> Set[UpdateType]: 9 | return determine_pyrogram_handler_update_types(handler) 10 | -------------------------------------------------------------------------------- /botkit/routing/update_types/updatetype.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | from boltons.typeutils import classproperty 4 | 5 | 6 | class UpdateType(Enum): 7 | raw = auto() 8 | message = auto() 9 | callback_query = auto() 10 | inline_query = auto() 11 | poll = auto() 12 | user_status = auto() 13 | start_command = auto() 14 | 15 | # noinspection PyMethodParameters 16 | @classproperty 17 | def all(cls): 18 | return [ 19 | cls.raw, 20 | cls.message, 21 | cls.callback_query, 22 | cls.inline_query, 23 | cls.poll, 24 | cls.user_status, 25 | ] 26 | -------------------------------------------------------------------------------- /botkit/routing/user_error.py: -------------------------------------------------------------------------------- 1 | class UserError(Exception): 2 | pass # TODO: this exception should be used in a gatherer to notify the user of a wrong input (with a prebuilt view) 3 | -------------------------------------------------------------------------------- /botkit/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/services/__init__.py -------------------------------------------------------------------------------- /botkit/services/historyservice.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | from typing import Awaitable 3 | 4 | from haps import SINGLETON_SCOPE, egg, base, scope 5 | from pyrogram import Client 6 | from typing import Callable, Union, AsyncGenerator, cast 7 | 8 | from pyrogram.types import Message 9 | 10 | from botkit.utils.typed_callable import TypedCallable 11 | 12 | 13 | @base 14 | @egg 15 | @scope(SINGLETON_SCOPE) 16 | class HistoryService: 17 | def __init__(self, client: Client): 18 | self.client = client 19 | 20 | async def follow_reply_chain( 21 | self, from_message: Message, until: Callable[[Message], Union[bool, Awaitable[bool]]], 22 | ) -> AsyncGenerator[Message, None]: 23 | # TODO: untested 24 | Message.__hash__ = lambda x: (x.chat.id << 32) + x.message_id 25 | until_is_coroutine = TypedCallable(until).is_coroutine 26 | 27 | yield from_message 28 | 29 | while True: 30 | reply_msg = from_message.reply_to_message 31 | if not reply_msg or reply_msg.empty: 32 | return 33 | 34 | if until_is_coroutine: 35 | is_match = await until(reply_msg) 36 | else: 37 | is_match = until(reply_msg) 38 | 39 | if not is_match: 40 | return 41 | 42 | yield reply_msg 43 | 44 | async def iter_replies_to( 45 | self, chat_id: Union[int, str], target_message: Union[Message, int], vicinity: int = 500, 46 | ) -> AsyncGenerator[Message, None]: 47 | target_message_id: int = ( 48 | target_message if isinstance(target_message, int) else target_message.message_id 49 | ) 50 | history_gen = self.client.iter_history( 51 | chat_id, limit=vicinity, offset_id=target_message_id, reverse=True 52 | ) 53 | 54 | # noinspection PyTypeChecker 55 | async for m in cast(AsyncGenerator[Message, None], history_gen): 56 | if m.empty: 57 | continue 58 | if m.reply_to_message and m.reply_to_message.message_id == target_message_id: 59 | yield m 60 | 61 | async def get_reply_counts(self, chat_id: Union[int, str], lookback: int = 3000) -> Counter: 62 | counter: Counter = Counter() 63 | 64 | async for m in cast( 65 | AsyncGenerator[Message, None], self.client.iter_history(chat_id, limit=lookback), 66 | ): 67 | reply_msg = m.reply_to_message 68 | if reply_msg: 69 | if reply_msg.empty: 70 | continue 71 | Message.__hash__ = lambda x: (x.chat.id << 32) + x.message_id 72 | counter.update([reply_msg]) 73 | 74 | return counter 75 | -------------------------------------------------------------------------------- /botkit/settings.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Literal, Optional 3 | 4 | 5 | _BOTKIT_DEFAULT_LOG_LEVEL = "INFO" 6 | 7 | 8 | @dataclass 9 | class _BotkitSettings: 10 | # region General 11 | 12 | application_name: str = "Botkit" 13 | 14 | # endregion 15 | 16 | # region Builder classes 17 | 18 | route_builder_class = None 19 | 20 | # endregion 21 | 22 | # region Callback manager 23 | 24 | callback_manager_qualifier: Literal["memory", "redis"] = "memory" 25 | """ 26 | Qualifier key of the kind of callback manager to be used. Should be "memory" for an in-memory store (without 27 | persistence) and "redis" if you have the `redis_collections` package installed. 28 | 29 | If you want to use Redis, you will need to supply Botkit with a Redis client. This is done by exposing it to haps 30 | by using `@egg` and usually a `@scope(SINGLETON_SCOPE)`. The following example illustrates this: 31 | 32 | ``` 33 | from decouple import config 34 | from haps import SINGLETON_SCOPE, egg, scope 35 | from redis import Redis 36 | 37 | @egg 38 | @scope(SINGLETON_SCOPE) 39 | def create_redis_client() -> Redis: 40 | return Redis(host=config("REDIS_HOST"), password=config("REDIS_PASSWORD")) 41 | ``` 42 | """ 43 | 44 | callbacks_ttl_days: int = 7 45 | """ abc """ 46 | 47 | # endregion 48 | 49 | # region Logging 50 | 51 | _current_log_level: str = _BOTKIT_DEFAULT_LOG_LEVEL 52 | 53 | @property 54 | def log_level(self) -> str: 55 | return self._current_log_level 56 | 57 | @log_level.setter 58 | def log_level(self, value: Optional[str]) -> None: 59 | value = value or _BOTKIT_DEFAULT_LOG_LEVEL 60 | self._current_log_level = value 61 | 62 | # endregion 63 | 64 | 65 | botkit_settings = _BotkitSettings() 66 | -------------------------------------------------------------------------------- /botkit/testing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/testing/__init__.py -------------------------------------------------------------------------------- /botkit/testing/module_test_factory.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Type 2 | 3 | from botkit.core.modules import Module 4 | from botkit.routing.route import RouteDefinition 5 | from botkit.routing.route_builder.builder import RouteBuilder 6 | from botkit.agnostic import PyrogramViewSender 7 | 8 | # TODO: implement 9 | 10 | 11 | class ModuleTestFactory: 12 | def __init__(self, module_type: Type): 13 | self.module_under_test = module_type 14 | 15 | def handle_update(self, update: Any, with_client: PyrogramViewSender): 16 | routes = self.get_routes() 17 | 18 | def get_routes(self, client: PyrogramViewSender) -> List[RouteDefinition]: 19 | module = self._create_instance() 20 | builder = RouteBuilder() 21 | module.register(builder) 22 | return builder._route_collection.routes_by_client[client] 23 | 24 | def _create_instance(self) -> Module: 25 | return self.module_under_test() 26 | -------------------------------------------------------------------------------- /botkit/tghelpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/tghelpers/__init__.py -------------------------------------------------------------------------------- /botkit/tghelpers/direct_links.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | from pyrogram import Client 4 | from pyrogram.raw.types import Channel 5 | from pyrogram.types import Message, User, Chat 6 | from typing import Optional, Union, cast, Dict 7 | 8 | _links_cache: Dict[int, str] = {} 9 | 10 | 11 | class Platform(IntEnum): 12 | android = 1 13 | ios = 2 14 | web = 3 15 | desktop = 4 16 | 17 | 18 | async def direct_link_to_message( 19 | reference: Message, platform: Optional[Platform] = Platform.android 20 | ) -> str: 21 | entity_link = await direct_link(reference._client, reference.chat, platform) 22 | return f"{entity_link}/{reference.message_id}" 23 | 24 | 25 | async def direct_link( 26 | client: Client, 27 | peer: Union[User, Chat, Channel], 28 | platform: Optional[Platform] = Platform.android, 29 | ) -> str: 30 | if peer.username: 31 | return f"https://t.me/{peer.username}" 32 | 33 | if isinstance(peer, User): 34 | return direct_link_user(peer, platform) 35 | 36 | peer_id = peer.id 37 | if isinstance(peer, Channel): 38 | return f"https://t.me/c/{peer_id}" 39 | invite_link: str = _links_cache.get(peer_id, None) 40 | if not invite_link: 41 | invite_link: str = (await client.get_chat(peer_id)).invite_link 42 | _links_cache[peer_id] = invite_link 43 | if invite_link: 44 | return invite_link 45 | 46 | 47 | def direct_link_user(user: User, platform: Optional[Platform] = Platform.android): 48 | if user.username: 49 | return f"https://t.me/{user.username}" 50 | 51 | if platform == Platform.android: 52 | return f"tg://openmessage?user_id={user.id}" 53 | elif platform == Platform.ios: 54 | return f"t.me/@{user.id}" 55 | elif platform == Platform.web: 56 | # TODO: maybe incorrect, test! 57 | return f"https://web.Telegram.org/#/im?p=u{user.id}" 58 | else: 59 | raise ValueError( 60 | f"User has no username, creating direct link on platform {platform} not possible." 61 | ) 62 | -------------------------------------------------------------------------------- /botkit/tghelpers/emoji_utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/tghelpers/emoji_utils/__init__.py -------------------------------------------------------------------------------- /botkit/tghelpers/emoji_utils/flags.py: -------------------------------------------------------------------------------- 1 | OFFSET = 127462 - ord("A") 2 | 3 | 4 | def flag_emoji(code): 5 | return chr(ord(code[0]) + OFFSET) + chr(ord(code[1]) + OFFSET) 6 | 7 | 8 | if __name__ == "__main__": 9 | available_locales = { 10 | "en_US": flag_emoji("US") + " English (US)", 11 | "de_DE": flag_emoji("DE") + " Deutsch (DE)", 12 | # 'es_ES': flag('ES') + ' Español (ES)', 13 | # 'id_ID': flag('ID') + ' Bahasa Indonesia', 14 | # 'it_IT': flag('IT') + ' Italiano', 15 | # 'pt_BR': flag('BR') + ' Português Brasileiro', 16 | # 'ru_RU': flag('RU') + ' Русский язык', 17 | # 'zh_CN': flag('CN') + ' 中文(简体)', 18 | # 'zh_HK': flag('HK') + ' 廣東話', 19 | # 'zh_TW': flag('TW') + ' 中文(台灣)' 20 | } 21 | 22 | print(available_locales) 23 | -------------------------------------------------------------------------------- /botkit/tghelpers/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/tghelpers/entities/__init__.py -------------------------------------------------------------------------------- /botkit/tghelpers/entities/message_entities.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Dict, List, Optional, Union 3 | 4 | import sys 5 | from pyrogram.filters import Filter, create 6 | from pyrogram.types import Message, MessageEntity 7 | from typing_extensions import Literal 8 | 9 | MessageEntityType = Literal[ 10 | "mention", 11 | "hashtag", 12 | "cashtag", 13 | "bot_command", 14 | "url", 15 | "email", 16 | "bold", 17 | "italic", 18 | "code", 19 | "pre", 20 | "underline", 21 | "strike", 22 | "blockquote", 23 | "text_link", 24 | "text_mention", 25 | "phone_number", 26 | ] 27 | 28 | 29 | def parse_entity_text(entity: MessageEntity, message_text: str) -> str: 30 | # Is it a narrow build, if so we don't need to convert 31 | if sys.maxunicode == 0xFFFF: 32 | return message_text[entity.offset : entity.offset + entity.length] 33 | else: 34 | entity_text = message_text.encode("utf-16-le") 35 | entity_text = entity_text[entity.offset * 2 : (entity.offset + entity.length) * 2] 36 | 37 | return entity_text.decode("utf-16-le") 38 | 39 | 40 | @dataclass 41 | class ParsedEntity: 42 | entity: MessageEntity 43 | text: str 44 | 45 | 46 | def parse_entities( 47 | message: Message, types: Union[List[MessageEntityType], MessageEntityType] = None 48 | ) -> List[ParsedEntity]: 49 | if types is None: 50 | types = MessageEntity.ENTITIES.values() 51 | elif isinstance(types, str): 52 | types = [types] 53 | 54 | return [ 55 | ParsedEntity(entity=entity, text=parse_entity_text(entity, message.text)) 56 | for entity in message.entities or [] 57 | if entity.type in types 58 | ] 59 | 60 | 61 | def create_entity_filter(type_: MessageEntityType) -> Filter: 62 | return create( 63 | lambda _, __, m: any(parse_entities(m, type_)) if m.entities else False, type_.upper(), 64 | ) 65 | 66 | 67 | class EntityFilters: 68 | url = create_entity_filter("url") 69 | text_link = create_entity_filter("text_link") 70 | -------------------------------------------------------------------------------- /botkit/tghelpers/names.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | 4 | def display_name(entity: Any) -> str: 5 | if title := getattr(entity, "title", None): 6 | return title 7 | elif first_name := getattr(entity, "first_name", None): 8 | if last_name := getattr(entity, "last_name", None): 9 | return f"{first_name} {last_name}" 10 | return entity.first_name 11 | raise ValueError(f"The entity of type {type(entity)} does not seem to have a display name.") 12 | 13 | 14 | def user_or_display_name(entity: Any) -> str: 15 | if entity.username: 16 | return f"@{entity.username}" 17 | return display_name(entity) 18 | -------------------------------------------------------------------------------- /botkit/types/helpers.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Protocol, Type, TypeVar, Union 2 | 3 | T = TypeVar("T") 4 | 5 | X = Union[T, Iterable[T]] 6 | 7 | d: X = 3 8 | 9 | 10 | class MaybeMany(Protocol[T]): 11 | def __class_getitem__(cls, item: Type[T]) -> Union[T, Iterable[T]]: 12 | ... 13 | 14 | 15 | x: MaybeMany[int] = 3 16 | -------------------------------------------------------------------------------- /botkit/uncategorized/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/uncategorized/__init__.py -------------------------------------------------------------------------------- /botkit/uncategorized/buttons.py: -------------------------------------------------------------------------------- 1 | from pyrogram.types import InlineKeyboardButton 2 | 3 | from botkit.inlinequeries.contexts import IInlineModeContext, DefaultInlineModeContext 4 | 5 | 6 | def switch_inline_button( 7 | caption: str = "Try me inline!", 8 | in_context: IInlineModeContext = DefaultInlineModeContext, 9 | current_chat: bool = True, 10 | ) -> InlineKeyboardButton: 11 | return InlineKeyboardButton( 12 | caption, 13 | **{ 14 | "switch_inline_query" + "_current_chat" 15 | if current_chat 16 | else "": in_context.format_query() 17 | }, 18 | ) 19 | -------------------------------------------------------------------------------- /botkit/unstable_experiments/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/unstable_experiments/__init__.py -------------------------------------------------------------------------------- /botkit/unstable_experiments/declarative_definitions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/unstable_experiments/declarative_definitions/__init__.py -------------------------------------------------------------------------------- /botkit/unstable_experiments/declarative_definitions/commands.py: -------------------------------------------------------------------------------- 1 | # I don't know man... 2 | # What do I want this to look like??? 3 | 4 | yaml = """ 5 | example_command: 6 | 7 | triggers: 8 | - /example 9 | - /examples 10 | - @mybot examples 11 | - 12 | 13 | handler: 14 | 15 | """ 16 | 17 | # expected_result = Command( 18 | # trigger= 19 | # ) 20 | -------------------------------------------------------------------------------- /botkit/unstable_experiments/html_views/ViewSchema.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /botkit/unstable_experiments/html_views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/unstable_experiments/html_views/__init__.py -------------------------------------------------------------------------------- /botkit/unstable_experiments/html_views/experiment.xml: -------------------------------------------------------------------------------- 1 | view xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> 2 | 3 | address 4 | 5 | 6 | Welcome, kind {{address}}! 7 | 8 | How to do foreach?? 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /botkit/unstable_experiments/html_views/view.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/unstable_experiments/html_views/view.py -------------------------------------------------------------------------------- /botkit/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .nameof import nameof 2 | -------------------------------------------------------------------------------- /botkit/utils/botkit_logging/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/utils/botkit_logging/__init__.py -------------------------------------------------------------------------------- /botkit/utils/botkit_logging/chatlogger.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import traceback 3 | from dataclasses import dataclass 4 | from logging import Handler, LogRecord 5 | from typing import Tuple, Union 6 | 7 | from botkit.builders import ViewBuilder 8 | from botkit.core.components import Component 9 | from botkit.agnostic.annotations import IClient 10 | from botkit.routing.route_builder.builder import RouteBuilder 11 | from botkit.settings import botkit_settings 12 | from botkit.views.botkit_context import Context 13 | from botkit.views.functional_views import ViewRenderFuncSignature, render_functional_view 14 | 15 | 16 | class ChatLoggerComponent(Component): 17 | def register(self, routes: RouteBuilder): 18 | pass 19 | 20 | async def invoke(self, context: Context): 21 | pass 22 | 23 | 24 | @dataclass 25 | class ChatLoggerConfig: 26 | client: IClient 27 | log_chat: Union[int, str] 28 | level_name: Union[int, str] = "WARNING" 29 | 30 | 31 | def _default_renderer(props: Tuple[str, LogRecord], builder: ViewBuilder): 32 | text, record = props 33 | builder.html.code(record.filename) 34 | 35 | if record.funcName: 36 | builder.html.spc().text("(").code(record.funcName).text(")") 37 | 38 | builder.html.text(":").br().text(record.message) 39 | 40 | 41 | def _filter_by_current_level(record): 42 | return record["level"].no >= botkit_settings.log_level 43 | 44 | 45 | async def emit_log(): 46 | pass 47 | 48 | 49 | class ChatLogHandler(Handler): 50 | def __init__( 51 | self, 52 | client: IClient, 53 | config: ChatLoggerConfig, 54 | render_log_entry: ViewRenderFuncSignature = _default_renderer, 55 | ): 56 | self.render_log_entry = render_log_entry 57 | self.client = client 58 | self.log_chat = config.log_chat 59 | 60 | super(ChatLogHandler, self).__init__(config.level) 61 | 62 | def handle(self, record: LogRecord) -> None: 63 | super().handle(record) 64 | 65 | def emit(self, record: LogRecord): 66 | text = self.format(record) 67 | asyncio.run_coroutine_threadsafe( 68 | self._render_and_send((text, record)), asyncio.get_event_loop() 69 | ) 70 | 71 | async def _render_and_send(self, text_and_record: Tuple[str, LogRecord]): 72 | rendered = render_functional_view(self.render_log_entry, text_and_record) 73 | try: 74 | await self.client.send_rendered_message(self.log_chat, rendered) 75 | except: 76 | traceback.print_exc() 77 | -------------------------------------------------------------------------------- /botkit/utils/botkit_logging/setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import * 3 | import re 4 | 5 | from loguru import logger 6 | from loguru._logger import Logger 7 | 8 | from botkit.settings import botkit_settings 9 | 10 | 11 | # def botkit_log_filter(record): 12 | # return ( 13 | # "botkit" in record["extra"] 14 | # and record["level"].no >= logger.level(botkit_settings.log_level).no 15 | # ) 16 | 17 | 18 | def create_logger(sub_logger_name: Optional[str] = None, **kwargs: Any) -> Logger: 19 | if sub_logger_name: 20 | name = "botkit." + re.sub(r"^botkit\.?", "", sub_logger_name, re.MULTILINE) 21 | else: 22 | name = "botkit" 23 | 24 | # Then you can use this one to log all messages 25 | log = logger.bind(identity=name, botkit=True) 26 | 27 | return log 28 | -------------------------------------------------------------------------------- /botkit/utils/cached_property.py: -------------------------------------------------------------------------------- 1 | from threading import RLock 2 | 3 | # sentinel 4 | _missing = object() 5 | 6 | 7 | # TODO: Can this be replaced with functools.cached_property ?? 8 | class _CachedPropertyDecorator(object): 9 | def __init__(self, func): 10 | self.__name__ = func.__name__ 11 | self.__module__ = func.__module__ 12 | self.__doc__ = func.__doc__ 13 | self.func = func 14 | self.lock = RLock() 15 | 16 | def __get__(self, obj, type=None): 17 | if obj is None: 18 | return self 19 | with self.lock: 20 | value = obj.__dict__.get(self.__name__, _missing) 21 | if value is _missing: 22 | value = self.func(obj) 23 | obj.__dict__[self.__name__] = value 24 | return value 25 | 26 | 27 | cached_property = _CachedPropertyDecorator 28 | -------------------------------------------------------------------------------- /botkit/utils/dataclass_helpers.py: -------------------------------------------------------------------------------- 1 | from dataclasses import field 2 | from typing import Dict, FrozenSet 3 | 4 | 5 | def default_field(obj): 6 | return field(default_factory=lambda: obj) 7 | 8 | 9 | def slots(anotes: Dict[str, object]) -> FrozenSet[str]: 10 | """ https://stackoverflow.com/a/63658478/3827785 """ 11 | return frozenset(anotes.keys()) 12 | -------------------------------------------------------------------------------- /botkit/utils/datetime_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | import pytz 5 | 6 | from pyrogram.types import Message 7 | 8 | 9 | def get_message_date(message: Message, including_edits: bool = True) -> datetime: 10 | if message is None: 11 | raise ValueError("view_sender_interface cannot be None.") 12 | 13 | if message.edit_date and including_edits: 14 | return timestamp_to_datetime(message.edit_date) 15 | 16 | if message.date: 17 | return timestamp_to_datetime(message.date) 18 | 19 | raise ValueError("No date found in message", message) 20 | 21 | 22 | def timestamp_to_datetime(timestamp: Optional[int]) -> datetime: 23 | return datetime.fromtimestamp(timestamp, tz=pytz.UTC) 24 | -------------------------------------------------------------------------------- /botkit/utils/legacy.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | import difflib 4 | import os 5 | import re 6 | import sys 7 | import urllib.parse 8 | from io import StringIO 9 | 10 | 11 | def multiple_replace(repl, text, *flags): 12 | # Create a regular expression from the dictionary keys 13 | regex = re.compile(r"(%s)" % "|".join(repl.keys()), *flags) 14 | return regex.sub( 15 | lambda mo: repl[ 16 | [k for k in repl if re.search(k, mo.string[mo.start() : mo.end()], *flags)][0] 17 | ], 18 | text, 19 | ) 20 | 21 | 22 | @contextlib.contextmanager 23 | def stdout_io(stdout=None): 24 | old = sys.stdout 25 | if stdout is None: 26 | stdout = StringIO() 27 | sys.stdout = stdout 28 | yield stdout 29 | sys.stdout = old 30 | 31 | 32 | def urlencode(text): 33 | return urllib.parse.quote(text) 34 | -------------------------------------------------------------------------------- /botkit/utils/nameof.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://github.com/alexmojaki/nameof 3 | 4 | MIT License 5 | 6 | Copyright (c) 2020 Alex Hall 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | """ 26 | 27 | import dis 28 | import inspect 29 | from functools import lru_cache 30 | 31 | 32 | def nameof(_): 33 | frame = inspect.currentframe().f_back 34 | return _nameof(frame.f_code, frame.f_lasti) 35 | 36 | 37 | @lru_cache() 38 | def _nameof(code, offset): 39 | instructions = list(dis.get_instructions(code)) 40 | ((current_instruction_index, current_instruction),) = ( 41 | (index, instruction) 42 | for index, instruction in enumerate(instructions) 43 | if instruction.offset == offset 44 | ) 45 | # assert current_instruction.opname in ("CALL_FUNCTION", "CALL_METHOD"), "Did you call nameof in a weird way?" 46 | name_instruction = instructions[current_instruction_index - 1] 47 | # assert name_instruction.opname.startswith("LOAD_"), "Argument must be a variable or attribute" 48 | return name_instruction.argrepr 49 | 50 | 51 | # def test(): 52 | # assert nameof(dis) == "dis" 53 | # assert nameof(dis.get_instructions) == "get_instructions" 54 | # x = 1 55 | # assert nameof(x) == "x" 56 | # 57 | # def foo(): 58 | # assert nameof(x) == "x" 59 | # 60 | # foo.nameof = nameof 61 | # assert foo.nameof(x) == "x" 62 | # 63 | # foo() 64 | # 65 | # 66 | # if __name__ == "__main__": 67 | # test() 68 | -------------------------------------------------------------------------------- /botkit/utils/scheduling.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | 5 | def maybe_async(func, *args, **kwargs): 6 | """ 7 | Turn a callable into a coroutine if it isn't 8 | """ 9 | 10 | if asyncio.iscoroutine(func): 11 | return func 12 | 13 | return asyncio.coroutine(func)(*args, **kwargs) 14 | 15 | 16 | def fire(func, *args, **kwargs): 17 | """ 18 | Fire a callable as a coroutine, and return its future. The cool thing 19 | about this function is that (via maybeAsync) it lets you treat synchronous 20 | and asynchronous callables the same, which simplifies code. 21 | """ 22 | 23 | return asyncio.ensure_future(maybe_async(func, *args, **kwargs)) 24 | 25 | 26 | async def _call_later(delay, func, *args, **kwargs): 27 | """ 28 | The bus stop, where we wait. 29 | """ 30 | 31 | await asyncio.sleep(delay) 32 | fire(func, *args, **kwargs) 33 | 34 | 35 | def call_later(delay, func, *args, **kwargs): 36 | """ 37 | After :delay seconds, call :callable with :args and :kwargs; :callable can 38 | be a synchronous or asynchronous callable (a coroutine). Note that _this_ 39 | function is synchronous - mission accomplished - it can be used from within 40 | any synchronous or asynchronous callable. 41 | """ 42 | 43 | fire(_call_later, delay, func, *args, **kwargs) 44 | 45 | 46 | def run_ignore_exc(coro, timeout: Optional[float] = None) -> asyncio.Future: 47 | async def inner(): 48 | try: 49 | if timeout is not None: 50 | await asyncio.sleep(timeout) 51 | await coro 52 | except: 53 | pass 54 | 55 | return asyncio.ensure_future(inner()) 56 | -------------------------------------------------------------------------------- /botkit/utils/sentinel.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | # See https://stackoverflow.com/questions/57959664/handling-conditional-logic-sentinel-value-with-mypy 4 | 5 | 6 | class Sentinel(enum.Enum): 7 | sentinel = object() 8 | 9 | 10 | NotSet = Sentinel.sentinel 11 | -------------------------------------------------------------------------------- /botkit/utils/strutils.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | 3 | 4 | def string_similarity(user_input: str, compare_to: str) -> float: 5 | compare_to = compare_to.lower() 6 | user_input = user_input.lower() 7 | 8 | add = 0 9 | if user_input in compare_to or compare_to in user_input: 10 | add = 0.15 11 | 12 | return min(1.0, difflib.SequenceMatcher(None, user_input, compare_to).ratio() + add) 13 | -------------------------------------------------------------------------------- /botkit/utils/timer.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Callable, Any 3 | 4 | from dataclasses import dataclass 5 | 6 | 7 | @dataclass 8 | class Measurement: 9 | func: Callable 10 | result: Any 11 | duration_ms: float 12 | 13 | def duration_str(self): 14 | if self.duration_ms > 1000: 15 | return "{:.1f}s".format(self.duration_ms / 1000) 16 | else: 17 | return "{:.5}ms".format(self.duration_ms) 18 | 19 | def __str__(self): 20 | return "{} function took {:.3f} ms".format(self.func.__name__, self.duration_ms) 21 | 22 | 23 | def time_call(func: Callable, *args, **kwargs) -> Measurement: 24 | start = time.time() 25 | result = func(*args, **kwargs) 26 | end = time.time() 27 | 28 | return Measurement(func=func, result=result, duration_ms=(end - start) * 1000.0) 29 | -------------------------------------------------------------------------------- /botkit/utils/tracing.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | 4 | # TODO: Should use boltons `Callpoint` 5 | def get_caller_frame_info() -> inspect.FrameInfo: 6 | """ 7 | Use `get_caller_frame_info()[3]` for the name of the calling function. 8 | """ 9 | curframe = inspect.currentframe() 10 | calframe = inspect.getouterframes(curframe, 2) 11 | return calframe[2] 12 | -------------------------------------------------------------------------------- /botkit/utils/typed_callable.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | import inspect 3 | from dataclasses import dataclass 4 | 5 | from cached_property import cached_property 6 | from typing import ( 7 | Any, 8 | Callable, 9 | Dict, 10 | Generic, 11 | Mapping, 12 | Protocol, 13 | Tuple, 14 | TypeVar, 15 | get_type_hints, 16 | ) 17 | 18 | # TArgs = TypeVar("TArgs", bound=Any) 19 | # TKwds = TypeVar("TKwds", bound=Any) 20 | # TRet = TypeVar("TRet", bound=Any) 21 | 22 | 23 | # class Func(Protocol[TArgs, TKwds, TRet]): 24 | # __name__: str 25 | 26 | # def __call__(self, *args: TArgs, **kwds: TKwds) -> TRet: 27 | # ... 28 | 29 | 30 | # T = TypeVar("T", bound=Func) 31 | 32 | 33 | # class FuncWrapper(Generic[TArgs, TKwds, TRet]): 34 | # def __init__(self, func: Func[TArgs, TKwds, TRet]) -> None: 35 | # self.func = func 36 | 37 | # def __call__(self, *args: TArgs, **kwds: Dict[str, Any]) -> TRet: 38 | # return self.func(*args, **kwds) 39 | 40 | # @cached_property 41 | # def name(self) -> str: 42 | # return self.func.__name__ 43 | 44 | 45 | class Func(Protocol): 46 | __name__: str 47 | __call__: Callable[..., Any] 48 | 49 | 50 | T = TypeVar("T", bound=Func) 51 | 52 | 53 | @dataclass(frozen=True) 54 | class TypedCallable(Generic[T]): 55 | func: T 56 | 57 | def __call__(self, *args: Any, **kwds: Any) -> Any: 58 | return self.func(*args, **kwds) 59 | 60 | @property 61 | def name(self) -> str: 62 | return self.func.__name__ 63 | 64 | @cached_property 65 | def signature(self) -> inspect.Signature: 66 | return inspect.signature(self.func) 67 | 68 | @cached_property 69 | def parameters(self) -> Mapping[str, inspect.Parameter]: 70 | return inspect.signature(self.func).parameters 71 | 72 | @cached_property 73 | def is_coroutine(self) -> bool: 74 | result = inspect.iscoroutinefunction(self.func) 75 | if not result and inspect.isawaitable(result): 76 | # Generally, we don't want to have anything to do with asyncio futures, tasks, and the like. 77 | warnings.warn( 78 | f"Callable {self.name} is a {type(self.func)}, which is awaitable, but not a coroutine (async " 79 | f"def). It is possible that you will always get the same result when it is awaited." 80 | ) 81 | return True 82 | return result 83 | 84 | @cached_property 85 | def type_hints(self) -> Dict[str, Any]: 86 | return get_type_hints(self.func) 87 | 88 | @cached_property 89 | def num_parameters(self) -> int: 90 | return len(self.parameters) 91 | 92 | @cached_property 93 | def num_non_optional_params(self) -> int: 94 | return sum((1 for p in self.parameters.values() if p.default is p.empty)) 95 | 96 | def __str__(self) -> str: 97 | return str(self.func) 98 | -------------------------------------------------------------------------------- /botkit/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/views/__init__.py -------------------------------------------------------------------------------- /botkit/views/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import ( 3 | Generic, 4 | Union, 5 | overload, 6 | ) 7 | 8 | from pyrogram.types import ForceReply, ReplyKeyboardMarkup, ReplyKeyboardRemove 9 | 10 | from botkit.abstractions import IRegisterable 11 | from botkit.builders.menubuilder import MenuBuilder 12 | from botkit.builders.metabuilder import MetaBuilder 13 | from botkit.views.rendered_messages import RenderedMessage 14 | from botkit.views.types import TViewState 15 | 16 | 17 | class ModelViewBase(Generic[TViewState], ABC): 18 | def __init__(self, state: TViewState): 19 | self.state = state 20 | 21 | 22 | class InlineResultViewBase(ModelViewBase, IRegisterable, Generic[TViewState], ABC): 23 | def assemble_metadata(self, meta: MetaBuilder): 24 | pass 25 | 26 | def render(self) -> RenderedMessage: 27 | meta_builder = MetaBuilder() 28 | self.assemble_metadata(meta_builder) 29 | return RenderedMessage(title=meta_builder.title, description=meta_builder.description) 30 | 31 | 32 | class RenderMarkupBase: # not an interface as the methods need to exist 33 | @overload 34 | def render_markup(self, menu: MenuBuilder): 35 | pass 36 | 37 | @overload 38 | def render_markup(self,) -> Union[ReplyKeyboardMarkup, ForceReply, ReplyKeyboardRemove]: 39 | pass 40 | 41 | def render_markup(self, *args): 42 | pass 43 | -------------------------------------------------------------------------------- /botkit/views/botkit_context.py: -------------------------------------------------------------------------------- 1 | from collections.abc import MutableMapping 2 | from dataclasses import dataclass 3 | from typing import Any, Generic, Iterator, Optional, TypeVar 4 | 5 | from botkit.dispatching.types import CallbackActionType 6 | from tgtypes.identities.chat_identity import ChatIdentity 7 | from tgtypes.identities.message_identity import MessageIdentity 8 | from tgtypes.update_field_extractor import UpdateFieldExtractor 9 | from .rendered_messages import RenderedMessage 10 | from ..routing.types import TViewState 11 | from ..routing.update_types.updatetype import UpdateType 12 | 13 | TPayload = TypeVar("TPayload") 14 | 15 | 16 | class _ScopedState(MutableMapping): 17 | def __init__(self): 18 | self._data = dict() 19 | 20 | def __setitem__(self, k, v) -> None: 21 | return self._data.__setitem__(k, v) 22 | 23 | def __delitem__(self, v) -> None: 24 | return self._data.__delitem__(v) 25 | 26 | def __getitem__(self, k) -> Any: 27 | return self._data.__getitem__(k) 28 | 29 | def __len__(self) -> int: 30 | return self._data.__len__() 31 | 32 | def __iter__(self) -> Iterator[Any]: 33 | return self._data.__iter__() 34 | 35 | 36 | class ChatState(_ScopedState): 37 | def __init__(self, chat_identity: ChatIdentity): 38 | self.chat_identity = chat_identity 39 | super(ChatState, self).__init__() 40 | 41 | 42 | class UserState(_ScopedState): 43 | def __init__(self): 44 | # TODO: implement descriptor 45 | super(UserState, self).__init__() 46 | 47 | 48 | @dataclass 49 | class Context(Generic[TViewState, TPayload], UpdateFieldExtractor): # TODO: maybe `RouteContext`? 50 | # TODO: rename to `view_state`? 51 | # TODO: maybe this shouldn't even be part of the context but always be passed separately (because of reducers)? 52 | update_type: UpdateType 53 | view_state: TViewState 54 | 55 | action: Optional[CallbackActionType] = None 56 | payload: Optional[TPayload] = None 57 | 58 | message_state: Optional[Any] = None # TODO: wtf 59 | user_state: Optional[UserState] = None 60 | chat_state: Optional[ChatState] = None 61 | 62 | # TODO: These should maybe not live on the context, but move to a dedicated `Pipeline(-specific)Context` 63 | rendered_message: RenderedMessage = None 64 | response_identity: MessageIdentity = None 65 | -------------------------------------------------------------------------------- /botkit/views/functional_views.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from loguru import logger as log 3 | from typing import ( 4 | Any, 5 | Callable, 6 | Optional, 7 | TypeVar, 8 | ) 9 | 10 | from decorators import FuncDecorator 11 | 12 | from botkit.builders import CallbackBuilder, HtmlBuilder, MenuBuilder, MetaBuilder, ViewBuilder 13 | from botkit.persistence.callback_store import ICallbackStore 14 | from botkit.views.rendered_messages import RenderedMessage 15 | from paraminjector import call_with_args 16 | 17 | T = TypeVar("T") 18 | 19 | 20 | def quacks_like_view_render_func(obj: Any) -> bool: 21 | if inspect.isclass(obj): 22 | return False 23 | if not callable(obj): 24 | return False 25 | if len(inspect.signature(obj).parameters) < 2: 26 | return False 27 | return True 28 | 29 | 30 | _RenderedMessageType = TypeVar("_RenderedMessageType", bound=RenderedMessage, covariant=True) 31 | 32 | 33 | def render_functional_view( 34 | view_func: Callable, state: Optional[Any], callback_store: ICallbackStore = None 35 | ) -> _RenderedMessageType: 36 | builder = ViewBuilder(CallbackBuilder(state=state, callback_store=callback_store)) 37 | 38 | try: 39 | # TODO: use the static version of paraminjector 40 | # TODO: allow only certain combinations of parameters as feature of paraminjector 41 | call_with_args( 42 | view_func, 43 | available_args={ 44 | ViewBuilder: builder, 45 | HtmlBuilder: builder.html, 46 | MenuBuilder: builder.menu, 47 | MetaBuilder: builder.meta, 48 | }, 49 | fixed_pos_args=(state,), 50 | ) 51 | except Exception as e: 52 | try: 53 | view_func(state, builder) 54 | except: 55 | log.exception(str(e), e) 56 | 57 | return builder.render() 58 | 59 | 60 | TViewState = TypeVar("TViewState", bound=type) 61 | 62 | 63 | ViewRenderFuncSignature = Callable[[TViewState, ViewBuilder], Optional[Any]] 64 | 65 | 66 | class _ViewDecorator(FuncDecorator): 67 | def decorate_func(self, *args, **kwargs): 68 | return super().decorate_func(*args, **kwargs) 69 | 70 | 71 | view = _ViewDecorator 72 | -------------------------------------------------------------------------------- /botkit/views/rendered_messages.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from dataclasses import dataclass 3 | from typing import Any, List, Literal, Optional, Union 4 | 5 | from pyrogram.types import ( 6 | ForceReply, 7 | InlineKeyboardMarkup, 8 | ReplyKeyboardMarkup, 9 | ReplyKeyboardRemove, 10 | ) 11 | 12 | from botkit.views.types import KeyboardTypes 13 | 14 | 15 | @dataclass 16 | class RenderedMessageBase(ABC): 17 | title: Optional[str] = None 18 | description: Optional[str] = None 19 | 20 | @property 21 | @abstractmethod 22 | def requires_bot(self) -> bool: 23 | """ 24 | Whether this message contains features that only a bot can render (and not a user). 25 | """ 26 | 27 | 28 | @dataclass 29 | class RenderedMessageMarkup(RenderedMessageBase): 30 | reply_markup: Union[ReplyKeyboardMarkup, ForceReply, ReplyKeyboardRemove] = None 31 | inline_buttons: Optional[List[List[KeyboardTypes]]] = None 32 | 33 | @property 34 | def inline_keyboard_markup(self) -> Optional[InlineKeyboardMarkup]: 35 | if self.inline_buttons is None: 36 | return None 37 | rows = [list(x) for x in self.inline_buttons] 38 | return InlineKeyboardMarkup(rows) 39 | 40 | @property 41 | def requires_bot(self) -> bool: 42 | return bool(self.reply_markup or (self.inline_buttons and self.inline_buttons[0])) 43 | 44 | 45 | @dataclass 46 | class RenderedMessage(RenderedMessageMarkup): 47 | parse_mode: str = "html" 48 | disable_web_page_preview: bool = True 49 | 50 | thumb_url: str = None # TODO: implement 51 | 52 | 53 | @dataclass 54 | class RenderedStickerMessage(RenderedMessage): 55 | sticker: Optional[str] = None 56 | 57 | 58 | @dataclass 59 | class RenderedMediaMessage(RenderedMessage): 60 | media: Optional[Any] = None 61 | caption: Optional[str] = None 62 | 63 | 64 | @dataclass 65 | class RenderedTextMessage(RenderedMessage): 66 | text: Optional[str] = None 67 | 68 | 69 | @dataclass 70 | class RenderedPollMessage(RenderedMessageMarkup): 71 | question: str = None 72 | options: List[str] = None 73 | is_anonymous: bool = True 74 | allows_multiple_answers: bool = None 75 | type: Literal["regular", "quiz"] = None 76 | correct_option_id: int = None 77 | -------------------------------------------------------------------------------- /botkit/views/success_view.py: -------------------------------------------------------------------------------- 1 | from pyrogram import emoji 2 | 3 | from botkit.builders.htmlbuilder import HtmlBuilder 4 | from botkit.views.views import TextView 5 | 6 | 7 | class SuccessView(TextView[str]): 8 | def render_body(self, builder: HtmlBuilder) -> None: 9 | builder.text(emoji.CHECK_MARK).spc().text(self.state) 10 | -------------------------------------------------------------------------------- /botkit/views/types.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, TypeVar, Union 2 | 3 | from pyrogram.types import InlineKeyboardButton 4 | 5 | KeyboardTypes = Union[InlineKeyboardButton, Tuple] 6 | TViewState = TypeVar("TViewState") 7 | -------------------------------------------------------------------------------- /botkit/widgets/DESIGN.md: -------------------------------------------------------------------------------- 1 | # Widget Architecture/Design 2 | 3 | ## Including widgets in renderers 4 | ``` 5 | 6 | def render_a_view(view_state: State, builder: ViewBuilder, buttons: ButtonListWidget): 7 | 8 | 9 | ``` 10 | 11 | -------------------------------------------------------------------------------- /botkit/widgets/WIDGET-IDEAS.md: -------------------------------------------------------------------------------- 1 | - ButtonList 2 | - 3 | -------------------------------------------------------------------------------- /botkit/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | from botkit.widgets._base import HtmlWidget, Widget, MetaWidget, MenuWidget 2 | -------------------------------------------------------------------------------- /botkit/widgets/_base/__init__.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import Generic, TypeVar 3 | 4 | from botkit.routing.types import TViewState 5 | from botkit.widgets._base.html_widget import HtmlWidget 6 | from botkit.widgets._base.menu_widget import MenuWidget 7 | from botkit.widgets._base.meta_widget import MetaWidget 8 | 9 | TWidgetState = TypeVar("TWidgetState") 10 | 11 | 12 | class Widget(Generic[TViewState, TWidgetState], HtmlWidget, MenuWidget, MetaWidget, ABC): 13 | """ 14 | ## Invariants: 15 | - no load/unload (nothing async) 16 | 17 | ## Problems: 18 | - how to register views? autoregistration? 19 | - (how) can widgets be used by views? 20 | """ 21 | 22 | def mutate(self): 23 | pass # TODO 24 | -------------------------------------------------------------------------------- /botkit/widgets/_base/html_widget.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from botkit.builders import HtmlBuilder 4 | from botkit.abstractions._named import INamed 5 | 6 | 7 | class HtmlWidget(INamed, ABC): 8 | @abstractmethod 9 | def render_html(self, html: HtmlBuilder): 10 | pass 11 | -------------------------------------------------------------------------------- /botkit/widgets/_base/menu_widget.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from botkit.builders import MenuBuilder 4 | 5 | 6 | class MenuWidget(ABC): 7 | @abstractmethod 8 | def render_menu(self, menu: MenuBuilder): 9 | pass 10 | -------------------------------------------------------------------------------- /botkit/widgets/_base/meta_widget.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from botkit.abstractions._named import INamed 4 | from botkit.builders import MetaBuilder 5 | 6 | 7 | class MetaWidget(INamed, ABC): 8 | @abstractmethod 9 | def render_meta(self, meta: MetaBuilder): 10 | pass 11 | -------------------------------------------------------------------------------- /botkit/widgets/collapse/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/widgets/collapse/__init__.py -------------------------------------------------------------------------------- /botkit/widgets/list/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/widgets/list/__init__.py -------------------------------------------------------------------------------- /botkit/widgets/pagination/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/botkit/widgets/pagination/__init__.py -------------------------------------------------------------------------------- /cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/cli/__init__.py -------------------------------------------------------------------------------- /cli/main.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | from typer import Typer, echo 4 | 5 | 6 | class ConfigFileType(Enum): 7 | dotenv = "dotenv" 8 | settings_config = "dotconfig" 9 | 10 | 11 | app = Typer() 12 | config_app = Typer() 13 | app.add_typer(config_app, name="config") 14 | 15 | 16 | @config_app.command("create") 17 | def items_create(file_type: ConfigFileType): 18 | echo(f"Creating a {file_type.name} file") 19 | 20 | 21 | @config_app.command("delete") 22 | def items_delete(item: str): 23 | echo(f"Deleting item: {item}") 24 | 25 | 26 | @config_app.command("sell") 27 | def items_sell(item: str): 28 | echo(f"Selling item: {item}") 29 | 30 | 31 | if __name__ == "__main__": 32 | app() 33 | -------------------------------------------------------------------------------- /docs/Conversational UI.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/docs/Conversational UI.pdf -------------------------------------------------------------------------------- /docs/Conversational UI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/docs/Conversational UI.png -------------------------------------------------------------------------------- /docs/TODO.md: -------------------------------------------------------------------------------- 1 | # Botkit TODOs 2 | 3 | ## Important / ASAP 4 | - Switch to new dependency injection lib 5 | - Add global cache for things like "how long is slow mode in this group", message id mappings in companionbots, ... 6 | 7 | ## Sometime 8 | - Finish QuickActions implementation 9 | - Create a concept for inline queries (probably with a builder) 10 | 11 | ## Just ideas 12 | - https://github.com/povilasb/pycollection-pipelines 13 | -------------------------------------------------------------------------------- /docs/Terminology.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/docs/Terminology.md -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import os 10 | import sys 11 | 12 | from importlib.metadata import version 13 | 14 | try: 15 | __version__ = version(__name__) 16 | except: 17 | pass 18 | 19 | sys.path.insert(0, os.path.abspath("..")) 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "Autogram Botkit" 24 | copyright = "2020, Joscha Götzer" 25 | author = "Joscha Götzer" 26 | 27 | # def format_version(v: ScmVersion): 28 | # version_string: str = guess_next_dev_version(v) 29 | # components = version_string.split(".") 30 | # return ".".join(components[0:2]) 31 | 32 | 33 | # -- General configuration --------------------------------------------------- 34 | 35 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx"] 36 | 37 | exclude_patterns = ["build", "Thumbs.db", ".DS_Store"] 38 | 39 | warning_is_error = True 40 | 41 | # -- Options for HTML output ------------------------------------------------- 42 | 43 | html_theme = "sphinx_rtd_theme" 44 | extensions.append("sphinx_rtd_theme") 45 | 46 | html_static_path = ["static"] 47 | 48 | intersphinx_mapping = { 49 | "pymongo": ("https://pymongo.readthedocs.io/en/stable/", None), 50 | "py": ("https://docs.python.org/3/", None), 51 | } 52 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Autogram-Botkit |version| Documentation 2 | ======================================= 3 | 4 | Overview 5 | -------- 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "autogram-botkit" 3 | packages = [ 4 | { include = "botkit" } 5 | ] 6 | version = "0.2.3" 7 | description = "Opinionated, library-agnostic Python framework for rapid development of Telegram (user)bots with focus on maintainability for large projects." 8 | license = "GPL-3.0-or-later" 9 | authors = ["JosXa "] 10 | readme = "README.md" 11 | repository = "https://github.com/autogram/Botkit" 12 | keywords = ["telegram-bots", "telegram-userbots", "python3", "framework"] 13 | classifiers = [ 14 | "Development Status :: 3 - Alpha", 15 | "Typing :: Typed", 16 | "Topic :: Software Development :: Libraries :: Python Modules", 17 | "Framework :: Robot Framework :: Library", 18 | "Intended Audience :: Developers", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: Implementation :: CPython" 22 | ] 23 | 24 | [tool.poetry.urls] 25 | "Bug Tracker" = "https://github.com/autogram/Botkit/issues" 26 | "Support Chat" = "https://t.me/BotkitChat" 27 | "News & Updates" = "https://t.me/AUTOBotkit" 28 | "Contact Author" = "https:/t.me/JosXa" 29 | 30 | [tool.poetry.dependencies] 31 | python = "^3.8" 32 | tgtypes = "*" 33 | cached_property = "^1.5.1" 34 | python-decouple = "3.3" 35 | more-itertools = "^8.2.0" 36 | unsync = "^1.2.1" 37 | haps = "^1.1.2" 38 | typing-inspect = ">=0.5.0" 39 | pydantic = { extras = ["dotenv"], version = "^1.6.1" } 40 | ordered_set = "^3.1.1" 41 | decorators = "^0.1.1" 42 | pyhumps = "^1.3.1" 43 | boltons = "*" 44 | pytz = "2020.1" 45 | redis-collections = { version = "^0.8.1", optional = true } 46 | watchgod = { version = "^0.6", optional = true } 47 | ensure = "^1.0.0" 48 | loguru = "^0.5.3" 49 | paraminjector = "^0.1.0" 50 | buslane = "^0.0.5" 51 | 52 | [tool.poetry.extras] 53 | redis = ["redis-collections"] 54 | hmr = ["watchgod"] 55 | 56 | [tool.poetry.dev-dependencies] 57 | black = { version = ">=20.8b1", python = "^3.8", markers = "platform_python_implementation == 'CPython'" } 58 | mypy = ">0.770" 59 | birdseye = "^0.8.3" 60 | mkinit = "^0.2.0" 61 | typer = "^0.3.1" 62 | pytest = "*" 63 | pytest-asyncio = "*" 64 | pytest-cov = "^2.10.1" 65 | pre-commit = "^2.7.1" 66 | devtools = { extras = ["pygments"], version = "^0.6" } 67 | pyrogram = "1.*" 68 | telethon = "1.*" 69 | loguru-mypy = "^0.0.2" 70 | docker = "^4.3.1" 71 | 72 | [tool.black] 73 | line-length = 99 74 | target-version = ['py38'] 75 | [build-system] 76 | requires = ["poetry>=0.12"] 77 | build-backend = "poetry.masonry.api" 78 | 79 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | error 4 | ignore::DeprecationWarning 5 | testpaths = tests 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/__init__.py -------------------------------------------------------------------------------- /tests/builders/callbackbuilder/test_callbackbuilder.py: -------------------------------------------------------------------------------- 1 | from botkit.abstractions._named import INamed 2 | from botkit.builders.callbackbuilder import CallbackBuilder 3 | from botkit.persistence.callback_store import MemoryDictCallbackStore 4 | import pytest 5 | 6 | SEP = "##" 7 | 8 | 9 | class SomeEntity(INamed): 10 | def __init__(self, name: str): 11 | self.name = name 12 | 13 | @property 14 | def unique_name(self) -> str: 15 | return self.name 16 | 17 | 18 | @pytest.fixture(scope="function") 19 | def cb_builder(): 20 | return CallbackBuilder(None, MemoryDictCallbackStore()) 21 | 22 | 23 | @pytest.fixture(scope="function") 24 | def gen_named(): 25 | wr = {0} 26 | 27 | def _inner(): 28 | n = wr.pop() 29 | wr.add(n + 1) 30 | 31 | return SomeEntity(f"testing{n}") 32 | 33 | return _inner 34 | 35 | 36 | def test_push(cb_builder, gen_named): 37 | assert cb_builder._current_action_prefix == "" 38 | cb_builder.push_scope(gen_named()) 39 | assert cb_builder._current_action_prefix == "testing0" 40 | cb_builder.push_scope(gen_named()) 41 | assert cb_builder._current_action_prefix == f"testing0{SEP}testing1" 42 | cb_builder.push_scope(gen_named()) 43 | assert cb_builder._current_action_prefix == f"testing0{SEP}testing1{SEP}testing2" 44 | 45 | 46 | def test_pop(cb_builder, gen_named): 47 | for i in range(3): 48 | cb_builder.push_scope(gen_named()) 49 | 50 | assert cb_builder._current_action_prefix == f"testing0{SEP}testing1{SEP}testing2" 51 | cb_builder.pop_scope() 52 | assert cb_builder._current_action_prefix == f"testing0{SEP}testing1" 53 | cb_builder.pop_scope() 54 | assert cb_builder._current_action_prefix == "testing0" 55 | cb_builder.pop_scope() 56 | assert cb_builder._current_action_prefix == "" 57 | 58 | 59 | def test_push_pop_root(cb_builder, gen_named): 60 | with cb_builder.scope(gen_named()): 61 | assert cb_builder._current_action_prefix == "testing0" 62 | assert cb_builder._current_action_prefix == "" 63 | 64 | 65 | def test_push_pop_levels_deep(cb_builder, gen_named): 66 | with cb_builder.scope(gen_named()): 67 | assert cb_builder._current_action_prefix == "testing0" 68 | with cb_builder.scope(gen_named()): 69 | assert cb_builder._current_action_prefix == f"testing0{SEP}testing1" 70 | with cb_builder.scope(gen_named()): 71 | assert cb_builder._current_action_prefix == f"testing0{SEP}testing1{SEP}testing2" 72 | assert cb_builder._current_action_prefix == "" 73 | 74 | 75 | def test_whitespace(cb_builder, gen_named): 76 | cb_builder.push_scope(SomeEntity(" aa f cc ")) 77 | assert cb_builder._format_action(" k e k ") == f"aafcc{SEP}kek" 78 | -------------------------------------------------------------------------------- /tests/builders/test_menubuilder.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from haps import Container, Egg 3 | from pyrogram.types import InlineKeyboardButton 4 | 5 | from botkit.builders.callbackbuilder import CallbackBuilder 6 | from botkit.builders.menubuilder import MenuBuilder 7 | from botkit.persistence.callback_store import ( 8 | ICallbackStore, 9 | MemoryDictCallbackStore, 10 | ) 11 | from botkit.persistence.callback_store._simple import lookup_callback 12 | from botkit.settings import botkit_settings 13 | 14 | 15 | @pytest.fixture(scope="function") 16 | def create_cbb(): 17 | def _create_with_state(state): 18 | return CallbackBuilder(state=state, callback_store=MemoryDictCallbackStore()) 19 | 20 | return _create_with_state 21 | 22 | 23 | def test_add_button__is_available(create_cbb): 24 | builder = MenuBuilder(create_cbb({"my": "choices"})) 25 | 26 | builder.rows[0].switch_inline_button("test") 27 | 28 | keyboard = builder.render() 29 | 30 | assert keyboard[0][0].text == "test" 31 | assert keyboard[0][0].switch_inline_query_current_chat == "" 32 | 33 | 34 | def test_add_buttons_to_rows__structure_is_correct(create_cbb): 35 | builder = MenuBuilder(create_cbb({"my": "choices"})) 36 | 37 | builder.rows[1].switch_inline_button("row1_col0").switch_inline_button("row1_col1") 38 | builder.rows[1].switch_inline_button("row1_col2") 39 | 40 | builder.rows[3].switch_inline_button("row3_col0").switch_inline_button("row3_col1") 41 | 42 | keyboard = builder.render() 43 | 44 | assert keyboard[0][0].text == "row1_col0" 45 | assert keyboard[0][1].text == "row1_col1" 46 | assert keyboard[0][2].text == "row1_col2" 47 | assert keyboard[1][0].text == "row3_col0" 48 | assert keyboard[1][1].text == "row3_col1" 49 | 50 | 51 | def test_buttons_retain_state_and_payload(create_cbb): 52 | state = {"my": "choices"} 53 | builder = MenuBuilder(create_cbb(state)) 54 | b = builder.rows[1].action_button("caption", "test_action", payload="test_payload") 55 | 56 | keyboard = builder.render() 57 | res: InlineKeyboardButton = keyboard[0][0] 58 | 59 | cb = lookup_callback(res.callback_data) 60 | assert cb.state == state 61 | assert cb.payload == "test_payload" 62 | assert cb.action == "test_action" 63 | -------------------------------------------------------------------------------- /tests/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/commands/__init__.py -------------------------------------------------------------------------------- /tests/commands/test_command.py: -------------------------------------------------------------------------------- 1 | # import pytest 2 | # from ensure import ensure 3 | # 4 | # from botkit.commands.command import CommandParser, _ParsedCommandDefinition 5 | # from botkit.core.modules._module import Module 6 | # from botkit.routing.route_builder.builder import RouteBuilder 7 | # 8 | # 9 | # class TestModule(Module): 10 | # def register(self, routes: RouteBuilder): 11 | # routes.on_command(_ParsedCommandDefinition(trigger=CommandParser(name=["test"]))) 12 | # 13 | # 14 | # @pytest.mark.parametrize( 15 | # "trigger_value,exp_name,exp_aliases", 16 | # [ 17 | # ("abc", "abc", []), 18 | # (("main", "secondary"), "main", ["secondary"]), 19 | # (["main", "secondary"], "main", ["secondary"]), 20 | # ], 21 | # ) 22 | # def test_initialization(trigger_value, exp_name, exp_aliases): 23 | # c = _ParsedCommandDefinition(trigger=trigger_value) 24 | # assert isinstance(c.trigger, CommandParser) 25 | # assert c.trigger.name == exp_name 26 | # assert c.trigger.aliases == exp_aliases 27 | -------------------------------------------------------------------------------- /tests/configuration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/configuration/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from haps import Container 2 | from pytest import fixture 3 | 4 | 5 | @fixture(scope="function", autouse=True) 6 | def reset_container(): 7 | Container._reset() 8 | yield 9 | Container._reset() 10 | -------------------------------------------------------------------------------- /tests/docker/DOCKERFILE: -------------------------------------------------------------------------------- 1 | # Poetry base image repository: 2 | # https://github.com/users/JosXa/packages/container/package/python-poetry-base 3 | FROM ghcr.io/josxa/python-poetry-base:latest as Build 4 | 5 | ENV PYTHONUNBUFFERED=1 6 | COPY pyproject.toml ./ 7 | COPY . ./ 8 | RUN poetry install --no-dev 9 | -------------------------------------------------------------------------------- /tests/docker/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/docker/__init__.py -------------------------------------------------------------------------------- /tests/docker/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "autogram-botkit-tests" 3 | version = "0.0.0" 4 | authors = ["A tester"] 5 | description = "Tests" 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | autogram-botkit = { path = "../../", extras = ["redis", "hmr"] } 10 | 11 | [build-system] 12 | requires = ["poetry>=0.12"] 13 | build-backend = "poetry.masonry.api" 14 | -------------------------------------------------------------------------------- /tests/docker/test_docker.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import docker 4 | 5 | 6 | def test_build_botkit_with_poetry_in_container(): 7 | client = docker.from_env() 8 | botkit_root_path = Path(__file__).parent.parent.parent 9 | dockerfile_path: Path = (Path(__file__).parent / "DOCKERFILE").relative_to(botkit_root_path) 10 | client.images.build(path=str(botkit_root_path), dockerfile=dockerfile_path.as_posix()) 11 | -------------------------------------------------------------------------------- /tests/inlinequeries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/inlinequeries/__init__.py -------------------------------------------------------------------------------- /tests/inlinequeries/test_prefixbasedinlinequeryhandler.py: -------------------------------------------------------------------------------- 1 | # from typing import Optional 2 | # 3 | # from botkit.inlinequeries.contexts import PrefixBasedInlineModeContext 4 | # 5 | # 6 | # def parse(text: str, prefix: str, delimiter: Optional[str] = None): 7 | # ctx = PrefixBasedInlineModeContext(prefix, delimiter=delimiter or ": ") 8 | # return ctx.parse_input(text) 9 | # 10 | # 11 | # def fmt(remove_trigger_setting: str, prefix: str, delimiter: Optional[str] = None): 12 | # ctx = PrefixBasedInlineModeContext(prefix, delimiter=delimiter or ": ") 13 | # return ctx.format_query(remove_trigger_setting) 14 | # 15 | # 16 | # # region parse_input tests 17 | # 18 | # 19 | # def test_happy_path(): 20 | # assert parse("henlo: fren", "henlo", ": ") == "fren" 21 | # 22 | # 23 | # def test_newline_chars(): 24 | # assert parse("henlo:\nfren\nand\nbois", "henlo") == "fren\nand\nbois" 25 | # 26 | # 27 | # def test_case_sensitivity(): 28 | # assert parse("hEnLo: frEn", "HENLO") == "frEn" 29 | # 30 | # 31 | # def test_no_spaces(): 32 | # assert parse("henlo:fren", "henlo:") == "fren" 33 | # 34 | # 35 | # def test_whitespace(): 36 | # assert parse("henlo: fren ", "henlo : ") == "fren" 37 | # assert parse("henlo : fren ", "henlo", ":") == "fren" 38 | # 39 | # 40 | # def test_custom_delimiter(): 41 | # assert parse("henlo + fren ", "henlo", "+") == "fren" 42 | # 43 | # 44 | # def test_whitespace_as_delimiter(): 45 | # assert parse("prefix test", "prefix", " ") == "test" 46 | # 47 | # 48 | # # endregion 49 | # 50 | # # region format_query tests 51 | # 52 | # 53 | # def test_format_input_happy_path(): 54 | # assert fmt(remove_trigger_setting="test", prefix="lala", delimiter=": ") == "lala: test" 55 | # 56 | # 57 | # def test_format_input_delimiter_in_prefix(): 58 | # assert fmt(remove_trigger_setting=" test ", prefix="lala: ", delimiter=": ") == "lala: test" 59 | # 60 | # 61 | # def test_format_input_whitespace(): 62 | # assert fmt(remove_trigger_setting=" test ", prefix="lala", delimiter=": ") == "lala: test" 63 | # 64 | # 65 | # def test_format_input_newlines(): 66 | # assert fmt(remove_trigger_setting=" a\nb\nc ", prefix="lala", delimiter=": ") == "lala: a\nb\nc" 67 | # 68 | # 69 | # # endregion 70 | -------------------------------------------------------------------------------- /tests/module_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/module_tests/__init__.py -------------------------------------------------------------------------------- /tests/module_tests/test_func_module.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Any, 3 | Callable, 4 | ) 5 | 6 | import pytest 7 | from haps import Egg, egg 8 | 9 | from botkit.core import modules 10 | from botkit.core.modules import module 11 | from botkit.core.modules.activation import haps_disambiguate_module_eggs 12 | from botkit.routing.route_builder.builder import RouteBuilder 13 | 14 | 15 | @pytest.yield_fixture(scope="function") 16 | def get_single_module() -> Callable[[], Any]: 17 | print("resetting") 18 | egg.factories = [] 19 | 20 | def inner(): 21 | print("GETTING") 22 | m = haps_disambiguate_module_eggs() 23 | assert len(m) <= 1, "More than one module found!" 24 | assert m, "No module found!" 25 | return m[0] 26 | 27 | yield inner 28 | print("teardown") 29 | egg.factories = [] 30 | 31 | 32 | @pytest.mark.xfail # TODO: Not implemented yet 33 | def test_func_can_be_decorated(get_single_module): 34 | @module("OpenInBrowserModule") 35 | def _(routes: RouteBuilder) -> None: 36 | pass # TODO 37 | 38 | actual = get_single_module() 39 | 40 | assert actual.base_ is modules.Module 41 | assert actual.type.__name__ == "OpenInBrowserModule" 42 | 43 | 44 | def test_func_decorator_without_name(get_single_module): 45 | def my_module(routes: RouteBuilder): 46 | pass # TODO 47 | 48 | # Fake decorator 49 | decorated = module(my_module)(None) 50 | 51 | actual = get_single_module() 52 | 53 | assert actual.qualifier == "MyModule", f"{actual.qualifier} != MyModule" 54 | 55 | 56 | def test_func_decorator_can_be_called(get_single_module): 57 | def my_module(routes: RouteBuilder): 58 | pass 59 | 60 | # Fake decorator 61 | decorated = module(my_module)(None) 62 | 63 | actual: Egg = get_single_module() 64 | 65 | assert issubclass(actual.type_, modules.Module) 66 | 67 | initialized = actual.type_() 68 | assert isinstance(initialized, modules.Module) 69 | 70 | 71 | def test_func_decorator_missing_route_builder_fails(get_single_module): 72 | def my_module(): 73 | pass 74 | 75 | # Fake decorator 76 | decorated = module(my_module)() 77 | 78 | actual = get_single_module() 79 | 80 | instance = actual.type_ 81 | 82 | with pytest.raises(TypeError) as ex: 83 | instance.register(RouteBuilder()) 84 | 85 | # TODO: Add error handling, "your register function should have a RouteBuilder 86 | print(ex.value) 87 | 88 | 89 | # def test_load_args_can_be_passed(): 90 | # @module("TestModule", load="test", unload=) 91 | # def my_module(routes: RouteBuilder): 92 | # pass 93 | -------------------------------------------------------------------------------- /tests/routing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/routing/__init__.py -------------------------------------------------------------------------------- /tests/routing/pipelines/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/routing/pipelines/__init__.py -------------------------------------------------------------------------------- /tests/routing/pipelines/factories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/routing/pipelines/factories/__init__.py -------------------------------------------------------------------------------- /tests/routing/pipelines/factories/steps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/routing/pipelines/factories/steps/__init__.py -------------------------------------------------------------------------------- /tests/routing/plan/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/routing/plan/__init__.py -------------------------------------------------------------------------------- /tests/routing/plan/test_update_types.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Union 2 | 3 | from pyrogram import Client 4 | from pyrogram.types import CallbackQuery, Message 5 | 6 | from botkit.agnostic.annotations import IClient 7 | from botkit.agnostic._pyrogram_update_type_inference import determine_pyrogram_handler_update_types 8 | from botkit.routing.update_types.updatetype import UpdateType 9 | from botkit.utils.typed_callable import TypedCallable 10 | 11 | 12 | def make_valid_handler_variations(update_type: object) -> List[Callable]: 13 | async def pure(client: IClient, message: update_type): 14 | pass 15 | 16 | async def pure_pyro(client: Client, message: update_type): 17 | pass 18 | 19 | async def no_client_annotation(client, x: update_type): 20 | pass 21 | 22 | async def with_additional_args(client, message: update_type, additional_arg: int = None): 23 | pass 24 | 25 | async def more_args_inbetween(client, something, sth_else, message: update_type): 26 | pass 27 | 28 | def sync(client, message: update_type): 29 | pass 30 | 31 | return [ 32 | pure, 33 | pure_pyro, 34 | no_client_annotation, 35 | with_additional_args, 36 | more_args_inbetween, 37 | sync, 38 | ] 39 | 40 | 41 | def test_message_handler_can_be_determined() -> None: 42 | handlers = make_valid_handler_variations(Message) 43 | 44 | for h in handlers: 45 | res = determine_pyrogram_handler_update_types(TypedCallable(h)) 46 | 47 | assert res == {UpdateType.message} 48 | 49 | 50 | def test_message_callback_query_union_handler_can_be_determined() -> None: 51 | handlers = make_valid_handler_variations(Union[Message, CallbackQuery]) 52 | 53 | for h in handlers: 54 | res = determine_pyrogram_handler_update_types(TypedCallable(h)) 55 | 56 | assert res == {UpdateType.message, UpdateType.callback_query} 57 | -------------------------------------------------------------------------------- /tests/routing/test_publish_expression.py: -------------------------------------------------------------------------------- 1 | # from dataclasses import dataclass 2 | # 3 | # from buslane.events import Event, EventHandler 4 | # from pyrogram.types import CallbackQuery 5 | # from unittest.mock import Mock 6 | # 7 | # from botkit.routing.route import RouteDefinition 8 | # from botkit.routing.route_builder.builder import RouteBuilder 9 | # from botkit.types.client import IClient 10 | # 11 | # client: IClient = Mock(IClient) 12 | # callback_query: CallbackQuery = Mock(CallbackQuery) 13 | # 14 | # 15 | # @dataclass 16 | # class EventTest(Event): 17 | # val: int 18 | # 19 | # 20 | # @dataclass 21 | # class EventHandler(EventHandler[EventTest]): 22 | # def handle(self, event: EventTest) -> None: 23 | # pass 24 | # 25 | # 26 | # def test_route_with_publish_expression_fires_event(): 27 | # ACTION = 123 28 | # 29 | # event = EventTest(val=1) 30 | # 31 | # builder = RouteBuilder() 32 | # builder.use(client) 33 | # builder.on_action(ACTION).publish(event) 34 | # 35 | # route: RouteDefinition = builder._route_collection.routes_by_client[client][0] 36 | # 37 | # assert len(route.pyrogram_handlers) == 1 38 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/test_typed_callables.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Union 2 | 3 | 4 | from botkit.utils.typed_callable import TypedCallable 5 | from botkit.views.botkit_context import Context 6 | 7 | 8 | def my_func_1(ctx: Context[Any, Any], test_int: int = 1, test_none: Optional[str] = None) -> Any: 9 | pass 10 | 11 | 12 | async def my_func_async( 13 | ctx: Context[Any, Any], test_int: int = 1, test_none: Optional[str] = None 14 | ): 15 | pass 16 | 17 | 18 | class TestClass: 19 | def my_method( 20 | self, ctx: Context[Any, Any], test_int: int = 1, test_none: Optional[str] = None 21 | ): 22 | pass 23 | 24 | async def my_method_async( 25 | self, ctx: Context[Any, Any], test_int: int = 1, test_none: Optional[str] = None 26 | ): 27 | pass 28 | 29 | 30 | def test_regular_function_properties(): 31 | tc = TypedCallable(my_func_1) 32 | assert not tc.is_coroutine 33 | assert tc.name == "my_func_1" 34 | assert tc.num_non_optional_params == 1 35 | assert tc.num_parameters == 3 36 | assert tc.type_hints == { 37 | "ctx": Context[Any, Any], 38 | "test_int": int, 39 | "test_none": Optional[str], 40 | "return": Any 41 | } 42 | 43 | 44 | def test_coroutine_function_properties(): 45 | tc = TypedCallable(my_func_async) 46 | assert tc.is_coroutine 47 | assert tc.name == "my_func_async" 48 | assert tc.num_non_optional_params == 1 49 | assert tc.num_parameters == 3 50 | assert tc.type_hints == { 51 | "ctx": Context[Any, Any], 52 | "test_int": int, 53 | "test_none": Union[str, None], 54 | } 55 | 56 | 57 | def test_regular_method_properties(): 58 | cls = TestClass() 59 | tc = TypedCallable(cls.my_method) 60 | assert not tc.is_coroutine 61 | assert tc.name == "my_method" 62 | assert tc.num_non_optional_params == 1 63 | assert tc.num_parameters == 3 64 | assert tc.type_hints == { 65 | "ctx": Context[Any, Any], 66 | "test_int": int, 67 | "test_none": Optional[str], 68 | } 69 | 70 | 71 | def test_coroutine_method_properties(): 72 | cls = TestClass() 73 | tc = TypedCallable(cls.my_method_async) 74 | assert tc.is_coroutine 75 | assert tc.name == "my_method_async" 76 | assert tc.num_non_optional_params == 1 77 | assert tc.num_parameters == 3 78 | assert tc.type_hints == { 79 | "ctx": Context[Any, Any], 80 | "test_int": int, 81 | "test_none": Optional[str], 82 | } 83 | -------------------------------------------------------------------------------- /tests/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/views/__init__.py -------------------------------------------------------------------------------- /tests/views/test_functional_views.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from botkit.builders import ViewBuilder 4 | from botkit.persistence import callback_store 5 | from botkit.persistence.callback_store import MemoryDictCallbackStore 6 | from botkit.views.functional_views import render_functional_view, view 7 | from botkit.views.rendered_messages import RenderedTextMessage 8 | 9 | 10 | @dataclass 11 | class Model: 12 | pass 13 | 14 | 15 | DESCRIPTION = "Henlo fren!" 16 | TITLE = "Saying henlo!" 17 | 18 | 19 | @view 20 | def full_view_experiment(state: Model, builder: ViewBuilder): 21 | builder.html.text("Henlo my").spc() 22 | builder.html.bold("bestest", end=" ").mono("fren") 23 | 24 | builder.menu.rows[0].action_button("row 0, col 0", action="test") 25 | builder.menu.rows[0].action_button("row 0, col 1", action="test") 26 | builder.menu.rows[1].action_button("row 1, col 0", action="test") 27 | 28 | builder.meta.description = DESCRIPTION 29 | builder.meta.title = TITLE 30 | 31 | 32 | def test_full_view_can_be_rendered(): 33 | rendered = render_functional_view(full_view_experiment, Model(), MemoryDictCallbackStore()) 34 | assert isinstance(rendered, RenderedTextMessage) 35 | assert rendered.text == "Henlo my bestest fren" 36 | assert len(rendered.inline_keyboard_markup.inline_keyboard) == 2 37 | assert len(rendered.inline_keyboard_markup.inline_keyboard[0][0].callback_data) == 36 38 | assert rendered.description == DESCRIPTION 39 | assert rendered.title == TITLE 40 | 41 | 42 | # def test_using_photo_rendered_message_is_media(): 43 | # def f(_, builder: ViewBuilder): 44 | # builder. 45 | # 46 | # render_functional_view() 47 | -------------------------------------------------------------------------------- /tests/views/test_view_validation.py: -------------------------------------------------------------------------------- 1 | # import inspect 2 | # from typing import Any 3 | # from unittest.mock import MagicMock 4 | # 5 | # from boltons.iterutils import flatten 6 | # 7 | # from botkit.builders import ViewBuilder 8 | # from botkit.persistence import callback_store 9 | # from botkit.views.functional_views import render_functional_view 10 | # 11 | # 12 | # def render_shit(state: Any, builder: ViewBuilder) -> None: 13 | # builder.html.text("Henlo frens!") 14 | # 15 | # for x in state: 16 | # builder.menu.rows[0].action_button(x.name, "some_action", payload="testing") 17 | # 18 | # 19 | # def test_experiment(di): 20 | # di(callback_store) 21 | # print(inspect.getsource(render_shit)) 22 | # mocked_state = MagicMock() 23 | # mocked_state.__iter__.return_value = [mocked_state, mocked_state] 24 | # rendered = render_functional_view(render_shit, mocked_state) 25 | # 26 | # assert bool(rendered.inline_keyboard_markup.inline_keyboard) 27 | -------------------------------------------------------------------------------- /tests/widgets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/widgets/__init__.py -------------------------------------------------------------------------------- /tests/widgets/_base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/autogram/Botkit/680e61e33aedc9fa8158fe6738f1562316089f15/tests/widgets/_base/__init__.py -------------------------------------------------------------------------------- /tests/widgets/_base/test_widget_types.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | import pytest 4 | 5 | from botkit.widgets._base import HtmlWidget, MenuWidget, MetaWidget 6 | 7 | 8 | def pytest_generate_tests(metafunc): 9 | if "widget_type" in metafunc.fixturenames: 10 | metafunc.parametrize("widget_type", [HtmlWidget, MenuWidget, MetaWidget]) 11 | 12 | 13 | def test_error_class_missing_abstract_method(widget_type: Type): 14 | with pytest.raises(TypeError, match=r"Can't instantiate abstract class"): 15 | widget_type() 16 | -------------------------------------------------------------------------------- /typings/cached_property/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | cached_property = property 6 | threaded_cached_property = property 7 | 8 | class cached_property_with_ttl(property): 9 | """ 10 | A property that is only computed once per instance and then replaces itself 11 | with an ordinary attribute. Setting the ttl to a number expresses how long 12 | the property will last before being timed out. 13 | """ 14 | def __init__(self, ttl: float=...) -> None: 15 | ... 16 | 17 | cached_property_ttl = cached_property_with_ttl 18 | timed_cached_property = cached_property_with_ttl 19 | class threaded_cached_property_with_ttl(cached_property_with_ttl): 20 | """ 21 | A cached_property version for use in environments where multiple threads 22 | might concurrently try to access the property. 23 | """ 24 | def __init__(self, ttl: float=...) -> None: 25 | ... 26 | 27 | threaded_cached_property_ttl = threaded_cached_property_with_ttl 28 | timed_threaded_cached_property = threaded_cached_property_with_ttl 29 | -------------------------------------------------------------------------------- /typings/haps/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from haps import scopes 6 | from haps.container import Container, Egg, INSTANCE_SCOPE, Inject, PROFILES, SINGLETON_SCOPE, base, egg, inject, scope 7 | 8 | -------------------------------------------------------------------------------- /typings/haps/application.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from typing import Any, List, Type 6 | from haps.config import Configuration 7 | 8 | class Application: 9 | """ 10 | Base Application class that should be the entry point for haps 11 | applications. You can override `__main__` to inject dependencies. 12 | """ 13 | @classmethod 14 | def configure(cls, config: Configuration) -> None: 15 | """ 16 | Method for configure haps application. 17 | 18 | This method is invoked before autodiscover. 19 | 20 | :param config: Configuration instance 21 | """ 22 | ... 23 | 24 | def run(self) -> None: 25 | """ 26 | Method for application entry point (like the `main` method in C). 27 | Must be implemented. 28 | """ 29 | ... 30 | 31 | 32 | 33 | class ApplicationRunner: 34 | @staticmethod 35 | def run(app_class: Type[Application], extra_module_paths: List[str] = ..., **kwargs: Any) -> None: 36 | """ 37 | Runner for haps application. 38 | 39 | :param app_class: :class:`~haps.application.Application` type 40 | :param extra_module_paths: Extra modules list to autodiscover 41 | :param kwargs: Extra arguments are passed to\ 42 | :func:`~haps.Container.autodiscover` 43 | """ 44 | ... 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /typings/haps/exceptions.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | class AlreadyConfigured(Exception): 6 | ... 7 | 8 | 9 | class ConfigurationError(Exception): 10 | ... 11 | 12 | 13 | class NotConfigured(Exception): 14 | ... 15 | 16 | 17 | class UnknownDependency(TypeError): 18 | ... 19 | 20 | 21 | class UnknownScope(TypeError): 22 | ... 23 | 24 | 25 | class CallError(TypeError): 26 | ... 27 | 28 | 29 | class UnknownConfigVariable(ConfigurationError): 30 | ... 31 | 32 | 33 | -------------------------------------------------------------------------------- /typings/haps/scopes/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from typing import Any, Callable 6 | 7 | class Scope: 8 | """ 9 | Base scope class. Every custom scope should subclass this. 10 | """ 11 | def get_object(self, type_: Callable) -> Any: 12 | """ 13 | Returns object from scope 14 | :param type_: 15 | """ 16 | ... 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /typings/haps/scopes/instance.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from typing import Any, Callable 6 | from haps.scopes import Scope 7 | 8 | class InstanceScope(Scope): 9 | """ 10 | Dependencies within InstanceScope are created at every injection. 11 | """ 12 | def get_object(self, type_: Callable) -> Any: 13 | ... 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /typings/haps/scopes/singleton.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from typing import Any, Callable 6 | from haps.scopes import Scope 7 | 8 | class SingletonScope(Scope): 9 | """ 10 | Dependencies within SingletonScope are created only once in 11 | the application context. 12 | """ 13 | _objects = ... 14 | def get_object(self, type_: Callable) -> Any: 15 | ... 16 | 17 | 18 | 19 | --------------------------------------------------------------------------------