Skip to content

Commit

Permalink
Merge pull request #340 from Tishka17/feature/groups
Browse files Browse the repository at this point in the history
Multiuser chat
  • Loading branch information
Tishka17 authored Aug 21, 2024
2 parents f81f734 + e826586 commit 3f2fe03
Show file tree
Hide file tree
Showing 38 changed files with 1,229 additions and 232 deletions.
58 changes: 58 additions & 0 deletions docs/group_business.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
******************************************
Groups and business chats
******************************************

.. warning::
Telegram has very strong limitations on amount of operations in groups,
so it is not recommended to use interactive menus there

Support of groups, supergroups and business chats is based on usage of additional dialog stacks.

Starting shared dialogs
=================================

When user sends message or other event not attached directly to some dialog, default stack is used. If you start dialogs in that stack, they can be accessed only by that user. So, the default stack in **personal**.

To send a *shared* dialog from *personal*, you need to use other stacks. It can be ``aiogram_dialog.GROUP_STACK_ID``, other predefined string or starting via ``StartMode.NEW_STACK``.

.. code-block:: python
bg = dialog_manager.bg(stack_id=GROUP_STACK_ID)
bg.start(
MyStateGroup.MY_STATE,
mode=StartMode.RESET_STACK,
)
If there are different topics in chat, stacks between them are isolated. To start dialog in different topic pass ``thread_id`` as ``.bg()`` argument

Limiting access
======================

To set limitations on who can interact with that dialog, you can pass ``AccessSettings`` when starting new dialog. If not access settings are set, they will be copied from last opened dialog in stack.

.. code-block:: python
dialog_manager.start(
MyStateGroup.MY_STATE,
mode=StartMode.RESET_STACK,
access_settings=AccessSettings(user_ids=[123456]),
)
In this example, pre-defined group stack will be used and new dialogs will be available only for user with id ``123456``. If later user clicks on a specific dialog, stack of that dialog is used, so you won't need to call ``.bg()``

Currently, only check by ``user.id`` is supported, but you bring your own logic implementing ``StackAccessValidator`` protocol and passing it so ``setup_dialogs`` function.

Handling forbidden interactions
=================================

If user is not allowed to interact with dialog his event is not routed to dialogs and you can handle it in aiogram. To filter this situation you can rely on ``aiogd_stack_forbidden`` key of middleware data.

Classes
===========


.. autoclass:: aiogram_dialog.AccessSettings


.. autoclass:: aiogram_dialog.api.protocols.StackAccessValidator
:members: is_allowed
2 changes: 1 addition & 1 deletion docs/how_are_messages_updated/index.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
How are messages updated
=====================
===========================

ShowMode
********************
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ aiogram-dialog
widgets/index
transitions/index
how_are_messages_updated/index
group_business
helper_tools/index
migration
faq
Expand Down
7 changes: 6 additions & 1 deletion example/mega/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@

async def start(message: Message, dialog_manager: DialogManager):
# it is important to reset stack because user wants to restart everything
await dialog_manager.start(states.Main.MAIN, mode=StartMode.RESET_STACK)
await dialog_manager.start(
states.Main.MAIN,
mode=StartMode.RESET_STACK,
show_mode=ShowMode.SEND,
)


async def on_unknown_intent(event: ErrorEvent, dialog_manager: DialogManager):
Expand Down Expand Up @@ -71,6 +75,7 @@ def setup_dp():
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp.message.register(start, F.text == "/start")
dp.business_message.register(start, F.text == "/start")
dp.errors.register(
on_unknown_intent,
ExceptionTypeFilter(UnknownIntent),
Expand Down
1 change: 1 addition & 0 deletions example/multistack.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ async def main():

# register handler which resets stack and start dialogs on /start command
dp.message.register(start, CommandStart())
dp.business_message.register(start, CommandStart())
setup_dialogs(dp)
await dp.start_polling(bot)

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ where = ["src"]

[project]
name = "aiogram_dialog"
version = "2.1.0"
version = "2.2.0a5"
readme = "README.md"
authors = [
{ name = "Andrey Tikhonov", email = "[email protected]" },
Expand All @@ -25,7 +25,7 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
'aiogram>=3.0.0',
'aiogram>=3.5.0',
'jinja2',
'cachetools>=4.0.0,<6.0.0',
'magic_filter',
Expand Down
1 change: 1 addition & 0 deletions requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ flake8-print

pytest
pytest-asyncio
pytest-repeat
11 changes: 9 additions & 2 deletions src/aiogram_dialog/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
__all__ = [
"AccessSettings",
"DEFAULT_STACK_ID",
"Dialog",
"Data",
"GROUP_STACK_ID",
"ChatEvent",
"LaunchMode",
"StartMode",
"BaseDialogManager",
"BgManagerFactory",
"CancelEventProcessing",
"DialogManager",
"DialogProtocol",
"UnsetId",
"setup_dialogs",
"ShowMode",
"SubManager",
Expand All @@ -18,10 +22,13 @@
import importlib.metadata as _metadata

from .api.entities import (
ChatEvent, Data, DEFAULT_STACK_ID, LaunchMode, ShowMode, StartMode,
AccessSettings, ChatEvent, Data, DEFAULT_STACK_ID, GROUP_STACK_ID,
LaunchMode, ShowMode, StartMode,
)
from .api.protocols import (
BaseDialogManager, BgManagerFactory, DialogManager, DialogProtocol,
BaseDialogManager, BgManagerFactory, CancelEventProcessing,
DialogManager, DialogProtocol,
UnsetId,
)
from .dialog import Dialog
from .manager.sub_manager import SubManager
Expand Down
9 changes: 5 additions & 4 deletions src/aiogram_dialog/api/entities/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
__all__ = [
"Context", "Data",
"ChatEvent",
"ChatEvent", "EVENT_CONTEXT_KEY", "EventContext",
"LaunchMode",
"MediaAttachment", "MediaId",
"ShowMode", "StartMode",
"MarkupVariant", "NewMessage", "OldMessage", "UnknownText",
"DEFAULT_STACK_ID", "Stack",
"AccessSettings", "DEFAULT_STACK_ID", "GROUP_STACK_ID", "Stack",
"DIALOG_EVENT_NAME", "DialogAction", "DialogUpdateEvent",
"DialogStartEvent", "DialogSwitchEvent", "DialogUpdate",
]

from .access import AccessSettings
from .context import Context, Data
from .events import ChatEvent
from .events import ChatEvent, EVENT_CONTEXT_KEY, EventContext
from .launch_mode import LaunchMode
from .media import MediaAttachment, MediaId
from .modes import ShowMode, StartMode
from .new_message import MarkupVariant, NewMessage, OldMessage, UnknownText
from .stack import DEFAULT_STACK_ID, Stack
from .stack import DEFAULT_STACK_ID, GROUP_STACK_ID, Stack
from .update_event import (
DIALOG_EVENT_NAME, DialogAction, DialogStartEvent, DialogSwitchEvent,
DialogUpdate, DialogUpdateEvent,
Expand Down
8 changes: 8 additions & 0 deletions src/aiogram_dialog/api/entities/access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from dataclasses import dataclass
from typing import Any, List


@dataclass
class AccessSettings:
user_ids: List[int]
custom: Any = None
7 changes: 6 additions & 1 deletion src/aiogram_dialog/api/entities/context.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from dataclasses import dataclass, field
from typing import Dict, List, Union
from typing import Dict, List, Optional, Union

from aiogram.fsm.state import State

from .access import AccessSettings

Data = Union[Dict, List, int, str, float, None]
DataDict = Dict[str, Data]

Expand All @@ -15,6 +17,9 @@ class Context:
start_data: Data = field(compare=False)
dialog_data: DataDict = field(compare=False, default_factory=dict)
widget_data: DataDict = field(compare=False, default_factory=dict)
access_settings: Optional[AccessSettings] = field(
compare=False, default=None,
)

@property
def id(self) -> str:
Expand Down
30 changes: 26 additions & 4 deletions src/aiogram_dialog/api/entities/events.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
from typing import Union
from dataclasses import dataclass
from typing import Optional, Union

from aiogram import Bot
from aiogram.types import (
CallbackQuery, ChatJoinRequest, ChatMemberUpdated, Message,
CallbackQuery,
Chat,
ChatJoinRequest,
ChatMemberUpdated,
Message,
User,
)

from .update_event import DialogUpdateEvent

ChatEvent = Union[
CallbackQuery, Message, DialogUpdateEvent,
ChatMemberUpdated, ChatJoinRequest,
CallbackQuery,
ChatJoinRequest,
ChatMemberUpdated,
DialogUpdateEvent,
Message,
]


@dataclass
class EventContext:
bot: Bot
chat: Chat
user: User
thread_id: Optional[int]
business_connection_id: Optional[str]


EVENT_CONTEXT_KEY = "aiogd_event_context"
3 changes: 3 additions & 0 deletions src/aiogram_dialog/api/entities/new_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ class OldMessage:
media_uniq_id: Optional[str]
text: Union[str, None, UnknownText] = None
has_reply_keyboard: bool = False
business_connection_id: Optional[str] = None


@dataclass
class NewMessage:
chat: Chat
thread_id: Optional[int] = None
business_connection_id: Optional[str] = None
text: Optional[str] = None
reply_markup: Optional[MarkupVariant] = None
parse_mode: Optional[str] = None
Expand Down
3 changes: 3 additions & 0 deletions src/aiogram_dialog/api/entities/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
from aiogram.fsm.state import State

from aiogram_dialog.api.exceptions import DialogStackOverflow
from .access import AccessSettings
from .context import Context, Data

DEFAULT_STACK_ID = ""
GROUP_STACK_ID = "<->"
_STACK_LIMIT = 100
_ID_SYMS = string.digits + string.ascii_letters

Expand Down Expand Up @@ -44,6 +46,7 @@ class Stack:
last_income_media_group_id: Optional[str] = field(
compare=False, default=None,
)
access_settings: Optional[AccessSettings] = None

@property
def id(self):
Expand Down
4 changes: 4 additions & 0 deletions src/aiogram_dialog/api/entities/update_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ShowMode,
StartMode,
)
from .stack import AccessSettings

DIALOG_EVENT_NAME = "aiogd_update"

Expand All @@ -36,12 +37,15 @@ class DialogUpdateEvent(TelegramObject):
data: Any
intent_id: Optional[str]
stack_id: Optional[str]
thread_id: Optional[int]
business_connection_id: Optional[str]
show_mode: Optional[ShowMode] = None


class DialogStartEvent(DialogUpdateEvent):
new_state: State
mode: StartMode
access_settings: Optional[AccessSettings] = None


class DialogSwitchEvent(DialogUpdateEvent):
Expand Down
27 changes: 4 additions & 23 deletions src/aiogram_dialog/api/internal/window.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from abc import abstractmethod
from typing import (
Any,
Dict,
Protocol,
)

Expand All @@ -11,36 +10,17 @@
from aiogram_dialog.api.entities import Data, NewMessage
from aiogram_dialog.api.protocols import DialogProtocol
from .manager import DialogManager
from .widgets import MarkupVariant


class WindowProtocol(Protocol):
@abstractmethod
async def render_text(self, data: Dict,
manager: DialogManager) -> str:
raise NotImplementedError

@abstractmethod
async def render_kbd(
self, data: Dict, manager: DialogManager,
) -> MarkupVariant:
raise NotImplementedError

@abstractmethod
async def load_data(
self,
dialog: "DialogProtocol",
manager: DialogManager,
) -> Dict:
raise NotImplementedError

@abstractmethod
async def process_message(
self,
message: Message,
dialog: "DialogProtocol",
manager: DialogManager,
) -> None:
) -> bool:
"""Return True if message in handled."""
raise NotImplementedError

@abstractmethod
Expand All @@ -49,7 +29,8 @@ async def process_callback(
callback: CallbackQuery,
dialog: "DialogProtocol",
manager: DialogManager,
) -> None:
) -> bool:
"""Return True if callback in handled."""
raise NotImplementedError

@abstractmethod
Expand Down
12 changes: 8 additions & 4 deletions src/aiogram_dialog/api/protocols/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
__all__ = [
"DialogProtocol",
"BaseDialogManager", "BgManagerFactory", "DialogManager",
"CancelEventProcessing", "DialogProtocol",
"BaseDialogManager", "BgManagerFactory", "DialogManager", "UnsetId",
"MediaIdStorageProtocol",
"MessageManagerProtocol", "MessageNotModified",
"DialogProtocol", "DialogRegistryProtocol",
"StackAccessValidator",
]

from .dialog import DialogProtocol
from .manager import BaseDialogManager, BgManagerFactory, DialogManager
from .dialog import CancelEventProcessing, DialogProtocol
from .manager import (
BaseDialogManager, BgManagerFactory, DialogManager, UnsetId,
)
from .media import MediaIdStorageProtocol
from .message_manager import MessageManagerProtocol, MessageNotModified
from .registry import DialogRegistryProtocol
from .stack_access import StackAccessValidator
Loading

0 comments on commit 3f2fe03

Please sign in to comment.