LoginSignup
13
5

More than 3 years have passed since last update.

私はもう二度と彼女からのメールを見落としたくないのでSlackとMastodonに通知するようにする

Last updated at Posted at 2020-02-24

あああっ!彼女からバレンタイン2日前にチョコくれるってメール来てたのに見落としたっっっ!

今どき珍しく、私は彼女との連絡手段に古き良きE-Mailを利用している。つまりはGMailを使っている。

さて、2020年のバレンタイン(2月14日)が近づいてきてたころ、私は大学の留年やら教育実習の挨拶やらでバレンタインどころの状態ではなかった。

また彼女も正月に聞いた感じかなり忙しそうであった。

したがって今年はバレンタインなしかなぁと思っていた。

さて、2月12日。一通のメールが届いていた。

こんばんは!

14日、会えますかー?
チョコ、そんなに好きじゃないみたいですけど、父にも作る予定なのでいくつかお渡ししようと。

しかしながら前述の通りの状況でメールをチェックするような余力がなかった。気がついたのは私の誕生日である2月22日、そういえば彼女からメール着てるかな?と思って確認したときだ。

バレンタイン、とっくに終わっとるやんけ! どないしょう・・・。

実は彼女からのメールを見落とすのはなにも今回が初めてではない。まあそもそも彼女も私もそこまで頻繁に連絡を取ることに価値を見出していないので、さほど問題になってこなかったのであるが、流石にバレンタインのチョコを逃すのはあかんやろ。

即座に彼女に謝罪のメールを投げつけたら翌日

はっ…こちらこそ昨日の連絡、忘れてました…

などと許してくれたが、再発防止策は講じるべきだ。

忙しくても確実に目を通しているものはなにか

かつてはTwitterを見ない日はなかったが、最近は毎日確実に見ているわけではない。

では何なら見ているか。

  • slack: 家族のやり取りや、ニコニコ動画ユーザー非公式交流slackに参加していて常時タブを開いている
  • Qiitadon(Mastodon): 常時タブを開いているし、こまめに確認している。

というわけでこの2つに彼女からのメールを転送すれば流石にどっちかで気がつくだろう。

GMail -> Slack

GMailからSlackに飛ばすのは恐ろしくかんたんだ。完全にSlackのAPIを叩く気満々で先行事例を探していた私は拍子抜けしてしまった。

「Slack」と「Gmail」の連携機能をチェック! 特定のメールをiPhoneにプッシュ通知する【週刊Slack情報局】 - INTERNET Watch

すなわち、GMailのメール転送機能でSlackの転送先メールアドレスに転送するだけだ。

もともと彼女からのメールにラベル付けと迷惑メールから除外する設定のためにGMailのフィルターを組んでいた私には2分で事足りた。

GMail --(Google Apps Script)--> Qiitadon(Mastodon)

Mastodonに通知を投げるには大きく2通りの方法がある。

  • ActivityPubを実装したサーバーアプリケーションを作成してそれを通知を受けたいアカウントでフォローする
  • 通知を送る用のMastodonアカウントを作成してそれを通知を受けたいアカウントでフォロー、APIを使ってTooTを作成する

まあどう考えても後者のほうが楽なのでMastodon APIを叩くことにした。

有料ならZapierを使うとか言う方法もあるらしいが、流石にそんなことにお金を掛けたくない。

かつてはIFTTTを使う手段もあったようだが、2019年3月31日からその手段はできなくなっている。

で、GMailから対象のメールを引っ張るのも、Mastodonに投稿するのもAPIを叩くということは、それをするサーバーが必要なわけですが流石にVPSで一からやりたくないので、GoogleのAPIをかんたんに叩けるGoogle Apps Scriptを使うことにした。

開発環境をどうするか。

もはや現代においてGoogle Apps Scriptを書くとなればclasp+Typescriptが基本的人権と言っていい。

概ね
clasp が Typescript をサポートした!
に従って環境を作った。

claspを使ったので当然ソースコードはgit管理できる。と言うわけで成果物が
https://github.com/yumetodo/gmail2mastodon
これだ。

ちなみにGASといえば、最近実行エンジンがV8に切り替わったという衝撃のニュースが業界を駆け巡ったが、残念ながらclaspはこれに対応していない。というのも、GASへのソースコードアップロードに使われる REST APIサーバー側で、何かしらのvalidateが走った結果、ナウでヤングな記法のJavaScriptファイルを受け取ると400を突き返すらしい。OSSであるclasp側ではどうにもならず、Google内部での作業が不可欠だ。

追記: という話だったのだが、Issueに「でもうまく行ったぜ?」って書き込みがあったので私も試したらうまくいった。なんかもうすこし細かい条件があるのかも

投稿用のMastodonアカウント作成とMastodon API tokenの取得

今回はなんとなく
https://mstdn.maud.io
にアカウントを作ることにした。

ところがアカウントを作るだけの簡単なお仕事のはずが罠があった。

Qiitadonが採用しているMastodonのバージョンにはないが、その後追加された機能としてBOTフラグ(正式名称はしらない)がある。

image.png

このアカウントは主に自動で動作し、人が見ていない可能性があります

ということをアピールできる。もちろん今回やろうとしていることはまさにBOTなのでこれをチェックしていた。

ところがその状態だとQiitadonのようなBOTフラグを実装していないバージョンのMastodonインスタンスからは、そのアカウントが見えなくなる。つまりリモートフォローできないし、メンションも投げられないし、BOTフラグが立ってる側からのリモートフォローもできない。

@paihu さんのファインプレーがなかったら未だにさまよってたに違いない。
https://qiitadon.com/web/statuses/103711452923765332

あとは普通にtokenを取ればいい。今回、権限はread write:statusesにしておいた。readいらんかった感あるけど。

追記: この部分を単独の記事にしました。
古のバージョンのMastodonからはBotフラグがついたアカウントを認知できない、そう、Qiitadonとかからな!

設定ファイルをどうするか

通知を投げるMastodonのインスタンス名であるとか、通知の送り先とか、TooTの文字数制限とか、転送対象のメールアドレスといった情報は、ソースコードに埋め込みたくないし、git repoにすら含めたくない。というか彼女のメールアドレスが流失してしまうのは困る。

GASで永続化する方法まとめ(設定や処理結果を保存・読み込みしたい時)
を眺めた結果、JSONで書いてGoogle Driveに置くことにした。それを実行時にGASから読み取る。

JSON schema

設定を書くときにJSON schemaがあると便利だということを最近痛感している。そもそも何を書けばいいのか知るためにソースコードを見に行くのは論外だし、ドキュメントを読みに行くのも面倒すぎる。以下を参考にJSON schemaを書いた。

{
    "$schema": "https://json-schema.org/draft/2019-09/schema",
    "title": "gmail2mastodon setting file",
    "type":"object",
    "properties": {
        "maxTootLength": {
            "description": "max Toot length on your instance",
            "type": "number"
        },
        "targetEMailAdresses": {
            "description": "E-Mail adresses that you want to notify",
            "type": "array",
            "items": {
                "description": "E-Mail adress",
                "type": "string"
            }
        },
        "mastodonInstance": {
            "description": "The mastodon instance this bot use",
            "type": "string",
            "pattern": "[^/?#]*"
        },
        "mastodonReciveUserId": {
            "description": "Your userid that recive notification",
            "type": "string"
        }
    }
}

これを使ってjsonを書くには

{
  "$schema": "https://raw.githubusercontent.com/yumetodo/gmail2mastodon/master/scheme.json",
  "mastodonInstance": "mstdn.maud.io",
  "mastodonReciveUserId": "@yumetodo@qiitadon.com",
  "maxTootLength": 500,
  "targetEMailAdresses": [
    "xxx@example.com"
  ]
}

のように$schemaで指定すれば良い。なおここのURLは、サーバーが返すcontent-typeなどはどうでも良いらしく、gistやgithubのrawボタンで手に入るURLでも良いようだ(VSCode上での挙動)。

Google Drive API

さて、そのjsonはgmail2mastodon_settings/setting.jsonに置くことに決めた。あとはそれをGASから読み取ればいい。

export interface Gmail2MastodonSettingFile {
  /**
   * max Toot length on your instance
   */
  maxTootLength?: number;
  /**
   * E-Mail adresses that you want to notify
   */
  targetEMailAdresses?: string[];
  /**
   * The mastodon instance this bot use
   */
  mastodonInstance?: string;
  /**
   * Your userid that recive notification
   */
  mastodonReciveUserId?: string;
}
const getFile = (parentName: string, name: string) => {
  const files = DriveApp.searchFiles(`title = "${name}"`);
  while (files.hasNext()) {
    const file = files.next();
    if (name !== file.getName()) continue;
    //gmail2mastodon_settings
    const parents = file.getParents();
    if (!parents.hasNext()) continue;
    const parent = parents.next();
    if (parentName === parent.getName()) return file;
  }
  throw new Error('no such file: ');
};
const readJson = (parentName = 'gmail2mastodon_settings', jsonName = 'setting.json') => {
  const file = getFile(parentName, jsonName);
  const content = file.getBlob().getDataAsString();
  if (!content) return;
  return JSON.parse(content);
};

どうもGoogle Drive APIにはパスという概念がこれでもかってくらい抹消されているらしい。そのため、std::ifstream("gmail2mastodon_settings/setting.json")くらいでC++で手元で動かすなら書けそうな
内容が上記のように膨れ上がった。

まずファイル名で検索を掛ける。これによって検索結果をたどるイテレータが手に入る。当然目的のファイル以外も引っかかる可能性がある。

const files = DriveApp.searchFiles(`title = "${name}"`);

次にこれをループで回す。

while (files.hasNext()) {
  const file = files.next();
  //後略
}

ループの中では、改めて検索結果で得たファイル名とほしいファイル名を比較する。

if (name !== file.getName()) continue;

また、親ディレクトリの名前を取得・比較する。

const parents = file.getParents();
if (!parents.hasNext()) continue;
const parent = parents.next();
if (parentName === parent.getName()) return file;

あとはJSONにパースしてあげればよい。

const content = file.getBlob().getDataAsString();
if (!content) return;
return JSON.parse(content);
const setting: Gmail2MastodonSettingFile = readJson();
if (
  null == setting.targetEMailAdresses ||
  null == setting.mastodonReciveUserId ||
  null == setting.mastodonInstance ||
  null == setting.maxTootLength
) {
  console.error('invalid setting file');
  return -1;
}

状態やMastodonのAPI tokenをどう持つか

MastodonのAPI tokenもまた公開できない情報だ。さっきの設定ファイルに書いたほうがスッキリしたかもしれないが、その技術検討より前にこちらを決定したという経緯から別の方法をとった。

また状態を設定ファイルでなんとかするのは汚すぎる。

GASで永続化する方法まとめ(設定や処理結果を保存・読み込みしたい時)
を眺めた結果、Properties Serviceを利用した。

読み書きするのは簡単で、

const properties = PropertiesService.getScriptProperties();
const lastDateStr = properties.getProperty('lastDate');
if (null == lastDateStr) {
  console.error('missing lastDate');
  return -1;
}
const mastodonToken = ScriptProperties.getProperty('mastodonToken');
if (null == mastodonToken) {
  console.error('missing mastodonToken');
  return -1;
}

のように取得できる。書き出しはproperties.setPropertyすればいい。

値を手動で設定するには、スクリプトエディターで、ファイル→プロジェクトのプロパティからダイアログがでる。

image.png

image.png

GMailからのメール取得

これは先行事例がたくさんある。

重複取得をどう回避するか

ところがここで一つ問題がある。

先程のGoogle Drive API同様、GMail APIもまた、検索クエリを投げて絞り込みをしていくことになる。では前回の実行で取得済みであるものを弾くにはどうしたらいいだろうか?

上記の記事ではどれも取得したらメールを既読にしてしまう方法をとっている。しかし勝手に既読にされるのは困る。

記事執筆中に見つけた方法は
GoogleがIFTTTサポート終了!?GASでGmailの新着メールをLINE Notifyで通知させてみた! – ADACHIN SERVER LABO

var get_interval = 1; //〇分前~現在の新着メールを取得 #--トリガーをこれに合わせておく!!

  //取得間隔
  var now_time= Math.floor(new Date().getTime() / 1000) ;//現在時刻を変換
  var time_term = now_time - (60 * get_interval); //変換

  //検索条件指定
  var strTerms = '(is:unread after:'+ time_term + ')';

のように、GASのトリガーの間隔と同じだけの幅の時間を対象にする方法だ。

しかし、これだとトリガー間隔を変えるのにコードをいじらないといけなくなるし、トリガーが本当に正確にキックされるかに依存してしまう。

そこで、実行時の時間をProperties Serviceに記録して、それを次回実行時に読み出すことにした。

JavaScriptのDateとの格闘

JavaScriptのDateはクソの塊なので本当はmomentjsかなにかを使いたかったのだが、GASで外部ライブラリを使うのはそれこそ難しい。GASの外部ライブラリ機能はclaspでの手元開発と相性が悪いし、webpackすると場合によってはファイルサイズ制限に引っかかって動かなくなる。

仕方なくDateとの格闘を行った。

まずGASがどんなロケールで実行されるかわからないので、全てUTCで扱うことにした。

また、Dateのコンストラクタ、ひいてはDate.parseが何をパースできるか、どのロケールでパースされるかの保障は基本的にないと思ったほうがよいので、正規表現とDate.UTCのあわせ技でパースした。

interface DateLike {
  /** Gets the year using Universal Coordinated Time (UTC). */
  getUTCFullYear(): number;
  /** Gets the month of a Date object using Universal Coordinated Time (UTC). */
  getUTCMonth(): number;
  /** Gets the day-of-the-month, using Universal Coordinated Time (UTC). */
  getUTCDate(): number;
  /** Gets the day of the week using Universal Coordinated Time (UTC). */
  getUTCDay(): number;
  /** Gets the hours value in a Date object using Universal Coordinated Time (UTC). */
  getUTCHours(): number;
  /** Gets the minutes of a Date object using Universal Coordinated Time (UTC). */
  getUTCMinutes(): number;
  /** Gets the seconds of a Date object using Universal Coordinated Time (UTC). */
  getUTCSeconds(): number;
  /** Gets the time value in milliseconds. */
  getTime(): number;
}
type DateConstructorArgumentApplyArrayType = [number, number, number?, number?, number?, number?, number?];
const isDateConstructorArgumentApplyArrayType = (
  arr: (number | undefined)[]
): arr is DateConstructorArgumentApplyArrayType =>
  2 <= arr.length &&
  arr.length <= 7 &&
  typeof arr[0] === 'number' &&
  typeof arr[1] === 'number' &&
  arr.every(e => typeof e === 'number' || typeof e === 'undefined');
const strToDate = (s: string) => {
  const splitted = /^(\d{4})\/(\d{1,2})\/(\d{1,2}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/.exec(s);
  if (null == splitted || 7 !== splitted.length) {
    throw new Error(`date format is invalid: ${s}`);
  }
  splitted.shift();
  const parsed = splitted.map(d => parseInt(d));
  if (!isDateConstructorArgumentApplyArrayType(parsed)) {
    throw new Error(`date format is invalid: ${s}`);
  }
  parsed[1] -= 1;
  return new Date(Date.UTC.apply(null, parsed));
};
const dateToStr = (d: DateLike) =>
  `${d.getUTCFullYear()}/${d.getUTCMonth() + 1}/${d.getUTCDate()} ` +
  `${d.getUTCHours()}:${d.getUTCMinutes()}:${d.getUTCSeconds()}`;
const dateToQuery = (d: DateLike) => `${d.getUTCFullYear()}/${d.getUTCMonth() + 1}/${d.getUTCDate()}`;

DateLikeというinterfaceはなぜ必要かというと、実はGoogle APIが使うDateはJavascript標準のものとはよく似た別物だからだ。

isDateConstructorArgumentApplyArrayTypeという関数はなぜ必要かというと、Date.UTC.applyするときにnumber[]を渡そうとすると、以下のように怒られる。

error

@paihu さんからas使っちゃえという悪魔のささやきが聞こえた気がする。
https://qiitadon.com/web/statuses/103711891508566837

もう少しでtsconfig.json"strictBindCallApply": falseを設定するところだったが、QiitadonのLTLで@okumurakengo さんからいい方法を教えてもらった。つまりTypeGuard使えばいいんじゃね?というもの。
https://qiitadon.com/web/statuses/103712043231534123
言われてみればそのとおりなので、そうした。

本題のメール取得部分

const concatEMailAdresses = (EMailAdresses: string[]) => EMailAdresses.join(' OR ');

const query = `is:unread after:${dateToQuery(lastDate)} from:(${concatEMailAdresses(setting.targetEMailAdresses)})`;
const threads = GmailApp.search(query);
const nextLastDate = Date.now();
const messages = GmailApp.getMessagesForThreads(threads);
for (const t of messages) {
  for (const m of t) {
    if (!m.isUnread() || m.getDate().getTime() < lastDate.getTime()) continue;
    //中略
  }
}
properties.setProperty('lastDate', dateToStr(new Date(nextLastDate)));

この辺は
【GAS】Gmailの特定条件で検索したスレッドの全メールを取得してスプレッドシートに書き出す
の説明がわかりやすいので丸投げする。

検索クエリでは日付単位までしか絞り込めないので、m.getDate().getTime()で各メッセージの真の受信時刻を取得して比較している。クエリでis:unreadしているのにm.isUnread()見てるのは、あんま検索クエリを信用していないからだ。というかあくまでスレッドを引っ掛けるはずだからなんなら既読のメッセージも含まれるんちゃうか?知らんけど。

Mastodon API

これが結構苦戦した。
Google Apps ScriptからMastodonにトゥートしてみた
をみて意外と簡単やん、とか思ってたら大きな間違いだった。

POSTリクエストを投げるにはUrlFetchApp.fetchを使う。

const payload = `{"status":"${postText}","visibility":"direct"}`;
UrlFetchApp.fetch(`https://${setting.mastodonInstance}/api/v1/statuses`, {
  method: 'post',
  contentType: 'application/json',
  payload: payload,
  headers: { Authorization: 'Bearer ' + mastodonToken },
});

これだけ見ると簡単そうに見える。しかし問題は上で言うところのpostTextをどう作るかであった。

文字数制限

MastodonのTooTは当たり前だが文字数制限がある。大体は500文字だがインスタンスによって変えられる。

ではその文字数はどう数えているのか?

Qiitadonの採用しているmastodonのバージョンの頃は
https://github.com/sallar/stringz/blob/bb25d60f2bc2ce4eb424e73a1166fc40a2d11a17/src/string.js
のように判定されていたが、それが別ライブラリに切り出され、現在ではさらにchar-regexというパッケージによる判定に置き換わっているようだ。
https://github.com/Richienb/char-regex/blob/master/index.js

で、つまるところUTF-16のcode unit数でもcodepoint数でもなく、グリフ単位になっている。ただしいつの時点のUnicode規格に則っているかは不明だ(規格と照らし合わせるのは面倒すぎるのでパス)。

いずれにせよ、GASでそんな判定をやるのは面倒すぎるし、しかもインスタンスごとに異なるとなってはもっと簡便な方法を採用したい。つまりcodepoint単位だ。

当初は
https://github.com/yumetodo/es-string-algorithm
から実装をコピペしたのだが、よく考えたらこれの実装にはfor...of、つまりイテレータが使われている。ES3なGASにそんなものはない。したがって正規表現を使って実装し直す羽目になった。

JavaScriptでのサロゲートペア文字列のメモ#IV-II. サロゲートペアに対応した配列化
を参考にした。

毎度思うがJavascriptの文字列操作系標準ライブラリは貧弱すぎる。

/**
 * Create part of the `s`
 * @param s string
 * @param pos copy start position
 * @param n copy length
 * @returns part of the `s` in range of `[pos...rlast]` (`rlast` is the smaller of `pos + n` and `std.size(s)`)
 * @throws {RangeError} When `pos` or `n` is negative or `pos` > `std.size(s)`
 */
const substr = (s: string, pos = 0, n?: number): string => {
  if (pos < 0) {
    throw new RangeError('std.substr: pos < 0');
  }
  if (typeof n === 'number' && n < 0) {
    throw new RangeError('std.substr: n < 0');
  }
  const arr = s.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]|[^\uD800-\uDFFF]/g) || [];
  if (arr.length < pos) {
    throw new RangeError(`std.substr: pos (which is ${pos}) > std.size(s) (which is ${arr.length})`);
  }
  return arr.slice(pos, typeof n === 'number' ? pos + n : undefined).join('');
};

改行の処理

Mastodon APIでは、どうやら改行するには\nという文字列を送り込まないといけないらしい。つまりTypeScript側では'\\n'のように書くということだ。

で、問題はもともと転送しようとしているメール本文(m.getBody())に改行が含まれている時どうするかだ。

CRは除去し、LFは\nという文字列に置換し、メール本文末尾のLFは除去しないといけない。
上記の文字数制限は、改行が1文字扱いになることを考慮するとCRを除去した直後に掛ける必要がある。

つまりこうなった。

const postTextRaw =
  setting.mastodonReciveUserId + '\\n' + `${dateToStr(m.getDate())} ${m.getSubject()}` + '\\n' + m.getBody();
// limit content length by maxTootLength
const postTextRawLimitted = substr(postTextRaw.replace(/\r/g, ''), 0, setting.maxTootLength);
const postText = postTextRawLimitted.substring(0, postTextRawLimitted.lastIndexOf('\n')).replace(/\n/g, '\\n');

この処理に丸一日悩まされた。どっかにそういう仕様だって書いとけよ、まじで。DocumentのS/N値をあげろし。

成果物

image.png

yumetodo/gmail2mastodon: 私は彼女のメールをもう見落としたくない。だからmastodonに転送するんだ。

image.png

残された課題

冒頭で紹介した彼女とのやり取り。その後を抜粋しておこう。

私: まあ、メール見たけど後で返そうと思って忘れるケースは防げないのでアレ。

彼女: あるあるですね(笑)

13
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
5