LoginSignup
4

More than 1 year has passed since last update.

はじめに

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

前置き

今回のアドベントカレンダーは、VonageのコミュニケーションAPIを使った参考になる記事を募集しているんだそうです。

参考になる記事ですよ。

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

要点

  1. VonageのコミュニケーションAPIを使えば、Voice API電話回線越しにWebサーバーと通信することが可能
    • コールセンターの自動応答Botをイメージしていただきたい
  2. TwitterというコミュニケーションSNSを使えば、ツイ廃としてネット回線越しにWebサーバーと通信し続けることが可能
    • コールセンターの自動応答Botをイメージしていただきたい1
  3. これらを混ぜ合わせることで、「電話回線からWebサーバーと通信し続ける存在」と化し、固定電話でTwitterすることが可能に!

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

本文

検討編

Voice APIのチュートリアルを読みつつ、どんな感じに実装するか考えていきましょう。
まず、このAPIを1人のユーザーとの対話で使用する場合、

  • ユーザー
  • 電話番号(Vonage)
  • サーバー

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

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

環境構築編

Vonageはいろいろなプラットフォームで開発可能らしいですが、今回はチュートリアルで使われていたNode.js+TypeScript+Express.jsでいきます。
その他使用したパッケージの中で、特筆すべきものを以下に挙げます。

@vonage/cli v1.1.4
Vonageアプリケーション管理ツール。チュートリアルで使われていたので
ngrok v3.1.0
ローカルポートを外部公開するサービス。チュートリアルで使われていたので
body-parser v1.20.1
メッセージボディ解析ライブラリ。チュートリアルで使われていたので
dotenv v16.0.3
秘密の情報を.envファイルから読むライブラリ
twitter-api-v2 v1.12.9
Twitter APIクライアントライブラリ
json-schema-to-typescript v11.0.2
後述

準備編

0. 割愛

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

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

1. 電話番号を借りる2

ダッシュボードのサイドバーから構築と管理>番号>番号を購入と進むと、アプリケーションで使用する電話番号の購入画面が出ます。
image-20221103170700744.png
世界各国の番号がずらっと並んでいますね。
日本の番号も購入できそうだったんですが、何かしらの法律に基づき審査が必要とのことだったので、今回は無難にアメリカのMobile - SMS & MMS & Voiceを借りました。3

電話番号のタイプは、Mobile(携帯)、Landline(固定)、Toll-free(フリーダイヤル)の3種類です。フリーダイヤルはちょっと面白そうだと思ったんですが、なんとなく面倒そうな気配がしたのでスルーしました。

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

2. Vonage CLIでログインする

ここからはしばらく、チュートリアルをなぞる形になります。
Vonage CLIは前述のとおり、Vonageのプロジェクトを管理するためのツールです。構築と管理>API設定に存在するAPIキー等を入力し、ログインしておきましょう。

$ vonage config:set --apiKey={API_KEY} --apiSecret={API_SECRET}

3. プロジェクトの作成

ローカルで作業用ディレクトリを作成し、CLIからnpm initなりnpx tsc --initなりします。ここは本旨ではないので触れません。
この状態で

$ ngrok http {任意のポート番号}

を実行し、指定したポート番号を外部公開します。
これによりngrokから一時的なホスト名(*.ngrok.ioなど)が渡されます。仮にそれをexample.comとするなら、

$ vonage apps:create "Call Transcription" --voice_answer_url=https://example.com/webhooks/answer --voice_event_url=https://example.com/webhooks/events

を実行することで、新規のアプリケーションを作成できます。
さらに、この時吐き出されるアプリケーションIDと先ほど購入した電話番号を合わせて、

$ vonage apps:link {APPLICATION_ID} --number={YOUR_VONAGE_NUMBER}

とすることで、アプリケーションに電話番号をリンクできます。

NCCOについて編

0. NCCOとは

先ほどの図では、Vonageとサーバーの間で行われる通信は「Webhookベース」だと説明しました。Webhookと言っても普通は何かしらの規定に沿って書かれているべきでしょう。
Vonageの場合、その大部分がNCCO(Nexmo Call Control Objects)4というJSONオブジェクト記法にのっとって表記されます。
NCCOは、基本的にアクションオブジェクトの配列という形をとります。例として、公式リファレンスから、適当なNCCOを引用してみましょう。

[
  {
    "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に渡した場合に何が起こるかというと、先ほどの図に則るならこうです。

なんとなく方向性はわかるでしょうか。
アクションには色々と種類がありますが、今回のツイ廃・プロジェクトではinputtalkしか使いませんでした。なので、これらについて軽く解説します。

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

1. アクション解説

talk

例:

{
    "action": "talk",
    "text": "私はムスカ大佐だ。",
    "level": 1,
    "language": "ja-JP"
    "bargeIn": false
}

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

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

例えばの話、

[
    {
        "action": "talk",
        "text": "助けてくれ!頼む!助けてくれ!",
        "bargeIn": false
    },
    {
        "action": "input",
        "type": ["speech"]
    }
]

というようなNCCOを設定したら、その後の会話はだいたいこういう感じに進むでしょう。

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

助け合いの社会という感じで素晴らしいですね。
しかし、

[
    {
        "action": "talk",
        "text": "助けてくれ!頼む!助けてくれ!",
        "bargeIn": true
    },
    {
        "action": "input",
        "type": ["speech"]
    }
]

bargeInを使ってこう改変すると、相手が喋り始めた時点で言葉が中断されるようになります。つまり、

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

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

input

例:

[
    {
        "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プロパティから指定できます。なお、dtmfspeech両方を指定することも可能です。
typeで指定した入力についてさらに詳細に設定する場合は、dtmf/speechプロパティを使います。設定はいろいろありますが、今回主に使ったもので言うと

  • dtmf
    • maxDigits: 入力できる桁数の上限
    • timeOut: 入力終了までの時間
    • submitOnHash: #を押した場合に入力完了とみなすか否か
  • speech
    • endonSilence: この秒数沈黙したら入力完了とみなす
    • language: 入力される音声の言語

あたりですね。
eventUrlは配列を受け取りますが、まあ基本的には1つのWebhookURLを渡しておけば問題ありません。
Vonageは、「入力完了」をトリガーにしてこのURLにPOSTリクエストを投げます。前述のリファレンスからdtmfの例をそのままコピーしてきました。

{
  "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の例も載せておきましょう。こちらは前述の『例』に多少の改変を加えて試したものです。

{
  "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が高いほどそれらしい、ということらしいです。

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

2. スキーマについて

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

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

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

注意:この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 URLGETリクエストを投げます。『準備編』と同じ設定をしている場合、answer URLhttps://example.com/webhooks/answerとなっているはずです。
例えばこの時、

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に置いた関数で生成する手法を取ります。すなわち、

interfaces.ts
+ import { NccoTalk } from './types/schema';
+ 
+ export function sayHello(req: Request): [NccoTalk] {
+     return [
+         {
+             action: "talk",
+             text: "こんにちは",
+             level: 1,
+             language: "ja-JP"
+         }
+     ]
+ }

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

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です。
このプロジェクトでは特に使いませんが、一応書いておきます。

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()

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

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

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

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

1-5. actionSender()について

ハンドラ内で直接res.json()をするのがなんとなく不安だったのと、talkアクションでlevel: 1language: "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);
}

なんとなくで書いたコードなので、なんとなく程度の意味しか持っていません。talkinput/speechについて、一部のプロパティを統一的に設定するのみです。
これを呼ぶ際は、

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に投げる

という感じです。
最後にコードを載せておきましょう。

actionSender.ts
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);
}
index.ts
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);
interfaces.ts
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`],
        },
    ]
}
twitterWorker.ts
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)
}
types/tweet.ts
types/tweet.ts
export type TweetWithUserName =  {
    text: string,
    name: string,
    id: string
}

使用編(強引なオチ)

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

0. ツイートをしてみよう!

なんだかドキドキしてきましたね。
胸の鼓動を感じながらも、Vonageで購入した番号に発信をします。

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

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

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

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

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

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

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

誰だよも~。

image.png

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

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

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

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

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

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

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

やった。

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

image.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で定義するための記法

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4