maxapi-sdk 0.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. maxapi/__init__.py +49 -0
  2. maxapi/bot.py +674 -0
  3. maxapi/builders/__init__.py +23 -0
  4. maxapi/builders/keyboards.py +133 -0
  5. maxapi/builders/media.py +81 -0
  6. maxapi/callback_schema.py +140 -0
  7. maxapi/client/__init__.py +3 -0
  8. maxapi/client/default.py +89 -0
  9. maxapi/compat/__init__.py +15 -0
  10. maxapi/connection/__init__.py +3 -0
  11. maxapi/connection/base.py +136 -0
  12. maxapi/dispatcher.py +487 -0
  13. maxapi/exceptions/__init__.py +3 -0
  14. maxapi/exceptions/max.py +45 -0
  15. maxapi/filters/__init__.py +25 -0
  16. maxapi/filters/base.py +73 -0
  17. maxapi/filters/command.py +28 -0
  18. maxapi/filters/common.py +78 -0
  19. maxapi/filters/text.py +71 -0
  20. maxapi/fsm/__init__.py +17 -0
  21. maxapi/fsm/context.py +41 -0
  22. maxapi/fsm/filters.py +30 -0
  23. maxapi/fsm/middleware.py +42 -0
  24. maxapi/fsm/state.py +33 -0
  25. maxapi/fsm/storage/__init__.py +4 -0
  26. maxapi/fsm/storage/base.py +30 -0
  27. maxapi/fsm/storage/memory.py +38 -0
  28. maxapi/middlewares/__init__.py +3 -0
  29. maxapi/middlewares/base.py +34 -0
  30. maxapi/plugins/__init__.py +3 -0
  31. maxapi/plugins/base.py +19 -0
  32. maxapi/py.typed +1 -0
  33. maxapi/runners/__init__.py +4 -0
  34. maxapi/runners/polling.py +55 -0
  35. maxapi/runners/webhook.py +72 -0
  36. maxapi/transport/__init__.py +13 -0
  37. maxapi/transport/client.py +239 -0
  38. maxapi/transport/config.py +81 -0
  39. maxapi/transport/errors.py +45 -0
  40. maxapi/types/__init__.py +73 -0
  41. maxapi/types/base.py +9 -0
  42. maxapi/types/bot_mixin.py +14 -0
  43. maxapi/types/models.py +308 -0
  44. maxapi_sdk-0.12.0.dist-info/METADATA +270 -0
  45. maxapi_sdk-0.12.0.dist-info/RECORD +48 -0
  46. maxapi_sdk-0.12.0.dist-info/WHEEL +5 -0
  47. maxapi_sdk-0.12.0.dist-info/licenses/LICENSE +21 -0
  48. maxapi_sdk-0.12.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..callback_schema import extract_callback_value
6
+ from .base import BaseFilter
7
+
8
+
9
+ class ChatId(BaseFilter):
10
+ def __init__(self, *chat_ids: int) -> None:
11
+ self.chat_ids = set(chat_ids)
12
+
13
+ async def __call__(self, event: Any) -> bool:
14
+ chat_id = getattr(event, "chat_id", None)
15
+ return chat_id in self.chat_ids
16
+
17
+
18
+ class UserId(BaseFilter):
19
+ def __init__(self, *user_ids: int) -> None:
20
+ self.user_ids = set(user_ids)
21
+
22
+ async def __call__(self, event: Any) -> bool:
23
+ user_id = getattr(event, "user_id", None)
24
+ return user_id in self.user_ids
25
+
26
+
27
+ class CallbackData(BaseFilter):
28
+ def __init__(
29
+ self,
30
+ value: str,
31
+ *,
32
+ case_sensitive: bool = True,
33
+ startswith: bool = False,
34
+ contains: bool = False,
35
+ ) -> None:
36
+ self.value = value
37
+ self.case_sensitive = case_sensitive
38
+ self.startswith = startswith
39
+ self.contains = contains
40
+
41
+ async def __call__(self, event: Any) -> bool:
42
+ callback = getattr(getattr(event, "update", None), "callback", None)
43
+ payload = getattr(callback, "payload", None)
44
+ payload_value = extract_callback_value(payload)
45
+ if payload_value is None:
46
+ return False
47
+ return self._compare(payload_value)
48
+
49
+ def _compare(self, other: str) -> bool:
50
+ left = other if self.case_sensitive else other.lower()
51
+ right = self.value if self.case_sensitive else self.value.lower()
52
+ if self.startswith:
53
+ return left.startswith(right)
54
+ if self.contains:
55
+ return right in left
56
+ return left == right
57
+
58
+
59
+ class HasAttachments(BaseFilter):
60
+ async def __call__(self, event: Any) -> bool:
61
+ message = getattr(event, "message", None)
62
+ if message is None or getattr(message, "body", None) is None:
63
+ return False
64
+ attachments = getattr(message.body, "attachments", None)
65
+ return bool(attachments)
66
+
67
+
68
+ class ChatType(BaseFilter):
69
+ def __init__(self, *chat_types: str) -> None:
70
+ self.chat_types = {value.lower() for value in chat_types}
71
+
72
+ async def __call__(self, event: Any) -> bool:
73
+ message = getattr(event, "message", None)
74
+ recipient = getattr(message, "recipient", None) if message is not None else None
75
+ recipient_type = getattr(recipient, "type", None)
76
+ if recipient_type is None:
77
+ return False
78
+ return recipient_type.lower() in self.chat_types
maxapi/filters/text.py ADDED
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Pattern
5
+
6
+ from .base import BaseFilter
7
+
8
+
9
+ class Text(BaseFilter):
10
+ """Точное совпадение текста сообщения."""
11
+
12
+ def __init__(self, value: str, *, case_sensitive: bool = False, strip: bool = True) -> None:
13
+ self.value = value
14
+ self.case_sensitive = case_sensitive
15
+ self.strip = strip
16
+
17
+ async def __call__(self, event: Any) -> bool:
18
+ text = _get_message_text(event)
19
+ if text is None:
20
+ return False
21
+ if self.strip:
22
+ text = text.strip()
23
+ if self.case_sensitive:
24
+ return text == self.value
25
+ return text.lower() == self.value.lower()
26
+
27
+
28
+ class TextContains(BaseFilter):
29
+ def __init__(self, value: str, *, case_sensitive: bool = False) -> None:
30
+ self.value = value
31
+ self.case_sensitive = case_sensitive
32
+
33
+ async def __call__(self, event: Any) -> bool:
34
+ text = _get_message_text(event)
35
+ if text is None:
36
+ return False
37
+ if self.case_sensitive:
38
+ return self.value in text
39
+ return self.value.lower() in text.lower()
40
+
41
+
42
+ class TextStartsWith(BaseFilter):
43
+ def __init__(self, value: str, *, case_sensitive: bool = False) -> None:
44
+ self.value = value
45
+ self.case_sensitive = case_sensitive
46
+
47
+ async def __call__(self, event: Any) -> bool:
48
+ text = _get_message_text(event)
49
+ if text is None:
50
+ return False
51
+ if self.case_sensitive:
52
+ return text.startswith(self.value)
53
+ return text.lower().startswith(self.value.lower())
54
+
55
+
56
+ class Regex(BaseFilter):
57
+ def __init__(self, pattern: str | Pattern[str], *, flags: int = 0) -> None:
58
+ self.pattern = re.compile(pattern, flags) if isinstance(pattern, str) else pattern
59
+
60
+ async def __call__(self, event: Any) -> bool:
61
+ text = _get_message_text(event)
62
+ if text is None:
63
+ return False
64
+ return self.pattern.search(text) is not None
65
+
66
+
67
+ def _get_message_text(event: Any) -> str | None:
68
+ message = getattr(event, "message", None)
69
+ if message is None or getattr(message, "body", None) is None:
70
+ return None
71
+ return getattr(message.body, "text", None)
maxapi/fsm/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ from .context import FSMContext
2
+ from .filters import StateFilter
3
+ from .middleware import FSMMiddleware
4
+ from .state import State, StatesGroup
5
+ from .storage.base import BaseStorage, StorageKey
6
+ from .storage.memory import MemoryStorage
7
+
8
+ __all__ = [
9
+ "BaseStorage",
10
+ "FSMContext",
11
+ "FSMMiddleware",
12
+ "MemoryStorage",
13
+ "State",
14
+ "StateFilter",
15
+ "StatesGroup",
16
+ "StorageKey",
17
+ ]
maxapi/fsm/context.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .storage.base import BaseStorage, StorageKey
6
+ from .state import State
7
+
8
+
9
+ class FSMContext:
10
+ """Контекст состояния для конкретного chat_id/user_id."""
11
+
12
+ def __init__(self, *, storage: BaseStorage, key: StorageKey) -> None:
13
+ self.storage = storage
14
+ self.key = key
15
+
16
+ async def get_state(self) -> str | None:
17
+ return await self.storage.get_state(self.key)
18
+
19
+ async def set_state(self, state: State | str | None) -> None:
20
+ normalized = _normalize_state(state)
21
+ await self.storage.set_state(self.key, normalized)
22
+
23
+ async def get_data(self) -> dict[str, Any]:
24
+ return await self.storage.get_data(self.key)
25
+
26
+ async def set_data(self, data: dict[str, Any]) -> None:
27
+ await self.storage.set_data(self.key, data)
28
+
29
+ async def update_data(self, **kwargs: Any) -> dict[str, Any]:
30
+ return await self.storage.update_data(self.key, kwargs)
31
+
32
+ async def clear(self) -> None:
33
+ await self.storage.clear(self.key)
34
+
35
+
36
+ def _normalize_state(state: State | str | None) -> str | None:
37
+ if state is None:
38
+ return None
39
+ if isinstance(state, State):
40
+ return str(state)
41
+ return state
maxapi/fsm/filters.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..filters.base import BaseFilter
6
+ from .middleware import build_storage_key
7
+ from .state import State
8
+
9
+
10
+ class StateFilter(BaseFilter):
11
+ def __init__(self, *states: State | str) -> None:
12
+ self.states = {_normalize_state(item) for item in states}
13
+
14
+ async def __call__(self, event: Any) -> bool:
15
+ bot = getattr(event, "bot", None)
16
+ dispatcher = getattr(bot, "dispatcher", None)
17
+ storage = getattr(dispatcher, "fsm_storage", None)
18
+ if storage is None:
19
+ return False
20
+ key = build_storage_key(event)
21
+ if key is None:
22
+ return False
23
+ current_state = await storage.get_state(key)
24
+ return current_state in self.states
25
+
26
+
27
+ def _normalize_state(state: State | str) -> str:
28
+ if isinstance(state, State):
29
+ return str(state)
30
+ return state
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..middlewares import BaseMiddleware, MiddlewareHandler
6
+ from .context import FSMContext
7
+ from .storage.base import BaseStorage, StorageKey
8
+
9
+
10
+ class FSMMiddleware(BaseMiddleware):
11
+ """Middleware, который внедряет FSMContext в handlers."""
12
+
13
+ def __init__(self, storage: BaseStorage) -> None:
14
+ self.storage = storage
15
+
16
+ async def __call__(
17
+ self,
18
+ handler: MiddlewareHandler,
19
+ event: Any,
20
+ data: dict[str, Any],
21
+ ) -> Any:
22
+ key = build_storage_key(event)
23
+ if key is None:
24
+ data.setdefault("state", None)
25
+ data.setdefault("fsm_context", None)
26
+ data.setdefault("raw_state", None)
27
+ data.setdefault("state_data", {})
28
+ return await handler(event, data)
29
+ context = FSMContext(storage=self.storage, key=key)
30
+ data["fsm_context"] = context
31
+ data["state"] = context
32
+ data["raw_state"] = await context.get_state()
33
+ data["state_data"] = await context.get_data()
34
+ return await handler(event, data)
35
+
36
+
37
+ def build_storage_key(event: Any) -> StorageKey | None:
38
+ chat_id = getattr(event, "chat_id", None)
39
+ user_id = getattr(event, "user_id", None)
40
+ if chat_id is None and user_id is None:
41
+ return None
42
+ return StorageKey(chat_id=chat_id, user_id=user_id)
maxapi/fsm/state.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class State:
7
+ """Описание состояния FSM."""
8
+
9
+ def __init__(self) -> None:
10
+ self.state: str | None = None
11
+
12
+ def __set_name__(self, owner: type[StatesGroup], name: str) -> None:
13
+ self.state = f"{owner.__name__}:{name}"
14
+
15
+ def __get__(self, instance: Any, owner: type[StatesGroup]) -> "State":
16
+ return self
17
+
18
+ def __str__(self) -> str:
19
+ if self.state is None:
20
+ raise RuntimeError("Состояние ещё не связано с классом StatesGroup.")
21
+ return self.state
22
+
23
+
24
+ class StatesGroup:
25
+ """Базовый контейнер состояний."""
26
+
27
+ @classmethod
28
+ def states(cls) -> list[State]:
29
+ values: list[State] = []
30
+ for item in cls.__dict__.values():
31
+ if isinstance(item, State):
32
+ values.append(item)
33
+ return values
@@ -0,0 +1,4 @@
1
+ from .base import BaseStorage, StorageKey
2
+ from .memory import MemoryStorage
3
+
4
+ __all__ = ["BaseStorage", "MemoryStorage", "StorageKey"]
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Protocol
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class StorageKey:
9
+ chat_id: int | None
10
+ user_id: int | None
11
+
12
+
13
+ class BaseStorage(Protocol):
14
+ async def get_state(self, key: StorageKey) -> str | None:
15
+ ...
16
+
17
+ async def set_state(self, key: StorageKey, state: str | None) -> None:
18
+ ...
19
+
20
+ async def get_data(self, key: StorageKey) -> dict[str, Any]:
21
+ ...
22
+
23
+ async def set_data(self, key: StorageKey, data: dict[str, Any]) -> None:
24
+ ...
25
+
26
+ async def update_data(self, key: StorageKey, data: dict[str, Any]) -> dict[str, Any]:
27
+ ...
28
+
29
+ async def clear(self, key: StorageKey) -> None:
30
+ ...
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .base import StorageKey
6
+
7
+
8
+ class MemoryStorage:
9
+ """In-memory storage для FSM."""
10
+
11
+ def __init__(self) -> None:
12
+ self._states: dict[StorageKey, str | None] = {}
13
+ self._data: dict[StorageKey, dict[str, Any]] = {}
14
+
15
+ async def get_state(self, key: StorageKey) -> str | None:
16
+ return self._states.get(key)
17
+
18
+ async def set_state(self, key: StorageKey, state: str | None) -> None:
19
+ if state is None:
20
+ self._states.pop(key, None)
21
+ return
22
+ self._states[key] = state
23
+
24
+ async def get_data(self, key: StorageKey) -> dict[str, Any]:
25
+ return dict(self._data.get(key, {}))
26
+
27
+ async def set_data(self, key: StorageKey, data: dict[str, Any]) -> None:
28
+ self._data[key] = dict(data)
29
+
30
+ async def update_data(self, key: StorageKey, data: dict[str, Any]) -> dict[str, Any]:
31
+ current = await self.get_data(key)
32
+ current.update(data)
33
+ await self.set_data(key, current)
34
+ return current
35
+
36
+ async def clear(self, key: StorageKey) -> None:
37
+ self._states.pop(key, None)
38
+ self._data.pop(key, None)
@@ -0,0 +1,3 @@
1
+ from .base import BaseMiddleware, FunctionMiddleware, MiddlewareHandler
2
+
3
+ __all__ = ["BaseMiddleware", "FunctionMiddleware", "MiddlewareHandler"]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from typing import Any, Awaitable, Callable
5
+
6
+ MiddlewareHandler = Callable[[Any, dict[str, Any]], Awaitable[Any]]
7
+
8
+
9
+ class BaseMiddleware:
10
+ """Базовый middleware."""
11
+
12
+ async def __call__(
13
+ self,
14
+ handler: MiddlewareHandler,
15
+ event: Any,
16
+ data: dict[str, Any],
17
+ ) -> Any:
18
+ return await handler(event, data)
19
+
20
+
21
+ class FunctionMiddleware(BaseMiddleware):
22
+ def __init__(self, callback: Callable[[MiddlewareHandler, Any, dict[str, Any]], Any]) -> None:
23
+ self.callback = callback
24
+
25
+ async def __call__(
26
+ self,
27
+ handler: MiddlewareHandler,
28
+ event: Any,
29
+ data: dict[str, Any],
30
+ ) -> Any:
31
+ result = self.callback(handler, event, data)
32
+ if inspect.isawaitable(result):
33
+ return await result
34
+ return result
@@ -0,0 +1,3 @@
1
+ from .base import BasePlugin, PluginProtocol
2
+
3
+ __all__ = ["BasePlugin", "PluginProtocol"]
maxapi/plugins/base.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+
6
+ class PluginProtocol(Protocol):
7
+ name: str
8
+
9
+ def setup(self, router) -> None:
10
+ ...
11
+
12
+
13
+ class BasePlugin:
14
+ """Базовый плагин для расширения Router/Dispatcher."""
15
+
16
+ name = "base"
17
+
18
+ def setup(self, router) -> None: # pragma: no cover - interface hook
19
+ raise NotImplementedError
maxapi/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,4 @@
1
+ from .polling import PollingRunner
2
+ from .webhook import WebhookRunner
3
+
4
+ __all__ = ["PollingRunner", "WebhookRunner"]
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Iterable
5
+
6
+ from ..types import UpdateType
7
+
8
+
9
+ class PollingRunner:
10
+ """Отдельный runtime-класс для long polling."""
11
+
12
+ def __init__(
13
+ self,
14
+ *,
15
+ bot,
16
+ dispatcher,
17
+ limit: int = 100,
18
+ timeout: int = 30,
19
+ allowed_updates: Iterable[UpdateType | str] | None = None,
20
+ retry_delay: float = 2.0,
21
+ raise_exceptions: bool = False,
22
+ ) -> None:
23
+ self.bot = bot
24
+ self.dispatcher = dispatcher
25
+ self.limit = limit
26
+ self.timeout = timeout
27
+ self.allowed_updates = list(allowed_updates or [])
28
+ self.retry_delay = retry_delay
29
+ self.raise_exceptions = raise_exceptions
30
+ self._stop_event = asyncio.Event()
31
+
32
+ async def run_once(self) -> None:
33
+ page = await self.bot.get_updates(
34
+ marker=self.bot.marker_updates,
35
+ limit=self.limit,
36
+ timeout=self.timeout,
37
+ types=self.allowed_updates or None,
38
+ )
39
+ for update in page.updates:
40
+ await self.dispatcher.process_update(update, bot=self.bot)
41
+ self.bot.marker_updates = page.marker
42
+
43
+ async def start(self) -> None:
44
+ while not self._stop_event.is_set():
45
+ try:
46
+ await self.run_once()
47
+ except asyncio.CancelledError:
48
+ raise
49
+ except Exception:
50
+ if self.raise_exceptions:
51
+ raise
52
+ await asyncio.sleep(self.retry_delay)
53
+
54
+ async def stop(self) -> None:
55
+ self._stop_event.set()
@@ -0,0 +1,72 @@
1
+ from typing import Any
2
+
3
+ from ..exceptions import MaxConnection
4
+
5
+ try: # pragma: no cover - optional dependency at import time
6
+ from fastapi import FastAPI, Header, HTTPException, Request
7
+ except ImportError: # pragma: no cover
8
+ FastAPI = None
9
+ Header = None
10
+ HTTPException = None
11
+ Request = None
12
+
13
+
14
+ class WebhookRunner:
15
+ """Отдельный runtime-класс для webhook."""
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ bot,
21
+ dispatcher,
22
+ path: str = "/webhook",
23
+ secret: str | None = None,
24
+ host: str = "127.0.0.1",
25
+ port: int = 8080,
26
+ log_level: str = "info",
27
+ ) -> None:
28
+ self.bot = bot
29
+ self.dispatcher = dispatcher
30
+ self.path = path
31
+ self.secret = secret
32
+ self.host = host
33
+ self.port = port
34
+ self.log_level = log_level
35
+
36
+ def create_app(self):
37
+ if FastAPI is None or Header is None or HTTPException is None or Request is None:
38
+ raise MaxConnection(
39
+ "Для webhook runtime требуется fastapi. Установите maxapi[webhook]."
40
+ )
41
+
42
+ app = FastAPI()
43
+
44
+ @app.post(self.path)
45
+ async def handle_update(
46
+ request: Request,
47
+ x_max_bot_api_secret: str | None = Header(default=None),
48
+ ) -> dict[str, Any]:
49
+ if self.secret is not None and x_max_bot_api_secret != self.secret:
50
+ raise HTTPException(status_code=403, detail="Invalid webhook secret")
51
+ payload = await request.json()
52
+ await self.dispatcher.process_update(payload, bot=self.bot)
53
+ return {"ok": True}
54
+
55
+ return app
56
+
57
+ async def start(self) -> None:
58
+ try:
59
+ import uvicorn
60
+ except ImportError as exc: # pragma: no cover
61
+ raise MaxConnection(
62
+ "Для webhook runtime требуется uvicorn. Установите maxapi[webhook]."
63
+ ) from exc
64
+ app = self.create_app()
65
+ config = uvicorn.Config(
66
+ app=app,
67
+ host=self.host,
68
+ port=self.port,
69
+ log_level=self.log_level,
70
+ )
71
+ server = uvicorn.Server(config)
72
+ await server.serve()
@@ -0,0 +1,13 @@
1
+ from .client import MaxApiTransport, TransportResult
2
+ from .config import RetryPolicy, TransportConfig
3
+ from .errors import RateLimitExceededError, ResponseDecodeError, ServerResponseError
4
+
5
+ __all__ = [
6
+ "MaxApiTransport",
7
+ "RateLimitExceededError",
8
+ "ResponseDecodeError",
9
+ "RetryPolicy",
10
+ "ServerResponseError",
11
+ "TransportConfig",
12
+ "TransportResult",
13
+ ]