Skip to content

Commit

Permalink
Merge branch 'main' into sk/pipeline-validations
Browse files Browse the repository at this point in the history
# Conflicts:
#	assets/javascript/apps/pipeline/PipelineNode.tsx
  • Loading branch information
snopoke committed Jan 17, 2025
2 parents d024f0e + 4e20233 commit 49f60f7
Show file tree
Hide file tree
Showing 58 changed files with 1,659 additions and 96 deletions.
91 changes: 91 additions & 0 deletions api-schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,73 @@ paths:
schema:
$ref: '#/components/schemas/ExperimentSessionWithMessages'
description: ''
/api/trigger_bot/:
post:
operationId: trigger_bot_message
description: Trigger the bot to send a message to the user
summary: Trigger the bot to send a message to the user
tags:
- Channels
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TriggerBotMessageRequest'
examples:
GenerateBotMessageAndSend:
value:
identifier: part1
experiment: exp1
platform: connect_messaging
prompt_text: Tell the user to do something
summary: Generates a bot message and sends it to the user
ParticipantNotFound:
value:
detail: Participant not found
summary: Participant not found
ExperimentChannelNotFound:
value:
detail: Experiment cannot send messages on the connect_messaging
channel
summary: Experiment cannot send messages on the specified channel
ConsentNotGiven:
value:
detail: User has not given consent
summary: User has not given consent
required: true
security:
- apiKeyAuth: []
- tokenAuth: []
responses:
'200':
description: No response body
'400':
content:
application/json:
schema:
description: Bad Request
examples:
ConsentNotGiven:
value:
detail: User has not given consent
summary: User has not given consent
description: ''
'404':
content:
application/json:
schema:
description: Not Found
examples:
ParticipantNotFound:
value:
detail: Participant not found
summary: Participant not found
ExperimentChannelNotFound:
value:
detail: Experiment cannot send messages on the connect_messaging
channel
summary: Experiment cannot send messages on the specified channel
description: ''
/channels/api/{experiment_id}/incoming_message:
post:
operationId: new_api_message
Expand Down Expand Up @@ -683,6 +750,7 @@ components:
- sureadhere
- api
- slack
- commcare_connect
type: string
description: |-
* `telegram` - Telegram
Expand All @@ -692,6 +760,7 @@ components:
* `sureadhere` - SureAdhere
* `api` - API
* `slack` - Slack
* `commcare_connect` - CommCare Connect
Team:
type: object
properties:
Expand All @@ -705,6 +774,28 @@ components:
required:
- name
- slug
TriggerBotMessageRequest:
type: object
properties:
identifier:
type: string
title: Participant Identifier
platform:
allOf:
- $ref: '#/components/schemas/PlatformEnum'
title: Participant Platform
experiment:
type: string
format: uuid
title: Experiment ID
prompt_text:
type: string
title: Prompt to go to bot
required:
- experiment
- identifier
- platform
- prompt_text
securitySchemes:
apiKeyAuth:
type: apiKey
Expand Down
46 changes: 46 additions & 0 deletions apps/api/permissions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import base64
import hashlib
import hmac
import logging
from functools import wraps

from django.conf import settings
from django.http import HttpResponse
from django.utils.translation import gettext as _
from rest_framework import exceptions
from rest_framework.authentication import BaseAuthentication
Expand All @@ -10,6 +17,8 @@

from .models import UserAPIKey

logger = logging.getLogger("ocs.api")


class BaseKeyAuthentication(BaseAuthentication):
def get_key(self, request):
Expand Down Expand Up @@ -67,3 +76,40 @@ class DjangoModelPermissionsWithView(DjangoModelPermissions):
"PATCH": ["%(app_label)s.change_%(model_name)s"],
"DELETE": ["%(app_label)s.delete_%(model_name)s"],
}


def verify_hmac(view_func):
"""Match the HMAC signature in the request to the calculated HMAC using the request payload."""

# Based on https://github.com/dimagi/commcare-hq/blob/master/corehq/util/hmac_request.py
@wraps(view_func)
def _inner(request, *args, **kwargs):
expected_digest = convert_to_bytestring_if_unicode(request.headers.get("X-Mac-Digest"))
secret_key_bytes = convert_to_bytestring_if_unicode(settings.COMMCARE_CONNECT_SERVER_SECRET)

if not (expected_digest and secret_key_bytes):
logger.exception(
"Request rejected reason=%s request=%s",
"hmac:missing_key" if not secret_key_bytes else "hmac:missing_header",
request.path,
)
return HttpResponse(_("Missing HMAC signature or shared key"), status=401)

data_digest = get_hmac_digest(key=secret_key_bytes, data_bytes=request.body)

if not hmac.compare_digest(data_digest, expected_digest):
logger.exception("Calculated HMAC does not match expected HMAC")
return HttpResponse(_("Invalid payload"), status=401)
return view_func(request, *args, **kwargs)

return _inner


def get_hmac_digest(key: bytes, data_bytes: bytes) -> bytes:
digest = hmac.new(key, data_bytes, hashlib.sha256).digest()
digest_base64 = base64.b64encode(digest)
return digest_base64


def convert_to_bytestring_if_unicode(shared_key):
return shared_key.encode("utf-8") if isinstance(shared_key, str) else shared_key
7 changes: 7 additions & 0 deletions apps/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,10 @@ class ParticipantDataUpdateRequest(serializers.Serializer):
choices=ChannelPlatform.choices, default=ChannelPlatform.API, label="Participant Platform"
)
data = ParticipantExperimentData(many=True)


class TriggerBotMessageRequest(serializers.Serializer):
identifier = serializers.CharField(label="Participant Identifier")
platform = serializers.ChoiceField(choices=ChannelPlatform.choices, label="Participant Platform")
experiment = serializers.UUIDField(label="Experiment ID")
prompt_text = serializers.CharField(label="Prompt to go to bot")
87 changes: 87 additions & 0 deletions apps/api/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import logging
from uuid import UUID

from celery.app import shared_task
from django.contrib.contenttypes.models import ContentType
from django.db.models import Subquery
from taskbadger.celery import Task as TaskbadgerTask

from apps.channels.clients.connect_client import CommCareConnectClient
from apps.channels.models import ChannelPlatform, ExperimentChannel
from apps.chat.channels import ChannelBase
from apps.experiments.models import Experiment, ParticipantData

logger = logging.getLogger(__name__)


@shared_task(bind=True, base=TaskbadgerTask, ignore_result=True)
def setup_connect_channels_for_bots(self, connect_id: UUID, experiment_data_map: dict):
"""
Set up Connect channels for experiments that are using the ConnectMessaging channel
experiment_data_map: {experiment_id: participant_data_id}
"""

experiment_ids = list(experiment_data_map.keys())
participant_data_ids = list(experiment_data_map.values())

# Only create channels for experiments that are using the ConnectMessaging channel
experiments_using_connect = ExperimentChannel.objects.filter(
platform=ChannelPlatform.COMMCARE_CONNECT,
experiment__id__in=experiment_ids,
).values_list("experiment_id", flat=True)

participant_data = (
ParticipantData.objects.filter(
id__in=participant_data_ids,
object_id__in=Subquery(experiments_using_connect),
content_type=ContentType.objects.get_for_model(Experiment),
)
.exclude(system_metadata__has_key="commcare_connect_channel_id")
.prefetch_related("content_object")
.all()
)

connect_client = CommCareConnectClient()

# TODO: Refactor when experiment_id is directly on the ParticipantData table
# https://github.com/dimagi/open-chat-studio/issues/1046
channels = ExperimentChannel.objects.filter(
platform=ChannelPlatform.COMMCARE_CONNECT,
experiment_id__in=[participant_data.object_id for participant_data in participant_data],
)

channels = {ch.experiment_id: ch for ch in channels}

for participant_datum in participant_data:
try:
experiment = participant_datum.content_object
channel = channels[experiment.id]
commcare_connect_channel_id = connect_client.create_channel(
connect_id=connect_id, channel_source=channel.extra_data["commcare_connect_bot_name"]
)
participant_datum.system_metadata["commcare_connect_channel_id"] = commcare_connect_channel_id
participant_datum.save(update_fields=["system_metadata"])
except Exception as e:
logger.exception(f"Failed to create channel for participant data {participant_datum.id}: {e}")


@shared_task(ignore_result=True)
def trigger_bot_message_task(data):
"""
Trigger a bot message for a participant on a specific platform using the prompt from the given data.
"""
platform = data["platform"]
experiment_public_id = data["experiment"]
prompt_text = data["prompt_text"]
identifier = data["identifier"]

experiment = Experiment.objects.get(public_id=experiment_public_id)
experiment_channel = ExperimentChannel.objects.get(platform=platform, experiment=experiment)

published_experiment = experiment.default_version
ChannelClass = ChannelBase.get_channel_class_for_platform(platform)
channel = ChannelClass(experiment=published_experiment, experiment_channel=experiment_channel)

channel.ensure_session_exists_for_participant(identifier)
channel.experiment_session.ad_hoc_bot_message(prompt_text, use_experiment=published_experiment)
Loading

0 comments on commit 49f60f7

Please sign in to comment.