├── .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 |
--------------------------------------------------------------------------------