Skip to content

Commit

Permalink
refactor: decompose connector into multiple platform-specific adapters
Browse files Browse the repository at this point in the history
  • Loading branch information
avidale committed Feb 16, 2021
1 parent 44c4a18 commit da677dc
Show file tree
Hide file tree
Showing 14 changed files with 554 additions and 349 deletions.
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setuptools.setup(
name="tgalice",
version="0.2.25",
version="0.2.26",
author="David Dale",
author_email="dale.david@mail.ru",
description="Yet another common wrapper for Alice skills and Facebook/Telegram bots",
Expand Down
1 change: 0 additions & 1 deletion tgalice/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from tgalice.server import flask_server
from tgalice.storage import session_storage, message_logging
from tgalice.dialog_manager.base import COMMANDS
from tgalice.storage.message_logging import LoggedMessage
from tgalice.nlu import basic_nlu

from tgalice.dialog.names import COMMANDS, REQUEST_TYPES, SOURCES
6 changes: 6 additions & 0 deletions tgalice/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .base import BaseAdapter
from .alice import AliceAdapter
from .facebook import FacebookAdapter
from .text import TextAdapter
from .tg import TelegramAdapter
from .vk import VkAdapter
153 changes: 153 additions & 0 deletions tgalice/adapters/alice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
from typing import Dict, Optional

from ..adapters.base import BaseAdapter, Context, Response, logger
from ..dialog.names import SOURCES, REQUEST_TYPES, COMMANDS
from ..interfaces.yandex import YandexRequest, YandexResponse
from ..utils.text import encode_uri
from ..utils.content_manager import YandexImageAPI


class AliceAdapter(BaseAdapter):
SOURCE = SOURCES.ALICE

def __init__(self, native_state=False, image_manager: Optional[YandexImageAPI] = None, **kwargs):
super(AliceAdapter, self).__init__(**kwargs)
self.native_state = native_state
self.image_manager: Optional[YandexImageAPI] = image_manager

def make_context(self, message: Dict, **kwargs) -> Context:
metadata = {}

if set(message.keys()) == {'body'}:
message = message['body']
try:
sess = message['session']
except KeyError:
raise KeyError(f'The key "session" not found in message among keys {list(message.keys())}.')
if sess.get('user', {}).get('user_id'):
# the new user_id, which is persistent across applications
user_id = self.SOURCE + '_auth__' + sess['user']['user_id']
else:
# the old user id, that changes across applications
user_id = self.SOURCE + '__' + sess['user_id']
try:
message_text = message['request'].get('command', '')
except KeyError:
raise KeyError(f'The key "request" not found in message among keys {list(message.keys())}.')
metadata['new_session'] = message.get('session', {}).get('new', False)

ctx = Context(
user_object=None,
message_text=message_text,
metadata=metadata,
user_id=user_id,
source=self.SOURCE,
raw_message=message,
)

ctx.request_type = message['request'].get('type', REQUEST_TYPES.SIMPLE_UTTERANCE)
ctx.payload = message['request'].get('payload', {})
try:
ctx.yandex = YandexRequest.from_dict(message)
except Exception as e:
logger.warning('Could not deserialize Yandex request: got exception "{}".'.format(e))

return ctx

def make_response(self, response: Response, original_message=None, **kwargs):

directives = {}
if response.commands:
for command in response.commands:
if command == COMMANDS.REQUEST_GEOLOCATION:
directives[COMMANDS.REQUEST_GEOLOCATION] = {}

result = {
"version": original_message['version'],
"response": {
"end_session": response.has_exit_command,
"text": response.text
}
}
if self.native_state and response.updated_user_object:
if self.native_state == 'session':
result['session_state'] = response.updated_user_object
elif self.native_state == 'application':
result['application_state'] = response.updated_user_object
elif self.native_state == 'user':
if original_message.get('session') and 'user' not in original_message['session']:
result['application_state'] = response.updated_user_object
result['user_state_update'] = response.updated_user_object
else:
if 'session' in response.updated_user_object:
result['session_state'] = response.updated_user_object['session']
if 'application' in response.updated_user_object:
result['application_state'] = response.updated_user_object['application']
if 'user' in response.updated_user_object:
result['user_state_update'] = response.updated_user_object['user']
if response.raw_response is not None:
if isinstance(response.raw_response, YandexResponse):
result = response.raw_response.to_dict()
else:
result['response'] = response.raw_response
return result
if response.voice is not None and response.voice != response.text:
result['response']['tts'] = response.voice.replace('\n', ' ')
buttons = response.links or []
for button in buttons:
# avoid cyrillic characters in urls - they are not supported by Alice
if 'url' in button:
button['url'] = encode_uri(button['url'])
if response.suggests:
buttons = buttons + [{'title': suggest} for suggest in response.suggests]
for button in buttons:
if not isinstance(button.get('hide'), bool):
button['hide'] = True
result['response']['buttons'] = buttons
if response.image_id:
result['response']['card'] = {
'type': 'BigImage',
'image_id': response.image_id,
'description': response.text
}
elif response.image_url and self.image_manager:
image_id = self.image_manager.get_image_id_by_url(response.image_url)
if image_id:
result['response']['card'] = {
'type': 'BigImage',
'image_id': image_id,
'description': response.text
}
if response.gallery is not None:
result['response']['card'] = response.gallery.to_dict()
if response.image is not None:
result['response']['card'] = response.image.to_dict()
if response.show_item_meta is not None:
result['response']['show_item_meta'] = response.show_item_meta
if directives:
result['response']['directives'] = directives
return result

def uses_native_state(self, context: Context) -> bool:
""" Whether dialog state can be extracted directly from a context"""
return bool(self.native_state)

def get_native_state(self, context: Context) -> Optional[Dict]:
""" Return native dialog state if it is possible"""
if not self.native_state:
return
message = context.raw_message
state = message.get('state', {})

if self.native_state == 'session':
user_object = state.get('session')
elif self.native_state == 'user':
user_object = state.get('user')
# for unauthorized users, use application state instead
if message.get('session') and 'user' not in message['session']:
user_object = state.get('application')
elif self.native_state == 'application':
user_object = state.get('application')
else:
user_object = state
return user_object
58 changes: 58 additions & 0 deletions tgalice/adapters/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import copy
import logging
from typing import Optional, Dict

from ..dialog.serialized_message import SerializedMessage
from ..dialog import Context, Response


logger = logging.getLogger(__name__)


class BaseAdapter:
""" A base class for adapters that encode and decode messages into a single format """

def make_context(self, message, **kwargs) -> Context:
""" Get a raw `message` in a platform-specific format and encode it into a unified `Context` """
raise NotImplementedError()

def make_response(self, response: Response, original_message=None, **kwargs):
""" Get a unified `Response` object and decode it into the platform-specific response """
raise NotImplementedError()

def serialize_context(self, context: Context, data=None, **kwargs) -> Optional[SerializedMessage]:
if data is None:
data = context.raw_message
if context.request_id is not None:
kwargs['request_id'] = context.request_id
return SerializedMessage(
text=context.message_text,
user_id=context.user_id,
from_user=True,
data=data,
source=context.source,
**kwargs
)

def serialize_response(self, data, context: Context, response: Response, **kwargs) -> Optional[SerializedMessage]:
data = copy.deepcopy(data)
if context.request_id is not None:
kwargs['request_id'] = context.request_id
if response.label:
kwargs['label'] = response.label
return SerializedMessage(
text=response.text,
user_id=context.user_id,
from_user=False,
data=data,
source=context.source,
**kwargs
)

def uses_native_state(self, context: Context) -> bool:
""" Whether dialog state can be extracted directly from a context"""
return False

def get_native_state(self, context: Context) -> Optional[Dict]:
""" Return native dialog state if it is possible"""
return
47 changes: 47 additions & 0 deletions tgalice/adapters/facebook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from typing import Dict

from tgalice.dialog.names import SOURCES, REQUEST_TYPES
from tgalice.adapters.base import BaseAdapter, Context, Response, logger


class FacebookAdapter(BaseAdapter):
SOURCE = SOURCES.FACEBOOK

def make_context(self, message: Dict, **kwargs) -> Context:
ctx = Context(
user_object=None,
message_text=message.get('message', {}).get('text', ''),
metadata={},
user_id=self.SOURCE + '__' + message['sender']['id'],
source=self.SOURCE,
raw_message=message,
)

if not message.get('message', {}).get('text', ''):
payload = message.get('postback', {}).get('payload')
if payload is not None:
ctx.payload = payload
ctx.request_type = REQUEST_TYPES.BUTTON_PRESSED # todo: check if it is really the case
# todo: do something in case of attachments (message['message'].get('attachments'))
# if user sends us a GIF, photo,video, or any other non-text item

return ctx

def make_response(self, response: Response, original_message=None, **kwargs):
if response.raw_response is not None:
return response.raw_response
result = {'text': response.text}
if response.suggests or response.links:
links = [{'type': 'web_url', 'title': link['title'], 'url': link['url']} for link in response.links]
suggests = [{'type': 'postback', 'title': s, 'payload': s} for s in response.suggests]
result = {
"attachment": {
"type": "template",
"payload": {
"template_type": "button",
"text": response.text,
"buttons": links + suggests
}
}
}
return result # for bot.send_message(recipient_id, result)
39 changes: 39 additions & 0 deletions tgalice/adapters/text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from typing import Dict

from tgalice.dialog.names import SOURCES, REQUEST_TYPES
from tgalice.adapters.base import BaseAdapter, Context, Response, logger


class TextAdapter(BaseAdapter):
SOURCE = SOURCES.TEXT

def make_context(self, message: Dict, **kwargs) -> Context:
ctx = Context(
user_object=None,
message_text=message,
metadata={},
user_id='the_text_user',
source=self.SOURCE,
raw_message=message,
)
return ctx

def make_response(self, response: Response, original_message=None, **kwargs):
result = response.text
if response.voice is not None and response.voice != response.text:
result = result + '\n[voice: {}]'.format(response.voice)
if response.image_id:
result = result + '\n[image: {}]'.format(response.image_id)
if response.image_url:
result = result + '\n[image: {}]'.format(response.image_url)
if response.sound_url:
result = result + '\n[sound: {}]'.format(response.sound_url)
if len(response.links) > 0:
result = result + '\n' + ', '.join(
['[{}: {}]'.format(link['title'], link['url']) for link in response.links]
)
if len(response.suggests) > 0:
result = result + '\n' + ', '.join(['[{}]'.format(s) for s in response.suggests])
if len(response.commands) > 0:
result = result + '\n' + ', '.join(['{{{}}}'.format(c) for c in response.commands])
return [result, response.has_exit_command]

0 comments on commit da677dc

Please sign in to comment.