Chat con moderación de los mensajes con Azure SignalR y Azure Cognitive Services

Una de las pruebas de concepto que estuve desarrollando a finales del año pasado es una que trata sobre el desarrollo de un chat en tiempo real, donde el contenido que los usuarios mandan al canal debe estar moderado. Esta moderación puede ser bien porque hayan palabras malsonantes o bien porque la intención de la frase no sea apropiada. Además cada uno de los mensajes del chat debe registrarse en una base de datos con el momento exacto en el transcurre un video que se está reproduciendo donde se encuentra el chat. Con todo ello la foto quedó con las siguientes piezas:

Arquitectura propuesta

Y el resultado sería como el siguiente:

Infraestructura como código con Terraform

Todas esta arquitectura se ha desplegado haciendo uso de Terraform, y su provider para Microsoft Azure, para que fuera lo más sencillo posible montar, y desmontar, el entorno con todos los servicios necesarios.

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 2.26"
    }
  }

  backend "remote" {
    organization = "returngis"
    workspaces {
      name = "signalr-chatroom"
    }
  }
}

provider "azurerm" {
  features {}
}

resource "random_pet" "service" {

}


#Resource Group
resource "azurerm_resource_group" "rg" {
  name     = random_pet.service.id
  location = var.location
}

#Azure SignalR
resource "azurerm_signalr_service" "signalr" {
  name                = random_pet.service.id
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  sku {
    name     = "Free_F1"
    capacity = 1
  }

  cors {
    allowed_origins = ["*"]
  }

  features {
    flag  = "ServiceMode"
    value = "Default"
  }

  features {
    flag  = "EnableConnectivityLogs"
    value = "False"
  }

  features {
    flag  = "EnableMessagingLogs"
    value = "False"
  }

}

#CosmosDB
resource "azurerm_cosmosdb_account" "cosmosdbaccount" {
  name                = random_pet.service.id
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  offer_type          = "Standard"
  kind                = "GlobalDocumentDB"

  consistency_policy {
    consistency_level       = "Session"
    max_interval_in_seconds = 10
    max_staleness_prefix    = 200
  }

  geo_location {
    location          = azurerm_resource_group.rg.location
    failover_priority = 0
  }

}

#CosmosDB - Database
resource "azurerm_cosmosdb_sql_database" "cosmosdb_db" {
  name                = var.cosmosdb_db
  resource_group_name = azurerm_resource_group.rg.name
  account_name        = azurerm_cosmosdb_account.cosmosdbaccount.name
  throughput          = 400
}

#CosmosDB - Container
resource "azurerm_cosmosdb_sql_container" "cosmosdb_container" {
  name                  = var.cosmosdb_container
  resource_group_name   = azurerm_resource_group.rg.name
  account_name          = azurerm_cosmosdb_account.cosmosdbaccount.name
  database_name         = azurerm_cosmosdb_sql_database.cosmosdb_db.name
  partition_key_path    = var.cosmosdb_partition_key
  partition_key_version = 1
  throughput            = 400
}


#Azure Search
resource "azurerm_search_service" "search" {
  name                = random_pet.service.id
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  sku                 = "standard"
}

#Content Moderator
resource "azurerm_cognitive_account" "content_moderator" {
  name                = "${random_pet.service.id}-mod"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  kind                = "ContentModerator"

  sku_name = "S0"
}

#LUIS
resource "azurerm_cognitive_account" "luis" {
  name                = "${random_pet.service.id}-luis-prediction"
  location            = var.luis_location
  resource_group_name = azurerm_resource_group.rg.name
  kind                = "LUIS"

  sku_name = "S0"
}

resource "azurerm_cognitive_account" "luis_authoring" {
  name                = "${random_pet.service.id}-luis-authoring"
  location            = var.luis_location
  resource_group_name = azurerm_resource_group.rg.name
  kind                = "LUIS.Authoring"

  sku_name = "F0"
}

output "luis_authoring_endpoint" {
  value = azurerm_cognitive_account.luis_authoring.endpoint
}

output "luis_authoring_key" {
  value = azurerm_cognitive_account.luis_authoring.primary_access_key
}


#Application Insights
resource "azurerm_application_insights" "appinsights" {
  name                = random_pet.service.id
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  application_type    = "web"
}

#Azure Storage for the static website (Chat)
resource "azurerm_storage_account" "staticweb" {
  name                     = replace(random_pet.service.id, "-", "")
  resource_group_name      = azurerm_resource_group.rg.name
  location                 = azurerm_resource_group.rg.location
  account_tier             = "Standard"
  account_replication_type = "GRS"
  account_kind             = "StorageV2"

  static_website {
    index_document = "index.html"
  }
}

output "storage" {
  value = azurerm_storage_account.staticweb.name
}

#App Service
#Plan
resource "azurerm_app_service_plan" "appserviceplan" {
  name                = random_pet.service.id
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  sku {
    tier = "Free"
    size = "F1"
  }
}

#Web App
resource "azurerm_app_service" "webapp" {
  name                = random_pet.service.id
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
  app_service_plan_id = azurerm_app_service_plan.appserviceplan.id

  # site_config {
  #   cors {
  #     allowed_origins     = [azurerm_storage_account.staticweb.primary_web_endpoint]
  #     support_credentials = false
  #   }
  # }

  app_settings = {
    "Azure:SignalR:ConnectionString" = azurerm_signalr_service.signalr.primary_connection_string
    "CosmosDb:Account"               = azurerm_cosmosdb_account.cosmosdbaccount.endpoint
    "CosmosDb:Key"                   = azurerm_cosmosdb_account.cosmosdbaccount.primary_key
    "CosmosDb:DatabaseName"          = var.cosmosdb_db
    "CosmosDb:ContainerName"         = var.cosmosdb_container
    "LUIS:PredictionEndpoint"        = azurerm_cognitive_account.luis.endpoint
    "LUIS:AppId"                     = var.luis_app_id
    "LUIS:ApiKey"                    = azurerm_cognitive_account.luis.primary_access_key
    "APPINSIGHTS_INSTRUMENTATIONKEY" = azurerm_application_insights.appinsights.instrumentation_key
    "ContentModerator:Endpoint"      = azurerm_cognitive_account.content_moderator.endpoint
    "ContentModerator:ApiKey"        = azurerm_cognitive_account.content_moderator.primary_access_key
  }
}

output "app_service_name" {
  value = azurerm_app_service.webapp.name
}

Lo guay es que gracias a esta configuración podemos desplegar toda la infraestructura en Microsoft Azure, con un simple terraform apply. En este caso he hecho uso de Terraform Cloud, pero también es posible usar una cuenta de Azure Storage para almacenar el estado del entorno. Sin embargo, para separar lo que es la infraestructura del código he utilizado GitHub Actions para esta segunda parte.

GitHub Actions

Para poder juntar todas las piezas, la infraestructura definida en Terraform y el código fuente que hay que desplegar, he creado un workflow en GitHub Actions que haga de pegamento de todo ello:

name: "Terraform"

env:
  LU_FILE: LUIS-app/Offensive-Intents.lu

on:
  push:
    branches:
      - main
    paths-ignore:
      - "LUIS-app/*.lu"
      - "LUIS-app/tests/*.json"
jobs:
  terraform:
    name: "Terraform"
    runs-on: ubuntu-latest
    outputs:
      webapp_name: ${{ steps.tf_output.outputs.webapp }}
      storage: ${{ steps.tf_output.outputs.storage }}
      luis_auth_endpoint: ${{ steps.tf_output.outputs.luisendpoint }}
      luis_key: ${{ steps.tf_output.outputs.luiskey }}
    # Use the Bash shell regardless whether the GitHub Actions runner is ubuntu-latest, macos-latest, or windows-latest
    defaults:
      run:
        shell: bash
    steps:
      # Checkout the repository to the GitHub Actions runner
      - name: Checkout
        uses: actions/[email protected]
      # Install the latest version of Terraform CLI and configure the Terraform CLI configuration file with a Terraform Cloud user API token
      - name: Setup Terraform
        uses: hashicorp/[email protected]
        with:
          terraform_wrapper: false #We need this to get the output
          cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
      # Initialize a new or existing Terraform working directory by creating initial files, loading any remote state, downloading modules, etc.
      - name: Terraform Init
        run: terraform init
      # Generates an execution plan for Terraform
      - name: Terraform Plan
        run: terraform plan
        # On push to main, build or change infrastructure according to Terraform configuration files
        # Note: It is recommended to set up a required "strict" status check in your repository for "Terraform Cloud". See the documentation on "strict" required status checks for more information: https://help.github.com/en/github/administering-a-repository/types-of-required-status-checks
      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve
      - name: Get outputs from Terraform
        id: tf_output
        run: |
          echo "::set-output name=webapp::$(terraform output -raw app_service_name)"
          echo "::set-output name=luisendpoint::$(terraform output -raw luis_authoring_endpoint)"
          echo "::set-output name=luiskey::$(terraform output -raw luis_authoring_key)"
          echo "::set-output name=storage::$(terraform output storage)"
  luis:
    name: "LUIS model"
    needs: [terraform]
    runs-on: ubuntu-latest
    outputs:
      appid: ${{ steps.luis_app.outputs.appid }}
    steps:
      # Checkout the repository to the GitHub Actions runner
      - name: Checkout
        uses: actions/[email protected]
      - uses: azure/[email protected]
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      - uses: actions/[email protected]
        with:
          node-version: "12.x"
      - name: Install @microsoft/botframework-cli
        run: |
          npm i -g @microsoft/botframework-cli
      - id: luis_app
        name: Get the App Id if the LUIS app already exists
        run: |
          appId=$(bf luis:application:list --subscriptionKey ${{ needs.terraform.outputs.luis_key }} --endpoint ${{ needs.terraform.outputs.luis_auth_endpoint }} | jq -c '.[] | select(.name | . and contains('\"${{ needs.terraform.outputs.webapp_name }}\"')) | .id')
          echo "::set-output name=appid::$appId"
      - name: if it doesn't exist, create a LUIS app, assign the prediction key, import the last version of the model, train and publish
        if: ${{ steps.luis_app.outputs.appid == '' }}
        run: |
          response=$(bf luis:application:create --name ${{ needs.terraform.outputs.webapp_name }} --subscriptionKey ${{ needs.terraform.outputs.luis_key }} --endpoint ${{ needs.terraform.outputs.luis_auth_endpoint }} --versionId=0.0 --culture es-es --json)
          appId=$(echo "$response" | jq '.id' | xargs)            
          bf luis:application:assignazureaccount --azureSubscriptionId $(az account show --query id -o tsv) --appId $appId --accountName ${{ needs.terraform.outputs.webapp_name }}-luis-prediction --subscriptionKey ${{ needs.terraform.outputs.luis_key }} --endpoint ${{ needs.terraform.outputs.luis_auth_endpoint }} --resourceGroup ${{ needs.terraform.outputs.webapp_name }} --armToken $(az account get-access-token --query accessToken -o tsv)
          bf luis:convert -i $LU_FILE -o ./model.json --name ${{ needs.terraform.outputs.webapp_name }}
          bf luis:version:import --appId $appId --endpoint ${{ needs.terraform.outputs.luis_auth_endpoint }} --subscriptionKey ${{ needs.terraform.outputs.luis_key }}  --in model.json --versionId 0.2
          bf luis:train:run --appId $appId --versionId 0.2 --endpoint ${{ needs.terraform.outputs.luis_auth_endpoint }} --subscriptionKey ${{ needs.terraform.outputs.luis_key }} --wait
          bf luis:application:publish --appId $appId --versionId 0.2 --endpoint ${{ needs.terraform.outputs.luis_auth_endpoint }} --subscriptionKey ${{ needs.terraform.outputs.luis_key }}
          az webapp config appsettings set -g ${{ needs.terraform.outputs.webapp_name }} -n ${{ needs.terraform.outputs.webapp_name }} --settings LUIS:AppId=$appId
  deployment:
    name: "Deploy code"
    needs: [terraform, luis]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/[email protected]
      - uses: actions/setup-dotnet[email protected]
        with:
          dotnet-version: "3.1.x"
      - run: |
          dotnet restore
          dotnet build ./Chatroom -c Release
          dotnet publish ./Chatroom -c Release -o './webapp'
      - uses: azure/[email protected]
        with:
          creds: ${{ secrets.AZURE_CREDENTIALS }}
      - uses: azure/[email protected]
        with:
          app-name: ${{ needs.terraform.outputs.webapp_name }}
          package: ./webapp
      - name: Prepend Chatroom URL to the index.js 
        run: |
         echo -e "const CHATROOM_URL='https://${{ needs.terraform.outputs.webapp_name }}.azurewebsites.net';\n$(cat Chat/js/index.js)" > Chat/js/index.js 
         cat Chat/js/index.js
      - name: Copy static web into the Azure Storage      
        uses: azure/[email protected]
        with:
          azcliversion: 2.0.72
          inlineScript: |
            az storage blob upload-batch --account-name ${{ needs.terraform.outputs.storage }} -d '$web' -s ./Chat

En él puedes ver que lo primero que hago es desplegar la infraestructura con Terraform, creo la aplicación de LUIS, si todavía no existe, y por último despliego el código en el App Service y en la cuenta de almacenamiento algunos estáticos.

Ahora que ya tenemos la infraestructura y el código desplegado, vamos a revisar cada uno de los componentes que forman la arquitectura.

El chat

Para el primer componente de la arquitectura he utilizado el código de ejemplo que se comparte para el tutorial de Azure SignalR, con algunos extras: he añadido un video en la parte superior del chat, que será el que se esté reproduciendo mientras los usuarios estén mandando sus mensajes, para el que he usado Azure Media Player.

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
    <meta name="viewport" content="width=device-width">
    <meta http-equiv="Pragma" content="no-cache" />
    <meta http-equiv="Expires" content="0" />
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" />
    <link href="//amp.azure.net/libs/amp/latest/skins/amp-default/azuremediaplayer.min.css" rel="stylesheet">
    <script src="//amp.azure.net/libs/amp/latest/azuremediaplayer.min.js"></script>
    <link href="css/site.css" rel="stylesheet" />
    <title>Azure SignalR Group Chat</title>
</head>

<body>
    <h2 class="text-center" style="margin-top: 0; padding-top: 30px; padding-bottom: 30px;">Azure SignalR Group Chat
    </h2>

    <div class="container" style="height: calc(100% - 110px);">

        <video id="azuremediaplayer" class="azuremediaplayer amp-default-skin amp-big-play-centered" controls autoplay muted
            width="100%" height="400" poster="" tabindex="0">
            <source
                src="//amssamples.streaming.mediaservices.windows.net/3b970ae0-39d5-44bd-b3a3-3136143d6435/AzureMediaServicesPromo.ism/manifest"
                type="application/vnd.ms-sstr+xml" />
            <p class="amp-no-js">To view this video please enable JavaScript, and consider upgrading to a web browser
                that supports HTML5 video</p>
        </video>
        <div id="messages" style="background-color: whitesmoke; height: 400px; "></div>
        <div style="width: 100%; border-left-style: ridge; border-right-style: ridge;">
            <textarea id="message" style="width: 100%; padding: 5px 10px; border-style: hidden;"
                placeholder="Type message and press Enter to send..."></textarea>
        </div>
        <div style="overflow: auto; border-style: ridge; border-top-style: hidden;">
            <button class="btn-warning pull-right" id="echo">Echo</button>
            <button class="btn-success pull-right" id="sendmessage">Send</button>
        </div>
    </div>
    <div class="modal alert alert-danger fade" id="myModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <div>Connection Error...</div>
                    <div><strong style="font-size: 1.5em;">Hit Refresh/F5</strong> to rejoin. ;)</div>
                </div>
            </div>
        </div>
    </div>
    <!--Script references. -->
    <!--Reference the SignalR library. -->
    <script type="text/javascript"
        src="https://cdn.jsdelivr.net/npm/@microsoft/[email protected]/dist/browser/signalr.min.js"></script>

    <!--Add script to update the page and send messages.-->
    <script type="text/javascript" src="js/index.js"></script>
</body>

</html>

También modifiqué el JavaScript asociado al chat, para mandar más información al servidor, como el momento en el que se encuentra la reproducción del video, además de la fecha actual. Además, el chat también recibe más información que en el tutorial, ya que necesito saber si el mensaje es apropiado o no (ugly), si quiero pintarlo como privado al usuario que lo ha enviado o a todos los participantes.

document.addEventListener('DOMContentLoaded', function () {

    var videoPlayer = amp("azuremediaplayer");

    function generateRandomName() {
        return Math.random().toString(36).substring(2, 10);
    }

    // Get the user name and store it to prepend to messages.
    var username = generateRandomName();
    var promptMessage = 'Enter your name:';
    do {
        username = prompt(promptMessage, username);
        if (!username || username.startsWith('_') || username.indexOf('<') > -1 || username.indexOf('>') > -1) {
            username = '';
            promptMessage = 'Invalid input. Enter your name:';
        }
    } while (!username)

    // Set initial focus to message input box.
    var messageInput = document.getElementById('message');
    messageInput.focus();

    function createMessageEntry(encodedName, encodedMsg, currentTime, ugly, private) {
        var entry = document.createElement('div');
        var uglyClass = ugly ? "ugly" : "";
        var privateClass = private ? "private" : "";

        entry.classList.add("message-entry");
        if (encodedName === "_SYSTEM_") {
            entry.innerHTML = encodedMsg;
            entry.classList.add("text-center");
            entry.classList.add("system-message");
        } else if (encodedName === "_BROADCAST_") {
            entry.classList.add("text-center");
            entry.innerHTML = `<div class="text-center broadcast-message">${encodedMsg}<em>${new Date().getDate()}<em></div>`;
        } else if (encodedName === username) {
            entry.innerHTML = `<div class="message-avatar pull-right">${encodedName}<br/><i style="font-size:x-small; float:right;">${new Date().toLocaleTimeString()}</i></div>` +
                `<div class="message-content ${uglyClass} ${privateClass} pull-right">${encodedMsg}<br/><i style="font-size:x-small">video time: ${currentTime} secs.</i><div>`;

        } else {
            entry.innerHTML = `<div class="message-avatar pull-left">${encodedName}</div>` +
                `<div class="message-content ${uglyClass} pull-left">${encodedMsg}<br/><i style="font-size:x-small; float:left;">${new Date().toLocaleTimeString()}</i><div>`;
        }
        return entry;
    }

    function bindConnectionMessage(connection) {
        var messageCallback = function (name, message, currentTime, ugly, terms, private) {
            if (!message) return;
            // Html encode display name and message.
            var encodedName = name;
            var encodedMsg = message.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

            if (ugly) {
                terms.forEach(term => {
                    var regEx = new RegExp(term, "ig");
                    encodedMsg = encodedMsg.replace(regEx, `${term.substring(0, 1)}${term.substring(1).replace(/./g, '*')}`);
                });
            }
            var messageEntry = createMessageEntry(encodedName, encodedMsg, currentTime, ugly, private);

            var messageBox = document.getElementById('messages');
            messageBox.appendChild(messageEntry);
            messageBox.scrollTop = messageBox.scrollHeight;
        };
        // Create a function that the hub can call to broadcast messages.
        connection.on('broadcastMessage', messageCallback);
        connection.on('echo', messageCallback);
        connection.onclose(onConnectionError);
    }

    function onConnected(connection) {
        console.log('connection started');
        connection.send('broadcastMessage', '_SYSTEM_', username + ' JOINED');
        document.getElementById('sendmessage').addEventListener('click', function (event) {

            // Call the broadcastMessage method on the hub.
            if (messageInput.value) {
                connection.send('broadcastMessage', username, messageInput.value, videoPlayer.currentTime(), new Date());
            }

            // Clear text box and reset focus for next comment.
            messageInput.value = '';
            messageInput.focus();
            event.preventDefault();
        });
        document.getElementById('message').addEventListener('keypress', function (event) {
            if (event.keyCode === 13) {
                event.preventDefault();
                document.getElementById('sendmessage').click();
                return false;
            }
        });
        document.getElementById('echo').addEventListener('click', function (event) {
            // Call the echo method on the hub.
            connection.send('echo', username, messageInput.value);

            // Clear text box and reset focus for next comment.
            messageInput.value = '';
            messageInput.focus();
            event.preventDefault();
        });
    }

    function onConnectionError(error) {
        if (error && error.message) {
            console.error(error.message);
        }
        var modal = document.getElementById('myModal');
        modal.classList.add('in');
        modal.style = 'display: block;';
    }

    var connection = new signalR.HubConnectionBuilder()
        .withUrl('/chat')
        .build();
    bindConnectionMessage(connection);
    connection.start()
        .then(function () {
            onConnected(connection);
        })
        .catch(function (error) {
            console.error(error.message);
        });
});

En esta prueba de concepto, la parte de cliente y de servidor están alojadas en el mismo App Service pero lo más probable es que en un entorno productivo estén en sitios separados. Es por ello que, a nivel de seguridad, será necesario añadir la URL de la aplicación cliente en la configuración de CORS del servidor, para que pueda hacer las llamadas desde JavaScript al dominio donde se encuentre este.

Una prueba sencilla en este aspecto sería alojar el código dentro de Chat una cuenta de almacenamiento de Azure Storage y habilitar la opción Static website. Esta parte está contemplada en el despliegue de la infraestructura con Terraform, con el fin de demostrar que con la configuración por defecto no es posible conectarse al chat desde otra ubicación externa a nuestra web. Para poder acceder a este sitio web estático, deberías de selecciona la cuenta de almacenamiento generada, seleccionar la sección Static Website y acceder a través del endpoint llamado Primary endpoint.

En este caso, para habilitar CORS para la web estática desplegada en Azure Storage solamente habría que acudir al apartado CORS del App Service y añadir la URL que nos facilitan como primary endpoint en el apartado Static website, y seleccionar el check Enable Access-Control-Allow-Credentials.

Azure SignalR

Esta pieza es la que nos permitirá gestionar las conexiones de nuestros participantes conectados a chat. Será consumida tanto por el lado cliente como por el servidor. Por un lado, el cliente se conectará a través de la librería de JavaSript y en el caso del servidor está desarrollado en .NET, como veremos en el siguiente apartado.

Azure App Service

Además de contener la parte cliente, vista en el apartado «El Chat», también aloja el Hub que gestiona los mensajes que le llegan de los participantes, por mediación de Azure SignalR:

using Microsoft.AspNetCore.SignalR;
using System;
using Microsoft.Azure.CognitiveServices.ContentModerator;
using System.IO;
using System.Text;
using System.Collections.Generic;
using System.Net.Http;
using System.Web;
using Newtonsoft.Json;
using Microsoft.Extensions.Options;

public class Chat : Hub
{
    private readonly ICosmosDbService _cosmosDbService;
    private readonly IOptions<LUISConfiguration> _luisConfiguration;
    private readonly IOptions<ContentModeratorConfiguration> _contentModeratorConfiguration;

    public Chat(ICosmosDbService cosmosDbService, IOptions<LUISConfiguration> luisConfiguration, IOptions<ContentModeratorConfiguration> contentModeratorConfiguration)
    {
        _cosmosDbService = cosmosDbService;
        _luisConfiguration = luisConfiguration;
        _contentModeratorConfiguration = contentModeratorConfiguration;
    }

    //Broadcast the message to all clients
    public void BroadcastMessage(string name, string message, decimal currentTime, DateTime date)
    {
        var ugly = false;
        var terms = new List<string>();

        //Content Moderator
        //Check if the content of the message is offensive
        var client = new ContentModeratorClient(new ApiKeyServiceClientCredentials(_contentModeratorConfiguration.Value.ApiKey))
        {
            Endpoint = _contentModeratorConfiguration.Value.Endpoint
        };


        try
        {
            var result = client.TextModeration.ScreenText("text/plain", new MemoryStream(Encoding.UTF8.GetBytes(message)), listId: "91", language: "spa");
            ugly = result.Terms != null && result.Terms.Count > 0;

            if (ugly) //If it's ugly with content moderator I don't call LUIS
            {
                foreach (var term in result.Terms)
                {
                    Console.WriteLine($"Ugly term: {term.Term}");
                    terms.Add(term.Term);
                }

                //Clients is an interface that gives you access to all connected clients
                Clients.All.SendAsync("broadcastMessage", name, message, currentTime, ugly, terms);
            }
            else
            { //but if content moderator says it's ok I'll do a double check with LUIS

                var httpClient = new HttpClient();
                var queryString = HttpUtility.ParseQueryString(string.Empty);

                //The request header contains your subscription key
                httpClient.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", _luisConfiguration.Value.ApiKey);

                //The "q" parameter contains the uterance to send to LUIS
                queryString["query"] = message;

                // These optional request parameters are set to their default values
                queryString["verbose"] = "true";
                queryString["show-all-intents"] = "true";
                queryString["staging"] = "false";
                queryString["timezoneOffset"] = "0";

                var predictionEndpointUri = String.Format("{0}luis/prediction/v3.0/apps/{1}/slots/production/predict?{2}", _luisConfiguration.Value.PredictionEndpoint, _luisConfiguration.Value.AppId, queryString);

                Console.WriteLine("endpoint: " + _luisConfiguration.Value.PredictionEndpoint);
                Console.WriteLine("appId: " + _luisConfiguration.Value.AppId);
                Console.WriteLine("queryString: " + queryString);
                Console.WriteLine("endpointUri: " + predictionEndpointUri);

                var response = httpClient.GetAsync(predictionEndpointUri).GetAwaiter().GetResult();

                var strResponseContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();

                // Display the JSON result from LUIS.
                Console.WriteLine(strResponseContent.ToString());

                dynamic data = JsonConvert.DeserializeObject(strResponseContent);

                Console.WriteLine($"This sentence is {data.prediction.topIntent}");

                if (data.prediction.topIntent == "Offensive")
                    //Clients is an interface that gives you access to all connected clients
                    Clients.Client(Context.ConnectionId).SendAsync("echo", name, "[PRIVADO] Este tipo de mensajes no están permitidos", currentTime, false, null, true);
                else
                    //Clients is an interface that gives you access to all connected clients
                    Clients.All.SendAsync("broadcastMessage", name, message, currentTime);

            }

        }
        catch (Exception error)
        {
            Console.WriteLine($"Error: {error}");
        }

        //Save in CosmosDb
        _cosmosDbService.AddMessageAsync(new Message
        {
            Id = Guid.NewGuid().ToString(),
            ChatRoomId = "chatroom1",
            Date = date,
            UserName = name,
            VideoTime = currentTime,
            Text = message,
            Ugly = ugly,
            Terms = terms
        });
    }

    //Sends the message back to the caller
    public void Echo(string name, string message)
    {
        Clients.Client(Context.ConnectionId).SendAsync("echo", name, message + " (echo from server)");
    }
}

Como puedes ver en el código, cuando un mensaje es recibido, lo primero que ocurre es que nuestro servidor va a comprobar, a través del servicio Content Moderator, si el mensaje contiene algún término inapropiado. En el caso de ser así mandará de vuelta a todos los participantes el mismo con la variable ugly a true y los términos que se consideran como ofensivos. Esto hará que en el lado del cliente se marquen con asteriscos estas palabras dentro del mensaje, además de marcarlos en rojo. El list Id 91 que aparece durante la llamada al servicio se trata de una lista personalizada que veremos a continuación.
Una vez pasado este primer check, si no se encontrara ningún término se comprobará también, a través de LUIS, si la frase como tal puede resultar ofensiva, ya que un mensaje puede ser malintencionado aun no teniendo palabras malsonantes. Si fuera el caso, el mensaje se devolvería solamente al participante que lo envió, diciéndole que el contenido que ha enviado no está permitido. Esto se ha desarrollado de esta manera simplemente para demostrar que hay diferentes formas de gestionar los mensajes inapropiados, a modo de prueba. Por último, si pasa las dos comprobaciones de manera satisfactoria se devolvería el mensaje al chat sin ningún tipo de marca o restricción. Pase lo que pase, el mensaje será almacenado en CosmosDB para su posterior procesado, o incluso por si quisiéramos volver a reproducir el video con los mensajes que se han ido enviando en cada momento de este, lo cual no está contemplado en esta prueba.

Content Moderator: lista personalizada de términos

Puede ocurrir que las palabras malsonantes que tiene registradas el servicio no sean suficientes para tu escenario concreto. Para ello, el servicio de Content Moderator te permite generar listas personalizadas donde puedes añadir las tuyas propias. Para esta PoC utilicé el siguiente código, ubicado en el proyecto TermLists, donde utilicé el listado que aparece en este artículo a modo de ejemplo:

using System;
using System.IO;
using System.Threading;
using Microsoft.Azure.CognitiveServices.ContentModerator;
using Microsoft.Azure.CognitiveServices.ContentModerator.Models;

namespace ContentModerator
{
    class Program
    {
        /// <summary>
        /// The language of the terms in the term lists.
        /// </summary>
        private const string lang = "spa";
        /// <summary>
        /// The minimum amount of time, in milliseconds, to wait between calls
        /// to the Content Moderator APIs.
        /// </summary>
        private const int throttleRate = 3000;

        /// <summary>
        /// The number of minutes to delay after updating the search index before
        /// performing image match operations against the list.
        /// </summary>
        private const double latencyDelay = 0.5;
        static void Main(string[] args)
        {

            //Create Content Moderator Client            
            var client = new ContentModeratorClient(new ApiKeyServiceClientCredentials("<YOUR_MODERATION_API_KEY"))
            {
                Endpoint = "https://ACCOUNT_NAME.cognitiveservices.azure.com/"
            };

            try
            {
                //1. Create a term list
                //There is a maximum limit of 5 term lists with each list to not exceed 10,000 terms.
                var listId = CreateTermList(client);
                //2. Add terms in the term list
                using (var file = new StreamReader("Insultos.txt"))
                {
                    string line;

                    while ((line = file.ReadLine()) != null)
                        AddTerm(client, listId, line);

                }
                //3. Get all terms in a term list
                GetAllTerms(client, listId);

                Console.WriteLine("Done");

            }
            catch (Exception ex)
            {
                Console.WriteLine($"ERROR: {ex.Message}");
                throw;
            }

        }

        private static string CreateTermList(ContentModeratorClient client)
        {
            Console.WriteLine($"Creating term list.");

            //https://www.elespanol.com/social/20181227/maravillosa-lista-insultos-castellano-perdiendo/363964046_0.html
            var body = new Body("Lista de insultos", "La maravillosa lista de insultos que el castellano está perdiendo");
            var list = client.ListManagementTermLists.Create("application/json", body);

            if (!list.Id.HasValue)
                throw new Exception($"{nameof(list.Id)} value missing.");

            var listId = list.Id.Value.ToString();
            Console.Write($"Term list created. ID {listId}");
            Thread.Sleep(throttleRate); //Your Content Moderator service key has a requests-per-second (RPS) rate limit, and if you exceed the limit, the SDK throws an exception with a 429 error code. A free tier key has a one-RPS rate limit.

            return listId;
        }

        private static void AddTerm(ContentModeratorClient client, string listId, string term)
        {
            Console.WriteLine($"Adding {term} into the term list");
            client.ListManagementTerm.AddTerm(listId, term, lang);
            Thread.Sleep(throttleRate);
        }

        private static void GetAllTerms(ContentModeratorClient client, string listId)
        {
            Console.WriteLine($"Getting terms in term list with ID {listId}");
            var terms = client.ListManagementTerm.GetAllTerms(listId, lang);

            foreach (var term in terms.Data.Terms)
            {
                Console.WriteLine($"{term.Term}");
            }

            Thread.Sleep(throttleRate);

        }
    }
}

El id que devuelve durante la creación de la lista de términos personalizados es el que se está usando en el código anterior, el 91, el cual debe incluirse como parte de la llamada para que lo tenga en cuenta.

Language Understanding Intelligence Service

Al usar Language Understanding Intelligence Service, es necesario tener un modelo que sepa las intenciones que queremos reconocer. En este caso solamente queremos saber si la intención de la frase es ofensiva o no lo es. Como parte del código hay una versión inicial de dicho modelo, que puedes encontrarla en LUIS-app/Offensive-Intents.lu.


> LUIS application information
> !# @app.name = Offensive-Intents
> !# @app.desc = Trying to detect offensive intentions
> !# @app.versionId = 0.1
> !# @app.culture = es-es
> !# @app.luis_schema_version = 7.0.0
> !# @app.tokenizerVersion = 1.0.0


> # Intent definitions

# None


# Offensive
- habría que matar a todos
- habría que matar a los plátanos
- habría que matar a las peras


> # Entity definitions


> # PREBUILT Entity definitions


> # Phrase list definitions


> # List entities

> # RegEx entities

Este puede desplegarse de forma manual, a través del portal de LUIS o bien a través de Bot Framework CLI. En este ejemplo se despliega como parte del flujo de GitHub Actions.

Azure CosmosDB

Este servicio, como he mencionado en el apartado del App Service, se encarga de almacenar en forma de documentos JSON todos los mensajes que los participantes envían a través del chat, tanto buenos como malos. El objetivo es que estos puedan ser más tarde explotados, ya sea para generar dashboards o incluso por si queremos reproducir el video con los mensajes que fueron enviados durante la emisión.

Cognitive Search

Por último, he incluido en la arquitectura la opción de usar Azure Cognitive Search, lo cual me va a permitir indexar y enriquecer la información que me está llegando desde CosmosDB, y por lo tanto de los mensajes que envían mis usuarios. La idea es que todos los mensajes que se almacenen puedan ser indexados y enriquecidos gracias a los modelos que nosotros decidamos.
Si quisieras probar este servicio, una vez que tengas la infraestructura desplegada, deberías de seleccionar tu cuenta de CosmosDB y seleccionar la opción Add Azure Cognitive Search:

CosmosDB – Add Azure Cognitive Search

Podrás seleccionar entonces el servicio de Cognitive Search generado con Terraform y enriquecer tus búsquedas sobre los mensajes del chat.

¡Saludos!