Google Homeに声をかけずに天気とGoogleカレンダーの予定を言わせる

[Google Homeに声をかけずに天気を言わせる](https://qiita.com/piyohiko/items/04e01d529011372ad2dc)を拡張して
Google Homeに声をかけずに天気とGoogleカレンダーの予定を言わせるようにしてみました。

# やりたいこと
- 声をかけずに天気とGoogleカレンダーの予定を言わせたい
- 会社の予定と個人の予定をまとめて教えて欲しい
- 朝が早い予定がある場合は警告して欲しい

# Google APIを使用するためのClient ID&Client Secretを取得する
[こちら](https://qiita.com/jordi/items/aa9f86cf92e04a8089db)等を参考に[Google Developer Console](https://console.developers.google.com/)にアクセスしてGoogle APIを使用するための
Client IDとClient Secretを作成する。
作成したついでにリダイレクトURLに `http://localhost` を登録しておく。
この作業を会社のアカウントと個人のアカウント両方で行う。

# Google APIのリフレッシュトークンを取得する
[こちら](https://qiita.com/kossacks/items/8d279bcc1acc2c2153ab)等を参考にGoogle APIで使用するアクセストークンを取得するためにリフレッシュトークンを取得する。
こちらも会社のアカウントと個人のアカウント両方のリフレッシュトークンを用意。

# 天気&スケジュールつぶやきスクリプト作成
## 必要なパッケージのインストール
```json:package.json
{
  "name": "google-home",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "google-home-notifier": "^1.2.0",
    "googleapis": "^28.1.0",
    "sync-request": "^6.0.0"
  }
}
```
```bash
$ npm install
```

## OAuthで必要な情報を記述したjson
```json:google-api-info.json
{
  "own": {
    "client_id": "${個人のアカウントのClient ID}",
    "client_secret": "${個人のアカウントのClient Secret}",
    "redirect_url": "http://localhost",
    "refresh_token": "${個人のアカウントのリフレッシュトークン}"
  },
  "company": {
    "client_id": "${会社のアカウントのClient ID}",
    "client_secret": "${会社のアカウントのClient Secret}",
    "redirect_url": "http://localhost",
    "refresh_token": "${会社のアカウントのリフレッシュトークン}"
  }
}
```

## 天気情報取得スクリプト

```js:weather.js
'use strict';

const request = require('sync-request');
const darkSkyUrl = 'https://api.darksky.net/forecast/${Secert Key}/${緯度},${経度}?lang=ja&units=si';

const options = {
  headers: {'Content-Type': 'application/json'}
};

const getDate = (unixTime) => {
    const date = unixTime ? new Date(unixTime*1000) : new Date();
    return `${date.getFullYear()}-${date.getMonth()+1}-${date.getDate()}`;
};

const getTodayData = hourly => {
  const today = getDate();
  const todayData = hourly.filter(hourlyData => {
    const date = getDate(hourlyData.time);
    return today === date;
  });
  const temps = todayData.map(data => { return data.temperature; });
  const precipProbabilities = todayData.map(data => { return data.precipProbability; });
  const maxTemp = Math.max.apply(null, temps);
  const minTemp = Math.min.apply(null, temps);
  const precipProbability = Math.max.apply(null, precipProbabilities);
  return {
    maxTemp: Math.round(maxTemp),
    minTemp: Math.round(minTemp),
    precipProbability: Math.round(precipProbability * 100)
  };
};

const createMessage = body => {
  const currently = body.currently;
  const weather = currently.summary;

  const todayData = getTodayData(body.hourly.data);
  const maxTemp = todayData.maxTemp;
  const minTemp = todayData.minTemp;
  const precipProbability = todayData.precipProbability;

  const message = `今日の天気は${weather}、最高気温${maxTemp}度、最低気温${minTemp}度、降水確率${precipProbability}%です。`;
  return message;
};

exports.fetchWeatherInfo = () => {
  const response = request('GET', darkSkyUrl, options);
  const body = JSON.parse(response.getBody('utf-8'));
  return createMessage(body);
};
```

## カレンダー情報取得スクリプト
```js:google-calendar.js
'use strict';

const API_INFO = require("./google-api-info.json")
const { google } = require('googleapis');
const OAuth2 = google.auth.OAuth2;

const today = () => {
  const date = new Date();
  return `${date.getFullYear()}-${('0'+(date.getMonth()+1)).slice(-2)}-${('0'+date.getDate()).slice(-2)}`;
};

const isToday = (date) => {
  const regexp = new RegExp(`^${today()}`);
  return regexp.test(date);
};

const time = (dateString) => {
  const date = new Date(dateString);
  return `${date.getHours()}:${('0'+date.getMinutes()).slice(-2)}`;
};

const getTodaySchedules = (scheduleData) => {
  const schedules = scheduleData.filter((schedule) => {
    return schedule.start.date && isToday(schedule.start.date);
  }).map((schedule) => {
    return schedule.summary;
  });
  return schedules;
};

const getHourlySchedules = (scheduleData) => {
  const regexp = new RegExp(`^${today()}`);
  const schedules = scheduleData.filter((schedule) => {
    return schedule.start.dateTime && isToday(schedule.start.dateTime);
  }).map((schedule) => {
    return { time: time(schedule.start.dateTime), summary: schedule.summary };
  });
  return schedules;
};

const mergeSchedules = (mySchedules, companySchedules) => {
  const todaySchedule = mySchedules.todaySchedule.concat(companySchedules.todaySchedule);
  const hourlySchedule = mySchedules.hourlySchedule.concat(companySchedules.hourlySchedule).sort((a,b) => {
    if (Number(a.time) < Number(b.time)) return - 1;
    if (Number(a.time) > Number(b.time)) return 1;
    return 0;
  });
  return { todaySchedule: todaySchedule, hourlySchedule: hourlySchedule };
};

const createMessage = (scheduleInfo) => {
  let message = '今日の予定は、';
  let isEarly = false;
  const todaySchedule = scheduleInfo.todaySchedule;
  const hourlySchedule = scheduleInfo.hourlySchedule;
  if (!todaySchedule.length && !hourlySchedule.length) {
    return '本日登録されている予定はありません。';
  }
  if (todaySchedule.length) {
    message += `${todaySchedule.join('と、')}、`;
  }
  if (hourlySchedule.length) {
    const hourly = hourlySchedule.map(schedule => {
      return `${schedule.time}から${schedule.summary}`;
    }).join('、');
    message += `${hourly}、`;
    isEarly = !!(hourlySchedule.filter(schedule => {
      return Number(schedule.time.replace(':', '')) < 1030;
    }).length);
  }
  message += 'です。';
  if (isEarly) message += '朝一の予定があるので、早めに起きてください。';
  return message;
};

const fetchTokens = async (apiInfo) => {
  const oauth2Client = new OAuth2(
    apiInfo.client_id, apiInfo.client_secret, apiInfo.redirect_url
  );
  oauth2Client.setCredentials({ refresh_token: apiInfo.refresh_token });

  const oauthPromise = new Promise((resolve, reject) => {
    oauth2Client.refreshAccessToken((err, response) => {
      if (err) return reject(err);
      resolve(response);
    });
  });
  const tokens = await oauthPromise;
  oauth2Client.credentials = tokens;
  return oauth2Client;
};

const fetchEvents = async (apiInfo) => {
  const auth = await fetchTokens(apiInfo);
  const today = new Date(new Date().setHours(0, 0, 0, 0));
  const timeMin = today.toISOString();
  const calendar = google.calendar({ version: 'v3', auth: auth});
  const calendarPromise = new Promise((resolve, reject) => {
    calendar.events.list({
      calendarId: 'primary',
      timeMin: timeMin,
      maxResults: 20,
      singleEvents: true,
      orderBy: 'startTime'
    }, (err, res) => {
      if (err) return reject(err);
      resolve(res);
    });
  });
  const response = await calendarPromise;
  const scheduleData = response.data.items;
  return {
    todaySchedule: getTodaySchedules(scheduleData),
    hourlySchedule: getHourlySchedules(scheduleData)
  };
};

const fetchSchedules = async () => {
  const mySchedules = await fetchEvents(API_INFO.own);
  const companySchedules = await fetchEvents(API_INFO.company);
  const schedule = mergeSchedules(mySchedules, companySchedules);
  return createMessage(schedule);
};

exports.fetchMySchedules = () => {
  return fetchSchedules();
};
```

## Google Homeつぶやきスクリプト
```js:notifier.js
'use strict';

const googlehome = require('google-home-notifier');
const googleCalendar = require('./google-calendar.js');
const weather = require('./weather.js');

const createMessage = async () => {
  const weatherInfo = weather.fetchWeatherInfo();
  const scheduleInfo = await googleCalendar.fetchMySchedules();
  const message = `おはようございます。${weatherInfo}${scheduleInfo}`;
  return message;
};

const notify = async (message) => {
  const text = await message;
  googlehome.device("Google-Home", 'ja');
  googlehome.notify(text, res => {
    console.log(text);
  });
};

notify(createMessage());
```

# 実行
```bash
$ node notifier.js
```

# 補足
文字数が多いとGoogleHomeがつぶやいてくれなかった。。
あと突然スクリプトが動かなくなって調べてみたら原因は[これ](https://qiita.com/ezmscrap/items/24b3a9a8548da0ab9ff5)だったので
 `node_modules/google_tts` を最新にして解決した。