kintoneとGoogle感情認識APIを連携して社員のHPを見える化してみた

こちらは [kintone Advent Calendar 2019](https://qiita.com/advent-calendar/2019/kintone) 25日目の記事です。

皆さんこんばんは。RPGより音ゲーのほうが好き、株式会社ウィルビジョンの住田でございます。

ちょっと前のことになりますが、11月に幕張メッセで行われた Cybozu Days 2019 Tokyo の kintone hack に出場させていただきました。

そこでは kintone を使った **社内RPG** というシステムを作ってご紹介したのですが、今回はその中で触れた **kintone × GCP Natural Language** の連携について、技術者目線でご紹介したいと思います。

正直、記事[^1]を書くのは初めてなので、つたない文章だとは思いますが、よろしくお願いいたします。

[^1]: あっ、正確には 「Qiita のアドベントカレンダーの最終日の大トリの記事」 は初めてです

え、今何時？ちょっと私時計読めないもので・・・
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/26ccae86-bcdb-dd89-7c43-59c2eee7ac9a.png)



# 社内RPGとは？

kintone hack ご紹介した **社内RPG** というのは、kintone を使う社員の体力 (HP) をリアルタイムで可視化してみよう！という試みです。

たとえば…

- 朝は体力が160あったとしても、30分ごとに10ダメージを受けたら8時間後には0になってしまう
- 定時退社して16時間しっかり休めば200回復して翌朝気分が良いけど、終電まで仕事をして翌朝出勤すると100しか回復しなくてつらい

というように、朝はしっかり回復して、仕事をこなすごとに体力が減っていくので、社員がどのくらい疲れているか一目瞭然となるわけです。

そして、 GCP Natural Language を使ったのは kintone の **コミュニケーション機能** すなわち **コメント[^2]** での体力を増減。

[^2]: [レコードにコメントを書き込む - kintone ヘルプ](https://jp.cybozu.help/k/ja/user/app_collectdata/record_comment.html)

- コメントできつ～い言葉をかけられると、それだけでやる気が霧散する → *ダメージを受ける*
- コメントで褒めてもらったり認めてもらったりしたら、やる気が出てくる → *回復する*

こうしてコメントが投稿されたときにリアルタイムで HP を増減することで、より社内のコミュニケーションが活発に明るくなるのでは！と考えました。

~~まあ、ただ感情認識をやってみたかっただけですが…~~


# どうやって？

今回は投稿されたコメントの本文を [GCP Natural Language](https://cloud.google.com/natural-language/) で解析することで、そのコメントが回復なのかダメージなのかを判断しています。

しかし単純に連携させようとしても一筋縄では行きません。 kintone のカスタマイズをしている方であれば気が付く方も多いと思いますが、 kintone には「 **コメントを投稿したとき** 」という **イベントは存在しません。[^3]** 

[^3]: [kintone JavaScript API一覧 – cybozu developer network](https://developer.cybozu.io/hc/ja/articles/360000361686)

なので今回は、「コメントが投稿された」という情報がリアルタイムで取得できる **Webhook** という機能を使って、 HP の増減を行うようにしてみました。


## Webhook とは

- 参考: [Webhookを設定する - kintone ヘルプ](https://jp.cybozu.help/k/ja/user/app_settings/set_webhook/webhook.html)

kintone からあるタイミングで外部サービスに通知を送信できる機能です。

たとえば、レコード登録時に [Microsoft Power Automate](https://flow.microsoft.com/ja-jp/) に Webhook を飛ばす設定にしておくと、そこからフローを実行して [Slack](https://slack.com/intl/ja-jp/) に通知したりメールを送信したりできます。

この機能を使えば、 JavaScript カスタマイズでなくともレコードが登録されたことが検出できたり、またイベントにはない「コメントが投稿されたとき」という条件もとることができます。

ただし、 Webhook はメールやスマホのプッシュ通知と違って、インターネット上の外部サービスに向けてしか発することができません。すなわち、外部サービスを自分で作る必要があるのです。


# 外部サービスを作る

というわけで、 Webhook を送る先の外部サービスを作っていきましょう。外部サービスと言えば聞こえがいいですが、単純に AWS Lambda でサーバを立てるだけです。

ここでは AWS の開始方法やサービスのセットアップ方法などは本筋から逸れるので割愛しますが、ざっくり AWS 内の構成をご紹介します。

![ざっくり構成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/e94c6692-6118-99f0-e3d2-46bb7ba2ba6a.png)

あ、しっかりした構成を期待した方ごめんなさい。私 AWS に関しては初心者…赤ちゃんです。

kintone からの Webhook を API Gateway で受け止め、 Lambda で処理するといった感じです。背後にある CloudWatch は、後述のタイマー処理のために使っています。


# いざ、プログラミング

さて、ここまで来たらががっと作っていきます。これからサンプルソースを載せていきたいと思います。

ただしいくつか注意点がありまして…

- サンプルソース内にはところどころ通信のメソッドが含まれていますが、独自の処理になっているため最後のほうにソースを載せておきます。できるだけわかりやすくは作っていますが、もしわからない場合は見比べながら見ていってください。
- もともとのソースから一部改変しているところがあるのですが、 **テストしてないので動くかどうか保証できません** …。参考程度にお願いします。今後時間があるときに細かくテストして直します。


## Webhook のメッセージ構造を確認する

そもそも Webhook でどんなデータが送られてくるかわからないことには、開発のしようがないですよね。

というわけで、まずはログを吐くところから始めようと思います。

AWS Lambda に次のようなソースを書いていきます。


```js:/index.js
const kintone = require('./wv/wv.kintone').kintone;

const _domain = '<hogehoge>';

exports.handler = async event => {
	kintone.config.setSubDomain(_domain);

	// ログ書き込み
	const log = await kintone.postApi(
		{
			app: '<ログアプリのID>',
			record: {
				CONTENT: { value: JSON.stringify(event), },
			},
		},
		{
			apiToken: '<fugafuga>',
		}
	);

	return { statusCode: 200, };
};
```

続いて、 Lambda を起動する API Gateway を作成して、コメントを解析したい kintone アプリの設定で Webhook 送信先に設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/6e14c22d-9615-ba57-fb33-d9f1a218379f.png)
これでログアプリに Webhook のリクエスト本文が書き込まれることになりました。さて、見てみましょう。
![コメント画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/f3beaed5-5522-5700-1802-1901bd1d8351.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/2942ab01-8c88-9755-78a5-f66d2676b250.png)
これが kintone から通知される Webhook の内容です。

誰がどのアプリに、誰に向かってどんなコメントしたか Webhook の情報から取り出せそうですね。


## コメントのダメージ量を評価してみる

さてコメントの本文が渡ってきたので、ここから感情を解析してダメージ量に換算していきます。

Google Cloud Platform に登録して Natural Language API を有効にしたら、 Lambda 関数を書き換えてリクエストを投げるようにしてみましょう。

```js:/index.js
const kintone = require('./wv/wv.kintone').kintone;
const Logic = require('./logic');

const _domain = '<hogehoge>';

exports.handler = async event => {

	kintone.config.setSubDomain(_domain);

	// ログ書き込み
	const log = await kintone.postApi(
		{
			app: '<ログアプリのID>',
			record: {
				CONTENT: { value: JSON.stringify(event), },
			},
		},
		{
			apiToken: '<fugafuga>',
		}
	);

	await webhookHandler(); // 追加

	return { statusCode: 200, };
};

async function webhookHandler(event) {
	let obj = {};
	if (event.type === 'ADD_RECORD_COMMENT') {
		obj = await Logic.commentAnalyze(event);
	}
}
```

```js:/logic.js
const GcpNaturalLanguage = require('./services/naturallanguage');

async function commentAnalyze(event) {

	const mentions = event.comment.mentions;
	const creator = event.comment.creator;

	const directMentions = mentions.filter(mention => mention.type === 'USER');

	const rawScores = [];
	for (let i = 0; i < directMentions.length; i++) {
		rawScores.push({
			code: directmentions[i].code,
			rawScore: await GcpNaturalLanguage.analyzeSentiment(event.comment.text),
		});
	}

	// Google から帰ってきた評価値をまとめて CloudWatch に出力
	console.log(rawScores);
}

exports.commentAnalyze = commentAnalyze;
```

```js:/services/naturallanguage.js
/** @type {import('@google-cloud/language/src/v1')} */
const language = require('@google-cloud/language');

const client = new language.LanguageServiceClient();

async function analyzeSentiment(content) {
	const [ result, ] = await client.analyzeSentiment({
		document: {
			content: content,
			type: 'PLAIN_TEXT',
		},
	});

	const sentiment = result.documentSentiment;
	return sentiment.score;
}

exports.analyzeSentiment = analyzeSentiment;
```

Google Cloud Platform に Lambda からアクセスできるようにするには、サービスアカウントのトークンを JSON にして Lambda に登録する必要があります。

サービスアカウントを作ったら[^4]、キーを作成して JSON をソースと一緒にアップロードして、そのファイルパスを環境変数に定義します。

[^4]: [サービス アカウントキーの作成と管理 | Cloud IAM のドキュメント | Google Cloud](https://cloud.google.com/iam/docs/creating-managing-service-account-keys?hl=ja)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/c1b5fcb6-3b80-f16f-ee95-8812984881be.png)
ここまで来てようやく、Googleからのレスポンスを取得できるようになりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/183fee4a-07c6-4878-eb11-2aa240484a84.png)


## 社員マスタの HP を増減する

さて最後は、この数値を kintone に書き戻します。社員マスタに HP を持たせる構造にしたほうが楽なので、まずはアプリに HP のフィールドを作成します。

そして Lambda の関数を書き換えます。コメントでのダメージ量は -10～10 くらいにしたいと思います。Google から帰ってくる値は -1～1 の範囲のようなので、つまり10倍ですね。

というわけで、書き込み処理をつけ足します。

```js:/logic.js
const Employee = require('./apps/employee'); // アプリ情報
const GcpNaturalLanguage = require('./services/naturallanguage');

async function commentAnalyze(event) {
	const weight = 10;

	const mentions = event.comment.mentions;
	const creator = event.comment.creator;

	const directMentions = mentions.filter(mention => mention.type === 'USER');

	const rawScores = [];
	for (let i = 0; i < directMentions.length; i++) {
		rawScores.push({
			code: directmentions[i].code,
			rawScore: await GcpNaturalLanguage.analyzeSentiment(event.comment.text),
		});
	}

	// Google から帰ってきた評価値をまとめて CloudWatch に出力
	console.log(rawScores);

	const scores = rawScores
		.map(score => ({
			code: score.code,
			rawScore: score.rawScore,
			score: parseInt(score.rawScore * weight),
			isDamage: score.rawScore < 0,
		}))
		.filter(score => score.score !== 0);

	const employees = await Employee.getEmployeeList(param.map(param => param.usercode)).records;

	await kintone.putApiRecords({
		app: '<社員マスタのID>',
		records: scores.map(score => {
			const employeeRec = employees.find(employee => employee['ユーザー選択'].value[0].code === score.usercode);
			return {
				id: employeeRec .$id.value,
				record: {
					HP: { value: parseInt(employeeRec.HP.value, 10) + score.hp, },
				},
			};
		}),
	});
}

exports.commentAnalyze = commentAnalyze;
```

あとは、やってみるだけですね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/39daf711-8416-3992-ee53-53bb01737602.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/0bcd8fc3-a454-fd1c-8b48-dfe60c0453b7.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/15a6e0e3-3449-62d9-5e64-1a682842ad57.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/a7d59c4a-9b37-1e03-e31a-c01ffc59848e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529493/8bb1d667-8db8-66a0-facf-cf0f7d075588.png)

~~顔文字って意外にダメージにならないっぽい…~~


# 感想

かねてからコメントやスレッドを使ったカスタマイズができたらもっと kintone って広がっていくのになぁとつくづく感じていて、今回は kintone **hack** ということもあり、そこに本気で挑戦させていただきました。

結果的に AWS Lambda でなんとかしてしまう、という kintone 関係ない感じになってしまいましたが、もし今後このようなカスタマイズができる API や機能が実装されれば、もっと kintone の価値も知名度も上がっていくのではないかな、と感じました。

来年ももし kintone hack があるなら、またコミュニケーション機能を使ったカスタマイズができるといいなぁ。うーんネタがないか。誰か良いネタをお持ちでしたら教えてください。

とてもつたない文章になってしまいましたが、今回はここまでにしたいと思います。

皆様、よいクリスマスをお過ごしください。


# 追記

今これを書いているのが 12月 **25日 15時50分** ということで絶賛遅刻気味です。本当に申し訳ありません。 ~~まだ遅刻してないから大丈夫だと思ってます。~~

思いのほか、やっていることやコードの説明が難しかったなぁと感じました。本当はもうちょっとサラッと説明するだけのはずだったのですが…。

検証する時間もなく、暫定版として出してしまうことをどうぞご容赦ください。


- - -

# [補足] kintone 通信ライブラリ

```js:/wv/wv.kintone.js
const request = require('request');
let _subdomain = undefined;
let _domain = 'cybozu.com';

exports.kintone = {};
exports.kintone.config = {};

/**
 * Configure target subdomain
 * @param {String} subdomain Subdomain string
 */
exports.kintone.config.setSubDomain = function (subdomain) {
	if (typeof subdomain !== 'string') {
		throw new TypeError('Subdomain must be "string" type.');
	}
	_subdomain = subdomain;
};

exports.kintone.config.setDomain = function (domain) {
	if (typeof domain !== 'string') {
		throw new TypeError('Domain must be "string" type.');
	}
	_domain = domain;
};

exports.kintone.config.getHost = function () {
	return _subdomain + '.' + _domain;
};

/**
 * @typedef {Object} Field
 * @property {String} type
 * @property {amy}    value
 */

/**
 * @typedef {Object} Record
 * @property {Field} $id
 * @property {Field} $revision
 */

/**
 * @typedef {Object} KintoneGetApiResponse
 * @property {Record[]} records
 * @property {String}   totalCount
 */

/**
 * kintone GET API with large query (POST overrided GET)
 * @param  {Number}  appid         Application Id
 * @param  {String}  query         Search Query
 * @param  {Object}  auth          Authorization info
 * @param  {String?} auth.apiToken API Token
 * @param  {String?} auth.password User and Password (BASE64 Encoded)
 * @param  {String?} auth.basic    Basic Auth, User and Password (BASE64 Encoded)
 * @return {Promise<KintoneGetApiResponse>}       Response
 */
exports.kintone.getApiLarge = function (appid, query, auth) {

	console.groupCollapsed('wv.kintone.getApiLarge');
	console.log('appid:', appid);
	console.log('query:', query);
	console.trace();
	console.groupEnd();

	return new Promise(function (resolve, reject) {

		// Create header
		const headers = {};

		// Authorization
		if (auth) {
			if (auth.apiToken) { headers['X-Cybozu-API-Token'] = auth.apiToken; }
			if (auth.password) { headers['X-Cybozu-Authorization'] = auth.password; }
			if (auth.basic) { headers['Authorization'] = 'Basic ' + auth.basic; }
		}

		// General header
		headers['Content-Type'] = 'application/json';
		headers['X-HTTP-Method-Override'] = 'GET';

		// Set URL
		const options = {
			url: _kintoneObj.api.url('/k/v1/records'),
			method: 'POST',
			headers: headers,
			json: {
				app: appid,
				query: query,
				totalCount: true,
			},
		};

		// Send request
		request(options, (error, res) => {
			if (!error) {
				resolve(res.body);
			} else {
				reject(error);
			}
		});
	});
};

/**
 * kintone GET API (All records)
 * @param  {Number}  appid         Application Id
 * @param  {String}  query         Search Query (DO NOT included "limit" and "offset")
 * @param  {Object}  auth          Authorization info
 * @param  {String?} auth.apiToken API Token
 * @param  {String?} auth.password User and Password (BASE64 Encoded)
 * @param  {String?} auth.basic    Basic Auth, User and Password (BASE64 Encoded)
 * @return {Promise<KintoneGetApiResponse>}       Response
 */
exports.kintone.getApiAll = function (appid, query, auth) {
	const array = { records: [], totalCount: undefined, };

	return (function invokeRequest(offset) {
		const limit = 500;
		const requestQuery = query + ' limit ' + limit + ' offset ' + offset;
		return exports.kintone.getApiLarge(appid, requestQuery, auth)
			.then(function (value) {
				if (!value.totalCount) { return value; }

				array.records = array.records.concat(value.records);
				array.totalCount = value.totalCount;

				return offset + limit < parseInt(value.totalCount) ? invokeRequest(offset + limit) : array;
			});
	})(0);
};

/**
 * kintone POST API
 * @param  {Object}        param         Payload
 * @param  {Number|String} param.app     App ID
 * @param  {Object}        param.record  Record object
 * @param  {Object}        auth          Authorization info
 * @param  {String?}       auth.apiToken API Token
 * @param  {String?}       auth.password User and Password (BASE64 Encoded)
 * @param  {String?}       auth.basic    Basic Auth, User and Password (BASE64 Encoded)
 * @return {Promise}                     Response
 */
exports.kintone.postApi = function (param, auth) {

	console.groupCollapsed('wv.kintone.postApi');
	console.log('param:', param);
	console.trace();
	console.groupEnd();

	return createRequestPromise('POST', _kintoneObj.api.url('/k/v1/record'), param, auth);
};

/**
 * kintone PUT API Multiple
 * @param  {Object}          param                         Payload
 * @param  {Number|String}   param.app                     App ID
 * @param  {Object[]}        param.records                 Record object array
 * @param  {Number?|String?} param.records.id              Record ID
 * @param  {Object}          param.records.updateKey       Unique key of the record
 * @param  {String}          param.records.updateKey.field Field code of unique key
 * @param  {String}          param.records.updateKey.value Field value of unique key
 * @param  {Object}          param.records.record          Record object
 * @param  {Number?|String?} param.records.revision        Revision number
 * @param  {Object}          auth                          Authorization info
 * @param  {String?}         auth.apiToken                 API Token
 * @param  {String?}         auth.password                 User and Password (BASE64 Encoded)
 * @param  {String?}         auth.basic                    Basic Auth, User and Password (BASE64 Encoded)
 * @return {Promise}                                       Response
 */
exports.kintone.putApiRecords = function (param, auth) {

	console.groupCollapsed('wv.kintone.putApiRecords');
	console.log('param:', param);
	console.trace();
	console.groupEnd();

	return createRequestPromise('PUT', _kintoneObj.api.url('/k/v1/records'), param, auth);
};

function createRequestPromiseNoParam(method, appUrl, auth) {
	return new Promise(function (resolve, reject) {
		// Set URL
		const options = {
			url: appUrl,
			method: method,
			headers: (() => {
				const obj = {};
				// Authorization
				if (auth) {
					if (auth.apiToken) { obj['X-Cybozu-API-Token'] = auth.apiToken; }
					if (auth.password) { obj['X-Cybozu-Authorization'] = auth.password; }
					if (auth.basic) { obj['Authorization'] = 'Basic ' + auth.basic; }
				}
				obj['Content-Type'] = 'application/json';
				return obj;
			})(),
		};

		request(options, (error, res) => {
			if (!error) {
				resolve(res.body);
			} else {
				reject(error);
			}
		});
	});
}


```