Closed25

Slackのメッセージをメール配信する

ピン留めされたアイテム
appareappare

これからやりたいこと

  • メンションがトリガーになっているので、メール側にもメンションのidが含まれている→メンションを削るかbotの名前に置換する
  • スラッシュコマンドで登録・登録解除できるようにする
  • 他の機能(予算登録・報告&リクエスト)も入れられるよう改造する
appareappare

経緯

部活にSlackを導入したいですって言ったら、ガラケーに対応してる?って言われた。
もちろん対応していないわけで、じゃあメッセージを届けるためにメールで配信すれば良いんじゃね?ってことになった

一応、Classroomは入っているので、必要なときにパソコンからSlackを見るくらいのことはできるはず。

作る物

とりあえずは、メンション(特に@channel)などがとんだ際に自動でメーリングリストにメールを送信する。

Slack APIとGASを使えばできるんじゃないか?って思ってる。

appareappare

Gmailの扱いが楽そうなのでGASを使いたい(Herokuは制限が厳しい気がする)
とりあえず基盤はGASで作りたいので新規プロジェクトを作成。学校のアカウントだとメールが使えない(これも問題だと思うが)ので、個人アカウント。

Claspでやろうとして失敗

Claspと言うのを使うとコマンドラインで操作できるらしい(すごい)

npm install @google/clasp -g #claspのインストール(node.jsを使ってる)
clasp login #ログイン(勝手にブラウザーが立ち上がった)
mkdir slack-mail-notification #ディレクトリを作成
cd slack-mail-notification #ディレクトリに移動
clasp create slack-mail-notification #slack-mail-notificationと言うapp scriptを作成
? Create which script? #スクリプトの種類を聞かれる
> api #どれを選べば良いか分からなかったが雰囲気でAPIを選択
Nested clasp projects are not supported.

よくわかんないですが、上手くいかないらしい

appareappare

学校のアカウントはgmailは使えないのに、GASは使える謎仕様

appareappare

新しいパソコンをもらったのに、未だに古いパソコンでやっていることに気づく。

GASプロジェクトの作成

気を取り直してWEB上で作った物をローカルに持ってくる作戦
昔より公式サイトがきれいになった印象
https://script.google.com/home/start
既にあるプロジェクトをCloneする(Gitっぽい)
設定のProject SettingsからScript IDをコピーしておく

clasp clone [Script ID]
appareappare

スクラップ作ってて思うのは、コマンドラインでスクラップをかければ便利だなぁ、、、などと

GitとTypescriptをセットアップ

Typescriptがサポートされているらしいのでとりあえず

git init #Gitのセットアップ
npm i -S @types/google-apps-script #型の設定
mv Code.js Code.ts #tsに変更
touch .gitignore
.gitignore
node_modules

因みに、clasp push -watchで何秒かに1回pushしてくれる。pushの時に自動でtsをgsに変換してくれる。
https://developers.google.com/apps-script/guides/typescript#upload_a_local_apps_script_project_that_uses_typescript
https://github.com/google/clasp#options-4
pushするときにエラーが出た。よく調べたらGASの設定でAPIをオンにする必要がある。
https://github.com/google/clasp#install
設定を変えたら、上手くいった。

appareappare

ts2gasと言うのを使ってts→gsのコンパイルをしているらしい
Image from Gyazo

appareappare

スクラップに限らないがどれくらいの粒度で情報を書けば良いのか分からぬ

Gmailでメール配信

最後の部分から作ることにした。(一般的にどのような順番で作るのが正解なのか知らない)
GmailAppのsendEmailを使えば良さげ。
制限があるらしいがEmailは相当なこと(1つのメッセージに250も添付ファイルを付けるなど)をしない限り超えなさそう。

Code.ts
const recipient: string = "kaibatsu35.7m45@gmail.com";
const subject: string = "Hallo World";
const body: string = "This is a test message from GAS";

GmailApp.sendEmail(recipient, subject, body);

実行すると、権限を求められるのでOKを出す。
ちゃんと送られていることが確認できる。
Image from Gyazo

appareappare

スクラップに概要欄みたいなの欲しいなって思ったけど、固定すれば良いんだって気づいた。

Gmailで一斉配信

複数のメールアドレスに一斉配信する。
スプレッドシートで、メールアドレスと名前を管理しているとする。
Image from Gyazo
これらをGASから取得してメールを配信する。
と思って、ドキュメントを見てみたのだが長すぎて読む気を失う。
いろいろ調べた結果openByIdを使えば良いらしい。(idは編集画面のURLでhttps://docs.google.com/spreadsheets/d/[id]/edit#gid=0となっている)
SpreadsheetIDを環境変数として保管するか迷ったが、スプレッドシート自体を公開しなければ見られないと言うことを考えて、普通に定数として扱うことにした。
idからデータを取得して最初の項目(ラベル)を削除。名前とメールアドレスを返してくれるようになった。

Code.ts
function getEmailList(
  SpreadSheetID: string
): { address: string; name: string }[] {
  let mailData = [];
  const sheet = SpreadsheetApp.openById(SpreadSheetID);
  const data = sheet.getDataRange().getValues();
  data.shift();
  data.forEach((data) => {
    mailData.push({
      address: data[1],
      name: data[0],
    });
  });
  return mailData;
}

(初めて、dataは単複同形だと言うことを知った)

appareappare

ご飯を食べた

Slackのメッセージを検出してGASにリクエストを送れるようにするが、、、

とりあえずGAS側でPOSTリクエストを受け付けるよう設定を変更。doPost()の関数を作る’。

Code.ts
function doPost(e) {
  const emailList = getEmailList(SpreadSheetID);
  const slackData = JSON.parse(e);
  emailList.forEach((emailData) => {
    SendEmail(
      emailData.address,
      `A message from ${slackData.event.user}`,
      `${slackData.event.user}: ${slackData.event.text}`
    );
    Logger.log(`Sent an email to ${emailData.name}`);
  });
  return ContentService.createTextOutput(JSON.stringify(e));
}

これをDeployしてURLを取得する。本来はテスト用のデプロイができるはずなのだが自分の環境では上手くいかなかった。
Image from Gyazo
仕方が無いので普通にデプロイ。

Slack側の設定

Slackのアプリを作ったらOAuth&PermissionのBot Token Scopesにapp_mentions:readを追加。
Eventのmentionに先ほどのGASをデプロイしたURLを貼り付けるとchallengeパラメーターにtoken入れて返してねと書いてあったので、入力をそのまま返したら上手くいった。

appareappare

そんなこんなで、最終的にはこんな感じのコードになった。

Code.ts
function doPost(e) {
  const emailList = getEmailList(SpreadSheetID);
  const slackData = JSON.parse(e.postData.getDataAsString());
  emailList.forEach((emailData) => {
    SendEmail(
      emailData.address,
      `A message from ${slackData.event.user}`,
      `${slackData.event.user}: ${slackData.event.text}`
    );
    Logger.log(`Sent an email to ${emailData.name}`);
  });
  const response = {
    challenge: e,
  };
  return ContentService.createTextOutput(JSON.stringify(response)).setMimeType(
    ContentService.MimeType.JSON
  );
}

ただ、この状態でも上手くいくのだがuserと言う項目が謎のidになってしまっているためこれは修正が必要。
Image from Gyazo

appareappare

userのデータが欲しいわけだが、Slack APIと言うのを使うっぽい。
https://api.slack.com/methods/users.profile.get
というわけで

Code.ts
function getUserInfo(id: string) {
  const slackUsersApi: string = "https://slack.com/api/users.profile.get";
  const params: object = {
    method: "post",
    headers: {
      Authorization: `Bearer ${PropertiesService.getScriptProperties().getProperty(
        "slackOAthToken"
      )}`,
    },
    payload: {
      user: id,
    },
  };
  const response = JSON.parse(
    UrlFetchApp.fetch(slackUsersApi, params).getContentText()
  );
  return response;
}

で任意のユーザーデータがとれるようになった。

appareappare

メールにユーザー名を含める

先ほどの関数で取得したデータを元に、ユーザー名をメールに含める。
見たところSlack用の方定義が見当たらないので全部anyにしてる(良くない)

Code.ts
 function doPost(e) {
   const emailList = getEmailList(SpreadSheetID);
   const slackData = JSON.parse(e.postData.getDataAsString());
+  const postedUser: string = getUserInfo(slackData.event.user).ok
+    ? "display_name_normalized" in getUserInfo(slackData.event.user).profile
+      ? getUserInfo(slackData.event.user).profile.display_name_normalized
+      : getUserInfo(slackData.event.user).profile.real_name_normalized
+    : "unknown";
   emailList.forEach((emailData) => {
     SendEmail(
       emailData.address,
-      `A message from ${slackData.event.user}`,
-      `${slackData.event.user}: ${slackData.event.text}`
+      `A message from ${postedUser}`,
+      `${postedUser}: ${slackData.event.text}`
     );
     Logger.log(`Sent an email to ${emailData.name}`);
   });
   const response = {
     challenge: e,

上手くいく

appareappare

上手くいってないです
修正

const postedUser: string = getUserInfo(slackData.event.user).ok
    ? getUserInfo(slackData.event.user).profile.display_name_normalized != ""
      ? getUserInfo(slackData.event.user).profile.display_name_normalized
      : getUserInfo(slackData.event.user).profile.real_name_normalized
    : "unknown";

別にdisplay nameが無いからってその項目がないわけではないと言うこと

appareappare

GmailAppからMailAppに書き換える

リファレンスによれば認証の回数が減るらしい(あまり意味ない気もするが)

Changes to scripts written using GmailApp are more likely to trigger a re-authorization request from a user than MailApp scripts.
と言うことで

Code.ts
function SendEmail(recipient: string, subject: string, body: string) {
-  GmailApp.sendEmail(recipient, subject, body, {
+  MailApp.sendEmail(recipient, subject, body, {
     name: "Slack Mail Notification Bot",
   });
 }
appareappare

新規登録をできるようにする

スプレッドシートに登録する関数を作る
appenRow()と言う関数を使うっぽい。結構簡単。
検索にはTextfinderとfindAllで配列を返してくれるらしい。

Code.ts
function addEmail(
  sheet: GoogleAppsScript.Spreadsheet.Sheet,
  name: string,
  address: string
): { error: boolean; message?: string } {
  let error = false;
  let message = "";
  const textFinder: GoogleAppsScript.Spreadsheet.TextFinder = sheet.createTextFinder(
    address
  );
  if (!!sheet.createTextFinder(address).findAll().length) {
    error = true;
    message += "既に登録済みです。";
  }
  if (!error) {
    sheet.appendRow([name, address, new Date().toLocaleDateString("ja-jp")]);
  }
  return {
    error: error,
    message: message,
  };
}

addressがメールアドレスかどうか見る必要がある気がします。メールアドレスの判定は何を使えば良いんですかね?

appareappare

Slackからメールアドレスを取得する

Slack側でGASにリクエストを送るよう設定する。
https://api.slack.com/interactivity/slash-commands
こちらはSlackからPostリクエストが飛んでくるらしいのでこれに対応しなければならない。
これをEvent APIと同じファイルでやろうとすると工夫が必要。PostリクエストのヘッダーがContent-Type: application/jsonの場合にはイベント、Content-type: application/x-www-form-urlencodedの場合はスラッシュコマンドとなる。
これは班別が簡単なのだが、ここから、メールアドレスを取得するのが難易度が高かった。これは自分とは関係ないが、GASがリクエストの実行結果のログが通常だと参照できない仕様なのはかなり困った。
例えば、先ほどユーザー名を取得するのに使った、Web APIだとメールアドレスは取得できない。(項目にメールアドレスは設定されているが、これは''が帰ってくる。これにかなりはまった。
よくリファレンスを見たところ、users:read.emailの権限が必要らしい。(気づかなかった)→これをオンにしても上手くいかない。
いろいろいじっていると、管理者権限でメールアドレスの表示をオンにしないといけないらしい!この設定項目の所には、「アプリ、テストトークン、API経由の場合引き続きメールアドレスにアクセスできます。」と書いてあるんです!!!何もしなくてもアクセスできると思うじゃないですか!!!

appareappare

スラッシュコマンドに対応する

そんなこんなでスラッシュコマンドに対応します。Slackによれば、リクエストヘッダーでtokenを認証しなければならないらしいのですが、GASではリクエストヘッダーを取得できないそうなので仕方なく本体の方で確認することにしました。

Code.ts
 function doPost(e) {
   if (e.postData.type == "application/json") {
     const slackData: slackEventResponse = JSON.parse(
       e.postData.getDataAsString()
     );
-    if (slackData.token == slackAuthToken)
+    if (slackData.token == slackVerificationToken)
       if (slackData.event.type == "app_mention") {
         Logger.log("App mentioned");
         appMentioned(slackData);
       }
+  } else if (
+    e.postData.type == "application/x-www-form-urlencoded" &&
+    e.parameter.token == slackVerificationToken
+  ) {
+    return slashCommand(e);
   }
 }
Code.ts
function subscribe(e) {
  const slackData: Readonly<slashCommandResponse> = e.parameter;
  console.info(e);
  console.info(e.parameter);
  const userInfo = getUserInfo(slackData.user_id);
  const name: string =
    userInfo.profile.display_name_normalized == ""
      ? userInfo.profile.display_name_normalized
      : userInfo.profile.real_name_normalized;
  const address: string = userInfo.profile.email;
  const addEmailStatus = addEmail(sheet, name, address);
  const message: string = addEmailStatus.error
    ? addEmailStatus.message
    : `${address} を登録しました`;
  const response = { text: message };
  return ContentService.createTextOutput(JSON.stringify(response)).setMimeType(
    ContentService.MimeType.JSON
  );
}
appareappare

登録解除コマンドを設定

Code.ts
function deleteEmail(
  sheet: GoogleAppsScript.Spreadsheet.Sheet,
  address: string
): { error: boolean; message?: string } {
  let error = false;
  let message = "";
  const matchCell: GoogleAppsScript.Spreadsheet.Range[] = sheet
    .createTextFinder(address)
    .findAll();
  if (!matchCell.length) {
    error = true;
    message += "このメールアドレスは登録されていません";
  }
  if (!error) {
    sheet.deleteRow(matchCell[0].getRow());
    SendEmail(
      address,
      "メールアドレス登録解除完了",
      `登録解除が完了しました。`
    );
  }
  return {
    error: error,
    message: message,
  };
}

登録の関数を改造しました。

appareappare

Google フォームで申請して自動でリンクを送信

Google フォームでSlackへの参加を申請して、自動的にメールを送信する仕組みを作りたかった。しかし、招待を作成するには、botに管理者権限が必要となり、これをインストールするためにはワークスペースを有料プランに変更する必要があるそうだ。
そんなお金は無いので、仕方なくGoogle フォームのアドオンから自動的に招待リンクを送信するようにした。
https://workspace.google.com/u/0/marketplace/app/form_notifications/573009629797

appareappare

新規参加者にDMを送信する

新しい参加者に対して、ルールなどを説明したDMを送信したい。とりあえずDMを送信する処理だけ書く。chat.postMessageを使えば良さそうだ。channleには、user IDを使えば良いらしい。

If you simply want your app's bot user to start a 1:1 conversation with another user in a workspace, provide the user's user ID as the channel value and a direct message conversation will be opened, if it hasn't already. Resultant messages and associated direct message objects will have a direct message ID you can use from that point forward, if you'd rather.

送信するデータはMarkdownとBlock Kitでできるそうだ。Block Kitは、GUIで組み立てられる新しいメッセージのフォーマットだそうだ。
引っかかったのは、Block Kitを使うときにはJSONに変更しないといけなかった点だ。

Code.ts
const params: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
    method: "post",
    headers: {
        Authorization: `Bearer ${slackAuthToken}`
    },
    payload: {
        channel: id,
        blocks: JSON.stringify(blocks)
    }
}
UrlFetchApp.fetch(slackCreateMessageAPI, params).getContentText()
const returnObj = { blocks: blocks }
return ContentService.createTextOutput(
    JSON.stringify(returnObj)
).setMimeType(ContentService.MimeType.JSON)

Slackの仕組み上、1度作成したアカウントを消すことができないので、テストができない。仕方が無いので入ってきてくれた人に聞くしかなかったのは辛かった。

このスクラップは2021/08/31にクローズされました