Skip to content

Commit

Permalink
Create interactive game app (#1705)
Browse files Browse the repository at this point in the history
aka Cranky Uncle adaptation to IoGT/Rapidpro

Co-authored-by: Md Eamin Hossain <[email protected]>
Co-authored-by: alviriseup <[email protected]>
  • Loading branch information
3 people authored Jul 15, 2024
1 parent d637f2c commit 1c9986c
Show file tree
Hide file tree
Showing 31 changed files with 1,402 additions and 0 deletions.
2 changes: 2 additions & 0 deletions home/templates/home/tags/child_pages.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
{% render_article_card child is_first_content %}
{% elif child.get_type == 'poll' or child.get_type == 'survey' or child.get_type == 'quiz' %}
{% render_questionnaire_wrapper child child.direct_display %}
{% elif child.get_type == 'interactivepage' %}
{% render_interactive_card child %}
{% endif %}
{% endwith %}
{% endfor %}
Expand Down
11 changes: 11 additions & 0 deletions home/templatetags/generic_components.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ def render_questionnaire_card(context, page, background_color=None, font_color=N
return context


@register.inclusion_tag('generic_components/interactive_card.html', takes_context=True)
def render_interactive_card(context, page=None, background_color=None, font_color=None):

context.update({
'interactive': page,
'background_color': background_color,
'font_color': font_color,
})
return context


@register.simple_tag
def language_picker_style():
theme_settings = globals_.theme_settings
Expand Down
70 changes: 70 additions & 0 deletions interactive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
Users can communicate with RapidPro by creating an Interactive chatbots in IoGT.

## Create a service account in IoGT

RapidPro needs a service account within IoGT to be able to authenticate itself to IoGT and establish communication. The service account is set up within IoGT as an unprivileged user account within a specific user group. Any Wagtail Admin should be able to set up the service account manually.

- Create a new group (_Settings_ > _Groups_, _Add a group_)
- The name must be exactly 'rapidpro_chatbot'
- No permissions are required
- Create the service account (_Settings_ > _Users_, _Add a user_)
- The username just has to be unique
- Use a long and varied password (no need to remember it)
- It is recommended to give the user a descriptive first and last name, e.g. 'RapidPro Bot'
- Add the user to the 'rapidpro_chatbot' group (in the _Roles_ section)
- Verify everything is set up correctly, by checking that the authorization header to be used by RapidPro when authenticating to IoGT now appears in _Interactive_ > _Interactive RapidPro Channels_.

You can now set up a channel in RapidPro, and add this channel to IoGT so you can start communicating with it.

## Setting up an Interactive Chatbot channel
1. Find the _Chatbot Authentication Header_ for the chatbot in the IoGT admin panel at _Interactive_ > _Interactive RapidPro Channels_ - displayed at the top of the page.
2. In your workspace in RapidPro, go to _Settings_ > _Add Channel_.
3. Select _External Api_.
4. Fill in the following form fields
1. **URN Type**: External identifier
2. **Address**: Enter a name identifying your RapidPro server, e.g. my_rapidpro_server
3. **Method**: HTTP POST
4. **Encoding**: Default Encoding (TODO: Confirm this)
5. **Content type**: JSON - application/json
6. **Max length**: 6400 (see notes: [1](#note-1), [2](#note-2))
7. **Authorization Header Value**: Enter the _Chatbot Authentication Header_ from the IoGT admin panel _Interactive_ > _Interactive RapidPro Channels_, mentioned earlier (include the word 'Bearer' as well as the code)
8. **Send URL**: `https://[URL of the IoGT site]/api/interactive/rapidpro-webhook/ ` where `[URL of the IoGT site]` is the URL of your IoGT site, e.g. `rw.goodinternet.org`.
9. **Request Body**: `{"id":{{id}}, "text":{{text}}, "to":{{to}}, "to_no_plus":{{to_no_plus}}, "from":{{from}}, "from_no_plus":{{from_no_plus}}, "channel":{{channel}}, "quick_replies":{{quick_replies}}}` (TODO: Check whether we can omit the `to_no_plus` and `from_no_plus`)
10. **MT Response check**: ok
5. Click _Submit_
6. You will land on the _External API Configuration_ page
7. Copy the _Received URL_ that is displayed on the page. This URL should look roughly like this: `https://[your RapidPro server]/c/ex/some-uuid-here-7afd839d7123-a95d/receive`
8. Go back to the IoGT admin panel, _Interactive_ > _Interactive RapidPro Channels_, _Add interactive channel_.
1. **DISPLAY NAME**: This is the name that users will see when interacting with the interactive bot.
2. **REQUEST URL**: Enter the _Received URL_ from step 7, immediately above.

### Notes

<a id="note-1">**1**</a> On older RapidPro installations the upper limit might only be up to 640.

<a id="note-2">**2**</a> Messages longer than the limit will be split up into multiple parts and will have to be re-joined on the IoGT side, which can cause spaces between words to be lost. Thus, larger limits are better, so we can avoid messages being split as much as possible.

## Allowing users to interact with a Chatbot
As part of an Interactive page content, you can now add an _Interactive RapidPro Chatbot button_. It has the following form fields:

- **Title**: The title identifying the conversation with the chatbot in the user's inbox.
- **Trigger string**: The initial message that will be sent to the chatbot, starting the conversation
- **Channel**: Select the channel you just created.

Upon clicking the page title, the user will be directed to the interactive page (showing the first message from RapidPro).

## Create a service account in IoGT with the management command

This is only an option for those who are operating an instance of IoGT (system administrator, ops team). The management commmand performs the manual steps for creating a service account in IoGT (detailed above) and is provided to automate the process (e.g. when an IoGT site is first created).

The setup command.
```
python manage.py sync_rapidpro_bot_user
```

There is another command that will print the authorization header to be used by RapidPro when authenticating to IoGT.
```
python manage.py get_rapidpro_authentication_header_value
```

This is the same value that appears in the IoGT admin panel under _Chatbot_ > _Chatbot channels_.
Empty file added interactive/__init__.py
Empty file.
8 changes: 8 additions & 0 deletions interactive/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.contrib import admin

from .models import InteractiveChannel


@admin.register(InteractiveChannel)
class InteractiveChannelAdmin(admin.ModelAdmin):
list_display = ("id", "display_name", "request_url")
11 changes: 11 additions & 0 deletions interactive/api/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.conf import settings
from rest_framework import permissions


class IsRapidProGroupUser(permissions.BasePermission):
message = "User is not allowed to access the webhook"

def has_permission(self, request, view):
return request.user.groups.filter(
name=settings.RAPIDPRO_BOT_GROUP_NAME
).exists()
15 changes: 15 additions & 0 deletions interactive/api/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from rest_framework import serializers


class RapidProMessageSerializer(serializers.Serializer):
channel = serializers.UUIDField()
from_ = serializers.CharField(required=False)
id = serializers.CharField()
quick_replies = serializers.JSONField()
text = serializers.CharField()
to = serializers.UUIDField()

def get_fields(self):
fields = super().get_fields()
fields["from"] = fields.pop("from_")
return fields
11 changes: 11 additions & 0 deletions interactive/api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.urls import path

from .views import RapidProWebhook

app_name = "interactive_api"

urlpatterns = [
path(
"rapidpro-webhook/", RapidProWebhook.as_view(), name="rapidpro_message_webhook"
)
]
66 changes: 66 additions & 0 deletions interactive/api/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from django.utils.timezone import now
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from interactive.models import Message

from .permissions import IsRapidProGroupUser
from .serializers import RapidProMessageSerializer


class RapidProWebhook(APIView):
permission_classes = [IsAuthenticated, IsRapidProGroupUser]

def post(self, request):
serializer = RapidProMessageSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

rapidpro_message_id = serializer.validated_data.get("id")
to = serializer.validated_data.get("to")
text = serializer.validated_data.get("text")
quick_replies = serializer.validated_data.get("quick_replies")
from_field = serializer.validated_data.get("from")
channel = serializer.validated_data.get("channel")

# Get the latest message for the 'to' recipient
prev_msg = (
Message.objects.filter(to=to, channel=channel)
.order_by("-created_at")
.first()
)

# Update or create a new message
if prev_msg:
prev_msg_text = prev_msg.text.strip()

if prev_msg_text.endswith("[CONTINUE]"):
text = prev_msg_text + text
else:
text = text

fields_to_update = {
"rapidpro_message_id": rapidpro_message_id,
"text": text,
"quick_replies": quick_replies,
"updated_at": now(),
}
Message.objects.filter(
rapidpro_message_id=prev_msg.rapidpro_message_id
).update(**fields_to_update)
else:
# Create a new message
message_data = {
"rapidpro_message_id": rapidpro_message_id,
"text": text,
"quick_replies": quick_replies,
"to": to,
"from_field": from_field,
"channel": channel,
"created_at": now(),
"updated_at": now(),
}
Message.objects.create(**message_data)

return Response(data="ok", status=status.HTTP_200_OK)
6 changes: 6 additions & 0 deletions interactive/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class InteractiveConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "interactive"
8 changes: 8 additions & 0 deletions interactive/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django import forms


class MessageSendForm(forms.Form):
text = forms.CharField(
widget=forms.TextInput(attrs={"class": "btn btn-outline-secondary"}),
min_length=1,
)
22 changes: 22 additions & 0 deletions interactive/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 3.2.24 on 2024-03-21 08:46

from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='InteractiveChannel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('display_name', models.CharField(help_text='Name for the interactive bot that the user will seen when interacting with it', max_length=80)),
('request_url', models.URLField(help_text='To set up a interactive bot channel on your RapidPro server and get a request URL, follow the steps outline in the Section "Setting up a Chatbot channel" here: https://github.com/unicef/iogt/blob/develop/messaging/README.md')),
],
),
]
26 changes: 26 additions & 0 deletions interactive/migrations/0002_message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 3.2.24 on 2024-03-23 10:04

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('interactive', '0001_initial'),
]

operations = [
migrations.CreateModel(
name='Message',
fields=[
('rapidpro_message_id', models.AutoField(primary_key=True, serialize=False)),
('text', models.TextField()),
('quick_replies', models.JSONField(blank=True, null=True)),
('to', models.CharField(max_length=255)),
('from_field', models.CharField(max_length=255)),
('channel', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
]
29 changes: 29 additions & 0 deletions interactive/migrations/0003_interactivepage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 3.2.24 on 2024-03-23 12:52

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


class Migration(migrations.Migration):

dependencies = [
('wagtailcore', '0066_collection_management_permissions'),
('interactive', '0002_message'),
]

operations = [
migrations.CreateModel(
name='InteractivePage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('button_text', models.CharField(blank=True, max_length=255, null=True)),
('trigger_string', models.CharField(blank=True, help_text='Language short code will postfix after trigger string e.g string_en', max_length=255, null=True)),
('channel', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='interactive.interactivechannel')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page', home.mixins.PageUtilsMixin, home.mixins.TitleIconMixin),
),
]
29 changes: 29 additions & 0 deletions interactive/migrations/0004_auto_20240328_0700.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 3.2.24 on 2024-03-28 07:00

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


class Migration(migrations.Migration):

dependencies = [
('interactive', '0003_interactivepage'),
]

operations = [
migrations.AlterField(
model_name='interactivepage',
name='button_text',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='interactivepage',
name='channel',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='interactive.interactivechannel'),
),
migrations.AlterField(
model_name='interactivepage',
name='trigger_string',
field=models.CharField(help_text='Language short code will postfix after trigger string e.g string_en', max_length=255),
),
]
22 changes: 22 additions & 0 deletions interactive/migrations/0005_auto_20240506_1128.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 3.2.24 on 2024-05-06 11:28

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('interactive', '0004_auto_20240328_0700'),
]

operations = [
migrations.RemoveField(
model_name='interactivepage',
name='button_text',
),
migrations.AlterField(
model_name='interactivepage',
name='trigger_string',
field=models.CharField(max_length=255),
),
]
Empty file.
Loading

0 comments on commit 1c9986c

Please sign in to comment.