0. はじめに
私の学校のクラスでは、予定管理にTimeTreeというスケジュール管理アプリを使用しています。しかし、クラスメイト全員がそのアプリをインストールしているわけではなく、スケジュールの共有が十分に機能しているとは言えませんでした。
そこで、TimeTreeに登録されている予定をクラスのグループLINEに通知すればいいのでは?と思い、今回のLINE Botを作ってみることにしました。
その名も、「Schedule Notifier」。
この記事は、このBotの作成にあたって身に付けた知識のメモ代わりとしています。少々雑なところもありますが、お許しを。
2023/10/08 追記 :
2023/12/22をもって、TimeTree APIの提供を終了する旨が発表されました(公式の発表はこちら)。この記事の内容は完全に過去の遺産となりました...
1. 成果物
先に今回の成果物を投下しておきます。
デザインには自信がないので何とかしたいところです。
2. 仕様
このBotの仕様をざっとまとめておきます。
2-1. 通知内容
- 通知日の翌日の日付と曜日。祝日なら祝日名も。
- 通知日の翌日の天気と気温。
- Timetree上で、通知日の翌日に登録されている予定。具体的には、以下の3つ
- 予定の時刻
- タイトル
- メモ
- Google Spread Sheet上に登録されている英単熟語とその意味。(この機能はオプションで付けくわえたものです、4-2. 通知と一緒に英単語も覚えたいで解説しています。)
2-2. 使用するサービス・API
- TimeTree API : TimeTree上の予定を取得する。
- LINE Messaging API : グループLINEへのメッセージ送信。
- tenki.jpのAPI : 天気予報と気温の取得。
- Google Apps Script : コードエディタ兼サーバ。以下、「GAS」と表記。
- Google Spread Sheet : データの管理。以下、「スプレッドシート」と表記。
2-3. 一連の流れ
-
毎日16:00頃、GASに設定されているTriggerが起動。
-
翌日の日付と祝日の情報を取得(GASの関数を利用)。
-
tenki.jpのAPIから天気予報と気温の取得。
-
TimeTree APIから予定を取得。このとき、翌日の予定がなければここで処理を打ち切る(通知をしない)。
-
送信メッセージの構築。
-
LINE Messaging APIにより、グループLINEへ送信。
-
次のTrigger起動まで待機。
3. 実装
実装の流れです。
3-1. TimeTree APIのトークンの生成
TimeTree API ドキュメント - TimeTree Developer Platformを参考に、TimeTree APIのアクセストークンを取得します。今回はカレンダー上の予定を取得するだけなので、「Personal access token」アプリケーションを選択しました。
Personal Access Tokens - TimeTreeから「トークンの作成」をクリックして、「読み取り」項目にすべてチェックを入れて作成します。生成されたトークンはメモ帳などに控えておきましょう。
3-2. LINE Bot用のチャネルを作成 + LINE Messaging APIの登録
公式ドキュメントがとても分かりやすいです。
この内容の通りにやっていき、チャネルが作成出来たら、「Messaging API設定」の「チャネルアクセストークン(長期)」を発行しメモしておきます。
また、LINE公式アカウント機能に関しては、このように設定しておきましょう。
3-3. GASプロジェクトの作成
GASの新規プロジェクトを作成します。名前は「Schedule-Notifier」としておきました。
ここで、保存しておいたトークンをスクリプトプロパティとしてGASプロジェクトに登録しておきます。
3-4. 送信先のグループLINEのgroup_idを取得する
正直ここがかなりめんどくさいのですが、LINE Messaging APIでグループLINEに送信するためには、そのグループのgroup_idが必要になります。これはLINE上から直接入手することができないので、スクリプトから取得していきます。
まずは、idの保存先となるスプレッドシートを作成。名前はあとで分かるように「LINE_GROUP_ID」としておきました。また、そのスプレッドシートidを記録しておきます。具体的には、URLhttps://docs.google.com/spreadsheets/d/xxx/edit#gid=0
のxxx
の部分です。このidも、スクリプトプロパティとして登録します。
GASの方に戻り、新しくgetLineGroupID.gs
を作成。以下のコードを書きます。コードスパン上は、Javascript表記にしています。
function doPost(e){
var json = JSON.parse(e.postData.contents);
var UID = json.events[0].source.userId; // ユーザーid。今回は使わない
var GID = json.events[0].source.groupId; // グループid。こっちを使う!
const sheet = SpreadsheetApp.openById(PropertiesService.getScriptProperties().getProperty("SPREADSHEET_ID")).getSheetByName("シート1");
sheet.appendRow([GID, UID]);
}
これで、グループLINEでなにかしら発言すると、スプレッドシートに発言者のユーザーidとグループidを書き込んでくれる仕組みができました。
この状態で「デプロイ」し、ウェブアプリURLを先ほどのLINE Developers -> Messaging API設定
から、 「Webhook設定」の「Webhook URL」に貼り付けます。念のため「検証」をクリックしておきます。
検証ができたら「Webhookの利用」をONにし、QRコードからBotと友達登録をして送信したいグループに招待します。その後、なにかしらメッセージを送信して、スプレッドシートを確認しましょう。「シート1」のA列には'C'から始まるグループidが、B列には'U'から始まるユーザーidが書き込まれているはずです。
このグループidも、GASプロジェクトのスクリプトプロパティに登録しておきます。
登録が終わったら、先ほどの「Webhookの利用」をOFFにしておきます。そうしないと、グループLINEでメッセージが送信されたときに延々とidが書き込まれてしまうからです。
3-5. 通知日の翌日の日付と祝日の情報を取得する
ここから本格的にコードを書いていきます。まずは日付部分から。
GASには便利なDate型の変数が用意されているので、これを使っていきます。
let date = new Date()
date.setDate(date.getDate() + 1);
const tomorrow = getDate(date); // 日付データ
const holiday = getHoliday(date); // 祝日データ
// 日付を取得
function getDate(date) {
return (date.getMonth() + 1) + '月' + date.getDate() + '日(' + '日月火水木金土'[date.getDay()] + ')';
}
// 祝日情報を取得
function getHoliday(date) {
const [event_holiday] = CalendarApp.getCalendarById('ja.japanese#holiday@group.v.calendar.google.com').getEventsForDay(date);
return event_holiday ? event_holiday.getTitle() : '';
}
getDate(date)
関数ですが、'日月火水木金土'[date.getDay()]
の部分は、以下のコードの短縮版です。結局は文字列も配列の一種なので、この方法が使えます。
const array = ['日', '月', '火', '水', '木', '金', '土'];
array[d.getDay()]
GASの拡張サービスに「CalenderApp」というものがあり、getCalendarById('ja.japanese#holiday@group.v.calendar.google.com')
で、Googleカレンダー公式の祝日情報を登録したカレンダーを取得します。
getEventsForDay(day)
で祝日情報を取得、event_holiday
に分割代入しています。
もし祝日がなければevent_holiday
はundefined
となり、3項演算子上でfalse
となります。
3-6. tenki.jpのAPIから天気予報と気温の取得をする
// 天気情報の取得
const area = 'XXXXX'; // 標準地域コード
let content = UrlFetchApp.fetch('https://static.tenki.jp/static-api/history/forecast/' + area + '.js').getContentText();
content = JSON.parse(content.substring(content.indexOf('(') + 1, content.indexOf(');')));
let {max_t: temp_h = "不明", min_t: temp_l = "不明", t: weather = "不明"} = content;
const words = {
'時々': '|',
'一時': '|',
'のち': '»',
'晴': '☀',
'曇': '☁',
'雨': '☔',
'雪': '⛄'
};
for (let key in words) {
weather = weather.replace(key, words[key]);
}
tenki.jpにアクセスすると、画面上部に自分が最近チェックした地域の天気予報が小さく表示されます(画像の赤丸の部分)。
実は、この情報を取得するAPIが存在するのです。
URLのフォーマットは、
https://static.tenki.jp/static-api/history/forecast/XXXXX.js
で、XXXXX
には標準地域コードが入ります。
レスポンスは、
'__r__XXXXX({"i":"画像番号","j":"標準地域コード","max_t":"最高気温","min_t":"最低気温","n":"地域名","p":"降水確率","t":"天気"});'
となり、画像番号はサイト表示用の画像の番号のようです。
https://static.tenki.jp/images/icon/forecast-days-weather/XXX.png
で表示できるらしい。
GASのUrlFetchApp.fetch(url, option)
を使用して、このAPIにHTTPリクエストを送信します。今回は特にoptionは設定しないので、普通のGETリクエストになります。
getContentText()
でレスポンスを文字列で受け取り、substring()
で両端の括弧を削除、JSON.parse()
でJSON形式にしてやると、こんな感じになります。
{
"i":"画像番号",
"j":"標準地域コード",
"max_t":"最高気温",
"min_t":"最低気温",
"n":"地域名",
"p":"降水確率",
"t":"天気"
}
これを各変数temp_h
, temp_l
, weather
に分割代入していきます。
最後に、天気情報を絵文字に変換します。
Let's EMOJIという絵文字のまとめサイトから適当な天気マークを取ってきて、連想配列(JSON)でwords
として宣言。weather
の文字列に対応する天気マークを設定します。
3-7. Flex Messageの作成
3-7-1. ベースのFlex Message
今回のBotが送信するデータのメインです。
冒頭のメッセージのようなものは、Flex Meesageで作成されており、LINEのメッセージをCSS flexboxの形式で表示してくれるようです。とはいっても、一から手打ちで作るのはかなり大変。ということで、Flex Message Simulatorというものを使って作っていきます。LINE Developersの中にあるので、使用にはLINEアカウントによるログインが必要です。
こちらを使用して好きなようにBotの送信メッセージをカスタマイズしていきます。詳しい使い方はここでは割愛します。
いい感じに作れたら、右上の「View as JSON」をクリックしてJSONコードをコピーします。これを変数flex
として宣言しておきます。
function createFlexMessage(date, holiday, temp_h, temp_l, weather) {
// flex messageの作成
let flex = {
"type": "bubble",
"size": "giga",
"header": {
"type": "box",
"layout": "vertical",
"contents": [
{
"type": "text",
"text": "Tomorrow's events",
"size": "lg",
"style": "italic",
"color": "#444444"
}
]
},
"body": {
"type": "box",
"layout": "vertical",
"contents": [
{
"type": "text",
"text": date,
"size": "xxl",
"weight": "bold",
"color": "#444444",
"margin": "xs"
},
{
"type": "box",
"layout": "horizontal",
"contents": [
// ここに祝日情報を挿入
{
"type": "text",
"text": weather,
"align": "end",
"size": "lg",
"color": "#444444",
"gravity": "center"
}
]
},
{
"type": "box",
"layout": "horizontal",
"contents": [
{
"type": "filler"
},
{
"type": "text",
"text": temp_l + '℃',
"flex": 0,
"color": "#3f51b5",
"size": "lg"
},
{
"type": "text",
"text": "/",
"flex": 0,
"color": "#444444",
"size": "lg",
"margin": "xs"
},
{
"type": "text",
"text": temp_h + '℃',
"margin": "xs",
"flex": 0,
"color": "#f44336",
"size": "lg",
"gravity": "center"
}
]
},
{
"type": "separator",
"color": "#808080",
"margin": "xl"
},
{
"type": "box",
"layout": "vertical",
"contents": [
// Events
],
"margin": "xl"
}
]
},
"styles": {
"header": {
"backgroundColor": "#a3ffa3"
},
"footer": {
"backgroundColor": "#ffead6"
}
}
};
}
これをベースに、日付や予定の情報を追加していきます。
3-7-2. 祝日情報の挿入
3-5. 通知日の翌日の日付と祝日の情報を取得するで取得した祝日情報を先ほどのflex messageに挿入します。
// 祝日情報の挿入
if (holiday != '') {
flex.body.contents[1].contents.splice(0, 0, {
'type': 'text',
'text': holiday,
'size': 'md',
'color': '#808080',
'flex': 0,
'gravity': 'center'
});
}
splice(start, coutn, data)
は、start
で割り込む位置を、count
で割り込み時のデータ消去数を、data
で挿入するデータを指定し、JSONデータに別のデータを挿入する関数です。3-7-1. ベースのFlex Messageのflex
の中の、コメントの位置に挿入されます。
3-7-3. TimeTree APIへのリクエスト
TimeTree APIを使用する関数は以下の別スクリプトにまとめてあります。
// TimeTree API
function timetreeAPI(url, method, payload) {
const accessToken = PropertiesService.getScriptProperties().getProperty('TIMETREE_TOKEN');
const headers = {
'Authorization': 'Bearer '+ accessToken
};
const options = {
'method': method,
'headers': headers,
'payload': payload
};
return UrlFetchApp.fetch(url, options);
}
// カレンダーリストの取得
function timetreeGetCalendars() {
let url = 'https://timetreeapis.com/calendars';
let method = 'GET';
let payload = '';
return timetreeAPI(url, method, payload);
}
// カレンダーから特定のカレンダーのみを取得する
function timetreeGetCalendarIdByName(name) {
let response = timetreeGetCalendars();
let calendars = JSON.parse(response).data;
let calendar = calendars.filter(function(data){
return data.attributes.name.toString() === name;
});
return calendar[0].id;
}
// カレンダーの当日以降の予定を取得(daysは1以上7以下)
function timetreeGetUpcomingEvents(id, days) {
let url = 'https://timetreeapis.com/calendars/' + id + '/upcoming_events?timezone=Asia/Tokyo&days=' + days;
let method = 'GET';
let payload = '';
return timetreeAPI(url, method, payload);
}
GASからAPIへリクエストを行うには、3-6. tenki.jpのAPIから天気予報と気温の取得をするで使用したurlFetchApp.fetch(uril, options)
を使います。リクエストの種類は先ほどと同じGETリクエストですが、今回はoption
を指定していきます。
TimeTree APIでは、リクエストタイプ(method
)と認証情報を送信する必要があるため、header
に認証情報を入れていきます。
まず、スクリプトプロパティに登録したAPIのトークンをaccessToken
に代入します。
その後、Bearerトークンを作成します。これは今回のAPIリクエストのおまじないだと思っておいてください。
おまじないは'Authorization': 'Bearer' + accessToken
です。
method
、header
をoptionsに連想配列として格納。なお、payload
は一応入れていますが今回は使用しません。
これらをurlFetchApp.fetch(uril, options)
で送信してやれば、レスポンスが返ってきます。
詳しくは公式ドキュメントを読んでください。
3-7-4. 予定を取得する
本Botのメイン機能です。
function createScheduleFlexMesseage(flex) {
// カレンダー情報の取得
const calendars = JSON.parse(timetreeGetCalendars()).data;
const zero_padding = (t) => ('0' + t).slice(-2);
let event_exists = false;
for (let calendar of calendars) {
let events = JSON.parse(timetreeGetUpcomingEvents(calendar.id, 2)).data;
// 予定なしの時は通知しない
if (events.length == 0) {
let schedule = createNoEventsMessage();
flex.body.contents[4].contents.push(schedule);
continue;
}
for (let event of events) {
let {title, description, start_at, end_at, all_day} = event.attributes;
start_at = new Date(start_at);
// 今日の予定は通知しない
let today_str = Utilities.formatDate(new Date(), 'JST', 'MM/dd');
let eventDate_str = Utilities.formatDate(start_at, 'JST', 'MM/dd');
if (today_str == eventDate_str) continue;
end_at = new Date(end_at);
let time = all_day ? '終日' : zero_padding(start_at.getHours()) + ':' + zero_padding(start_at.getMinutes()) + '-' + zero_padding(end_at.getHours()) + ':' + zero_padding(end_at.getMinutes());
// メモがないとき
if (description === null) description = 'メモはありません';
let schedule = {
"type": "box",
"layout": "horizontal",
"contents": [
{
"type": "text",
"text": time,
"flex": 0,
"size": "md",
"color": "#808080",
"gravity": "center"
},
{
"type": "text",
"text": title,
"size": "lg",
"margin": "lg",
"color": "#606060",
"weight": "bold",
"flex": 0,
"gravity": "center"
},
{
"type": "box",
"layout": "horizontal",
"contents": [
{
"type": "text",
"color": "#606060",
"gravity": "center",
"margin": "lg",
"size": "sm",
"flex": 0,
"wrap": true,
"text": description
}
],
"margin": "none"
}
],
"margin": "sm"
};
flex.body.contents[4].contents.push(schedule);
event_exists = true;
}
}
// 予定なしの時は通知しない
if (!event_exists) {
return event_exists;
}
return true;
}
この関数は、flex
を編集した後に、予定の追加があればTrue
を、なければFalse
を返す関数です。
JSON.parse(timetreeGetUpcomingEvents(calendar.id, 2)).data
で、カレンダーに登録されている当日と翌日の予定をすべて取得し、events
に代入。
events
が空でなければ、翌日の各イベントについて、「タイトル」、「メモ」、「開始時刻」、「終了時刻」、「終日のイベントかどうか」を取得します。
取得した情報をflex Messageの中に埋め込み、ベースのflexに挿入していきます。
なお、イベントがなければここで処理を打ち切り、通知は行われません。
3-8. LINE側へ送信
いよいよ最後の処理です。LINE Messaging APIを使ってグループLINEへ送信します。
// LINE Messeaging API
function lineMessagingAPI(date, flex) {
const LINE_TOKEN = PropertiesService.getScriptProperties().getProperty('LINE_TOKEN');
const ToID = PropertiesService.getScriptProperties().getProperty("LINE_GROUP_ID");
const payload = {
'to' : ToID,
'messages': [
{
'type': 'flex',
'altText': date,
'contents': flex
}
]
};
const options = {
'headers': {
'Content-Type': 'application/json; charset=UTF-8',
'Authorization': 'Bearer ' + LINE_TOKEN
},
'method': 'post',
'payload': JSON.stringify(payload)
};
UrlFetchApp.fetch('https://api.line.me/v2/bot/message/push', options);
}
スクリプトプロパティに登録してあるAPIのトークンとgroup_idを変数に格納。
再びUrlFetchApp.fetch(url)
で送信します。
LINE Messaging APIの詳しい情報は公式のリファレンスを参照してください。
3-9. トリガーを設置する
最後に、スクリプトが毎日16:00に実行されるように、トリガーを設置していきます。
トリガーは、GASのコードエディタ左側にあるトリガータブから「トリガーを追加」をクリックすることで設置できます。
しかし、この方法では時刻を指定するときに1時間単位までしか指定できず、実行時間にばらつきが出てしまいます。これを解決するために、スクリプト上からトリガーを設置していきます。
// 通知時間指定のためのトリガーを起動
function setTrigger(){
// 通知時刻
const time = new Date();
time.setHours(16);
time.setMinutes(0);
ScriptApp.newTrigger('notify').timeBased().at(time).create();
}
// 使用済みのトリガーを削除
function delTrigger() {
const triggers = ScriptApp.getProjectTriggers();
for(const trigger of triggers){
if(trigger.getHandlerFunction() == "notify") ScriptApp.deleteTrigger(trigger);
}
}
setTrigger()
では、ScriptApp.newTrigger(string)
で、実行する関数を指定しています。厳密にはこの時点ではまだトリガーは設定されておらず、create()
で設定しています。
設置したトリガーは勝手に削除されないため、このままではトリガーがどんどんたまっていってしまいます。そこで、delTrigger()
では実行されたトリガーを削除しています。
3-10. main.jsはこうなった!
以上で、今回のBotのスクリプトはすべてです。ここで、main.js
のコードを全文掲載します。
const ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty('LINE_TOKEN');
function notify() {
delTrigger();
let date = new Date()
date.setDate(date.getDate() + 1);
// 日付と祝日の取得
const tomorrow = getDate(date);
const holiday = getHoliday(date);
// 天気情報の取得
const area = '13104';
let content = UrlFetchApp.fetch('https://static.tenki.jp/static-api/history/forecast/' + area + '.js').getContentText();
content = JSON.parse(content.substring(content.indexOf('(') + 1, content.indexOf(');')));
let {max_t: temp_h = "不明", min_t: temp_l = "不明", t: weather = "不明"} = content;
const words = {
'時々': '|',
'一時': '|',
'のち': '»',
'晴': '☀',
'曇': '☁',
'雨': '☔',
'雪': '⛄'
};
for (let key in words) {
weather = weather.replace(key, words[key]);
}
// flex messeageの作成
const {flex, doNotify} = createFlexMessage(tomorrow, holiday, temp_h, temp_l, weather);
// LINE Messaging APIへ送信
if (doNotify) lineMessagingAPI(tomorrow, flex);
}
これで、毎日16:00にトリガーイベントが発火し、TimeTree上に登録されている明日の予定がグループLINEに通知されるようになりました。
4. 実際の運用に関して
ここからは実際に運用するなかで起こった問題や提案などを書いていきます。
4-1. 毎日の送信でLINE Messaging APIの上限に引っかかった
掲載している実装では、イベントがないときは送信が行われないようになっていますが、これはこの事件が原因です。
当初、予定のないときは以下のような通知が行われるようになっていました。
※「今日の英単語」に関してはまた後で説明します。
つまり、毎日何かしらの通知がグループLINEに行くようになっていたのです。
当時の私は、APIの制限のこの部分を勘違いしていました。
無料版の場合、メッセージ数の上限は1か月に1000通ですが、そのカウントは送信回数ではなく、送信対象となったのべ人数なのです。
私のクラスのグループLINEには41人が参加しており、毎日送信していたようでは最低でも41*28=1148通のメッセージになってしまい、実際の運用では8月25日からは制限超過によりBotが停止する事態に...
APIを使用するときはドキュメントをよく読みましょう...
4-2. 通知と一緒に英単語も覚えたい
あるクラスメイトからの要望。
まだ毎日通知していた時期にあった要望で、今日の予定はありません:)
の通知だけでは物足りないからとのこと。そこで、英語の授業でたびたび行われる単語テストの範囲の英単語を、通知の最後に付け加えることにしました。
仕組みとしては、あらかじめスプレッドシートに英単語を登録しておき、
その中から「ID」「単語」「品詞」「意味」を取り出して、flex形式にしてベースのflex Messageのfooter部分に挿入する、といった感じです。
コードはこんな感じになりました。
const sheet_name = Math.floor(Math.random() * 2) == 0 ? "必修速読英単語" : "英熟語ターゲット";
const wordID_str = sheet_name === "必修速読英単語" ? '速単-' : '熟語-';
const sheet = SpreadsheetApp.openById(PropertiesService.getScriptProperties().getProperty("ENGLISHWORD_SHEET")).getSheetByName(sheet_name);
const wordNum = sheet.getLastRow();
let wordIndex = Math.floor(Math.random() * wordNum) + 1;
function getWord() {
const wordID = sheet.getRange(wordIndex, 1).getValue();
const word = sheet.getRange(wordIndex, 2).getValue();
const wordType = sheet.getRange(wordIndex, 3).getValue();
return {
"type": "box",
"layout": "horizontal",
"contents": [
{
"type": "text",
"text": '【' + wordID_str + wordID + '】',
"flex": 0,
"size": "md",
"color": "#444444"
},
{
"type": "text",
"text": word,
"flex": 0,
"size": "md",
"color": "#444444"
},
{
"type": "text",
"text": '(' + wordType + ')',
"flex": 0,
"size": "md",
"margin": "md",
"color": "#444444",
"gravity": "center"
}
],
"margin": "xs"
}
}
function getMeaning() {
const wordMeaning = sheet.getRange(wordIndex, 4).getValue();
return {
"type": "text",
"text": wordMeaning,
"wrap": true,
"gravity": "center",
"margin": "xs",
"size": "sm",
"color": "#444444",
"offsetStart": "md"
}
}
ちょっとした機能をすぐに付け加えられるのは、個人開発ならではの良さですね。
5. 作った感想と今後に向けて
実は、本格的にBotというものを作って実際に運用したのは今回が初めてでした。
初めはAPIのリクエストの仕様やLINE Botの仕組みもよく分からず、エラーが出てはググって...の繰り返しでしたが、慣れてくると自分のアイデアを形にするのがとても楽しくて、作業に没頭していました。
私はゲームを作ろうとすると、途中でモチベーションが落ちてきて作りかけで終わってしまう、ということが日常茶飯事でしたが、このようなシステム系の実装はだれることなく続けられ、こちらの方が向いているのかもしれないと考えるようになっています。
今回のBotは一般公開はしないつもりですが、機会があればまた誰かに貢献できるようなシステムを作ってみたいと思っています。
Githubにコードを上げておきますので、参考にしてみてください。