【Vonage Voice API】できるツイ廃は固定電話でTwitter📞📞📞

# はじめに
Node.jsの記事です。npm/VSCodeを使用しているという前提でコトを進めます。

# 前置き
[今回のアドベントカレンダー](https://qiita.com/advent-calendar/2022/vonage)は、[VonageのコミュニケーションAPI](https://www.vonagebusiness.jp/communications-apis/)を使った**参考になる記事**を募集しているんだそうです。

**参考になる記事**ですよ。

やっぱりこういう場合、なるべく情報の少ない存在について記事を書いた方が良いでしょう。そういうわけで調べてみたら、どうも[**Voice API**](https://www.vonagebusiness.jp/communications-apis/voice/)についてはあんまり情報が無いらしいことが分かりました。
Voice APIというのは、要するに「インタラクティブな音声通話」を実装するためのAPIだそうです。設定した電話番号にコールすると……あの、すみません。なんか**前置きが長い**気がしてきたので、要点だけ書かせていただきます。

# 要点
1. VonageのコミュニケーションAPIを使えば、[Voice API](https://www.vonagebusiness.jp/communications-apis/voice/)で**電話回線越しにWebサーバーと通信する**ことが可能
    - コールセンターの自動応答Botをイメージしていただきたい
2. TwitterというコミュニケーションSNSを使えば、[ツイ廃](https://dic.nicovideo.jp/a/%E3%83%84%E3%82%A4%E5%BB%83)として**ネット回線越しにWebサーバーと通信し続ける**ことが可能
    - コールセンターの自動応答Botをイメージしていただきたい[^1]
3. これらを混ぜ合わせることで、「電話回線からWebサーバーと通信し続ける存在」と化し、**固定電話でTwitter**することが可能に！



……これは、**最高に参考になる記事**が書けそうですよ！！！

# 本文
## 検討編
[Voice APIのチュートリアル](https://developer.vonage.com/voice/voice-api/tutorials/)を読みつつ、どんな感じに実装するか考えていきましょう。
まず、このAPIを1人のユーザーとの対話で使用する場合、
- ユーザー
- 電話番号(Vonage)
- サーバー

の3つについて考える必要があります。
ユーザーとVonageは電話回線で、Vonageとサーバーはインターネット回線でそれぞれ繋がっています。ユーザーとサーバーという**2つのエンドポイント**を、Vonageが中継しているという見方もでいるでしょう。
Vonageとサーバーの通信は、基本的に**Webhookベース**で行われます。
例を挙げてみましょう。「タイマー」の役割を果たすサービスについて考えると、下の図のような流れになります。

```plantuml
!pragma teoz true
actor ユーザー as u
control Vonage as v
database サーバー as s
u <--> v: 電話回線
& v <--> s: インターネット回線

===通話==
u -> v: 発信
v -> s: Webhookで発信を通知
note over s: 処理
s -> v: Webhookで命令を送信\n「秒数を入力してください」\n「入力待機」
v -> u: 「秒数を入力してください」
group 入力
    note over v: 待機
    u -> v: 「180」(プッシュボタン)
end
v -> s: Webhookで入力を通知\n「180」
s -> v: Webhookで命令を送信\n「180秒のタイマーを開始します」
v -> u: 「180秒のタイマーを開始します」
& note over s: 待機
...180秒後...
s -> v: Webhookで命令を送信\n「タイマーが終了しました」
v -> u: 「タイマーが終了しました」
```

僕が言いたいのは、PlantUMLは極めて便利だということです。

## 環境構築編
Vonageはいろいろなプラットフォームで開発可能らしいですが、今回はチュートリアルで使われていた**Node.js+TypeScript+Express.js**でいきます。
その他使用したパッケージの中で、特筆すべきものを以下に挙げます。
<dl>
<dt><a href="https://www.npmjs.com/package/@vonage/cli">@vonage/cli</a> v1.1.4</dt>
<dd>Vonageアプリケーション管理ツール。チュートリアルで使われていたので</dd>
<dt><a href="https://www.npmjs.com/package/ngrok">ngrok</a> v3.1.0</dt>
<dd>ローカルポートを外部公開するサービス。チュートリアルで使われていたので</dd>
<dt><a href="https://www.npmjs.com/package/body-parser">body-parser</a> v1.20.1</dt>
<dd>メッセージボディ解析ライブラリ。チュートリアルで使われていたので</dd>
<dt><a href="https://www.npmjs.com/package/dotenv">dotenv</a> v16.0.3</dt>
<dd>秘密の情報を<code>.env</code>ファイルから読むライブラリ</dd>
<dt><a href="https://www.npmjs.com/package/twitter-api-v2">twitter-api-v2</a> v1.12.9</dt>
<dd>Twitter APIクライアントライブラリ</dd>
<dt><a href="https://www.npmjs.com/package/json-schema-to-typescript">json-schema-to-typescript
</a> v11.0.2</dt>
<dd>後述</dd>
</dl>

## 準備編
### 0. 割愛
今回のメインは技術的な話なので、以下に挙げるごくごく基本的なところは割愛します。

- アカウントを作る
- ダッシュボードに行く
- クレジットを用意する
    - 筆者の場合、アドベントカレンダーのトップに書いてあったコードを使用した

### 1. 電話番号を借りる[^2]
ダッシュボードのサイドバーから`構築と管理>番号>番号を購入`と進むと、アプリケーションで使用する電話番号の購入画面が出ます。
![image-20221103170700744.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2703746/5eefd15d-c338-354a-64cc-9880656a4ba7.png)
世界各国の番号がずらっと並んでいますね。
日本の番号も購入できそうだったんですが、**何かしらの法律**に基づき審査が必要とのことだったので、今回は無難にアメリカの`Mobile - SMS & MMS & Voice`を借りました。[^3]

:::note info
**電話番号のタイプ**は、`Mobile`(携帯)、`Landline`(固定)、`Toll-free`(フリーダイヤル)の3種類です。フリーダイヤルはちょっと面白そうだと思ったんですが、[なんとなく面倒そう](https://api.support.vonage.com/hc/en-us/articles/360055483251-Verified-Toll-Free-US-Canada)な気配がしたのでスルーしました。
:::

電話番号はメモっておきます。

### 2. Vonage CLIでログインする
ここからはしばらく、[チュートリアル](https://developer.vonage.com/voice/voice-api/tutorials/connect-to-a-websocket/introduction/node)をなぞる形になります。
Vonage CLIは前述のとおり、Vonageのプロジェクトを管理するためのツールです。`構築と管理>API設定`に存在するAPIキー等を入力し、ログインしておきましょう。
```console
$ vonage config:set --apiKey={API_KEY} --apiSecret={API_SECRET}
```

### 3. プロジェクトの作成
ローカルで作業用ディレクトリを作成し、CLIから`npm init`なり`npx tsc --init`なりします。ここは本旨ではないので触れません。
この状態で
```console
$ ngrok http {任意のポート番号}
```
を実行し、指定したポート番号を外部公開します。
これによりngrokから一時的なホスト名（`*.ngrok.io`など）が渡されます。仮にそれを`example.com`とするなら、
```console
$ vonage apps:create "Call Transcription" --voice_answer_url=https://example.com/webhooks/answer --voice_event_url=https://example.com/webhooks/events
```
を実行することで、新規のアプリケーションを作成できます。
さらに、この時吐き出されるアプリケーションIDと先ほど購入した電話番号を合わせて、
```console
$ vonage apps:link {APPLICATION_ID} --number={YOUR_VONAGE_NUMBER}
```
とすることで、アプリケーションに電話番号をリンクできます。

## NCCOについて編
### 0. NCCOとは
先ほどの図では、Vonageとサーバーの間で行われる通信は「**Webhookベース**」だと説明しました。Webhookと言っても普通は何かしらの規定に沿って書かれているべきでしょう。
Vonageの場合、その大部分が**NCCO**(*Nexmo Call Control Objects*)[^4]というJSONオブジェクト記法にのっとって表記されます。
NCCOは、基本的に**アクションオブジェクトの配列**という形をとります。例として、[公式リファレンス](https://developer.vonage.com/voice/voice-api/ncco-reference)から、適当なNCCOを引用してみましょう。
```json
[
  {
    "action": "talk",
    "text": "Welcome to a Voice API I V R. ",
    "language": "en-GB",
    "bargeIn": false
  },
  {
    "action": "talk",
    "text": "Press 1 for maybe and 2 for not sure followed by the hash key",
    "language": "en-GB",
    "bargeIn": true
  },
  {
    "action": "input",
    "submitOnHash": true,
    "eventUrl": ["https://example.com/ivr"]
  }
]
```

この場合、`talk`アクション→`talk`アクション→`input`アクション、という順序で実行していることになります。
これをVonageに渡した場合に何が起こるかというと、先ほどの図に則るならこうです。

```plantuml
!pragma teoz true
actor ユーザー as u
control Vonage as v
database サーバー as s
u <--> v: 電話回線
& v <--> s: インターネット回線

===通話==
s -> v: Webhook(NCCO)で命令を送信
v -> u: 「Welcome to a Voice API I V R. 」
v -> u: 「Press 1 for maybe and 2 for not\n sure followed by the hash key」
group 入力
    note over v: 待機
    u -> v: 入力(プッシュボタン)
end
v -> s: Webhookで入力を通知\n→https://example.com/ivr
```
なんとなく方向性はわかるでしょうか。
アクションには色々と種類がありますが、今回のツイ廃・プロジェクトでは`input`と`talk`しか使いませんでした。なので、これらについて軽く解説します。

:::note warn
NCCOのリファレンスには日本語版もありますが、若干**情報が古く**、チュートリアルそのままで書いてもうまく動かない場合がありました。なので、本記事では**英語版準拠**で進めていきます。
:::

### 1. アクション解説
#### `talk`
例：
```json
{
    "action": "talk",
    "text": "私はムスカ大佐だ。",
    "level": 1,
    "language": "ja-JP"
    "bargeIn": false
}
```

`text`に設定されている文字列を読み上げるアクションです。この`text`には単なる文字列の他**SSML[^5]も使える**というのがおもしろポイントだと感じたんですが、今回はややこしくなるので見送りました。

`level`による音量調節や`language`による言語設定は、ほとんどすべての読み上げで必要になると思います。このプロジェクトでは、双方を`1`(最大)と`ja-JP`(日本語)で固定しています。
一際特殊なのが **`bargeIn`プロパティ**です。これが`true`に設定されていた場合、次に来る`input`アクションの入力が開始された時点で読み上げが**スキップ**されます。

例えばの話、
```json
[
    {
        "action": "talk",
        "text": "助けてくれ！頼む！助けてくれ！",
        "bargeIn": false
    },
    {
        "action": "input",
        "type": ["speech"]
    }
]
```
というようなNCCOを設定したら、その後の会話はだいたいこういう感じに進むでしょう。

>Bot: 助けてくれ！頼む！助けてくれ！
User: 大丈夫か！？**(音声入力扱いになる)**

助け合いの社会という感じで素晴らしいですね。
しかし、
```json
[
    {
        "action": "talk",
        "text": "助けてくれ！頼む！助けてくれ！",
        "bargeIn": true
    },
    {
        "action": "input",
        "type": ["speech"]
    }
]
```
`bargeIn`を使ってこう改変すると、**相手が喋り始めた時点で言葉が中断される**ようになります。つまり、

>Bot: 助けてく……
User: え、何？
Bot: ……**(中断中)**
User: おーい

というわけです。
なかなか**無情な機能**ですね。

#### `input`
例：
```json
[
    {
        "action": "talk",
        "text": "時間だ。答えを聞こう",
        "level": 1,
        "language": "ja-JP",
        "bargeIn": false
    },
    {
        "action": "input",
        "type": ["speech"],
        "speech":{
            "endOnSilence": 4.0,
            "language": "ja-JP"
        },
        "eventUrl": ["https://~/webhooks/speech"]
    }
]

```
前項で若干ネタバレをしましたが、**ユーザーの入力を受け付ける**アクションです。入力には
- `dtmf`: DTMF(*Dual-Tone Multi-Frequency*)、要するに電話の数字ボタンを使った**プッシュ信号**
- `speech`: 発話による**自然言語入力**

の2種類があり、`type`プロパティから指定できます。なお、`dtmf`と`speech`の**両方を指定する**ことも可能です。
`type`で指定した入力についてさらに詳細に設定する場合は、`dtmf`/`speech`プロパティを使います。設定はいろいろありますが、今回主に使ったもので言うと
- `dtmf`
    - `maxDigits`: 入力できる桁数の上限
    - `timeOut`: 入力終了までの時間
    - `submitOnHash`: `#`を押した場合に入力完了とみなすか否か
- `speech`
    - `endonSilence`: この秒数沈黙したら入力完了とみなす
    - `language`: 入力される音声の言語

あたりですね。
`eventUrl`は配列を受け取りますが、まあ基本的には1つのWebhookURLを渡しておけば問題ありません。
Vonageは、「入力完了」をトリガーにしてこのURLにPOSTリクエストを投げます。前述のリファレンスから`dtmf`の例をそのままコピーしてきました。
```json
{
  "speech": { "results": [ ] },
  "dtmf": {
    "digits": "1234",
    "timed_out": true
  },
  "from": "15551234567",
  "to": "15557654321",
  "uuid": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
  "conversation_uuid": "bbbbbbbb-cccc-dddd-eeee-0123456789ab",
  "timestamp": "2020-01-01T14:00:00.000Z"
}
```
色々ありますが、今回は`dtmf.digits`の値以外は使いませんでした。
`speech`の例も載せておきましょう。こちらは前述の『例』に多少の改変を加えて試したものです。
```json
{
  "speech": {
    "timeout_reason": "end_on_silence_timeout",
    "results": [
      {
        "confidence": "0.9158283",
        "text": "false"
      },
      {
        "confidence": "0.9158283",
        "text": "ばるす"
      },
      {
        "confidence": "0.89916855",
        "text": "パルス"
      },
      {
        "confidence": "0.85711956",
        "text": "バルス"
      }
    ]
  },
  "dtmf": {
    "digits": null,
    "timed_out": false
  },
  "from": "15551234567",
  "to": "15557654321",
  "uuid": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
  "conversation_uuid": "bbbbbbbb-cccc-dddd-eeee-0123456789ab",
  "timestamp": "2020-01-01T14:00:00.000Z"
}
```
ご覧の通り、`speech.results`に色々なものが並んでいます。これは要するに入力された文字列の**候補**で、`confidence`が高いほどそれらしい、ということらしいです。

:::note
**改善ポイント**：
今回は愚直に**一番`confidence`が高い**候補をそのまま採用するような方式をとったので、ユーザーの活舌によっては、このように **「バルス」が「false」として入力される**ことになります。まあ、実際のところ偽ではあります。
:::

### 2. スキーマについて
NCCOは割と厳密に仕様が決まってるっぽいしVSCodeで入力補助受けられないかな～と思って色々調べたら、公式のGitHubリポジトリにありました。

https://github.com/Nexmo/api-specification/blob/main/json-schemas/ncco-schema.json

NCCOの構造を定義したJSON Schema[^6]です。
今回は前述の`json-schema-to-typescript`を使って、このJSON SchemaをTypeScriptの型に変換しました。

```console
$ curl https://raw.githubusercontent.com/Nexmo/api-specification/main/json-schemas/ncco-schema.json | npx json2ts > ./src/types/schema.ts
```

:::note warn
注意：このJSON Schemaはどうも少しばかり**古い仕様**に基づいて定義されているようで、例えば`input`アクションは音声入力実装前のものです。今回はそこまで厳密に型を使いたいわけではないので、エラーが出ない程度の適当さで書き替えました。
:::


## 開発編
### 0.実装した機能について
とりあえず今回は、

- 音声入力を行い、**ツイートを投稿**する機能
- プッシュ通信を使い、**タイムラインを閲覧**する機能

の2つを実装しました。
公式チュートリアルのコードを拡張する方向で進めたため、関数を延々と羅列する感じになりましたが……まあ、**もうちょっと何とかならなかったのか**みたいなところが多いです。もう少し大規模だったら死んでたと思います。
ディレクトリ構造はこうです。
```
root
├ .env
├ package-lock.json
├ package.json
├ tsconfig.json
├ {プロジェクト名}.key
├ vonage_app.json
├ dist  
│  └...
├ node_modules
│  └...
└ src
  ├ actionSender.ts
  ├ index.ts
  ├ interfaces.ts
  ├ twitterWorker.ts
  └ types
    ├ schema.ts
    └ tweet.ts
```


### 1. メインメニューを実装する
#### 1-1. 基礎
色々と書くことはありますが、ひとまずチュートリアル通りに**メインメニュー**を実装します。
`src/index.ts`を書いていきましょう。まずExpressのお約束を諸々踏襲し、
```index.ts
import Express from 'express';
import bodyParser from 'body-parser';

import * as dotenv from 'dotenv';

dotenv.config();

const port_number = 3000 //ngrokで指定したポート番号

const app = Express();
app.use(bodyParser.json());

//TODO

app.listen(port_number);
```

とします。

#### 1-2. answer URL

アプリケーションとリンクされた電話番号に着信があると、Vonageは既定の`answer URL`に**GETリクエスト**を投げます。『準備編』と同じ設定をしている場合、`answer URL`は`https://example.com/webhooks/answer`となっているはずです。
例えばこの時、
```diff_typescript:index.ts
/*...*/

app.use(bodyParser.json());

- //TODO
+ app.get("/webhooks/answer", (req, res) => {
+     res.json([{
+         action: "talk",
+         text: "こんにちは",
+         level: 1,
+         language: "ja-JP"
+     }]);
+ });

app.listen(port_number);
```

とすれば、「コールを受けると『こんにちは』と発言し、電話を切る」という行動をとるBotになります。
レスポンスとして投げるNCCOについては、再利用の可能性があることや、**分離した方がなんかすっきりする**ことなどを理由に、`src/intefaces.ts`に置いた関数で生成する手法を取ります。すなわち、
```diff_typescript:interfaces.ts
+ import { NccoTalk } from './types/schema';
+ 
+ export function sayHello(req: Request): [NccoTalk] {
+     return [
+         {
+             action: "talk",
+             text: "こんにちは",
+             level: 1,
+             language: "ja-JP"
+         }
+     ]
+ }
```

というような関数を作ったうえで、

```diff_typescript:index.ts
/*...*/

import * as dotenv from 'dotenv';

+ import {sayHello} from './interfaces';

dotenv.config();

/*...*/

app.get("/webhooks/answer", (req, res) => {
-     res.json([{
-         action: "talk",
-         text: "こんにちは",
-         level: 1,
-         language: "ja-JP"
-     }]);
+     res.json(sayHello(req));
});

app.listen(port_number);
```

とするわけです。

### 1-3. event URL
Vonageで定義された何かしらのイベントが発生するたびにPOSTされるURLです。
このプロジェクトでは特に使いませんが、一応書いておきます。

```diff_typescript:index.ts
/*...*/

+ app.post('/webhooks/events', (req, res) => {
+     res.sendStatus(200);
+ })
```

### 1-4. `openMenu()`関数
とりあえず雛形はできたので、改めて**メインメニュー**を作っていきましょう。とりあえず、

- ユーザーに「ようこそ」みたいなことを言う
- どういう行動をとるか聞く

という能力があればそれで十分です。

```interfaces.ts
export function menuOpen(req: Request): [NccoTalk, NccoInput] {
    return [
        {
            action: "talk",
            bargeIn: true,
            text: "ツイートをする場合は 1 を、タイムラインを見る場合は 2 を、終了する場合は アスタリスク を押してください。その他のキーでもう一度再生します。",
            language: "ja-JP",
            level: 1,
        },
        {
            action: "input",
            type: ["dtmf"],
            dtmf: {
                timeOut: 10,
                maxDigits: 1,
                submitOnHash: true,
            },
            eventUrl: [`https://${req.get("host")}/webhooks/inputs/dtmf/menu`],
        }
    ];
}
```
```index.ts
app.get("/webhooks/answer", (req, res) => {
    res.json(menuOpen(req));
});
```

こうすることでひとまず、**電話がかかってきたら選択肢を提示し、DTMFの入力を受け取って、`/webhooks/inputs/dtmf/menu`にPOSTする**というような動きをするようになります。
とはいえこのままでは肝心の`/webhooks/inputs/dtmf/menu`がガラ空きですから、`index.ts`にPOSTされた際の挙動をハンドラ付きで書きます。

```index.ts
app.post("/webhooks/inputs/dtmf/menu", (req, res) => {
    let actions:Ncco[] = [];
    switch (req.body.dtmf.digits){
        case "1":
            actions = actions.concat(tweetInput(req));
            actions = actions.concat(menuOpen(req));
            break;
        case "2":
            actions = actions.concat(timelineInput(req));
            actions = actions.concat(menuOpen(req));
            break;
        case "*":
            actions = actions.concat(menuFinish());
            break;
        default:
            actions = actions.concat(menuOpen(req));
    }

    res.json(actions)
});
```

構造的には、Switch文で入力内容を読み、分岐を行っているだけです。しかし、`interfaces.ts`から引っ張れるのを良い事に、以下に挙げるような**謎の関数**が大量に出没しています。

- `tweetInput()`
- `timelineInput()`
- `menuFinish()`

:::note
関数の命名規則としては、使用中の機能名(e.g.`tweet`/`timeline`/`menu`)と動作(`open`/`input`/`finish`)を組み合わせています。
いやクラスを使えよ
:::

これらの関数について逐一動きを説明するようなことはしません。ただ、Webhook駆動での開発においては

- `app.post()`でリクエストを受け取る
- `res.json()`でNCCOを投げる

この2つを繰り返す形でアプリケーションを作れるのだ、と理解していただければ幸いです。

#### 1-5. `actionSender()`について
ハンドラ内で直接`res.json()`をするのが**なんとなく不安**だったのと、`talk`アクションで`level: 1`や`language: "ja-JP"`をいちいち書くのが**なんとなく面倒**だったことを踏まえ、最終的なコードでは`res.json()`の代わりに、`actionSender()`という関数を呼ぶようになっています。

```actionSender.ts
import { Response } from 'express';
import { Ncco } from './types/schema';

export default function actionSender(res: Response, actions: Ncco[]){
    const result: Ncco[] = [];
    actions.forEach(ncco => {
        const newNcco = ncco;
        switch (ncco.action){
            case "talk":
                newNcco.level = 1;
                newNcco.language = "ja-JP";
                break;
            case "input":
                if ((ncco.type as ("dtmf" | "speech")[]).includes("speech")) {
                    if (!newNcco.hasOwnProperty("speech")) {
                        newNcco.speech = {};
                    }
                    (newNcco.speech! as { [k: string]: (number | string) }).language = "ja-JP";
                }
                break;
        }
        result.push(newNcco);
    })
    res.json(result);
}
```
なんとなくで書いたコードなので、**なんとなく程度の意味**しか持っていません。`talk`と`input/speech`について、一部のプロパティを統一的に設定するのみです。
これを呼ぶ際は、

```diff_typescript:index.ts
- res.json(actions);
+ actionSender(res, actions);
```
という風にすれば大丈夫です。

### 2. 割愛
残りの実装は基本的に繰り返しです。フローとしては、

1. `src/index.ts`でExpressを使ってWebhookリクエストを受け取り、
2. `src/interfaces.ts`の関数を読んで生成したNCCOオブジェクトを、
    - `src/twitterWorker.ts`によるTwitterクライアントの操作をはさみつつ、
3. `src/actionSender.ts`でVonageに投げる

という感じです。
最後にコードを載せておきましょう。
<details><summary>actionSender.ts</summary>

```actionSender.ts
import { Response } from 'express';
import { Ncco } from './types/schema';

export default function actionSender(res: Response, actions: Ncco[]){
    const result: Ncco[] = [];
    actions.forEach(ncco => {
        const newNcco = ncco;
        switch (ncco.action){
            case "talk":
                newNcco.level = 1;
                newNcco.language = "ja-JP";
                break;
            case "input":
                if ((ncco.type as ("dtmf" | "speech")[]).includes("speech")) {
                    if (!newNcco.hasOwnProperty("speech")) {
                        newNcco.speech = {};
                    }
                    (newNcco.speech! as { [k: string]: (number | string) }).language = "ja-JP";
                }
                break;
        }
        result.push(newNcco);
    })
    res.json(result);
}
```
</details>
<details><summary>index.ts</summary>

```index.ts
import Express from 'express';
import bodyParser from 'body-parser';
import { menuOpen, timelineInput, tweetConfirmation, menuFinish, tweetInput, tweetSending, timelineStarting, timelineMoving } from './interfaces';
import {TweetV2, TwitterApi} from 'twitter-api-v2';
import * as dotenv from 'dotenv';
import { Ncco } from './types/schema';
import actionSender from './actionSender';
import { TweetWithUserName } from './types/tweet';
import { retweet } from './twitterWorker';
dotenv.config();

const twitterClient = new TwitterApi({
    appKey: process.env.TWITTER_API_KEY!,
    appSecret: process.env.TWITTER_API_KEY_SECRET!,
    accessToken: process.env.TWITTER_ACCESS_TOKEN!,
    accessSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET!,
});



const app = Express();

const temporaryMemory: {
    tweet_content:string,
    timeline_length:number,
    timeline_reading:number,
    timeline_tweets:TweetWithUserName[]
} = {
    tweet_content: "",
    timeline_length: 0,
    timeline_reading: 0,
    timeline_tweets: [],
}

app.use(bodyParser.json());

app.get("/webhooks/answer", (req, res) => {
    actionSender(res, menuOpen(req));
});

app.post('/webhooks/events', (req, res) => {
    res.sendStatus(200);
})

app.post("/webhooks/inputs/dtmf/menu", (req, res) => {
    let actions:Ncco[] = [];
    switch (req.body.dtmf.digits){
        case "1":
            actions = actions.concat(tweetInput(req));
            actions = actions.concat(menuOpen(req));
            break;
        case "2":
            actions = actions.concat(timelineInput(req));
            actions = actions.concat(menuOpen(req));
            break;
        case "*":
            actions = actions.concat(menuFinish());
            break;
        default:
            actions = actions.concat(menuOpen(req));
    }

    actionSender(res,actions);
});

app.post("/webhooks/inputs/speech/tweetinput", (req, res) => {
    let actions: Ncco[] = [];

    const tweet_content = req.body.speech.results[0].text
    temporaryMemory.tweet_content = tweet_content;

    actions = actions.concat(tweetConfirmation(req,tweet_content));

    actionSender(res,actions);
});

app.post("/webhooks/inputs/dtmf/tweetconfirmation", (req, res) => {
    let actions: Ncco[] = [];
    switch (req.body.dtmf.digits) {
        case "1":
            actions = actions.concat(tweetSending(twitterClient, temporaryMemory.tweet_content))
            actions = actions.concat(menuOpen(req));
            break;
        case "2":
            actions = actions.concat(tweetInput(req));
            break;
        case "*":
            actions = actions.concat(menuOpen(req));
            break;
        default:
            actions = actions.concat(tweetConfirmation(req,temporaryMemory.tweet_content));
    }

    actionSender(res,actions);
});

app.post("/webhooks/inputs/dtmf/timelinestart", async (req, res) => {
    let actions: Ncco[] = [];
    const length = Math.min(100, Math.max(1, parseInt(req.body.dtmf.digits, 10)));
    temporaryMemory.timeline_length = length+0;
    temporaryMemory.timeline_reading = -1;
    const [ncco, tweets] = await timelineStarting(req, twitterClient, length);
    temporaryMemory.timeline_tweets = tweets;
    actions = actions.concat(ncco);
    actionSender(res,actions);
});

app.post("/webhooks/inputs/dtmf/timelinenext", async (req, res) => {
    let actions: Ncco[] = [];
    let dtmf_input: string = req.body.dtmf.digits;
    if (temporaryMemory.timeline_reading == -1) {
        dtmf_input = "1";
    }
    switch (dtmf_input){
        case "2":
            if (temporaryMemory.timeline_reading - 1 < 0) {
                actions.push({
                    "action": "talk",
                    "text": "これ以上戻ることはできません。",
                    "bargeIn": true
                });
            } else {
                temporaryMemory.timeline_reading -= 1;
            }
            actions = actions.concat(timelineMoving(req, temporaryMemory.timeline_tweets[temporaryMemory.timeline_reading]));
            break;
        case "3":
            actions = actions.concat(timelineMoving(req, temporaryMemory.timeline_tweets[temporaryMemory.timeline_reading]));
            break;
        case "4":
            await retweet(twitterClient, temporaryMemory.timeline_tweets[temporaryMemory.timeline_reading].id)
            actions.push({
                "action": "talk",
                "text": "リツイートしました。",
            });
            actions = actions.concat(timelineMoving(req, temporaryMemory.timeline_tweets[temporaryMemory.timeline_reading]));
            break;
        case "0":
            break;
        default:
            if (temporaryMemory.timeline_reading + 2 > temporaryMemory.timeline_length) {
                actions.push({
                    "action": "talk",
                    "text": "これ以上進むことはできません。",
                });
            } else {
                temporaryMemory.timeline_reading += 1;
            }
            actions = actions.concat(timelineMoving(req, temporaryMemory.timeline_tweets[temporaryMemory.timeline_reading]));
            break;
        }
    actions = actions.concat(menuOpen(req));
    actionSender(res,actions)

        

});

app.listen(3000);
```
</details>
<details><summary>interfaces.ts</summary>

```interfaces.ts
import { Request } from 'express';
import { TweetV2, TwitterApi } from 'twitter-api-v2';
import { checkTimeLine, tweet } from './twitterWorker';
import { Ncco, NccoInput, NccoTalk } from './types/schema';
import { TweetWithUserName } from './types/tweet';

export function menuOpen(req: Request): [NccoTalk, NccoInput] {
    return [
        {
            action: "talk",
            bargeIn: true,
            text: "ツイートをする場合は 1 を、タイムラインを見る場合は 2 を、終了する場合は アスタリスク を押してください。その他のキーでもう一度再生します。",
        },
        {
            action: "input",
            type: ["dtmf"],
            dtmf: {
                timeOut: 10,
                maxDigits: 1,
                submitOnHash: true,
            },
            eventUrl: [`https://${req.get("host")}/webhooks/inputs/dtmf/menu`],
        }
    ];
}

export function tweetInput(req: Request): [NccoTalk, NccoInput] {
    return [
        {
            action: "talk",
            bargeIn: true,
            text: "ツイート内容を喋ってください。喋り終えたら、シャープ を押してください。",
        },
        {
            action: "input",
            type: ["speech","dtmf"],
            speech: {
                endOnSilence: 4.0,
                submitOnHash: true,
            },
            dtmf: {
                submitOnHash: true,
            },
            eventUrl: [`https://${req.get("host")}/webhooks/inputs/speech/tweetinput`],
        },
    ];
}
export function tweetConfirmation(req: Request,tweet_content:string): [NccoTalk, NccoInput] {
    return [
        {
            action: "talk",
            bargeIn: true,
            text: `「${tweet_content}」でよろしいですね？ 正しい場合は 1 を、修正する場合は 2 を、ツイートを終了する場合は アスタリスク を押してください。`,
        },
        {
            action: "input",
            type: ["dtmf"],
            dtmf: {
                timeOut: 10,
                maxDigits: 1,
                submitOnHash: true,
            },
            eventUrl: [`https://${req.get("host")}/webhooks/inputs/dtmf/tweetconfirmation`],
        },
    ];
}

export function tweetSending(userClient: TwitterApi, tweet_content: string): [NccoTalk] {
    tweet(userClient, tweet_content)
    return [
        {
            action: "talk",
            bargeIn: false,
            text: "送信しました。",
        }
    ]
}

export function menuFinish(): [NccoTalk] {
    return [
        {
            action: "talk",
            bargeIn: false,
            text: "終了します。",
        }
    ]
}

export function timelineInput(req: Request): [NccoTalk, NccoInput] {
    return [
        {
            action: "talk",
            bargeIn: true,
            text: `タイムラインを閲覧します。取得するツイート数を入力してください。`,
        },
        {
            action: "input",
            type: ["dtmf"],
            dtmf: {
                timeOut: 10,
                maxDigits: 3,
                submitOnHash: true,
            },
            eventUrl: [`https://${req.get("host")}/webhooks/inputs/dtmf/timelinestart`],
        }
    ];
}

export async function timelineStarting(req: Request, userClient: TwitterApi, length: number): Promise<[[NccoTalk, NccoInput],TweetWithUserName[]]> {
    return [[
        {
            action: "talk",
            bargeIn: true,
            text: `「1」で1つ進み、「2」で1つ戻り、「3」でもう一度読み上げます。「4」でリツイートします。「0」で終了します。`,
        },
        {
            action: "input",
            type: ["dtmf"],
            dtmf: {
                timeOut: 1,
                maxDigits: 1,
                submitOnHash: true,
            },
            eventUrl: [`https://${req.get("host")}/webhooks/inputs/dtmf/timelinenext`],
        },
    ], await checkTimeLine(userClient, length)];
}

export function timelineMoving(req: Request, tweet: TweetWithUserName): [NccoTalk, NccoTalk, NccoInput]{
    return [
        {
            action: "talk",
            bargeIn: false,
            text: `${tweet.name}さんのツイートです。`,
        },
        {
            action: "talk",
            bargeIn: true,
            text: `${tweet.text}`,
        },
        {
            action: "input",
            type: ["dtmf"],
            dtmf: {
                timeOut: 10,
                maxDigits: 1,
                submitOnHash: true,
            },
            eventUrl: [`https://${req.get("host")}/webhooks/inputs/dtmf/timelinenext`],
        },
    ]
}
```
</details>
<details><summary>twitterWorker.ts</summary>

```twitterWorker.ts
import { TweetV2, TwitterApi } from "twitter-api-v2";
import { TweetWithUserName } from "./types/tweet";


export async function tweet(userClient:TwitterApi,content:string){
    userClient.v2.tweet(content);
}


export async function checkTimeLine(userClient: TwitterApi, length: number):Promise<TweetWithUserName[]> {
    const homeTimeline = await userClient.v2.homeTimeline({ exclude: 'replies', max_results: length, expansions: ["author_id"]});
    const homeTimelineWithUserNames: TweetWithUserName[] = [];
    console.log(homeTimeline.includes.users)
    homeTimeline.tweets.forEach((tw:TweetV2)=>{
        homeTimelineWithUserNames.push({
            text: tw.text,
            name: homeTimeline.includes.author(tw)!.name,
            id: tw.id
        })
    });
    return homeTimelineWithUserNames

}
export async function retweet(userClient: TwitterApi, tweetId:string): Promise<void> {
    await userClient.v2.retweet((await userClient.v2.me()).data.id,tweetId)
}
```
</details>
<details><summary>types/tweet.ts</summary>

```types/tweet.ts
export type TweetWithUserName =  {
    text: string,
    name: string,
    id: string
}
```
</details>

## 使用編(強引なオチ)
さあ……やっていきましょう、**固定電話でTwitter**！
僕はこのために**捨て・アンド・鍵アカウント**を作り、Twitter Dev PortalからAPIキーを取得してなんのかんのも済ませています。タイムライン確認機能のため、とりあえず[**特務機関NERV**](https://twitter.com/UN_NERV)だけをフォローする人になりました。やっぱり災害への備えは重要ですからね。

### 0. ツイートをしてみよう！
なんだかドキドキしてきましたね。
**胸の鼓動**を感じながらも、Vonageで購入した番号に発信をします。

>Bot: ツイートをす……
筆者: **(『1』のボタンを押す)**

ナビゲーションなんていちいち気にしていられません。ツイート画面に飛ぶことを意味する『1』を即押しします。

>Bot: ツイート内容をしゃ……
筆者: **布団が吹っ飛んだ**

**超おもしろジョークツイート**を間髪入れずに入力します。Botの声など聞いていられません。そのまま`#`を押し、Botが音声を処理するのを待ちわびます。

>Bot: …… **『オトンが降ったんだ』** でよろしいですね？正しい場合は……

本当にそれがお前の答えでいいのか？
しかし……問題ありません。こんなこともあろうかと、ツイートを**修正**することも可能にしてあるのです。

>Bot: ……修正する場合は……
筆者: **(『2』のボタンを押す)**
Bot: ツイートな……
筆者: 布団が吹っ飛んだ **(『#』のボタンを押す)**
Bot: **『三田』** でよろしいですね？

誰だよも～。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2703746/5d66ff25-7f49-a549-6590-1b6e5c775dcf.png "ちょっと")

自動検出結果で**ライム踏む**のやめろって～。

しかし……めげません。筆者は、絶対に固定電話でTwitterをすると決めている。こんなところで敗退するわけにはいかない。

>筆者: **(『2』のボタンを押す)**
Bot: ツイ……
筆者: 布団が吹っ飛んだ **(『#』のボタンを押す)**
Bot: **『音が吹っ飛んだ』** でよろしいですね？

めげない、めげませんよ。

>Bot: **『遠賀サンダース』** でよろしいですね？

……これ、『音が吹っ飛んだ』で妥協しといた方が良かったかな？
そんな誘惑が心を突き刺します。でも、俺はできるツイ廃だから。固定電話でTwitterをしなくちゃいけない。

>Bot: **『布団が吹っ飛んだ』** でよろしいですね？

やった。

>筆者: **(『1』のボタンを押す)**
>Bot: ツイートしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2703746/5c3b2242-751a-bfb1-8645-d80f970239a0.png)
Twitterって、こんなに楽しくて……達成感のあるものでしたっけ。

# 分かったこと
- Twitterは楽しい
- 国際電話料金は思ったより高い
- Vonage Communication APIのVoice APIは極めて高品質な電話回線越しのカスタマブルユーザーエクスペリエンスを提供してくれる

[^1]:**誤植にあらず**
[^2]:「購入」と表記されているが**買い切りではない**。『費用』カラムに表示されているのは**月額**
[^3]:€0.90/M
[^4]:`Nexmo`はVonageの旧称なので、実質的には**VCCO**みたいなもの
[^5]:*Speech Synthesis Markup Language*、XMLベースの合成音声用マークアップ言語
[^6]:JSONの構造をJSONで定義するための記法