メンションしたのに反応が無い人に電話をかけて催促するシステムを作ったのでシステムの内容を紹介

今回[Twilioハッカソン](https://mashupawards.connpass.com/event/264896/)に参加して[やさしい呼び出しくん](https://protopedia.net/prototype/3640)というプロダクトを開発し、最優秀賞を受賞することができました。
発表会の様子はこちらより確認できます

<iframe width="560" height="315" src="https://www.youtube.com/embed/dD7R5MLTyeQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

またプロダクトのソースコードはこちらにて公開しております。
[TwilioHackathon2022](https://github.com/TakuKobayashi/TwilioHackathon2022)
よろしければ参考にしてください。

以降ではこのプロダクトの開発で使用した技術や得られた知見について紹介します。

# 要件

[やさしい呼び出しくん](https://protopedia.net/prototype/3640)とはどういうプロダクトなのかざっくり解説しますと
「チャットツールでメンションしたのに反応が無い人に反応してもらえるように催促するシステム」
というものになります。
主に例えば緊急の事案に対して呼び出しを行いたい時に使用されることを想定しています。

このシステムの実現のために必要だった要件について以下の通りです。

* Slackからのメンションを受け取る
* 返事、リアクションがなかった時にのみ遅延実行させる
* Twilioを使って電話をかける
* Twilioを使ってSMSを送る
* 電話機からプッシュホンを受け取る
* 電話を転送する
* 通話内容を録音する
* 通話内容を文字に起こす

要件の実現のために実際に行なった処理の流れの詳細を以下に図示します。

```plantuml
@startuml
actor Aさん
database Slack
database AWSLambda
queue AWSSQS
database AWSS3
database AWSTranscribe
database Twilio
actor Bさん

Aさん -> Slack:Bさんに\nメンション投稿
Slack -> AWSLambda:Event APIによる\nWebhook送信
AWSLambda -> AWSSQS:Bさんからの\nレスポンス待ち\n遅延実行タスクを\nキューに追加
group Bさんからのレスポンスの有無 [レスポンスがあった場合]
  Bさん -> Slack:Slackに投稿またはリアクション
  Slack -> AWSLambda:Event APIによる\nWebhook送信
  AWSLambda -> AWSSQS:キューからタスクを削除\nor\n実行時に何もしない
else レスポンスがなかった場合
  AWSSQS -> AWSLambda:キューに入ってた\nタスクを実行
  AWSLambda -> Twilio:電話をかける
  Twilio -> Bさん:電話をかける
  group 電話を受け取ったか否か [電話を受けとった場合]
    Twilio -> AWSLambda:電話を受けとったCallback
    AWSLambda -> Twilio:プッシュホンを要求
    Twilio -> Bさん:プッシュホンを要求
    group プッシュホンの内容での処理分岐 [Aさんに電話をかけることを選択された場合]
      Bさん -> Twilio:Aさんに電話をかける\nプッシュホンを選択
      Twilio -> AWSLambda:Aさんに電話をかけるプッシュホンCallback
      AWSLambda -> Twilio:電話転送レスポンス
      Twilio -> Aさん:電話を転送
    else 要件を返答する場合
      Bさん -> Twilio:要件を返答する\nプッシュホンを選択
      Twilio -> AWSLambda:要件を返答する\nプッシュホンCallback
      AWSLambda -> Twilio:録音開始のレスポンス
      Twilio -> Bさん:録音の開始
      Bさん -> Twilio:要件をしゃべる
      Twilio -> AWSLambda:録音完了Callback
      Twilio --> AWSLambda:文字起こし完了\nCallback\n(英語文字起こしのみ)
      AWSLambda -> Twilio:録音した音声ファイルのダウンロードリクエスト
      Twilio -> AWSLambda:録音した音声ファイルをダウンロード
      AWSLambda -> AWSS3:録音した音声ファイルを\nアップロード
      AWSLambda -> AWSTranscribe:文字起こし実行Job起動
      AWSS3 -> AWSTranscribe:音声ファイルの参照
      AWSTranscribe -> AWSS3:文字起こし結果\nJSONファイルの生成
      AWSS3 -> AWSLambda:文字起こし結果\nJSONファイルの生成イベント
      AWSLambda -> Slack:文字起こし\n結果を投稿
    end
  else 電話を受けとらなかった場合
   Twilio -> AWSLambda:電話受けとらなかったCallback
   AWSLambda -> Twilio:SMSを送信
   Twilio -> Bさん:SMSを送信
  end
end
@enduml
```

以降ではこの要件(機能)を具体的にどうやって実装していったのか紹介していきます。

# 使用技術

## 一覧

* [Node.js](https://nodejs.org/)
* [Typescript](https://www.typescriptlang.org/)
* [Serverless Framwork](https://www.serverless.com/)
* [Express](https://expressjs.com/ja/) ([serverless-express](https://github.com/vendia/serverless-express))
* [Slack API](https://api.slack.com/)
* AWS
  * [Lambda](https://aws.amazon.com/jp/lambda/): Webhook受け取りなどのサーバとして使用
  * [API Gateway](https://aws.amazon.com/jp/api-gateway/): Web APIとしての受け口
  * [SQS](https://aws.amazon.com/jp/sqs/): 遅延実行を行うために使用
  * [S3](https://aws.amazon.com/jp/s3/): 録音ファイルから文字に起こすために使用
  * [Transcribe](https://aws.amazon.com/jp/transcribe/): 文字起こしに使用
* [Twilio Node](https://github.com/twilio/twilio-node)
* [Kintone](https://kintone.cybozu.co.jp/): データベース

## 基本的な構成

webhookの受け取り先(サーバ)は[API Gateway](https://aws.amazon.com/jp/api-gateway/) + [Lambda](https://aws.amazon.com/jp/lambda/)で受けとれるようにしました。
[Serverless Framwork](https://www.serverless.com/), [Node.js](https://nodejs.org/), [Express](https://expressjs.com/ja/) ([serverless-express](https://github.com/vendia/serverless-express))でサーバサイドの開発を進めていきました。

## システムを動かしてみる

開発したプロジェクトの動かし方やデプロイの方法などについては詳しくは [README.md](https://github.com/TakuKobayashi/TwilioHackathon2022/tree/master/serverless) に記述しましたのでそちらを参考にしてください。

### 簡単に動かしてみる解説

[Serverless Framwork](https://www.serverless.com/) を使用していますので、[Serverless Framwork](https://www.serverless.com/)がインストールされている状況下で以下のコマンドを実行するとlocalにてサーバーが立ち上がります。

```
serverless offline start
```

サーバーが立ち上がった後に [http://localhost:3000/dev/test](http://localhost:3000/dev/test) にアクセスすると該当のエンドポイントのAPIの実行結果を確認できます。
また [Lambda](https://aws.amazon.com/jp/lambda/) へのデプロイは [AWS Credentials](https://www.serverless.com/framework/docs/providers/aws/guide/credentials/) を参考にAWSへの認証まわりの設定が完了した状態で

```
serverless deploy
```

コマンドを実行することでデプロイすることができます。

# 要件と実現した方法について

以降ではシステムを動かしてみることができている状態(サーバが立ち上がっている状態)でそれぞれの用件についてその実装方法やコードについての解説を行なっていきます。

## Slackからのメンションを受け取る

Slackからのメンションおよびリアクションを受け取るにはSlack APIの中にある[Slack Events API](https://api.slack.com/apis/connections/events-api) を使用することでSlackにてメンションおよびリアクションがあった時にWebhookイベントが送られてくるようになります。

### [Slack API](https://api.slack.com/)を使用できるようにする

Slack APIを使用できるようにするためにはまずは「Create New App」を選択してAPI Keyなどの取得を行います。

![Slack-Apps.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72596/0e8cdba1-5559-695b-1353-6dec2e2aef55.png)


### [Slack Events API](https://api.slack.com/apis/connections/events-api)を使用してWebhookを受け取る

SlackのAPIが利用できるようになったら、[Slack Events API](https://api.slack.com/apis/connections/events-api) が利用できる状態にしていきます。
`Event Subscriptions` を選択して `Request URL` にwebhookを受け取る場所のURLを入力します。

![Slack-event-api.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72596/0ec5faee-1b74-dbad-dadb-680c35aa741c.png)

すでに上記にてサーバが立ち上がっている状態であるため、その中にある受け取りたい箇所でのURLを入力します。
またこの時に[Slack Events API](https://api.slack.com/apis/connections/events-api)にてWebhookが受取できるのかどうか確認するために`Challenge認証` が行われます。`Challenge認証`では入力したURLに対して`POST`でHTTPリクエストが送られてきます。そのリクエストの中に以下のようなJSONの情報がBodyに含まれて送られてきます。

```plantuml
@startjson
{
    "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
    "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
    "type": "url_verification"
}
@endjson
```

この中の `challenge` の値を `{challenge: "challengeの値"}` となるJSONの形で返すことによって `Challenge認証` が完了します。(上記のようなJSONが送られてきた場合は以下のようなJSONを返します)

```plantuml
@startjson
{
    "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"
}
@endjson
```

`Challenge認証` を行なっている部分の処理は以下のようなソースコードで実現しています。

```typescript
import { NextFunction, Request, Response }, express from 'express';
import bodyParser from 'body-parser';

const slackWebhookRouter = express.Router();
// JSON bodyが送られてきた時に自動的にJSON parseを行うための設定
slackWebhookRouter.use(bodyParser.text({ type: "application/json" }));
slackWebhookRouter.use(bodyParser.urlencoded({ extended: false }));

slackWebhookRouter.post('/recieved_event', async (req: Request, res: Response, next: NextFunction) => {
  const webhookBody = req.body;
  // challengeが行われたときのresponse
  res.json({challenge: webhookBody.challenge});
});
```

作成したコードにおいての該当の部分は[こちら](https://github.com/TakuKobayashi/TwilioHackathon2022/blob/master/serverless/src/routes/webhooks/slack.ts#L18)になります。

#### 参考

* [Using the Slack Events API](https://api.slack.com/apis/connections/events-api)
* [url_verification](https://api.slack.com/events/url_verification)
* [SlackとAWSを繋ぐはじめの一歩~SlackのChallenge認証へチャレンジする~](https://dev.classmethod.jp/articles/challenge-about-challenge-request-for-slack/)

### Webhookで受け取るイベントの登録

[Slack Events API](https://api.slack.com/apis/connections/events-api)によるWebhookを受け取るURLの登録が完了したら、Webhookで送ってほしいイベントの登録を行います。
このイベントの登録には `Bot` または `user` に追加することができます。

* `Bot`に追加した場合は `Bot` を追加した特定のチャンネルでのイベントにのみWebhookが実行されます。
* `user` に追加した場合 `user` が参加しているチャンネル全てにおいてWebhookが実行されます。

今回はメンション(Slackへの投稿)とリアクション(投稿へのリアクション)が発生した時のイベントを取得できるようにしたいので、`message.channels` と `reaction_added` のEventを `Bot` に対して追加しました。

![add-slack-event-bot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72596/5bbe0164-398f-99cd-465d-306e04686dd7.png)

これでSlackでの投稿、およびリアクションがあった時にWebhookイベントを受け取れるようになりました。

## 返事、リアクションがなかった時にのみ遅延実行する

SlackからのWebhookイベントを受け取れるようになったので、メンションをつけて投稿の投稿、本プロダクトの起動コマンドが入力されたのかどうか判別することができるようになりました。
メンションをつけられた人からのリアクションなどの反応があるまで一定時間待ってみて、リアクションがなかった電話をかけられるようにします。
反応があるまで待つ処理を実現させるために [AWS SQS](https://aws.amazon.com/jp/sqs/) を使用することで処理の遅延実行が可能になります。

### AWS SQSに遅延実行を追加する

[AWS SQS](https://aws.amazon.com/jp/sqs/) にメッセージ(タスク)を追加する時に `DelaySeconds` 要素を指定することでことで指定した秒数の分だけ処理の遅延実行が可能になります。
まずは[AWS SDK for JavaScript v3](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/)の[AWS SQS](https://aws.amazon.com/jp/sqs/)を実行できるライブラリの導入を行います。

```
yarn add @aws-sdk/client-sqs
```

そして以下のような処理を実行することでキューに追加されて `900秒` 遅れて処理が実行されるようになります。(なお`DelaySeconds` に指定できる秒数は 0 ~ 900の間。最大で遅延できる秒数は `900秒`)

```typescript
import { SQSClient, SendMessageCommand, SendMessageCommandInput } from '@aws-sdk/client-sqs';

const sqsClient = new SQSClient({ region: process.env.AWS_REGION });  // 'ap-northeast-1' と指定したら東京リージョン
const params: SendMessageCommandInput = {
  QueueUrl: process.env.QUEUE_URL,
  DelaySeconds: 900, // 900秒遅れて遅延実行
  MessageBody: "messsage text",
};
sqsClient.send(new SendMessageCommand(params));
```

上記の `process.env.QUEUE_URL` の値の確認と遅延実行されるときに実行される処理について [serverless.ts](https://github.com/TakuKobayashi/TwilioHackathon2022/blob/master/serverless/serverless.ts) に記述して `serverless deploy` を行うことでAWS上で実行されるようになります。それぞれどのように記述すると適用されるのか以下に記述していきます。

```typescript
const serverlessConfiguration: AWS = {
  ...
  resources: {
    Resources: {
      // ここに指定することでserverless deploy時にSQSにキューを自動的に作成してくれるようになります
      Type: 'AWS::SQS::Queue',
      Properties: {
        QueueName: 'queueName',
      },
    },
  },
  provider: {
    ...
    // enviroment の項目に追加することによって process.env.QUEUE_URL にて設定された値を参照することができます
    environment: {
      QUEUE_URL: 'https://sqs.${aws:region}.amazonaws.com/${aws:accountId}/queueName';,
    },
  },
  functions: {
    ...
    queueevent: {
      // キューから取り出される時に遅延実行が行われる処理が記述されている場所(Lambdaにて実行される)
      handler: 'src/sqs.handler',
      events: [
        {
          sqs: {
            arn: 'arn:aws:sqs:${aws:region}:${aws:accountId}:queueName',
          },
        },
      ],
    },
  }
}
```

※ 上記で指定した変数の内容について

* `queueName` はそれぞれ設定したいキューの名前を指定してください
* `src/sqs.handler` は [src/sqs.ts](https://github.com/TakuKobayashi/TwilioHackathon2022/blob/master/serverless/src/sqs.ts) の中の記述の `export const handler = async (event, context) => {}` (handler function) 以下にキューから取り出された時に関数が呼び出されることを指定しています。
* `${aws:region}` と `${aws:accountId}` はそれぞれ変数が自動的に入力されます。 `serverless deploy` や `serverless offline start` を行う時にAWSから値を取得して自動的に入力されます。([AWS Credentials](https://www.serverless.com/framework/docs/providers/aws/guide/credentials/) にて認証した値を基に参照が行われる)

キューから取り出された時に呼び出される関数は以下のように様になります。

```typescript
import { SQSHandler, SQSEvent, Context } from 'aws-lambda';

export const handler: SQSHandler = async (event: SQSEvent, context: Context) => {
  for (const record of event.Records) {
    // record.bodyにMessageBodyにて指定した文字列
  }
};
```

`record.body` にてキューに追加したときに`MessageBody`にて指定した文字列を取得することができます。`MessageBody`にJSONを入れて追加した場合、ここで取得した時に `JSON.parse()` 行うことで変数を変数を取得することもできます。

詳しい処理はこちらにて記述しています。[src/sqs.ts](https://github.com/TakuKobayashi/TwilioHackathon2022/blob/master/serverless/src/sqs.ts)

#### 参考

* [キューへメッセージを送信する](https://docs.aws.amazon.com/ja_jp/sdk-for-javascript/v3/developer-guide/sqs-examples-send-receive-messages.html)
* [SQS Client - AWS SDK for JavaScript v3](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-sqs/index.html)
* [Using Lambda with Amazon SQS](https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html)
* [SQS Queues](https://www.serverless.com/framework/docs/providers/aws/events/sqs)
* [AWS LambdaのイベントソースにSQSを使う](https://dev.classmethod.jp/articles/sqs-lambda/)

## Twilioを使って電話をかける

### Twilioの設定

Twilioの利用を可能にするための各種設定をおこなっていきます。
まずはTwilioのNode.jsのライブラリである[Twilio Node](https://github.com/twilio/twilio-node)をインストールするようにします。

```
yarn add twilio
```

次にTwilioの機能を使用するためにTwilioのコンソールにアクセスして  `ACCOUNT_SID` と `AUTH_TOKEN` の値を確認する(後で使用します)

![Twilio-account-info.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72596/f1de6515-2bfb-96ba-87eb-9f24c2fece0a.png)

今回は電話をかけたりSMSを送ったりするのでTwilioで電話番号を購入します。
コンソールから `Buy a number` タブを選択し、`Buy` を選択して電話番号を購入します。

![Twilio-buy-number.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72596/3f76c8a1-41e1-0f31-c894-195a47a9f11f.png)

### Twilioから電話をかける

`ACCOUNT_SID` と `AUTH_TOKEN` と 購入した番号を使用して以下のような処理を記述することでTwilioから電話をかけることができます。

```typescript
import twilio from 'twilio';
const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
twilioClient.calls.create({
  twiml: twiml,
  from: process.env.TWILIO_US_PHONE_NUMBER,
  to: '+818012345678',
});
```

* `TWILIO_ACCOUNT_SID` には `ACCOUNT_SID`
* `process.env.TWILIO_AUTH_TOKEN` には `AUTH_TOKEN`
* `process.env.TWILIO_US_PHONE_NUMBER` には購入した電話番号

の値をそれぞれ指定します。
上記の場合 `+818012345678` に電話をかける処理となります(←の番号はサンプルの番号です)

上記のコードで指定している `twiml` とは `Twilio Markup Language` といってXMLの形式の文字列を挿入することでTwilioにおける電話の操作ができるようになります。

例えば

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
    <Say>Hello, world!</Say>
</Response>
```

このような `twiml` を指定して電話をかけると通話越しに `Hello, world!` としゃべってくれます。Typescriptで[Twilio Node](https://github.com/twilio/twilio-node)を使用しながら電話をかける処理と組み合わせて記述すると以下のようになります。

```typescript
import twilio from 'twilio';
const VoiceResponse = twilio.twiml.VoiceResponse;
const response = new VoiceResponse();
response.say('Hello, world!');
const twiml = response.toString();

const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
twilioClient.calls.create({
  twiml: twiml,
  from: process.env.TWILIO_US_PHONE_NUMBER,
  to: '+818012345678',
});
```

### 電話をかけたけど、受け取ったかのかどうかを取得する

上記電話かけた時に電話を受けたのかどうか判別するには 電話をかける時に `statusCallback` を指定することによって、電話かけた時に電話を受けたのかどうか判別できるWebhookが送られてくるようになります。以下のように記述することで電話を受けたかどうか判別できます。

```typescript
import twilio from 'twilio';
const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
twilioClient.calls.create({
  twiml: twiml,
  from: process.env.TWILIO_US_PHONE_NUMBER,
  to: '+818012345678',
  statusCallback: "https://aaa.co.jp/call_handler",
  statusCallbackMethod: 'POST',
});
```

以下のように記述することで`statusCallback` で指定したURLにTwilioからのwebhookを受けとります。

```typescript
import express from 'express';
const app = express();
// Bodyの内容を文字列として受け取る
app.use(bodyParser.text({ type: '*/*' }));
app.use(bodyParser.urlencoded({ extended: false }));

app.post('/call_handler', async (req, res) => {
  const payload = req.body;
  const callStatus = payload.CallStatus;
  switch(callStatus) {
    case 'completed':
      // 電話を受け取った場合、何もしない
      break;
    case 'busy':
      // 電話を受け取らなかった場合、SMSに通知を送る
      break;
    default:
      break;
  }
  res.send('ok');
});
```

Twilioのwebhookは`POST`で受け取る場合`application/x-www-form-urlencoded` がヘッダーについてBodyには `CallStatus=completed&AccountSid=AccountSid` のような形式で黄倉れてきます。
上記の記述ではその内容を適切な形でParseしてその後の処理に活かしています。

#### 参考

* [TwiML for Programmable Voice](https://www.twilio.com/docs/voice/twiml)
* [TwiML Voice: <Say>](https://www.twilio.com/docs/voice/twiml/say)
* [Status Callbacks](https://www.twilio.com/docs/video/api/status-callbacks)

## Twilioを使ってSMSを送る

Twilioを使って電話をかけることがすでにできる状態で以下のように記述することでSMSを送ることができます。

```typescript
import twilio from 'twilio';
const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
twilioClient.messages.create({
  body: "メッセージを送りたい内容",
  from: process.env.TWILIO_US_PHONE_NUMBER,
  to: '+818012345678',
});
```

### 参考

* [メッセージの送信](https://www.twilio.com/ja/docs/sms/send-messages)

## 電話機からプッシュホンを受け取る

プッシュホンを受け取る場合は電話をかける時の `twiml` に `Gather` の要素を加えた`twiml`を作成することでプッシュホンを受け付けることができるようになります。
プッシュホンの内容を受け取る場合、`Gather` の要素の `action` の部分に受け取り先のURLを指定することで、プッシュホンが押された内容がTwilioからのWebhookで送られてきます。以下のように `twiml` を作成することでプッシュホンの内容を受け取ることができます

```typescript
import twilio from 'twilio';

const VoiceResponse = twilio.twiml.VoiceResponse;
const response = new VoiceResponse();
// 番号をプッシュした時の受け取り先を指定
const gather = response.gather({
  // 番号を押した時の受け取り先
  action: "https://aaa.co.jp/gather_dtmf_handler",
  input: 'dtmf', // dtmf がいわゆる電話機の番号入植という意味 speech にしたら話している内容を文字に起こして入力される
  finishOnKey: '#', // 入力終了のKey defaultは'#' 文字を空を指定したら全ての記号が乳力終了になる
  method: 'POST',
  timeout: 30, // 入力をうけつけてくれる秒数
});
gather.say(
  {
    language: 'ja-JP',
    voice: 'woman',
  },
  '1を押したら電話をかけます。2を押したら要件の内容をメッセージに残してお伝えします。最後にシャープキーを押してください。';
);
const twiml = response.toString();
const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
twilioClient.calls.create({
  twiml: twiml,
  from: process.env.TWILIO_US_PHONE_NUMBER,
  to: '+818012345678',
  statusCallback: "https://aaa.co.jp/call_handler",
  statusCallbackMethod: 'POST',
});
```

※ 上記のコードで `gather.say()` とすることで以下のように `<Gather></Gather>`の要素の内部に`<Say></Say>`の要素が入った`twiml` が生成されます。

```xml
<Response>
    <Gather>
        <Say>1を押したら電話をかけます。2を押したら要件の内容をメッセージに残してお伝えします。最後にシャープキーを押してください。</Say>
    </Gather>
</Response>
```

このような形式の `twiml` を作成することで `Say` の内容をしゃべり終えてからプッシュホンの受付を待つようになります。それ以外の場所に `Say` の要素を入れるとしゃべっている最中にプッシュホンの受付が開始されてしまいます。

プッシュホンの受付は以下のように記述することで`action` で指定したURLにTwilioからのwebhookを受けとります。

```typescript
import express from 'express';
const app = express();
// Bodyの内容を文字列として受け取る
app.use(bodyParser.text({ type: '*/*' }));
app.use(bodyParser.urlencoded({ extended: false }));

app.post('/gather_dtmf_handler', async (req, res) => {
  const payload = req.body;
  // 返答する内容のTwimlを設定する
  const response = new VoiceResponse();
  if (payload.Digits) {
    // 1が押された時の処理
    if (payload.Digits === '1') {
      response.say(
        {
          language: 'ja-JP',
          voice: 'woman',
        },
        '1が押されました',
      );
    // 2が押された時の処理
    } else if (payload.Digits === '2') {
      response.say(
        {
          language: 'ja-JP',
          voice: 'woman',
        },
        '2が押されました',
      );
    }
  }
  res.type('text/xml');
  res.send(response.toString());
});
```

Webhookの受け取り処理ではプッシュホンを押した後に続けての音声などの返答を返す必要があるため`twiml` の形式で返答しています。
上記の処理の内容では `1` を押したら `1が押されました` という返答が返ってきます。

#### 参考

* [TwiML Voice: <Gather>](https://www.twilio.com/docs/voice/twiml/gather)

他

## 電話を転送する

Twilioを使って電話を転送するときは `twiml` をレスポンスに返す時に `twiml` の中に`Dial`の要素を含めた`twiml`を返すことで電話の転送が可能になります。
具体的には以下のように記述することで電話の転送が可能になります。

```typescript
import express from 'express';
const app = express();
// Bodyの内容を文字列として受け取る
app.use(bodyParser.text({ type: '*/*' }));
app.use(bodyParser.urlencoded({ extended: false }));

app.post('/gather_dtmf_handler', async (req, res) => {
  const payload = req.body;
  // 返答する内容のTwimlを設定する
  const response = new VoiceResponse();
  if (payload.Digits) {
    // 1が押された時の処理
    if (payload.Digits === '1') {
      // dialで電話を転送する
      response.say(
        {
          language: 'ja-JP',
          voice: 'woman',
        },
        '電話をかけます',
      );
      response.dial('+818087654321');
    // 2が押された時の処理
    } else if (payload.Digits === '2') {
      response.say(
        {
          language: 'ja-JP',
          voice: 'woman',
        },
        '2が押されました',
      );
    }
  }
  res.type('text/xml');
  res.send(response.toString());
});
```

上記の場合では Twilioから `+818012345678` の電話番号にかけている電話を `+818012345678` から `+818087654321` の電話番号に電話をかける(転送する)るようにしています。
また上記の`twiml`は以下のようになります。

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>電話をかけます</Say>
  <Dial>+818087654321</Dial>
</Response>
```

`<Say></Say>` の要素が `<Dial></Dial>` の要素よりも上にあることで、`<Say></Say>` の内容をしゃべってから 電話の転送が行われます。
`<Say></Say>` の要素が `<Dial></Dial>` の要素よりも下にある場合、電話の転送が行われた後に`<Say></Say>` の内容をしゃべります。

また電話の転送は上記の `+818012345678` から電話をかけているのではなく、Twilioから電話をかけているものとして扱われます。

#### 参考

* [TwiML™ Voice: <Dial>](https://www.twilio.com/docs/voice/twiml/dial)

## 通話内容を録音する

### Twilioで通話内容が録音されるようになる

Twilioを使って通話中の内容を録音するときは `twiml` をレスポンスに返す時に `twiml` の中に `Record` の要素を含めた `twiml` を返すことで通話の録音が可能になります。
具体的には以下のように記述することで電話の録音が可能になります。

```typescript
import express from 'express';
const app = express();
// Bodyの内容を文字列として受け取る
app.use(bodyParser.text({ type: '*/*' }));
app.use(bodyParser.urlencoded({ extended: false }));

app.post('/gather_dtmf_handler', async (req, res) => {
  const payload = req.body;
  // 返答する内容のTwimlを設定する
  const response = new VoiceResponse();
  if (payload.Digits) {
    // 1が押された時の処理
    if (payload.Digits === '1') {
      // dialで電話を転送する
      response.say(
        {
          language: 'ja-JP',
          voice: 'woman',
        },
        '電話をかけます',
      );
      response.dial('+818087654321');
    // 2が押された時の処理
    } else if (payload.Digits === '2') {
      const timeoutSecond = 30;
      // 録音するより前に言わせるにはrecordより前にsayの処理を書く
      response.say(
        {
          language: 'ja-JP',
          voice: 'woman',
        },
        'ピーとなったら' + timeoutSecond.toString() + '秒で要件をお話しください',
      );
      response.record({
        timeout: timeoutSecond,
        playBeep: true, // 録音を開始する前にピーという音を鳴らす
        recordingStatusCallbackMethod: 'POST',
        recordingStatusCallback: "https://aaa.co.jp/recording_status_handler",
      });
    }
  }
  res.type('text/xml');
  res.send(response.toString());
});
```

このとき `recordingStatusCallback:` の要素にURLを指定すると、録音が完了し、音声ファイルが出来上がった段階でTwilioから音声ファイルのダウンロードURLなどの情報が記載されたWebhookが送られてきます。
このWebhookは以下のように受け取ります。

```typescript
import express from 'express';
const app = express();
// Bodyの内容を文字列として受け取る
app.use(bodyParser.text({ type: '*/*' }));
app.use(bodyParser.urlencoded({ extended: false }));

app.post('/recording_status_handler', async (req, res) => {
  const payload = req.body;
});
```

このとき上記のWebhookで送られてきた上h上を受け取った `payload` には以下のような情報が送られてきます

```plantuml
@startjson
{
  "AccountSid": "AccountSid",
  "CallSid": "CallSid",
  "ErrorCode": "0",
  "RecordingChannels": "1",
  "RecordingDuration": "3",
  "RecordingSid": "RecordingSid",
  "RecordingSource": "RecordVerb",
  "RecordingStartTime": "Fri, 09 Dec 2022 11:37:35 +0000",
  "RecordingStatus": "completed",
  "RecordingUrl": "録音した音声ファイルの格納先のURL"
}
@endjson
```

### 音声ファイルをダウンロードする

上記のWebhookで受け取った情報の中にある `RecordingUrl` の要素が音声ファイルのダウンロード先のURLにあたります。
このダウンロード先のURLからダウンロードしようとした場合、BASIC認証がかかっており、ユーザ名とパスワードの入力を求められます。
ユーザ名は上記のTwilioの `ACCOUNT_SID`、パスワードは `AUTH_TOKEN` の値を入力することでBASIC認証を通り、音声ファイルのダウンロードを行うことができます。
また[axios](https://github.com/axios/axios)を使い、処理の中で音声ファイルのダウンロードを記述すると以下のようになります

```typescript
import express from 'express';
const app = express();
// Bodyの内容を文字列として受け取る
app.use(bodyParser.text({ type: '*/*' }));
app.use(bodyParser.urlencoded({ extended: false }));

app.post('/recording_status_handler', async (req, res) => {
  const payload = req.body;
  const downloadResponse = axios.get(`${payload.RecordingUrl}.wav`, {
    auth: {
      username: process.env.TWILIO_ACCOUNT_SID,
      password: process.env.TWILIO_AUTH_TOKEN,
    },
  });
});
```

上記のソースコードの中において `downloadResponse.data` と呼び出すことで音声ファイルのバイナリが取得できます。
この内容をファイルとして保存するなどすることで音ファイルとして保存することができます。

#### 参考

* [TwiML Voice: <Record>](https://www.twilio.com/docs/voice/twiml/record)
* [Twilioの録音データをAmiVoiceで音声認識させる方法](https://qiita.com/mobilebiz/items/812ddaaeeb4c3b20bd39)

## 通話内容を文字に起こす

### Twilioを使って文字起こしをする

Twilioを使って通話中の内容を文字に起こすとき録音時と同様に `twiml` をレスポンスに返す時に `twiml` の中に `Record` の要素を含めて、`transcribe` の要素を `true` にし、`transcribeCallback` の要素に文字起こしの結果を受け取るWebhookのURLを指定した `twiml` を返すことで通話の録音と文字起こしが可能になります。
※ ただし現在Twilioで文字起こしに対応している言語は英語のみになります。

具体的には以下のように記述することで電話の録音および文字起こしが可能になります。

```typescript
import express from 'express';
const app = express();
// Bodyの内容を文字列として受け取る
app.use(bodyParser.text({ type: '*/*' }));
app.use(bodyParser.urlencoded({ extended: false }));

app.post('/gather_dtmf_handler', async (req, res) => {
  const payload = req.body;
  // 返答する内容のTwimlを設定する
  const response = new VoiceResponse();
  if (payload.Digits) {
    // 1が押された時の処理
    if (payload.Digits === '1') {
      // dialで電話を転送する
      response.say(
        {
          language: 'ja-JP',
          voice: 'woman',
        },
        '電話をかけます',
      );
      response.dial('+818087654321');
    // 2が押された時の処理
    } else if (payload.Digits === '2') {
      const timeoutSecond = 30;
      // 録音するより前に言わせるにはrecordより前にsayの処理を書く
      response.say(
        {
          language: 'ja-JP',
          voice: 'woman',
        },
        'ピーとなったら' + timeoutSecond.toString() + '秒で要件をお話しください',
      );
      response.record({
        timeout: timeoutSecond,
        playBeep: true, // 録音を開始する前にピーという音を鳴らす
        recordingStatusCallbackMethod: 'POST',
        recordingStatusCallback: "https://aaa.co.jp/recording_status_handler",
        transcribe: true,
        transcribeCallback: "https://aaa.co.jp/transcribe_handler",
      });
    }
  }
  res.type('text/xml');
  res.send(response.toString());
});
```

`transcribeCallback:` の要素に指定したURLに文字起こしが完了した段階でTwilioから文字起こしの結果などの情報を含んだWebhookが送られてきます。
このWebhookは以下のように受け取ります。

```typescript
import express from 'express';
const app = express();
// Bodyの内容を文字列として受け取る
app.use(bodyParser.text({ type: '*/*' }));
app.use(bodyParser.urlencoded({ extended: false }));

app.post('/transcribe_handler', async (req, res) => {
  const payload = req.body;
});
```
のとき上記のWebhookで送られてきた上h上を受け取った `payload` には以下のような情報が送られてきます

```plantuml
@startjson
{
  "AccountSid": "AccountSid",
  "ApiVersion": "2010-04-01",
  "CallSid": "CallSid",
  "CallStatus": "completed",
  "Called": "電話を受けた方の電話番号",
  "Caller": "電話をかけた方の電話番号",
  "Direction": "outbound-api",
  "From": "電話をかけた方の電話番号",
  "RecordingSid": "RecordingSid",
  "RecordingUrl": "録音した音声ファイルの格納先のURL",
  "To": "電話を受けた方の電話番号",
  "TranscriptionSid": "TranscriptionSid",
  "TranscriptionStatus": "completed", 
  "TranscriptionText": "文字起こしした結果",
  "TranscriptionType": "fast",
  "TranscriptionUrl": "文字に起こししたデータの格納先URL",
  "url": "このwebhookの情報を送ったURL"
}
@endjson
```

Webhookで受け取った情報の中にある `TranscriptionText` の要素を使用してSlackに投稿した入りなどの処理行います。

### 通話内容の日本語への文字起こし

Twilioでは日本語の文字起こしには対応していないので、日本語の文字起こしを行うために [AWS Transcribe](https://aws.amazon.com/jp/transcribe/) を使って日本語文字起こしを行います。
[AWS Transcribe](https://aws.amazon.com/jp/transcribe/)を使って文字起こしを行う場合は対象の音声ファイルは[S3](https://aws.amazon.com/jp/s3/)に置いてあるファイルを指定する必要があります。
そのため上記の音声ファイルのダウンロードし、そのまま[S3](https://aws.amazon.com/jp/s3/)にアップロードするようにしました。
具体的には以下のように記述することで音声ファイルのダウンロードし、そのまま[S3](https://aws.amazon.com/jp/s3/)にアップロードしました。

```typescript
import express from 'express';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';

const app = express();
// Bodyの内容を文字列として受け取る
app.use(bodyParser.text({ type: '*/*' }));
app.use(bodyParser.urlencoded({ extended: false }));

app.post('/recording_status_handler', async (req, res) => {
  const payload = req.body;
  const downloadResponse = axios.get(`${payload.RecordingUrl}.wav`, {
    responseType: 'stream',
    auth: {
      username: process.env.TWILIO_ACCOUNT_SID,
      password: process.env.TWILIO_AUTH_TOKEN,
    },
  });
  const s3Client = new S3Client({ region: process.env.AWS_REGION });
  const upload = new Upload({
    client: s3Client,
    params: {
      Bucket: process.env.S3_BUCKERT_NAME,
      Key: `RecordingFiles/${payload.RecordingSid}.wav`,
      Body: downloadResponse.data,
    },
  });
  await upload.done();
});
```

今回、[axios](https://github.com/axios/axios)でダウンロードする時 `responseType: 'stream'` を指定してダウンロードを行い、[S3](https://aws.amazon.com/jp/s3/)へのアップロードにおいて `@aws-sdk/client-s3` だけでなく、`@aws-sdk/lib-storage` のライブラリをもちいて[S3](https://aws.amazon.com/jp/s3/)にアップロードしています。
これはダウンロードしながら同時にアップロードを行うようにしているためです。
これによりダウンロード、アップロードの処理時間の短縮および少ないメモリでの実現が可能になりました。

[S3](https://aws.amazon.com/jp/s3/)へのアップロードが完了したら[AWS Transcribe](https://aws.amazon.com/jp/transcribe/)を起動させて、音声ファイルの文字起こしを行ないます。
具体的には以下のように記述することで[AWS Transcribe](https://aws.amazon.com/jp/transcribe/)を用いての日本語文字起こしを行なっています。

```typescript
import express from 'express';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import {
  TranscribeClient,
  StartTranscriptionJobCommand,
  StartTranscriptionJobCommandInput,
} from '@aws-sdk/client-transcribe';

const app = express();
// Bodyの内容を文字列として受け取る
app.use(bodyParser.text({ type: '*/*' }));
app.use(bodyParser.urlencoded({ extended: false }));

app.post('/recording_status_handler', async (req, res) => {
  const payload = req.body;
  const downloadResponse = axios.get(`${payload.RecordingUrl}.wav`, {
    responseType: 'stream',
    auth: {
      username: process.env.TWILIO_ACCOUNT_SID,
      password: process.env.TWILIO_AUTH_TOKEN,
    },
  });
  const s3Client = new S3Client({ region: process.env.AWS_REGION });
  const upload = new Upload({
    client: s3Client,
    params: {
      Bucket: process.env.S3_BUCKERT_NAME,
      Key: `RecordingFiles/${payload.RecordingSid}.wav`,
      Body: downloadResponse.data,
    },
  });
  await upload.done();
  const transcribeClient = new TranscribeClient({ region: process.env.AWS_REGION });
  const params: StartTranscriptionJobCommandInput = {
    TranscriptionJobName: payload.RecordingSid,
    LanguageCode: 'ja-JP',
    MediaFormat: 'wav',
    Media: { MediaFileUri: `s3://${process.env.S3_BUCKERT_NAME}/RecordingFiles/${payload.RecordingSid}.wav` },
    OutputBucketName: process.env.S3_BUCKERT_NAME,
    OutputKey: `TranscribeResult/${payload.RecordingSid}.json`,
  };
  const command = new StartTranscriptionJobCommand(params);
  await transcribeClient.send(command);
});
```

これにより [AWS Transcribe](https://aws.amazon.com/jp/transcribe/)を用いての日本語文字起こしが行われるJobが実行されます。
文字起こしされた結果は `OutputKey:` の要素で指定した[S3](https://aws.amazon.com/jp/s3/)の場所に文字起こしされた結果のJSONファイルが生成されます。

※ [AWS Transcribe](https://aws.amazon.com/jp/transcribe/) ではJobが完了してもJobは消えることなく残り続けます。また、Job名が重複してしまうとエラーになってしまいます。そのためJob名が重複しないようにJobを実行する時にすでにJobが存在する場合は事前にJobを消した上で実行するようにする必要があります。
(実際にJob名が重複しないようにおこなっている処理は[こちら](https://github.com/TakuKobayashi/TwilioHackathon2022/blob/master/serverless/src/commons/twilio.ts#L169)を参考にしてください)

[S3](https://aws.amazon.com/jp/s3/)に新しいJSONファイルが作成されたということなので、そのタイミングでもってLambdaでイベントを実行することで、文字起こしの結果の内容を参照してSlackに投稿した入りなどの処理行います。

#### 参考

* [音声を文字起こしする](https://aws.amazon.com/jp/getting-started/hands-on/create-audio-transcript-transcribe/)
* [Transcribe Client - AWS SDK for JavaScript v3](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-transcribe/globals.html)
* [How to upload a stream to S3 with AWS SDK v3
](https://stackoverflow.com/questions/69884898/how-to-upload-a-stream-to-s3-with-aws-sdk-v3)
* [[Node.js] [AWS SDK v3] StreamでCSVファイルをS3からS3にコピーする](https://dev.classmethod.jp/articles/nodejs-aws-sdk-v3-csv-streaming-in-s3/)

# さいごに

実際のソースコードは疎結合ができるように細かいパーツ単位で分かれています。
またハッカソン内で作成したものであるので、ソースコードのりファクタなどを行う余地が十分にあるように感じています。リファクタによる改善にも取り組んでいきたいと思います。

また

* [AWS SQS](https://aws.amazon.com/jp/sqs/)
* [AWS S3](https://aws.amazon.com/jp/s3/)
* [AWS Transcribe](https://aws.amazon.com/jp/transcribe/)

においてはローカルでの開発環境を整えることができませんでした。
このためこれらのAWSサービスを使用する場合は環境構築の方法やノウハウの調査を進めて、ローカルでも開発ができるようにしていきたいと思います。
またはIAMの権限を制限して、その上で他のメンバーでもAWSのデプロイができるように情報の共有などを行えるようにしたいとおもいます。