--とある会社にて。
N屋「これで仕事もはかどりますね!それじゃM田さん、頑張ってください!」
M田「さっすがN屋先輩!kintoneって意外と何でもできるんだな~。ついでに俺の召使い、作ってくれないかな~」
すると…
「「エリピー 5分ハッキング!!!」」
M田「また1俺のPCがハックされたーッ!!」(迫真)
N屋「それでは本日は、 アプリののっけ盛り ~召使いの気まぐれ仕立て~
のレシピをご紹介します。材料はこちらです」
N屋「相も変わらず急いでおりますので、出来上がったものをご用意いたしました。それでは、使ってみてください!」
M田「ポータルになんかいるーー!!!」
kintone hack Night 2020 優勝したぞー!!
こちらは kintone 2 Advent Calendar 2020 15日目の記事です。
みなさんこんにちは、株式会社ウィルビジョンの住田です。
遅ればせながら、 kintone hack Night 2020 おつかれさまでした!
みなさんにたっっっくさん応援していただいたおかげで、見事ウィルビジョンのチームが優勝することができました!本当にありがとうございました!
というわけで今回は、 kintone hack Night で披露した ひげおじちゃん という召使いについて、ご紹介していきたいなと思います。
当日は6分という短い時間でしか漫才プレゼンできなかったため、まだひげおじちゃんには数多くの秘密が隠されています。
ひげおじちゃんのできること から ひげおじちゃんの中身 までいろいろ紹介していきますので、しばしお付き合いいただけたらなと思います。
それでは、 Let's GO!!
ひげおじちゃんができること
ひげおじちゃんをクリックすると、どこかで見たことあるような入力ボックスが出てきます。ここからひげおじちゃんにお願いごとをすると、いろいろ叶えてくれます。
それではひげおじちゃんの すんばらしい搭載機能 をご紹介いたしましょう。
天気を教えてくれる
天気を教えて
ってお願いすると、OpenWeather を検索して今日の天気を答えてくれます。
現在地をブラウザから取得してくれるので、パッとコンビニに出かけたいときでも万全の対策をとることができます。
名古屋市の天気を教えて
と地名を指定したり、 明日の天気を教えて
と日付を指定できたりする2ので、使い方は無限大。さすがひげおじちゃん。
目的地までの道のりを教えてくれる
~までの道のりを教えて
とお願いすると、NAVITIME の乗換案内をしてくれます。
開始地点も指定して ~から~までの道のりを教えて
とお願いすることもできるので、デートプランを考えるときも抜かりありません。
建物の名前や住所でも検索できるので、ざっくりお願いしてもちゃんと答えてくれます。さすがひげおじちゃん。
オススメのコーディネートを教えてくれる
オススメのコーディネートを教えて
とお願いすると、 TNQL (テンキュール) というサービスから取得したおすすめのコーディネートを教えてくれます。
なんとこのサービス、画像付き!イメージ画像とオススメコーデの説明が3つ表示されます。朝 kintone を開いてひげおじちゃんに聞くだけで、忙しい中コーデの組み合わせを考える手間が省けます。
仕事だけではなく日々の生活までサポートしてくれる。さすがひげおじちゃん。
挨拶をしてくれる
おつかれ
って話しかけると、 「お疲れ。」 って返してくれます。
会社の誰かに話しかけたいのに、みんな忙しそうで話しかけづらい…。そういう状況になったこと、ありませんか?
ひげおじちゃんなら、いつでもどこでも「お疲れ。」って返してくれます。もう寂しい思いをする必要はありません。さすがひげおじちゃん。
疲れたときに励ましてくれる
しんどい
って話しかけると、 「元気だせよ。」 って返してくれます。
もうこれだけで癒しです。仕事に疲れても、恋に疲れても、こんなに純粋な瞳で励まされたら疲れなんて吹っ飛んでしまうでしょう。
しかもこれ kintone の画面上ですから、 仕事中いつもずっと見守ってくれています 。安心感が違いますよね。さすがひげおじちゃん。
仲間を連れてきてくれる
ゆかいな仲間たち
とお願いすると、ひげおじちゃんが 仲間を連れてきてくれる。
これは…そう、某有名〇分ハッキングのオープニングで踊っていたあのキャラクターたちだ。どうしてこんなところにいるんだ?
他にもお願いすればいろんなキャラクターを連れてきてくれるのかな?さすがひげおじちゃん。
花火を打ち上げてくれる
決勝進出したよ
と教えると、 お祝いに花火を打ち上げてくれます。
これはきっと kintone hack 2020 の予選会の結果を受けて、ひげおじちゃんが用意してくれたサプライズに違いない3。
さすがひげおじちゃん。
…とまあ、ここまで紹介してきましたが、後半の機能は完全に茶番です。実用性のある機能を期待していたみなさま、大変申し訳ございません。
ひげおじちゃんのしくみ
それでは実際に中身がどうなっているか、ご紹介していきましょう。
応答処理の分岐
ひげおじちゃんに何か教えてほしい時、吹き出しに文章を入力します。
ここに入力された文章によって、天気を検索したり、道のりを検索したり、花火を打ち上げたりするのですが、当然、入力する項目は1つです。つまり、文章の内容を見て検索する対象を分ける必要があります。
ちょっと難しい内容かもしれませんが、今回は簡単に実装するため AI や検索エンジンは使わず、 正規表現 という機能を使って分岐をしました。
実際のソースはこんな感じです。
/**
* 検索処理を行います
* @param query 検索文字列
*/
private async searchFunc(query: string) {
// 外部の検索処理を読み込み
const exfuncs = createSearchFuncTable(this);
// 検索処理テーブルを作成 (正規表現でマッチした処理を行う)
const table: SearchFunc[] = [
// 改行はめんどくさいのでパス (じゃあtextareaで作るな)
{ test: /(\r|\n)/g, exec: () => this.dispSearchPanel('改行は入れないで'), },
// 空欄で検索されたとき (やさしく注意してあげよう)
{ test: /^$/, exec: () => this.dispSearchPanel('いやなんか入力しろよ'), },
// 外部の検索処理を展開
...exfuncs,
// わしと愉快な仲間たちを見たけりゃ戻ってきな!わしが3人になって相手してやる
{ test: /^(愉快|ゆかい|ユカイ)な(仲間|なかま|ナカマ)(達|たち)(について)?$/, exec: () => {
localStorage.setItem('concierge-type', 'yukai');
this.clear();
this.disp();
this.dispSearchPanel('私たちのことかい?');
}, },
// 元に戻したくなった時用
{ test: /^(ひげおじちゃん(について)?|(?:元|もと)に(?:戻|もど)して)$/, exec: () => {
localStorage.removeItem('concierge-type')
this.clear();
this.disp();
this.dispSearchPanel('ただいま。');
}, },
// 癒し その1
{ test: /^.*お(疲|つか)れ.*$/, exec: () => this.dispSearchPanel('お疲れ。'), },
// 癒し その2
{ test: /^.*しんどい.*$/, exec: () => this.dispSearchPanel('元気だせよ。'), },
// 音声入力だけじゃつまらないので
{ test: /^(?:(?:何|なん|なに)か)?(?:喋|しゃべ|い|言)って$/, exec: () => {
speechSynthesis.speak(new SpeechSynthesisUtterance()); // voice一覧を取得するため
const utter = new SpeechSynthesisUtterance('うるせえ');
utter.pitch = 2;
utter.voice = speechSynthesis.getVoices().find(voice => voice.lang === 'ja-JP' && voice.name.includes('Google')) ?? null;
speechSynthesis.speak(utter); // しゃべらせる
this.dispSearchPanel('うるせえ');
}, },
// 予選が通ったとき
{ test: /^.*(決勝|本戦)に?(?:進)出.*$/, exec: async () => {
this.dispSearchPanel('おめでとう。');
await new Promise(r => setTimeout(r, 2000));
this.dispSearchPanel('花火打ち上げておくよ。');
new Fireworks(20);
}, },
// 優勝したとき
{ test: /^.*(?:kintone hack)?.*優勝.*$/, exec: async () => {
this.dispSearchPanel('や っ た ぜ');
await new Promise(r => setTimeout(r, 2000));
this.dispSearchPanel('花火打ち上げておくよ。');
const fireworksObj = new Fireworks(1000, 'night');
await new Promise(r => setTimeout(r, 3000));
this.dispSearchPanel('ほれほれ、もっとじゃ');
fireworksObj.setFreq(0.65);
await new Promise(r => setTimeout(r, 3000));
this.dispSearchPanel('まだまだ行くぞ?');
fireworksObj.setFreq(0);
fireworksObj.setRate(2);
await new Promise(r => setTimeout(r, 3000));
this.dispSearchPanel('これでどうじゃ!');
fireworksObj.setRate(10);
await new Promise(r => setTimeout(r, 6000));
this.dispSearchPanel('ごめんやりすぎたわ...');
fireworksObj.dispose();
}, },
// 何もわからないとき
{ test: /^.*$/, exec: () => this.dispSearchPanel('ごめん分からん'), },
];
// 正規表現で検索文字列をテスト、最初に引っかかったものを実行
const result = table.find(row => row.test.test(query));
result?.exec(result.test.exec(query));
}
ちょっとまあ、茶番が多めですが…。この処理で重要な部分は 2つ あります。
1つめは、各々の処理を配列にしている部分。
// 検索処理テーブルを作成 (正規表現でマッチした処理を行う)
const table: SearchFunc[] = [
// 改行はめんどくさいのでパス (じゃあtextareaで作るな)
{ test: /(\r|\n)/g, exec: () => this.dispSearchPanel('改行は入れないで'), },
// わしと愉快な仲間たちを見たけりゃ戻ってきな!わしが3人になって相手してやる
{ test: /^(愉快|ゆかい|ユカイ)な(仲間|なかま|ナカマ)(達|たち)(について)?$/, exec: () => {
localStorage.setItem('concierge-type', 'yukai');
this.clear();
this.disp();
this.dispSearchPanel('私たちのことかい?');
}, },
...
];
これは、入力された文章がこのパターンだったら処理したい!を表した正規表現 (test) と 実際の処理 (exec) を持ったオブジェクトを配列にしています。
そもそも正規表現って何?という方のためにわかりやすく説明すると、 いろいろな文章を分岐や繰り返しといった条件で比較できる機能 のことを正規表現といいます。
例えば、ひげおじちゃんが仲間を連れてくる処理の正規表現は
/^(愉快|ゆかい|ユカイ)な(仲間|なかま|ナカマ)(達|たち)(について)?$/
となっていますが、 (
~ )
の中で |
で区切られているとき、それは or 条件 のような処理をしてくれます。
なので上記の正規表現を使うと、次の文章は全部マッチします。
愉快な仲間達について
ゆかいななかまたちについて
愉快ななかま達について
ユカイなナカマ達について
逆に、次の文章はマッチしません。
-
愉快ゆかいな仲間達について
-
愉快
とゆかい
は同じ場所で or 条件になっているので、愉快
の次にはな
が来ないとダメ
-
-
明日の天気を教えて
- 文章のはじめは
愉快
かゆかい
かユカイ
のどれかじゃないとダメ
- 文章のはじめは
そして、ここからが正規表現の素晴らしいところなのですが、 (
~ )
で囲った部分は グループ と呼ばれ、後から個別に参照することができます。
どういうことかというと…
/^(今日|明日)の(.*)の天気を教えて$/
このような正規表現があったとき、
今日の名古屋の天気を教えて
明日の東京の天気を教えて
この2つの文章はどちらもマッチ4します。
しかし、 (今日|明日)
と (.*)
の部分はグループとして後から参照できる形になるため、
-
今日の名古屋の天気を教えて
- グループ1:
今日
- グループ2:
名古屋
- グループ1:
-
明日の東京の天気を教えて
- グループ1:
明日
- グループ2:
東京
- グループ1:
このようになります。
もうお気づきの方もいるかと思いますが、適切に正規表現でグループを作ることで、様々な入力に対して 目的の部分だけを切り取り 、日付や地名の検出をスムーズに行えるようになります。
特に今回は文章ということもあり、接続詞を固定することでどんな地名や名前が入力されても柔軟に対応することができます。例えば…
/^(?:(.*)から)?(?:(.*)(?:へ|まで))?の道のりを教えて$/
いくつか難しい記号が絡んできましたが、いくつか説明すると
-
(?:
~)
: グループ化するが、後から参照しないようにする -
(
~)?
: そのグループは無くてもマッチする
このような意味を持っています。なので、次のようなマッチの仕方をします。
-
名古屋から東京への道のりを教えて
- グループ1:
名古屋
- グループ2:
東京
- グループ1:
-
名古屋からの道のりを教えて
- グループ1:
名古屋
- グループ2: null
- グループ1:
-
東京までの道のりを教えて
- グループ1: null
- グループ2:
東京
ここまでくれば、グループ1を出発地点、グループ2を到着地点と捉えることができ、 もしどちらかが欠けていた場合は現在地を設定する といった挙動にすることができます。
正規表現だけでも、意外と自然な応答を作ることができます。意外と便利ですね。
そして2つ目。判定して処理を実行する部分。
// 正規表現で検索文字列をテスト、最初に引っかかったものを実行
const result = table.find(row => row.test.test(query));
result?.exec(result.test.exec(query));
配列の中身1つ1つのオブジェクトに対して、正規表現のチェックを行っています。今回は for
文でループするのではなく、配列に用意されている find
メソッドを使って1行で書いています。
find
メソッドは与えられた関数が true
を返すまで配列の頭からすべてチェックして、最初に true
になったときのオブジェクトを返します。今回は オブジェクトの test
プロパティの正規表現が入力された文章とマッチするか 、という関数を与えているため、最初にマッチしたオブジェクトが result
変数に入ります。
最後に result
の exec
プロパティを実行すれば、文章からどんな動きをすればよいか選んで処理してくれるひげおじちゃんの頭脳ができあがりました。人工知能一切なしなのに、とても自然な動きをします。
また今後いろいろな機能も増やしやすく、 kintone ならではのカスタマイズ性まで備えています。さすがひげおじちゃん。
この考え方は JavaScript だけではなく、関数をオブジェクトとして扱える言語であれば使うことができます。 test
プロパティを 条件によって真偽値を返す関数 にすれば、switch文の代わりやif文の代わりに使うこともできます。意外と便利ですので、ぜひみなさんも一度使ってみてください。
注意点として、 find
メソッドは、条件に合う配列の要素が1つも存在しなかった場合 null
を返すため、 TypeScript 上では result
の型は SearchFunc | null
となってしまいます。そのため、実行するとき単純に result.exec
と記述するとエラーが発生します5。
これを回避するため、 TypeScript 4.0.0 から導入された Optional Chaning 演算子を使って null チェックをスマートに書いています。
まあ、配列の最後に必ず一致する正規表現で処理を入れておけば問題はないのですが…。
天気や道のりやコーディネートの検索
今回、検索処理の実装にあたり Rakuten Rapid API というサービスを利用しました。これは API のマーケットプレイスのような感じで、 Rakuten Rapid API に登録すれば世界中の API を検索・使用することができるというサービスです。
わざわざサービスごとに契約し、わざわざサービスごとにアダプタを書き、わざわざサービスごとに違うログイン形態に対応し…という煩わしい作業が無くなり、スムーズに開発ができます。リファレンスの形もバラついてないのでわかりやすいです。
ひげおじちゃんの実装した機能の実現には、以下のサービスを利用させていただきました。
- tnql coords trial v2
- NAVITIME Route(totalnavi)
- Open Weather Map
- Google Maps Geocoding
使い方は非常に簡単で、
- まず Rakuten Rapid API にログインします
- 使いたい API を検索します
- 支払い用クレジットカードを登録して、APIの料金プランを選択して、ブラウザ上でテスト!
- あとは製品に組み込むだけ!
ね、かんたんでしょ?
あいまいな地名に対応する
今回使用させていただいた天気を取得する Open Weather Map の API は英語で作られていて、 実は日本語に対応していません。 例えば 名古屋
や 東京
なんか入力しても返してくれません。6
というわけで単純に考えると、ひげおじちゃんにお願いするとき Nagoyaの天気を教えて
と地名を英語で書かなくてはいけません。これはとても不便。いや、英語がわかるひげおじちゃんも凄いけど、違う、そうじゃない7。
悩んだ挙句、 地名から先に座標を割り出して、座標をもとに天気を取得する ことにしました。
こちらも Rakuten Rapid API に対応している Google Maps Geocoding という API を使って、地名から座標を引き当ててもらうことにしました。
Google Maps というだけあって、地名だけでなく建物のお店の名前でもしっかり検索することができます。完全一致じゃない曖昧な名前だって検索できるし、そこらへんの居酒屋だって検索できるので、急に飲み会を開催することになっても道に迷うことはありません。
こうすることで、 Open Wather Map に用意されている座標から天気を取得する API を叩くことができ、日本語でお願いしてもちゃんと対応してくれるようになります。
--しかし、残念ながらこれだけでは解決できない問題があります。そう、 現在地 です。
みなさん、実はひげおじちゃんに天気や道のりを教えてもらうとき、住所も一緒に表示されていることにお気づきでしょうか。
ひげおじちゃんに 現在地の天気を教えて
とお願いしようものなら、 Google Maps Geocoding に 現在地
という地名を検索しに行ってしまい、まったく意味の分からない住所が返ってきてしまうかもしれません。もしかしたら到着地点が 天国 なんてこともあり得るかもしれません。さすがひg
こんなことにはならない、大丈夫。大丈夫。実は Google Maps Geocoding には、地名から座標を求める 正引き に加え、座標から地名を求める 逆引き にも対応しています。
幸い、ブラウザから現在地を取得すると座標が返ってきますので、 API と掛け合わせて現在地の地名、住所を取得することができるようになります。地名であれば、どのくらい今いる場所と離れているか一発でわかりますもんね。
というわけで、以下のようなコードを実装しました。
async function getCoords(loc?: string) {
const isCurrentPosition = /^(|現在地|今((い|居)る|の)場所|ここ|ココ)$/.test(loc ?? '');
if (!isCurrentPosition) {
const urlQuery = [
`address=${encodeURIComponent(loc ?? '')}`,
`language=ja`,
].filter(q => q).join('&');
const uri = 'https://google-maps-geocoding.p.rapidapi.com/geocode/json' + (urlQuery ? `?${urlQuery}` : '');
const resp = await fetch(uri, {
method: 'GET',
headers: {
'x-rapidapi-host': 'google-maps-geocoding.p.rapidapi.com',
'x-rapidapi-key': /* API キーを入れる */
},
});
const respBody = await resp.json();
return {
lat: respBody?.results?.[0]?.geometry?.location?.lat as number | undefined,
lng: respBody?.results?.[0]?.geometry?.location?.lng as number | undefined,
address: respBody?.results?.[0]?.formatted_address as string,
};
} else {
const nowPosition = await new Promise<Position>(resolve => navigator.geolocation.getCurrentPosition(resolve));
const urlQuery = [
`latlng=${encodeURIComponent(`${nowPosition.coords.latitude},${nowPosition.coords.longitude}`)}`,
`language=ja`,
].filter(q => q).join('&');
const uri = 'https://google-maps-geocoding.p.rapidapi.com/geocode/json' + (urlQuery ? `?${urlQuery}` : '');
const resp = await fetch(uri, {
method: 'GET',
headers: {
'x-rapidapi-host': 'google-maps-geocoding.p.rapidapi.com',
'x-rapidapi-key': /* API キーを入れる */
},
});
const respBody = await resp.json();
return {
lat: nowPosition.coords.latitude,
lng: nowPosition.coords.longitude,
address: respBody?.results?.[0]?.formatted_address as string,
};
}
}
このコードのおかげで、ひげおじちゃんに 今の場所の天気を教えて
とか ウィルビジョンからここまでの道のりを教えて
という ざっくりした お願いでも聞いてもらえるようになりました。
おわりに
まだまだ話し足りない気もしますが、気が付いたらスクロールバーがめちゃくちゃ小さくなっていたので、今回はこのくらいにしようと思います。
実のところ、ひげおじちゃんのプログラムを実際に書き始めたのは kintone hack 予選会の1週間前 でした。最後まで何を発表するか決まらず、もうこれ以上は後に押せない、というところからのスタートでした。
だから少しでも作り直しがあったりイメージとズレてしまったりしたら取り返しがつかなくなってしまうので、本当に真剣に、堅実に、そしてスピーディーに作る必要がありました。
結果、こだわりがものすごく強いひげおじちゃんが出来上がりました笑
今回はご紹介しなかった通知のカスタマイズについてもそうですが、予選会で発表するまで ここまでやって怒られるんじゃないか…
と不安になっていました。
でも実際、いろんな方に「面白かった!」と言っていただけて、本当にうれしかったです。 kintone って何でもできる面白い製品だし、 kintone 界隈って何でも楽しんでくださる温かい人たちばかりだなって、つくづく思いました。
kintone カスタマイズって本当に楽しいです。みなさんもぜひ、最強の kintone をその手で!作ってみてください!
ありがとうございました!
-
当日のレポートはこちらから見ることができます: https://ascii.jp/elem/000/004/033/4033794/ ↩
-
取得先のAPIが今日と明日しかないので今のところその2通りですね… ↩
-
実は
優勝したよ
と報告するともっとすごい花火を打ち上げてくれたりします。さすがひげおじちゃん。 ↩ -
.*
は任意の長さのすべての文字にマッチする表現なので、何が来てもマッチします ↩ -
TypeScript は実行時にもしかしたらエラーが発生するかもしれないところを事前に警告してくれる。 ↩
-
Rakuten Rapid API の画面で試すとわかりますが、リクエストは成功するけど配列が空のまま返ってきます… ↩
-
https://www.amazon.co.jp/%E9%81%95%E3%81%86%E3%80%81%E3%81%9D%E3%81%86%E3%81%98%E3%82%83%E3%81%AA%E3%81%84/dp/B00FVH4Z8K ↩