🌟

Electronで作る絶対に見逃さないSlack通知ツール

2024/12/25に公開

この記事は jig.jp Advent Calender 2024、25日目の記事です。

こんにちは。Yです。普段はSPOTLIGHTSのサーバを担当しています。
今回はElectronでアプリケーションを作る方法を解説します。

Slack通知に気付けない

皆さんはこんな経験はありませんか?

  • さっきDMしたんだけど・・」と直接言われる
  • 昨日Mentionした件どうなってる?」と言われるが心当たりがない
  • 早く会議室に来てください‼️」と言われて慌てて会議室に駆け込む

SlackでDMやMentionが来ると通知センターに通知され、通知音も鳴ります。なので通常は問題なく気付けるはずです。
Googleカレンダーの予定は5分前(設定による)に通知が来ます。Slackと連携している場合はSlackからも通知されます。なので会議に遅れることは普通はありません。

しかし作業に深く集中していてこれらの通知に気付けない人がいます。そう、私です

一般的な対策

通知音の音量を上げる

通知音の音量を上げると確かに気づきやすくはなります。しかし以下のような問題があるためおすすめできません。

  • 会議中はミュートにすることが多く、会議後に戻し忘れてしまう
  • 慣れてしまうとそれすら気づかなくなる
  • 周囲の迷惑になる
    • ※私はイヤホンを付けない派です

Slackの通知センターの設定を通知パネルにする

通知パネルはクリックするまで表示され続けるため、これはかなり有力な解決方法です。
しかし残念ながら通知パネルは画面端に表示されさほど大きくもないため、作業に集中して視野が狭くなっている場合、それを見落とす可能性が高いです。

通知ツールを作成しよう

そんなわけで、Slack通知ツールを自作することにしました。

こんなのをつくりたい

  • 常駐アプリケーションで普段は何も表示されない
  • 新着DMやメンションが来たら、全画面で通知画面を表示
  • 通知画面をクリックすると画面が閉じSlackでそのメッセージが開く

これが実現できれば強制的に作業は中断され、Slackのメッセージを確認することになるでしょう。
名付けてExtreme Slack Notifier (略称: xsn)です。

Slackから新着メッセージの情報を得る方法

今回はSlack Web APIsearch.messageを使用します。
DMとメンションそれぞれで自分宛てのメッセージを検索します。
API回数制限は20回/分なので、最短6秒間隔でDM検索とメンション検索を実行できます。

こちらの記事を参考に、SlackワークスペースにSlack Appを作成し、OAuth Tokenを取得してください。Scopeはsearch:read です。

言語・フレームワーク選定

全画面ウインドウを表示するということは、GUIアプリケーションです。
GUIを作るためのライブラリは色々あります。

安直ですがSlack.appがElectron製なのでElectronを使います。

Electronとは?

Electron公式サイト: https://www.electronjs.org/ja/

GUIアプリケーションを作るためのソフトウェアフレームワークです。SlackやVSCodeなどで使われています。

メインプロセスはNodeで動作し、レンダリングエンジンはChromiumです。
Chromiumは要するにWebブラウザなので、表示部分はHTML/CSSで作成します。
Chromiumのせいでプログラムファイルが大容量(100MB〜)で動作速度がやや遅いという弱点はありますが、今回は問題にならないでしょう。

作り方

※筆者の環境はM3 MacBook Proであり、それに準拠した開発方法・アプリ仕様となっています。Windowsなどでは若干勝手が違う可能性がありますのでご注意ください。

必要なプログラムのインストール・プロジェクト作成

まずはNode.jsをインストールします。Electronのチュートリアルでは最新LTS版(2024年12月時点ではv22.12.0)が推奨されています。

$ node -v
v22.12.0

下記コマンドでnpmプロジェクトを初期化します。

$ mkdir xsn
$ cd xsn
$ npm init
→ ウィザードは全てデフォルトでOKです。

必要なライブラリの追加

xsnで使用するライブラリは以下のとおりです。

  • Electron: Electron本体
  • Electron-Packager: 実行形式ファイルを作るためのもの
  • electron-store: Electronでファイルに値を保存するためのライブラリ
  • @slack/web-api: SlackAPIを使うためのライブラリ

下記コマンドでこれらのパッケージを追加します。

$ npm install --save-dev electron electron-packager
$ npm install electron-store @slack/web-api

生成されたpackage.jsonを下記のように書き換えます。
"type": "module"を追加、mainをsrc/main.jsに変更、scriptsにstartとbuildを追加してください。

package.json
{
  "name": "xsn",
  "version": "1.0.0",
  "type": "module",
  "main": "src/main.js",
  "scripts": {
    "start": "electron .",
    "build": "electron-packager . --overwrite",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "description": "",
  "devDependencies": {
    "electron": "^33.2.1",
    "electron-packager": "^17.1.2"
  },
  "dependencies": {
    "@slack/web-api": "^7.8.0",
    "electron-store": "^10.0.0"
  }
}

※"type": "module"によりプロジェクトがCommonJSからES Moduleになります。これはES Moduleであるelectron-storeを使用するためです。ちなみにNode v22.12.0からrequire(esm)でES Moduleをそのまま読み込めるようになりましたが、Electron v33の内部実行環境はNode v20系なのでまだそれは使えません。

参考:
Electron での ES Modules (ESM)
CommonJSとES Modulesについてまとめる

設定画面の実装

まず、Electronの基本的な動作を理解するためにも、設定画面を実装します。
設定画面の仕様は下記のとおりとします。

  • 設定画面にはSlack OAuth Tokenを入力するテキストボックスと保存ボタンがある
  • Slack OAuth Tokenが設定されてなければ起動時に設定画面を開く
  • 保存ボタンを押すとSlack OAuth Tokenがファイルに保存される
  • ファイルから読み込んだSlack OAuth Tokenがあればテキストボックスのデフォルト値に設定される
  • タスクトレイメニューから設定画面を開くことができる

プロジェクトのディレクトリ・ファイル構成

このプロジェクトは複数の画面を実装する予定です。全体を制御するためのmain.jsと、各画面ごとのディレクトリという構成にします。いまは設定画面のみ実装するのでsettingディレクトリだけ作成しています。

  • package.json
  • node_modules/
  • src/
    • main.js : エントリーポイント
    • file_path_utils.js : ファイルパス関連ユーティリティー
    • setting/
      • setting.js : 設定画面のメインプロセス用スクリプト
      • index.html: 設定画面のHTML
      • preload.js : 設定画面のプリロードスクリプト
      • renderer.js : 設定画面のレンダラースクリプト
  • resources/
    • trayicon.png : トレイアイコン画像

トレイアイコン画像
トレイアイコン画像

3種類のスクリプト(重要)

Electronアプリには3種類のスクリプトがあります。
ざっくりと以下のような使い分けとなっています。

種類 実行環境 用途
メイン Node ウインドウを開くなど色々(直接的なDOM操作は不可)
プリロード Chromium(ページ読込前) メインとレンダラーのプロセス間通信の定義
レンダラー Chromium DOM操作などブラウザでできること

メインスクリプトのウインドウを開くコードは以下のようになっています。

win = new BrowserWindow({
  webPreferences: {
    preload: getAbsoluteFilePath(import.meta.url, "./preload.js"),
  },
});
win.loadFile(getAbsoluteFilePath(import.meta.url, "./index.html"));
  1. ウインドウにプリロードスクリプトを設定
  2. HTMLを読み込ませる
  3. HTMLに書かれたscriptタグでrenderer.jsをロードする

というふうにプリロードとレンダラースクリプトが読み込まれ、ブラウザのレンダラープロセス上で動きます。

これらを踏まえて以下に続くコードを見ていってください。

src/main.js (メインスクリプト)

src/main.js はpackage.jsonで指定しているプロジェクトのエントリーポイントです。

src/main.js
// これはメインプロセスで動作します
// app.whenReady().thenがこのアプリのエントリーポイントです
// このファイルはアプリケーション全体の制御を担当します

import { app, Tray, Menu } from "electron";
import { getSetting, openSettingWindow } from "./setting/setting.js";
import { getAbsoluteFilePath } from "./file_path_utils.js";

// アプリケーションが起動したときの処理
app.whenReady().then(() => {
  // タスクトレイの初期化
  initTray();
  // 設定を読み込む
  const setting = getSetting();
  // SlackTokenが未設定の場合は設定画面を開く
  if (!setting.slackToken) {
    openSettingWindow();
  }
});

// タスクトレイの初期化
const initTray = () => {
  let imgFilePath = getAbsoluteFilePath(import.meta.url, "../resources/trayicon.png");
  const tray = new Tray(imgFilePath);
  tray.setToolTip(app.name);
  const contextMenu = Menu.buildFromTemplate([
    { label: "設定", click: openSettingWindow },
    { label: "終了", role: "quit" },
  ]);
  tray.setContextMenu(contextMenu);
};

// ウインドウを全部閉じたときの処理
// デフォルト動作はアプリ終了なので空の関数を設定して終了しないようにする
app.on("window-all-closed", () => {});

src/setting/setting.js (メインスクリプト)

src/setting/setting.js
// これはメインプロセスで動作します
// 設定の読込・保存と、設定画面ウインドウの表示を行います
// 同ディレクトリのindex.html, preload.js, renderer.jsと連携して動作します

import { BrowserWindow, ipcMain } from "electron";
import Store from "electron-store";
import { getAbsoluteFilePath } from "../file_path_utils.js";

// プロセス間通信のチャンネル名 preload.jsと対応している
const saveChannel = "save";
const setDefaultChannel = "set-default";

// ipcMainのハンドラ登録済みフラグ
let handled = false;

// 設定値を保存するためのStoreインスタンス 実際の保存先はMacの場合は~/Library/Application Support/xsn/config.json
const store = new Store();
const settingStoreKey = "setting";

// ウインドウのインスタンス
let win = null;

// 設定画面を開く
export const openSettingWindow = async () => {
  if (!handled) {
    // レンダラ側で保存ボタンが押されたときの処理を登録
    ipcMain.handle(saveChannel, save);
    handled = true;
  }
  // 既に開いてたら一旦閉じる
  if (win) {
    if (!win.isDestroyed()) {
      win.destroy();
    }
  }
  // ウインドウを開く
  win = new BrowserWindow({
    width: 240,
    height: 100,
    webPreferences: {
      preload: getAbsoluteFilePath(import.meta.url, "./preload.js"),
    },
  });
  // ウインドウに表示するHTMLを読み込む
  await win.loadFile(getAbsoluteFilePath(import.meta.url, "./index.html"));
  // ウインドウにフォームのデフォルト値(現在の設定値)を送信
  win.webContents.send(setDefaultChannel, getSetting());
};

// 設定値をstoreに保存し、ウインドウを閉じる
const save = (_event, setting) => {
  store.set(settingStoreKey, setting);
  win.destroy();
};

// storeから設定値を読み込む
export const getSetting = () => {
  return store.get(settingStoreKey) || {};
};

src/setting/preload.js (プリロードスクリプト)

src/setting/preload.js
// このファイルはプリロード用サンドボックスで実行されます。
// Electron や Node の組み込みモジュールのサブセット以外のモジュールを読み込むことはできません。
// ウインドウ生成前に実行されるため、ここでDOM操作はできません。
// 主にメインプロセス(setting.js)⇔レンダラプロセス(renderer.js)のプロセス間通信を定義します。

// サンドボックス化されたプリロードスクリプトなのでESMインポートを使用できません。CommonJS方式でインポートします。
const { contextBridge, ipcRenderer } = require("electron/renderer");

// 第一引数によってレンダラプロセス側でwindow.electronAPIが生える
contextBridge.exposeInMainWorld("electronAPI", {
  // メインプロセス → レンダラプロセス
  // メインプロセス側でwin.webContents.send("set-default", value)を実行すると、(winはBrowserWindowのインスタンス)
  // レンダラプロセス側ではwindow.electronAPI.onSetDefault((event, value) => {})で値を受け取ることができる
  onSetDefault: (callback) =>
    ipcRenderer.on("set-default", (event, value) => callback(event, value)),

  // レンダラプロセス → メインプロセス
  // レンダラプロセス側にwindow.electronAPI.saveSetting(slackToken, slackUserID)関数を生やす
  // メインプロセス側ではipcMain.handle("save", (event, slackToken, slackUserID) => {})で値を受け取ることができる
  saveSetting: (setting) => ipcRenderer.invoke("save", setting),
});

src/setting/renderer.js (レンダラースクリプト)

src/setting/renderer.js
// このファイルはレンダラープロセスで実行されます。
// DOMを操作することができます。
// node_modulesやNode.js組み込みモジュールを使うことはできません。
// window.electronAPI(preload.jsで定義)を通じてメインプロセスと通信を行います。

// フォームエレメントを取得
const slackToken = document.getElementById("slack-token");
const saveButton = document.getElementById("save-button");

// 保存ボタンが押されたら設定値をメインプロセスに送信
saveButton.addEventListener("click", async () => {
  setting = {};
  setting.slackToken = slackToken.value;
  await window.electronAPI.saveSetting(setting);
});

// メインプロセスからデフォルト値を受け取ってフォームにセット
window.electronAPI.onSetDefault((_event, setting) => {
  if (setting.slackToken) {
    slackToken.value = setting.slackToken;
  }
});

src/setting/index.html

src/setting/index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>設定画面</title>
  </head>
  <body>
    Slack OAuth Token:<br />
    <input
      type="text"
      id="slack-token"
      placeholder="xoxp-XXXXXXXXX-XXXXXXXXXXX.."
    />
    <button id="save-button">保存</button><br />
    <div id="message"></div>
    <script src="./renderer.js"></script>
  </body>
</html>

src/file_path_utils.js (メインスクリプト)

src/file_path_utils.js
// これはメインプロセス用です。
// ファイルパスに関する汎用的な関数を定義しています。

import path from "node:path";
import { fileURLToPath } from "url";

// 相対パスを絶対パスに変換
// 第一引数には import.meta.url を渡すこと。
export const getAbsoluteFilePath = (importMetaURL, relativePath) => {
  return path.join(path.dirname(fileURLToPath(importMetaURL)), relativePath);
};

実行結果

npm startで起動します。

$ npm start

無事に設定画面が表示されました。

タスクトレイにアイコンが表示され、クリックするとメニューが表示されます。

テキストボックスに適当な文字列を入力して保存ボタンを押すと設定画面は閉じられます。タスクトレイから設定を選択すると設定画面が表示されます。

ここまでのまとめ

Electronを使用して下記の動作を行うことができました。

  • ウインドウを開く
  • メインプロセスからレンダラープロセスに情報を送る
  • レンダラープロセスがメインプロセスから情報を受け取ってDOMを操作
  • レンダラープロセスからメインプロセスに情報を送る
  • メインプロセスはレンダラープロセスから情報を受け取る
  • データの保存と読み込み
  • タスクトレイアイコンの設定
  • タスクトレイメニューの定義

Electronの基本的な動作は押さえられたので、以降は重要な部分をピックアップして解説します。

SlackAPIまわりの実装

前述のとおり、新着メッセージの取得にはSlack Web API を使用します。

src/slack_search.js
// ※一部抜粋

import { WebClient } from "@slack/web-api";
import Store from 'electron-store'
import { getSetting } from "./setting/setting.js"

const searchInner = async (setting, query) => {
  const setting = getSetting()
  const client = new WebClient(setting.slackToken);
  return client.search.messages({query: query, sort: "timestamp", sortDir: "desc"}).then((result) => {
    if (result.error) {
      return null
    }
    if (result.messages.matches.length == 0) {
      return null
    }
    return result.messages.matches[0]
  }).catch((error) => {
    return null
  })
}

こんな感じのコードでSlack検索を行うことができます。queryをto:meにすれば最新の自分宛てDMを、'@username'(usernameは自分のSlackID)にすれば自分宛てメンションを取得することができます。

SlackAPIから取得したメッセージはstoreに保存しておきます。タイムスタンプを比較して新着ありかどうかの判定に使用したり、通知ウインドウをクリックしたときにSlackを開くために使用します。

src/slack_search.js
  // ※一部抜粋
  // 最新DMを取得
  const dm = await searchInner(setting, "to:me");
  const latestDM = store.get(latestDMStoreKey) || {};
  if (dm && dm.ts > (latestDM.ts || 0)) {// 新着判定
    store.set(latestDMStoreKey, dm); // 次回新着判定用に保存
    store.set(messageForNotifyStoreKey, dm); // 通知用storeに保存
    receiveMessageHandler(dm); // 通知ウインドウを開く
    return;
  }

通知ウインドウ

通知ウインドウは全画面で開きます。

src/notify/notify.js
  // ※一部抜粋
  // プライマリディスプレイを取得し、その座標とサイズを使って全画面表示でウインドを開く
  const display = screen.getPrimaryDisplay();
  win = new BrowserWindow({
    x: display.workArea.x,
    y: display.workArea.y,
    width: display.workArea.width,
    height: display.workArea.height,
    resizable: false,
    alwaysOnTop: true,
    webPreferences: {
      preload: getAbsoluteFilePath(import.meta.url, "./preload.js"),
    },
  });
  await win.loadFile(getAbsoluteFilePath(import.meta.url, "./index.html"));

通知画面がクリックされたときにはshell.openExternalメソッドにSlackURLを渡すとSlackアプリが開きます。

src/notify/notify.js
// ※一部抜粋
// 通知画面がクリックされたときの処理
const onClick = () => {
  // SlackのURLを生成して開く。
  // URLはslack://なのでSlack.appが開き、クエリ文字列に対応した画面が表示される
  const slackUrl = createSlackUrl(message);
  shell.openExternal(slackUrl);
  win.destroy(); // 通知画面を閉じる
};

// SlackのURLを生成
const createSlackUrl = (message) => {
  let slackUrl =
    "slack://channel?team=" +
    message.team +
    "&id=" +
    message.channel.id +
    "&message=" +
    message.ts;
  // threads_tsを付加することでスレッド内のメッセージを表示できる
  const threadTsIndex = message.permalink.indexOf("?thread_ts=");
  if (threadTsIndex != -1) {
    slackUrl += "&" + message.permalink.substring(threadTsIndex + 1);
  }
  return slackUrl;
};

ここまでの実装のソースコードは https://github.com/ApplePedlar/xsn-protorype

更に作り込む

あとは実際に使いながら追加実装をしていきます。ここからは各自の好みで実装していけばよいかと思います。以下は追加実装例です。

  • 複数ディスプレイに対応(私はトリプルディスプレイ派)
  • メッセージの種類によって背景色を変える
    • メッセージの種類はDM, メンション, Googleカレンダーの予定通知, Asana通知など
  • メッセージの種類によって通知ダイアログを閉じるのに必要なクリック回数を変える
    • 10回くらいにすれば無意識スルーを完全無効化できる
  • Googleカレンダーの予定通知で会議室の名前を表示
  • 不要なメッセージの除外
    • 自分が送ったものは除外
  • 履歴表示
    • 検索結果を全部storeに保存しておく
    • 履歴画面を新規追加
  • Slack以外の通知を追加
    • 時報, 株価アラート, 為替相場アラート, 各種SNS通知, emailなどなど

トリプルディスプレイ全てに表示させてみました

ミーティングに間に合いました

履歴画面をつくってみました

まとめ

今回はElectronを使用した常駐型デスクトップアプリであるExtreme Slack Notifierの作成方法を紹介しました。

このツールが正常動作している限り、私がSlack通知を見逃すことはもうありません。

ところで弊社 株式会社jig.jp の企業理念は 「利用者に最も近いソフトウェアを提供し、より豊かな社会を実現する」 です。

今回Extreme Slack Notifierを作成することにより、利用者(自分)に最も近いソフトウェアを提供し、より豊かな社会(DMやメンションが無視されない社会)を実現しました🎉

jig.jp Engineers' Blog

Discussion