Skip to content

Commit

Permalink
Update to v.0.3.0b2
Browse files Browse the repository at this point in the history
  • Loading branch information
maxbot-ai committed Aug 29, 2023
1 parent 4665ae7 commit cbf46bd
Show file tree
Hide file tree
Showing 91 changed files with 6,223 additions and 1,362 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ syntax: glob
.settings
.classpath
.pydevproject
.coverage
.coverage*
.pytest_cache
.env*
htmlcov
Expand Down
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,4 @@ repos:
entry: make test
language: system
always_run: True
pass_filenames: false
16 changes: 8 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ $(eval POETRY_VERSION_NEW=$(POETRY_VERSION_MAIN)$(POETRY_VERSION_NAME)$(POETRY_V
.PHONY: test

test:
pytest --cov=maxbot --cov-report html --cov-fail-under=95
pytest -p no:maxbot_stories --cov=maxbot --cov-report html --cov-fail-under=95

stories:
maxbot stories -B examples/hello-world
maxbot stories -B examples/echo
maxbot stories -B examples/restaurant
maxbot stories -B examples/reservation-basic
maxbot stories -B examples/reservation
maxbot stories -B examples/digression-showcase
maxbot stories -B examples/rpc-showcase
pytest --bot examples/hello-world examples/hello-world/stories.yaml
pytest --bot examples/echo examples/echo/stories.yaml
pytest --bot examples/restaurant examples/restaurant/stories.yaml
pytest --bot examples/reservation-basic examples/reservation-basic/stories.yaml
pytest --bot examples/reservation examples/reservation/stories.yaml
pytest --bot examples/digression-showcase examples/digression-showcase/stories.yaml
pytest --bot examples/rpc-showcase examples/rpc-showcase/stories.yaml

clean:
rm -f dist/maxbot-*.*.*-py3-none-any.whl
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ Press `Ctrl-C` to exit MaxBot CLI app.

Congratulations! You have successfully created and launched a simple bot and chatted with it.

### Advanced examples

There are several examples of services built on Maxbot. They show the advanced features of Maxbot, such as custom messanger controls, integration with different REST services, databases and so on. You can also check the implementation details of these features in the examples below.

- [Bank Bot example](https://github.com/maxbot-ai/bank_bot).
- [Taxi Bot example](https://github.com/maxbot-ai/taxi_bot).
- [Transport Bot example](https://github.com/maxbot-ai/transport_bot).

## Where to ask questions

The **Maxbot** project is maintained by the [Maxbot team](https://maxbot.ai).
Expand Down
152 changes: 73 additions & 79 deletions maxbot/bot.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
"""Create and run conversations applications."""
import asyncio
import logging
import os

from .channels import ChannelsCollection
from .dialog_manager import DialogManager
from .errors import BotError
from .resources import Resources
from .user_locks import AsyncioLocks
from .user_locks import AsyncioLocks, UnixSocketStreams

logger = logging.getLogger(__name__)

Expand All @@ -20,22 +19,28 @@ def __init__(
dialog_manager=None,
channels=None,
user_locks=None,
state_store=None,
persistence_manager=None,
resources=None,
history_tracked=False,
):
"""Create new class instance.
:param DialogManager dialog_manager: Dialog manager.
:param ChannelsCollection channels: Channels for communication with users.
:param StateStore state_store: State store.
:param PersistenceManager persistence_manager: Persistence manager.
:param Resources resources: Resources for tracking and reloading changes.
"""
self.dialog_manager = dialog_manager or DialogManager()
self.channels = channels or ChannelsCollection.empty()
self._state_store = state_store # the default value is initialized lazily
self.user_locks = user_locks or AsyncioLocks()
self._persistence_manager = persistence_manager # the default value is initialized lazily
self._history_tracked = history_tracked
self._user_locks = user_locks
self.resources = resources or Resources.empty()

SocketStreams = UnixSocketStreams
SUFFIX_LOCKS = "-locks.sock"
SUFFIX_DB = ".db"

@classmethod
def builder(cls, **kwargs):
"""Create a :class:`~BotBuilder` in a convenient way.
Expand Down Expand Up @@ -84,6 +89,23 @@ def from_directory(cls, bot_dir, **kwargs):
builder.use_directory_resources(bot_dir)
return builder.build()

@property
def user_locks(self):
"""Get user locks implementation."""
if self._user_locks is None:
self._user_locks = AsyncioLocks()
return self._user_locks

def setdefault_user_locks(self, value):
"""Set .user_locks field value if it is not set.
:param AsyncioLocks value: User locks object.
:return AsyncioLocks: .user_locks field value
"""
if self._user_locks is None:
self._user_locks = value
return self._user_locks

@property
def rpc(self):
"""Get RPC manager used by the bot.
Expand All @@ -93,14 +115,24 @@ def rpc(self):
return self.dialog_manager.rpc

@property
def state_store(self):
"""State store used to maintain state variables."""
if self._state_store is None:
def persistence_manager(self):
"""Return persistence manager."""
if self._persistence_manager is None:
# lazy import to speed up load time
from .state_store import SQLAlchemyStateStore
from .persistence_manager import SQLAlchemyManager

self._persistence_manager = SQLAlchemyManager()
return self._persistence_manager

self._state_store = SQLAlchemyStateStore()
return self._state_store
def setdefault_persistence_manager(self, factory):
"""Set .persistence_manager field value if it is not set.
:param callable factory: Persistence manager factory.
:return SQLAlchemyStateStore: .persistence_manager field value.
"""
if self._persistence_manager is None:
self._persistence_manager = factory()
return self._persistence_manager

def process_message(self, message, dialog=None):
"""Process user message.
Expand All @@ -115,8 +147,10 @@ def process_message(self, message, dialog=None):
"""
if dialog is None:
dialog = self._default_dialog()
with self.state_store(dialog) as state:
return asyncio.run(self.dialog_manager.process_message(message, dialog, state))
with self.persistence_manager(dialog) as tracker:
return asyncio.run(
self.dialog_manager.process_message(message, dialog, tracker.get_state())
)

def process_rpc(self, request, dialog=None):
"""Process RPC request.
Expand All @@ -131,8 +165,10 @@ def process_rpc(self, request, dialog=None):
"""
if dialog is None:
dialog = self._default_dialog()
with self.state_store(dialog) as state:
return asyncio.run(self.dialog_manager.process_rpc(request, dialog, state))
with self.persistence_manager(dialog) as tracker:
return asyncio.run(
self.dialog_manager.process_rpc(request, dialog, tracker.get_state())
)

def _default_dialog(self):
return {"channel_name": "builtin", "user_id": "1"}
Expand All @@ -148,10 +184,14 @@ async def default_channel_adapter(self, data, channel):
message = await channel.call_receivers(data)
if message is None:
return
with self.state_store(dialog) as state:
commands = await self.dialog_manager.process_message(message, dialog, state)
with self.persistence_manager(dialog) as tracker:
commands = await self.dialog_manager.process_message(
message, dialog, tracker.get_state()
)
for command in commands:
await channel.call_senders(command, dialog)
if self._history_tracked:
tracker.set_message_history(message, commands)

async def default_rpc_adapter(self, request, channel, user_id):
"""Handle RPC request for specific channel.
Expand All @@ -162,80 +202,32 @@ async def default_rpc_adapter(self, request, channel, user_id):
"""
dialog = {"channel_name": channel.name, "user_id": str(user_id)}
async with self.user_locks(dialog):
with self.state_store(dialog) as state:
commands = await self.dialog_manager.process_rpc(request, dialog, state)
with self.persistence_manager(dialog) as tracker:
commands = await self.dialog_manager.process_rpc(
request, dialog, tracker.get_state()
)
for command in commands:
await channel.call_senders(command, dialog)

def run_webapp(self, host="localhost", port="8080", *, public_url=None, autoreload=False):
"""Run web application.
:param str host: Hostname or IP address on which to listen.
:param int port: TCP port on which to listen.
:param str public_url: Base url to register webhook.
:param bool autoreload: Enable tracking and reloading bot resource changes.
"""
# lazy import to speed up load time
import sanic

self._validate_at_least_one_channel()

app = sanic.Sanic("maxbot", configure_logging=False)
app.config.FALLBACK_ERROR_FORMAT = "text"

for channel in self.channels:
if public_url is None:
logger.warning(
"Make sure you have a public URL that is forwarded to -> "
f"http://{host}:{port}/{channel.name} and register webhook for it."
)

app.blueprint(
channel.blueprint(
self.default_channel_adapter,
public_url=public_url,
webhook_path=f"/{channel.name}",
)
)

if self.rpc:
app.blueprint(self.rpc.blueprint(self.channels, self.default_rpc_adapter))

if autoreload:

@app.after_server_start
async def start_autoreloader(app, loop):
app.add_task(self.autoreloader, name="autoreloader")

@app.before_server_stop
async def stop_autoreloader(app, loop):
await app.cancel_task("autoreloader")

@app.after_server_start
async def report_started(app, loop):
logger.info(
f"Started webhooks updater on http://{host}:{port}. Press 'Ctrl-C' to exit."
)

if sanic.__version__.startswith("21."):
app.run(host, port, motd=False, workers=1)
else:
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "true"
app.run(host, port, motd=False, single_process=True)
if self._history_tracked:
tracker.set_rpc_history(request, commands)

def run_polling(self, autoreload=False):
"""Run polling application.
:param bool autoreload: Enable tracking and reloading bot resource changes.
"""
# lazy import to speed up load time
from telegram.ext import ApplicationBuilder, MessageHandler, filters
from telegram.ext import ApplicationBuilder, CallbackQueryHandler, MessageHandler, filters

self._validate_at_least_one_channel()
self.validate_at_least_one_channel()
self._validate_polling_support()

builder = ApplicationBuilder()
builder.token(self.channels.telegram.config["api_token"])

builder.request(self.channels.telegram.create_request())
builder.get_updates_request(self.channels.telegram.create_request())

background_tasks = []

@builder.post_init
Expand Down Expand Up @@ -263,6 +255,7 @@ async def error_handler(update, context):

app = builder.build()
app.add_handler(MessageHandler(filters.ALL, callback))
app.add_handler(CallbackQueryHandler(callback=callback, pattern=None))
app.add_error_handler(error_handler)
app.run_polling()

Expand Down Expand Up @@ -311,7 +304,8 @@ def _exclude_unsupported_changes(self, changes):
)
return changes - unsupported

def _validate_at_least_one_channel(self):
def validate_at_least_one_channel(self):
"""Raise BotError if at least one channel is missing."""
if not self.channels:
raise BotError(
"At least one channel is required to run a bot. "
Expand Down
40 changes: 24 additions & 16 deletions maxbot/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,14 @@ def __init__(self, *, available_extensions=None):
self._bot_created = False
self.resources = Resources.empty()
self._user_locks = None
self._state_store = None
self._persistence_manager = None
self._nlu = None
self._message_schemas = {}
self._command_schemas = {}
self._before_turn_hooks = []
self._after_turn_hooks = []
self._middlewares = []
self._history_tracked = False

def add_message(self, schema, name):
"""Register a custom message.
Expand Down Expand Up @@ -152,35 +153,37 @@ def user_locks(self, value):
self._user_locks = value

@property
def state_store(self):
"""State store used to maintain state variables.
def persistence_manager(self):
"""Return persistence manager.
See default implementation :class:`~maxbot.state_store.SQLAlchemyStateStore` for more information.
Used, for example, to save-restore state variables.
See default implementation :class:`~maxbot.persistence_manager.SQLAlchemyManager` for more information.
You can use this property to configure default state tracker::
builder.state_store.engine = sqlalchemy.create_engine(...)
builder.persistence_manager.engine = sqlalchemy.create_engine(...)
or set your own implementation::
class CustomStateStore:
class CustomPersistenceManager:
@contextmanager
def __call__(self, dialog):
# load variables...
yield StateVariables(...)
# save variables...
builder.state_store = CustomStateStore()
builder.persistence_manager = CustomPersistenceManager()
"""
if self._state_store is None:
if self._persistence_manager is None:
# lazy import to speed up load time
from .state_store import SQLAlchemyStateStore
from .persistence_manager import SQLAlchemyManager

self._state_store = SQLAlchemyStateStore()
return self._state_store
self._persistence_manager = SQLAlchemyManager()
return self._persistence_manager

@state_store.setter
def state_store(self, value):
self._state_store = value
@persistence_manager.setter
def persistence_manager(self, value):
self._persistence_manager = value

@property
def nlu(self):
Expand Down Expand Up @@ -407,6 +410,10 @@ def use_resources(self, resources):
"""
self.resources = resources

def track_history(self, value=True):
"""Set/reset flag that controls history recording."""
self._history_tracked = value

def _create_dialog_manager(self):
message_schema = self._create_message_schema()
command_schema = self._create_command_schema()
Expand Down Expand Up @@ -449,7 +456,8 @@ def build(self):
return MaxBot(
self._create_dialog_manager(),
channels,
self.user_locks,
self._state_store,
self._user_locks,
self._persistence_manager,
self.resources,
self._history_tracked,
)
Loading

0 comments on commit cbf46bd

Please sign in to comment.