DEV Community

Cover image for Serverless and event-driven translation bot
Jimmy Dahlqvist for AWS Community Builders

Posted on

Serverless and event-driven translation bot

In a talk I recently gave at a conference I did some live coding on stage, in that session I created a translation service using AWS and Slack, where you could directly do translations from Slack using a slash command. You also got a audio file where Polly read back the translation for you.

The entire setup was serverless and didn't use much code, instead I used the SDK and Service integrations in StepFunctions (like I always tend to do), and EventBridge to create an event-driven architecture.

In this post I will explain the solution, and I how it was setup end to end. All the source code is available on GitHub as well.

Architecture

First of all, let us do an overview of the architecture and what patterns that I use, before we do a deep dive.

In this solution we will combine the best of two worlds from orchestration and choreography. We have four domain services that each is responsible for a certain task. They will emit domain events so we can orchestrate a Saga pattern. Where services will be invoked in different phases and in response to domain events. Each of the service consists of several steps choreographed by StepFunctions to run in a certain order.

Image showing saga orchestration and choreography.

If we now add some more details to the image above, and start laying out the services we use. We have our hook that Slack will invoke on our slash command this is implemented with API Gateway and Lambda. The translation service that is implemented with a StepFunction and Amazon Translate. The text to voice service, which is also is setup with a StepFunction and Amazon Polly. The final service is a service responsible communicating back to Slack with both the translated text but also the generated voice file.

The services are invoked and communicate in an event-driven way over EventBridge event-buses, both a custom and the default bus. The default bus relay messages from S3 when objects are created.

Image showing architecture overview.

With that short overview, let us dive deep into the different services, events, logic, and infrastructure.

Common infrastructure

In the common infrastructure we will create the custom EventBridge event-bus and we'll create a S3 bucket that we use as intermediate storage of translated text and generated voice.


AWSTemplateFormatVersion: "2010-09-09"
Description: Event-Driven Translation Common Infra
Parameters:
  Application:
    Type: String
    Description: Name of owning application
    Default: eventdriven-translation

Resources:
  ##########################################################################
  #   INFRASTRUCTURE
  ##########################################################################
  TranslationBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      BucketName: !Sub ${Application}-translation-bucket
      NotificationConfiguration:
        EventBridgeConfiguration:
          EventBridgeEnabled: true
      Tags:
        - Key: Application
          Value: !Ref Application

  EventBridgeBus:
    Type: AWS::Events::EventBus
    Properties:
      Name: !Sub ${Application}-eventbus

  SlackBotSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Description: Slack bot oauth token
      Name: /slackbot
      Tags:
        - Key: Application
          Value: !Ref Application

##########################################################################
#  Outputs                                                               #
##########################################################################
Outputs:
  TranslationBucket:
    Description: Name of the bucket to store translations in
    Value: !Ref TranslationBucket
    Export:
      Name: !Sub ${AWS::StackName}:TranslationBucket
  EventBridgeBus:
    Description: The EventBridge EventBus
    Value: !Ref EventBridgeBus
    Export:
      Name: !Sub ${AWS::StackName}:EventBridgeBus
  SlackBotSecret:
    Description: The Slack Bot Secret
    Value: !Ref SlackBotSecret
    Export:
      Name: !Sub ${AWS::StackName}:SlackBotSecret

Enter fullscreen mode Exit fullscreen mode

With this common infrastructure created we can move on.

Slack Integration

Next let's create the Slack Application and create the API that the application will call. We'll also create the Notification service that will send messages back to out Slack channel.

Slash command hook API

This will create the API that Slack will send the slash commands to. We will create this using API Gateway with a Lambda function integration where we will parse the command, send a response to Slack, and post an event onto our custom event-bus that will be the start of our translations Saga. This is this small part of the architecture.

Image showing architecture overview.

WARNING!
In this API setup there is no authorization! If you build this for anything else than a demo make sure you include authorization on you API.


AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Event-driven Slack Bot

Parameters:
  Application:
    Type: String
    Description: Name of the application
  CommonInfraStackName:
    Type: String
    Description: Name of the Common Infra Stack

Globals:
  Function:
    Runtime: python3.9
    Timeout: 30
    MemorySize: 1024

Resources:
  ##########################################################################
  #  WEBHOOK INFRASTRUCTURE                                                #
  ##########################################################################

  ##########################################################################
  #  WebHook HTTP                                                          #
  ##########################################################################
  SlackHookHttpApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      CorsConfiguration:
        AllowMethods:
          - GET
        AllowOrigins:
          - "*"
        AllowHeaders:
          - "*"

  ##########################################################################
  #  HTTP API Slackhook Lambdas                                           #
  ##########################################################################
  SlackhookFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/SlackhookLambda
      Handler: slackhook.handler
      Events:
        SackhookPost:
          Type: HttpApi
          Properties:
            Path: /slackhook
            Method: post
            ApiId: !Ref SlackHookHttpApi
      Policies:
        - EventBridgePutEventsPolicy:
            EventBusName:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:EventBridgeBus
      Environment:
        Variables:
          EVENT_BUS_NAME:
            Fn::ImportValue: !Sub ${CommonInfraStackName}:EventBridgeBus

##########################################################################
#  Outputs                                                               #
##########################################################################
Outputs:
  ApiEndpoint:
    Description: HTTP API endpoint URL
    Value: !Sub https://${SlackHookHttpApi}.execute-api.${AWS::Region}.amazonaws.com

Enter fullscreen mode Exit fullscreen mode

In the Lambda function we'll decode the slash command, this is an url encoded, base64 encoded, key:value pair string. We create the event that we need, post that on our event-bud and then return a 200 OK with a message to Slack.


import json
import base64
from urllib import parse as urlparse
import boto3
import os
import re

def handler(event, context):

    msg_map = dict(
        urlparse.parse_qsl(base64.b64decode(str(event["body"])).decode("ascii"))
    ) 
    commandString = msg_map.get("command", "err")
    text = msg_map.get("text", "err") 

    translateText = re.findall(r'"(.*?)"', text)[0]

    text = text.replace(translateText, "")
    text = text.replace('"', "")
    index = text.find("to")
    text = text.replace("to", "").strip()
    languages = text.split(",")

    languageArray = []
    for language in languages:
        language = language.strip()
        languageArray.append(
            {"Code": language},
        )

    commandEvent = {
        "Languages": languageArray,
        "Text": translateText,
        "RequestId": event["requestContext"]["requestId"],
    }

    client = boto3.client("events")
    response = client.put_events(
        Entries=[
            {
                "Source": "Translation",
                "DetailType": "TranslateText",
                "Detail": json.dumps(commandEvent),
                "EventBusName": os.environ["EVENT_BUS_NAME"],
            },
        ]
    )
    return {"statusCode": 200, "body": f"Translating........"}
Enter fullscreen mode Exit fullscreen mode

Create Slack command

Now that we have the hook API up and running we can create the actual slash command in Slack, navigate to Slack API.
Click on Create New App to start creating a new Slack App.

Image showing create slack app.

I name my app "Translation", you can name it however you like, also associate it with your workspace.

Image showing create slack app step 2.

When the app is created select it in the drop down menu and navigate to "Slash Commands"

Image showing how to select slash command in the menu.

Here we create a new Slash Command.

Image showing create slash command.

I create the "/translate" command, we need the url for the API that we created previously, the value is in the Output section of the Cloudformation template, copy the value for ApiEndpoint and paste it in Request URL box. A short description of the command is not mandatory, but I still enter a very basic description. After creation the Slash Command should be visible in the menu.

Image showing that the new slash command is visible

Next we need to give our application some permissions. That is done from the OAuth and Permissions menu. Our app need "chat:write", "commands", and "files:write" add these under the Scope section.

Image showing the OAuth scopes

We are almost there now. To get the OAuth token we need, we first need to install the application to our workspace. Navigate to the top and click "Install to workspace".

Image showing the OAuth install to workspace

After a successful installation we should now have the OAuth token that we need.

Image showing the OAuth token

We need to copy this token and store it in the SecretsManager Secret that was created with the common infrastructure previous, so head over to the AWS Console and SecretsManager. Select the "/slackbot" secret and create a key/value pair with the key "OauthToken" and the value set to the token.

Image showing storing the OAuth token in secrets manager

Final step now is to navigate to your workspace, find the Translation app under Apps in the left pane, click it and select "Add this app to a channel" and select the channel of your choice.

Image showing adding the app to a channel

Translation

That was one long section on how to create and setup your Slack app. But with that out of the way we can now create the Translation service. This service looks like this.

Image showing the translation service

It will start on an event from a custom EventBridge event-bus, this will start a StepFunction state-machine. Amazon Translate will use Amazon Comprehend to detect the source language and translate it to the destination. The translated text will be stored in the S3 bucket, that we created in the common infrastructure, and finally post a event back to the event-bus to move to the next step in our saga pattern. We can actually translate to several languages at once, for this we use the Map state in the state-machine to run the translation logic over an array. The StepFunction state-machine looks like this.

Image showing the translation state-machine

I only use the SDK or Optimized integrations, no need for any code or Lambda functions for performing this task, less code to manage.

SAM Tamplate:

AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Translate Text State Machine
Parameters:
  Application:
    Type: String
    Description: Name of owning application
  CommonInfraStackName:
    Type: String
    Description: Name of the Common Infra Stack

Resources:
  ##########################################################################
  ## TRANSLATE STATEMACHINE
  ##########################################################################
  TranslateStateMachineStandard:
    Type: AWS::Serverless::StateMachine
    Properties:
      DefinitionUri: statemachine/translate-broken.asl.yaml
      Tracing:
        Enabled: true
      DefinitionSubstitutions:
        S3Bucket:
          Fn::ImportValue: !Sub ${CommonInfraStackName}:TranslationBucket
        EventBridgeBusName:
          Fn::ImportValue: !Sub ${CommonInfraStackName}:EventBridgeBus
      Policies:
        - Statement:
            - Effect: Allow
              Action:
                - logs:*
              Resource: "*"
        - Statement:
            - Effect: Allow
              Action:
                - translate:TranslateText
                - comprehend:DetectDominantLanguage
              Resource: "*"
        - S3WritePolicy:
            BucketName:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:TranslationBucket
        - EventBridgePutEventsPolicy:
            EventBusName:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:EventBridgeBus
      Events:
        StateChange:
          Type: EventBridgeRule
          Properties:
            InputPath: $.detail
            EventBusName:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:EventBridgeBus
            Pattern:
              source:
                - Translation
              detail-type:
                - TranslateText
      Type: STANDARD
Enter fullscreen mode Exit fullscreen mode

StepFunction definition:

Comment: Translation State Machine
StartAt: Debug
States:
  Debug:
    Type: Pass
    Next: Map
  Map:
    Type: Map
    ItemProcessor:
      ProcessorConfig:
        Mode: INLINE
      StartAt: Translate Text
      States:
        Translate Text:
          Type: Task
          Parameters:
            SourceLanguageCode: auto
            TargetLanguageCode.$: $.TargetLanguage
            Text.$: $.Text
          Resource: arn:aws:states:::aws-sdk:translate:translateText
          ResultPath: $.Translation
          Next: Store Translated Text
        Store Translated Text:
          Type: Task
          Parameters:
            Body.$: $.Translation.TranslatedText
            Bucket: ${S3Bucket}
            Key.$: States.Format('{}/{}/text.txt',$.RequestId, $.TargetLanguage)
          Resource: arn:aws:states:::aws-sdk:s3:putObject
          ResultPath: null
          Next: Notify
        Notify:
          Type: Task
          Resource: arn:aws:states:::events:putEvents
          Parameters:
            Entries:
              - Source: Translation
                DetailType: TextTranslated
                Detail:
                  TextBucket: ${S3Bucket}
                  TextKey.$: States.Format('{}/{}/text.txt',$.RequestId, $.TargetLanguage)
                  Language.$: $.TargetLanguage
                  RequestId.$: $.RequestId
                EventBusName: ${EventBridgeBusName}
          End: true
    End: true
    ItemsPath: $.Languages
    ItemSelector:
      TargetLanguage.$: $$.Map.Item.Value.Code
      RequestId.$: $.RequestId
      Text.$: $.Text
Enter fullscreen mode Exit fullscreen mode

Text to speech

Next part of the saga is the text to speech service, here we like to use Amazon Polly to read the translated text to us.

Image showing the voice service

This service will be invoked by the translated text being stored in the S3 bucket by the Translation service. This will invoke a StepFunction state-machine that will load the text and start a Polly speech synthesis task. The state-machine will poll and wait for the task to finish, complete or fail. The generated speech mp3 file will be copied to the same place as the translated text. Finally an event is posted onto a custom event-bus that will invoke the last part of our saga.

Image showing the voice state-machine

Once again I only use the SDK or Optimized integrations, no need for any code or Lambda functions for performing this task, less code to manage.

SAM template

AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Generate Voice State Machine
Parameters:
  Application:
    Type: String
    Description: Name of owning application
  CommonInfraStackName:
    Type: String
    Description: Name of the Common Infra Stack

Resources:
  ##########################################################################
  ## VOICE STATEMACHINE
  ##########################################################################
  VoiceStateMachineStandard:
    Type: AWS::Serverless::StateMachine
    Properties:
      DefinitionUri: statemachine/voice-broken.asl.yaml
      Tracing:
        Enabled: true
      DefinitionSubstitutions:
        S3Bucket:
          Fn::ImportValue: !Sub ${CommonInfraStackName}:TranslationBucket
        EventBridgeBusName:
          Fn::ImportValue: !Sub ${CommonInfraStackName}:EventBridgeBus
      Policies:
        - Statement:
            - Effect: Allow
              Action:
                - logs:*
              Resource: "*"
        - Statement:
            - Effect: Allow
              Action:
                - polly:StartSpeechSynthesisTask
                - polly:GetSpeechSynthesisTask
              Resource: "*"
        - S3CrudPolicy:
            BucketName:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:TranslationBucket
        - EventBridgePutEventsPolicy:
            EventBusName:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:EventBridgeBus
      Events:
        StateChange:
          Type: EventBridgeRule
          Properties:
            EventBusName: default
            InputPath: $.detail
            Pattern:
              source:
                - aws.s3
              detail-type:
                - Object Created
              detail:
                bucket:
                  name:
                    - Fn::ImportValue: !Sub ${CommonInfraStackName}:TranslationBucket
                object:
                  key:
                    - suffix: ".txt"
      Type: STANDARD

Enter fullscreen mode Exit fullscreen mode

StepFunction definition

Comment: Convert text to voice.
StartAt: Set Source Information
States:
  Set Source Information:
    Type: Pass
    ResultPath: $
    Parameters:
      TargetBucket.$: $.bucket.name
      Targetkey.$: States.Format('{}/{}/voice',States.ArrayGetItem(States.StringSplit($.object.key,'/'),0),States.ArrayGetItem(States.StringSplit($.object.key,'/'),1))
      SourceBucket.$: $.bucket.name
      SourceKey.$: $.object.key
      Langaguge.$: States.Format('{}',States.ArrayGetItem(States.StringSplit($.object.key,'/'),1))
    Next: Load Text
  Load Text:
    Type: Task
    Next: Start Speech Synthesis
    Parameters:
      Bucket.$: $.SourceBucket
      Key.$: $.SourceKey
    Resource: arn:aws:states:::aws-sdk:s3:getObject
    ResultPath: $.Text
    ResultSelector:
      Body.$: $.Body
  Start Speech Synthesis:
    Type: Task
    Parameters:
      Engine: neural
      LanguageCode.$: $.Langaguge
      OutputFormat: mp3
      OutputS3BucketName.$: $.TargetBucket
      OutputS3KeyPrefix.$: $.Targetkey
      TextType: text
      Text.$: $.Text.Body
      VoiceId: Joanna
    Resource: arn:aws:states:::aws-sdk:polly:startSpeechSynthesisTask
    ResultPath: $.Voice
    Next: Get Speech Synthesis Status
  Get Speech Synthesis Status:
    Type: Task
    Parameters:
      TaskId.$: $.Voice.SynthesisTask.TaskId
    Resource: arn:aws:states:::aws-sdk:polly:getSpeechSynthesisTask
    ResultPath: $.Voice
    Next: Speech Synthesis Done?
  Speech Synthesis Done?:
    Type: Choice
    Choices:
      - Variable: $.Voice.SynthesisTask.TaskStatus
        StringMatches: completed
        Next: Update Voice Object
        Comment: Completed!
      - Variable: $.Voice.SynthesisTask.TaskStatus
        StringMatches: failed
        Next: Failed
        Comment: Failed!
    Default: Wait
  Update Voice Object:
    Type: Task
    Next: Notify
    ResultPath: null
    Parameters:
      Bucket.$: $.TargetBucket
      CopySource.$: $.Voice.SynthesisTask.OutputUri
      Key.$: States.Format('{}_{}.mp3',$.Targetkey,$.Voice.SynthesisTask.VoiceId)
    Resource: arn:aws:states:::aws-sdk:s3:copyObject
  Notify:
    Type: Task
    Resource: arn:aws:states:::events:putEvents
    Next: Completed
    Parameters:
      Entries:
        - Source: Translation
          DetailType: VoiceGenerated
          Detail:
            VoiceBucket.$: $.TargetBucket
            VoiceKey.$: States.Format('{}_{}.mp3',$.Targetkey,$.Voice.SynthesisTask.VoiceId)
            Language.$: $.Langaguge
            Voice.$: $.Voice.SynthesisTask.VoiceId
          EventBusName: ${EventBridgeBusName}
  Completed:
    Type: Pass
    End: true
  Failed:
    Type: Pass
    End: true
  Wait:
    Type: Wait
    Seconds: 10
    Next: Get Speech Synthesis Status
Enter fullscreen mode Exit fullscreen mode

Posting back to Slack

The final service involved in our saga is the notification service, that will post text and audio back to Slack. This service will be invoked by two different domain events, text translated, and audio generated. The state-machine need to handle both and uses a choice state to walk down different paths. In this state-machine we need to use a Lambda function to post to the Slack API. However, with the new HTTPS integration release at re:Invent 2023 we might be able to remove this as well.

Image showing the notification state-machine

SAM Template

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Event-driven Slack Bot Notification Service

Parameters:
  Application:
    Type: String
    Description: Name of the application
  CommonInfraStackName:
    Type: String
    Description: Name of the Common Infra Stack

Globals:
  Function:
    Runtime: python3.9
    Timeout: 30
    MemorySize: 1024

Resources:
  ##########################################################################
  #   LAMBDA FUNCTIONS                                                     #
  ##########################################################################
  PostToChannelFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/SlackPostToChannel
      Handler: postchannel.handler
      Policies:
        - AWSSecretsManagerGetSecretValuePolicy:
            SecretArn:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:SlackBotSecret
      Environment:
        Variables:
          SLACK_CHANNEL: <your-slack-channel>
          SLACK_BOT_TOKEN_ARN:
            Fn::ImportValue: !Sub ${CommonInfraStackName}:SlackBotSecret

  UploadAudioToChannelFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/UploadAudioToChannel
      Handler: uploadchannel.handler
      Policies:
        - AWSSecretsManagerGetSecretValuePolicy:
            SecretArn:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:SlackBotSecret
        - S3ReadPolicy:
            BucketName:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:TranslationBucket
      Environment:
        Variables:
          SLACK_CHANNEL: <your-slack-channel>
          SLACK_BOT_TOKEN_ARN:
            Fn::ImportValue: !Sub ${CommonInfraStackName}:SlackBotSecret

  ##########################################################################
  #   STEP FUNCTION                                                        #
  ##########################################################################
  NotificationLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "${Application}/notificationstatemachine"
      RetentionInDays: 5

  SlackNotificationStateMachineStandard:
    Type: AWS::Serverless::StateMachine
    Properties:
      DefinitionUri: statemachine/statemachine.asl.yaml
      DefinitionSubstitutions:
        EventBridgeName:
          Fn::ImportValue: !Sub ${CommonInfraStackName}:EventBridgeBus
        PostToChannelFunctionArn: !GetAtt PostToChannelFunction.Arn
        UploadAudioToChannelFunctionArn: !GetAtt UploadAudioToChannelFunction.Arn
      Events:
        SlackNotification:
          Type: EventBridgeRule
          Properties:
            EventBusName:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:EventBridgeBus
            Pattern:
              source:
                - Translation
              detail-type:
                - TextTranslated
                - VoiceGenerated
            RetryPolicy:
              MaximumEventAgeInSeconds: 300
              MaximumRetryAttempts: 2
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - "cloudwatch:*"
                - "logs:*"
              Resource: "*"
        - EventBridgePutEventsPolicy:
            EventBusName:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:EventBridgeBus
        - LambdaInvokePolicy:
            FunctionName: !Ref PostToChannelFunction
        - LambdaInvokePolicy:
            FunctionName: !Ref UploadAudioToChannelFunction
        - S3ReadPolicy:
            BucketName:
              Fn::ImportValue: !Sub ${CommonInfraStackName}:TranslationBucket
      Tracing:
        Enabled: true
      Logging:
        Destinations:
          - CloudWatchLogsLogGroup:
              LogGroupArn: !GetAtt NotificationLogGroup.Arn
        IncludeExecutionData: true
        Level: ALL
      Type: STANDARD
Enter fullscreen mode Exit fullscreen mode

StepFunction definition

Comment: Translate App Slack Notification service
StartAt: Debug
States:
  Debug:
    Type: Pass
    Next: Event Type ?
  Event Type ?:
    Type: Choice
    Choices:
      - Variable: $.detail-type
        StringEquals: TextTranslated
        Next: Text Translated
      - Variable: $.detail-type
        StringEquals: VoiceGenerated
        Next: Voice Generated
    Default: Unknown Event Type
  Text Translated:
    Type: Pass
    Next: GetObject
    ResultPath: $
    Parameters:
      TextBucket.$: $.detail.TextBucket
      TextKey.$: $.detail.TextKey
      Language.$: $.detail.Language
      RequestId.$: $.detail.RequestId
  GetObject:
    Type: Task
    Parameters:
      Bucket.$: $.TextBucket
      Key.$: $.TextKey
    Resource: arn:aws:states:::aws-sdk:s3:getObject
    ResultSelector:
      Body.$: $.Body
    ResultPath: $.Text
    Next: Post Text To Channel
  Post Text To Channel:
    Type: Task
    Resource: arn:aws:states:::lambda:invoke
    OutputPath: $.Payload
    Parameters:
      Payload.$: $
      FunctionName: ${PostToChannelFunctionArn}
    Retry:
      - ErrorEquals:
          - Lambda.ServiceException
          - Lambda.AWSLambdaException
          - Lambda.SdkClientException
          - Lambda.TooManyRequestsException
        IntervalSeconds: 1
        MaxAttempts: 3
        BackoffRate: 2
    Next: Done
  Done:
    Type: Succeed
  Voice Generated:
    Type: Pass
    ResultPath: $
    Parameters:
      VoiceBucket.$: $.detail.VoiceBucket
      VoiceKey.$: $.detail.VoiceKey
      Language.$: $.detail.Language
      Voice.$: $.detail.Voice
    Next: Upload Audio To Channel
  Upload Audio To Channel:
    Type: Task
    Resource: arn:aws:states:::lambda:invoke
    OutputPath: $.Payload
    Parameters:
      Payload.$: $
      FunctionName: ${UploadAudioToChannelFunctionArn}
    Retry:
      - ErrorEquals:
          - Lambda.ServiceException
          - Lambda.AWSLambdaException
          - Lambda.SdkClientException
          - Lambda.TooManyRequestsException
        IntervalSeconds: 1
        MaxAttempts: 3
        BackoffRate: 2
    Next: Done
  Unknown Event Type:
    Type: Fail
Enter fullscreen mode Exit fullscreen mode

Post translated text

import json
import os
import boto3
from symbol import parameters
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

SLACK_CHANNEL = os.environ["SLACK_CHANNEL"]

def handler(event, context):
    set_bot_token()

    text = f"{event['Language']}:\n{event['Text']['Body']}"

    client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
    client.chat_postMessage(channel="#" + SLACK_CHANNEL, text=text)

    return {"statusCode": 200, "body": "Hello there"}


def set_bot_token():
    os.environ["SLACK_BOT_TOKEN"] = get_secret()


def get_secret():
    session = boto3.session.Session()
    client = session.client(service_name="secretsmanager")

    try:
        secretValueResponse = client.get_secret_value(
            SecretId=os.environ["SLACK_BOT_TOKEN_ARN"]
        )
    except ClientError as e:
        raise e

    secret = json.loads(secretValueResponse["SecretString"])["OauthToken"]
    return secret
Enter fullscreen mode Exit fullscreen mode

Upload audio file

import json
import os
import boto3
from symbol import parameters
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

SLACK_CHANNEL = os.environ["SLACK_CHANNEL"]

def handler(event, context):
    set_bot_token()

    path = download_audio_file(
        event["VoiceBucket"], event["VoiceKey"], event["Voice"], event["Language"]
    )
    upload_audio_file(event["Language"], path)

    return {"statusCode": 200, "body": "Hello there"}


def download_audio_file(bucket, key, voice, language):
    s3 = boto3.client("s3")
    path = f"/tmp/{language}_{voice}.mp3"
    s3.download_file(bucket, key, path)
    return path

def upload_audio_file(language, path):
    client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
    client.files_upload(
        channels="#" + SLACK_CHANNEL,
        initial_comment=f"Polly Voiced Translation for: {language}",
        file=path,
    )

    return {"statusCode": 200, "body": "Hello there"}

def set_bot_token():
    os.environ["SLACK_BOT_TOKEN"] = get_secret()

def get_secret():
    session = boto3.session.Session()
    client = session.client(service_name="secretsmanager")

    try:
        secretValueResponse = client.get_secret_value(
            SecretId=os.environ["SLACK_BOT_TOKEN_ARN"]
        )
    except ClientError as e:
        raise e
    secret = json.loads(secretValueResponse["SecretString"])["OauthToken"]
    return secret

Enter fullscreen mode Exit fullscreen mode

Test it

To test the solution we send a slash command with the pattern /translate "text to translate" language_code_1,language_code_2,language_code_n

Image showing the slash command sent in slack

Image showing the notification state-machine

Final Words

In the era of Generative AI it was interesting to build a solution using the more traditional AI services that has been around for several years. The performance on these are really good and the translations and voice files are created very quickly. Building this in a serverless and event-driven way creates a cost effective solution as alway. There are improvements and extensions that can be done to the solution. Stay tuned as I make this changes and update this blog. Also this solution will be powering my new feature turning this blog into multi language.

Don't forget to follow me on LinkedIn and X for more content, and read rest of my Blogs

Top comments (0)