Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7c59f8e
Алиас `maxo.filters` к `maxo.routing.filters`
K1rL3s Feb 17, 2026
42cb5a6
Шаг к интерфейсу аиограма
K1rL3s Feb 27, 2026
1fff51f
Добавил примеры ловли разных `AttachmentType`, починил поддержку `Att…
K1rL3s Feb 27, 2026
38c0c3b
Методы `BaseRouter.include_router` и `BaseRouter.include_routers` для…
K1rL3s Feb 27, 2026
1d79b21
Руффо-фиксы
K1rL3s Feb 27, 2026
70d45ef
Breaking изменения!
K1rL3s Feb 27, 2026
8977965
Скачивание файла по ссылке через бота
K1rL3s Mar 2, 2026
7c60213
Гвоздь в крышку подражания аиограму
K1rL3s Mar 2, 2026
b10e5e7
Проброс мидлварей и остальных аргументов в `MaxApiClient` через `Bot`
K1rL3s Mar 2, 2026
aa3e32e
Обновление моделей по документации
K1rL3s Mar 4, 2026
f917843
Фикс распространения сигналов в вложенные роутеры, если в родительско…
K1rL3s Mar 5, 2026
ff28a9b
Breaking изменения!
K1rL3s Mar 9, 2026
58b4667
Merge branch 'refs/heads/feature/0.5.0' into feature/aiogram-like
K1rL3s Mar 9, 2026
557e883
Добавил флаг `Dispatcher.__init__`: `disable_fsm` для включения-выклю…
K1rL3s Mar 9, 2026
c5b19dc
Пример с общей FSM для ботов в ТГ и Максе
K1rL3s Mar 9, 2026
b9b5ef2
Проверка наличия `"current_user"` в примере
K1rL3s Mar 9, 2026
82b4984
Убрал `Button` из `InlineButtons`
K1rL3s Mar 10, 2026
babe897
Попытка в обработку `dp.update()`
K1rL3s Mar 10, 2026
edb9b7a
Добавил `self.message = self.message_created` и `self.callback_query …
K1rL3s Mar 11, 2026
3ce162a
Тесты на установку состояний обсерверам и мидлварям по сигналам
K1rL3s Mar 11, 2026
c1e6ffb
Добавил миллисекунды в генератор айди для стэка
K1rL3s Mar 11, 2026
d9c9203
`formatting.Text`
K1rL3s Mar 14, 2026
a07bc04
Вынес всю реторту в одно место
K1rL3s Mar 14, 2026
c48a361
Вебхуки
K1rL3s Mar 14, 2026
ce28b68
Заменил аиограм на махо
K1rL3s Mar 14, 2026
b388670
Добавил fastapi в дев зависимости
K1rL3s Mar 14, 2026
a51ad2e
fix: исправления по ревью PR #61
goduni Mar 16, 2026
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
141 changes: 141 additions & 0 deletions examples/attachments_from_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import logging
import os

from maxo import Bot, Ctx, Dispatcher
from maxo.enums import AttachmentType
from maxo.routing.filters import BaseFilter
from maxo.routing.updates import MessageCreated
from maxo.utils.facades import MessageCreatedFacade
from maxo.utils.long_polling import LongPolling

bot = Bot(os.environ["TOKEN"])
dp = Dispatcher()


class AttachmentFilter(BaseFilter[MessageCreated]):
def __init__(self, attachment_type: AttachmentType) -> None:
self._attachment_type = attachment_type

async def __call__(self, update: MessageCreated, ctx: Ctx) -> bool:
for attachment in update.message.body.attachments or []:
if attachment.type == self._attachment_type:
return True

# ruff: noqa: SIM103
if self._attachment_type == AttachmentType.TEXT and update.message.body.text:
return True

return False


@dp.message_created(AttachmentFilter(AttachmentType.AUDIO))
async def audio_handler(
update: MessageCreated,
facade: MessageCreatedFacade,
) -> None:
await facade.answer_text("Получил голосовое сообщение")
await facade.bot.send_message(
chat_id=facade.chat_id,
attachments=[update.message.body.audio],
)


@dp.message_created(AttachmentFilter(AttachmentType.CONTACT))
async def contact_handler(
update: MessageCreated,
facade: MessageCreatedFacade,
) -> None:
await facade.answer_text("Получил сообщение с контактом")
await facade.bot.send_message(
chat_id=facade.chat_id,
attachments=[update.message.body.contact],
)


@dp.message_created(AttachmentFilter(AttachmentType.FILE))
async def file_handler(
update: MessageCreated,
facade: MessageCreatedFacade,
) -> None:
await facade.answer_text("Получил сообщение с файлами")
await facade.bot.send_message(
chat_id=facade.chat_id,
attachments=[update.message.body.file],
)


@dp.message_created(AttachmentFilter(AttachmentType.IMAGE))
async def image_handler(
update: MessageCreated,
facade: MessageCreatedFacade,
) -> None:
await facade.answer_text("Получил сообщение с изображениями")
await facade.bot.send_message(
chat_id=facade.chat_id,
attachments=update.message.body.photo,
)


@dp.message_created(AttachmentFilter(AttachmentType.LOCATION))
async def location_handler(
update: MessageCreated,
facade: MessageCreatedFacade,
) -> None:
await facade.answer_text("Получил сообщение с геопозицией")
await facade.bot.send_message(
chat_id=facade.chat_id,
attachments=[update.message.body.location],
)


@dp.message_created(AttachmentFilter(AttachmentType.SHARE))
async def share_handler(
update: MessageCreated,
facade: MessageCreatedFacade,
) -> None:
await facade.answer_text("Получил сообщение с предпросмотром ссылки")
await facade.bot.send_message(
chat_id=facade.chat_id,
attachments=[update.message.body.share],
)


@dp.message_created(AttachmentFilter(AttachmentType.STICKER))
async def sticker_handler(
update: MessageCreated,
facade: MessageCreatedFacade,
) -> None:
await facade.answer_text("Получил сообщение с стикером")
await facade.bot.send_message(
chat_id=facade.chat_id,
attachments=[update.message.body.sticker],
)


@dp.message_created(AttachmentFilter(AttachmentType.VIDEO))
async def video_handler(
update: MessageCreated,
facade: MessageCreatedFacade,
) -> None:
await facade.answer_text("Получил сообщение с видео")
await facade.bot.send_message(
chat_id=facade.chat_id,
attachments=[update.message.body.video],
)


@dp.message_created(AttachmentFilter(AttachmentType.TEXT))
async def text_handler(
update: MessageCreated,
facade: MessageCreatedFacade,
) -> None:
await facade.answer_text("Получил простое текстовое сообщение")


def main() -> None:
logging.basicConfig(level=logging.DEBUG)
LongPolling(dp).run(bot)


if __name__ == "__main__":
main()
102 changes: 102 additions & 0 deletions examples/bot_middlewares.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import asyncio
import logging
import os
from collections.abc import Sequence

from unihttp.http.request import HTTPRequest
from unihttp.http.response import HTTPResponse
from unihttp.middlewares.base import AsyncHandler, AsyncMiddleware

from maxo import Bot
from maxo.backoff import Backoff, BackoffConfig
from maxo.errors import MaxBotNotFoundError

logger = logging.getLogger(__name__)

_DEFAULT_BACKOFF_CONFIG = BackoffConfig(
min_delay=1.0,
max_delay=5.0,
factor=1.3,
jitter=0.1,
)


class LoggingMiddleware(AsyncMiddleware):
async def handle(
self,
request: HTTPRequest,
next_handler: AsyncHandler,
) -> HTTPResponse:
logger.info("Request: %s", request)
response = await next_handler(request)
logger.info("Response: %s", response)
return response


class RetryMiddleware(AsyncMiddleware):
def __init__(
self,
retries: int = 3,
backoff_config: BackoffConfig = _DEFAULT_BACKOFF_CONFIG,
status_codes: Sequence[int] | None = None,
exceptions: Sequence[type[Exception]] | None = None,
) -> None:
self._retries = retries
self._backoff_config = backoff_config
self._status_codes = status_codes or (500, 502, 503, 504)
self._exceptions = exceptions or ()

async def handle(
self,
request: HTTPRequest,
next_handler: AsyncHandler,
) -> HTTPResponse:
attempt = 0
backoff = Backoff(self._backoff_config)
while True:
try:
response = await next_handler(request)
if (
response.status_code in self._status_codes
and attempt < self._retries
):
logger.warning(
"Bad status code %d: %s",
response.status_code,
response,
)
backoff.next()
await backoff.sleep()
attempt += 1
continue
except Exception as e:
if (
self._exceptions
and isinstance(e, tuple(self._exceptions))
and attempt < self._retries
):
logger.warning("Bad exception %s", e, exc_info=e)
backoff.next()
await backoff.sleep()
attempt += 1
continue
raise

return response


async def main() -> None:
bot = Bot(
token=os.environ["TOKEN"],
middleware=[
LoggingMiddleware(),
RetryMiddleware(exceptions=[MaxBotNotFoundError]),
],
)
async with bot.context():
await bot.send_message(chat_id=-1)


if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
asyncio.run(main())
85 changes: 85 additions & 0 deletions examples/text_formatting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import logging
import os

from maxo import Bot, Dispatcher
from maxo.enums import TextFormat
from maxo.routing.filters import Command
from maxo.routing.updates import MessageCreated
from maxo.utils.facades import MessageCreatedFacade
from maxo.utils.formatting import (
Bold,
Italic,
Link,
Mention,
Monospaced,
Strikethrough,
Text,
Underline,
as_list,
as_marked_list,
as_numbered_list,
)
from maxo.utils.long_polling import LongPolling

bot = Bot(os.environ["TOKEN"])
dp = Dispatcher()


@dp.message_created(Command("start"))
async def start_handler(update: MessageCreated, facade: MessageCreatedFacade) -> None:
text = Text(
"Привет, это демонстрация возможностей форматирования текста.",
"\n\n",
Bold("Это жирный текст."),
"\n",
Italic("Это курсивный текст."),
"\n",
Underline("Это подчеркнутый текст."),
"\n",
Strikethrough("Это зачеркнутый текст."),
"\n",
Monospaced("Это моноширинный текст."),
"\n",
Link(
"Это ссылка на библиотеку maxo.",
url="https://github.com/K1rL3s/maxo",
),
"\n",
Mention("Это упоминание пользователя.", user_id=update.message.sender.id),
"\n\n",
"Вы также можете использовать вспомогательные функции для создания списков:",
"\n\n",
as_list(
"Простой список:",
"Элемент 1",
"Элемент 2",
"Элемент 3",
),
"\n\n",
as_marked_list(
"Маркированный список:",
"Элемент 1",
"Элемент 2",
"Элемент 3",
),
"\n\n",
as_numbered_list(
"Нумерованный список:",
"Элемент 1",
"Эleмент 2",
"Элемент 3",
start=4,
),
)

await facade.answer_text(text.as_html(), format=TextFormat.HTML)
await facade.answer_text(text.as_markdown(), format=TextFormat.MARKDOWN)


def main() -> None:
logging.basicConfig(level=logging.DEBUG)
LongPolling(dp).run(bot)


if __name__ == "__main__":
main()
54 changes: 54 additions & 0 deletions examples/tg_max_one_fsm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Telegram + Max: Одна FSM

Этот пример показывает, как использовать одну FSM для двух ботов: одного для Telegram (используя aiogram) и одного для Max (используя maxo).

## Как это работает

1. **Общая база данных**: Оба бота используют одну и ту же базу данных (`db.sqlite`), которая создается в родительской директории примера. Класс `UserRepo` в `user_repo.py` обрабатывает операции с базой данных.
2. **Связывание пользователей**:
- Когда пользователь впервые взаимодействует с любым из ботов, в таблице `users` создается новая запись с уникальным `shared_id`
- У каждого бота есть команда `/start`, которая показывает `shared_id`
- Чтобы связать аккаунты, нужно отправить команду `/link <shared_id>` в другого бота
- В вашей системе вы можете использовать другой подход связывания
3. **Общее состояние FSM**:
- Состояние хранится в Redis'е
- `SharedFSMContextMiddleware` создает `FSMContext` с общим ключом, основанным на `shared_id` из базы данных
- Это гарантирует, что у пользователя будет одинаковое состояние FSM в обоих ботах

## Как запустить

Из директории `examples`:

1. **Установите зависимости**:
```bash
pip install aiogram maxo redis aiosqlite magic-filter
```
2. **Запустите Redis**:
```bash
docker compose -f ./tg_max_one_fsm/docker-compose.yml run --remove-orphans -d -p 6379:6379 redis
```
3. **Установите переменные окружения**:
```bash
export TG_TOKEN="tg_token"
export MAX_TOKEN="max_token"
export REDIS_URL="redis://localhost:6379/0"
```
4. **Запустите ботов**:
```bash
python -m tg_max_one_fsm.tg
python -m tg_max_one_fsm.max
```

## Как использовать

1. **Telegram бот**:
- Отправьте `/start`, чтобы получить ваш `shared_id` и клавиатуру для смены состояний
- Используйте кнопки, чтобы переключаться между `state1` и `state2`
- Отправьте `/state`, чтобы проверить текущее состояние
- Отправьте `/link <shared_id>`, чтобы связать свой аккаунт с другим аккаунтом
2. **Max бот**:
- Отправьте `/start`, чтобы получить ваш `shared_id` и клавиатуру для смены состояний
- Отправьте команду `/link <shared_id>`, полученную от телеграм-бота
- Теперь у вас общее состояние с телеграм-ботом
- Используйте кнопки для смены состояния
- Отправьте `/state`, чтобы проверить текущее состояние. Вы увидите, что оно совпадает с состоянием в телеграм-боте
Empty file.
12 changes: 12 additions & 0 deletions examples/tg_max_one_fsm/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
services:
redis:
container_name: maxo-redis-fsm
image: redis:7.4.3-alpine3.21
restart: unless-stopped
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
command: "redis-server --loglevel warning"
Loading
Loading