Google HomeとFirebase Cloud Functionsでビットコインを全力買いする

この記事は[Firebase Advent Calendar 2018](https://qiita.com/advent-calendar/2018/firebase) 5日目の記事です。
ネタ記事かつ、投稿が遅れて肩身が狭いですが書いてきたいと思います。[@daikiojm](https://twitter.com/daikiojm)です。
[Google Homeでうんこなう](https://qiita.com/daikiojm/items/683c8cab107ea0a87755)に引き続きGoogle Homeで遊んでみます。

## 何をしたいのか

以下の動画のように、Google Homeに話しかけることで仮想通貨を全力買いします。
Action on Google(Dialogflow)のwebhookからhttp(s)経由でFirebase Cloud Functions呼び出し、仮想通貨取引所の注文APIを叩くというシンプルなものですが、スマートスピーカーに話しかけるだけで全資産をかけて仮想通貨の売買をするのはなかなかのスリルがありますね😇

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">Google Homeでビットコイン全力買いした <a href="https://t.co/nl9ICrlVut">pic.twitter.com/nl9ICrlVut</a></p>&mdash; Daiki (@daikiojm) <a href="https://twitter.com/daikiojm/status/1070385418065989632?ref_src=twsrc%5Etfw">2018年12月5日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


## 構成

構成は次の図の通りです。
Action on Googleと仮想通貨取引所とのつなぎ込みにFirebase Cloud Functions使います。
また、今回はアカウント開設済みかつNode.jsのSDKが提供されているbitbankの取引所APIを使って仮想通貨の注文を行います。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/79414/6ac60809-b557-8e81-05a5-cd60b593d054.png)

## 実現方法

大まかな流れ

- Action on Googleでプロジェクト作成
- DialogflowでEntities/Intentsの定義
- Firebase Cloud Functionsでdialogflow webhookのハンドラ/取引所注文の実装
- Firebase Cloud Functionsのデプロイ
- Dialogflowからwebhook連携の設定

それぞれ、ポイントをかいつまんで紹介していきたいと思います。
(それぞれの項目に記載する参考リンクが有用なので、詳しく解説するまでもなさそう...)

### Action on Googleでプロジェクト作成

[Actions Console](https://console.actions.google.com/u/0/?pli=1)からAction on Googleプロジェクトを作成します。
DialogflowでのEntities/Intentsの定義を含めて、以下の記事が参考になりました。

![スクリーンショット_2018-12-06_2_22_05.png](https://qiita-image-store.s3.amazonaws.com/0/79414/b4bca580-5a39-758b-8a05-7406f55f0384.png)


[Dialogflow と Firebase Cloud Functions で Actions On Google 作り](https://qiita.com/doki_k/items/11f8a23d71dce59409da)

### DialogflowでEntities/Intentsの定義

今回、DialogflowのEntitiesには以下の2つを定義しています。
これら２つが、注文時のパラメータになります。(数量は指定できません全力です)
それぞれ、対話時の発話の揺れを吸収できるように複数の言い回しを登録しています。

- Asset
    - 売買する通貨名
- Side
    - 売り/買いの種別


![スクリーンショット 2018-12-06 2.25.46.png](https://qiita-image-store.s3.amazonaws.com/0/79414/27ec9413-9c09-e4b1-4ad1-dd19205089d1.png)

![スクリーンショット 2018-12-06 2.25.56.png](https://qiita-image-store.s3.amazonaws.com/0/79414/5a02b447-0feb-581b-015c-38a8d5bad6e7.png)

Intentsは次のような構成になっています。
actions-on-googleのSDK v2以降では、ハンドラ内でIntent名でアクションを識別するため、それぞれわかり易い名前にしておいたほうが良さそうです。
(ここでは、`zenryoku-buy-sell - order` などが識別子として使われる)

![スクリーンショット 2018-12-06 2.29.39.png](https://qiita-image-store.s3.amazonaws.com/0/79414/2ef354a0-b8f9-f425-74a4-ca6d8e962523.png)

その他、Training phrases、Action and parametersは以下の通りに設定しています。

![スクリーンショット 2018-12-06 2.33.48.png](https://qiita-image-store.s3.amazonaws.com/0/79414/ab06c692-8c7e-7b9d-7707-7ba0f0a4f593.png)


### Firebase Cloud Functionsでdialogflow webhookのハンドラ/取引所注文の実装

細かいプロジェクト構成は省きますが、firebase-toolsで初期化したTypeScriptのFirebase Cloud Functionsプロジェクトをベースに、以下の3ファイルで構成されています。

- index.ts
  - Firebase Cloud Functionsのhttpハンドラ
- types.ts
  - enum定義
- bitbank-handler.ts
  - 取引所APIの呼び出し
  - ロジックは至ってシンプルで、Cloud Functionが発火した際の最新価格 & 指定された通貨の全保有数量で成行注文を入れる仕様になっています

#### index.ts

```ts
import * as functions from 'firebase-functions';
import { Request, Response } from 'express';
import { dialogflow, DialogflowConversation, Parameters } from 'actions-on-google'

import { zenryokuBuyOrSell } from './bitbank-handler';
import { IntentNames, ZenryokuStatus } from './types';

const runtimeOptions = {
  timeoutSeconds: 10,
};

function intentHandler(request: Request, response: Response): void {
  const app = dialogflow();

  app.intent(IntentNames.Default, async (conv: DialogflowConversation, params: Parameters) => {
    conv.ask('まだ仮想通貨持ってないの？');
  });

  app.intent(IntentNames.Order, async (conv: DialogflowConversation, params: Parameters) => {
    const asset = params.Asset;
    const side = params.Side as ('buy' | 'sell');

    if (!asset || !side) {
      conv.close('エラーが発生しました');
    }

    conv.ask(`${asset}を全力${side === 'buy' ? '買い' : '売り'}します`);

    const result = await zenryokuBuyOrSell(`${asset}_jpy`, side);

    if (result === ZenryokuStatus.Done) {
      conv.close(`全力${side === 'buy' ? '買い' : '売り'}しました!`);
    } else if (result === ZenryokuStatus.Ordered) {
      conv.close(`全力で${side === 'buy' ? '買い' : '売り'}注文しました!`);
    } else {
      conv.close('エラーが発生しました');
    }
  });

  app.intent(IntentNames.Cancel, async (conv: DialogflowConversation, params: Parameters) => {
    conv.close('やめておきました');
  });

  app(request, response);
}

export const fulfillment = functions.runWith(runtimeOptions).https.onRequest((request, response) => intentHandler(request, response));
```  

#### types.ts

```ts

export enum IntentNames {
  Default = 'zenryoku-buy-sell',
  Order = 'zenryoku-buy-sell - order',
  Cancel = 'zenryoku-buy-sell - no',
}

export enum ZenryokuStatus {
  Done = 'DONE',
  Ordered = 'ORDERED',
  Error = 'ERROR',
}
```

#### bitbank-handler.ts

```ts
import * as functions from 'firebase-functions';
import * as bitbank from 'node-bitbankcc';
import { ZenryokuStatus } from './types';

const limitMaxBuyRate = 0.8;
const limitMaxSellRate = 1;
const apiKey = process.env.BITBANK_API_KEY;
const apiSecret = process.env.BITBANK_API_SECRET;

export async function zenryokuBuyOrSell(pair: string, side: 'buy' | 'sell' = 'buy'): Promise<ZenryokuStatus> {
  try {
    const { baseAsset, quoteAsset } = getBaseAndQuoteAssetByPair(pair);
    const privateClient = getBitbankPrivateClient();
    const publicClient = getBitbankPublicClient();

    let price = 0;
    let freeAmount = 0;

    if (side === 'buy') {
      price = await getPriceByPairNameAndSide(publicClient, pair);
      freeAmount = await getAmountByAssetName(privateClient, quoteAsset);
    } else {
      freeAmount = await getAmountByAssetName(privateClient, baseAsset);
    }

    // market 買い注文では資産の80%までの注文しかできない
    const canBuyAmmount = Math.floor((freeAmount / price) * limitMaxBuyRate * 10000) / 10000;
    const canSellAmmount = Math.floor(freeAmount * limitMaxSellRate * 10000) / 10000;
    const orderResult = await privateClient.postOrder({ pair, amount: side === 'buy' ? `${canBuyAmmount}` : `${canSellAmmount}`, side, type: 'market' });

    // 約定を待つ
    await new Promise((resolve) => setTimeout(resolve, 3000));

    const order = await privateClient.getOrder({ order_id: orderResult.data.order_id, pair });

    if (!order || order.success !== 1) {
      return ZenryokuStatus.Error;
    }

    if (order.data.status === 'FULLY_FILLED') {
      return ZenryokuStatus.Done;
    }

    return ZenryokuStatus.Ordered;
  } catch (e) {
    console.log(e);
    return ZenryokuStatus.Error;
  }
}

function getBitbankPublicClient(): bitbank.PublicApi {
  const conf: bitbank.ApiConfig = {
    endPoint: 'https://public.bitbank.cc',
    keepAlive: false,
    timeout: 3000,
  };
  return new bitbank.PublicApi(conf);
}

function getBitbankPrivateClient(): bitbank.PrivateApi {
  const conf: bitbank.PrivateApiConfig = {
    endPoint: 'https://api.bitbank.cc/v1',
    apiKey: functions.config().api.key,
    apiSecret: functions.config().api.secret,
    keepAlive: false,
    timeout: 3000,
  };
  return new bitbank.PrivateApi(conf);
}

function getBaseAndQuoteAssetByPair(pair: string): { baseAsset: string, quoteAsset: string } {
  const sepaPair = pair.split('_');
  return { baseAsset: sepaPair[0], quoteAsset: sepaPair[1] };
}

async function getAmountByAssetName(client: bitbank.PrivateApi, asset: string): Promise<number> {
  const assets = await client.getAssets();

  if (!assets || assets.success !== 1) {
    return 0;
  }

  return +assets.data.assets.find((a) => a.asset === asset).free_amount;
}

export async function getPriceByPairNameAndSide(client: bitbank.PublicApi, pair: string, side: 'buy' | 'sell' = 'buy'): Promise<number> {
  const ticker = await client.getTicker({ pair });

  if (!ticker || ticker.success !== 1) {
    return 0;
  }

  return side === 'buy' ? +ticker.data.buy : +ticker.data.sell;
}
```

### Firebase Cloud Functionsのデプロイ

こちらも、firebase-toolsを使ってデプロイするだけなのですが、取引所APIのAPI Keyを環境変数から取得するようにしているため以下のコマンドで環境変数を設定してからデプロイを行います。

```bash
$ firebase functions:config:set api.key="apikey" api.secret="apisecret"
$ firebase deploy --only functions
```

参考: [Firebase functions に環境変数を設定してみる](https://medium.com/google-cloud-jp/firebase-functions-%E3%81%AB%E7%92%B0%E5%A2%83%E5%A4%89%E6%95%B0%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B-50ce701b2f3f)



## 課題

- 認証の問題
    - 今回とりあえず動かしてみることに集中したため、Action on Google → Firebase Cloud Functions間での認証が全くありません😱
    - デプロイされているFirebase Cloud Functionsのエンドポイントが分かれば、誰でも全力注文し放題で激ヤバです
    - 取引所のAPI Keyの権限を絞ることももちろんですが、実際に使っていくには何かしらの認証を入れることを検討する必要がありそうです
- Firebase Cloud Functionsのアウトバウンド制約
    - 無料プランでは外部APIの呼び出しができないため、今回は従量課金制の `Blaze` プランに上げています...
    - Action on GoogleとFirebase Cloud Functions間はwebhookでの連携なので、AWS Lambdaに置き換えてもいいかも
- 会話のやり取りが雑すぎる問題
    - 誤注文を防ぐためにも確認のIntentをしっかりと用意してあげたほうが良さそうです
    - [Transactions API](https://developers.google.com/actions/transactions/)なんかも合せて使える?


以上、少し雑な記事になってしまいましたが、ここらへんにしたいと思います。
今後もFirebase/スマートスピーカで遊んできいたいです。