はじめに
勤務時間がシフトで決まっている業務に携わっている方(アルバイトなども含みます)、自分のシフトの時間管理はどうしているでしょうか?
働く世代にはほぼスマホが1人1台ずつ行き渡っている現在では、カレンダーアプリなどを使って手動で登録している方や、あえて紙の手帳などに記入している方も多いと思います。
この記事では、自分のシフトの予定を Google カレンダーに自動登録してくれる LINE のチャットボットを個人使用目的で開発します。バックエンドには IBM Cloud Functions を使用します。
用意するもの
- LINE アカウント
- IBM Cloud アカウント (無料の範囲内で作成できます)
- Google アカウント (Gmail のメールアドレスがあれば OK)
- アルバイトのシフト表や習い事のスケジュール表(全部または一部が Google カレンダーに登録してあるとします)
【注意点】今回作成するアプリ(LINE チャットボット)は、自分または家族などに割り当てられたアルバイトのシフトや習い事のスケジュールを管理するための、個人での使用を目的としています。複数人それぞれの Google カレンダーでのスケジュール管理や確認は想定していませんので、あらかじめご了承ください。
作成手順
本記事では次のように作成手順を説明していきます。
- データフローを確認する
- シフト表をデータ化する
- Google Cloud Platform でサービスアカウントを作成する
- シフト管理に使う Google カレンダーをサービスアカウントでアクセスできるように共有設定する
- IBM Cloud Functions で機能を実装する
- LINE Messaging API を登録し、バックエンドを指定する
そして、最後に LINE のチャット画面で動作確認をします。
データフロー
まずは、今回作成するシステムのデータフローを簡略化して説明します。
この図において、データの流れは以下のようになります。
- ユーザーが LINE アプリでチャットボットに対してスケジュール管理の指示を出す
- LINE Messaging API が、IBM Cloud Functions エンドポイントへ Webhook リクエストを送信する
- IBM Cloud Functions が指示コマンドを基に Google Calendar API でイベントを操作する
- Google Calendar API がイベントを追加・修正・削除する
- IBM Cloud Functions が処理結果メッセージを LINE Messaging API へ返答する
- LINE Messaging API が、LINE アプリ上でユーザーへ処理結果メッセージを返答する
シフト表をデータ化する
まずは、上記のステップ3で、Google Calendar API を用いてイベントを追加したりするときの元データとなる、シフトのマスターデータを作成します。
シフト表にはさまざまな形式があるため、ここでは簡略化して、以下の例に示す表の内容を CSV として用意し、そのあと、JSON へ変換します。
- 勤務シフトは4通りあり、1->2->3->4->1->2->3->4 と順番にローテーションしていく
- 各シフトには平日版と土休日版がある(計8通り)
- シフトの中に「公休」が1つある(平日公休と土休日公休)
- 勤務シフトには1つまたは複数の業務が含まれる
※例のための仮想のシフトですので、法定基準を満たしている等の議論はここでは扱いません。
表の前半の 平日が 1 となっているのが平日版、0 となっているのが土休日版のシフトです。
番号 | 業務 | 開始時刻 | 終了時刻 | 平日 | 公休 |
---|---|---|---|---|---|
1 | A | 09:30:00 | 12:00:00 | 1 | 0 |
1 | B | 13:00:00 | 15:00:00 | 1 | 0 |
1 | C | 15:30:00 | 17:00:00 | 1 | 0 |
... | ... | ... | ... | ... | ... |
4 | 公休 | 1 | 1 | ||
1 | A | 08:30:00 | 11:00:00 | 0 | 0 |
1 | B | 12:00:00 | 14:00:00 | 0 | 0 |
1 | C | 14:30:00 | 16:00:00 | 0 | 0 |
... | ... | ... | ... | ... | ... |
4 | 公休 | 0 | 1 |
番号,業務,開始時刻,終了時刻,平日,公休
1,A,09:30:00,12:00:00,1,0
1,B,13:00:00,15:00:00,1,0
1,C,15:30:00,17:00:00,1,0
...
4,公休,,,1,1
1,A,08:30:00,11:00:00,0,0
1,B,12:00:00,14:00:00,0,0
1,C,14:30:00,16:00:00,0,0
...
4,公休,,,0,1
CSV 化したシフトのデータを、扱いやすいように JSON に変換します。
import csv
import json
import sys
with open(sys.argv[1], 'r') as f:
reader = csv.reader(f, delimiter=',')
data_list = [row for row in reader]
data = [dict(zip(data_list[0],row)) for row in data_list]
data.pop(0)
s = json.dumps(data,ensure_ascii=False)
print (s)
実行するコマンドは以下のようになります。
% python convert.py data.csv > data.json
これにより、このような JSON データが生成されます。
[{"番号": "1", "業務": "A", "開始時刻": "09:30:00", "終了時刻": "12:00:00", "平日": "1", "公休": "0"}, ...}]
Google Cloud Platform でサービスアカウントを作成する
まずは、Google Cloud Platform でプロジェクトを作ります。
さらに、こちらの記事などを参考に、Google Calendar API 有効化とサービスアカウント作成を行います。
作成したサービスアカウントの秘密鍵を JSON 形式でダウンロードします。
サービスアカウントの秘密鍵を JSON 形式でエクスポートして保存します。元のファイル名では <プロジェクトID>_<数字のID>.json
のようになっていますが、以下ではこれを service-account.json
という名前で参照します。
さらに、実際にシフト管理に使用する Google カレンダーの設定にてこのサービスアカウントのメールアドレス(<サービスアカウント名>@<プロジェクトID>.iam.googleserviceaccount.com
) を共有の相手として追加します。
カレンダーIDの確認
使用する Google Calendar の ID は、次のように確認します。
- カレンダーの画面の左にリストされている マイカレンダー から、対象のカレンダーを見つける
- そのカレンダーにマウスを合わせると現れる縦3点リーダー「︙」のオーバーフローメニューから設定と共有を選ぶ
- カレンダーの設定 画面で、カレンダーの統合セクションまでスクロールしていき、カレンダーID
abcde...@group.calendar.google.com
を選択してコピーする
このカレンダーIDを、後述する app.js
の中で CALENDAR_ID
にセットします。
さらに、シフト管理に使う Google カレンダーをサービスアカウントでアクセスできるように共有設定します。
- さきほどの設定と共有の画面で、特定のユーザーとの共有セクションに移動する
- +ユーザーを追加ボタンを押して、入力画面を出す
- メールアドレスに、サービスアカウントのemailアドレスを入れる
- 権限は予定の変更をセットし、送信ボタンを押す
一覧に、自身の Gmail アドレスのほかに、サービスアカウントの email アドレスが追加されたことを確認します。
IBM Cloud Functions で機能を実装する
IBM Cloud Functions で実装する機能としては、以下のようになります。
- Alexa スキルで解釈された、日付を含む発話内容1を受け取る
- 日付から Google カレンダーを検索して該当する日のシフト内容を取得する
- シフト内容から、開始時刻と終了時刻を含むメッセージを作成する
- 作成したメッセージを、Alexa に送り返す
IBM Cloud Functions へアップロードする形式としては、Node.js アプリで必要なファイル一式を ZIP で固めたものになります。Web 上のエディタでは作成できない形式です。
アップロードするための ZIP (myapp.zip
とします) に含まれるファイルの一覧はこちらです。
ファイル名 | 目的 |
---|---|
app.js | アプリのメインファイル |
package.json | Node.js |
data.json | シフトのマスターデータ |
service-account.json | サービスアカウント資格情報ファイル |
node_modules/ | Node.js 依存関係モジュール(ディレクトリ) |
app.js
アプリのメインファイル app.js
の内容は以下のようになります。個々の関数の説明は省略しますが、各関数の冒頭のコメントで補足しています。
const line = require('@line/bot-sdk');
const { google } = require('googleapis');
const fs = require('fs').promises;
const path = require('path');
const CALENDAR_ID = '...@group.calendar.google.com';
const CALENDAR_JP_HOLIDAY_ID = 'ja.japanese#holiday@group.v.calendar.google.com';
const TZ = 'Asia/Tokyo';
const SERVICE_ACCOUNT_FILE = 'service-account.json';
const LINE_CONFIG = {
channelAccessToken: "...",
channelSecret: "..."
};
const client = new line.Client(LINE_CONFIG);
const SCOPES = ['https://www.googleapis.com/auth/calendar'];
const calendar = google.calendar('v3');
// サービスアカウントによる Google API 認証
const authenticate = async () => {
const keyPath = path.join(__dirname, SERVICE_ACCOUNT_FILE);
if (!!(await fs.lstat(keyPath))) {
try {
const auth = new google.auth.GoogleAuth({
keyFile: keyPath,
scopes: SCOPES,
});
return await auth.getClient();
} catch (err) {
console.error(err);
}
}
};
// シフトのマスターデータをロードして、平日と土休日のリストをつくる
const load_master = async () => {
let dutyData = {};
let content;
try {
content = await fs.readFile('data.json',);
} catch (err) {
console.error('Error loading data file: ' + err);
}
if (content) {
const masterdata = JSON.parse(content);
for (let element of masterdata) {
if (dutyData[element['番号']] == undefined) {
dutyData[element['番号']] = {};
dutyData[element['番号']]['平日'] = [];
dutyData[element['番号']]['土休日'] = [];
}
const data = {
'番号': element['番号'],
'業務': element['業務'],
'開始時刻': element['開始時刻'],
'終了時刻': element['終了時刻'],
'平日': element['平日'] === "1",
'公休': element['公休'] === "1"
};
if (data['平日']) {
dutyData[element['番号']]['平日'].push(data);
} else {
dutyData[element['番号']]['土休日'].push(data);
}
}
}
return dutyData;
};
// API の連続リクエストを緩和するための sleep
const sleep = async (time) => {
await new Promise(resolve => setTimeout(resolve, time));
};
// 日本の祝日リストを Google カレンダーから取得
const getJPHolidays = async (auth, startdate, enddate, rot) => {
const list = []
const events = await listEvents(auth, CALENDAR_JP_HOLIDAY_ID, startdate, enddate);
events.forEach((event) => {
list.push(event.start.dateTime || event.start.date);
});
return {
startdate,
enddate,
rot,
holidayList: list,
};
};
// 指定した期間内の各日がそれぞれ平日か土休日かの判定
const genWeekdayList = async (data) => {
const d = data.startdate;
const res = {};
const weekdayList = {};
while (d < data.enddate) {
const d_date = formatDate(d);
// Rule 1: if holiday?
let weekday = (data.holidayList.indexOf(d_date) == -1);
// Rule 2: Saturday or Sunday
if (weekday) {
const dday = d.getDay();
weekday = !(dday == 0 || dday == 6);
}
// Rule 3: End of Year 12/30 and 12/31
if (weekday) {
const ddate = d.getDate();
weekday = !(d.getMonth()==11 && (ddate==30||ddate==31));
}
// Rule 4: Begin of Year 1/1 thru 1/3
if (weekday) {
const ddate = d.getDate();
weekday = !(d.getMonth()==0 && (ddate==1||ddate==2||ddate==3));
}
weekdayList[d_date] = weekday;
d.setDate(d.getDate() + 1);
}
return {
weekdayList,
rot: data.rot,
};
};
const attachDayRot = async (data) => {
const res = {};
let currot = data.rot;
const dutyData = await load_master();
Object.keys(data.weekdayList).forEach((date) => {
let rotdata = dutyData[currot];
if (!rotdata) {
currot = '1';
rotdata = dutyData[currot];
}
res[date] = (data.weekdayList[date]) ? (rotdata['平日']) : (rotdata['土休日']);
currot = String(parseInt(currot) + 1);
});
return res;
};
const formatDate = (date) => {
let res = "";
res += date.getFullYear() + '';
res += "-";
res += ('0' + (date.getMonth() + 1)).slice(-2);
res += "-";
res += ('0' + date.getDate()).slice(-2);
return res;
}
// 指定した期間のシフト情報を Google カレンダーのデータに変換
const convertToGCalEvents = async (data) => {
const gcevents = [];
Object.keys(data).forEach((date) => {
const rotlist = data[date];
rotlist.forEach((elem) => {
const summary = elem['業務'] + " #" + elem['番号'];
const description = "番号:" + elem['番号'] + ", 業務:" + elem['業務'];
const event = {
summary: summary,
description: description,
start: {
timeZone: TZ,
},
end: {
timeZone: TZ,
},
};
let starttime = "";
let endtime = "";
if (elem['公休']) {
const str = date.replace(/-/g, "/");
const st = new Date(str);
const et = new Date(str);
et.setDate(et.getDate() + 1);
starttime = formatDate(st);
endtime = formatDate(et);
event.start.date = starttime;
event.end.date = endtime;
} else {
starttime = date + "T" + elem['開始時刻'] + "+09:00";
endtime = date + "T" + elem['終了時刻'] + "+09:00";
event.start.dateTime = starttime;
event.end.dateTime = endtime;
}
gcevents.push(event);
});
});
return gcevents;
};
// Google カレンダーにシフトを追加
const insertEvents = async (auth, startdate, enddate, rot, days) => {
const command = "新規"
const resmsg = command + ": " + startdate.toLocaleDateString() + " の " + rot + " 番から " + days + " 日分追加しました。";
let data = await getJPHolidays(auth, startdate, enddate, rot);
data = await genWeekdayList(data);
data = await attachDayRot(data);
const gcevents = await convertToGCalEvents(data);
for (let event of gcevents) {
await sleep(250);
try {
const response = await calendar.events.insert({
auth: auth,
calendarId: CALENDAR_ID,
resource: event
});
console.log(response.status, response.data.id);
} catch (err) {
console.log('insertEvents: The API returned an error: ' + err);
}
}
return resmsg;
};
// Google カレンダーでシフトの更新
const updateEvent = async (auth, startdate, enddate, rot, days) => {
const command = "変更"
const resmsg = command + ": " + startdate.toLocaleDateString() + " を " + rot + " 番に変更しました。";
try {
const response1 = await deleteEvents(auth, startdate, enddate);
const response2 = await insertEvents(auth, startdate, enddate, rot, days);
return resmsg;
} catch(error){
console.log(error);
return;
}
};
// Google カレンダーで、指定したカレンダーIDと期間のイベントを取得
const listEvents = async (auth, calendar_id, startdate, enddate) => {
try {
const response = await calendar.events.list({
auth: auth,
calendarId: calendar_id,
timeMin: startdate.toISOString(),
timeMax: enddate.toISOString(),
maxResults: 1000,
singleEvents: true,
orderBy: 'startTime',
timeZone: TZ,
});
return response.data.items;
} catch (err) {
console.error('listEvents: the API returned an error: ' + err);
return;
}
};
// Google カレンダーで1件ずつイベントを削除
const deleteOneEvent = async (auth, calendar_id, event) => {
if (event.description!=null && event.description.indexOf('番号')!=-1) {
try {
const response2 = await calendar.events.delete({
auth: auth,
calendarId: calendar_id,
eventId: event.id
});
return response2;
} catch (err2) {
console.log('deleteOneEvent: The API returned an error: ' + err2);
return;
}
}
};
// Google カレンダーで複数のイベントを削除
const deleteEvents = async (auth, startdate, enddate, days) => {
const command = "削除"
const resmsg = command + ": " + startdate.toLocaleDateString() + " から " + days + " 日分削除しました。";
let events = {};
try {
events = await listEvents(auth, CALENDAR_ID, startdate, enddate);
console.log('Deleting ' + events.length + ' events:');
} catch (err) {
console.error(err);
}
for (let event of events) {
await sleep(1000);
try {
const res = await deleteOneEvent(auth, CALENDAR_ID, event);
console.log(res.status, event.id);
} catch (err) {
console.error(err);
};
}
return resmsg;
};
// コマンドに応じたシフト操作の振り分け
const doOperation = async (auth, command, date, arg1, arg2) => {
console.log("doOperation", command, date, arg1, arg2);
const y = date.substr(0, 4),
m = date.substr(4, 2) - 1,
d = date.substr(6, 2);
const startdate = new Date(y, m, d);
if (command === "新規" || command === "追加" || command === "add" || command === "new" || command === "insert") {
const days = parseInt(arg2);
const rot = arg1;
const endday = parseInt(d) + days;
const enddate = new Date(y, m, endday);
return await insertEvents(auth, startdate, enddate, rot, days);
} else if (command === "変更" || command === "更新" || command === "update" || command === "replace") {
const days = 1;
const rot = arg1;
const endday = parseInt(d) + days;
const enddate = new Date(y, m, endday);
return await updateEvent(auth, startdate, enddate, rot, days);
} else if (command === "削除" || command === "消去" || command === "delete" || command === "remove") {
const days = parseInt(arg1);
const endday = parseInt(d) + days;
const enddate = new Date(y, m, endday);
return await deleteEvents(auth, startdate, enddate, days);
}
};
// LINE Messaging API のコールバック関数
const eventHandler = async (event) => {
if (event.type !== 'message' || event.message.type !== 'text') {
// ignore non-text-message event
return null;
}
const input = event.message.text;
let client;
try {
client = await authenticate();
} catch (err) {
console.log('err from auth',err);
return;
};
const res = input.match(/(新規|add|new|変更|update|削除|delete|追加|insert|消去|remove|更新|replace)[\s ]+([0-90-9]+)[\s ]+([0-90-9]+)([\s ]+[0-90-9]+)?$/);
if (res) {
const command = res[1].trim();
const date = res[2].trim().normalize("NFKC");
const arg1 = res[3].trim().normalize("NFKC");
const arg2 = res[4] ? (res[4].trim().normalize("NFKC")) : undefined;
try {
const resmsg = await doOperation(client, command, date, arg1, arg2);
return {
replyToken: event.replyToken,
response: {
type: 'text',
text: resmsg
},
};
} catch (error) {
console.error(error);
return {
replyToken: event.replyToken,
response: {
type: 'text',
text: error
},
};
}
} else {
const resmsg = "もう一度いれてください";
return {
replyToken: event.replyToken,
response: {
type: 'text',
text: resmsg
},
};
}
}
// IBM Cloud Functions のエントリポイント
const main = async (params) => {
if (params.__ow_headers) {
const signature = params.__ow_headers["x-line-signature"];
const rawBody = Buffer.from(params.__ow_body, 'base64').toString('utf-8')
const verified = line.validateSignature(rawBody, LINE_CONFIG.channelSecret, signature);
if ( !signature || !verified ) {
throw new line.SignatureValidationFailed(
"signature validation failed",
signature
);
}
const body = JSON.parse(rawBody);
if (body.events) {
const reply = await Promise.all(body.events.map(eventHandler));
console.log('reply',reply);
if (reply.length==1) {
try {
const ret = await client.replyMessage(reply[0].replyToken,reply[0].response)
return {
headers: {},
statusCode: 200,
body: ret,
};
} catch (err) {
console.log(err);
return {
headers: {},
statusCode: 500,
body: err,
};
};
}
} else {
return {
headers: {
},
statusCode: 400,
body: 'Bad request'
};
}
}
return {
headers: {
},
statusCode: 204,
body: 'No content'
};
};
exports.main = main;
package.json
{
"name": "myapp-linebot",
"version": "0.1.0",
"description": "MyApp LINE BOT",
"main": "app.js",
"dependencies": {
"@line/bot-sdk": "^5.0.0",
"fs": "0.0.1-security",
"googleapis": "^105.0.0"
}
}
service-account.json
{
"type": "service_account",
"project_id": "<your-project-name>",
"private_key_id": "...",
"private_key": "-----BEGIN PRIVATE KEY-----\n...",
"client_email": "<your-service-account>@<your-project-id>.iam.gserviceaccount.com",
"client_id": "...",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/<your-service-account>%40<your-project-id>.iam.gserviceaccount.com"
}
node_modules
package.json
を作成したあと、npm コマンドで関連するライブラリを以下のコマンドでダウンロードします。
% npm install
すると、node_modules
というディレクトリが作成され、依存するライブラリが格納されます。このフォルダごと ZIP ファイルに組み込みます。
アップロードするコマンド
まずは、ZIP ファイルを作成します。(コマンド例を示します)
% zip -q -r myapp.zip app.js data.json package.json service-account.json node_modules
続いて、IBM Cloud CLI を用いてログインして、IBM Cloud Functions のパッケージ myapp を作成します(作成済みの場合は省略)。
% ibmcloud login
% ibmcloud fn package create myapp
そして、タイプを Web の Raw に指定して、Node.js v16 アプリとして、create コマンドでZIP ファイルをアップロードします。
% ibmcloud fn action create myapp/linebot myapp.zip --web raw --kind nodejs:16
ファイルを更新したときは、再度 ZIP ファイルを作り直して、update コマンドで ZIP ファイルをアップロードします。
% ibmcloud fn action update myapp/linebot myapp.zip --web raw --kind nodejs:16
console.log(...)
などで出力した内容を確認するために、アクション・ログがリアルタイムで流れてくるようにします。
% ibmcloud fn activation poll
エンドポイント URL の確認
IBM Cloud コンソール上で、Functions のダッシュボード (https://cloud.ibm.com/functions/) を開き、myapp/linebot
の設定画面から「エンドポイント」を開きます。
Web アクションのセクションで、Web アクションとして有効化 と 未加工HTTP処理 がセットされていることを確認します。さらに、HTTPメソッドの欄が以下のようになっています。
HTTPメソッド | 認証 | URL |
---|---|---|
いずれか | パブリック | (例) https://us-south.functions.appdomain.cloud/api/v1/web/your_namespace/myapp/linebot |
※この記事では、エンドポイント自体には認証は行いませんので、パッケージ名 myapp やアクション名 linebot を適宜変更して、この URL が他者に容易に推測されないように注意してください。しかしながら、データの中身(ペイロード)は、 LINE Messaging API からの正式なアクセスであることを確認するコードが入っていますので、URLが推測されても不正なアクセスをしてカレンダーの内容を確認することはできないようにしています。
LINE Messaging API を登録し、バックエンドを指定する
こちらの記事を参考に、LINE Messaging API の設定をします。
Webhook URL には、さきほど確認したエンドポイントURLを指定します。
Verify ボタンを押して検証する際、立ち上がりに時間がかかり Timeout エラーになることがありますが、何度か繰り返すと Success に変わります。
また、チャネルアクセストークンとチャネルシークレットは、それぞれ app.js
の LINE_CONFIG
に、channelAccessToken
と channelSecret
をセットします。
LINE チャット画面での動作確認
作成した LINE チャットボットには、コマンドの形式でシフト管理の命令を出します1。
タイプ | コマンド | 引数1 | 引数2 | 引数3 | 説明 |
---|---|---|---|---|---|
登録 | 新規/追加/new/add/insert | 日付8桁 (20221223) | シフト番号 | 日数 | 日付から日数分だけシフト番号から順にイベントとして追加する |
変更 | 変更/更新/update | 日付8桁 (20221223) | シフト番号 | - | 日付のシフトを指定した番号に変更する |
削除 | 削除/消去/delete/remove | 日付8桁 (20221223) | シフト番号 | 日数 | 日付から日数分だけイベントを削除する |
引数1, 引数2, 引数3 で指定する数字は、半角でも全角でもよいです。また、コマンドと引数の間の区切りも、半角スペース全角スペースどちらでもよいです2。
最初に4日分追加したあとの、Google カレンダー上の表示はこうなります。
12月23日は平日シフト、24日25日は土休日シフトで登録されます3。
さいごに
本記事では、紙で用意されたシフト表を Google カレンダーに登録するためのアプリケーションを、LINE のチャット画面をインターフェースとして、バックエンドに IBM Cloud Functions を用いて作成しました。使用頻度にも依りますが、数日に1回起動する程度であれば無料の範囲で実行可能です。
自分のシフトではなくても、ご家族のシフトや、習い事のスケジュールなどをカレンダー共有をすることによってスマホを開かずに確認することができるようになりますので、本記事の手順を参考に、試してみてください。
登録したスケジュールを音声で確認できるように、Alexa スキルとして実装した記事もあります。こちらもぜひご参照ください。