この記事はFirebase Advent Calendar 2018 5日目の記事です。
ネタ記事かつ、投稿が遅れて肩身が狭いですが書いてきたいと思います。@daikiojmです。
Google Homeでうんこなうに引き続きGoogle Homeで遊んでみます。
何をしたいのか
以下の動画のように、Google Homeに話しかけることで仮想通貨を全力買いします。
Action on Google(Dialogflow)のwebhookからhttp(s)経由でFirebase Cloud Functions呼び出し、仮想通貨取引所の注文APIを叩くというシンプルなものですが、スマートスピーカーに話しかけるだけで全資産をかけて仮想通貨の売買をするのはなかなかのスリルがありますね😇
Google Homeでビットコイン全力買いした pic.twitter.com/nl9ICrlVut
— Daiki (@daikiojm) 2018年12月5日
構成
構成は次の図の通りです。
Action on Googleと仮想通貨取引所とのつなぎ込みにFirebase Cloud Functions使います。
また、今回はアカウント開設済みかつNode.jsのSDKが提供されているbitbankの取引所APIを使って仮想通貨の注文を行います。
実現方法
大まかな流れ
- Action on Googleでプロジェクト作成
- DialogflowでEntities/Intentsの定義
- Firebase Cloud Functionsでdialogflow webhookのハンドラ/取引所注文の実装
- Firebase Cloud Functionsのデプロイ
- Dialogflowからwebhook連携の設定
それぞれ、ポイントをかいつまんで紹介していきたいと思います。
(それぞれの項目に記載する参考リンクが有用なので、詳しく解説するまでもなさそう...)
Action on Googleでプロジェクト作成
Actions ConsoleからAction on Googleプロジェクトを作成します。
DialogflowでのEntities/Intentsの定義を含めて、以下の記事が参考になりました。
Dialogflow と Firebase Cloud Functions で Actions On Google 作り
DialogflowでEntities/Intentsの定義
今回、DialogflowのEntitiesには以下の2つを定義しています。
これら2つが、注文時のパラメータになります。(数量は指定できません全力です)
それぞれ、対話時の発話の揺れを吸収できるように複数の言い回しを登録しています。
- Asset
- 売買する通貨名
- Side
- 売り/買いの種別
Intentsは次のような構成になっています。
actions-on-googleのSDK v2以降では、ハンドラ内でIntent名でアクションを識別するため、それぞれわかり易い名前にしておいたほうが良さそうです。
(ここでは、zenryoku-buy-sell - order
などが識別子として使われる)
その他、Training phrases、Action and parametersは以下の通りに設定しています。
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
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
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
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を環境変数から取得するようにしているため以下のコマンドで環境変数を設定してからデプロイを行います。
$ firebase functions:config:set api.key="apikey" api.secret="apisecret"
$ firebase deploy --only functions
参考: Firebase functions に環境変数を設定してみる
課題
- 認証の問題
- 今回とりあえず動かしてみることに集中したため、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に置き換えてもいいかも
- 無料プランでは外部APIの呼び出しができないため、今回は従量課金制の
- 会話のやり取りが雑すぎる問題
- 誤注文を防ぐためにも確認のIntentをしっかりと用意してあげたほうが良さそうです
- Transactions APIなんかも合せて使える?
以上、少し雑な記事になってしまいましたが、ここらへんにしたいと思います。
今後もFirebase/スマートスピーカで遊んできいたいです。