├── mubble ├── py.typed ├── rules.py ├── node │ ├── tools │ │ ├── __init__.py │ │ └── generator.py │ ├── me.py │ ├── scope.py │ ├── command.py │ ├── container.py │ ├── text.py │ ├── file.py │ ├── callback_query.py │ ├── event.py │ ├── rule.py │ ├── polymorphic.py │ ├── __init__.py │ ├── payload.py │ ├── source.py │ └── either.py ├── tools │ ├── adapter │ │ ├── errors.py │ │ ├── __init__.py │ │ ├── raw_event.py │ │ ├── raw_update.py │ │ ├── abc.py │ │ ├── node.py │ │ ├── dataclass.py │ │ └── event.py │ ├── i18n │ │ ├── middleware │ │ │ ├── __init__.py │ │ │ └── abc.py │ │ ├── __init__.py │ │ ├── abc.py │ │ └── simple.py │ ├── parse_mode.py │ ├── loop_wrapper │ │ ├── __init__.py │ │ └── abc.py │ ├── callback_data_serilization │ │ ├── __init__.py │ │ ├── abc.py │ │ └── json_ser.py │ ├── state_storage │ │ ├── __init__.py │ │ ├── memory.py │ │ └── abc.py │ ├── functional.py │ ├── error_handler │ │ ├── __init__.py │ │ ├── error.py │ │ └── abc.py │ ├── global_context │ │ ├── __init__.py │ │ ├── mubble_ctx.py │ │ └── abc.py │ ├── strings.py │ ├── input_file_directory.py │ ├── limited_dict.py │ ├── formatting │ │ ├── spec_html_formats.py │ │ └── __init__.py │ ├── lifespan.py │ └── buttons.py ├── bot │ ├── polling │ │ ├── __init__.py │ │ └── abc.py │ ├── dispatch │ │ ├── middleware │ │ │ ├── __init__.py │ │ │ ├── global_middleware.py │ │ │ └── abc.py │ │ ├── view │ │ │ ├── chat_join_request.py │ │ │ ├── inline_query.py │ │ │ ├── callback_query.py │ │ │ ├── pre_checkout_query.py │ │ │ ├── __init__.py │ │ │ ├── abc.py │ │ │ ├── chat_member.py │ │ │ ├── message.py │ │ │ └── raw.py │ │ ├── waiter_machine │ │ │ ├── actions.py │ │ │ ├── hasher │ │ │ │ ├── __init__.py │ │ │ │ ├── state.py │ │ │ │ ├── message.py │ │ │ │ ├── callback.py │ │ │ │ └── hasher.py │ │ │ ├── __init__.py │ │ │ ├── short_state.py │ │ │ └── middleware.py │ │ ├── return_manager │ │ │ ├── inline_query.py │ │ │ ├── __init__.py │ │ │ ├── callback_query.py │ │ │ ├── pre_checkout_query.py │ │ │ ├── message.py │ │ │ └── abc.py │ │ ├── handler │ │ │ ├── abc.py │ │ │ ├── __init__.py │ │ │ ├── message_reply.py │ │ │ ├── sticker_reply.py │ │ │ ├── audio_reply.py │ │ │ ├── photo_reply.py │ │ │ ├── video_reply.py │ │ │ ├── document_reply.py │ │ │ ├── media_group_reply.py │ │ │ └── base.py │ │ ├── abc.py │ │ ├── __init__.py │ │ └── context.py │ ├── scenario │ │ ├── __init__.py │ │ ├── abc.py │ │ └── choice.py │ ├── rules │ │ ├── update.py │ │ ├── message.py │ │ ├── mention.py │ │ ├── integer.py │ │ ├── logic.py │ │ ├── id.py │ │ ├── fuzzy.py │ │ ├── func.py │ │ ├── payment_invoice.py │ │ ├── enum_text.py │ │ ├── state.py │ │ ├── node.py │ │ ├── message_entities.py │ │ ├── regex.py │ │ ├── start.py │ │ ├── text.py │ │ ├── chat_join.py │ │ ├── markup.py │ │ ├── inline.py │ │ ├── rule_enum.py │ │ ├── payload.py │ │ └── __init__.py │ ├── cute_types │ │ ├── __init__.py │ │ ├── inline_query.py │ │ ├── pre_checkout_query.py │ │ ├── utils.py │ │ └── chat_join_request.py │ ├── bot.py │ └── __init__.py ├── msgspec_json.py ├── api │ ├── __init__.py │ ├── error.py │ ├── response.py │ ├── token.py │ └── api.py ├── client │ ├── __init__.py │ ├── form_data.py │ └── abc.py ├── verification_utils.py └── types │ └── input_file.py ├── .DS_Store ├── images ├── image.jpg ├── click_here.png ├── dispatcher.jpg ├── middleware.jpg ├── keyboards_1.jpg ├── keyboards_2.jpg ├── keyboards_3.jpg ├── keyboards_4.jpg └── mubble_logo.png ├── .gitignore ├── docs ├── installation.md ├── README.md ├── license.md └── quickstart.md └── pyproject.toml /mubble/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mubble/rules.py: -------------------------------------------------------------------------------- 1 | from .bot.rules import * # noqa: F403 2 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladislavkovalskyi/mubble/HEAD/.DS_Store -------------------------------------------------------------------------------- /images/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladislavkovalskyi/mubble/HEAD/images/image.jpg -------------------------------------------------------------------------------- /images/click_here.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladislavkovalskyi/mubble/HEAD/images/click_here.png -------------------------------------------------------------------------------- /images/dispatcher.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladislavkovalskyi/mubble/HEAD/images/dispatcher.jpg -------------------------------------------------------------------------------- /images/middleware.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladislavkovalskyi/mubble/HEAD/images/middleware.jpg -------------------------------------------------------------------------------- /images/keyboards_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladislavkovalskyi/mubble/HEAD/images/keyboards_1.jpg -------------------------------------------------------------------------------- /images/keyboards_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladislavkovalskyi/mubble/HEAD/images/keyboards_2.jpg -------------------------------------------------------------------------------- /images/keyboards_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladislavkovalskyi/mubble/HEAD/images/keyboards_3.jpg -------------------------------------------------------------------------------- /images/keyboards_4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladislavkovalskyi/mubble/HEAD/images/keyboards_4.jpg -------------------------------------------------------------------------------- /images/mubble_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladislavkovalskyi/mubble/HEAD/images/mubble_logo.png -------------------------------------------------------------------------------- /mubble/node/tools/__init__.py: -------------------------------------------------------------------------------- 1 | from .generator import generate_node 2 | 3 | __all__ = ("generate_node",) 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | dist 4 | tests* 5 | test* 6 | mubble.* 7 | __pycache__ 8 | poetry.lock 9 | mubble_old -------------------------------------------------------------------------------- /mubble/tools/adapter/errors.py: -------------------------------------------------------------------------------- 1 | class AdapterError(RuntimeError): 2 | pass 3 | 4 | 5 | __all__ = ("AdapterError",) 6 | -------------------------------------------------------------------------------- /mubble/tools/i18n/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from .abc import ABCTranslatorMiddleware 2 | 3 | __all__ = ("ABCTranslatorMiddleware",) 4 | -------------------------------------------------------------------------------- /mubble/bot/polling/__init__.py: -------------------------------------------------------------------------------- 1 | from .abc import ABCPolling 2 | from .polling import Polling 3 | 4 | __all__ = ("ABCPolling", "Polling") 5 | -------------------------------------------------------------------------------- /mubble/tools/parse_mode.py: -------------------------------------------------------------------------------- 1 | class ParseMode: 2 | MARKDOWNV2 = "MarkdownV2" 3 | HTML = "HTML" 4 | 5 | 6 | __all__ = ("ParseMode",) 7 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.dispatch.middleware.abc import ABCMiddleware 2 | 3 | __all__ = ("ABCMiddleware",) 4 | -------------------------------------------------------------------------------- /mubble/bot/scenario/__init__.py: -------------------------------------------------------------------------------- 1 | from .abc import ABCScenario 2 | from .checkbox import Checkbox 3 | from .choice import Choice 4 | 5 | __all__ = ("ABCScenario", "Checkbox", "Choice") 6 | -------------------------------------------------------------------------------- /mubble/tools/loop_wrapper/__init__.py: -------------------------------------------------------------------------------- 1 | from .abc import ABCLoopWrapper 2 | from .loop_wrapper import DelayedTask, Lifespan, LoopWrapper 3 | 4 | __all__ = ("ABCLoopWrapper", "DelayedTask", "Lifespan", "LoopWrapper") 5 | -------------------------------------------------------------------------------- /mubble/tools/callback_data_serilization/__init__.py: -------------------------------------------------------------------------------- 1 | from .abc import ABCDataSerializer 2 | from .json_ser import JSONSerializer 3 | from .msgpack_ser import MsgPackSerializer 4 | 5 | __all__ = ("ABCDataSerializer", "JSONSerializer", "MsgPackSerializer") 6 | -------------------------------------------------------------------------------- /mubble/tools/state_storage/__init__.py: -------------------------------------------------------------------------------- 1 | from mubble.tools.state_storage.abc import ABCStateStorage, StateData 2 | from mubble.tools.state_storage.memory import MemoryStateStorage 3 | 4 | __all__ = ("ABCStateStorage", "MemoryStateStorage", "StateData") 5 | -------------------------------------------------------------------------------- /mubble/tools/functional.py: -------------------------------------------------------------------------------- 1 | from fntypes import Nothing, Option, Some 2 | 3 | 4 | def from_optional[Value](value: Value | None, /) -> Option[Value]: 5 | return Some(value) if value is not None else Nothing() 6 | 7 | 8 | __all__ = ("from_optional",) 9 | -------------------------------------------------------------------------------- /mubble/tools/error_handler/__init__.py: -------------------------------------------------------------------------------- 1 | from .abc import ABCErrorHandler 2 | from .error import CatcherError 3 | from .error_handler import Catcher, ErrorHandler 4 | 5 | __all__ = ( 6 | "ABCErrorHandler", 7 | "Catcher", 8 | "CatcherError", 9 | "ErrorHandler", 10 | ) 11 | -------------------------------------------------------------------------------- /mubble/tools/error_handler/error.py: -------------------------------------------------------------------------------- 1 | class CatcherError(BaseException): 2 | __slots__ = ("exc", "message") 3 | 4 | def __init__(self, exc: BaseException, message: str) -> None: 5 | self.exc = exc 6 | self.message = message 7 | 8 | 9 | __all__ = ("CatcherError",) 10 | -------------------------------------------------------------------------------- /mubble/msgspec_json.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.msgspec_utils import decoder, encoder 4 | 5 | 6 | def loads(s: str | bytes) -> typing.Any: 7 | return decoder.decode(s) 8 | 9 | 10 | def dumps(o: typing.Any) -> str: 11 | return encoder.encode(o) 12 | 13 | 14 | __all__ = ("dumps", "loads") 15 | -------------------------------------------------------------------------------- /mubble/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import API 2 | from .error import APIError, APIServerError, InvalidTokenError 3 | from .response import APIResponse 4 | from .token import Token 5 | 6 | __all__ = ( 7 | "API", 8 | "APIError", 9 | "APIResponse", 10 | "APIServerError", 11 | "InvalidTokenError", 12 | "Token", 13 | ) 14 | -------------------------------------------------------------------------------- /mubble/client/__init__.py: -------------------------------------------------------------------------------- 1 | from .abc import ABCClient 2 | from .aiohttp import AiohttpClient 3 | from .form_data import MultipartFormProto, encode_form_data 4 | from .sonic import AiosonicClient 5 | 6 | __all__ = ( 7 | "ABCClient", 8 | "AiohttpClient", 9 | "AiosonicClient", 10 | "MultipartFormProto", 11 | "encode_form_data", 12 | ) 13 | -------------------------------------------------------------------------------- /mubble/tools/global_context/__init__.py: -------------------------------------------------------------------------------- 1 | from .abc import ABCGlobalContext, CtxVar, GlobalCtxVar 2 | from .global_context import GlobalContext, ctx_var 3 | from .mubble_ctx import MubbleContext 4 | 5 | __all__ = ( 6 | "ABCGlobalContext", 7 | "CtxVar", 8 | "GlobalContext", 9 | "GlobalCtxVar", 10 | "MubbleContext", 11 | "ctx_var", 12 | ) 13 | -------------------------------------------------------------------------------- /mubble/tools/i18n/__init__.py: -------------------------------------------------------------------------------- 1 | from .abc import ABCI18n, ABCTranslator, I18nEnum 2 | from .middleware import ABCTranslatorMiddleware 3 | from .simple import SimpleI18n, SimpleTranslator 4 | 5 | __all__ = ( 6 | "ABCI18n", 7 | "ABCTranslator", 8 | "ABCTranslatorMiddleware", 9 | "I18nEnum", 10 | "SimpleI18n", 11 | "SimpleTranslator", 12 | ) 13 | -------------------------------------------------------------------------------- /mubble/tools/strings.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | PATTERN = re.compile(r"[^ a-z A-Z 0-9 \s]") 4 | 5 | 6 | def to_pascal_case(string: str, /) -> str: 7 | return "".join( 8 | "".join(char.capitalize() for char in sub_str) 9 | for sub_str in (char.split() for char in PATTERN.split(string)) 10 | ) 11 | 12 | 13 | __all__ = ("to_pascal_case",) 14 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/view/chat_join_request.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.cute_types.chat_join_request import ChatJoinRequestCute 2 | from mubble.bot.dispatch.view.base import BaseStateView 3 | 4 | 5 | class ChatJoinRequestView(BaseStateView[ChatJoinRequestCute]): 6 | @classmethod 7 | def get_state_key(cls, event: ChatJoinRequestCute) -> int | None: 8 | return event.chat_id 9 | 10 | 11 | __all__ = ("ChatJoinRequestView",) 12 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/waiter_machine/actions.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.bot.cute_types import BaseCute 4 | from mubble.bot.dispatch.handler.abc import ABCHandler 5 | 6 | from .short_state import ShortState 7 | 8 | 9 | class WaiterActions[Event: BaseCute](typing.TypedDict): 10 | on_miss: typing.NotRequired[ABCHandler[Event]] 11 | on_drop: typing.NotRequired[typing.Callable[[ShortState[Event]], None]] 12 | 13 | 14 | __all__ = ("WaiterActions",) 15 | -------------------------------------------------------------------------------- /mubble/bot/rules/update.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.cute_types.update import UpdateCute 2 | from mubble.types.enums import UpdateType 3 | 4 | from .abc import ABCRule 5 | 6 | 7 | class IsUpdateType(ABCRule): 8 | def __init__(self, update_type: UpdateType, /) -> None: 9 | self.update_type = update_type 10 | 11 | def check(self, event: UpdateCute) -> bool: 12 | return event.update_type == self.update_type 13 | 14 | 15 | __all__ = ("IsUpdateType",) 16 | -------------------------------------------------------------------------------- /mubble/node/me.py: -------------------------------------------------------------------------------- 1 | from mubble.api.api import API 2 | from mubble.node.base import ComposeError, scalar_node 3 | from mubble.node.scope import GLOBAL 4 | from mubble.types.objects import User 5 | 6 | 7 | @scalar_node(scope=GLOBAL) 8 | class Me: 9 | @classmethod 10 | async def compose(cls, api: API) -> User: 11 | me = await api.get_me() 12 | return me.expect(ComposeError("Can't complete api.get_me() request.")) 13 | 14 | 15 | __all__ = ("Me",) 16 | -------------------------------------------------------------------------------- /mubble/bot/scenario/abc.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from abc import ABC, abstractmethod 3 | 4 | from mubble.bot.cute_types.base import BaseCute 5 | 6 | if typing.TYPE_CHECKING: 7 | from mubble.api import API 8 | from mubble.bot.dispatch.view.abc import ABCStateView 9 | 10 | 11 | class ABCScenario[Event: BaseCute](ABC): 12 | @abstractmethod 13 | def wait(self, api: "API", view: "ABCStateView[Event]") -> typing.Any: 14 | pass 15 | 16 | 17 | __all__ = ("ABCScenario",) 18 | -------------------------------------------------------------------------------- /mubble/bot/rules/message.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import typing 3 | 4 | from mubble.tools.adapter.event import EventAdapter 5 | from mubble.types.objects import Message as MessageEvent 6 | 7 | from .abc import ABCRule, CheckResult, Message 8 | 9 | 10 | class MessageRule(ABCRule[Message], abc.ABC, adapter=EventAdapter(MessageEvent, Message)): 11 | @abc.abstractmethod 12 | def check(self, *args: typing.Any, **kwargs: typing.Any) -> CheckResult: ... 13 | 14 | 15 | __all__ = ("MessageRule",) 16 | -------------------------------------------------------------------------------- /mubble/tools/loop_wrapper/abc.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from abc import ABC, abstractmethod 3 | 4 | 5 | class ABCLoopWrapper(ABC): 6 | @property 7 | @abstractmethod 8 | def is_running(self) -> bool: 9 | pass 10 | 11 | @abstractmethod 12 | def add_task(self, task: typing.Any, /) -> None: 13 | pass 14 | 15 | @abstractmethod 16 | def run_event_loop(self) -> typing.NoReturn: 17 | raise NotImplementedError 18 | 19 | 20 | __all__ = ("ABCLoopWrapper",) 21 | -------------------------------------------------------------------------------- /mubble/bot/rules/mention.py: -------------------------------------------------------------------------------- 1 | from mubble.types.enums import MessageEntityType 2 | 3 | from .message import Message, MessageRule 4 | from .text import HasText 5 | 6 | 7 | class HasMention(MessageRule, requires=[HasText()]): 8 | def check(self, message: Message) -> bool: 9 | if not message.entities.unwrap_or_none(): 10 | return False 11 | return any(entity.type == MessageEntityType.MENTION for entity in message.entities.unwrap()) 12 | 13 | 14 | __all__ = ("HasMention",) 15 | -------------------------------------------------------------------------------- /mubble/api/error.py: -------------------------------------------------------------------------------- 1 | class APIError(Exception): 2 | def __init__(self, code: int, error: str) -> None: 3 | self.code, self.error = code, error 4 | 5 | def __str__(self) -> str: 6 | return f"[{self.code}] {self.error}" 7 | 8 | def __repr__(self) -> str: 9 | return f"" 10 | 11 | 12 | class APIServerError(Exception): 13 | pass 14 | 15 | 16 | class InvalidTokenError(BaseException): 17 | pass 18 | 19 | 20 | __all__ = ("APIError", "APIServerError", "InvalidTokenError") 21 | -------------------------------------------------------------------------------- /mubble/bot/polling/abc.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from abc import ABC, abstractmethod 3 | 4 | import msgspec 5 | 6 | from mubble.types.objects import Update 7 | 8 | 9 | class ABCPolling(ABC): 10 | offset: int 11 | 12 | @abstractmethod 13 | async def get_updates(self) -> list[msgspec.Raw]: 14 | pass 15 | 16 | @abstractmethod 17 | async def listen(self) -> typing.AsyncGenerator[list[Update], None]: 18 | yield [] 19 | 20 | @abstractmethod 21 | def stop(self) -> None: 22 | pass 23 | 24 | 25 | __all__ = ("ABCPolling",) 26 | -------------------------------------------------------------------------------- /mubble/bot/rules/integer.py: -------------------------------------------------------------------------------- 1 | from mubble.node.base import as_node 2 | from mubble.node.text import TextInteger 3 | 4 | from .abc import ABCRule 5 | from .node import NodeRule 6 | 7 | 8 | class IsInteger(NodeRule): 9 | def __init__(self) -> None: 10 | super().__init__(as_node(TextInteger)) 11 | 12 | 13 | class IntegerInRange(ABCRule): 14 | def __init__(self, rng: range) -> None: 15 | self.rng = rng 16 | 17 | def check(self, integer: TextInteger) -> bool: 18 | return integer in self.rng 19 | 20 | 21 | __all__ = ("IntegerInRange", "IsInteger") 22 | -------------------------------------------------------------------------------- /mubble/api/response.py: -------------------------------------------------------------------------------- 1 | import msgspec 2 | from fntypes.result import Error, Ok, Result 3 | 4 | from mubble.api.error import APIError 5 | from mubble.model import Model 6 | 7 | 8 | class APIResponse(Model): 9 | ok: bool = False 10 | result: msgspec.Raw = msgspec.Raw(b"") 11 | error_code: int = 400 12 | description: str = "Something went wrong" 13 | 14 | def to_result(self) -> Result[msgspec.Raw, APIError]: 15 | if self.ok: 16 | return Ok(self.result) 17 | return Error(APIError(self.error_code, self.description)) 18 | 19 | 20 | __all__ = ("APIResponse",) 21 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/waiter_machine/hasher/__init__.py: -------------------------------------------------------------------------------- 1 | from .callback import CALLBACK_QUERY_FOR_MESSAGE, CALLBACK_QUERY_FROM_CHAT, CALLBACK_QUERY_IN_CHAT_FOR_MESSAGE 2 | from .hasher import Hasher 3 | from .message import MESSAGE_FROM_USER, MESSAGE_FROM_USER_IN_CHAT, MESSAGE_IN_CHAT 4 | from .state import StateViewHasher 5 | 6 | __all__ = ( 7 | "CALLBACK_QUERY_FOR_MESSAGE", 8 | "CALLBACK_QUERY_FROM_CHAT", 9 | "CALLBACK_QUERY_IN_CHAT_FOR_MESSAGE", 10 | "Hasher", 11 | "MESSAGE_FROM_USER", 12 | "MESSAGE_FROM_USER_IN_CHAT", 13 | "MESSAGE_IN_CHAT", 14 | "StateViewHasher", 15 | ) 16 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/view/inline_query.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.cute_types.inline_query import InlineQueryCute 2 | from mubble.bot.dispatch.return_manager import InlineQueryReturnManager 3 | from mubble.bot.dispatch.view.base import BaseStateView 4 | 5 | 6 | class InlineQueryView(BaseStateView[InlineQueryCute]): 7 | def __init__(self) -> None: 8 | super().__init__() 9 | self.return_manager = InlineQueryReturnManager() 10 | 11 | @classmethod 12 | def get_state_key(cls, event: InlineQueryCute) -> int | None: 13 | return event.from_user.id 14 | 15 | 16 | __all__ = ("InlineQueryView",) 17 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/return_manager/inline_query.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.bot.cute_types.inline_query import InlineQueryCute 4 | from mubble.bot.dispatch.context import Context 5 | from mubble.bot.dispatch.return_manager.abc import BaseReturnManager, register_manager 6 | 7 | 8 | class InlineQueryReturnManager(BaseReturnManager[InlineQueryCute]): 9 | @register_manager(dict[str, typing.Any]) 10 | @staticmethod 11 | async def dict_manager(value: dict[str, typing.Any], event: InlineQueryCute, ctx: Context) -> None: 12 | await event.answer(**value) 13 | 14 | 15 | __all__ = ("InlineQueryReturnManager",) 16 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/view/callback_query.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.cute_types.callback_query import CallbackQueryCute 2 | from mubble.bot.dispatch.return_manager.callback_query import CallbackQueryReturnManager 3 | from mubble.bot.dispatch.view.base import BaseStateView 4 | 5 | 6 | class CallbackQueryView(BaseStateView[CallbackQueryCute]): 7 | def __init__(self) -> None: 8 | super().__init__() 9 | self.return_manager = CallbackQueryReturnManager() 10 | 11 | @classmethod 12 | def get_state_key(cls, event: CallbackQueryCute) -> int | None: 13 | return event.message_id.unwrap_or_none() 14 | 15 | 16 | __all__ = ("CallbackQueryView",) 17 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/view/pre_checkout_query.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.cute_types.pre_checkout_query import PreCheckoutQueryCute 2 | from mubble.bot.dispatch.return_manager.pre_checkout_query import PreCheckoutQueryManager 3 | from mubble.bot.dispatch.view.base import BaseStateView 4 | 5 | 6 | class PreCheckoutQueryView(BaseStateView[PreCheckoutQueryCute]): 7 | def __init__(self) -> None: 8 | super().__init__() 9 | self.return_manager = PreCheckoutQueryManager() 10 | 11 | @classmethod 12 | def get_state_key(cls, event: PreCheckoutQueryCute) -> int | None: 13 | return event.from_user.id 14 | 15 | 16 | __all__ = ("PreCheckoutQueryView",) 17 | -------------------------------------------------------------------------------- /mubble/bot/rules/logic.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from .abc import ABCRule, Context, UpdateCute, check_rule 4 | 5 | 6 | class If(ABCRule): 7 | def __init__(self, condition: ABCRule) -> None: 8 | self.conditions = [condition] 9 | 10 | async def check(self, update: UpdateCute, ctx: Context) -> bool: 11 | for condition in self.conditions[:-1]: 12 | if not await check_rule(update.api, condition, update, ctx): 13 | return True 14 | return await check_rule(update.api, self.conditions[-1], update, ctx) 15 | 16 | def then(self, condition: ABCRule) -> typing.Self: 17 | self.conditions.append(condition) 18 | return self 19 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/handler/abc.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from abc import ABC, abstractmethod 3 | 4 | from mubble.api import API 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.tools.adapter.abc import ABCAdapter 7 | from mubble.types.objects import Update 8 | 9 | 10 | class ABCHandler[Event](ABC): 11 | final: bool 12 | adapter: ABCAdapter[Update, Event] | None = None 13 | 14 | @abstractmethod 15 | async def check(self, api: API, event: Update, ctx: Context | None = None) -> bool: 16 | pass 17 | 18 | @abstractmethod 19 | async def run(self, api: API, event: Event, ctx: Context) -> typing.Any: 20 | pass 21 | 22 | 23 | __all__ = ("ABCHandler",) 24 | -------------------------------------------------------------------------------- /mubble/bot/rules/id.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.types.objects import Update 4 | 5 | from .abc import ABCRule 6 | 7 | if typing.TYPE_CHECKING: 8 | from mubble.tools.adapter.abc import ABCAdapter 9 | 10 | 11 | class IdRule[Identifier](ABCRule[Identifier]): 12 | def __init__( 13 | self, 14 | adapter: "ABCAdapter[Update, Identifier]", 15 | tracked_identifiers: set[Identifier] | None = None, 16 | ): 17 | self.tracked_identifiers = tracked_identifiers or set() 18 | self.adapter = adapter 19 | 20 | async def check(self, event: Identifier) -> bool: 21 | return event in self.tracked_identifiers 22 | 23 | 24 | __all__ = ("IdRule",) 25 | -------------------------------------------------------------------------------- /mubble/tools/adapter/__init__.py: -------------------------------------------------------------------------------- 1 | from mubble.tools.adapter.abc import ABCAdapter, AdaptResult, Event 2 | from mubble.tools.adapter.dataclass import DataclassAdapter 3 | from mubble.tools.adapter.errors import AdapterError 4 | from mubble.tools.adapter.event import EventAdapter 5 | from mubble.tools.adapter.node import NodeAdapter 6 | from mubble.tools.adapter.raw_event import RawEventAdapter 7 | from mubble.tools.adapter.raw_update import RawUpdateAdapter 8 | 9 | __all__ = ( 10 | "ABCAdapter", 11 | "AdaptResult", 12 | "AdapterError", 13 | "DataclassAdapter", 14 | "Event", 15 | "EventAdapter", 16 | "NodeAdapter", 17 | "RawEventAdapter", 18 | "RawUpdateAdapter", 19 | ) 20 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/waiter_machine/hasher/state.py: -------------------------------------------------------------------------------- 1 | from fntypes.option import Option 2 | 3 | from mubble.bot.cute_types import BaseCute 4 | from mubble.bot.dispatch.view import BaseStateView 5 | from mubble.bot.dispatch.waiter_machine.hasher.hasher import ECHO, Hasher 6 | from mubble.tools.functional import from_optional 7 | 8 | 9 | class StateViewHasher[Event: BaseCute](Hasher[Event, int]): 10 | view: BaseStateView[Event] 11 | 12 | def __init__(self, view: BaseStateView[Event]) -> None: 13 | self.view = view 14 | super().__init__(view.__class__, get_hash_from_data=ECHO) 15 | 16 | def get_data_from_event(self, event: Event) -> Option[int]: 17 | return from_optional(self.view.get_state_key(event)) 18 | 19 | 20 | __all__ = ("StateViewHasher",) 21 | -------------------------------------------------------------------------------- /mubble/bot/rules/fuzzy.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | 3 | from mubble.bot.dispatch.context import Context 4 | from mubble.node.text import Text 5 | 6 | from .abc import ABCRule 7 | 8 | 9 | class FuzzyText(ABCRule): 10 | def __init__(self, texts: str | list[str], /, min_ratio: float = 0.7) -> None: 11 | if isinstance(texts, str): 12 | texts = [texts] 13 | self.texts = texts 14 | self.min_ratio = min_ratio 15 | 16 | def check(self, message_text: Text, ctx: Context) -> bool: 17 | match = max(difflib.SequenceMatcher(a=message_text, b=text).ratio() for text in self.texts) 18 | if match < self.min_ratio: 19 | return False 20 | ctx.fuzzy_ratio = match 21 | return True 22 | 23 | 24 | __all__ = ("FuzzyText",) 25 | -------------------------------------------------------------------------------- /mubble/tools/i18n/middleware/abc.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | 3 | from mubble.bot.cute_types.base import BaseCute 4 | from mubble.bot.dispatch.context import Context 5 | from mubble.bot.dispatch.middleware import ABCMiddleware 6 | from mubble.tools.i18n import ABCI18n, I18nEnum 7 | 8 | 9 | class ABCTranslatorMiddleware[Event: BaseCute](ABCMiddleware[Event]): 10 | def __init__(self, i18n: ABCI18n) -> None: 11 | self.i18n = i18n 12 | 13 | @abstractmethod 14 | async def get_locale(self, event: Event) -> str: 15 | pass 16 | 17 | async def pre(self, event: Event, ctx: Context) -> bool: 18 | ctx[I18nEnum.I18N] = self.i18n.get_translator_by_locale(await self.get_locale(event)) 19 | return True 20 | 21 | 22 | __all__ = ("ABCTranslatorMiddleware",) 23 | -------------------------------------------------------------------------------- /mubble/bot/cute_types/__init__.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.cute_types.base import BaseCute 2 | from mubble.bot.cute_types.callback_query import CallbackQueryCute 3 | from mubble.bot.cute_types.chat_join_request import ChatJoinRequestCute 4 | from mubble.bot.cute_types.chat_member_updated import ChatMemberUpdatedCute 5 | from mubble.bot.cute_types.inline_query import InlineQueryCute 6 | from mubble.bot.cute_types.message import MessageCute 7 | from mubble.bot.cute_types.pre_checkout_query import PreCheckoutQueryCute 8 | from mubble.bot.cute_types.update import UpdateCute 9 | 10 | __all__ = ( 11 | "BaseCute", 12 | "CallbackQueryCute", 13 | "ChatJoinRequestCute", 14 | "ChatMemberUpdatedCute", 15 | "InlineQueryCute", 16 | "MessageCute", 17 | "PreCheckoutQueryCute", 18 | "UpdateCute", 19 | ) 20 | -------------------------------------------------------------------------------- /mubble/tools/global_context/mubble_ctx.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import vbml 4 | 5 | from mubble.tools.global_context import GlobalContext, ctx_var 6 | 7 | 8 | class MubbleContext(GlobalContext): 9 | """Basic type-hinted mubble context with context name `"mubble"`. 10 | 11 | You can use this class or GlobalContext: 12 | ``` 13 | from mubble.tools.global_context import GlobalContext, MubbleContext 14 | 15 | ctx1 = MubbleContext() 16 | ctx2 = GlobalContext("mubble") # same, but without the type-hints 17 | assert ctx1 == ctx2 # ok 18 | ``` 19 | """ 20 | 21 | __ctx_name__ = "mubble" 22 | 23 | vbml_pattern_flags: re.RegexFlag | None = None 24 | vbml_patcher: vbml.Patcher = ctx_var(default=vbml.Patcher(), frozen=True) 25 | 26 | 27 | __all__ = ("MubbleContext",) 28 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/return_manager/__init__.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.dispatch.return_manager.abc import ( 2 | ABCReturnManager, 3 | BaseReturnManager, 4 | Manager, 5 | register_manager, 6 | ) 7 | from mubble.bot.dispatch.return_manager.callback_query import CallbackQueryReturnManager 8 | from mubble.bot.dispatch.return_manager.inline_query import InlineQueryReturnManager 9 | from mubble.bot.dispatch.return_manager.message import MessageReturnManager 10 | from mubble.bot.dispatch.return_manager.pre_checkout_query import PreCheckoutQueryManager 11 | 12 | __all__ = ( 13 | "ABCReturnManager", 14 | "BaseReturnManager", 15 | "CallbackQueryReturnManager", 16 | "InlineQueryReturnManager", 17 | "Manager", 18 | "MessageReturnManager", 19 | "PreCheckoutQueryManager", 20 | "register_manager", 21 | ) 22 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/return_manager/callback_query.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.bot.cute_types.callback_query import CallbackQueryCute 4 | from mubble.bot.dispatch.context import Context 5 | from mubble.bot.dispatch.return_manager.abc import BaseReturnManager, register_manager 6 | 7 | 8 | class CallbackQueryReturnManager(BaseReturnManager[CallbackQueryCute]): 9 | @register_manager(str) 10 | @staticmethod 11 | async def str_manager(value: str, event: CallbackQueryCute, ctx: Context) -> None: 12 | await event.answer(value) 13 | 14 | @register_manager(dict[str, typing.Any]) 15 | @staticmethod 16 | async def dict_manager(value: dict[str, typing.Any], event: CallbackQueryCute, ctx: Context) -> None: 17 | await event.answer(**value) 18 | 19 | 20 | __all__ = ("CallbackQueryReturnManager",) 21 | -------------------------------------------------------------------------------- /mubble/client/form_data.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.msgspec_utils import encoder 4 | 5 | 6 | def encode_form_data( 7 | data: dict[str, typing.Any], 8 | files: dict[str, tuple[str, typing.Any]], 9 | /, 10 | ) -> dict[str, str]: 11 | context = dict(files=files) 12 | return { 13 | k: encoder.encode(v, context=context).removeprefix('"').removesuffix('"') # Remove quoted strings 14 | if not isinstance(v, str) 15 | else v 16 | for k, v in data.items() 17 | } 18 | 19 | 20 | class MultipartFormProto(typing.Protocol): 21 | def add_field( 22 | self, 23 | name: str, 24 | value: typing.Any, 25 | /, 26 | *, 27 | filename: str | None = None, 28 | ) -> None: ... 29 | 30 | 31 | __all__ = ("MultipartFormProto", "encode_form_data") 32 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/return_manager/pre_checkout_query.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.bot.cute_types.pre_checkout_query import PreCheckoutQueryCute 4 | from mubble.bot.dispatch.context import Context 5 | from mubble.bot.dispatch.return_manager.abc import BaseReturnManager, register_manager 6 | 7 | 8 | class PreCheckoutQueryManager(BaseReturnManager[PreCheckoutQueryCute]): 9 | @register_manager(bool) 10 | @staticmethod 11 | async def bool_manager(value: bool, event: PreCheckoutQueryCute, ctx: Context) -> None: 12 | await event.answer(value) 13 | 14 | @register_manager(dict[str, typing.Any]) 15 | @staticmethod 16 | async def dict_manager(value: dict[str, typing.Any], event: PreCheckoutQueryCute, ctx: Context) -> None: 17 | await event.answer(**value) 18 | 19 | 20 | __all__ = ("PreCheckoutQueryManager",) 21 | -------------------------------------------------------------------------------- /mubble/tools/state_storage/memory.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from fntypes.option import Option 4 | 5 | from mubble.tools.functional import from_optional 6 | from mubble.tools.state_storage.abc import ABCStateStorage, StateData 7 | 8 | type Payload = dict[str, typing.Any] 9 | 10 | 11 | class MemoryStateStorage(ABCStateStorage[Payload]): 12 | def __init__(self) -> None: 13 | self.storage: dict[int, StateData[Payload]] = {} 14 | 15 | async def get(self, user_id: int) -> Option[StateData[Payload]]: 16 | return from_optional(self.storage.get(user_id)) 17 | 18 | async def set(self, user_id: int, key: str, payload: Payload) -> None: 19 | self.storage[user_id] = StateData(key, payload) 20 | 21 | async def delete(self, user_id: int) -> None: 22 | self.storage.pop(user_id) 23 | 24 | 25 | __all__ = ("MemoryStateStorage",) 26 | -------------------------------------------------------------------------------- /mubble/tools/i18n/abc.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import typing 3 | from abc import ABC, abstractmethod 4 | 5 | 6 | class ABCI18n(ABC): 7 | @abstractmethod 8 | def get_translator_by_locale(self, locale: str) -> "ABCTranslator": 9 | pass 10 | 11 | 12 | class ABCTranslator(ABC): 13 | def __init__(self, locale: str, **kwargs: typing.Any) -> None: 14 | self.locale = locale 15 | 16 | @abstractmethod 17 | def get(self, __key: str, *args: typing.Any, **kwargs: typing.Any) -> str: 18 | """This translates a key to actual human-readable string""" 19 | 20 | def __call__(self, __key: str, *args: typing.Any, **kwargs: typing.Any) -> str: 21 | return self.get(__key, *args, **kwargs) 22 | 23 | 24 | class I18nEnum(enum.Enum): 25 | I18N = "_" 26 | 27 | 28 | __all__ = ( 29 | "ABCI18n", 30 | "ABCTranslator", 31 | "I18nEnum", 32 | ) 33 | -------------------------------------------------------------------------------- /mubble/tools/error_handler/abc.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from abc import ABC, abstractmethod 3 | 4 | from mubble.api import API 5 | from mubble.bot.dispatch.context import Context 6 | 7 | type Handler = typing.Callable[..., typing.Awaitable[typing.Any]] 8 | 9 | 10 | class ABCErrorHandler[Event](ABC): 11 | @abstractmethod 12 | def __call__( 13 | self, 14 | *args: typing.Any, 15 | **kwargs: typing.Any, 16 | ) -> typing.Callable[[typing.Callable[..., typing.Any]], typing.Callable[..., typing.Any]]: 17 | """Decorator for registering callback as a catcher for the error handler.""" 18 | 19 | @abstractmethod 20 | async def run( 21 | self, 22 | exception: BaseException, 23 | event: Event, 24 | api: API, 25 | ctx: Context, 26 | ) -> typing.Any: 27 | """Run the error handler.""" 28 | 29 | 30 | __all__ = ("ABCErrorHandler",) 31 | -------------------------------------------------------------------------------- /mubble/node/scope.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import typing 3 | 4 | if typing.TYPE_CHECKING: 5 | from .base import IsNode 6 | 7 | 8 | class NodeScope(enum.Enum): 9 | GLOBAL = enum.auto() 10 | PER_EVENT = enum.auto() 11 | PER_CALL = enum.auto() 12 | 13 | 14 | PER_EVENT = NodeScope.PER_EVENT 15 | PER_CALL = NodeScope.PER_CALL 16 | GLOBAL = NodeScope.GLOBAL 17 | 18 | 19 | def per_call[T: IsNode](node: type[T]) -> type[T]: 20 | setattr(node, "scope", PER_CALL) 21 | return node 22 | 23 | 24 | def per_event[T: IsNode](node: type[T]) -> type[T]: 25 | setattr(node, "scope", PER_EVENT) 26 | return node 27 | 28 | 29 | def global_node[T: IsNode](node: type[T]) -> type[T]: 30 | setattr(node, "scope", GLOBAL) 31 | return node 32 | 33 | 34 | __all__ = ( 35 | "GLOBAL", 36 | "NodeScope", 37 | "PER_CALL", 38 | "PER_EVENT", 39 | "global_node", 40 | "per_call", 41 | "per_event", 42 | ) 43 | -------------------------------------------------------------------------------- /mubble/bot/rules/func.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import typing 3 | 4 | from mubble.bot.dispatch.context import Context 5 | from mubble.tools.adapter.abc import ABCAdapter 6 | from mubble.tools.adapter.raw_update import RawUpdateAdapter 7 | from mubble.types.objects import Update 8 | 9 | from .abc import ABCRule, AdaptTo 10 | 11 | 12 | class FuncRule(ABCRule, typing.Generic[AdaptTo]): 13 | def __init__( 14 | self, 15 | func: typing.Callable[[AdaptTo, Context], typing.Awaitable[bool] | bool], 16 | adapter: ABCAdapter[Update, AdaptTo] | None = None, 17 | ) -> None: 18 | self.func = func 19 | self.adapter = adapter or RawUpdateAdapter() # type: ignore 20 | 21 | async def check(self, event: AdaptTo, ctx: Context) -> bool: 22 | result = self.func(event, ctx) 23 | if inspect.isawaitable(result): 24 | result = await result 25 | return result 26 | 27 | 28 | __all__ = ("FuncRule",) 29 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/handler/__init__.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.dispatch.handler.abc import ABCHandler 2 | from mubble.bot.dispatch.handler.audio_reply import AudioReplyHandler 3 | from mubble.bot.dispatch.handler.document_reply import DocumentReplyHandler 4 | from mubble.bot.dispatch.handler.func import FuncHandler 5 | from mubble.bot.dispatch.handler.media_group_reply import MediaGroupReplyHandler 6 | from mubble.bot.dispatch.handler.message_reply import MessageReplyHandler 7 | from mubble.bot.dispatch.handler.photo_reply import PhotoReplyHandler 8 | from mubble.bot.dispatch.handler.sticker_reply import StickerReplyHandler 9 | from mubble.bot.dispatch.handler.video_reply import VideoReplyHandler 10 | 11 | __all__ = ( 12 | "ABCHandler", 13 | "AudioReplyHandler", 14 | "DocumentReplyHandler", 15 | "FuncHandler", 16 | "MediaGroupReplyHandler", 17 | "MessageReplyHandler", 18 | "PhotoReplyHandler", 19 | "StickerReplyHandler", 20 | "VideoReplyHandler", 21 | ) 22 | -------------------------------------------------------------------------------- /mubble/tools/input_file_directory.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import pathlib 3 | 4 | from mubble.types.objects import InputFile 5 | 6 | 7 | @dataclasses.dataclass 8 | class InputFileDirectory: 9 | directory: pathlib.Path 10 | storage: dict[str, InputFile] = dataclasses.field(init=False, repr=False) 11 | 12 | def __post_init__(self) -> None: 13 | self.storage = self._load_files() 14 | 15 | def _load_files(self) -> dict[str, InputFile]: 16 | files = {} 17 | 18 | for path in self.directory.rglob("*"): 19 | if path.is_file(): 20 | relative_path = path.relative_to(self.directory) 21 | files[str(relative_path)] = InputFile(path.name, path.read_bytes()) 22 | 23 | return files 24 | 25 | def get(self, filename: str, /) -> InputFile: 26 | assert filename in self.storage, f"File {filename!r} not found." 27 | return self.storage[filename] 28 | 29 | 30 | __all__ = ("InputFileDirectory",) 31 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/waiter_machine/__init__.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.dispatch.waiter_machine.hasher import ( 2 | CALLBACK_QUERY_FOR_MESSAGE, 3 | CALLBACK_QUERY_FROM_CHAT, 4 | CALLBACK_QUERY_IN_CHAT_FOR_MESSAGE, 5 | MESSAGE_FROM_USER, 6 | MESSAGE_FROM_USER_IN_CHAT, 7 | MESSAGE_IN_CHAT, 8 | Hasher, 9 | StateViewHasher, 10 | ) 11 | from mubble.bot.dispatch.waiter_machine.machine import WaiterMachine, clear_wm_storage_worker 12 | from mubble.bot.dispatch.waiter_machine.middleware import WaiterMiddleware 13 | from mubble.bot.dispatch.waiter_machine.short_state import ShortState 14 | 15 | __all__ = ( 16 | "CALLBACK_QUERY_FOR_MESSAGE", 17 | "CALLBACK_QUERY_FROM_CHAT", 18 | "CALLBACK_QUERY_IN_CHAT_FOR_MESSAGE", 19 | "Hasher", 20 | "MESSAGE_FROM_USER", 21 | "MESSAGE_FROM_USER_IN_CHAT", 22 | "MESSAGE_IN_CHAT", 23 | "ShortState", 24 | "StateViewHasher", 25 | "WaiterMachine", 26 | "WaiterMiddleware", 27 | "clear_wm_storage_worker", 28 | ) 29 | -------------------------------------------------------------------------------- /mubble/bot/rules/payment_invoice.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import typing 3 | 4 | from mubble.bot.cute_types.pre_checkout_query import PreCheckoutQueryCute 5 | from mubble.bot.rules.abc import ABCRule, CheckResult 6 | from mubble.tools.adapter.event import EventAdapter 7 | from mubble.types.enums import Currency, UpdateType 8 | 9 | PreCheckoutQuery: typing.TypeAlias = PreCheckoutQueryCute 10 | 11 | 12 | class PaymentInvoiceRule( 13 | ABCRule[PreCheckoutQuery], 14 | abc.ABC, 15 | adapter=EventAdapter(UpdateType.PRE_CHECKOUT_QUERY, PreCheckoutQuery), 16 | ): 17 | @abc.abstractmethod 18 | def check(self, *args: typing.Any, **kwargs: typing.Any) -> CheckResult: ... 19 | 20 | 21 | class PaymentInvoiceCurrency(PaymentInvoiceRule): 22 | def __init__(self, currency: str | Currency, /) -> None: 23 | self.currency = currency 24 | 25 | def check(self, query: PreCheckoutQuery) -> bool: 26 | return self.currency == query.currency 27 | 28 | 29 | __all__ = ("PaymentInvoiceCurrency", "PaymentInvoiceRule") 30 | -------------------------------------------------------------------------------- /mubble/tools/state_storage/abc.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import dataclasses 3 | import enum 4 | 5 | from fntypes.option import Option 6 | 7 | from mubble.bot.rules.state import State, StateMeta 8 | 9 | 10 | @dataclasses.dataclass(frozen=True, slots=True) 11 | class StateData[Payload]: 12 | key: str | enum.Enum 13 | payload: Payload 14 | 15 | 16 | class ABCStateStorage[Payload](abc.ABC): 17 | @abc.abstractmethod 18 | async def get(self, user_id: int) -> Option[StateData[Payload]]: ... 19 | 20 | @abc.abstractmethod 21 | async def delete(self, user_id: int) -> None: ... 22 | 23 | @abc.abstractmethod 24 | async def set(self, user_id: int, key: str | enum.Enum, payload: Payload) -> None: ... 25 | 26 | def State(self, key: str | StateMeta | enum.Enum = StateMeta.ANY, /) -> State[Payload]: # noqa: N802 27 | """Can be used as a shortcut to get a state rule dependant on current storage.""" 28 | return State(storage=self, key=key) 29 | 30 | 31 | __all__ = ("ABCStateStorage", "StateData") 32 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/view/__init__.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.dispatch.view.abc import ABCStateView, ABCView 2 | from mubble.bot.dispatch.view.base import BaseStateView, BaseView 3 | from mubble.bot.dispatch.view.box import ViewBox 4 | from mubble.bot.dispatch.view.callback_query import CallbackQueryView 5 | from mubble.bot.dispatch.view.chat_join_request import ChatJoinRequestView 6 | from mubble.bot.dispatch.view.chat_member import ChatMemberView 7 | from mubble.bot.dispatch.view.inline_query import InlineQueryView 8 | from mubble.bot.dispatch.view.message import MessageView 9 | from mubble.bot.dispatch.view.pre_checkout_query import PreCheckoutQueryView 10 | from mubble.bot.dispatch.view.raw import RawEventView 11 | 12 | __all__ = ( 13 | "ABCStateView", 14 | "ABCView", 15 | "BaseStateView", 16 | "BaseView", 17 | "CallbackQueryView", 18 | "ChatJoinRequestView", 19 | "ChatMemberView", 20 | "InlineQueryView", 21 | "MessageView", 22 | "PreCheckoutQueryView", 23 | "RawEventView", 24 | "ViewBox", 25 | ) 26 | -------------------------------------------------------------------------------- /mubble/bot/rules/enum_text.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from mubble.bot.dispatch.context import Context 4 | from mubble.node.text import Text 5 | 6 | from .abc import ABCRule 7 | 8 | 9 | class EnumTextRule[T: enum.Enum](ABCRule): 10 | def __init__(self, enum_t: type[T], *, lower_case: bool = True) -> None: 11 | self.enum_t = enum_t 12 | self.texts = list( 13 | map( 14 | lambda x: x.value.lower() if lower_case else x.value, 15 | self.enum_t, 16 | ) 17 | ) 18 | 19 | def find(self, s: str) -> T: 20 | for enumeration in self.enum_t: 21 | if enumeration.value.lower() == s: 22 | return enumeration 23 | raise KeyError("Enumeration is undefined.") 24 | 25 | def check(self, text: Text, ctx: Context) -> bool: 26 | text = text.lower() # type: ignore 27 | if text not in self.texts: 28 | return False 29 | ctx.enum_text = self.find(text) 30 | return True 31 | 32 | 33 | __all__ = ("EnumTextRule",) 34 | -------------------------------------------------------------------------------- /mubble/bot/rules/state.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import enum 3 | import typing 4 | 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.bot.rules.abc import ABCRule 7 | from mubble.node.source import Source 8 | 9 | if typing.TYPE_CHECKING: 10 | from mubble.tools.state_storage.abc import ABCStateStorage 11 | 12 | 13 | class StateMeta(enum.Enum): 14 | NO_STATE = enum.auto() 15 | ANY = enum.auto() 16 | 17 | 18 | @dataclasses.dataclass(frozen=True, slots=True, repr=False) 19 | class State[Payload](ABCRule): 20 | storage: "ABCStateStorage[Payload]" 21 | key: str | StateMeta | enum.Enum 22 | 23 | async def check(self, source: Source, ctx: Context) -> bool: 24 | state = await self.storage.get(source.from_user.id) 25 | if not state: 26 | return self.key == StateMeta.NO_STATE 27 | 28 | if self.key != StateMeta.ANY and self.key != state.unwrap().key: 29 | return False 30 | 31 | ctx["state"] = state.unwrap() 32 | return True 33 | 34 | 35 | __all__ = ("State", "StateMeta") 36 | -------------------------------------------------------------------------------- /mubble/tools/adapter/raw_event.py: -------------------------------------------------------------------------------- 1 | from fntypes.result import Error, Ok, Result 2 | 3 | from mubble.api.api import API 4 | from mubble.bot.dispatch.context import Context 5 | from mubble.model import Model 6 | from mubble.tools.adapter.abc import ABCAdapter 7 | from mubble.tools.adapter.errors import AdapterError 8 | from mubble.types.objects import Update 9 | 10 | 11 | class RawEventAdapter(ABCAdapter[Update, Model]): 12 | def __init__(self, event_model: type[Model], /) -> None: 13 | self.event_model = event_model 14 | 15 | def __repr__(self) -> str: 16 | return "<{}: adapt Update -> {}>".format( 17 | self.__class__.__name__, 18 | self.event_model.__name__, 19 | ) 20 | 21 | def adapt(self, api: API, update: Update, context: Context) -> Result[Model, AdapterError]: 22 | if isinstance(update.incoming_update, self.event_model): 23 | return Ok(update.incoming_update) 24 | return Error(AdapterError(f"Update is not an {self.event_model.__name__!r}.")) 25 | 26 | 27 | __all__ = ("RawEventAdapter",) 28 | -------------------------------------------------------------------------------- /mubble/api/token.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import typing 3 | from functools import cached_property 4 | 5 | from envparse import env 6 | 7 | from .error import InvalidTokenError 8 | 9 | 10 | class Token(str): 11 | def __new__(cls, token: str, /) -> typing.Self: 12 | if token.count(":") != 1 or not token.split(":")[0].isdigit(): 13 | raise InvalidTokenError("Invalid token, it should look like this: 12345:ABCdef") 14 | return super().__new__(cls, token) 15 | 16 | def __repr__(self) -> str: 17 | return f"" 18 | 19 | @classmethod 20 | def from_env( 21 | cls, 22 | var_name: str = "BOT_TOKEN", 23 | *, 24 | is_read: bool = False, 25 | path_to_envfile: str | pathlib.Path | None = None, 26 | ) -> typing.Self: 27 | if not is_read: 28 | env.read_envfile(path_to_envfile) 29 | return cls(env.str(var_name)) 30 | 31 | @cached_property 32 | def bot_id(self) -> int: 33 | return int(self.split(":")[0]) 34 | 35 | 36 | __all__ = ("Token",) 37 | -------------------------------------------------------------------------------- /mubble/tools/adapter/raw_update.py: -------------------------------------------------------------------------------- 1 | from fntypes.result import Ok, Result 2 | 3 | from mubble.api.api import API 4 | from mubble.bot.cute_types.update import UpdateCute 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.tools.adapter.abc import ABCAdapter 7 | from mubble.tools.adapter.errors import AdapterError 8 | from mubble.types.objects import Update 9 | 10 | 11 | class RawUpdateAdapter(ABCAdapter[Update, UpdateCute]): 12 | ADAPTED_VALUE_KEY: str = "_adapted_update_cute" 13 | 14 | def __repr__(self) -> str: 15 | return f"<{self.__class__.__name__}: adapt Update -> UpdateCute>" 16 | 17 | def adapt( 18 | self, 19 | api: API, 20 | update: Update, 21 | context: Context, 22 | ) -> Result[UpdateCute, AdapterError]: 23 | if self.ADAPTED_VALUE_KEY not in context: 24 | context[self.ADAPTED_VALUE_KEY] = ( 25 | UpdateCute.from_update(update, api) if not isinstance(update, UpdateCute) else update 26 | ) 27 | return Ok(context[self.ADAPTED_VALUE_KEY]) 28 | 29 | 30 | __all__ = ("RawUpdateAdapter",) 31 | -------------------------------------------------------------------------------- /mubble/node/command.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from dataclasses import dataclass, field 3 | 4 | from fntypes.option import Nothing, Option, Some 5 | 6 | from mubble.node.base import DataNode 7 | from mubble.node.either import Either 8 | from mubble.node.text import Caption, Text 9 | 10 | 11 | def single_split(s: str, separator: str) -> tuple[str, str]: 12 | left, *right = s.split(separator, 1) 13 | return left, (right[0] if right else "") 14 | 15 | 16 | def cut_mention(text: str) -> tuple[str, Option[str]]: 17 | left, right = single_split(text, "@") 18 | return left, Some(right) if right else Nothing() 19 | 20 | 21 | @dataclass(slots=True) 22 | class CommandInfo(DataNode): 23 | name: str 24 | arguments: str 25 | mention: Option[str] = field(default_factory=Nothing) 26 | 27 | @classmethod 28 | def compose(cls, text: Either[Text, Caption]) -> typing.Self: 29 | name, arguments = single_split(text, separator=" ") 30 | name, mention = cut_mention(name) 31 | return cls(name, arguments, mention) 32 | 33 | 34 | __all__ = ("CommandInfo", "cut_mention", "single_split") 35 | -------------------------------------------------------------------------------- /mubble/verification_utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import typing 4 | 5 | 6 | def verify_webapp_request( 7 | secret_token: str, 8 | request_headers: typing.Mapping[str, typing.Any], 9 | ) -> bool: 10 | """Verifies update request is from telegram.""" 11 | return request_headers.get("X-Telegram-Bot-Api-Secret-Token") == secret_token 12 | 13 | 14 | def webapp_validate_request( 15 | bot_token: str, 16 | request_query_params: typing.Mapping[str, typing.Any], 17 | ) -> bool: 18 | """Verifies authentity of webapp request by counting hash of its parameters.""" 19 | items = sorted(request_query_params.items(), key=lambda kv: kv[0]) 20 | data_check_string = "\n".join(f"{k}={param}" for k, param in items if k != "hash") 21 | secret = hmac.new( 22 | "WebAppData".encode(), 23 | bot_token.encode(), 24 | hashlib.sha256, 25 | ).digest() 26 | data_chk = hmac.new(secret, data_check_string.encode(), hashlib.sha256) 27 | return data_chk.hexdigest() == request_query_params.get("hash") 28 | 29 | 30 | __all__ = ("verify_webapp_request", "webapp_validate_request") 31 | -------------------------------------------------------------------------------- /mubble/node/tools/generator.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import typing 3 | 4 | from mubble.node.base import ComposeError, IsNode, Node 5 | from mubble.node.container import ContainerNode 6 | 7 | 8 | def cast_false_to_none[Value](value: Value) -> Value | None: 9 | if value is False: 10 | return None 11 | return value 12 | 13 | 14 | def error_on_none[Value](value: Value | None) -> Value: 15 | if value is None: 16 | raise ComposeError 17 | return value 18 | 19 | 20 | def generate_node( 21 | subnodes: tuple[IsNode, ...], 22 | func: typing.Callable[..., typing.Any], 23 | casts: tuple[typing.Callable[[typing.Any], typing.Any], ...] = (cast_false_to_none, error_on_none), 24 | ) -> type[Node]: 25 | async def compose(cls, *args: typing.Any) -> typing.Any: 26 | result = func(*args) 27 | if inspect.isawaitable(result): 28 | result = await result 29 | for cast in casts: 30 | result = cast(result) 31 | return result 32 | 33 | return ContainerNode.link_nodes(linked_nodes=list(subnodes), composer=compose) 34 | 35 | 36 | __all__ = ("generate_node",) 37 | -------------------------------------------------------------------------------- /mubble/bot/rules/node.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.bot.dispatch.context import Context 4 | from mubble.node.base import IsNode, NodeType, is_node 5 | from mubble.tools.adapter.node import NodeAdapter 6 | 7 | from .abc import ABCRule 8 | 9 | 10 | class NodeRule(ABCRule): 11 | def __init__(self, *nodes: IsNode | tuple[str, IsNode]) -> None: 12 | self.nodes: list[IsNode] = [] 13 | self.node_keys: list[str | None] = [] 14 | 15 | for binding in nodes: 16 | node_key, node_t = binding if isinstance(binding, tuple) else (None, binding) 17 | if not is_node(node_t): 18 | continue 19 | self.nodes.append(node_t) 20 | self.node_keys.append(node_key) 21 | 22 | @property 23 | def adapter(self) -> NodeAdapter: 24 | return NodeAdapter(*self.nodes) 25 | 26 | def check(self, resolved_nodes: tuple[NodeType, ...], ctx: Context) -> typing.Literal[True]: 27 | for i, node in enumerate(resolved_nodes): 28 | if key := self.node_keys[i]: 29 | ctx[key] = node 30 | return True 31 | 32 | 33 | __all__ = ("NodeRule",) 34 | -------------------------------------------------------------------------------- /mubble/tools/limited_dict.py: -------------------------------------------------------------------------------- 1 | from collections import UserDict, deque 2 | 3 | 4 | class LimitedDict[Key, Value](UserDict[Key, Value]): 5 | def __init__(self, *, maxlimit: int = 1000) -> None: 6 | super().__init__() 7 | self.maxlimit = maxlimit 8 | self.queue: deque[Key] = deque(maxlen=maxlimit) 9 | 10 | def set(self, key: Key, value: Value, /) -> Value | None: 11 | """Set item in the dictionary. 12 | Returns a value that was deleted when the limit in the dictionary 13 | was reached, otherwise None. 14 | """ 15 | deleted_item = None 16 | if len(self.queue) >= self.maxlimit: 17 | deleted_item = self.pop(self.queue.popleft(), None) 18 | if key not in self.queue: 19 | self.queue.append(key) 20 | super().__setitem__(key, value) 21 | return deleted_item 22 | 23 | def __setitem__(self, key: Key, value: Value, /) -> None: 24 | self.set(key, value) 25 | 26 | def __delitem__(self, key: Key) -> None: 27 | if key in self.queue: 28 | self.queue.remove(key) 29 | return super().__delitem__(key) 30 | 31 | 32 | __all__ = ("LimitedDict",) 33 | -------------------------------------------------------------------------------- /mubble/bot/rules/message_entities.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.dispatch.context import Context 2 | from mubble.types.enums import MessageEntityType 3 | from mubble.types.objects import MessageEntity 4 | 5 | from .message import Message, MessageRule 6 | 7 | type Entity = str | MessageEntityType 8 | 9 | 10 | class HasEntities(MessageRule): 11 | def check(self, message: Message) -> bool: 12 | return bool(message.entities) 13 | 14 | 15 | class MessageEntities(MessageRule, requires=[HasEntities()]): 16 | def __init__(self, entities: Entity | list[Entity], /) -> None: 17 | self.entities = [entities] if not isinstance(entities, list) else entities 18 | 19 | def check(self, message: Message, ctx: Context) -> bool: 20 | message_entities: list[MessageEntity] = [] 21 | for entity in message.entities.unwrap(): 22 | for entity_type in self.entities: 23 | if entity_type == entity.type: 24 | message_entities.append(entity) 25 | 26 | if not message_entities: 27 | return False 28 | 29 | ctx.message_entities = message_entities 30 | return True 31 | 32 | 33 | __all__ = ("HasEntities", "MessageEntities") 34 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/view/abc.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from abc import ABC, abstractmethod 3 | 4 | from mubble.api.api import API 5 | from mubble.bot.cute_types.base import BaseCute 6 | from mubble.bot.dispatch.context import Context 7 | from mubble.bot.dispatch.handler.abc import ABCHandler 8 | from mubble.types.objects import Update 9 | 10 | 11 | class ABCView(ABC): 12 | def __repr__(self) -> str: 13 | return "<{}>".format(self.__class__.__name__) 14 | 15 | @abstractmethod 16 | async def check(self, event: Update) -> bool: 17 | pass 18 | 19 | @abstractmethod 20 | async def process( 21 | self, 22 | event: Update, 23 | api: API[typing.Any], 24 | context: Context, 25 | ) -> bool: 26 | pass 27 | 28 | @abstractmethod 29 | def load(self, external: typing.Self, /) -> None: 30 | pass 31 | 32 | 33 | class ABCEventRawView[Event: BaseCute](ABCView, ABC): 34 | handlers: list[ABCHandler[Event]] 35 | 36 | 37 | class ABCStateView[Event: BaseCute](ABCView): 38 | @abstractmethod 39 | def get_state_key(self, event: Event) -> int | None: 40 | pass 41 | 42 | 43 | __all__ = ( 44 | "ABCEventRawView", 45 | "ABCStateView", 46 | "ABCView", 47 | ) 48 | -------------------------------------------------------------------------------- /mubble/node/container.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.node.base import IsNode, Node 4 | 5 | 6 | class ContainerNode(Node): 7 | linked_nodes: typing.ClassVar[list[IsNode]] 8 | composer: typing.Callable[..., typing.Awaitable[typing.Any]] 9 | 10 | @classmethod 11 | async def compose(cls, **kw: typing.Any) -> typing.Any: 12 | subnodes = cls.get_subnodes().keys() 13 | return await cls.composer(*tuple(t[1] for t in sorted(kw.items(), key=lambda t: t[0]) if t[0] in subnodes)) 14 | 15 | @classmethod 16 | def get_subnodes(cls) -> dict[str, IsNode]: 17 | subnodes = getattr(cls, "subnodes", None) 18 | if subnodes is None: 19 | subnodes_dct = {f"node_{i}": node_t for i, node_t in enumerate(cls.linked_nodes)} 20 | setattr(cls, "subnodes", subnodes_dct) 21 | return subnodes_dct 22 | return subnodes 23 | 24 | @classmethod 25 | def link_nodes( 26 | cls, 27 | linked_nodes: list[IsNode], 28 | composer: typing.Callable[..., typing.Awaitable[typing.Any]], 29 | ) -> type["ContainerNode"]: 30 | return type(cls.__name__, (cls,), {"linked_nodes": linked_nodes, "composer": classmethod(composer)}) 31 | 32 | 33 | __all__ = ("ContainerNode",) 34 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/handler/message_reply.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.api.api import API 4 | from mubble.bot.cute_types.message import MessageCute 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.bot.dispatch.handler.base import BaseReplyHandler 7 | from mubble.bot.rules.abc import ABCRule 8 | 9 | 10 | class MessageReplyHandler(BaseReplyHandler): 11 | def __init__( 12 | self, 13 | text: str, 14 | *rules: ABCRule, 15 | parse_mode: str | None = None, 16 | final: bool = True, 17 | as_reply: bool = False, 18 | preset_context: Context | None = None, 19 | **default_params: typing.Any, 20 | ) -> None: 21 | self.text = text 22 | self.parse_mode = parse_mode 23 | super().__init__( 24 | *rules, 25 | final=final, 26 | as_reply=as_reply, 27 | preset_context=preset_context, 28 | **default_params, 29 | ) 30 | 31 | async def run(self, _: API, event: MessageCute, __: Context) -> typing.Any: 32 | method = event.answer if not self.as_reply else event.reply 33 | await method(text=self.text, parse_mode=self.parse_mode, **self.default_params) 34 | 35 | 36 | __all__ = ("MessageReplyHandler",) 37 | -------------------------------------------------------------------------------- /mubble/bot/rules/regex.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing 3 | 4 | from mubble.bot.dispatch.context import Context 5 | from mubble.node.either import Either 6 | from mubble.node.text import Caption, Text 7 | 8 | from .abc import ABCRule 9 | 10 | type PatternLike = str | typing.Pattern[str] 11 | 12 | 13 | class Regex(ABCRule): 14 | def __init__(self, regexp: PatternLike | list[PatternLike]) -> None: 15 | self.regexp: list[re.Pattern[str]] = [] 16 | match regexp: 17 | case re.Pattern() as pattern: 18 | self.regexp.append(pattern) 19 | case str(regex): 20 | self.regexp.append(re.compile(regex)) 21 | case _: 22 | self.regexp.extend(re.compile(regexp) if isinstance(regexp, str) else regexp for regexp in regexp) 23 | 24 | def check(self, text: Either[Text, Caption], ctx: Context) -> bool: 25 | for regexp in self.regexp: 26 | response = re.match(regexp, text) 27 | if response is not None: 28 | if matches := response.groupdict(): 29 | ctx |= matches 30 | else: 31 | ctx |= {"matches": response.groups() or (response.group(),)} 32 | return True 33 | return False 34 | 35 | 36 | __all__ = ("Regex",) 37 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/view/chat_member.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.bot.cute_types.chat_member_updated import ChatMemberUpdatedCute 4 | from mubble.bot.dispatch.view.base import BaseStateView 5 | from mubble.types.enums import UpdateType 6 | from mubble.types.objects import Update 7 | 8 | ChatMemberUpdateType: typing.TypeAlias = typing.Literal[ 9 | UpdateType.CHAT_MEMBER, 10 | UpdateType.MY_CHAT_MEMBER, 11 | ] 12 | 13 | 14 | class ChatMemberView(BaseStateView[ChatMemberUpdatedCute]): 15 | def __init__(self, *, update_type: ChatMemberUpdateType | None = None) -> None: 16 | super().__init__() 17 | self.update_type = update_type 18 | 19 | def __repr__(self) -> str: 20 | return "<{}: {!r}>".format( 21 | self.__class__.__name__, 22 | "chat_member_updated" if self.update_type is None else self.update_type.value, 23 | ) 24 | 25 | @classmethod 26 | def get_state_key(cls, event: ChatMemberUpdatedCute) -> int | None: 27 | return event.chat_id 28 | 29 | async def check(self, event: Update) -> bool: 30 | return not ( 31 | self.update_type is not None 32 | and self.update_type != event.update_type 33 | or not await super().check(event) 34 | ) 35 | 36 | 37 | __all__ = ("ChatMemberView",) 38 | -------------------------------------------------------------------------------- /mubble/bot/rules/start.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.bot.dispatch.context import Context 4 | from mubble.types.enums import MessageEntityType 5 | 6 | from .is_from import IsPrivate 7 | from .markup import Markup 8 | from .message import MessageRule 9 | from .message_entities import MessageEntities 10 | 11 | 12 | class StartCommand( 13 | MessageRule, 14 | requires=[ 15 | IsPrivate(), 16 | MessageEntities(MessageEntityType.BOT_COMMAND), 17 | Markup(["/start ", "/start"]), 18 | ], 19 | ): 20 | def __init__( 21 | self, 22 | validator: typing.Callable[[str], typing.Any | None] | None = None, 23 | *, 24 | param_required: bool = False, 25 | alias: str | None = None, 26 | ) -> None: 27 | self.param_required = param_required 28 | self.validator = validator 29 | self.alias = alias 30 | 31 | def check(self, ctx: Context) -> bool: 32 | param: str | None = ctx.pop("param", None) 33 | validated_param = self.validator(param) if self.validator and param is not None else param 34 | 35 | if self.param_required and validated_param is None: 36 | return False 37 | 38 | ctx.set(self.alias or "param", validated_param) 39 | return True 40 | 41 | 42 | __all__ = ("StartCommand",) 43 | -------------------------------------------------------------------------------- /mubble/bot/rules/text.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble import node 4 | from mubble.tools.i18n.abc import ABCTranslator 5 | 6 | from .abc import ABCRule, with_caching_translations 7 | from .node import NodeRule 8 | 9 | 10 | class HasText(NodeRule): 11 | def __init__(self) -> None: 12 | super().__init__(node.as_node(node.text.Text)) 13 | 14 | 15 | class HasCaption(NodeRule): 16 | def __init__(self) -> None: 17 | super().__init__(node.as_node(node.text.Caption)) 18 | 19 | 20 | class Text(ABCRule): 21 | def __init__(self, texts: str | list[str], /, *, ignore_case: bool = False) -> None: 22 | if not isinstance(texts, list): 23 | texts = [texts] 24 | self.texts = texts if not ignore_case else list(map(str.lower, texts)) 25 | self.ignore_case = ignore_case 26 | 27 | def check(self, text: node.either.Either[node.text.Text, node.text.Caption]) -> bool: 28 | return (text if not self.ignore_case else text.lower()) in self.texts 29 | 30 | @with_caching_translations 31 | async def translate(self, translator: ABCTranslator) -> typing.Self: 32 | return self.__class__( 33 | [translator.get(text) for text in self.texts], 34 | ignore_case=self.ignore_case, 35 | ) 36 | 37 | 38 | __all__ = ("HasCaption", "HasText", "Text") 39 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/handler/sticker_reply.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.api.api import API 4 | from mubble.bot.cute_types.message import MessageCute 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.bot.dispatch.handler.base import BaseReplyHandler 7 | from mubble.bot.rules.abc import ABCRule 8 | from mubble.types.objects import InputFile 9 | 10 | 11 | class StickerReplyHandler(BaseReplyHandler): 12 | def __init__( 13 | self, 14 | sticker: InputFile | str, 15 | *rules: ABCRule, 16 | emoji: str | None = None, 17 | final: bool = True, 18 | as_reply: bool = False, 19 | preset_context: Context | None = None, 20 | **default_params: typing.Any, 21 | ) -> None: 22 | self.sticker = sticker 23 | self.emoji = emoji 24 | super().__init__( 25 | *rules, 26 | final=final, 27 | as_reply=as_reply, 28 | preset_context=preset_context, 29 | **default_params, 30 | ) 31 | 32 | async def run(self, _: API, event: MessageCute, __: Context) -> typing.Any: 33 | method = event.answer_sticker if not self.as_reply else event.reply_sticker 34 | await method(sticker=self.sticker, emoji=self.emoji, **self.default_params) 35 | 36 | 37 | __all__ = ("StickerReplyHandler",) 38 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/return_manager/message.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.bot.cute_types.message import MessageCute 4 | from mubble.bot.dispatch.context import Context 5 | from mubble.bot.dispatch.return_manager.abc import BaseReturnManager, register_manager 6 | from mubble.tools.formatting import HTMLFormatter 7 | 8 | 9 | class MessageReturnManager(BaseReturnManager[MessageCute]): 10 | @register_manager(str) 11 | @staticmethod 12 | async def str_manager(value: str, event: MessageCute, ctx: Context) -> None: 13 | await event.answer(value) 14 | 15 | @register_manager(list[str] | tuple[str, ...]) 16 | @staticmethod 17 | async def seq_manager( 18 | value: list[str] | tuple[str, ...], 19 | event: MessageCute, 20 | ctx: Context, 21 | ) -> None: 22 | for message in value: 23 | await event.answer(message) 24 | 25 | @register_manager(dict[str, typing.Any]) 26 | @staticmethod 27 | async def dict_manager(value: dict[str, typing.Any], event: MessageCute, ctx: Context) -> None: 28 | await event.answer(**value) 29 | 30 | @register_manager(HTMLFormatter) 31 | @staticmethod 32 | async def htmlformatter_manager(value: HTMLFormatter, event: MessageCute, ctx: Context) -> None: 33 | await event.answer(value, parse_mode=HTMLFormatter.PARSE_MODE) 34 | 35 | 36 | __all__ = ("MessageReturnManager",) 37 | -------------------------------------------------------------------------------- /mubble/tools/callback_data_serilization/abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import typing 5 | from functools import cached_property 6 | 7 | import msgspec 8 | from fntypes.result import Result 9 | 10 | if typing.TYPE_CHECKING: 11 | from dataclasses import Field 12 | 13 | from _typeshed import DataclassInstance 14 | 15 | type ModelType = DataclassWithIdentKey | ModelWithIdentKey | msgspec.Struct | DataclassInstance 16 | 17 | 18 | @typing.runtime_checkable 19 | class DataclassWithIdentKey(typing.Protocol): 20 | __key__: str 21 | __dataclass_fields__: typing.ClassVar[dict[str, Field[typing.Any]]] 22 | 23 | 24 | @typing.runtime_checkable 25 | class ModelWithIdentKey(typing.Protocol): 26 | __key__: str 27 | __struct_fields__: typing.ClassVar[tuple[str, ...]] 28 | __struct_config__: typing.ClassVar[msgspec.structs.StructConfig] 29 | 30 | 31 | class ABCDataSerializer[Data](abc.ABC): 32 | ident_key: str | None = None 33 | 34 | @abc.abstractmethod 35 | def __init__(self, data_type: type[Data], /) -> None: 36 | pass 37 | 38 | @cached_property 39 | def key(self) -> str: 40 | return self.ident_key + "_" if self.ident_key else "" 41 | 42 | @abc.abstractmethod 43 | def serialize(self, data: Data) -> str: 44 | pass 45 | 46 | @abc.abstractmethod 47 | def deserialize(self, serialized_data: str) -> Result[Data, str]: 48 | pass 49 | 50 | 51 | __all__ = ("ABCDataSerializer",) 52 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/waiter_machine/hasher/message.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.cute_types import MessageCute as Message 2 | from mubble.bot.dispatch.view import MessageView 3 | from mubble.bot.dispatch.waiter_machine.hasher.hasher import Hasher 4 | 5 | 6 | def from_chat_hash(chat_id: int) -> int: 7 | return chat_id 8 | 9 | 10 | def get_chat_from_event(event: Message) -> int: 11 | return event.chat.id 12 | 13 | 14 | def from_user_in_chat_hash(chat_and_user: tuple[int, int]) -> str: 15 | return f"{chat_and_user[0]}_{chat_and_user[1]}" 16 | 17 | 18 | def get_user_in_chat_from_event(event: Message) -> tuple[int, int]: 19 | return event.chat.id, event.from_user.id 20 | 21 | 22 | def from_user_hash(from_id: int) -> int: 23 | return from_id 24 | 25 | 26 | def get_user_from_event(event: Message) -> int: 27 | return event.from_user.id 28 | 29 | 30 | MESSAGE_IN_CHAT = Hasher( 31 | view_class=MessageView, 32 | get_hash_from_data=from_chat_hash, 33 | get_data_from_event=get_chat_from_event, 34 | ) 35 | 36 | MESSAGE_FROM_USER = Hasher( 37 | view_class=MessageView, 38 | get_hash_from_data=from_user_hash, 39 | get_data_from_event=get_user_from_event, 40 | ) 41 | 42 | MESSAGE_FROM_USER_IN_CHAT = Hasher( 43 | view_class=MessageView, 44 | get_hash_from_data=from_user_in_chat_hash, 45 | get_data_from_event=get_user_in_chat_from_event, 46 | ) 47 | 48 | 49 | __all__ = ( 50 | "MESSAGE_FROM_USER", 51 | "MESSAGE_FROM_USER_IN_CHAT", 52 | "MESSAGE_IN_CHAT", 53 | ) 54 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/handler/audio_reply.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.api.api import API 4 | from mubble.bot.cute_types.message import MessageCute 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.bot.dispatch.handler.base import BaseReplyHandler 7 | from mubble.bot.rules.abc import ABCRule 8 | from mubble.types.objects import InputFile 9 | 10 | 11 | class AudioReplyHandler(BaseReplyHandler): 12 | def __init__( 13 | self, 14 | audio: InputFile | str, 15 | *rules: ABCRule, 16 | caption: str | None = None, 17 | parse_mode: str | None = None, 18 | final: bool = True, 19 | as_reply: bool = False, 20 | preset_context: Context | None = None, 21 | **default_params: typing.Any, 22 | ) -> None: 23 | self.audio = audio 24 | self.parse_mode = parse_mode 25 | self.caption = caption 26 | super().__init__( 27 | *rules, 28 | final=final, 29 | as_reply=as_reply, 30 | preset_context=preset_context, 31 | **default_params, 32 | ) 33 | 34 | async def run(self, _: API, event: MessageCute, __: Context) -> typing.Any: 35 | method = event.answer_audio if not self.as_reply else event.reply_audio 36 | await method( 37 | audio=self.audio, 38 | parse_mode=self.parse_mode, 39 | caption=self.caption, 40 | **self.default_params, 41 | ) 42 | 43 | 44 | __all__ = ("AudioReplyHandler",) 45 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/handler/photo_reply.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.api.api import API 4 | from mubble.bot.cute_types.message import MessageCute 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.bot.dispatch.handler.base import BaseReplyHandler 7 | from mubble.bot.rules.abc import ABCRule 8 | from mubble.types.objects import InputFile 9 | 10 | 11 | class PhotoReplyHandler(BaseReplyHandler): 12 | def __init__( 13 | self, 14 | photo: InputFile | str, 15 | *rules: ABCRule, 16 | caption: str | None = None, 17 | parse_mode: str | None = None, 18 | final: bool = True, 19 | as_reply: bool = False, 20 | preset_context: Context | None = None, 21 | **default_params: typing.Any, 22 | ) -> None: 23 | self.photo = photo 24 | self.parse_mode = parse_mode 25 | self.caption = caption 26 | super().__init__( 27 | *rules, 28 | final=final, 29 | as_reply=as_reply, 30 | preset_context=preset_context, 31 | **default_params, 32 | ) 33 | 34 | async def run(self, _: API, event: MessageCute, __: Context) -> typing.Any: 35 | method = event.answer_photo if not self.as_reply else event.reply_photo 36 | await method( 37 | photo=self.photo, 38 | parse_mode=self.parse_mode, 39 | caption=self.caption, 40 | **self.default_params, 41 | ) 42 | 43 | 44 | __all__ = ("PhotoReplyHandler",) 45 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/handler/video_reply.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.api.api import API 4 | from mubble.bot.cute_types.message import MessageCute 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.bot.dispatch.handler.base import BaseReplyHandler 7 | from mubble.bot.rules.abc import ABCRule 8 | from mubble.types.objects import InputFile 9 | 10 | 11 | class VideoReplyHandler(BaseReplyHandler): 12 | def __init__( 13 | self, 14 | video: InputFile | str, 15 | *rules: ABCRule, 16 | caption: str | None = None, 17 | parse_mode: str | None = None, 18 | final: bool = True, 19 | as_reply: bool = False, 20 | preset_context: Context | None = None, 21 | **default_params: typing.Any, 22 | ) -> None: 23 | self.video = video 24 | self.parse_mode = parse_mode 25 | self.caption = caption 26 | super().__init__( 27 | *rules, 28 | final=final, 29 | as_reply=as_reply, 30 | preset_context=preset_context, 31 | **default_params, 32 | ) 33 | 34 | async def run(self, _: API, event: MessageCute, __: Context) -> typing.Any: 35 | method = event.answer_video if not self.as_reply else event.reply_video 36 | await method( 37 | video=self.video, 38 | parse_mode=self.parse_mode, 39 | caption=self.caption, 40 | **self.default_params, 41 | ) 42 | 43 | 44 | __all__ = ("VideoReplyHandler",) 45 | -------------------------------------------------------------------------------- /mubble/tools/adapter/abc.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import dataclasses 3 | import inspect 4 | import typing 5 | 6 | from fntypes import Error, Nothing, Ok, Option, Some 7 | from fntypes.result import Result 8 | 9 | from mubble.modules import logger 10 | from mubble.tools.adapter.errors import AdapterError 11 | 12 | type AdaptResult[To] = Result[To, AdapterError] | typing.Awaitable[Result[To, AdapterError]] 13 | 14 | 15 | if typing.TYPE_CHECKING: 16 | from mubble.api.api import API 17 | from mubble.bot.dispatch.context import Context 18 | from mubble.model import Model 19 | 20 | 21 | class ABCAdapter[From: "Model", To](abc.ABC): 22 | ADAPTED_VALUE_KEY: str | None = None 23 | 24 | @abc.abstractmethod 25 | def adapt(self, api: "API", update: From, context: "Context") -> AdaptResult[To]: 26 | pass 27 | 28 | 29 | @dataclasses.dataclass(slots=True) 30 | class Event[To]: 31 | obj: To 32 | 33 | 34 | async def run_adapter[T, U: "Model"]( 35 | adapter: "ABCAdapter[U, T]", 36 | api: "API", 37 | update: U, 38 | context: "Context", 39 | ) -> Option[T]: 40 | adapt_result = adapter.adapt(api, update, context) 41 | match await adapt_result if inspect.isawaitable(adapt_result) else adapt_result: 42 | case Ok(value): 43 | return Some(value) 44 | case Error(err): 45 | logger.debug("Adapter {!r} failed with error message: {!r}", adapter, str(err)) 46 | return Nothing() 47 | 48 | 49 | __all__ = ("ABCAdapter", "AdaptResult", "Event", "run_adapter") 50 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/handler/document_reply.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.api.api import API 4 | from mubble.bot.cute_types.message import MessageCute 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.bot.dispatch.handler.base import BaseReplyHandler 7 | from mubble.bot.rules.abc import ABCRule 8 | from mubble.types.objects import InputFile 9 | 10 | 11 | class DocumentReplyHandler(BaseReplyHandler): 12 | def __init__( 13 | self, 14 | document: InputFile | str, 15 | *rules: ABCRule, 16 | caption: str | None = None, 17 | parse_mode: str | None = None, 18 | final: bool = True, 19 | as_reply: bool = False, 20 | preset_context: Context | None = None, 21 | **default_params: typing.Any, 22 | ) -> None: 23 | self.document = document 24 | self.parse_mode = parse_mode 25 | self.caption = caption 26 | super().__init__( 27 | *rules, 28 | final=final, 29 | as_reply=as_reply, 30 | preset_context=preset_context, 31 | **default_params, 32 | ) 33 | 34 | async def run(self, _: API, event: MessageCute, __: Context) -> typing.Any: 35 | method = event.answer_document if not self.as_reply else event.reply_document 36 | await method( 37 | document=self.document, 38 | parse_mode=self.parse_mode, 39 | caption=self.caption, 40 | **self.default_params, 41 | ) 42 | 43 | 44 | __all__ = ("DocumentReplyHandler",) 45 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/view/message.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.bot.cute_types.message import MessageCute 4 | from mubble.bot.dispatch.return_manager.message import MessageReturnManager 5 | from mubble.bot.dispatch.view.base import BaseStateView 6 | from mubble.types.enums import UpdateType 7 | from mubble.types.objects import Update 8 | 9 | MessageUpdateType: typing.TypeAlias = typing.Literal[ 10 | UpdateType.MESSAGE, 11 | UpdateType.BUSINESS_MESSAGE, 12 | UpdateType.CHANNEL_POST, 13 | UpdateType.EDITED_BUSINESS_MESSAGE, 14 | UpdateType.EDITED_CHANNEL_POST, 15 | UpdateType.EDITED_MESSAGE, 16 | ] 17 | 18 | 19 | class MessageView(BaseStateView[MessageCute]): 20 | def __init__(self, *, update_type: MessageUpdateType | None = None) -> None: 21 | super().__init__() 22 | self.update_type = update_type 23 | self.return_manager = MessageReturnManager() 24 | 25 | def __repr__(self) -> str: 26 | return "<{}: {!r}>".format( 27 | self.__class__.__name__, 28 | "any message update" if self.update_type is None else self.update_type.value, 29 | ) 30 | 31 | @classmethod 32 | def get_state_key(cls, event: MessageCute) -> int | None: 33 | return event.chat_id 34 | 35 | async def check(self, event: Update) -> bool: 36 | return not ( 37 | self.update_type is not None 38 | and self.update_type != event.update_type 39 | or not await super().check(event) 40 | ) 41 | 42 | 43 | __all__ = ("MessageView",) 44 | -------------------------------------------------------------------------------- /mubble/bot/rules/chat_join.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import typing 3 | 4 | from mubble.bot.cute_types import ChatJoinRequestCute 5 | from mubble.tools.adapter.event import EventAdapter 6 | from mubble.types.enums import UpdateType 7 | 8 | from .abc import ABCRule, CheckResult 9 | 10 | ChatJoinRequest: typing.TypeAlias = ChatJoinRequestCute 11 | 12 | 13 | class ChatJoinRequestRule( 14 | ABCRule[ChatJoinRequest], 15 | abc.ABC, 16 | adapter=EventAdapter(UpdateType.CHAT_JOIN_REQUEST, ChatJoinRequest), 17 | ): 18 | @abc.abstractmethod 19 | def check(self, *args: typing.Any, **kwargs: typing.Any) -> CheckResult: ... 20 | 21 | 22 | class HasInviteLink(ChatJoinRequestRule): 23 | def check(self, event: ChatJoinRequest) -> bool: 24 | return bool(event.invite_link) 25 | 26 | 27 | class InviteLinkName(ChatJoinRequestRule, requires=[HasInviteLink()]): 28 | def __init__(self, name: str, /) -> None: 29 | self.name = name 30 | 31 | def check(self, event: ChatJoinRequest) -> bool: 32 | return event.invite_link.unwrap().name.unwrap_or_none() == self.name 33 | 34 | 35 | class InviteLinkByCreator(ChatJoinRequestRule, requires=[HasInviteLink()]): 36 | def __init__(self, creator_id: int, /) -> None: 37 | self.creator_id = creator_id 38 | 39 | def check(self, event: ChatJoinRequest) -> bool: 40 | return event.invite_link.unwrap().creator.id == self.creator_id 41 | 42 | 43 | __all__ = ( 44 | "ChatJoinRequestRule", 45 | "HasInviteLink", 46 | "InviteLinkByCreator", 47 | "InviteLinkName", 48 | ) 49 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/handler/media_group_reply.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.api.api import API 4 | from mubble.bot.cute_types.message import MessageCute 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.bot.dispatch.handler.base import BaseReplyHandler 7 | from mubble.bot.rules.abc import ABCRule 8 | from mubble.types.objects import InputMedia 9 | 10 | 11 | class MediaGroupReplyHandler(BaseReplyHandler): 12 | def __init__( 13 | self, 14 | media: InputMedia | list[InputMedia], 15 | *rules: ABCRule, 16 | caption: str | list[str] | None = None, 17 | parse_mode: str | list[str] | None = None, 18 | final: bool = True, 19 | as_reply: bool = False, 20 | preset_context: Context | None = None, 21 | **default_params: typing.Any, 22 | ) -> None: 23 | self.media = media 24 | self.parse_mode = parse_mode 25 | self.caption = caption 26 | super().__init__( 27 | *rules, 28 | final=final, 29 | as_reply=as_reply, 30 | preset_context=preset_context, 31 | **default_params, 32 | ) 33 | 34 | async def run(self, _: API, event: MessageCute, __: Context) -> typing.Any: 35 | method = event.answer_media_group if not self.as_reply else event.reply_media_group 36 | await method( 37 | media=self.media, 38 | parse_mode=self.parse_mode, 39 | caption=self.caption, 40 | **self.default_params, 41 | ) 42 | 43 | 44 | __all__ = ("MediaGroupReplyHandler",) 45 | -------------------------------------------------------------------------------- /mubble/bot/rules/markup.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import vbml 4 | 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.node.either import Either 7 | from mubble.node.text import Caption, Text 8 | from mubble.tools.global_context.mubble_ctx import MubbleContext 9 | 10 | from .abc import ABCRule 11 | 12 | type PatternLike = str | vbml.Pattern 13 | global_ctx: typing.Final[MubbleContext] = MubbleContext() 14 | 15 | 16 | def check_string(patterns: list[vbml.Pattern], s: str, ctx: Context) -> bool: 17 | for pattern in patterns: 18 | match global_ctx.vbml_patcher.check(pattern, s): 19 | case None | False: 20 | continue 21 | case {**response}: 22 | ctx |= response 23 | return True 24 | return False 25 | 26 | 27 | class Markup(ABCRule): 28 | """Markup Language. See the [vbml documentation](https://github.com/tesseradecade/vbml/blob/master/docs/index.md).""" 29 | 30 | def __init__(self, patterns: PatternLike | list[PatternLike], /) -> None: 31 | if not isinstance(patterns, list): 32 | patterns = [patterns] 33 | self.patterns = [ 34 | ( 35 | vbml.Pattern(pattern, flags=global_ctx.vbml_pattern_flags) 36 | if isinstance(pattern, str) 37 | else pattern 38 | ) 39 | for pattern in patterns 40 | ] 41 | 42 | def check(self, text: Either[Text, Caption], ctx: Context) -> bool: 43 | return check_string(self.patterns, text, ctx) 44 | 45 | 46 | __all__ = ("Markup", "check_string") 47 | -------------------------------------------------------------------------------- /mubble/node/text.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.bot.cute_types.message import MessageCute 4 | from mubble.node.base import ComposeError, FactoryNode, scalar_node 5 | from mubble.node.either import Either 6 | 7 | 8 | @scalar_node 9 | class Caption: 10 | @classmethod 11 | def compose(cls, message: MessageCute) -> str: 12 | if not message.caption: 13 | raise ComposeError("Message has no caption.") 14 | return message.caption.unwrap() 15 | 16 | 17 | @scalar_node 18 | class Text: 19 | @classmethod 20 | def compose(cls, message: MessageCute) -> str: 21 | if not message.text: 22 | raise ComposeError("Message has no text.") 23 | return message.text.unwrap() 24 | 25 | 26 | @scalar_node 27 | class TextInteger: 28 | @classmethod 29 | def compose(cls, text: Either[Text, Caption]) -> int: 30 | if not text.isdigit(): 31 | raise ComposeError("Text is not digit.") 32 | return int(text) 33 | 34 | 35 | if typing.TYPE_CHECKING: 36 | from typing import Literal as TextLiteral 37 | 38 | else: 39 | 40 | class TextLiteral(FactoryNode): 41 | texts: tuple[str, ...] 42 | 43 | def __class_getitem__(cls, texts, /): 44 | return cls(texts=(texts,) if not isinstance(texts, tuple) else texts) 45 | 46 | @classmethod 47 | def compose(cls, text: Text) -> str: 48 | if text in cls.texts: 49 | return text 50 | raise ComposeError("Text mismatched literal.") 51 | 52 | 53 | __all__ = ("Caption", "Text", "TextInteger", "TextLiteral") 54 | -------------------------------------------------------------------------------- /mubble/node/file.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import mubble.types as tg_types 4 | from mubble.api.api import API 5 | from mubble.node.attachment import Animation, Audio, Document, Photo, Video, VideoNote, Voice 6 | from mubble.node.base import FactoryNode 7 | 8 | type Attachment = Animation | Audio | Document | Photo | Video | VideoNote | Voice 9 | 10 | 11 | class _FileId(FactoryNode): 12 | attachment_node: type[Attachment] 13 | 14 | def __class_getitem__(cls, attachment_node: type[Attachment], /): 15 | return cls(attachment_node=attachment_node) 16 | 17 | @classmethod 18 | def get_subnodes(cls): 19 | return {"attach": cls.attachment_node} 20 | 21 | @classmethod 22 | def compose(cls, attach: Attachment) -> str: 23 | if isinstance(attach, Photo): 24 | return attach.sizes[-1].file_id 25 | return attach.file_id 26 | 27 | 28 | class _File(FactoryNode): 29 | attachment_node: type[Attachment] 30 | 31 | def __class_getitem__(cls, attachment_node: type[Attachment], /): 32 | return cls(attachment_node=attachment_node) 33 | 34 | @classmethod 35 | def get_subnodes(cls): 36 | return {"file_id": _FileId[cls.attachment_node]} 37 | 38 | @classmethod 39 | async def compose(cls, file_id: str, api: API) -> tg_types.File: 40 | return (await api.get_file(file_id=file_id)).expect("File can't be downloaded.") 41 | 42 | 43 | if typing.TYPE_CHECKING: 44 | type FileId[T: Attachment] = str 45 | type File[T: Attachment] = tg_types.File 46 | else: 47 | FileId = _FileId 48 | File = _File 49 | 50 | 51 | __all__ = ("File", "FileId") 52 | -------------------------------------------------------------------------------- /mubble/types/input_file.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import secrets 3 | import typing 4 | 5 | from mubble.msgspec_utils import encoder 6 | 7 | type Files = dict[str, tuple[str, bytes]] 8 | 9 | 10 | class InputFile: 11 | """Object `InputFile`, see the [documentation](https://core.telegram.org/bots/api#inputfile). 12 | 13 | This object represents the contents of a file to be uploaded. Must be posted using `multipart/form-data` in the usual way that files are uploaded via the browser. 14 | """ 15 | 16 | __slots__ = ("filename", "data") 17 | 18 | filename: str 19 | """File name.""" 20 | 21 | data: bytes 22 | """Bytes of file.""" 23 | 24 | def __init__(self, filename: str, data: bytes) -> None: 25 | self.filename = filename 26 | self.data = data 27 | 28 | def __repr__(self) -> str: 29 | return "{}(filename={!r}, data={!r})".format( 30 | self.__class__.__name__, 31 | self.filename, 32 | (self.data[:30] + b"...") if len(self.data) > 30 else self.data, 33 | ) 34 | 35 | @classmethod 36 | def from_path(cls, path: str | pathlib.Path, /) -> typing.Self: 37 | path = pathlib.Path(path) 38 | return cls(path.name, path.read_bytes()) 39 | 40 | def _to_multipart(self, files: Files, /) -> str: 41 | attach_name = secrets.token_urlsafe(16) 42 | files[attach_name] = (self.filename, self.data) 43 | return f"attach://{attach_name}" 44 | 45 | 46 | @encoder.add_enc_hook(InputFile) 47 | def encode_inputfile(inputfile: InputFile, files: Files) -> str: 48 | return inputfile._to_multipart(files) 49 | 50 | 51 | __all__ = ("InputFile",) 52 | -------------------------------------------------------------------------------- /mubble/bot/cute_types/inline_query.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from fntypes.result import Result 4 | 5 | from mubble.api.api import API, APIError 6 | from mubble.bot.cute_types.base import BaseCute, compose_method_params 7 | from mubble.model import get_params 8 | from mubble.tools.magic import shortcut 9 | from mubble.types.objects import * 10 | 11 | 12 | class InlineQueryCute(BaseCute[InlineQuery], InlineQuery, kw_only=True): 13 | api: API 14 | 15 | @property 16 | def from_user(self) -> User: 17 | return self.from_ 18 | 19 | @shortcut("answer_inline_query", custom_params={"results", "inline_query_id"}) 20 | async def answer( 21 | self, 22 | results: InlineQueryResult | list[InlineQueryResult], 23 | *, 24 | button: InlineQueryResultsButton | None = None, 25 | cache_time: int | None = None, 26 | inline_query_id: str | None = None, 27 | is_personal: bool | None = None, 28 | next_offset: str | None = None, 29 | **other: typing.Any, 30 | ) -> Result[bool, APIError]: 31 | """Shortcut `API.answer_inline_query()`, see the [documentation](https://core.telegram.org/bots/api#answerinlinequery) 32 | 33 | Use this method to send answers to an inline query. On success, True is returned. 34 | No more than 50 results per query are allowed.""" 35 | params = compose_method_params( 36 | get_params(locals()), 37 | self, 38 | default_params={("inline_query_id", "id")}, 39 | ) 40 | params["results"] = [results] if not isinstance(results, list) else results 41 | return await self.ctx_api.answer_inline_query(**params) 42 | 43 | 44 | __all__ = ("InlineQueryCute",) 45 | -------------------------------------------------------------------------------- /mubble/tools/adapter/node.py: -------------------------------------------------------------------------------- 1 | import typing_extensions as typing 2 | from fntypes.result import Error, Ok, Result 3 | 4 | from mubble.api.api import API 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.msgspec_utils import repr_type 7 | from mubble.node.composer import NodeSession, compose_nodes 8 | from mubble.tools.adapter.abc import ABCAdapter, Event 9 | from mubble.tools.adapter.errors import AdapterError 10 | from mubble.types.objects import Update 11 | 12 | if typing.TYPE_CHECKING: 13 | from mubble.node.base import IsNode 14 | 15 | 16 | class NodeAdapter(ABCAdapter[Update, Event[tuple["IsNode", ...]]]): 17 | def __init__(self, *nodes: "IsNode") -> None: 18 | self.nodes = nodes 19 | 20 | def __repr__(self) -> str: 21 | return "<{}: adapt Update -> ({})>".format( 22 | self.__class__.__name__, 23 | ", ".join(repr_type(node) for node in self.nodes), 24 | ) 25 | 26 | async def adapt( 27 | self, 28 | api: API, 29 | update: Update, 30 | context: Context, 31 | ) -> Result[Event[tuple[NodeSession, ...]], AdapterError]: 32 | result = await compose_nodes( 33 | nodes={f"node_{i}": typing.cast("IsNode", node) for i, node in enumerate(self.nodes)}, 34 | ctx=context, 35 | data={Update: update, API: api}, 36 | ) 37 | 38 | match result: 39 | case Ok(collection): 40 | sessions: list[NodeSession] = list(collection.sessions.values()) 41 | return Ok(Event(tuple(sessions))) 42 | case Error(err): 43 | return Error(AdapterError(f"Couldn't compose nodes, error: {err}.")) 44 | 45 | 46 | __all__ = ("NodeAdapter",) 47 | -------------------------------------------------------------------------------- /mubble/tools/i18n/simple.py: -------------------------------------------------------------------------------- 1 | """This is an implementation of GNU gettext (pyBabel).""" 2 | 3 | import gettext 4 | import os 5 | 6 | from mubble.tools.i18n.abc import ABCI18n, ABCTranslator 7 | 8 | 9 | class SimpleTranslator(ABCTranslator): 10 | def __init__(self, locale: str, g: gettext.GNUTranslations) -> None: 11 | self.g = g 12 | super().__init__(locale) 13 | 14 | def get(self, __key: str, *args: object, **kwargs: object) -> str: 15 | return self.g.gettext(__key).format(*args, **kwargs) 16 | 17 | 18 | class SimpleI18n(ABCI18n): 19 | def __init__(self, folder: str, domain: str, default_locale: str) -> None: 20 | self.folder = folder 21 | self.domain = domain 22 | self.default_locale = default_locale 23 | self.translators = self._load_translators() 24 | 25 | def _load_translators(self) -> dict[str, gettext.GNUTranslations]: 26 | result = {} 27 | for name in os.listdir(self.folder): 28 | if not os.path.isdir(os.path.join(self.folder, name)): 29 | continue 30 | 31 | mo_path = os.path.join(self.folder, name, "LC_MESSAGES", f"{self.domain}.mo") 32 | if os.path.exists(mo_path): 33 | with open(mo_path, "rb") as f: 34 | result[name] = gettext.GNUTranslations(f) 35 | elif os.path.exists(mo_path[:-2] + "po"): 36 | raise FileNotFoundError(".po files should be compiled first") 37 | return result 38 | 39 | def get_translator_by_locale(self, locale: str) -> "SimpleTranslator": 40 | return SimpleTranslator(locale, self.translators.get(locale, self.translators[self.default_locale])) 41 | 42 | 43 | __all__ = ("SimpleI18n", "SimpleTranslator") 44 | -------------------------------------------------------------------------------- /mubble/bot/cute_types/pre_checkout_query.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from fntypes.result import Result 4 | 5 | from mubble.api.api import API 6 | from mubble.api.error import APIError 7 | from mubble.bot.cute_types.base import BaseCute, compose_method_params 8 | from mubble.model import get_params 9 | from mubble.tools.magic import shortcut 10 | from mubble.types.objects import PreCheckoutQuery, User 11 | 12 | 13 | class PreCheckoutQueryCute(BaseCute[PreCheckoutQuery], PreCheckoutQuery, kw_only=True): 14 | api: API 15 | 16 | @property 17 | def from_user(self) -> User: 18 | return self.from_ 19 | 20 | @shortcut("answer_pre_checkout_query", custom_params={"pre_checkout_query_id"}) 21 | async def answer( 22 | self, 23 | ok: bool, 24 | *, 25 | error_message: str | None = None, 26 | pre_checkout_query_id: str | None = None, 27 | **other: typing.Any, 28 | ) -> Result[bool, APIError]: 29 | """Shortcut `API.answer_pre_checkout_query()`, see the [documentation](https://core.telegram.org/bots/api#answerprecheckoutquery) 30 | 31 | Once the user has confirmed their payment and shipping details, the Bot 32 | API sends the final confirmation in the form of an Update with the field pre_checkout_query. 33 | Use this method to respond to such pre-checkout queries. On success, True 34 | is returned. Note: The Bot API must receive an answer within 10 seconds after 35 | the pre-checkout query was sent.""" 36 | params = compose_method_params( 37 | get_params(locals()), self, default_params={("pre_checkout_query_id", "id")} 38 | ) 39 | return await self.ctx_api.answer_pre_checkout_query(**params) 40 | 41 | 42 | __all__ = ("PreCheckoutQueryCute",) 43 | -------------------------------------------------------------------------------- /mubble/node/callback_query.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from fntypes.result import Error, Ok 4 | 5 | from mubble.bot.cute_types.callback_query import CallbackQueryCute 6 | from mubble.msgspec_utils import msgspec_convert 7 | from mubble.node.base import ComposeError, FactoryNode, Name, scalar_node 8 | 9 | 10 | @scalar_node 11 | class CallbackQueryData: 12 | @classmethod 13 | def compose(cls, callback_query: CallbackQueryCute) -> str: 14 | return callback_query.data.expect(ComposeError("Cannot complete decode callback query data.")) 15 | 16 | 17 | @scalar_node 18 | class CallbackQueryDataJson: 19 | @classmethod 20 | def compose(cls, callback_query: CallbackQueryCute) -> dict: 21 | return callback_query.decode_data().expect( 22 | ComposeError("Cannot complete decode callback query data."), 23 | ) 24 | 25 | 26 | class _Field(FactoryNode): 27 | field_type: type[typing.Any] 28 | 29 | def __class_getitem__(cls, field_type: type[typing.Any], /) -> typing.Self: 30 | return cls(field_type=field_type) 31 | 32 | @classmethod 33 | def compose(cls, callback_query_data: CallbackQueryDataJson, data_name: Name) -> typing.Any: 34 | if data := callback_query_data.get(data_name): 35 | match msgspec_convert(data, cls.field_type): 36 | case Ok(value): 37 | return value 38 | case Error(err): 39 | raise ComposeError(err) 40 | 41 | raise ComposeError(f"Cannot find callback data with name {data_name!r}.") 42 | 43 | 44 | if typing.TYPE_CHECKING: 45 | type Field[FieldType] = typing.Annotated[FieldType, ...] 46 | else: 47 | Field = _Field 48 | 49 | 50 | __all__ = ( 51 | "CallbackQueryData", 52 | "CallbackQueryDataJson", 53 | "Field", 54 | ) 55 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | This guide will help you install Mubble and set up your development environment. 4 | 5 | ## Requirements 6 | 7 | - Python 3.8 or higher 8 | - pip or poetry package manager 9 | 10 | ## Installing with pip 11 | 12 | The simplest way to install Mubble is using pip: 13 | 14 | ```bash 15 | pip install mubble 16 | ``` 17 | 18 | To install a specific version: 19 | 20 | ```bash 21 | pip install mubble==1.6.0 22 | ``` 23 | 24 | ## Installing with Poetry 25 | 26 | If you're using Poetry for dependency management: 27 | 28 | ```bash 29 | poetry add mubble 30 | ``` 31 | 32 | ## Installing from Source 33 | 34 | You can also install the latest development version directly from GitHub: 35 | 36 | ```bash 37 | pip install git+https://github.com/vladislavkovalskyi/mubble.git#master 38 | ``` 39 | 40 | Or with Poetry: 41 | 42 | ```bash 43 | poetry add git+https://github.com/vladislavkovalskyi/mubble.git#master 44 | ``` 45 | 46 | ## Verifying Installation 47 | 48 | To verify that Mubble has been installed correctly, you can run a simple Python script: 49 | 50 | ```python 51 | import mubble 52 | 53 | print(f"Mubble installed successfully!") 54 | ``` 55 | 56 | ## Optional Dependencies 57 | 58 | Mubble works out of the box with its default dependencies. However, you might want to install additional packages for specific features: 59 | 60 | - `aiosonic`: For an alternative HTTP client with potentially better performance 61 | - `msgpack`: For efficient serialization of callback data 62 | 63 | Install these with: 64 | 65 | ```bash 66 | pip install mubble[full] 67 | ``` 68 | 69 | Or with Poetry: 70 | 71 | ```bash 72 | poetry add mubble[full] 73 | ``` 74 | 75 | ## Next Steps 76 | 77 | Now that you have Mubble installed, check out the [Quick Start](quickstart.md) guide to create your first bot. -------------------------------------------------------------------------------- /mubble/bot/cute_types/utils.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.model import get_params 4 | from mubble.types.objects import ( 5 | InputFile, 6 | InputMediaAnimation, 7 | InputMediaAudio, 8 | InputMediaDocument, 9 | InputMediaPhoto, 10 | InputMediaVideo, 11 | MessageEntity, 12 | ReactionEmoji, 13 | ReactionType, 14 | ReactionTypeEmoji, 15 | ) 16 | 17 | type InputMedia = typing.Union[ 18 | InputMediaAnimation, 19 | InputMediaAudio, 20 | InputMediaDocument, 21 | InputMediaPhoto, 22 | InputMediaVideo, 23 | ] 24 | 25 | INPUT_MEDIA_TYPES: typing.Final[dict[str, type[InputMedia]]] = { 26 | "animation": InputMediaAnimation, 27 | "audio": InputMediaAudio, 28 | "document": InputMediaDocument, 29 | "photo": InputMediaPhoto, 30 | "video": InputMediaVideo, 31 | } 32 | 33 | 34 | def compose_reactions( 35 | reactions: str | ReactionEmoji | ReactionType | list[str | ReactionEmoji | ReactionType], 36 | /, 37 | ) -> list[ReactionType]: 38 | if not isinstance(reactions, list): 39 | reactions = [reactions] 40 | return [ 41 | ( 42 | ReactionTypeEmoji(emoji) 43 | if isinstance(emoji, ReactionEmoji) 44 | else (ReactionTypeEmoji(ReactionEmoji(emoji)) if isinstance(emoji, str) else emoji) 45 | ) 46 | for emoji in reactions 47 | ] 48 | 49 | 50 | def input_media( 51 | type: typing.Literal["animation", "audio", "document", "photo", "video"], 52 | media: str | InputFile, 53 | *, 54 | caption: str | None = None, 55 | parse_mode: str | None = None, 56 | caption_entities: list[MessageEntity] | None = None, 57 | **other: typing.Any, 58 | ) -> InputMedia: 59 | return INPUT_MEDIA_TYPES[type](**get_params(locals())) 60 | 61 | 62 | __all__ = ("compose_reactions", "input_media") 63 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/waiter_machine/hasher/callback.py: -------------------------------------------------------------------------------- 1 | from fntypes.option import Some 2 | 3 | from mubble.bot.cute_types import CallbackQueryCute as CallbackQuery 4 | from mubble.bot.dispatch.view import CallbackQueryView 5 | from mubble.bot.dispatch.waiter_machine.hasher.hasher import Hasher 6 | 7 | 8 | def from_chat_hash(chat_id: int) -> int: 9 | return chat_id 10 | 11 | 12 | def get_chat_from_event(event: CallbackQuery) -> int | None: 13 | return event.chat.and_then(lambda chat: Some(chat.id)).unwrap_or_none() 14 | 15 | 16 | def for_message_hash(message_id: int) -> int: 17 | return message_id 18 | 19 | 20 | def get_message_for_event(event: CallbackQuery) -> int | None: 21 | return event.message_id.unwrap_or_none() 22 | 23 | 24 | def for_message_in_chat(chat_and_message: tuple[int, int]) -> str: 25 | return f"{chat_and_message[0]}_{chat_and_message[1]}" 26 | 27 | 28 | def get_chat_and_message_for_event(event: CallbackQuery) -> tuple[int, int] | None: 29 | if not event.message_id or not event.chat: 30 | return None 31 | return event.chat.unwrap().id, event.message_id.unwrap() 32 | 33 | 34 | CALLBACK_QUERY_FROM_CHAT = Hasher( 35 | view_class=CallbackQueryView, 36 | get_hash_from_data=from_chat_hash, 37 | get_data_from_event=get_chat_from_event, 38 | ) 39 | 40 | CALLBACK_QUERY_FOR_MESSAGE = Hasher( 41 | view_class=CallbackQueryView, 42 | get_hash_from_data=for_message_hash, 43 | get_data_from_event=get_message_for_event, 44 | ) 45 | 46 | CALLBACK_QUERY_IN_CHAT_FOR_MESSAGE = Hasher( 47 | view_class=CallbackQueryView, 48 | get_hash_from_data=for_message_in_chat, 49 | get_data_from_event=get_chat_and_message_for_event, 50 | ) 51 | 52 | 53 | __all__ = ( 54 | "CALLBACK_QUERY_FOR_MESSAGE", 55 | "CALLBACK_QUERY_FROM_CHAT", 56 | "CALLBACK_QUERY_IN_CHAT_FOR_MESSAGE", 57 | ) 58 | -------------------------------------------------------------------------------- /mubble/bot/scenario/choice.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.bot.cute_types.callback_query import CallbackQueryCute 4 | from mubble.bot.dispatch.waiter_machine.hasher.hasher import Hasher 5 | from mubble.bot.scenario.checkbox import Checkbox, ChoiceAction 6 | 7 | if typing.TYPE_CHECKING: 8 | from mubble.api.api import API 9 | 10 | class Choice[Key: typing.Hashable](Checkbox[Key]): 11 | async def wait( 12 | self, 13 | hasher: Hasher[CallbackQueryCute, int], 14 | api: API, 15 | ) -> tuple[Key, int]: ... 16 | 17 | else: 18 | 19 | class Choice(Checkbox): 20 | async def handle(self, cb): 21 | code = cb.data.unwrap().replace(self.random_code + "/", "", 1) 22 | if code == ChoiceAction.READY: 23 | return False 24 | 25 | for choice in self.choices: 26 | choice.is_picked = False 27 | 28 | for i, choice in enumerate(self.choices): 29 | if choice.code == code: 30 | self.choices[i].is_picked = True 31 | await cb.ctx_api.edit_message_text( 32 | text=self.message, 33 | chat_id=cb.message.unwrap().v.chat.id, 34 | message_id=cb.message.unwrap().v.message_id, 35 | parse_mode=self.PARSE_MODE, 36 | reply_markup=self.get_markup(), 37 | ) 38 | 39 | return True 40 | 41 | async def wait(self, hasher, api): 42 | if len(tuple(choice for choice in self.choices if choice.is_picked)) != 1: 43 | raise ValueError("Exactly one choice must be picked.") 44 | choices, m_id = await super().wait(hasher, api) 45 | return tuple(choices.keys())[tuple(choices.values()).index(True)], m_id 46 | 47 | 48 | __all__ = ("Choice",) 49 | -------------------------------------------------------------------------------- /mubble/tools/formatting/spec_html_formats.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | from mubble.types.enums import ProgrammingLanguage 5 | 6 | SpecialFormat: typing.TypeAlias = typing.Union[ 7 | "BlockQuote", 8 | "Link", 9 | "Mention", 10 | "PreCode", 11 | "TgEmoji", 12 | ] 13 | 14 | 15 | def is_spec_format(obj: typing.Any) -> typing.TypeGuard[SpecialFormat]: 16 | return dataclasses.is_dataclass(obj) and hasattr(obj, "__formatter_name__") and isinstance(obj, Base) 17 | 18 | 19 | @dataclasses.dataclass(repr=False) 20 | class Base: 21 | __formatter_name__: typing.ClassVar[str] = dataclasses.field(init=False, repr=False) 22 | 23 | def __repr__(self) -> str: 24 | return f" {self.__formatter_name__!r}>" 25 | 26 | 27 | @dataclasses.dataclass(repr=False, slots=True) 28 | class Mention(Base): 29 | __formatter_name__ = "mention" 30 | 31 | string: str 32 | user_id: int 33 | 34 | 35 | @dataclasses.dataclass(repr=False, slots=True) 36 | class Link(Base): 37 | __formatter_name__ = "link" 38 | 39 | href: str 40 | string: str | None = None 41 | 42 | 43 | @dataclasses.dataclass(repr=False, slots=True) 44 | class PreCode(Base): 45 | __formatter_name__ = "pre_code" 46 | 47 | string: str 48 | lang: str | ProgrammingLanguage | None = None 49 | 50 | 51 | @dataclasses.dataclass(repr=False, slots=True) 52 | class TgEmoji(Base): 53 | __formatter_name__ = "tg_emoji" 54 | 55 | string: str 56 | emoji_id: int 57 | 58 | 59 | @dataclasses.dataclass(repr=False, slots=True) 60 | class BlockQuote(Base): 61 | __formatter_name__ = "block_quote" 62 | 63 | string: str 64 | expandable: bool = False 65 | 66 | 67 | __all__ = ( 68 | "Base", 69 | "BlockQuote", 70 | "Link", 71 | "Mention", 72 | "PreCode", 73 | "SpecialFormat", 74 | "TgEmoji", 75 | ) 76 | -------------------------------------------------------------------------------- /mubble/tools/global_context/abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | from abc import ABC, abstractmethod 5 | 6 | import typing_extensions as typing 7 | 8 | T = typing.TypeVar("T", default=typing.Any) 9 | 10 | 11 | @dataclasses.dataclass(repr=False, frozen=True) 12 | class CtxVar(typing.Generic[T]): 13 | value: T 14 | const: bool = dataclasses.field(default=False, kw_only=True) 15 | 16 | def __repr__(self) -> str: 17 | return "<{}(value={!r})>".format( 18 | ("Const" if self.const else "") + CtxVar.__name__, 19 | self.value, 20 | ) 21 | 22 | 23 | @dataclasses.dataclass(repr=False, frozen=True) 24 | class GlobalCtxVar(typing.Generic[T]): 25 | name: str 26 | value: T 27 | const: bool = dataclasses.field(default=False, kw_only=True) 28 | 29 | def __repr__(self) -> str: 30 | return "<{}({}={})>".format( 31 | self.__class__.__name__, 32 | self.name, 33 | repr(CtxVar(self.value, const=self.const)), 34 | ) 35 | 36 | @classmethod 37 | def collect(cls, name: str, ctx_value: T | CtxVariable[T]) -> typing.Self: 38 | ctx_value = CtxVar(ctx_value) if not isinstance(ctx_value, CtxVar | GlobalCtxVar) else ctx_value 39 | params = ctx_value.__dict__ 40 | params["name"] = name 41 | return cls(**params) 42 | 43 | 44 | class ABCGlobalContext(ABC, typing.Generic[T]): 45 | @abstractmethod 46 | def __getattr__(self, __name: str) -> typing.Any: 47 | pass 48 | 49 | @abstractmethod 50 | def __setattr__(self, __name: str, __value: T | CtxVariable[T]) -> None: 51 | pass 52 | 53 | @abstractmethod 54 | def __delattr__(self, __name: str) -> None: 55 | pass 56 | 57 | 58 | CtxVariable = CtxVar[T] | GlobalCtxVar[T] 59 | 60 | 61 | __all__ = ( 62 | "ABCGlobalContext", 63 | "CtxVar", 64 | "CtxVariable", 65 | "GlobalCtxVar", 66 | ) 67 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/handler/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import typing 3 | 4 | from fntypes.result import Result 5 | 6 | from mubble.api.api import API 7 | from mubble.api.error import APIError 8 | from mubble.bot.cute_types.message import MessageCute 9 | from mubble.bot.dispatch.context import Context 10 | from mubble.bot.dispatch.handler.abc import ABCHandler 11 | from mubble.bot.dispatch.process import check_rule 12 | from mubble.bot.rules.abc import ABCRule 13 | from mubble.modules import logger 14 | from mubble.types.objects import Update 15 | 16 | type APIMethod = typing.Callable[ 17 | typing.Concatenate[MessageCute, ...], typing.Awaitable[Result[typing.Any, APIError]] 18 | ] 19 | 20 | 21 | class BaseReplyHandler(ABCHandler[MessageCute], abc.ABC): 22 | def __init__( 23 | self, 24 | *rules: ABCRule, 25 | final: bool = True, 26 | as_reply: bool = False, 27 | preset_context: Context | None = None, 28 | **default_params: typing.Any, 29 | ) -> None: 30 | self.rules = list(rules) 31 | self.as_reply = as_reply 32 | self.final = final 33 | self.default_params = default_params 34 | self.preset_context = preset_context or Context() 35 | 36 | def __repr__(self) -> str: 37 | return f"<{self.__class__.__qualname__}>" 38 | 39 | async def check(self, api: API, event: Update, ctx: Context | None = None) -> bool: 40 | ctx = Context(raw_update=event) if ctx is None else ctx 41 | temp_ctx = ctx.copy() 42 | temp_ctx |= self.preset_context 43 | 44 | for rule in self.rules: 45 | if not await check_rule(api, rule, event, ctx): 46 | logger.debug("Rule {!r} failed!", rule) 47 | return False 48 | 49 | ctx |= temp_ctx 50 | return True 51 | 52 | @abc.abstractmethod 53 | async def run(self, api: API, event: MessageCute, ctx: Context) -> typing.Any: 54 | pass 55 | 56 | 57 | __all__ = ("BaseReplyHandler",) 58 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/waiter_machine/short_state.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import dataclasses 3 | import datetime 4 | import typing 5 | 6 | from mubble.bot.cute_types import BaseCute 7 | from mubble.bot.dispatch.context import Context 8 | from mubble.bot.rules.abc import ABCRule 9 | from mubble.tools.magic import cancel_future 10 | 11 | if typing.TYPE_CHECKING: 12 | from .actions import WaiterActions 13 | 14 | 15 | class ShortStateContext[Event: BaseCute](typing.NamedTuple): 16 | event: Event 17 | context: Context 18 | 19 | 20 | @dataclasses.dataclass(slots=True) 21 | class ShortState[Event: BaseCute]: 22 | event: asyncio.Event 23 | actions: "WaiterActions[Event]" 24 | 25 | release: ABCRule | None = dataclasses.field( 26 | default=None, 27 | kw_only=True, 28 | ) 29 | filter: ABCRule | None = dataclasses.field( 30 | default=None, 31 | kw_only=True, 32 | ) 33 | 34 | lifetime: dataclasses.InitVar[datetime.timedelta | None] = dataclasses.field( 35 | default=None, 36 | kw_only=True, 37 | ) 38 | 39 | expiration_date: datetime.datetime | None = dataclasses.field(init=False, kw_only=True) 40 | creation_date: datetime.datetime = dataclasses.field(init=False) 41 | context: ShortStateContext[Event] | None = dataclasses.field(default=None, init=False, kw_only=True) 42 | 43 | def __post_init__(self, expiration: datetime.timedelta | None = None) -> None: 44 | self.creation_date = datetime.datetime.now() 45 | self.expiration_date = (self.creation_date + expiration) if expiration is not None else None 46 | 47 | async def cancel(self) -> None: 48 | """Cancel schedule waiters.""" 49 | waiters = typing.cast( 50 | typing.Iterable[asyncio.Future[typing.Any]], 51 | self.event._waiters, # type: ignore 52 | ) 53 | for future in waiters: 54 | await cancel_future(future) 55 | 56 | 57 | __all__ = ("ShortState", "ShortStateContext") 58 | -------------------------------------------------------------------------------- /mubble/node/event.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | import msgspec 5 | 6 | from mubble.api.api import API 7 | from mubble.bot.cute_types import BaseCute 8 | from mubble.msgspec_utils import decoder 9 | from mubble.node.base import ComposeError, FactoryNode 10 | from mubble.types.objects import Update 11 | 12 | if typing.TYPE_CHECKING: 13 | from _typeshed import DataclassInstance 14 | 15 | type DataclassType = DataclassInstance | msgspec.Struct | dict[str, typing.Any] 16 | 17 | 18 | class _EventNode(FactoryNode): 19 | dataclass: type[DataclassType] 20 | orig_dataclass: type[DataclassType] 21 | 22 | def __class_getitem__(cls, dataclass: type[DataclassType], /) -> typing.Self: 23 | return cls(dataclass=dataclass, orig_dataclass=typing.get_origin(dataclass) or dataclass) 24 | 25 | @classmethod 26 | def compose(cls, raw_update: Update, api: API) -> DataclassType: 27 | try: 28 | if issubclass(cls.orig_dataclass, BaseCute): 29 | update = raw_update if issubclass(cls.orig_dataclass, Update) else raw_update.incoming_update 30 | dataclass = cls.orig_dataclass.from_update(update=update, bound_api=api) 31 | 32 | elif issubclass(cls.orig_dataclass, msgspec.Struct) or dataclasses.is_dataclass( 33 | cls.orig_dataclass, 34 | ): 35 | dataclass = decoder.convert( 36 | obj=raw_update.incoming_update, 37 | type=cls.dataclass, 38 | from_attributes=True, 39 | ) 40 | else: 41 | dataclass = cls.dataclass(**raw_update.incoming_update.to_full_dict()) 42 | 43 | return dataclass 44 | except Exception as exc: 45 | raise ComposeError(f"Cannot parse an update object into {cls.dataclass!r}, error: {str(exc)}") 46 | 47 | 48 | if typing.TYPE_CHECKING: 49 | type EventNode[Dataclass: DataclassType] = Dataclass 50 | else: 51 | EventNode = _EventNode 52 | 53 | 54 | __all__ = ("EventNode",) 55 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/waiter_machine/hasher/hasher.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from functools import cached_property 3 | 4 | from fntypes.option import Option 5 | 6 | from mubble.bot.cute_types import BaseCute 7 | from mubble.bot.dispatch.view.base import BaseView 8 | from mubble.tools.functional import from_optional 9 | 10 | Event = typing.TypeVar("Event", bound=BaseCute, covariant=True) 11 | Data = typing.TypeVar("Data", covariant=True) 12 | 13 | 14 | def _echo[T](__x: T) -> T: 15 | return __x 16 | 17 | 18 | ECHO = _echo 19 | 20 | 21 | class Hasher(typing.Generic[Event, Data]): 22 | def __init__( 23 | self, 24 | view_class: type[BaseView[Event]], 25 | get_hash_from_data: typing.Callable[[Data], typing.Hashable | None] | None = None, 26 | get_data_from_event: typing.Callable[[Event], Data | None] | None = None, 27 | ) -> None: 28 | self.view_class = view_class 29 | self._get_hash_from_data = get_hash_from_data 30 | self._get_data_from_event = get_data_from_event 31 | 32 | def __hash__(self) -> int: 33 | return hash(self.name) 34 | 35 | def __repr__(self) -> str: 36 | return f"" 37 | 38 | @cached_property 39 | def name(self) -> str: 40 | return f"{self.view_class.__name__}_{id(self)}" 41 | 42 | def get_hash_from_data[D](self: "Hasher[Event, D]", data: D) -> Option[typing.Hashable]: 43 | if self._get_hash_from_data is None: 44 | raise NotImplementedError 45 | return from_optional(self._get_hash_from_data(data)) 46 | 47 | def get_data_from_event[E: BaseCute](self: "Hasher[E, Data]", event: E) -> Option[Data]: 48 | if not self._get_data_from_event: 49 | raise NotImplementedError 50 | return from_optional(self._get_data_from_event(event)) 51 | 52 | def get_hash_from_data_from_event[E: BaseCute]( 53 | self: "Hasher[E, Data]", 54 | event: E, 55 | ) -> Option[typing.Hashable]: 56 | return self.get_data_from_event(event).and_then(self.get_hash_from_data) # type: ignore 57 | 58 | 59 | __all__ = ("Hasher",) 60 | -------------------------------------------------------------------------------- /mubble/bot/rules/inline.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import typing 3 | 4 | from mubble.bot.cute_types import InlineQueryCute 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.bot.rules.abc import ABCRule, CheckResult 7 | from mubble.tools.adapter.event import EventAdapter 8 | from mubble.types.enums import ChatType, UpdateType 9 | 10 | from .markup import Markup, PatternLike, check_string 11 | 12 | InlineQuery: typing.TypeAlias = InlineQueryCute 13 | 14 | 15 | class InlineQueryRule( 16 | ABCRule[InlineQuery], 17 | abc.ABC, 18 | adapter=EventAdapter(UpdateType.INLINE_QUERY, InlineQuery), 19 | ): 20 | @abc.abstractmethod 21 | def check(self, *args: typing.Any, **kwargs: typing.Any) -> CheckResult: ... 22 | 23 | 24 | class HasLocation(InlineQueryRule): 25 | def check(self, query: InlineQuery) -> bool: 26 | return bool(query.location) 27 | 28 | 29 | class InlineQueryChatType(InlineQueryRule): 30 | def __init__(self, chat_type: ChatType, /) -> None: 31 | self.chat_type = chat_type 32 | 33 | def check(self, query: InlineQuery) -> bool: 34 | return query.chat_type.map(lambda x: x == self.chat_type).unwrap_or(False) 35 | 36 | 37 | class InlineQueryText(InlineQueryRule): 38 | def __init__(self, texts: str | list[str], *, lower_case: bool = False) -> None: 39 | self.texts = [ 40 | text.lower() if lower_case else text for text in ([texts] if isinstance(texts, str) else texts) 41 | ] 42 | self.lower_case = lower_case 43 | 44 | def check(self, query: InlineQuery) -> bool: 45 | return (query.query.lower() if self.lower_case else query.query) in self.texts 46 | 47 | 48 | class InlineQueryMarkup(InlineQueryRule): 49 | def __init__(self, patterns: PatternLike | list[PatternLike], /) -> None: 50 | self.patterns = Markup(patterns).patterns 51 | 52 | def check(self, query: InlineQuery, ctx: Context) -> bool: 53 | return check_string(self.patterns, query.query, ctx) 54 | 55 | 56 | __all__ = ( 57 | "HasLocation", 58 | "InlineQueryChatType", 59 | "InlineQueryMarkup", 60 | "InlineQueryRule", 61 | "InlineQueryText", 62 | ) 63 | -------------------------------------------------------------------------------- /mubble/tools/callback_data_serilization/json_ser.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import msgspec 4 | from fntypes.result import Error, Ok, Result 5 | 6 | from mubble.modules import json 7 | from mubble.msgspec_utils import decoder 8 | 9 | from .abc import ABCDataSerializer, ModelType 10 | 11 | type Json = dict[str, typing.Any] | ModelType 12 | 13 | 14 | class JSONSerializer[JsonT: Json](ABCDataSerializer[JsonT]): 15 | @typing.overload 16 | def __init__(self, model_t: type[JsonT]) -> None: ... 17 | 18 | @typing.overload 19 | def __init__(self, model_t: type[JsonT], *, ident_key: str | None = ...) -> None: ... 20 | 21 | def __init__( 22 | self, 23 | model_t: type[JsonT] = dict[str, typing.Any], 24 | *, 25 | ident_key: str | None = None, 26 | ) -> None: 27 | self.model_t = model_t 28 | self.ident_key: str | None = ident_key or getattr(model_t, "__key__", None) 29 | 30 | @classmethod 31 | def serialize_from_json(cls, data: JsonT, *, ident_key: str | None = None) -> str: 32 | return cls(data.__class__, ident_key=ident_key).serialize(data) 33 | 34 | @classmethod 35 | def deserialize_to_json(cls, serialized_data: str, model_t: type[JsonT]) -> Result[JsonT, str]: 36 | return cls(model_t).deserialize(serialized_data) 37 | 38 | def serialize(self, data: JsonT) -> str: 39 | return self.key + json.dumps(data) 40 | 41 | def deserialize(self, serialized_data: str) -> Result[JsonT, str]: 42 | if self.ident_key and not serialized_data.startswith(self.key): 43 | return Error("Data is not corresponding to key.") 44 | 45 | data = serialized_data.removeprefix(self.key) 46 | try: 47 | data_obj = json.loads(data) 48 | except (msgspec.ValidationError, msgspec.DecodeError): 49 | return Error("Cannot decode json.") 50 | 51 | if not issubclass(self.model_t, dict): 52 | try: 53 | return Ok(decoder.convert(data_obj, type=self.model_t)) 54 | except (msgspec.ValidationError, msgspec.DecodeError): 55 | return Error("Incorrect data.") 56 | 57 | return Ok(data_obj) 58 | 59 | 60 | __all__ = ("JSONSerializer",) 61 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Mubble Documentation (AUTO-GENERATED) 2 | 3 | Welcome to the Mubble documentation! Mubble is a modern, type-safe framework for building Telegram bots in Python. 4 | 5 | ## Getting Started 6 | 7 | - [Installation](installation.md) - How to install Mubble 8 | - [Quickstart](quickstart.md) - Create your first bot with Mubble 9 | - [Basic Concepts](basic-concepts.md) - Learn the basic concepts of Mubble 10 | 11 | ## Core Components 12 | 13 | - [Bot Structure](bot-structure.md) - Understanding the structure of a Mubble bot 14 | - [API Client](api-client.md) - Working with the Telegram Bot API 15 | - [Dispatching](dispatching.md) - How updates are dispatched to handlers 16 | - [Handlers](handlers.md) - Creating handlers for different types of updates 17 | - [Rules](rules.md) - Filtering updates with rules 18 | - [Types](types.md) - Type definitions for Telegram objects 19 | 20 | ## Features 21 | 22 | - [State Management](state-management.md) - Managing state in your bot 23 | - [Middleware](middleware.md) - Using middleware for pre-processing updates 24 | - [Error Handling](error-handling.md) - Handling errors in your bot 25 | - [Callback Queries](callback-queries.md) - Working with callback queries 26 | - [Inline Keyboards](inline-keyboards.md) - Creating inline keyboards 27 | - [Keyboards](keyboards.md) - Working with reply keyboards 28 | - [Formatting](formatting.md) - Formatting messages 29 | - [Internationalization (i18n)](i18n.md) - Making your bot multilingual 30 | - [Webhooks](webhooks.md) - Using webhooks instead of polling 31 | - [Global Context](global-context.md) - Sharing data across updates 32 | - [Loop Wrapper](loop-wrapper.md) - Managing the asyncio event loop 33 | 34 | ## Contributing 35 | 36 | - [Contributing](contributing.md) - How to contribute to Mubble 37 | - [License](license.md) - Mubble's license information 38 | 39 | ## Examples 40 | 41 | Check out the `examples` directory for complete examples of Mubble bots. 42 | 43 | ## Support 44 | 45 | If you need help with Mubble, you can: 46 | 47 | 1. Open an issue on [GitHub](https://github.com/vladislavkovalskyi/mubble/issues) 48 | 2. Ask a question in the [GitHub Discussions](https://github.com/vladislavkovalskyi/mubble/discussions) 49 | 50 | ## License 51 | 52 | Mubble is licensed under the [MIT License](license.md). -------------------------------------------------------------------------------- /mubble/bot/cute_types/chat_join_request.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from fntypes.result import Result 4 | 5 | from mubble.api.api import API, APIError 6 | from mubble.bot.cute_types.base import BaseCute 7 | from mubble.bot.cute_types.chat_member_updated import ChatMemberShortcuts, chat_member_interaction 8 | from mubble.tools.magic import shortcut 9 | from mubble.types.objects import * 10 | 11 | 12 | class ChatJoinRequestCute(BaseCute[ChatJoinRequest], ChatJoinRequest, ChatMemberShortcuts, kw_only=True): 13 | api: API 14 | 15 | @property 16 | def from_user(self) -> User: 17 | return self.from_ 18 | 19 | @property 20 | def user_id(self) -> int: 21 | return self.from_user.id 22 | 23 | @shortcut( 24 | "approve_chat_join_request", 25 | executor=chat_member_interaction, 26 | custom_params={"chat_id", "user_id"}, 27 | ) 28 | async def approve( 29 | self, 30 | *, 31 | chat_id: int | str | None = None, 32 | user_id: int | None = None, 33 | **other: typing.Any, 34 | ) -> Result[bool, APIError]: 35 | """Shortcut `API.approve_chat_join_request()`, see the [documentation](https://core.telegram.org/bots/api#approvechatjoinrequest) 36 | 37 | Use this method to approve a chat join request. The bot must be an administrator 38 | in the chat for this to work and must have the can_invite_users administrator 39 | right. Returns True on success.""" 40 | ... 41 | 42 | @shortcut( 43 | "decline_chat_join_request", 44 | executor=chat_member_interaction, 45 | custom_params={"chat_id", "user_id"}, 46 | ) 47 | async def decline( 48 | self, 49 | *, 50 | chat_id: int | str | None = None, 51 | user_id: int | None = None, 52 | **other: typing.Any, 53 | ) -> Result[bool, APIError]: 54 | """Shortcut `API.decline_chat_join_request()`, see the [documentation](https://core.telegram.org/bots/api#declinechatjoinrequest) 55 | 56 | Use this method to decline a chat join request. The bot must be an administrator 57 | in the chat for this to work and must have the can_invite_users administrator 58 | right. Returns True on success.""" 59 | ... 60 | 61 | 62 | __all__ = ("ChatJoinRequestCute",) 63 | -------------------------------------------------------------------------------- /mubble/bot/rules/rule_enum.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | from mubble.bot.dispatch.context import Context 5 | 6 | from .abc import ABCRule, Update, check_rule 7 | from .func import FuncRule 8 | 9 | 10 | @dataclasses.dataclass(slots=True) 11 | class RuleEnumState: 12 | name: str 13 | rule: ABCRule 14 | cls: type["RuleEnum"] 15 | 16 | def __eq__(self, other: typing.Self) -> bool: 17 | return self.cls == other.cls and self.name == other.name 18 | 19 | 20 | class RuleEnum(ABCRule): 21 | __enum__: list[RuleEnumState] 22 | 23 | def __init_subclass__(cls, *args: typing.Any, **kwargs: typing.Any) -> None: 24 | new_attributes = set(cls.__dict__) - set(RuleEnum.__dict__) - {"__enum__", "__init__"} 25 | enum_lst: list[RuleEnumState] = [] 26 | 27 | self = cls.__new__(cls) 28 | self.__init__() 29 | 30 | for attribute_name in new_attributes: 31 | rules = getattr(cls, attribute_name) 32 | attribute = RuleEnumState(attribute_name, rules, cls) 33 | 34 | setattr( 35 | self, 36 | attribute.name, 37 | self & FuncRule(lambda _, ctx: self.must_be_state(ctx, attribute)), # type: ignore 38 | ) 39 | enum_lst.append(attribute) 40 | 41 | setattr(cls, "__enum__", enum_lst) 42 | 43 | @classmethod 44 | def save_state(cls, ctx: Context, enum: RuleEnumState) -> None: 45 | ctx.update({cls.__class__.__name__ + "_state": enum}) 46 | 47 | @classmethod 48 | def check_state(cls, ctx: Context) -> RuleEnumState | None: 49 | return ctx.get(cls.__class__.__name__ + "_state") 50 | 51 | @classmethod 52 | def must_be_state(cls, ctx: Context, state: RuleEnumState) -> bool: 53 | real_state = cls.check_state(ctx) 54 | if not real_state: 55 | return False 56 | return real_state == state 57 | 58 | async def check(self, event: Update, ctx: Context) -> bool: 59 | if self.check_state(ctx): 60 | return True 61 | 62 | for enum in self.__enum__: 63 | ctx_copy = ctx.copy() 64 | if await check_rule(event.ctx_api, enum.rule, event, ctx_copy): 65 | ctx.update(ctx_copy) 66 | self.save_state(ctx, enum) 67 | return True 68 | 69 | return False 70 | 71 | 72 | __all__ = ("RuleEnum", "RuleEnumState") 73 | -------------------------------------------------------------------------------- /mubble/tools/adapter/dataclass.py: -------------------------------------------------------------------------------- 1 | from fntypes.option import Nothing, Some 2 | from fntypes.result import Error, Ok, Result 3 | 4 | from mubble.api.api import API 5 | from mubble.bot.cute_types.base import BaseCute 6 | from mubble.bot.cute_types.update import UpdateCute 7 | from mubble.bot.dispatch.context import Context 8 | from mubble.tools.adapter.abc import ABCAdapter 9 | from mubble.tools.adapter.errors import AdapterError 10 | from mubble.types.enums import UpdateType 11 | from mubble.types.objects import Update 12 | 13 | 14 | class DataclassAdapter[Dataclass](ABCAdapter[Update, Dataclass]): 15 | ADAPTED_VALUE_KEY: str 16 | 17 | def __init__( 18 | self, 19 | dataclass: type[Dataclass], 20 | update_type: UpdateType | None = None, 21 | ) -> None: 22 | self.ADAPTED_VALUE_KEY = f"_adapted_dataclass_{dataclass.__name__}" 23 | self.dataclass = dataclass 24 | self.update_type = update_type 25 | 26 | def __repr__(self) -> str: 27 | return f" {self.dataclass.__name__}>" 28 | 29 | def adapt(self, api: API, update: Update, context: Context) -> Result[Dataclass, AdapterError]: 30 | if self.ADAPTED_VALUE_KEY in context: 31 | return Ok(context[self.ADAPTED_VALUE_KEY]) 32 | 33 | update_type = (self.update_type or update.update_type).value 34 | try: 35 | if self.dataclass is Update: 36 | return Ok(update) # type: ignore 37 | elif issubclass(self.dataclass, UpdateCute): 38 | dataclass = self.dataclass.from_update(update, bound_api=api) 39 | else: 40 | match getattr(update, update_type): 41 | case Some(val): 42 | dataclass = ( 43 | self.dataclass.from_update(val, bound_api=api) 44 | if issubclass(self.dataclass, BaseCute) 45 | else self.dataclass(**val.to_dict()) 46 | ) 47 | case Nothing(): 48 | return Error(AdapterError(f"Update has no event {update_type!r}.")) 49 | except Exception as e: 50 | return Error(AdapterError(f"Cannot adapt Update to {self.dataclass!r}, error: {e!r}")) 51 | 52 | context[self.ADAPTED_VALUE_KEY] = dataclass 53 | return Ok(dataclass) # type: ignore 54 | 55 | 56 | __all__ = ("DataclassAdapter",) 57 | -------------------------------------------------------------------------------- /mubble/tools/adapter/event.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from fntypes.result import Error, Ok, Result 4 | 5 | from mubble.api.api import API 6 | from mubble.bot.cute_types.base import BaseCute 7 | from mubble.bot.cute_types.update import UpdateCute 8 | from mubble.bot.dispatch.context import Context 9 | from mubble.tools.adapter.abc import ABCAdapter 10 | from mubble.tools.adapter.errors import AdapterError 11 | from mubble.tools.adapter.raw_update import RawUpdateAdapter 12 | from mubble.types.enums import UpdateType 13 | from mubble.types.objects import Model, Update 14 | 15 | 16 | class EventAdapter[ToEvent: BaseCute](ABCAdapter[Update, ToEvent]): 17 | ADAPTED_VALUE_KEY: str = "_adapted_cute_event" 18 | 19 | def __init__(self, event: UpdateType | type[Model], cute_model: type[ToEvent]) -> None: 20 | self.event = event 21 | self.cute_model = cute_model 22 | 23 | def __repr__(self) -> str: 24 | raw_update_type = ( 25 | f"Update -> {self.event.__name__}" if isinstance(self.event, type) else f"Update.{self.event.value}" # type: ignore 26 | ) 27 | return "<{}: adapt {} -> {}>".format( 28 | self.__class__.__name__, 29 | raw_update_type, 30 | self.cute_model.__name__, 31 | ) 32 | 33 | def get_event(self, update: UpdateCute) -> Model | None: 34 | if isinstance(self.event, UpdateType) and self.event == update.update_type: 35 | return update.incoming_update 36 | 37 | if not isinstance(self.event, UpdateType) and (event := update.get_event(self.event)): 38 | return event.unwrap() 39 | 40 | return None 41 | 42 | def adapt(self, api: API, update: Update, context: Context) -> Result[ToEvent, AdapterError]: 43 | match RawUpdateAdapter().adapt(api, update, context): 44 | case Ok(update_cute) if event := self.get_event(update_cute): 45 | if self.ADAPTED_VALUE_KEY in context: 46 | return Ok(context[self.ADAPTED_VALUE_KEY]) 47 | 48 | adapted = ( 49 | typing.cast(ToEvent, event) 50 | if isinstance(event, BaseCute) 51 | else self.cute_model.from_update(event, bound_api=api) 52 | ) 53 | context[self.ADAPTED_VALUE_KEY] = adapted 54 | return Ok(adapted) 55 | case Error(_) as err: 56 | return err 57 | case _: 58 | return Error(AdapterError(f"Update is not an {self.event!r}.")) 59 | 60 | 61 | __all__ = ("EventAdapter",) 62 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/abc.py: -------------------------------------------------------------------------------- 1 | import importlib.util as importlib_util 2 | import os 3 | import pathlib 4 | import sys 5 | import typing 6 | from abc import ABC, abstractmethod 7 | 8 | from fntypes.option import Option 9 | 10 | from mubble.api.api import API 11 | from mubble.types.objects import Update 12 | 13 | if typing.TYPE_CHECKING: 14 | from mubble.bot.dispatch.view.abc import ABCView 15 | 16 | 17 | class PathExistsError(BaseException): 18 | pass 19 | 20 | 21 | class ABCDispatch(ABC): 22 | @abstractmethod 23 | async def feed(self, event: Update, api: API[typing.Any]) -> bool: 24 | pass 25 | 26 | @abstractmethod 27 | def load(self, external: typing.Self) -> None: 28 | pass 29 | 30 | @abstractmethod 31 | def get_view[T](self, of_type: type[T]) -> Option[T]: 32 | pass 33 | 34 | @abstractmethod 35 | def get_views(self) -> dict[str, "ABCView"]: 36 | pass 37 | 38 | def load_many(self, *externals: typing.Self) -> None: 39 | for external in externals: 40 | self.load(external) 41 | 42 | def load_from_dir(self, directory: str | pathlib.Path) -> bool: 43 | """Loads dispatchers from a directory containing Python modules where global variables 44 | are declared with instances of dispatch. 45 | Returns True if dispatchers were found, otherwise False. 46 | """ 47 | directory = pathlib.Path(directory) 48 | 49 | if not directory.exists(): 50 | raise PathExistsError(f"Path {str(directory)!r} does not exists.") 51 | 52 | dps: list[typing.Self] = [] 53 | for root, _, files in os.walk(directory): 54 | for f in files: 55 | if f.endswith(".py") and f != "__init__.py": 56 | module_path = os.path.join(root, f) 57 | module_name = os.path.splitext(os.path.relpath(module_path, directory))[0] 58 | module_name = module_name.replace(os.sep, ".") 59 | 60 | spec = importlib_util.spec_from_file_location(module_name, module_path) 61 | if spec is None or spec.loader is None: 62 | continue 63 | 64 | module = importlib_util.module_from_spec(spec) 65 | sys.modules[module_name] = module 66 | spec.loader.exec_module(module) 67 | 68 | for obj in module.__dict__.values(): 69 | if isinstance(obj, self.__class__): 70 | dps.append(obj) 71 | 72 | self.load_many(*dps) 73 | return bool(dps) 74 | 75 | 76 | __all__ = ("ABCDispatch",) 77 | -------------------------------------------------------------------------------- /mubble/node/rule.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import importlib 3 | import typing 4 | 5 | from mubble.bot.cute_types.update import UpdateCute 6 | from mubble.bot.dispatch.context import Context 7 | from mubble.node.base import ComposeError, Node 8 | 9 | if typing.TYPE_CHECKING: 10 | from mubble.bot.dispatch.process import check_rule 11 | from mubble.bot.rules.abc import ABCRule 12 | 13 | 14 | class RuleChain(dict[str, typing.Any], Node): 15 | dataclass: type[typing.Any] = dict 16 | rules: tuple["ABCRule", ...] = () 17 | 18 | def __init_subclass__(cls, *args: typing.Any, **kwargs: typing.Any) -> None: 19 | super().__init_subclass__(*args, **kwargs) 20 | 21 | if cls.__name__ == "_RuleNode": 22 | return 23 | cls.dataclass = cls.generate_node_dataclass(cls) 24 | 25 | def __new__(cls, *rules: "ABCRule") -> type[Node]: 26 | return type("_RuleNode", (cls,), {"dataclass": dict, "rules": rules}) # type: ignore 27 | 28 | def __class_getitem__( 29 | cls, items: "ABCRule | tuple[ABCRule, ...]", / 30 | ) -> typing.Self: 31 | if not isinstance(items, tuple): 32 | items = (items,) 33 | return cls(*items) 34 | 35 | @staticmethod 36 | def generate_node_dataclass(cls_: type["RuleChain"]): # noqa: ANN205 37 | return dataclasses.dataclass( 38 | type(cls_.__name__, (object,), dict(cls_.__dict__)) 39 | ) 40 | 41 | @classmethod 42 | async def compose(cls, update: UpdateCute) -> typing.Any: 43 | # Hack to avoid circular import 44 | globalns = globals() 45 | if "check_rule" not in globalns: 46 | globalns.update( 47 | { 48 | "check_rule": getattr( 49 | importlib.import_module("mubble.bot.dispatch.process"), 50 | "check_rule", 51 | ), 52 | }, 53 | ) 54 | 55 | ctx = Context() 56 | for rule in cls.rules: 57 | if not await check_rule(update.api, rule, update, ctx): 58 | raise ComposeError(f"Rule {rule!r} failed!") 59 | 60 | try: 61 | if dataclasses.is_dataclass(cls.dataclass): 62 | return cls.dataclass(**{k: ctx[k] for k in cls.__annotations__}) 63 | return cls.dataclass(**ctx) 64 | except Exception as exc: 65 | raise ComposeError(f"Dataclass validation error: {exc}") 66 | 67 | @classmethod 68 | def as_node(cls) -> type[typing.Self]: 69 | return cls 70 | 71 | @classmethod 72 | def is_generator(cls) -> typing.Literal[False]: 73 | return False 74 | 75 | 76 | __all__ = ("RuleChain",) 77 | -------------------------------------------------------------------------------- /mubble/tools/formatting/__init__.py: -------------------------------------------------------------------------------- 1 | from .deep_links import ( 2 | tg_bot_attach_open_any_chat, 3 | tg_bot_attach_open_current_chat, 4 | tg_bot_attach_open_specific_chat, 5 | tg_bot_start_link, 6 | tg_bot_startchannel_link, 7 | tg_bot_startgroup_link, 8 | tg_chat_folder_link, 9 | tg_chat_invite_link, 10 | tg_direct_mini_app_link, 11 | tg_emoji_link, 12 | tg_emoji_stickerset_link, 13 | tg_invoice_link, 14 | tg_language_pack_link, 15 | tg_main_mini_app_link, 16 | tg_mention_link, 17 | tg_open_message_link, 18 | tg_premium_multigift_link, 19 | tg_premium_offer_link, 20 | tg_private_channel_boost_link, 21 | tg_private_message_link, 22 | tg_public_channel_boost_link, 23 | tg_public_message_link, 24 | tg_public_username_link, 25 | tg_share_link, 26 | tg_story_link, 27 | ) 28 | from .html_formatter import ( 29 | FormatString, 30 | HTMLFormatter, 31 | block_quote, 32 | bold, 33 | code_inline, 34 | escape, 35 | italic, 36 | link, 37 | mention, 38 | pre_code, 39 | spoiler, 40 | strike, 41 | tg_emoji, 42 | underline, 43 | ) 44 | from .spec_html_formats import ( 45 | Base, 46 | BlockQuote, 47 | Link, 48 | Mention, 49 | PreCode, 50 | SpecialFormat, 51 | TgEmoji, 52 | ) 53 | 54 | __all__ = ( 55 | "Base", 56 | "BlockQuote", 57 | "FormatString", 58 | "HTMLFormatter", 59 | "Link", 60 | "Mention", 61 | "PreCode", 62 | "SpecialFormat", 63 | "TgEmoji", 64 | "block_quote", 65 | "bold", 66 | "code_inline", 67 | "escape", 68 | "italic", 69 | "link", 70 | "mention", 71 | "pre_code", 72 | "spoiler", 73 | "strike", 74 | "tg_bot_attach_open_any_chat", 75 | "tg_bot_attach_open_current_chat", 76 | "tg_bot_attach_open_specific_chat", 77 | "tg_bot_start_link", 78 | "tg_bot_startchannel_link", 79 | "tg_bot_startgroup_link", 80 | "tg_chat_folder_link", 81 | "tg_chat_invite_link", 82 | "tg_direct_mini_app_link", 83 | "tg_emoji", 84 | "tg_emoji_link", 85 | "tg_emoji_stickerset_link", 86 | "tg_invoice_link", 87 | "tg_language_pack_link", 88 | "tg_main_mini_app_link", 89 | "tg_mention_link", 90 | "tg_open_message_link", 91 | "tg_premium_multigift_link", 92 | "tg_premium_offer_link", 93 | "tg_private_channel_boost_link", 94 | "tg_private_message_link", 95 | "tg_public_channel_boost_link", 96 | "tg_public_message_link", 97 | "tg_public_username_link", 98 | "tg_share_link", 99 | "tg_story_link", 100 | "underline", 101 | ) 102 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/middleware/global_middleware.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import typing 3 | from contextlib import contextmanager 4 | 5 | from mubble.api import API 6 | from mubble.bot.cute_types.update import UpdateCute 7 | from mubble.bot.dispatch.context import Context 8 | from mubble.bot.dispatch.middleware.abc import ABCMiddleware 9 | from mubble.bot.rules.abc import ABCRule, check_rule 10 | from mubble.node import IsNode, compose_nodes 11 | from mubble.tools.adapter.abc import ABCAdapter 12 | from mubble.tools.adapter.raw_update import RawUpdateAdapter 13 | from mubble.types import Update 14 | 15 | 16 | class GlobalMiddleware(ABCMiddleware): 17 | adapter = RawUpdateAdapter() 18 | 19 | def __init__(self): 20 | self.filters: set[ABCRule] = set() 21 | self.source_filters: dict[ABCAdapter | IsNode, dict[typing.Any, ABCRule]] = {} 22 | 23 | async def pre(self, event: UpdateCute, ctx: Context) -> bool: 24 | for filter in self.filters: 25 | if not await check_rule(event.api, filter, event, ctx): 26 | return False 27 | 28 | # Simple implication.... Grouped by source categories 29 | for source, identifiers in self.source_filters.items(): 30 | if isinstance(source, ABCAdapter): 31 | result = source.adapt(event.api, event, ctx) 32 | if inspect.isawaitable(result): 33 | result = await result 34 | 35 | result = result.unwrap_or_none() 36 | if result is None: 37 | return True 38 | 39 | else: 40 | result = await compose_nodes({"value": source}, ctx, {Update: event, API: event.api}) 41 | if result := result.unwrap(): 42 | result = result.values["value"] 43 | else: 44 | return True 45 | 46 | if result in identifiers: 47 | return await check_rule(event.api, identifiers[result], event, ctx) 48 | 49 | return True 50 | 51 | @contextmanager 52 | def apply_filters( 53 | self, 54 | *filters: ABCRule, 55 | source_filter: tuple[ABCAdapter | IsNode, typing.Any, ABCRule] | None = None, 56 | ): 57 | if source_filter is not None: 58 | self.source_filters.setdefault(source_filter[0], {}) 59 | self.source_filters[source_filter[0]].update({source_filter[1]: source_filter[2]}) 60 | 61 | self.filters |= set(filters) 62 | yield 63 | self.filters.difference_update(filters) 64 | 65 | if source_filter is not None: # noqa: SIM102 66 | if identifiers := self.source_filters.get(source_filter[0]): 67 | identifiers.pop(source_filter[1], None) 68 | 69 | 70 | __all__ = ("GlobalMiddleware",) 71 | -------------------------------------------------------------------------------- /mubble/bot/rules/payload.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from contextlib import suppress 3 | from functools import cached_property 4 | 5 | import msgspec 6 | 7 | from mubble.bot.dispatch.context import Context 8 | from mubble.bot.rules.abc import ABCRule 9 | from mubble.bot.rules.markup import Markup, PatternLike, check_string 10 | from mubble.msgspec_json import loads 11 | from mubble.node.base import Node 12 | from mubble.node.payload import Payload, PayloadData 13 | from mubble.tools.callback_data_serilization.abc import ABCDataSerializer, ModelType 14 | from mubble.tools.callback_data_serilization.json_ser import JSONSerializer 15 | 16 | 17 | class PayloadRule[Data](ABCRule): 18 | def __init__( 19 | self, 20 | data_type: type[Data], 21 | serializer: type[ABCDataSerializer[Data]], 22 | *, 23 | alias: str | None = None, 24 | ) -> None: 25 | self.data_type = data_type 26 | self.serializer = serializer 27 | self.alias = alias or "data" 28 | 29 | @cached_property 30 | def required_nodes(self) -> dict[str, type[Node]]: 31 | return {"payload": PayloadData[self.data_type, self.serializer]} # type: ignore 32 | 33 | def check(self, payload: PayloadData[Data], context: Context) -> typing.Literal[True]: 34 | context.set(self.alias, payload) 35 | return True 36 | 37 | 38 | class PayloadModelRule[Model: ModelType](PayloadRule[Model]): 39 | def __init__( 40 | self, 41 | model_t: type[Model], 42 | *, 43 | serializer: type[ABCDataSerializer[Model]] | None = None, 44 | alias: str | None = None, 45 | ) -> None: 46 | super().__init__(model_t, serializer or JSONSerializer, alias=alias or "model") 47 | 48 | 49 | class PayloadEqRule(ABCRule): 50 | def __init__(self, payloads: str | list[str], /) -> None: 51 | self.payloads = [payloads] if isinstance(payloads, str) else payloads 52 | 53 | def check(self, payload: Payload) -> bool: 54 | return any(p == payload for p in self.payloads) 55 | 56 | 57 | class PayloadMarkupRule(ABCRule): 58 | def __init__(self, pattern: PatternLike | list[PatternLike], /) -> None: 59 | self.patterns = Markup(pattern).patterns 60 | 61 | def check(self, payload: Payload, context: Context) -> bool: 62 | return check_string(self.patterns, payload, context) 63 | 64 | 65 | class PayloadJsonEqRule(ABCRule): 66 | def __init__(self, payload: dict[str, typing.Any], /) -> None: 67 | self.payload = payload 68 | 69 | def check(self, payload: Payload) -> bool: 70 | with suppress(msgspec.DecodeError, msgspec.ValidationError): 71 | return self.payload == loads(payload) 72 | return False 73 | 74 | 75 | __all__ = ( 76 | "PayloadEqRule", 77 | "PayloadJsonEqRule", 78 | "PayloadMarkupRule", 79 | "PayloadModelRule", 80 | "PayloadRule", 81 | ) 82 | -------------------------------------------------------------------------------- /mubble/node/polymorphic.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import typing 3 | 4 | from fntypes.result import Error, Ok 5 | 6 | from mubble.api.api import API 7 | from mubble.bot.cute_types.update import UpdateCute 8 | from mubble.bot.dispatch.context import Context 9 | from mubble.modules import logger 10 | from mubble.node.base import ComposeError, Node, get_nodes 11 | from mubble.node.composer import CONTEXT_STORE_NODES_KEY, NodeSession, compose_nodes 12 | from mubble.node.scope import NodeScope 13 | from mubble.tools.magic import get_polymorphic_implementations, impl, magic_bundle 14 | from mubble.types.objects import Update 15 | 16 | 17 | class Polymorphic(Node): 18 | @classmethod 19 | async def compose(cls, raw_update: Update, update: UpdateCute, context: Context) -> typing.Any: 20 | logger.debug(f"Composing polymorphic node {cls.__name__!r}...") 21 | scope = getattr(cls, "scope", None) 22 | node_ctx = context.get_or_set(CONTEXT_STORE_NODES_KEY, {}) 23 | data = { 24 | API: update.ctx_api, 25 | Context: context, 26 | Update: raw_update, 27 | } 28 | 29 | for i, impl_ in enumerate(get_polymorphic_implementations(cls)): 30 | logger.debug("Checking impl {!r}...", impl_.__name__) 31 | node_collection = None 32 | 33 | match await compose_nodes(get_nodes(impl_), context, data=data): 34 | case Ok(col): 35 | node_collection = col 36 | case Error(err): 37 | logger.debug(f"Composition failed with error: {err!r}") 38 | 39 | if node_collection is None: 40 | logger.debug("Impl {!r} composition failed!", impl_.__name__) 41 | continue 42 | 43 | # To determine whether this is a right morph, all subnodes must be resolved 44 | if scope is NodeScope.PER_EVENT and (cls, i) in node_ctx: 45 | logger.debug( 46 | "Morph is already cached as per_event node, using its value. Impl {!r} succeeded!", 47 | impl_.__name__, 48 | ) 49 | res: NodeSession = node_ctx[(cls, i)] 50 | await node_collection.close_all() 51 | return res.value 52 | 53 | result = impl_(cls, **node_collection.values | magic_bundle(impl_, data, typebundle=True)) 54 | if inspect.isawaitable(result): 55 | result = await result 56 | 57 | if scope is NodeScope.PER_EVENT: 58 | node_ctx[(cls, i)] = NodeSession(cls, result, {}) 59 | 60 | await node_collection.close_all(with_value=result) 61 | logger.debug("Impl {!r} succeeded with value: {!r}", impl_.__name__, result) 62 | return result 63 | 64 | raise ComposeError("No implementation found.") 65 | 66 | 67 | __all__ = ("Polymorphic", "impl") 68 | -------------------------------------------------------------------------------- /mubble/node/__init__.py: -------------------------------------------------------------------------------- 1 | from .attachment import ( 2 | Animation, 3 | Attachment, 4 | Audio, 5 | Document, 6 | Photo, 7 | SuccessfulPayment, 8 | Video, 9 | VideoNote, 10 | Voice, 11 | ) 12 | from .base import ( 13 | Composable, 14 | ComposeError, 15 | DataNode, 16 | FactoryNode, 17 | GlobalNode, 18 | IsNode, 19 | Name, 20 | Node, 21 | NodeComposeFunction, 22 | NodeImpersonation, 23 | NodeProto, 24 | NodeType, 25 | as_node, 26 | is_node, 27 | scalar_node, 28 | unwrap_node, 29 | ) 30 | from .callback_query import ( 31 | CallbackQueryData, 32 | CallbackQueryDataJson, 33 | Field, 34 | ) 35 | from .command import CommandInfo 36 | from .composer import NodeCollection, NodeSession, compose_node, compose_nodes 37 | from .container import ContainerNode 38 | from .either import Either, Optional 39 | from .event import EventNode 40 | from .file import File, FileId 41 | from .me import Me 42 | from .payload import Payload, PayloadData, PayloadSerializer 43 | from .polymorphic import Polymorphic, impl 44 | from .rule import RuleChain 45 | from .scope import ( 46 | GLOBAL, 47 | PER_CALL, 48 | PER_EVENT, 49 | NodeScope, 50 | global_node, 51 | per_call, 52 | per_event, 53 | ) 54 | from .source import ChatSource, Source, UserId, UserSource 55 | from .text import Text, TextInteger, TextLiteral 56 | from .tools import generate_node 57 | 58 | __all__ = ( 59 | "Animation", 60 | "Attachment", 61 | "Audio", 62 | "CallbackQueryData", 63 | "CallbackQueryDataJson", 64 | "ChatSource", 65 | "CommandInfo", 66 | "Composable", 67 | "ComposeError", 68 | "ContainerNode", 69 | "DataNode", 70 | "Document", 71 | "Either", 72 | "EventNode", 73 | "FactoryNode", 74 | "Field", 75 | "Field", 76 | "File", 77 | "FileId", 78 | "GLOBAL", 79 | "GlobalNode", 80 | "IsNode", 81 | "Me", 82 | "Name", 83 | "Node", 84 | "NodeCollection", 85 | "NodeComposeFunction", 86 | "NodeImpersonation", 87 | "NodeProto", 88 | "NodeScope", 89 | "NodeSession", 90 | "NodeType", 91 | "Optional", 92 | "PER_CALL", 93 | "PER_EVENT", 94 | "Payload", 95 | "PayloadData", 96 | "PayloadSerializer", 97 | "Photo", 98 | "Polymorphic", 99 | "RuleChain", 100 | "Source", 101 | "SuccessfulPayment", 102 | "Text", 103 | "TextInteger", 104 | "TextLiteral", 105 | "UserId", 106 | "UserSource", 107 | "Video", 108 | "VideoNote", 109 | "Voice", 110 | "as_node", 111 | "compose_node", 112 | "compose_nodes", 113 | "generate_node", 114 | "global_node", 115 | "impl", 116 | "is_node", 117 | "per_call", 118 | "per_event", 119 | "scalar_node", 120 | "unwrap_node", 121 | ) 122 | -------------------------------------------------------------------------------- /mubble/client/abc.py: -------------------------------------------------------------------------------- 1 | import io 2 | import typing 3 | from abc import ABC, abstractmethod 4 | 5 | from mubble.client.form_data import MultipartFormProto, encode_form_data 6 | 7 | type Data = dict[str, typing.Any] | MultipartFormProto 8 | 9 | 10 | class ABCClient[MultipartForm: MultipartFormProto](ABC): 11 | CONNECTION_TIMEOUT_ERRORS: tuple[type[BaseException], ...] = () 12 | CLIENT_CONNECTION_ERRORS: tuple[type[BaseException], ...] = () 13 | 14 | @abstractmethod 15 | def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: 16 | pass 17 | 18 | @abstractmethod 19 | async def request_text( 20 | self, 21 | url: str, 22 | method: str = "GET", 23 | data: Data | None = None, 24 | **kwargs: typing.Any, 25 | ) -> str: 26 | pass 27 | 28 | @abstractmethod 29 | async def request_json( 30 | self, 31 | url: str, 32 | method: str = "GET", 33 | data: Data | None = None, 34 | **kwargs: typing.Any, 35 | ) -> dict[str, typing.Any]: 36 | pass 37 | 38 | @abstractmethod 39 | async def request_content( 40 | self, 41 | url: str, 42 | method: str = "GET", 43 | data: Data | None = None, 44 | **kwargs: typing.Any, 45 | ) -> bytes: 46 | pass 47 | 48 | @abstractmethod 49 | async def request_bytes( 50 | self, 51 | url: str, 52 | method: str = "GET", 53 | data: Data | None = None, 54 | **kwargs: typing.Any, 55 | ) -> bytes: 56 | pass 57 | 58 | @abstractmethod 59 | async def close(self) -> None: 60 | pass 61 | 62 | @classmethod 63 | @abstractmethod 64 | def multipart_form_factory(cls) -> MultipartForm: 65 | pass 66 | 67 | @classmethod 68 | def get_form( 69 | cls, 70 | *, 71 | data: dict[str, typing.Any], 72 | files: dict[str, tuple[str, typing.Any]] | None = None, 73 | ) -> MultipartForm: 74 | multipart_form = cls.multipart_form_factory() 75 | files = files or {} 76 | 77 | for k, v in encode_form_data(data, files).items(): 78 | multipart_form.add_field(k, v) 79 | 80 | for n, (filename, content) in { 81 | k: (n, io.BytesIO(c) if isinstance(c, bytes) else c) for k, (n, c) in files.items() 82 | }.items(): 83 | multipart_form.add_field(n, content, filename=filename) 84 | 85 | return multipart_form 86 | 87 | async def __aenter__(self) -> typing.Self: 88 | return self 89 | 90 | async def __aexit__( 91 | self, 92 | exc_type: type[BaseException], 93 | exc_val: typing.Any, 94 | exc_tb: typing.Any, 95 | ) -> bool: 96 | await self.close() 97 | return not bool(exc_val) 98 | 99 | 100 | __all__ = ("ABCClient",) 101 | -------------------------------------------------------------------------------- /mubble/node/payload.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | from fntypes.result import Error, Ok 5 | 6 | from mubble.bot.cute_types.callback_query import CallbackQueryCute 7 | from mubble.bot.cute_types.message import MessageCute 8 | from mubble.bot.cute_types.pre_checkout_query import PreCheckoutQueryCute 9 | from mubble.node.base import ComposeError, DataNode, FactoryNode, GlobalNode, scalar_node 10 | from mubble.node.polymorphic import Polymorphic, impl 11 | from mubble.tools.callback_data_serilization import ABCDataSerializer, JSONSerializer 12 | 13 | 14 | @scalar_node[str] 15 | class Payload(Polymorphic): 16 | @impl 17 | def compose_pre_checkout_query(cls, event: PreCheckoutQueryCute) -> str: 18 | return event.invoice_payload 19 | 20 | @impl 21 | def compose_callback_query(cls, event: CallbackQueryCute) -> str: 22 | return event.data.expect("CallbackQuery has no data.") 23 | 24 | @impl 25 | def compose_message(cls, event: MessageCute) -> str: 26 | return event.successful_payment.map( 27 | lambda payment: payment.invoice_payload, 28 | ).expect("Message has no successful payment.") 29 | 30 | 31 | @dataclasses.dataclass(frozen=True, slots=True) 32 | class PayloadSerializer[T: type[ABCDataSerializer[typing.Any]]](DataNode, GlobalNode[T]): 33 | serializer: type[ABCDataSerializer[typing.Any]] 34 | 35 | @classmethod 36 | def compose(cls) -> typing.Self: 37 | return cls(serializer=cls.get(default=JSONSerializer)) 38 | 39 | 40 | class _PayloadData(FactoryNode): 41 | data_type: type[typing.Any] 42 | serializer: type[ABCDataSerializer[typing.Any]] | None = None 43 | 44 | def __class_getitem__( 45 | cls, 46 | data_type: type[typing.Any] | tuple[type[typing.Any], type[ABCDataSerializer[typing.Any]]], 47 | /, 48 | ): 49 | data_type, serializer = (data_type, None) if not isinstance(data_type, tuple) else data_type 50 | return cls(data_type=data_type, serializer=serializer) 51 | 52 | @classmethod 53 | def compose(cls, payload: Payload, payload_serializer: PayloadSerializer) -> typing.Any: 54 | serializer = cls.serializer or payload_serializer.serializer 55 | match serializer(cls.data_type).deserialize(payload): 56 | case Ok(value): 57 | return value 58 | case Error(err): 59 | raise ComposeError(err) 60 | 61 | 62 | if typing.TYPE_CHECKING: 63 | import typing_extensions 64 | 65 | DataType = typing.TypeVar("DataType") 66 | Serializer = typing_extensions.TypeVar( 67 | "Serializer", 68 | bound=ABCDataSerializer, 69 | default=JSONSerializer[typing.Any], 70 | ) 71 | 72 | type PayloadDataType[DataType, Serializer] = typing.Annotated[DataType, Serializer] 73 | PayloadData: typing.TypeAlias = PayloadDataType[DataType, Serializer] 74 | else: 75 | PayloadData = _PayloadData 76 | 77 | 78 | __all__ = ("Payload", "PayloadData", "PayloadSerializer") 79 | -------------------------------------------------------------------------------- /mubble/node/source.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | from fntypes.option import Nothing, Option, Some 5 | 6 | from mubble.api.api import API 7 | from mubble.bot.cute_types import CallbackQueryCute, ChatJoinRequestCute, MessageCute, PreCheckoutQueryCute 8 | from mubble.node.base import ComposeError, DataNode, scalar_node 9 | from mubble.node.polymorphic import Polymorphic, impl 10 | from mubble.types.objects import Chat, Message, User 11 | 12 | 13 | @dataclasses.dataclass(kw_only=True, slots=True) 14 | class Source(Polymorphic, DataNode): 15 | api: API 16 | from_user: User 17 | chat: Option[Chat] = dataclasses.field(default_factory=Nothing) 18 | thread_id: Option[int] = dataclasses.field(default_factory=Nothing) 19 | 20 | @impl 21 | def compose_message(cls, message: MessageCute) -> typing.Self: 22 | return cls( 23 | api=message.ctx_api, 24 | from_user=message.from_user, 25 | chat=Some(message.chat), 26 | thread_id=message.message_thread_id, 27 | ) 28 | 29 | @impl 30 | def compose_callback_query(cls, callback_query: CallbackQueryCute) -> typing.Self: 31 | return cls( 32 | api=callback_query.ctx_api, 33 | from_user=callback_query.from_user, 34 | chat=callback_query.chat, 35 | thread_id=callback_query.message_thread_id, 36 | ) 37 | 38 | @impl 39 | def compose_chat_join_request(cls, chat_join_request: ChatJoinRequestCute) -> typing.Self: 40 | return cls( 41 | api=chat_join_request.ctx_api, 42 | from_user=chat_join_request.from_user, 43 | chat=Some(chat_join_request.chat), 44 | thread_id=Nothing(), 45 | ) 46 | 47 | @impl 48 | def compose_pre_checkout_query(cls, pre_checkout_query: PreCheckoutQueryCute) -> typing.Self: 49 | return cls( 50 | api=pre_checkout_query.ctx_api, 51 | from_user=pre_checkout_query.from_user, 52 | chat=Nothing(), 53 | thread_id=Nothing(), 54 | ) 55 | 56 | async def send(self, text: str, **kwargs: typing.Any) -> Message: 57 | result = await self.api.send_message( 58 | chat_id=self.chat.map_or(self.from_user.id, lambda chat: chat.id).unwrap(), 59 | message_thread_id=self.thread_id.unwrap_or_none(), 60 | text=text, 61 | **kwargs, 62 | ) 63 | return result.unwrap() 64 | 65 | 66 | @scalar_node 67 | class ChatSource: 68 | @classmethod 69 | def compose(cls, source: Source) -> Chat: 70 | return source.chat.expect(ComposeError("Source has no chat.")) 71 | 72 | 73 | @scalar_node 74 | class UserSource: 75 | @classmethod 76 | def compose(cls, source: Source) -> User: 77 | return source.from_user 78 | 79 | 80 | @scalar_node 81 | class UserId: 82 | @classmethod 83 | def compose(cls, user: UserSource) -> int: 84 | return user.id 85 | 86 | 87 | __all__ = ("ChatSource", "Source", "UserId", "UserSource") 88 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | Mubble is licensed under the MIT License, which is a permissive open-source license that allows for free use, modification, and distribution of the software. 4 | 5 | ## MIT License 6 | 7 | ``` 8 | MIT License 9 | 10 | Copyright (c) 2023-2024 Mubble Contributors 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 | SOFTWARE. 29 | ``` 30 | 31 | ## What This Means 32 | 33 | The MIT License grants you the following permissions: 34 | 35 | 1. **Use**: You can use Mubble for any purpose, including commercial applications. 36 | 2. **Modify**: You can modify the source code to suit your needs. 37 | 3. **Distribute**: You can distribute the original or modified versions of Mubble. 38 | 4. **Sublicense**: You can incorporate Mubble into a larger project with a different license. 39 | 5. **Sell**: You can sell copies of Mubble or products that use Mubble. 40 | 41 | ## Requirements 42 | 43 | The only requirement is that you include the original copyright notice and license text in any copy or substantial portion of the software. 44 | 45 | ## No Warranty 46 | 47 | The MIT License explicitly states that the software is provided "as is" without warranty of any kind. The authors or copyright holders are not liable for any claims, damages, or other liabilities arising from the use of the software. 48 | 49 | ## Third-Party Dependencies 50 | 51 | Mubble depends on several third-party libraries, each with its own license: 52 | 53 | 1. **aiohttp**: Apache 2.0 License 54 | 2. **pydantic**: MIT License 55 | 56 | Please review the licenses of these dependencies if you plan to distribute Mubble or a modified version of it. 57 | 58 | ## Contributing 59 | 60 | By contributing to Mubble, you agree that your contributions will be licensed under the same MIT License. See the [Contributing](contributing.md) document for more information. 61 | 62 | ## Questions 63 | 64 | If you have any questions about the license or how you can use Mubble in your project, please feel free to contact the maintainers or open an issue on GitHub. -------------------------------------------------------------------------------- /mubble/bot/dispatch/__init__.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.dispatch.abc import ABCDispatch 2 | from mubble.bot.dispatch.context import Context 3 | from mubble.bot.dispatch.dispatch import Dispatch, MubbleContext 4 | from mubble.bot.dispatch.handler import ( 5 | ABCHandler, 6 | AudioReplyHandler, 7 | DocumentReplyHandler, 8 | FuncHandler, 9 | MediaGroupReplyHandler, 10 | MessageReplyHandler, 11 | PhotoReplyHandler, 12 | StickerReplyHandler, 13 | VideoReplyHandler, 14 | ) 15 | from mubble.bot.dispatch.middleware import ABCMiddleware 16 | from mubble.bot.dispatch.process import check_rule, process_inner 17 | from mubble.bot.dispatch.return_manager import ( 18 | ABCReturnManager, 19 | BaseReturnManager, 20 | CallbackQueryReturnManager, 21 | InlineQueryReturnManager, 22 | Manager, 23 | MessageReturnManager, 24 | PreCheckoutQueryManager, 25 | register_manager, 26 | ) 27 | from mubble.bot.dispatch.view import ( 28 | ABCStateView, 29 | ABCView, 30 | BaseStateView, 31 | BaseView, 32 | CallbackQueryView, 33 | ChatJoinRequestView, 34 | ChatMemberView, 35 | InlineQueryView, 36 | MessageView, 37 | PreCheckoutQueryView, 38 | RawEventView, 39 | ViewBox, 40 | ) 41 | from mubble.bot.dispatch.waiter_machine import ( 42 | CALLBACK_QUERY_FOR_MESSAGE, 43 | CALLBACK_QUERY_FROM_CHAT, 44 | CALLBACK_QUERY_IN_CHAT_FOR_MESSAGE, 45 | MESSAGE_FROM_USER, 46 | MESSAGE_FROM_USER_IN_CHAT, 47 | MESSAGE_IN_CHAT, 48 | Hasher, 49 | ShortState, 50 | StateViewHasher, 51 | WaiterMachine, 52 | clear_wm_storage_worker, 53 | ) 54 | 55 | __all__ = ( 56 | "ABCDispatch", 57 | "ABCHandler", 58 | "ABCMiddleware", 59 | "ABCReturnManager", 60 | "ABCStateView", 61 | "ABCView", 62 | "AudioReplyHandler", 63 | "BaseReturnManager", 64 | "BaseStateView", 65 | "BaseView", 66 | "CALLBACK_QUERY_FOR_MESSAGE", 67 | "CALLBACK_QUERY_FROM_CHAT", 68 | "CALLBACK_QUERY_IN_CHAT_FOR_MESSAGE", 69 | "CallbackQueryReturnManager", 70 | "CallbackQueryView", 71 | "ChatJoinRequestView", 72 | "ChatMemberView", 73 | "Context", 74 | "Dispatch", 75 | "DocumentReplyHandler", 76 | "FuncHandler", 77 | "Hasher", 78 | "InlineQueryReturnManager", 79 | "InlineQueryView", 80 | "MESSAGE_FROM_USER", 81 | "MESSAGE_FROM_USER_IN_CHAT", 82 | "MESSAGE_IN_CHAT", 83 | "Manager", 84 | "MediaGroupReplyHandler", 85 | "MessageReplyHandler", 86 | "MessageReturnManager", 87 | "MessageView", 88 | "PhotoReplyHandler", 89 | "PreCheckoutQueryManager", 90 | "PreCheckoutQueryView", 91 | "RawEventView", 92 | "ShortState", 93 | "StateViewHasher", 94 | "StickerReplyHandler", 95 | "MubbleContext", 96 | "VideoReplyHandler", 97 | "ViewBox", 98 | "WaiterMachine", 99 | "check_rule", 100 | "clear_wm_storage_worker", 101 | "clear_wm_storage_worker", 102 | "process_inner", 103 | "register_manager", 104 | ) 105 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | import typing 5 | from reprlib import recursive_repr 6 | 7 | from mubble.types.objects import Update 8 | 9 | if typing.TYPE_CHECKING: 10 | from mubble.node.composer import NodeCollection 11 | 12 | type Key = str | enum.Enum 13 | type AnyValue = typing.Any 14 | 15 | 16 | class Context(dict[str, AnyValue]): 17 | """Context class like dict & dotdict. 18 | 19 | For example: 20 | ```python 21 | class MyRule(ABCRule[T]): 22 | async def check(self, event: T, ctx: Context) -> bool: 23 | ctx.me = (await event.ctx_api.get_me()).unwrap() 24 | ctx["items"] = [1, 2, 3] 25 | return True 26 | ``` 27 | """ 28 | 29 | raw_update: Update 30 | node_col: NodeCollection | None = None 31 | 32 | def __init__(self, **kwargs: AnyValue) -> None: 33 | cls_vars = vars(self.__class__) 34 | defaults = {} 35 | 36 | for k in self.__class__.__annotations__: 37 | if k in cls_vars: 38 | defaults[k] = cls_vars[k] 39 | delattr(self.__class__, k) 40 | 41 | dict.__init__(self, **defaults | kwargs) 42 | 43 | @recursive_repr() 44 | def __repr__(self) -> str: 45 | return "{}({})".format(self.__class__.__name__, ", ".join(f"{k}={v!r}" for k, v in self.items())) 46 | 47 | def __setitem__(self, __key: Key, __value: AnyValue) -> None: 48 | dict.__setitem__(self, self.key_to_str(__key), __value) 49 | 50 | def __getitem__(self, __key: Key) -> AnyValue: 51 | return dict.__getitem__(self, self.key_to_str(__key)) 52 | 53 | def __delitem__(self, __key: Key) -> None: 54 | dict.__delitem__(self, self.key_to_str(__key)) 55 | 56 | def __setattr__(self, __name: str, __value: AnyValue) -> None: 57 | self.__setitem__(__name, __value) 58 | 59 | def __getattr__(self, __name: str) -> AnyValue: 60 | return self.__getitem__(__name) 61 | 62 | def __delattr__(self, __name: str) -> None: 63 | self.__delitem__(__name) 64 | 65 | @staticmethod 66 | def key_to_str(key: Key) -> str: 67 | return key if isinstance(key, str) else str(key.value) 68 | 69 | def copy(self) -> typing.Self: 70 | return self.__class__(**dict.copy(self)) 71 | 72 | def set(self, key: Key, value: AnyValue) -> None: 73 | self[key] = value 74 | 75 | @typing.overload 76 | def get(self, key: Key) -> AnyValue | None: ... 77 | 78 | @typing.overload 79 | def get[T](self, key: Key, default: T) -> T | AnyValue: ... 80 | 81 | @typing.overload 82 | def get(self, key: Key, default: None = None) -> AnyValue | None: ... 83 | 84 | def get[T](self, key: Key, default: T | None = None) -> T | AnyValue | None: 85 | return dict.get(self, key, default) 86 | 87 | def get_or_set[T](self, key: Key, default: T) -> T: 88 | if key not in self: 89 | self.set(key, default) 90 | return self.get(key, default) 91 | 92 | def delete(self, key: Key) -> None: 93 | del self[key] 94 | 95 | 96 | __all__ = ("Context",) 97 | -------------------------------------------------------------------------------- /mubble/node/either.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from fntypes.result import Ok 4 | 5 | from mubble.api.api import API 6 | from mubble.bot.dispatch.context import Context 7 | from mubble.node.base import ComposeError, FactoryNode, Node 8 | from mubble.node.composer import CONTEXT_STORE_NODES_KEY, GLOBAL_VALUE_KEY, compose_node, compose_nodes 9 | from mubble.node.scope import NodeScope, per_call 10 | from mubble.types.objects import Update 11 | 12 | 13 | @per_call 14 | class _Either(FactoryNode): 15 | """Represents a node that either to compose `left` or `right` nodes. 16 | 17 | For example: 18 | ```python 19 | # ScalarNode `Integer` -> int 20 | # ScalarNode `Float` -> float 21 | 22 | Number = Either[Integer, Float] # using a type alias just as an example 23 | 24 | def number_to_int(number: Number) -> int: 25 | return int(number) 26 | ``` 27 | """ 28 | 29 | nodes: tuple[type[Node], type[Node] | None] 30 | 31 | def __class_getitem__(cls, node: type[Node] | tuple[type[Node], type[Node]], /): 32 | nodes = (node, None) if not isinstance(node, tuple) else node 33 | assert len(nodes) == 2, "Node `Either` must have at least two nodes." 34 | return cls(nodes=nodes) 35 | 36 | @classmethod 37 | async def compose(cls, api: API, update: Update, context: Context) -> typing.Any | None: 38 | data = {API: api, Update: update, Context: context} 39 | node_ctx = context.get_or_set(CONTEXT_STORE_NODES_KEY, {}) 40 | 41 | for node in cls.nodes: 42 | if node is None: 43 | return None 44 | 45 | if node.scope is NodeScope.PER_EVENT and node in node_ctx: 46 | return node_ctx[node].value 47 | elif node.scope is NodeScope.GLOBAL and hasattr(node, GLOBAL_VALUE_KEY): 48 | return getattr(node, GLOBAL_VALUE_KEY) 49 | 50 | subnodes = node.as_node().get_subnodes() 51 | match await compose_nodes(subnodes, context, data): 52 | case Ok(col): 53 | try: 54 | session = await compose_node( 55 | node=node, 56 | linked={ 57 | typing.cast(type, n): col.sessions[name].value for name, n in subnodes.items() 58 | }, 59 | data=data, 60 | ) 61 | except ComposeError: 62 | continue 63 | 64 | if node.scope is NodeScope.PER_EVENT: 65 | node_ctx[node] = session 66 | elif node.scope is NodeScope.GLOBAL: 67 | setattr(node, GLOBAL_VALUE_KEY, session.value) 68 | 69 | return session.value 70 | 71 | raise ComposeError("Cannot compose either nodes: {}.".format(", ".join(repr(n) for n in cls.nodes))) 72 | 73 | 74 | if typing.TYPE_CHECKING: 75 | type Either[Left, Right: typing.Any | None] = Left | Right 76 | type Optional[Left] = Either[Left, None] 77 | else: 78 | Either = _Either 79 | Optional = type("Optional", (Either,), {}) 80 | 81 | 82 | __all__ = ("Either", "Optional") 83 | -------------------------------------------------------------------------------- /mubble/bot/bot.py: -------------------------------------------------------------------------------- 1 | import typing_extensions as typing 2 | 3 | from mubble.api.api import API, HTTPClient 4 | from mubble.bot.dispatch import dispatch as dp 5 | from mubble.bot.dispatch.abc import ABCDispatch 6 | from mubble.bot.polling import polling as pg 7 | from mubble.bot.polling.abc import ABCPolling 8 | from mubble.modules import logger 9 | from mubble.tools.loop_wrapper import ABCLoopWrapper 10 | from mubble.tools.loop_wrapper import loop_wrapper as lw 11 | 12 | Dispatch = typing.TypeVar( 13 | "Dispatch", bound=ABCDispatch, default=dp.Dispatch[HTTPClient] 14 | ) 15 | Polling = typing.TypeVar("Polling", bound=ABCPolling, default=pg.Polling[HTTPClient]) 16 | LoopWrapper = typing.TypeVar( 17 | "LoopWrapper", bound=ABCLoopWrapper, default=lw.LoopWrapper 18 | ) 19 | 20 | 21 | class Mubble(typing.Generic[HTTPClient, Dispatch, Polling, LoopWrapper]): 22 | def __init__( 23 | self, 24 | api: API[HTTPClient], 25 | *, 26 | dispatch: Dispatch | None = None, 27 | polling: Polling | None = None, 28 | loop_wrapper: LoopWrapper | None = None, 29 | ) -> None: 30 | self.api = api 31 | self.dispatch = typing.cast(Dispatch, dispatch or dp.Dispatch()) 32 | self.polling = typing.cast(Polling, polling or pg.Polling(api)) 33 | self.loop_wrapper = typing.cast(LoopWrapper, loop_wrapper or lw.LoopWrapper()) 34 | 35 | def __repr__(self) -> str: 36 | return "<{}: api={!r}, dispatch={!r}, polling={!r}, loop_wrapper={!r}>".format( 37 | self.__class__.__name__, 38 | self.api, 39 | self.dispatch, 40 | self.polling, 41 | self.loop_wrapper, 42 | ) 43 | 44 | @property 45 | def on(self) -> Dispatch: 46 | return self.dispatch 47 | 48 | async def reset_webhook(self) -> None: 49 | if not (await self.api.get_webhook_info()).unwrap().url: 50 | return 51 | await self.api.delete_webhook() 52 | 53 | async def run_polling( 54 | self, 55 | *, 56 | offset: int = 0, 57 | skip_updates: bool = False, 58 | ) -> typing.NoReturn: 59 | async def polling() -> typing.NoReturn: 60 | if skip_updates: 61 | logger.debug("Dropping pending updates") 62 | await self.reset_webhook() 63 | await self.api.delete_webhook(drop_pending_updates=True) 64 | self.polling.offset = offset 65 | 66 | async for updates in self.polling.listen(): 67 | for update in updates: 68 | logger.debug( 69 | "Received update (update_id={}, update_type={!r})", 70 | update.update_id, 71 | update.update_type.name, 72 | ) 73 | self.loop_wrapper.add_task(self.dispatch.feed(update, self.api)) 74 | 75 | if self.loop_wrapper.is_running: 76 | await polling() 77 | else: 78 | self.loop_wrapper.add_task(polling()) 79 | self.loop_wrapper.run_event_loop() 80 | 81 | def run_forever( 82 | self, *, offset: int = 0, skip_updates: bool = False 83 | ) -> typing.NoReturn: 84 | logger.debug("Running blocking polling (id={})", self.api.id) 85 | self.loop_wrapper.add_task( 86 | self.run_polling(offset=offset, skip_updates=skip_updates) 87 | ) 88 | self.loop_wrapper.run_event_loop() 89 | 90 | 91 | __all__ = ("Mubble",) 92 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/middleware/abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC 4 | 5 | import typing_extensions as typing 6 | from fntypes import Some 7 | 8 | from mubble.api import API 9 | from mubble.bot.cute_types.base import BaseCute 10 | from mubble.bot.dispatch.context import Context 11 | from mubble.model import Model 12 | from mubble.modules import logger 13 | from mubble.tools.adapter.abc import ABCAdapter, run_adapter 14 | from mubble.tools.lifespan import Lifespan 15 | from mubble.types.objects import Update 16 | 17 | ToEvent = typing.TypeVar("ToEvent", bound=Model, default=typing.Any) 18 | 19 | 20 | async def run_middleware[Event: Model, R: bool | None]( 21 | method: typing.Callable[typing.Concatenate[Event, Context, ...], typing.Awaitable[R]], 22 | api: API[typing.Any], 23 | event: Event, 24 | ctx: Context, 25 | raw_event: Update | None = None, 26 | adapter: "ABCAdapter[Update, Event] | None" = None, 27 | *args: typing.Any, 28 | **kwargs: typing.Any, 29 | ) -> R: 30 | if adapter is not None: 31 | if raw_event is None: 32 | raise RuntimeError("raw_event must be specified to apply adapter") 33 | match await run_adapter(adapter, api, raw_event, ctx): 34 | case Some(val): 35 | event = val 36 | case _: 37 | return False # type: ignore 38 | 39 | logger.debug("Running {}-middleware {!r}...", method.__name__, method.__qualname__.split(".")[0]) 40 | return await method(event, ctx, *args, **kwargs) # type: ignore 41 | 42 | 43 | class ABCMiddleware[Event: Model | BaseCute](ABC): 44 | adapter: ABCAdapter[Update, Event] | None = None 45 | 46 | def __repr__(self) -> str: 47 | name = f"middleware {self.__class__.__name__!r}:" 48 | has_pre = self.pre.__qualname__.split(".")[0] != "ABCMiddleware" 49 | has_post = self.post.__qualname__.split(".")[0] != "ABCMiddleware" 50 | 51 | if has_post: 52 | name = "post-" + name 53 | if has_pre: 54 | name = "pre-" + name 55 | 56 | return "<{} with adapter={!r}>".format(name, self.adapter) 57 | 58 | async def pre(self, event: Event, ctx: Context) -> bool: ... 59 | 60 | async def post(self, event: Event, ctx: Context) -> None: ... 61 | 62 | @typing.overload 63 | def to_lifespan(self, event: Event, ctx: Context | None = None, *, api: API) -> Lifespan: ... 64 | 65 | @typing.overload 66 | def to_lifespan(self, event: Event, ctx: Context | None = None) -> Lifespan: ... 67 | 68 | def to_lifespan( 69 | self, 70 | event: Event, 71 | ctx: Context | None = None, 72 | api: API | None = None, 73 | **add_context: typing.Any, 74 | ) -> Lifespan: 75 | if api is None: 76 | if not isinstance(event, BaseCute): 77 | raise LookupError("Cannot get api, please pass as kwarg or provide BaseCute api-bound event") 78 | api = event.api 79 | 80 | ctx = ctx or Context() 81 | ctx |= add_context 82 | return Lifespan( 83 | startup_tasks=[run_middleware(self.pre, api, event, raw_event=None, ctx=ctx, adapter=None)], 84 | shutdown_tasks=[ 85 | run_middleware( 86 | self.post, 87 | api, 88 | event, 89 | raw_event=None, 90 | ctx=ctx, 91 | adapter=None, 92 | ) 93 | ], 94 | ) 95 | 96 | 97 | __all__ = ("ABCMiddleware", "run_middleware") 98 | -------------------------------------------------------------------------------- /mubble/bot/__init__.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.bot import Mubble 2 | from mubble.bot.cute_types import ( 3 | BaseCute, 4 | CallbackQueryCute, 5 | ChatJoinRequestCute, 6 | ChatMemberUpdatedCute, 7 | InlineQueryCute, 8 | MessageCute, 9 | PreCheckoutQueryCute, 10 | UpdateCute, 11 | ) 12 | from mubble.bot.dispatch import ( 13 | CALLBACK_QUERY_FOR_MESSAGE, 14 | CALLBACK_QUERY_FROM_CHAT, 15 | CALLBACK_QUERY_IN_CHAT_FOR_MESSAGE, 16 | MESSAGE_FROM_USER, 17 | MESSAGE_FROM_USER_IN_CHAT, 18 | MESSAGE_IN_CHAT, 19 | ABCDispatch, 20 | ABCHandler, 21 | ABCMiddleware, 22 | ABCReturnManager, 23 | ABCStateView, 24 | ABCView, 25 | AudioReplyHandler, 26 | BaseReturnManager, 27 | BaseStateView, 28 | BaseView, 29 | CallbackQueryReturnManager, 30 | CallbackQueryView, 31 | ChatJoinRequestView, 32 | ChatMemberView, 33 | Context, 34 | Dispatch, 35 | DocumentReplyHandler, 36 | FuncHandler, 37 | Hasher, 38 | InlineQueryReturnManager, 39 | Manager, 40 | MediaGroupReplyHandler, 41 | MessageReplyHandler, 42 | MessageReturnManager, 43 | MessageView, 44 | PhotoReplyHandler, 45 | PreCheckoutQueryManager, 46 | PreCheckoutQueryView, 47 | RawEventView, 48 | ShortState, 49 | StateViewHasher, 50 | StickerReplyHandler, 51 | VideoReplyHandler, 52 | ViewBox, 53 | WaiterMachine, 54 | clear_wm_storage_worker, 55 | register_manager, 56 | ) 57 | from mubble.bot.polling import ABCPolling, Polling 58 | from mubble.bot.rules import ( 59 | ABCRule, 60 | CallbackQueryRule, 61 | ChatJoinRequestRule, 62 | InlineQueryRule, 63 | MessageRule, 64 | ) 65 | from mubble.bot.scenario import ABCScenario, Checkbox, Choice 66 | 67 | __all__ = ( 68 | "ABCDispatch", 69 | "ABCHandler", 70 | "ABCMiddleware", 71 | "ABCPolling", 72 | "ABCReturnManager", 73 | "ABCRule", 74 | "ABCScenario", 75 | "ABCStateView", 76 | "ABCView", 77 | "AudioReplyHandler", 78 | "BaseCute", 79 | "BaseReturnManager", 80 | "BaseStateView", 81 | "BaseView", 82 | "CALLBACK_QUERY_FOR_MESSAGE", 83 | "CALLBACK_QUERY_FROM_CHAT", 84 | "CALLBACK_QUERY_IN_CHAT_FOR_MESSAGE", 85 | "CallbackQueryCute", 86 | "CallbackQueryReturnManager", 87 | "CallbackQueryRule", 88 | "CallbackQueryView", 89 | "ChatJoinRequestCute", 90 | "ChatJoinRequestRule", 91 | "ChatJoinRequestView", 92 | "ChatMemberUpdatedCute", 93 | "ChatMemberView", 94 | "Checkbox", 95 | "Choice", 96 | "Context", 97 | "Dispatch", 98 | "DocumentReplyHandler", 99 | "FuncHandler", 100 | "Hasher", 101 | "InlineQueryCute", 102 | "InlineQueryReturnManager", 103 | "InlineQueryRule", 104 | "MESSAGE_FROM_USER", 105 | "MESSAGE_FROM_USER_IN_CHAT", 106 | "MESSAGE_IN_CHAT", 107 | "Manager", 108 | "MediaGroupReplyHandler", 109 | "MessageCute", 110 | "MessageReplyHandler", 111 | "MessageReturnManager", 112 | "MessageRule", 113 | "MessageView", 114 | "PhotoReplyHandler", 115 | "Polling", 116 | "PreCheckoutQueryCute", 117 | "PreCheckoutQueryManager", 118 | "PreCheckoutQueryView", 119 | "RawEventView", 120 | "ShortState", 121 | "StateViewHasher", 122 | "StickerReplyHandler", 123 | "Mubble", 124 | "UpdateCute", 125 | "VideoReplyHandler", 126 | "ViewBox", 127 | "WaiterMachine", 128 | "clear_wm_storage_worker", 129 | "register_manager", 130 | ) 131 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/waiter_machine/middleware.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import typing 3 | 4 | from mubble.bot.cute_types.base import BaseCute 5 | from mubble.bot.dispatch.context import Context 6 | from mubble.bot.dispatch.handler.func import FuncHandler 7 | from mubble.bot.dispatch.middleware.abc import ABCMiddleware 8 | from mubble.bot.dispatch.process import check_rule 9 | from mubble.bot.dispatch.waiter_machine.short_state import ShortStateContext 10 | from mubble.modules import logger 11 | 12 | from .hasher import Hasher 13 | 14 | if typing.TYPE_CHECKING: 15 | from .machine import WaiterMachine 16 | from .short_state import ShortState 17 | 18 | 19 | INITIATOR_CONTEXT_KEY = "initiator" 20 | 21 | 22 | class WaiterMiddleware[Event: BaseCute](ABCMiddleware[Event]): 23 | def __init__( 24 | self, 25 | machine: "WaiterMachine", 26 | hasher: Hasher, 27 | ) -> None: 28 | self.machine = machine 29 | self.hasher = hasher 30 | 31 | async def pre(self, event: Event, ctx: Context) -> bool: 32 | if self.hasher not in self.machine.storage: 33 | return True 34 | 35 | key = self.hasher.get_hash_from_data_from_event(event) 36 | if not key: 37 | logger.info(f"Unable to get hash from event with hasher {self.hasher!r}") 38 | return True 39 | 40 | short_state: "ShortState[Event] | None" = self.machine.storage[self.hasher].get( 41 | key.unwrap() 42 | ) 43 | if not short_state: 44 | return True 45 | 46 | preset_context = Context(short_state=short_state) 47 | if short_state.context is not None: 48 | preset_context.update(short_state.context.context) 49 | 50 | # Run filter rule 51 | if short_state.filter and not await check_rule( 52 | event.ctx_api, 53 | short_state.filter, 54 | ctx.raw_update, 55 | preset_context, 56 | ): 57 | logger.debug("Filter rule {!r} failed", short_state.filter) 58 | return True 59 | 60 | if ( 61 | short_state.expiration_date is not None 62 | and datetime.datetime.now() >= short_state.expiration_date 63 | ): 64 | await self.machine.drop( 65 | self.hasher, 66 | self.hasher.get_data_from_event(event).unwrap(), 67 | **preset_context.copy(), 68 | ) 69 | return True 70 | 71 | handler = FuncHandler( 72 | self.pass_runtime, 73 | [short_state.release] if short_state.release else [], 74 | preset_context=preset_context, 75 | ) 76 | handler.get_name_event_param = lambda event: "event" # FIXME: HOTFIX 77 | result = await handler.check(event.ctx_api, ctx.raw_update, ctx) 78 | 79 | if result is True: 80 | await handler.run(event.api, event, ctx) 81 | 82 | elif on_miss := short_state.actions.get("on_miss"): # noqa: SIM102 83 | if await on_miss.check(event.ctx_api, ctx.raw_update, ctx): 84 | await on_miss.run(event.ctx_api, event, ctx) 85 | 86 | return False 87 | 88 | async def pass_runtime( 89 | self, 90 | event: Event, 91 | short_state: "ShortState[Event]", 92 | ctx: Context | None = None, 93 | ) -> None: 94 | if ctx is None: 95 | ctx = Context() 96 | ctx.initiator = self.hasher 97 | short_state.context = ShortStateContext(event, ctx) 98 | short_state.event.set() 99 | 100 | 101 | __all__ = ("WaiterMiddleware",) 102 | -------------------------------------------------------------------------------- /mubble/tools/lifespan.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import dataclasses 3 | import datetime 4 | import typing 5 | 6 | type CoroutineTask[T] = typing.Coroutine[typing.Any, typing.Any, T] 7 | type CoroutineFunc[**P, T] = typing.Callable[P, CoroutineTask[T]] 8 | type Task[**P, T] = CoroutineFunc[P, T] | CoroutineTask[T] | DelayedTask[typing.Callable[P, CoroutineTask[T]]] 9 | 10 | 11 | def run_tasks( 12 | tasks: list[CoroutineTask[typing.Any]], 13 | /, 14 | ) -> None: 15 | loop = asyncio.get_event_loop() 16 | while tasks: 17 | loop.run_until_complete(tasks.pop(0)) 18 | 19 | 20 | def to_coroutine_task[**P, T](task: Task[P, T], /) -> CoroutineTask[T]: 21 | if asyncio.iscoroutinefunction(task) or isinstance(task, DelayedTask): 22 | task = task() 23 | elif not asyncio.iscoroutine(task): 24 | raise TypeError("Task should be coroutine or coroutine function.") 25 | return task 26 | 27 | 28 | @dataclasses.dataclass(slots=True) 29 | class DelayedTask[Handler: CoroutineFunc[..., typing.Any]]: 30 | handler: Handler 31 | seconds: float | datetime.timedelta 32 | repeat: bool = dataclasses.field(default=False, kw_only=True) 33 | _cancelled: bool = dataclasses.field(default=False, init=False, repr=False) 34 | 35 | @property 36 | def is_cancelled(self) -> bool: 37 | return self._cancelled 38 | 39 | @property 40 | def delay(self) -> float: 41 | return self.seconds if isinstance(self.seconds, int | float) else self.seconds.total_seconds() 42 | 43 | async def __call__(self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: 44 | while not self.is_cancelled: 45 | await asyncio.sleep(self.delay) 46 | if self.is_cancelled: 47 | break 48 | try: 49 | await self.handler(*args, **kwargs) 50 | finally: 51 | if not self.repeat: 52 | break 53 | 54 | def cancel(self) -> None: 55 | if not self._cancelled: 56 | self._cancelled = True 57 | 58 | 59 | @dataclasses.dataclass(kw_only=True, slots=True, frozen=True) 60 | class Lifespan: 61 | startup_tasks: list[CoroutineTask[typing.Any]] = dataclasses.field(default_factory=lambda: []) 62 | shutdown_tasks: list[CoroutineTask[typing.Any]] = dataclasses.field(default_factory=lambda: []) 63 | 64 | def on_startup[**P, T](self, task: Task[P, T], /) -> Task[P, T]: 65 | self.startup_tasks.append(to_coroutine_task(task)) 66 | return task 67 | 68 | def on_shutdown[**P, T](self, task: Task[P, T], /) -> Task[P, T]: 69 | self.shutdown_tasks.append(to_coroutine_task(task)) 70 | return task 71 | 72 | def start(self) -> None: 73 | run_tasks(self.startup_tasks) 74 | 75 | def shutdown(self) -> None: 76 | run_tasks(self.shutdown_tasks) 77 | 78 | def __enter__(self) -> None: 79 | self.start() 80 | 81 | def __exit__(self) -> None: 82 | self.shutdown() 83 | 84 | async def __aenter__(self) -> None: 85 | for task in self.startup_tasks: 86 | await task 87 | 88 | async def __aexit__(self, *args) -> None: 89 | for task in self.shutdown_tasks: 90 | await task 91 | 92 | def __add__(self, other: typing.Self, /) -> typing.Self: 93 | return self.__class__( 94 | startup_tasks=self.startup_tasks + other.startup_tasks, 95 | shutdown_tasks=self.shutdown_tasks + other.shutdown_tasks, 96 | ) 97 | 98 | 99 | __all__ = ( 100 | "CoroutineTask", 101 | "DelayedTask", 102 | "Lifespan", 103 | "run_tasks", 104 | "to_coroutine_task", 105 | ) 106 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "mubble" 7 | version = "1.6.0" 8 | description = "Modern Async Telegram framework for bot building" 9 | authors = [{ name = "vladislavkovalskyi", email = "vladislavkovalskyi@icloud.com" }] 10 | requires-python = ">=3.12,<4.0" 11 | readme = "README.md" 12 | maintainers = [{ name = "vladislavkovalskyi", email = "vladislavkovalskyi@icloud.com" }] 13 | keywords = [ 14 | "asyncio", 15 | "async", 16 | "bot api", 17 | "telegram", 18 | "telegram bot api", 19 | "telegram framework", 20 | "mubble", 21 | "best framework", 22 | "mubble framework", 23 | "bot building", 24 | "bot building framework" 25 | ] 26 | classifiers = [ 27 | "Typing :: Typed", 28 | "Environment :: Console", 29 | "Intended Audience :: Developers", 30 | "License :: OSI Approved :: MIT License", 31 | "Programming Language :: Python :: 3.12", 32 | "Programming Language :: Python :: 3.13", 33 | "Topic :: Software Development :: Libraries :: Python Modules", 34 | "Topic :: Software Development :: Quality Assurance", 35 | ] 36 | dependencies = [ 37 | "aiohttp>=3.11.11,<4.0.0", 38 | "msgspec>=0.19.0,<0.20.0", 39 | "fntypes>=0.1.4.post3,<0.2.0", 40 | "certifi>=2025.1.31", 41 | "colorama>=0.4.6,<0.5.0", 42 | "vbml>=1.1.post1,<2.0", 43 | "choicelib>=0.1.5,<0.2.0", 44 | "envparse>=0.2.0,<0.3.0", 45 | "typing-extensions>=4.12.2,<5.0.0", 46 | ] 47 | 48 | [project.optional-dependencies] 49 | all = [ 50 | "uvloop>=0.21.0", 51 | "loguru>=0.7.0", 52 | ] 53 | fast = ["uvloop>=0.21.0"] 54 | uvloop = ["uvloop>=0.21.0"] 55 | loguru = ["loguru>=0.7.0"] 56 | 57 | [project.urls] 58 | Source = "https://github.com/vladislavkovalskyi/mubble" 59 | Documentation = "https://github.com/vladislavkovalskyi/mubble" 60 | 61 | [tool.hatch.build.targets.wheel] 62 | packages = ["mubble"] 63 | 64 | [tool.hatch.envs.dev] 65 | dependencies = [ 66 | "pre-commit>=4.1.0,<5.0", 67 | "ruff>=0.9.2,<0.10", 68 | "basedpyright>=1.28.1,<2", 69 | "requests>=2.28.1,<3", 70 | "sort-all>=1.2.0,<2", 71 | "libcst>=1.4.0,<2", 72 | "pytest>=8.0.0,<9", 73 | "pytest-asyncio>=0.23.5,<0.26.0", 74 | "pytest-cov>=5,<7", 75 | "pytest-mock>=3.10.0,<4", 76 | ] 77 | 78 | [tool.uv] 79 | package = true 80 | 81 | [tool.ruff] 82 | line-length = 115 83 | target-version = "py312" 84 | 85 | [tool.ruff.format] 86 | quote-style = "double" 87 | docstring-code-line-length = "dynamic" 88 | 89 | [tool.ruff.lint] 90 | select = [ 91 | "I", 92 | "D", 93 | "N", 94 | "PLR", 95 | "Q", 96 | "COM", 97 | "TC", 98 | "YTT", 99 | "SIM" 100 | ] 101 | ignore = [ 102 | "COM812", 103 | "N805", 104 | "N818", 105 | "TC001", 106 | "TC002", 107 | "TC003", 108 | "TC004", 109 | "D100", 110 | "D101", 111 | "D102", 112 | "D103", 113 | "D104", 114 | "D105", 115 | "D107", 116 | "D202", 117 | "D203", 118 | "D205", 119 | "D209", 120 | "D211", 121 | "D213", 122 | "D400", 123 | "D401", 124 | "D404", 125 | "D415", 126 | "PLR2004", 127 | "PLR0911", 128 | "PLR0912", 129 | "PLR0913" 130 | ] 131 | fixable = ["ALL"] 132 | 133 | [tool.ruff.lint.per-file-ignores] 134 | "__init__.py" = ["F401", "F403"] 135 | "typegen/**.py" = ["N802"] 136 | 137 | [tool.pytest.ini_options] 138 | asyncio_mode = "auto" 139 | asyncio_default_fixture_loop_scope = "function" 140 | 141 | [tool.pyright] 142 | exclude = [ 143 | ".env", 144 | "**/__pycache__", 145 | "docs", 146 | "local", 147 | ] 148 | typeCheckingMode = "basic" 149 | pythonPlatform = "All" 150 | pythonVersion = "3.12" 151 | reportMissingImports = true 152 | reportMissingTypeStubs = false -------------------------------------------------------------------------------- /mubble/api/api.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property 2 | 3 | import msgspec 4 | import typing_extensions as typing 5 | from fntypes.result import Error, Ok, Result 6 | 7 | from mubble.api.error import APIError 8 | from mubble.api.response import APIResponse 9 | from mubble.api.token import Token 10 | from mubble.client import ABCClient, AiohttpClient, MultipartFormProto 11 | from mubble.model import decoder 12 | from mubble.types.methods import APIMethods 13 | 14 | HTTPClient = typing.TypeVar("HTTPClient", bound=ABCClient, default=AiohttpClient) 15 | 16 | type Json = str | int | float | bool | list[Json] | dict[str, Json] | None 17 | 18 | 19 | def compose_data[MultipartForm: MultipartFormProto]( 20 | client: ABCClient[MultipartForm], 21 | data: dict[str, typing.Any], 22 | files: dict[str, tuple[str, bytes]], 23 | ) -> MultipartForm: 24 | if not data and not files: 25 | return client.multipart_form_factory() 26 | return client.get_form(data=data, files=files) 27 | 28 | 29 | class API(APIMethods[HTTPClient], typing.Generic[HTTPClient]): 30 | """Bot API with available API methods and http client.""" 31 | 32 | API_URL = "https://api.telegram.org/" 33 | API_FILE_URL = "https://api.telegram.org/file/" 34 | 35 | token: Token 36 | http: HTTPClient 37 | 38 | def __init__(self, token: Token, *, http: HTTPClient | None = None) -> None: 39 | self.token = token 40 | self.http = http or AiohttpClient() # type: ignore 41 | super().__init__(api=self) 42 | 43 | def __repr__(self) -> str: 44 | return "<{}: token={!r}, http={!r}>".format( 45 | self.__class__.__name__, 46 | self.token, 47 | self.http, 48 | ) 49 | 50 | @cached_property 51 | def id(self) -> int: 52 | return self.token.bot_id 53 | 54 | @property 55 | def request_url(self) -> str: 56 | return self.API_URL + f"bot{self.token}/" 57 | 58 | @property 59 | def request_file_url(self) -> str: 60 | return self.API_FILE_URL + f"bot{self.token}/" 61 | 62 | async def download_file(self, file_path: str) -> bytes: 63 | return await self.http.request_content(f"{self.request_file_url}/{file_path}") 64 | 65 | async def request( 66 | self, 67 | method: str, 68 | data: dict[str, typing.Any] | None = None, 69 | files: dict[str, tuple[str, bytes]] | None = None, 70 | ) -> Result[Json, APIError]: 71 | """Request a `JSON` response with the `POST` HTTP method and passing data, files as `multipart/form-data`.""" 72 | response = await self.http.request_json( 73 | url=self.request_url + method, 74 | method="POST", 75 | data=compose_data(self.http, data or {}, files or {}), 76 | ) 77 | if response.get("ok", False) is True: 78 | return Ok(response["result"]) 79 | return Error( 80 | APIError( 81 | code=response.get("error_code", 400), 82 | error=response.get("description", "Something went wrong"), 83 | ), 84 | ) 85 | 86 | async def request_raw( 87 | self, 88 | method: str, 89 | data: dict[str, typing.Any] | None = None, 90 | files: dict[str, tuple[str, bytes]] | None = None, 91 | ) -> Result[msgspec.Raw, APIError]: 92 | """Request a `raw` response with the `POST` HTTP method and passing data, files as `multipart/form-data`.""" 93 | response_bytes = await self.http.request_bytes( 94 | url=self.request_url + method, 95 | method="POST", 96 | data=compose_data(self.http, data or {}, files or {}), 97 | ) 98 | return decoder.decode(response_bytes, type=APIResponse).to_result() 99 | 100 | 101 | __all__ = ("API",) 102 | -------------------------------------------------------------------------------- /mubble/bot/rules/__init__.py: -------------------------------------------------------------------------------- 1 | from mubble.bot.rules.abc import ABCRule, AndRule, NotRule, OrRule 2 | from mubble.bot.rules.callback_data import ( 3 | CallbackDataEq, 4 | CallbackDataJsonEq, 5 | CallbackDataJsonModel, 6 | CallbackDataMap, 7 | CallbackDataMarkup, 8 | CallbackQueryDataRule, 9 | CallbackQueryRule, 10 | HasData, 11 | ) 12 | from mubble.bot.rules.chat_join import ( 13 | ChatJoinRequestRule, 14 | HasInviteLink, 15 | InviteLinkByCreator, 16 | InviteLinkName, 17 | ) 18 | from mubble.bot.rules.command import Argument, Command 19 | from mubble.bot.rules.enum_text import EnumTextRule 20 | from mubble.bot.rules.func import FuncRule 21 | from mubble.bot.rules.fuzzy import FuzzyText 22 | from mubble.bot.rules.id import IdRule 23 | from mubble.bot.rules.inline import ( 24 | HasLocation, 25 | InlineQueryChatType, 26 | InlineQueryMarkup, 27 | InlineQueryRule, 28 | InlineQueryText, 29 | ) 30 | from mubble.bot.rules.integer import IntegerInRange, IsInteger 31 | from mubble.bot.rules.is_from import ( 32 | IsBot, 33 | IsChat, 34 | IsChatId, 35 | IsDice, 36 | IsDiceEmoji, 37 | IsForum, 38 | IsForward, 39 | IsForwardType, 40 | IsGroup, 41 | IsLanguageCode, 42 | IsPremium, 43 | IsPrivate, 44 | IsReply, 45 | IsSuperGroup, 46 | IsUser, 47 | IsUserId, 48 | ) 49 | from mubble.bot.rules.logic import If 50 | from mubble.bot.rules.markup import Markup 51 | from mubble.bot.rules.mention import HasMention 52 | from mubble.bot.rules.message import MessageRule 53 | from mubble.bot.rules.message_entities import HasEntities, MessageEntities 54 | from mubble.bot.rules.node import NodeRule 55 | from mubble.bot.rules.payload import ( 56 | PayloadEqRule, 57 | PayloadJsonEqRule, 58 | PayloadMarkupRule, 59 | PayloadModelRule, 60 | PayloadRule, 61 | ) 62 | from mubble.bot.rules.payment_invoice import ( 63 | PaymentInvoiceCurrency, 64 | PaymentInvoiceRule, 65 | ) 66 | from mubble.bot.rules.regex import Regex 67 | from mubble.bot.rules.rule_enum import RuleEnum 68 | from mubble.bot.rules.start import StartCommand 69 | from mubble.bot.rules.state import State, StateMeta 70 | from mubble.bot.rules.text import HasCaption, HasText, Text 71 | from mubble.bot.rules.update import IsUpdateType 72 | 73 | __all__ = ( 74 | "ABCRule", 75 | "AndRule", 76 | "Argument", 77 | "CallbackDataEq", 78 | "CallbackDataJsonEq", 79 | "CallbackDataJsonModel", 80 | "CallbackDataMap", 81 | "CallbackDataMarkup", 82 | "CallbackQueryDataRule", 83 | "CallbackQueryRule", 84 | "ChatJoinRequestRule", 85 | "Command", 86 | "EnumTextRule", 87 | "FuncRule", 88 | "FuzzyText", 89 | "HasCaption", 90 | "HasData", 91 | "HasEntities", 92 | "HasInviteLink", 93 | "HasLocation", 94 | "HasMention", 95 | "HasText", 96 | "IdRule", 97 | "If", 98 | "InlineQueryChatType", 99 | "InlineQueryMarkup", 100 | "InlineQueryRule", 101 | "InlineQueryText", 102 | "IntegerInRange", 103 | "InviteLinkByCreator", 104 | "InviteLinkName", 105 | "IsBot", 106 | "IsChat", 107 | "IsChatId", 108 | "IsDice", 109 | "IsDiceEmoji", 110 | "IsForum", 111 | "IsForward", 112 | "IsForwardType", 113 | "IsGroup", 114 | "IsInteger", 115 | "IsLanguageCode", 116 | "IsPremium", 117 | "IsPrivate", 118 | "IsReply", 119 | "IsSuperGroup", 120 | "IsUpdateType", 121 | "IsUser", 122 | "IsUserId", 123 | "Markup", 124 | "MessageEntities", 125 | "MessageRule", 126 | "NodeRule", 127 | "NotRule", 128 | "OrRule", 129 | "PayloadEqRule", 130 | "PayloadJsonEqRule", 131 | "PayloadMarkupRule", 132 | "PayloadModelRule", 133 | "PayloadRule", 134 | "PaymentInvoiceCurrency", 135 | "PaymentInvoiceRule", 136 | "Regex", 137 | "RuleEnum", 138 | "StartCommand", 139 | "State", 140 | "StateMeta", 141 | "Text", 142 | ) 143 | -------------------------------------------------------------------------------- /mubble/bot/dispatch/return_manager/abc.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import types 3 | import typing 4 | from abc import ABC, abstractmethod 5 | 6 | from mubble.bot.dispatch.context import Context 7 | from mubble.model import Model 8 | from mubble.modules import logger 9 | 10 | 11 | def get_union_types(t: types.UnionType | typing.Any) -> tuple[type[typing.Any], ...] | None: 12 | if type(t) in (types.UnionType, typing._UnionGenericAlias): # type: ignore 13 | return tuple(typing.get_origin(x) or x for x in typing.get_args(t)) 14 | return None 15 | 16 | 17 | def register_manager(return_type: type[typing.Any] | types.UnionType): 18 | def wrapper(func: typing.Callable[..., typing.Awaitable[typing.Any]]): 19 | return Manager(get_union_types(return_type) or (return_type,), func) # type: ignore 20 | 21 | return wrapper 22 | 23 | 24 | @dataclasses.dataclass(frozen=True, slots=True) 25 | class Manager: 26 | types: tuple[type, ...] 27 | callback: typing.Callable[..., typing.Awaitable[typing.Any]] 28 | 29 | async def __call__(self, *args: typing.Any, **kwargs: typing.Any) -> None: 30 | await self.callback(*args, **kwargs) 31 | 32 | 33 | class ABCReturnManager[Event: Model](ABC): 34 | @abstractmethod 35 | async def run(self, response: typing.Any, event: Event, ctx: Context) -> None: 36 | pass 37 | 38 | 39 | class BaseReturnManager[Event: Model](ABCReturnManager[Event]): 40 | def __repr__(self) -> str: 41 | return "<{}: {}>".format( 42 | self.__class__.__name__, 43 | ", ".join(x.callback.__name__ + "=" + repr(x) for x in self.managers), 44 | ) 45 | 46 | @property 47 | def managers(self) -> list[Manager]: 48 | managers = self.__dict__.get("managers") 49 | if managers is not None: 50 | return managers 51 | managers_lst = [ 52 | manager 53 | for manager in (vars(BaseReturnManager) | vars(self.__class__)).values() 54 | if isinstance(manager, Manager) 55 | ] 56 | self.__dict__["managers"] = managers_lst 57 | return managers_lst 58 | 59 | @register_manager(Context) 60 | @staticmethod 61 | async def ctx_manager(value: Context, event: Event, ctx: Context) -> None: 62 | """Basic manager for returning context from handler.""" 63 | ctx.update(value) 64 | 65 | async def run(self, response: typing.Any, event: Event, ctx: Context) -> None: 66 | logger.debug("Run return manager for response: {!r}", response) 67 | for manager in self.managers: 68 | if typing.Any in manager.types or any(type(response) is x for x in manager.types): 69 | logger.debug("Run manager {!r}...", manager.callback.__name__) 70 | await manager(response, event, ctx) 71 | 72 | @typing.overload 73 | def register_manager[T]( 74 | self, 75 | return_type: type[T], 76 | ) -> typing.Callable[[typing.Callable[[T, Event, Context], typing.Awaitable[typing.Any]]], Manager]: ... 77 | 78 | @typing.overload 79 | def register_manager[T]( 80 | self, 81 | return_type: tuple[type[T], ...], 82 | ) -> typing.Callable[ 83 | [typing.Callable[[tuple[T, ...], Event, Context], typing.Awaitable[typing.Any]]], 84 | Manager, 85 | ]: ... 86 | 87 | def register_manager[T]( 88 | self, 89 | return_type: type[T] | tuple[type[T], ...], 90 | ) -> typing.Callable[ 91 | [typing.Callable[[T | tuple[T, ...], Event, Context], typing.Awaitable[typing.Any]]], 92 | Manager, 93 | ]: 94 | def wrapper(func: typing.Callable[[T, Event, Context], typing.Awaitable]) -> Manager: 95 | manager = Manager(get_union_types(return_type) or (return_type,), func) # type: ignore 96 | self.managers.append(manager) 97 | return manager 98 | 99 | return wrapper 100 | 101 | 102 | __all__ = ( 103 | "ABCReturnManager", 104 | "BaseReturnManager", 105 | "Manager", 106 | "get_union_types", 107 | "register_manager", 108 | ) 109 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quick Start Guide 2 | 3 | This guide will help you create your first Telegram bot with Mubble. 4 | 5 | ## Prerequisites 6 | 7 | Before you begin, make sure you have: 8 | 9 | 1. Installed Mubble (see [Installation](installation.md)) 10 | 2. Created a Telegram bot and obtained a token from [@BotFather](https://t.me/BotFather) 11 | 12 | ## Creating Your First Bot 13 | 14 | Let's create a simple echo bot that responds to the `/start` command and echoes back any text messages it receives. 15 | 16 | ### 1. Set Up Your Project 17 | 18 | Create a new directory for your project and create a file named `bot.py`: 19 | 20 | ```bash 21 | mkdir my_first_bot 22 | cd my_first_bot 23 | touch bot.py 24 | ``` 25 | 26 | ### 2. Write the Bot Code 27 | 28 | Open `bot.py` in your favorite editor and add the following code: 29 | 30 | ```python 31 | from mubble import API, Message, Mubble, Token 32 | from mubble.modules import logger 33 | from mubble.rules import StartCommand, Text 34 | 35 | # Initialize the API with your bot token 36 | api = API(token=Token("YOUR_BOT_TOKEN")) # Replace with your actual token 37 | # Or load from environment variable: 38 | # api = API(token=Token.from_env()) # Set TOKEN environment variable 39 | 40 | # Create a bot instance 41 | bot = Mubble(api) 42 | 43 | # Set logging level 44 | logger.set_level("INFO") 45 | 46 | # Handler for /start command 47 | @bot.on.message(StartCommand()) 48 | async def start_handler(message: Message) -> None: 49 | await message.answer(f"Hello, {message.from_user.full_name}! I'm an echo bot created with Mubble.") 50 | 51 | # Handler for any text message 52 | @bot.on.message(Text()) 53 | async def echo_handler(message: Message) -> None: 54 | await message.answer(f"You said: {message.text}") 55 | 56 | # Run the bot 57 | if __name__ == "__main__": 58 | bot.run_forever() 59 | ``` 60 | 61 | ### 3. Run Your Bot 62 | 63 | Run your bot with: 64 | 65 | ```bash 66 | python bot.py 67 | ``` 68 | 69 | ### 4. Test Your Bot 70 | 71 | Open Telegram and search for your bot by its username. Start a conversation and: 72 | 73 | 1. Send the `/start` command - the bot should greet you 74 | 2. Send any text message - the bot should echo it back 75 | 76 | ## Adding More Features 77 | 78 | Let's enhance our bot with a few more features: 79 | 80 | ### Adding Command Handlers 81 | 82 | ```python 83 | @bot.on.message(Text("/help")) 84 | async def help_handler(message: Message) -> None: 85 | await message.answer( 86 | "This is a simple echo bot created with Mubble.\n" 87 | "Available commands:\n" 88 | "/start - Start the bot\n" 89 | "/help - Show this help message" 90 | ) 91 | ``` 92 | 93 | ### Adding Inline Keyboards 94 | 95 | ```python 96 | from mubble.tools.keyboard import InlineKeyboard, InlineButton 97 | from mubble import CallbackQuery 98 | 99 | @bot.on.message(Text("/menu")) 100 | async def menu_handler(message: Message) -> None: 101 | keyboard = ( 102 | InlineKeyboard() 103 | .add(InlineButton("Option 1", callback_data="option_1")) 104 | .add(InlineButton("Option 2", callback_data="option_2")) 105 | ).get_markup() 106 | 107 | await message.answer("Please select an option:", reply_markup=keyboard) 108 | 109 | @bot.on.callback_query(lambda cq: cq.data == "option_1") 110 | async def option_1_handler(callback_query: CallbackQuery) -> None: 111 | await callback_query.answer("You selected Option 1!") 112 | await callback_query.message.answer("You clicked Option 1") 113 | 114 | @bot.on.callback_query(lambda cq: cq.data == "option_2") 115 | async def option_2_handler(callback_query: CallbackQuery) -> None: 116 | await callback_query.answer("You selected Option 2!") 117 | await callback_query.message.answer("You clicked Option 2") 118 | ``` 119 | 120 | ## Next Steps 121 | 122 | Now that you've created your first bot, you can: 123 | 124 | - Learn about [Basic Concepts](basic-concepts.md) 125 | - Explore more [Examples](https://github.com/vladislavkovalskyi/mubble/blob/master/examples) 126 | - Dive into the [API Client](api-client.md) documentation -------------------------------------------------------------------------------- /mubble/bot/dispatch/view/raw.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from mubble.api.api import API 4 | from mubble.bot.cute_types.base import BaseCute 5 | from mubble.bot.cute_types.update import UpdateCute 6 | from mubble.bot.dispatch.context import Context 7 | from mubble.bot.dispatch.handler.func import Func, FuncHandler 8 | from mubble.bot.dispatch.process import process_inner 9 | from mubble.bot.dispatch.view.abc import ABCEventRawView 10 | from mubble.bot.dispatch.view.base import BaseView 11 | from mubble.bot.rules.abc import ABCRule 12 | from mubble.tools.error_handler.error_handler import ABCErrorHandler, ErrorHandler 13 | from mubble.types.enums import UpdateType 14 | from mubble.types.objects import Update 15 | 16 | 17 | class RawEventView(ABCEventRawView[UpdateCute], BaseView[UpdateCute]): 18 | @typing.overload 19 | def __call__[**P, R]( 20 | self, 21 | *rules: ABCRule, 22 | update_type: UpdateType, 23 | final: bool = True, 24 | ) -> typing.Callable[ 25 | [Func[P, R]], 26 | FuncHandler[BaseCute[typing.Any], Func[P, R], ErrorHandler[BaseCute[typing.Any]]], 27 | ]: ... 28 | 29 | @typing.overload 30 | def __call__[**P, Dataclass, R]( 31 | self, 32 | *rules: ABCRule, 33 | dataclass: type[Dataclass], 34 | final: bool = True, 35 | ) -> typing.Callable[[Func[P, R]], FuncHandler[Dataclass, Func[P, R], ErrorHandler[Dataclass]]]: ... 36 | 37 | @typing.overload 38 | def __call__[**P, Dataclass, R]( 39 | self, 40 | *rules: ABCRule, 41 | update_type: UpdateType, 42 | dataclass: type[Dataclass], 43 | final: bool = True, 44 | ) -> typing.Callable[[Func[P, R]], FuncHandler[Dataclass, Func[P, R], ErrorHandler[Dataclass]]]: ... 45 | 46 | @typing.overload 47 | def __call__[**P, ErrorHandlerT: ABCErrorHandler, R]( 48 | self, 49 | *rules: ABCRule, 50 | error_handler: ErrorHandlerT, 51 | final: bool = True, 52 | ) -> typing.Callable[ 53 | [Func[P, R]], 54 | FuncHandler[BaseCute[typing.Any], Func[P, R], ErrorHandlerT], 55 | ]: ... 56 | 57 | @typing.overload 58 | def __call__[**P, ErrorHandlerT: ABCErrorHandler, R]( 59 | self, 60 | *rules: ABCRule, 61 | error_handler: ErrorHandlerT, 62 | update_type: UpdateType, 63 | final: bool = True, 64 | ) -> typing.Callable[ 65 | [Func[P, R]], 66 | FuncHandler[BaseCute[typing.Any], Func[P, R], ErrorHandlerT], 67 | ]: ... 68 | 69 | @typing.overload 70 | def __call__[**P, Dataclass, ErrorHandlerT: ABCErrorHandler, R]( 71 | self, 72 | *rules: ABCRule, 73 | update_type: UpdateType, 74 | dataclass: type[Dataclass], 75 | error_handler: ErrorHandlerT, 76 | final: bool = True, 77 | ) -> typing.Callable[[Func[P, R]], FuncHandler[Dataclass, Func[P, R], ErrorHandlerT]]: ... 78 | 79 | def __call__( 80 | self, 81 | *rules: ABCRule, 82 | update_type: UpdateType | None = None, 83 | dataclass: type[typing.Any] | None = None, 84 | error_handler: ABCErrorHandler | None = None, 85 | final: bool = True, 86 | ) -> typing.Callable[..., typing.Any]: 87 | def wrapper(func: typing.Callable[..., typing.Any]): 88 | func_handler = FuncHandler( 89 | func, 90 | rules=[*self.auto_rules, *rules], 91 | final=final, 92 | dataclass=dataclass, 93 | error_handler=error_handler or ErrorHandler(), 94 | update_type=update_type, 95 | ) 96 | self.handlers.append(func_handler) 97 | return func_handler 98 | 99 | return wrapper 100 | 101 | async def check(self, event: Update) -> bool: 102 | return bool(self.handlers) or bool(self.middlewares) 103 | 104 | async def process(self, event: Update, api: API, context: Context) -> bool: 105 | return await process_inner( 106 | api, 107 | UpdateCute.from_update(event, bound_api=api), 108 | event, 109 | context, 110 | self.middlewares, 111 | self.handlers, 112 | self.return_manager, 113 | ) 114 | 115 | 116 | __all__ = ("RawEventView",) 117 | -------------------------------------------------------------------------------- /mubble/tools/buttons.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | 4 | import msgspec 5 | 6 | from mubble.msgspec_utils import encoder 7 | from mubble.types.objects import ( 8 | CallbackGame, 9 | CopyTextButton, 10 | KeyboardButtonPollType, 11 | KeyboardButtonRequestChat, 12 | KeyboardButtonRequestUsers, 13 | LoginUrl, 14 | SwitchInlineQueryChosenChat, 15 | WebAppInfo, 16 | ) 17 | 18 | from .callback_data_serilization import ABCDataSerializer, JSONSerializer 19 | 20 | if typing.TYPE_CHECKING: 21 | from _typeshed import DataclassInstance 22 | 23 | type CallbackData = str | bytes | dict[str, typing.Any] | DataclassInstance | msgspec.Struct 24 | 25 | 26 | @dataclasses.dataclass 27 | class BaseButton: 28 | def get_data(self) -> dict[str, typing.Any]: 29 | return {k: v for k, v in dataclasses.asdict(self).items() if v is not None} 30 | 31 | 32 | class RowButtons[KeyboardButton: BaseButton]: 33 | buttons: list[KeyboardButton] 34 | auto_row: bool 35 | 36 | def __init__(self, *buttons: KeyboardButton, auto_row: bool = True) -> None: 37 | self.buttons = list(buttons) 38 | self.auto_row = auto_row 39 | 40 | def get_data(self) -> list[dict[str, typing.Any]]: 41 | return [b.get_data() for b in self.buttons] 42 | 43 | 44 | @dataclasses.dataclass 45 | class Button(BaseButton): 46 | text: str 47 | request_contact: bool = dataclasses.field(default=False, kw_only=True) 48 | request_location: bool = dataclasses.field(default=False, kw_only=True) 49 | request_chat: KeyboardButtonRequestChat | None = dataclasses.field( 50 | default=None, 51 | kw_only=True, 52 | ) 53 | request_user: KeyboardButtonRequestUsers | None = dataclasses.field(default=None, kw_only=True) 54 | request_poll: KeyboardButtonPollType | None = dataclasses.field( 55 | default=None, 56 | kw_only=True, 57 | ) 58 | web_app: WebAppInfo | None = dataclasses.field(default=None, kw_only=True) 59 | 60 | 61 | @dataclasses.dataclass 62 | class InlineButton(BaseButton): 63 | text: str 64 | url: str | None = dataclasses.field(default=None, kw_only=True) 65 | login_url: LoginUrl | None = dataclasses.field(default=None, kw_only=True) 66 | pay: bool | None = dataclasses.field(default=None, kw_only=True) 67 | callback_data: CallbackData | None = dataclasses.field(default=None, kw_only=True) 68 | callback_data_serializer: dataclasses.InitVar[ABCDataSerializer[typing.Any] | None] = dataclasses.field( 69 | default=None, 70 | kw_only=True, 71 | ) 72 | callback_game: CallbackGame | None = dataclasses.field(default=None, kw_only=True) 73 | copy_text: str | CopyTextButton | None = dataclasses.field(default=None, kw_only=True) 74 | switch_inline_query: str | None = dataclasses.field(default=None, kw_only=True) 75 | switch_inline_query_current_chat: str | None = dataclasses.field(default=None, kw_only=True) 76 | switch_inline_query_chosen_chat: SwitchInlineQueryChosenChat | None = dataclasses.field( 77 | default=None, 78 | kw_only=True, 79 | ) 80 | web_app: str | WebAppInfo | None = dataclasses.field(default=None, kw_only=True) 81 | 82 | def __post_init__(self, callback_data_serializer: ABCDataSerializer[typing.Any] | None) -> None: 83 | if ( 84 | callback_data_serializer is None 85 | and isinstance(self.callback_data, msgspec.Struct | dict) 86 | or dataclasses.is_dataclass(self.callback_data) 87 | ): 88 | callback_data_serializer = callback_data_serializer or JSONSerializer( 89 | self.callback_data.__class__, 90 | ) 91 | 92 | if callback_data_serializer is not None: 93 | self.callback_data = callback_data_serializer.serialize(self.callback_data) 94 | elif self.callback_data is not None and not isinstance(self.callback_data, str | bytes): 95 | self.callback_data = encoder.encode(self.callback_data) 96 | 97 | if isinstance(self.copy_text, str): 98 | self.copy_text = CopyTextButton(text=self.copy_text) 99 | 100 | if isinstance(self.web_app, str): 101 | self.web_app = WebAppInfo(url=self.web_app) 102 | 103 | 104 | __all__ = ( 105 | "BaseButton", 106 | "Button", 107 | "InlineButton", 108 | "RowButtons", 109 | ) 110 | --------------------------------------------------------------------------------