📩

学童の入退室メールを Google Home に読み上げさせる

2022/06/09に公開

子供が通っている学童では、入退室時に保護者にメールが送られてくるシステムが導入されています。ざっと調べた限りでは、そういうシステムを導入している学童・塾などは結構あるようです。

在宅で仕事をしているのですが、携帯をずっと見ているわけではないので、メール Google Homeが通知してくれたら便利そうと思ったので作ってみることにしました。
( Google Home ではなく最近は Google Nest と呼ぶらしいですが、今回は分かりやすさのためにこの記事では Google Home と記述します)

Google Home には任意のテキストを喋らせるような API は無いのですが、 Google Cast プロトコルで音声データを Google Home にしゃべらせることができるようです。また、クラウド等外部のサーバーから直接 Cast プロトコルで喋らせることはできません。そこでローカルのネットワーク内に Raspberry Pi を設置して、Google Home の IP アドレス宛に Cast を流すようにしています。

今回作ったプログラムは以下で公開しています。

https://github.com/fujikky/gmail-google-home-notify

設定ファイルの読み込み

設定ファイルに Google の OAuth クライアントの認証情報や、メールの検索クエリ、読み上げテキストなどを設定ファイル config.js にまとめるようにしました。秘匿値が含まれているため config.js.gitignore に追加してあります。 config.sample.js をコピーして必要な箇所を書き換えてもらう想定です。

config.js
// @ts-check

/**
 * @type {import("./src/config").Config}
 */
const config = {
  // Google Cloud Console で作った OAuth クライアントの情報
  googleClientId: "xxxxxxx",
  googleClientSecret: "xxxxxxx",
  // Google Home の IP アドレス
  // Home アプリのデバイス情報から確認できる
  googleHomeIp: "192.168.x.x",
  // 読み上げる言語
  speechLanguage: "ja",
  // フィルタする From メールアドレス
  // 空にするとでフィルタしない
  emails: ["user1@example.com", "user2@example.com"],
  // メールの本文にマッチさせる正規表現と、読み上げテキストを作る関数のリスト
  // 空にするとメール本文全体を読み上げる
  conditions: [
    {
      regex: /\d\d?\d\d?(\d\d:\d\d):\d\d に◯◯学童に到着しました。/,
      speechText: (match) => `${match[1]}に学童に到着しました。`,
    },
    {
      regex: /\d\d?\d\d?(\d\d:\d\d):\d\d に◯◯学童を出発しました。/,
      speechText: (match) => `${match[1]}に学童を出発しました。`,
    },
  ],
};

module.exports = config;

.gitignore に指定されている設定ファイルは、リポジトリ上は存在しないため、そのまま import するとエラーが起きてしまいます。そこで dynamic import をする関数を定義しています。ファイルが存在しない場合はエラーが throw されます。

config.ts
import path from "node:path";

export type SpeechTextCondition = {
  readonly regex: RegExp;
  readonly speechText: (match: RegExpMatchArray) => string;
};

// 設定ファイルの型
export type Config = {
  readonly googleClientId: string;
  readonly googleClientSecret: string;
  readonly googleHomeIp: string;
  readonly speechLanguage: string;
  readonly emails: readonly string[];
  readonly conditions: readonly SpeechTextCondition[];
};

const CONFIG_PATH = path.join(process.cwd(), "config.js");

// 型エラー回避のため dynamic import にする
export const loadConfig = async () =>
  (await import(CONFIG_PATH)).default as Config;

本体コードは TypeScript で書いてあり、実行用にトランスパイルした dist があるのですが、設定ファイルはトランスパイルが不要で動かせるようにここは JS で書いています。// @ts-check をつけることでファイル単位で型チェックをしてくれるようになります。また tsconfig.json を分けることで、エディタや CI での型チェックは走らせつつトランスパイル対象から外すこともできます。

tsconfig.json
{
  "extends": "@tsconfig/node16-strictest/tsconfig.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src", "typings"]
}
tsconfig.tsc.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": true
  },
  "include": ["config.js", "config.sample.js"]
}

Gmail API の認証をする

Google の認証を通すために以下のような認証用のモジュールを作ります。余談ですが fs/promises モジュールは、 fs の各コールバック式のメソッドをすべて Promise 化してくれます。 util.promisify などを使わずに済むのでとても便利です。

redirectUri には urn:ietf:wg:oauth:2.0:oob を入れることで、ブラウザで認証したあとに認証コードをコンソールに貼り付ける方式になっています。随分前にこの手法は非推奨となったのですが、テストモードで OAuth クライアントを作った場合はまだ使えるようです。開発時には便利だったのでこのまま残してほしいですね。

https://takuya-1st.hatenablog.jp/entry/2022/03/14/171939

authorize.ts
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";

import type { Credentials } from "google-auth-library";
import { google } from "googleapis";

import type { Config } from "./config";

// トークン情報を保存するファイル
const TOKEN_PATH = path.join(process.cwd(), ".credentials.json");

// ユーザーの入力を受け付ける
const prompt = (message: string) =>
  new Promise<string>((resolve) => {
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });
    rl.question(message, (answer) => {
      resolve(answer.trim());
      rl.close();
    });
  });

const loadTokens = async (): Promise<Credentials | null> => {
  try {
    return JSON.parse(await fs.readFile(TOKEN_PATH, "utf-8"));
  } catch {
    return null;
  }
};

export const authorize = async (config: Config) => {
  const client = new google.auth.OAuth2({
    clientId: config.googleClientId,
    clientSecret: config.googleClientSecret,
    redirectUri: "urn:ietf:wg:oauth:2.0:oob",
  });

  // トークン情報が更新されたらファイルに書き出す
  client.on("tokens", async (tokens) => {
    // リフレッシュトークンが含まれていない場合があるので既存のトークンファイルの内容とマージする
    const oldTokens = (await loadTokens()) || {};
    await fs.writeFile(TOKEN_PATH, JSON.stringify({ ...oldTokens, ...tokens }));
  });

  // トークンファイルを読み込む
  let tokens = await loadTokens();

  // トークンファイルがない場合はブラウザで認証させる
  if (!tokens) {
    const url = client.generateAuthUrl({
      access_type: "offline",
      scope: "https://www.googleapis.com/auth/gmail.readonly",
    });
    const code = await prompt(
      `${url}\n\n` +
        "Open the following URL in your browser, then paste the resulting authorization code below: "
    );
    // コンソールからユーザーにコピペしてもらった認証コードを受け取る
    const result = await client.getToken(code);
    tokens = result.tokens;
  }

  client.setCredentials(tokens);

  return client;
};

Gmail の条件にあった新着メールを取ってくる

認証が終わったら Gmail API を使ってメールの一覧から必要なメッセージを検索します。 q パラメーターに検索クエリを入れることで、Gmail のウェブ画面で検索フィールドに入れたものと同じ結果を API から取得することができます。

送信元のメールアドレスをフィルタするクエリを from: で指定します。我が家では学童以外に別の習い事でも入退室メールを送ってくれるシステムがあったので、複数のメールアドレスを受け付けられるようにしました。複数のメールアドレスを OR 検索させる場合は {from:user1@example.com from:user2@example.com} のように表記するようです。

新着メールを取ってくる仕組みもクエリで表現できます。after: クエリを使うと「指定した日付よりも新しいもの」を指定することができます。調べていて初めて知ったのですが、通常は after:2022/06/01 のような「日付」指定しかできないのですが、UNIX タイムスタンプを渡すと「時刻」も含めて指定できるそうです。

https://www.labnol.org/internet/gmail-search-tips/29206/

そこで、前回メールチェックをしてメッセージを取得できた日時を保存しておき、この after: クエリにタイムスタンプを入れてあげることで、漏れなく新着メールを取得できる仕組みを考えてみました。

最後に、メッセージ一覧の取得 gmail.users.messages.list() はメッセージの ID しか返してくれないので、メッセージの内容を取得するリクエスト gmail.users.messages.get() を追加で行います。

fetchMessage.ts
import { google } from "googleapis";

import { authorize } from "./authorize";
import type { Config } from "./config";

export const fetchMessage = async (after: number, config: Config) => {
  // 認証した OAuth2 クライアントを Gmail API にセットする
  const client = await authorize(config);
  const gmail = google.gmail({ version: "v1", auth: client });

  const mutableQuery: string[] = [];
  if (config.emails.length > 0) {
      // From メールアドレスのフィルタのクエリを組み立てる
    const fromQuery = config.emails.map((mail) => `from:${mail}`).join(" ");
    mutableQuery.push(`{${fromQuery}}`);
  }
  // 受信時刻フィルタのクエリを組み立てる
  mutableQuery.push(`after:${after}`);

    // メッセージ一覧の取得
  const result = await gmail.users.messages.list({
    userId: "me",
    maxResults: 1,
    q: mutableQuery.join(" "),
  });

  const messageId = result.data.messages?.[0]?.id;
  if (!messageId) return null;

    // メッセージ詳細の取得
  const message = await gmail.users.messages.get({
    userId: "me",
    id: messageId,
  });

  return message.data;
};

メールを受け取った時刻の読み込み、保存

.timestamp というファイルに直近でメールを受け取った時の時刻を UNIX タイムスタンプで保存したり読み込んだりする処理を作ります。

timestamp.ts
import fs from "node:fs/promises";
import path from "node:path";

const TIMESTAMP_PATH = path.join(process.cwd(), ".timestamp");

export const createTimestamp = (beforeMinutes: number = 0) => {
  const d = new Date();
  d.setMinutes(d.getMinutes() - beforeMinutes);
  return Math.floor(d.getTime() / 1000);
};

export const loadTimestamp = async () => {
  try {
    const file = await fs.readFile(TIMESTAMP_PATH, "utf-8");
    const ts = parseInt(file, 10);
    if (Number.isNaN(ts)) throw new Error(`${file} is not a number`);
    return ts;
  } catch (e) {
    const ts = createTimestamp(5);
    await saveTimestamp(ts);
    return ts;
  }
};

export const saveTimestamp = async (ts: number) => {
  await fs.writeFile(TIMESTAMP_PATH, String(ts));
};

これで新着メールがあったときだけタイムスタンプがファイルに書き込まれます。新着メールがない場合は何度実行しても前回メールを受け取った時のタイムスタンプが使われます。こうすることで取りこぼしなくメールを確認することができます。初回に実行した時は現在時刻のタイムスタンプを使うようにしています。

index.ts
import { loadConfig } from "./config";
import { fetchMessage } from "./fetchMessage";
import { createTimestamp, loadTimestamp, saveTimestamp } from "./timestamp";

(async () => {
  const config = await loadConfig();

  // 次回用に保存するタイムスタンプを作成しておく
  const nextTs = createTimestamp();
  // ファイルに保存されたタイムスタンプを読み込む
  const ts = await loadTimestamp();
  // メッセージを取得
  const message = await fetchMessage(ts, config);
  if (!message) return;

  // TODO: メールの内容を元に Google Home に喋らせる

  // メッセージが取得できた場合のみタイムスタンプを保存
  await saveTimestamp(nextTs);
})();

メッセージの内容から Google Home に喋らせるテキストを作成する

メールの件名や本文を喋らせるだけなら実装がシンプルなのですが、本文の最後の署名などがそのまま読み上げられてしまいます。今回はメールの本文から正規表現でマッチする部分だけを読み上げるようにしてみました。
ちなみにメール本文はリッチテキストを考慮したり Base64 エンドコードされたりしていて扱いがめんどくさいので、 message.snippet を使っています。これはリッチテキスト・プレーンテキストに関わらず、本文の要約を短い文字列で返してくれるカラムです。

https://developers.google.com/gmail/api/reference/rest/v1/users.messages?hl=ja#Message.FIELDS.snippet

createSpeechText.ts
import type { gmail_v1 } from "googleapis";

import type { Config } from "./config";

export const createSpeechText = (
  message: gmail_v1.Schema$Message,
  config: Config
) => {
  const snippet = message.snippet;
  if (!snippet) return null;

  if (config.conditions.length === 0) {
    return snippet;
  }

  for (const condition of config.conditions) {
    const match = snippet.match(condition.regex);
    if (!match) continue;

    return condition.speechText(match);
  }
  return null;
};

Google Home に喋らせる

テキストから音声データを作り、Google Cast に流すというところを手軽にやってくれる google-home-player というライブラリがあったので利用することにしました。

GoogleHomePlayer を初期化する際に、 Google Home の IP アドレスを入れる必要があります。スマホの Home アプリを起動し、対象の Google Home デバイスの設定画面にいって「デバイス情報」を表示することで IP アドレスを確認することができます。

import GoogleHomePlayer from "google-home-player";

const googleHome = new GoogleHomePlayer("192.168.x.x", "ja");
await googleHome.say("こんにちは");

最後にすべて組み合わせれば完成です。

index
import GoogleHomePlayer from "google-home-player";

import { loadConfig } from "./config";
import { createSpeechText } from "./createSpeechText";
import { fetchMessage } from "./fetchMessage";
import { createTimestamp, loadTimestamp, saveTimestamp } from "./timestamp";

(async () => {
  const config = await loadConfig();

  const nextTs = createTimestamp();
  const ts = await loadTimestamp();

  const message = await fetchMessage(ts, config);
  if (!message) return;

  const text = createSpeechText(message, config);
  if (!text) return;

  const googleHome = new GoogleHomePlayer(
    config.googleHomeIp,
    config.speechLanguage
  );
  await googleHome.say(text);

  await saveTimestamp(nextTs);
})();

Raspberry Pi で動かす

実機は手元にあった Raspberry Pi 3 Model B+ を使いました。Raspberry Pi OS Light をインストールして、Wi-Fi、SSHなどをセットアップし、手元の PC から SSH できるようにしました。

今回は Node.js の LTS である v16 を使っているので、ラズパイ側も同じバージョンの Node.js をインストールしました。デフォルトだと少し古いバージョンになってしまうので、 NodeSource から v16 を取得しています。また、git や yarn もインストールしておきます。

$ curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
$ sudo apt-get install -y nodejs
$ sudo apt-get install -y git
$ npm install -g yarn

リポジトリをクローンして設定ファイルを編集します。

$ git clone https://github.com/fujikky/gmail-google-home-notify.git
$ cd gmail-google-home-notify
$ cp config.sample.js config.js
$ pico config.js

その後、依存関係をインストールして、一度実行してみます。

$ yarn --production
$ yarn start

ブラウザで Google 認証後、認証コードをコンソールに入力すると処理が進むはずです。初回はタイムスタンプに現在時刻が使われるため、読み上げられることはありません。その後正常終了すれば .timestamp が保存されます。定期的に yarn start を実行すれば、 .timestamp の時刻から現在時刻までの間に来ているメールが読み上げられます。

定期実行といえば cron なので、cron 用のスクリプトも作りました。シェルスクリプトを経由しないとカレントディレクトリが機能せず、うまく動いてくれなかったためです。

start.sh
#!/bin/bash -eu

BASEDIR=$(cd $(dirname $0) && pwd)

cd $BASEDIR
yarn start >> /var/log/gmail-google-home-notify.log 2>&1

最後に cron の設定を追加します。

$ sudo touch /var/log/gmail-google-home-notify.log
$ sudo chmod 666 /var/log/gmail-google-home-notify.log
$ crontab edit -e

1分ごとに実行する場合は以下のようになります。

*/1 * * * * '/home/xxxx/gmail-google-home-notify/start.sh'

あとはこれを放置しておけば、Google Homeが自動でメールを読み上げてくれます!
よかったら試してみてください!

Discussion