Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,44 @@ app: FastAPI = create_fastapi_bot_app(
- опционально `GET /metrics`
- healthcheck routes через `setup_healthcheck(...)`

### Динамические account-ы и несколько CTS

Если credentials появляются во время работы процесса, передайте
`account_provider` вместо `bot_accounts`. Provider является синхронным
read-side контрактом: он не должен выполнять сетевой или database I/O в
обработчике команды. Для динамического хранилища используйте локальный
snapshot, обновляемый приложением после регистрации или через invalidation.

```python
from uuid import UUID

from pybotx import Bot, BotAccountKey, BotAccountProvider, HandlerCollector

collector = HandlerCollector()


def create_bot(registration_account_provider: BotAccountProvider) -> Bot:
return Bot(
collectors=[collector],
account_provider=registration_account_provider,
)

# В background-задаче CTS выбирается явно.
async def send_registration_confirmation(
bot: Bot, *, bot_id: UUID, chat_id: UUID
) -> None:
with bot.account_scope(BotAccountKey(server_id="cts-001", bot_id=bot_id)):
await bot.send_message(bot_id=bot_id, chat_id=chat_id, body="Готово")
```

Входящие команды связываются с `BotAccountKey` provider-ом до вызова handler;
JWT v1 cache изолирован ключом account-а и его `revision`. Если вызов производится
вне handler и один `bot_id` зарегистрирован на нескольких CTS, provider обязан
поднять `AmbiguousBotAccountError`, а не выбрать произвольный credential.

Полный контракт и требования к adapter-у описаны в
[`docs/dynamic_accounts.md`](docs/dynamic_accounts.md).

Если у вас уже есть свой `FastAPI()` объект, можно не создавать новый app, а
подключить `pybotx` в существующий:

Expand Down
13 changes: 12 additions & 1 deletion pybotx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
)
from pybotx.auth import BotXAuthVersion
from pybotx.bot.bot import Bot
from pybotx.bot.bot_account_provider import (
BotAccountProvider,
StaticBotAccountProvider,
)
from pybotx.bot.command_processing import (
BotCommandOverloadAction,
BotCommandOverloadStrategy,
Expand Down Expand Up @@ -152,7 +156,11 @@
AttachmentVoice,
OutgoingAttachment,
)
from pybotx.models.bot_account import BotAccount, BotAccountWithSecret
from pybotx.models.bot_account import (
BotAccount,
BotAccountKey,
BotAccountWithSecret,
)
from pybotx.models.bot_catalog import BotsListItem
from pybotx.models.bot_sender import BotSender
from pybotx.models.chats import (
Expand Down Expand Up @@ -242,6 +250,8 @@
"AttachmentVideo",
"AttachmentVoice",
"Bot",
"BotAccountKey",
"BotAccountProvider",
"BotAPIBotDisabledErrorData",
"BotAPIBotDisabledResponse",
"BotAPIMethodFailedCallback",
Expand Down Expand Up @@ -385,6 +395,7 @@
"SmartappManifestWebLayoutChoices",
"SmartappManifestWebParams",
"StatusRecipient",
"StaticBotAccountProvider",
"StealthModeDisabledError",
"Sticker",
"StickerPack",
Expand Down
69 changes: 54 additions & 15 deletions pybotx/bot/bot.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from asyncio import Task
from collections.abc import AsyncIterable, AsyncIterator, Iterator, Mapping, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
from contextvars import ContextVar, Token
from datetime import datetime
from types import SimpleNamespace
Expand All @@ -14,6 +14,7 @@
from aiofiles.tempfile import NamedTemporaryFile, TemporaryDirectory

from pybotx.async_buffer import AsyncBufferReadable, AsyncBufferWritable
from pybotx.bot.bot_account_provider import BotAccountProvider
from pybotx.bot.bot_accounts_storage import BotAccountsStorage
from pybotx.auth import BotXAuthVersion
from pybotx.bot.callbacks.callback_manager import (
Expand All @@ -30,6 +31,7 @@
from pybotx.bot.callbacks.callback_memory_repo import CallbackMemoryRepo
from pybotx.bot.callbacks.callback_repo_proto import CallbackRepoProto
from pybotx.bot.contextvars import (
bot_account_key_var,
bot_id_var,
chat_id_var,
request_id_var,
Expand Down Expand Up @@ -263,7 +265,7 @@
from pybotx.missing import Missing, MissingOptional, Undefined
from pybotx.models.async_files import File
from pybotx.models.attachments import IncomingFileAttachment, OutgoingAttachment
from pybotx.models.bot_account import BotAccountWithSecret
from pybotx.models.bot_account import BotAccountKey, BotAccountWithSecret
from pybotx.models.bot_catalog import BotsListItem
from pybotx.models.call import Call
from pybotx.models.chats import ChatInfo, ChatLink, ChatListItem
Expand Down Expand Up @@ -306,7 +308,8 @@ def __init__(
self,
*,
collectors: Sequence[HandlerCollector],
bot_accounts: Sequence[BotAccountWithSecret],
bot_accounts: Sequence[BotAccountWithSecret] | None = None,
account_provider: BotAccountProvider | None = None,
middlewares: Sequence[Middleware] | None = None,
httpx_client: httpx.AsyncClient | None = None,
httpx_timeout: httpx.Timeout | None = None,
Expand All @@ -327,7 +330,11 @@ def __init__(
) -> None:
if not collectors:
logger.warning("Bot has no connected collectors")
if not bot_accounts:
if bot_accounts is not None and account_provider is not None:
raise ValueError("Pass either `bot_accounts` or `account_provider`, not both")
if bot_accounts is None and account_provider is None:
logger.warning("Bot has no bot accounts")
elif bot_accounts is not None and not bot_accounts:
logger.warning("Bot has no bot accounts")

middlewares = optional_sequence_to_list(middlewares)
Expand All @@ -344,7 +351,8 @@ def __init__(

self._default_callback_timeout = default_callback_timeout
self._bot_accounts_storage = BotAccountsStorage(
list(bot_accounts),
bot_accounts,
account_provider=account_provider,
auth_version=auth_version,
retry_policy=retry_policy,
retry_request_policy=retry_request_policy,
Expand Down Expand Up @@ -444,10 +452,16 @@ def async_execute_bot_command(
self,
bot_command: BotCommand,
) -> "Task[None]":
# raise UnknownBotAccountError if no bot account with this bot_id.
self._bot_accounts_storage.ensure_bot_id_exists(bot_command.bot.id)
account_key = self._bot_accounts_storage.resolve_incoming_account_key(
bot_id=bot_command.bot.id,
host=bot_command.bot.host,
)

return self._handler_collector.async_handle_bot_command(self, bot_command)
return self._handler_collector.async_handle_bot_command(
self,
bot_command,
account_key=account_key,
)

async def sync_execute_raw_smartapp_event(
self,
Expand Down Expand Up @@ -489,11 +503,18 @@ async def sync_execute_smartapp_event(
self,
smartapp_event: SmartAppEvent,
) -> BotAPISyncSmartAppEventResponse:
self._bot_accounts_storage.ensure_bot_id_exists(smartapp_event.bot.id)
return await self._handler_collector.handle_sync_smartapp_event(
self,
smartapp_event,
account_key = self._bot_accounts_storage.resolve_incoming_account_key(
bot_id=smartapp_event.bot.id,
host=smartapp_event.bot.host,
)
account_key_token = bot_account_key_var.set(account_key)
try:
return await self._handler_collector.handle_sync_smartapp_event(
self,
smartapp_event,
)
finally:
bot_account_key_var.reset(account_key_token)

async def raw_get_status(
self,
Expand Down Expand Up @@ -591,21 +612,39 @@ async def wait_botx_method_callback(
def bot_accounts(self) -> Iterator[BotAccountWithSecret]:
yield from self._bot_accounts_storage.iter_bot_accounts()

@contextmanager
def account_scope(self, account_key: BotAccountKey) -> Iterator[None]:
"""Bind an explicit CTS identity for an outbound background operation."""
self._bot_accounts_storage.ensure_account_key_exists(account_key)
account_key_token = bot_account_key_var.set(account_key)
try:
yield
finally:
bot_account_key_var.reset(account_key_token)

def invalidate_account(self, account_key: BotAccountKey) -> None:
"""Discard cached legacy JWTs after registration lifecycle changes."""
self._bot_accounts_storage.invalidate(account_key)

async def fetch_tokens(self) -> None:
if self._bot_accounts_storage.get_auth_version() != BotXAuthVersion.V1:
return
for bot_account in self.bot_accounts:
try:
token = await self.get_token(bot_id=bot_account.id)
if bot_account.account_key is None:
token = await self.get_token(bot_id=bot_account.id)
self._bot_accounts_storage.set_token(bot_account.id, token)
else:
with self.account_scope(bot_account.account_key):
token = await self.get_token(bot_id=bot_account.id)
self._bot_accounts_storage.set_token(bot_account.id, token)
except (InvalidBotAccountError, httpx.HTTPError):
logger.opt(exception=True).warning(
"Can't get token for bot account: "
f"host - {bot_account.host}, bot_id - {bot_account.id}",
)
continue

self._bot_accounts_storage.set_token(bot_account.id, token)

async def startup(self, *, fetch_tokens: bool = True) -> None:
if fetch_tokens:
await self.fetch_tokens()
Expand Down
64 changes: 64 additions & 0 deletions pybotx/bot/bot_account_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from __future__ import annotations

from collections.abc import Iterator, Sequence
from typing import Protocol
from uuid import UUID

from pybotx.bot.exceptions import UnknownBotAccountError
from pybotx.models.bot_account import BotAccountKey, BotAccountWithSecret


class BotAccountProvider(Protocol):
"""Read-side port for resolving BotX credentials at runtime.

Implementations must not perform blocking I/O. Dynamic providers should
serve a locally cached snapshot and refresh it outside a request path.
"""

def get_account(self, key: BotAccountKey) -> BotAccountWithSecret:
"""Return the credential for an explicit CTS and bot identity."""

def get_account_by_bot_id(self, bot_id: UUID) -> BotAccountWithSecret:
"""Resolve only when ``bot_id`` is unambiguous for this provider."""

def resolve_incoming_account_key(
self,
*,
bot_id: UUID,
host: str | None,
) -> BotAccountKey:
"""Map inbound Bot API identity to a stable account key."""

def iter_bot_accounts(self) -> Iterator[BotAccountWithSecret]:
"""Yield known accounts for legacy startup token fetching."""


class StaticBotAccountProvider:
"""Backward-compatible adapter over the historical ``bot_accounts`` list."""

def __init__(self, bot_accounts: Sequence[BotAccountWithSecret]) -> None:
self._bot_accounts = tuple(bot_accounts)

def get_account(self, key: BotAccountKey) -> BotAccountWithSecret:
for account in self._bot_accounts:
if account.id == key.bot_id:
return account
raise UnknownBotAccountError(key.bot_id)

def get_account_by_bot_id(self, bot_id: UUID) -> BotAccountWithSecret:
for account in self._bot_accounts:
if account.id == bot_id:
return account
raise UnknownBotAccountError(bot_id)

def resolve_incoming_account_key(
self,
*,
bot_id: UUID,
host: str | None,
) -> BotAccountKey:
account = self.get_account_by_bot_id(bot_id)
return BotAccountKey(server_id=host or account.host, bot_id=bot_id)

def iter_bot_accounts(self) -> Iterator[BotAccountWithSecret]:
yield from self._bot_accounts
Loading