Skip to content

Commit

Permalink
State machine for handlers and chats. This way only some handler migh…
Browse files Browse the repository at this point in the history
…t apply
  • Loading branch information
jlmadurga committed Mar 29, 2016
1 parent 814aaf1 commit 7d91525
Show file tree
Hide file tree
Showing 13 changed files with 791 additions and 23 deletions.
53 changes: 53 additions & 0 deletions microbot/migrations/0008_auto_20160329_0516.py
@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.4 on 2016-03-29 10:16
from __future__ import unicode_literals

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('microbot', '0007_auto_20160328_1108'),
]

operations = [
migrations.CreateModel(
name='ChatState',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('chat', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='microbot.Chat', verbose_name='Chat')),
],
options={
'verbose_name': 'Chats States',
},
),
migrations.CreateModel(
name='State',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, max_length=255, verbose_name='State name')),
('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='states', to='microbot.Bot', verbose_name='Bot')),
],
options={
'verbose_name': 'State',
'verbose_name_plural': 'States',
},
),
migrations.AddField(
model_name='chatstate',
name='state',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chat', to='microbot.State', verbose_name='State'),
),
migrations.AddField(
model_name='handler',
name='source_states',
field=models.ManyToManyField(related_name='source_handlers', to='microbot.State', verbose_name='Source States'),
),
migrations.AddField(
model_name='handler',
name='target_state',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='target_handlers', to='microbot.State', verbose_name='Target State'),
),
]
1 change: 1 addition & 0 deletions microbot/models/__init__.py
@@ -1,4 +1,5 @@
from microbot.models.telegram_api import (User, Chat, Message, Update) # NOQA
from microbot.models.state import State, ChatState # NOQA
from microbot.models.bot import Bot # NOQA
from microbot.models.response import Response # NOQA
from microbot.models.handler import Handler, Request, UrlParam, HeaderParam # NOQA
Expand Down
32 changes: 26 additions & 6 deletions microbot/models/bot.py
Expand Up @@ -7,15 +7,15 @@
from django.dispatch import receiver
from django.core.urlresolvers import reverse
import logging
from microbot.models import User
from microbot.models import User, ChatState
from django.core.urlresolvers import RegexURLResolver
from django.core.urlresolvers import Resolver404
from telegram import ParseMode, ReplyKeyboardHide, ReplyKeyboardMarkup
from telegram.bot import InvalidToken
import ast
from django.conf import settings
from django.core.exceptions import ValidationError

from django.db.models import Q

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -52,8 +52,14 @@ def __str__(self):

def handle(self, update):
urlpatterns = []
for handler in self.handlers.filter(enabled=True):
urlpatterns.append(handler.urlpattern())
try:
state = ChatState.objects.get(chat=update.message.chat).state
for handler in self.handlers.filter(Q(enabled=True), Q(source_states=state) | Q(source_states=None)):
urlpatterns.append(handler.urlpattern())
except ChatState.DoesNotExist:
for handler in self.handlers.filter(enabled=True):
urlpatterns.append(handler.urlpattern())

resolver = RegexURLResolver(r'^', urlpatterns)
try:
resolver_match = resolver.resolve(update.message.text)
Expand All @@ -63,15 +69,29 @@ def handle(self, update):
callback, callback_args, callback_kwargs = resolver_match
logger.debug("Calling callback:%s for update %s with %s" %
(callback, update, callback_kwargs))
text, keyboard = callback(self, update=update, **callback_kwargs)
text, keyboard, target_state = callback(self, update=update, **callback_kwargs)
if keyboard:
keyboard = ast.literal_eval(keyboard)
keyboard = ReplyKeyboardMarkup(keyboard, resize_keyboard=True)
else:
keyboard = ReplyKeyboardHide()
self.send_message(chat_id=update.message.chat.id,
text=text.encode('utf-8'), reply_markup=keyboard, parse_mode=ParseMode.HTML)

if target_state:
try:
chat_state = ChatState.objects.get(chat=update.message.chat)
except ChatState.DoesNotExist:
logger.error("Chat state update error:%s for update %s with %s" %
(target_state, update, callback_kwargs))
else:
chat_state.state = target_state
chat_state.save()
logger.debug("Chat state updated:%s for update %s with %s" %
(target_state, update, callback_kwargs))
else:
logger.warning("No target state after calling:%s for update %s with %s" %
(callback, update, callback_kwargs))

def handle_hook(self, hook, data):
logger.debug("Calling hook %s process: with %s" % (hook.key, data))
text, keyboard = hook.process(self, data)
Expand Down
4 changes: 3 additions & 1 deletion microbot/models/handler.py
Expand Up @@ -105,6 +105,8 @@ class Handler(models.Model):
request = models.OneToOneField(Request)
response = models.OneToOneField(Response)
enabled = models.BooleanField(_('Enable'), default=True)
source_states = models.ManyToManyField('State', verbose_name=_('Source States'), related_name='source_handlers')
target_state = models.ForeignKey('State', verbose_name=_('Target State'), related_name='target_handlers', null=True, blank=True)

class Meta:
verbose_name = _('Handler')
Expand Down Expand Up @@ -133,4 +135,4 @@ def process(self, bot, update, **url_context):
response_context = {}
context['response'] = response_context
response_text, response_keyboard = self.response.process(**context)
return response_text, response_keyboard
return response_text, response_keyboard, self.target_state
33 changes: 33 additions & 0 deletions microbot/models/state.py
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
import logging
from microbot.models import Chat

logger = logging.getLogger(__name__)

@python_2_unicode_compatible
class State(models.Model):
name = models.CharField(_('State name'), db_index=True, max_length=255)
bot = models.ForeignKey('Bot', verbose_name=_('Bot'), related_name='states')

class Meta:
verbose_name = _('State')
verbose_name_plural = _('States')

def __str__(self):
return "%s" % self.name


@python_2_unicode_compatible
class ChatState(models.Model):
chat = models.OneToOneField(Chat, db_index=True, verbose_name=_('Chat'))
state = models.ForeignKey(State, verbose_name=_('State'), related_name='chat')

class Meta:
verbose_name = _('Chat State')
verbose_name = _('Chats States')

def __str__(self):
return "(%s:%s)" % (str(self.chat.id), self.state.name)
51 changes: 47 additions & 4 deletions microbot/serializers.py
@@ -1,6 +1,6 @@
from rest_framework import serializers
from microbot.models import User, Chat, Message, Update, Bot, EnvironmentVar, Handler, Request, UrlParam, HeaderParam, \
Response, Hook, Recipient
Response, Hook, Recipient, State, ChatState
from datetime import datetime
import time

Expand Down Expand Up @@ -71,6 +71,12 @@ class UserAPISerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = User
fields = ('first_name', 'last_name', 'username')

class StateSerializer(serializers.HyperlinkedModelSerializer):

class Meta:
model = State
fields = ['name']

class BotSerializer(serializers.ModelSerializer):
info = UserAPISerializer(many=False, source='user_api', read_only=True)
Expand Down Expand Up @@ -106,10 +112,13 @@ class Meta:
class HandlerSerializer(serializers.ModelSerializer):
request = RequestSerializer(many=False)
response = ResponseSerializer(many=False)
target_state = StateSerializer(many=False, required=False)
source_states = StateSerializer(many=True, required=False)

class Meta:
model = Handler
fields = ('name', 'pattern', 'enabled', 'request', 'response')
fields = ('name', 'pattern', 'enabled', 'request', 'response', 'target_state', 'source_states')
read_only = ('source_states', )

def _create_params(self, params, model, request):
for param in params:
Expand All @@ -125,13 +134,17 @@ def _update_params(self, params, query_get):
instance_param.save()

def create(self, validated_data):
state = None
if 'target_state' in validated_data:
state, _ = Request.objects.get_or_create(**validated_data['target_state'])
request, _ = Request.objects.get_or_create(**validated_data['request'])
response, _ = Response.objects.get_or_create(**validated_data['response'])

handler, _ = Handler.objects.get_or_create(pattern=validated_data['pattern'],
response=response,
enabled=validated_data['enabled'],
request=request)
request=request,
target_state=state)

self._create_params(validated_data['request']['url_parameters'], UrlParam, request)
self._create_params(validated_data['request']['header_parameters'], HeaderParam, request)
Expand All @@ -142,6 +155,10 @@ def update(self, instance, validated_data):
instance.pattern = validated_data.get('name', instance.name)
instance.pattern = validated_data.get('pattern', instance.pattern)
instance.enabled = validated_data.get('enabled', instance.enabled)
if 'target_state' in validated_data:
state, _ = State.objects.get_or_create(bot=instance.bot,
name=validated_data['target_state']['name'])
instance.target_state = state

instance.response.text_template = validated_data['response'].get('text_template', instance.response.text_template)
instance.response.keyboard_template = validated_data['response'].get('keyboard_template', instance.response.keyboard_template)
Expand Down Expand Up @@ -205,4 +222,30 @@ def update(self, instance, validated_data):
self._update_recipients(validated_data['recipients'], instance)

instance.save()
return instance
return instance

class ChatStateSerializer(serializers.ModelSerializer):
chat = serializers.IntegerField(source="chat.id")
state = StateSerializer(many=False)

class Meta:
model = ChatState
fields = ['chat', 'state']

def create(self, validated_data):
chat = Chat.objects.get(pk=validated_data['chat'])
state = State.objects.get(name=validated_data['state']['name'])

chat_state = ChatState.objects.create(chat=chat,
state=state)

return chat_state

def update(self, instance, validated_data):
chat = Chat.objects.get(pk=validated_data['chat']['id'])
state = State.objects.get(name=validated_data['state']['name'])

instance.chat = chat
instance.state = state
instance.save()
return instance
3 changes: 3 additions & 0 deletions microbot/test/factories/__init__.py
Expand Up @@ -4,4 +4,7 @@
from microbot.test.factories.hook import HookFactory, RecipientFactory # noqa
from microbot.test.factories.telegram_lib import (UserLibFactory, ChatLibFactory, # noqa
MessageLibFactory, UpdateLibFactory) # noqa
from microbot.test.factories.state import StateFactory, ChatStateFactory # noqa
from microbot.test.factories.handler import HandlerFactory, RequestFactory, UrlParamFactory, HeaderParamFactory # noqa
from microbot.test.factories.telegram_api import (UserAPIFactory, ChatAPIFactory, # noqa
MessageAPIFactory, UpdateAPIFactory) # noqa
16 changes: 16 additions & 0 deletions microbot/test/factories/state.py
@@ -0,0 +1,16 @@
# coding=utf-8
from factory import DjangoModelFactory, SubFactory, Sequence
from microbot.models import State, ChatState
from microbot.test.factories import ChatLibFactory, BotFactory

class StateFactory(DjangoModelFactory):
class Meta:
model = State
bot = SubFactory(BotFactory)
name = Sequence(lambda n: 'state_%d' % n)

class ChatStateFactory(DjangoModelFactory):
class Meta:
model = ChatState
chat = SubFactory(ChatLibFactory)
state = SubFactory(StateFactory)
38 changes: 38 additions & 0 deletions microbot/test/factories/telegram_api.py
@@ -0,0 +1,38 @@
# coding=utf-8
from factory import DjangoModelFactory, Sequence, SubFactory
from factory.fuzzy import FuzzyText
from microbot.models import User, Chat, Message, Update
from django.utils import timezone

class UserAPIFactory(DjangoModelFactory):
class Meta:
model = User
id = Sequence(lambda n: n+1)
first_name = Sequence(lambda n: 'first_name_%d' % n)
last_name = Sequence(lambda n: 'last_name_%d' % n)
username = Sequence(lambda n: 'username_%d' % n)

class ChatAPIFactory(DjangoModelFactory):
class Meta:
model = Chat
id = Sequence(lambda n: n+1)
type = "private"
title = Sequence(lambda n: 'title_%d' % n)
username = Sequence(lambda n: 'username_%d' % n)
first_name = Sequence(lambda n: 'first_name_%d' % n)
last_name = Sequence(lambda n: 'last_name_%d' % n)

class MessageAPIFactory(DjangoModelFactory):
class Meta:
model = Message
message_id = Sequence(lambda n: n+1)
from_user = SubFactory(UserAPIFactory)
date = timezone.now()
chat = SubFactory(ChatAPIFactory)
text = FuzzyText()

class UpdateAPIFactory(DjangoModelFactory):
class Meta:
model = Update
update_id = Sequence(lambda n: n+1)
message = SubFactory(MessageAPIFactory)
7 changes: 7 additions & 0 deletions microbot/urls.py
Expand Up @@ -17,9 +17,16 @@
url(r'^api/bots/(?P<bot_pk>[0-9]+)/handlers/(?P<pk>[0-9]+)/headerparams/$', views.HeaderParameterList.as_view(), name='handler-headerparameter-list'),
url(r'^api/bots/(?P<bot_pk>[0-9]+)/handlers/(?P<handler_pk>[0-9]+)/headerparams/(?P<pk>[0-9]+)/$', views.HeaderParameterDetail.as_view(),
name='handler-headerparameter-detail'),
url(r'^api/bots/(?P<bot_pk>[0-9]+)/handlers/(?P<pk>[0-9]+)/sourcestates/$', views.SourceStateList.as_view(), name='handler-sourcestate-list'),
url(r'^api/bots/(?P<bot_pk>[0-9]+)/handlers/(?P<handler_pk>[0-9]+)/sourcestates/(?P<pk>[0-9]+)/$', views.SourceStateDetail.as_view(),
name='handler-sourcestate-detail'),
url(r'^api/bots/(?P<bot_pk>[0-9]+)/hooks/$', views.HookList.as_view(), name='hook-list'),
url(r'^api/bots/(?P<bot_pk>[0-9]+)/hooks/(?P<pk>[0-9]+)/$', views.HookDetail.as_view(), name='hook-detail'),
url(r'^api/bots/(?P<bot_pk>[0-9]+)/hooks/(?P<pk>[0-9]+)/recipients/$', views.RecipientList.as_view(), name='hook-recipient-list'),
url(r'^api/bots/(?P<bot_pk>[0-9]+)/hooks/(?P<hook_pk>[0-9]+)/recipients/(?P<pk>[0-9]+)/$', views.RecipientDetail.as_view(), name='hook-recipient-list'),
url(r'^api/bots/(?P<bot_pk>[0-9]+)/states/$', views.StateList.as_view(), name='state-list'),
url(r'^api/bots/(?P<bot_pk>[0-9]+)/states/(?P<pk>[0-9]+)/$', views.StateDetail.as_view(), name='state-detail'),
url(r'^api/bots/(?P<bot_pk>[0-9]+)/chatstates/$', views.ChatStateList.as_view(), name='chatstate-list'),
url(r'^api/bots/(?P<bot_pk>[0-9]+)/chatstates/(?P<pk>[0-9]+)/$', views.ChatStateDetail.as_view(), name='chatstate-detail'),

]

0 comments on commit 7d91525

Please sign in to comment.