ホントは教えたくない、誰かがSlackにリアクションしたら便乗するChrome拡張を作る

※[日々の業務をプチハック - Chrome Extension(拡張機能)を作ろう！](./cf983b70d0fa4efd8718)の続きです

職場では `Slack` に出退勤時に「おはようございます/お疲れ様でした」の報告と、それにリアクションする文化があります。
(必須ではないですが)目を通した後に、いちいちリアクションしていくのがめんどい。
特に、朝イチでどっさりたまっている前日の退勤メッセージと当日の出勤メッセージへのリアクション。

これなら既存リアクションに便乗して自動クリックすればいいんじゃない？ということで、ChromeExtensionで実装してみます。

欠点は、「誰かが先にリアクションしてくれる」を前提にしているので、みんなが使いだすと成立しないところ・・・
(教えたくないと言う割に書いちゃうのは、記事の引き出しが少ないから・・・)

## プログラムの仕様を考える

「おはようございます」や「おつかれさまでした」のリアクションは、ほとんどの人が同じリアクションを使います。
職場では `:おはよう_ござます:` と `:otsukaresamadeshita:` が定番のようです。


### ざっくり仕様

- Slackを開いたらバックグラウンドで待機
- 未読ページのみで動作
- 誰かが反応対象のリアクションをしたら便乗する

こんな感じで作り込んでいきます。

## 前提

ブラウザでSlackのワークスペースを開いているときに動作します。
スマホやSlackアプリでは使えません。

## manifest.json の作成

DOM操作を簡単にするため、[jQueryのCDN](https://releases.jquery.com/)から [jquery-3.6.3.slim.min.js](https://code.jquery.com/jquery-3.6.3.slim.min.js)をプロジェクト内にDLしておきます。

ファイル構成は下記のようになります。
`manifest.json` と `background.js` が作成していくファイルです。

```text
./slack-auto-reaction
  + jquery-3.6.3.slim.min.js
  + manifest.json
  + background.js
```

### URLの指定について

コンテンツ側だけで動作できるので、 `content_scripts` に設定していきます。
ブラウザ版SlackはSPA(Single Page Application) なので、URLが変わってもページ全体の更新ではありません。
特定のURLだけに反応するスクリプトだと、別のページから移動してきた際に発動できません。
なので `content_scripts.matches` に `["https://app.slack.com/client/*"]` を指定して、初回どのページにいてもスクリプトが読み込まれるようにしておきます。

```json:manifest.json
{
  "manifest_version": 3,
  "name": "slack-auto-reaction",
  "description": "slackに自動リアクション",
  "version": "0.0.1",
  "permissions": ["activeTab", "scripting"],
  "content_scripts": [
    {
      "run_at": "document_end",
      "matches": ["https://app.slack.com/client/*"],
      "all_frames": true,
      "js": ["jquery-3.6.3.slim.min.js", "background.js"]
    }
  ]
}
```

## スクリプトを作成する

いきなり完成形です。この仕様なら20行ちょっとで動作するプログラムを書くことができます。
ここからは、上記の実装に至るまでを解説していきます。

```javascript:background.js
$(() => {
  const query = [
    "otsukaresamadeshita",
    "おはよう_ござます",
    "kawaii",
    "odaijini",
  ]
    .map(
      (reaction) =>
        `button:not(.c-reaction--reacted) > img[data-stringify-emoji=":${reaction}:"]`
    )
    .join(",");

  const regTargetPage = new RegExp("^/client/[A-Z0-9]+/unreads");
  const searchAndClick = () => {
    regTargetPage.test(location.pathname) && $(query).parent().trigger("click");
    setTimeout(searchAndClick, 3000);
  };

  searchAndClick();
});
```

### まずは Slack の仕様と動作を確認

スクリプトを作成するにあたって、Slack側の仕様を確認しておきます。
ページ要素を外からいじる形になるので、操作したいページ要素の調査が必要です。
公式ドキュメントなんてありません。誰かが作ったプログラムをデバッグするときのような、外部仕様と見えるコードから解析していきます。
とはいえHTML/CSSなので、そんなに難しくはありません。

### リアクションボタンの要素情報を確認する

リアクション前/済を判定する必要があるので、開発者ツールから確認します。

#### リアクションボタンを押していないときの要素
![no-reaction.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3104999/00af5746-0249-e861-d533-289a64ebb450.png)

ブラウザの開発者ツールを開き、要素の情報を確認します。
リアクション前はフラットなアイコンが表示されています。

```html:リアクション前(抜粋)
<button
  class="c-button-unstyled c-reaction c-reaction--light"
>
  <img
    src="https://emoji.slack-edge.com/workspace-id/otsukaresamadeshita.png"
    data-stringify-emoji=":otsukaresamadeshita:"
  /><span class="c-reaction__count">11</span>
</button>
```

2行目、アイコンの外側にあるボタンの `class` に注目してください。

#### リアクションボタンを押したときの要素

![has-reaction.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3104999/97dd2419-7c77-59cf-e1f9-84e89908ca75.png)

ボタンの見た目が違いますね。ボタンを押したら `c-reaction--reacted` のクラスがあたってアウトライン表示になりました。

```html:リアクション後(抜粋)
<button
  class="c-button-unstyled c-reaction c-reaction--light c-reaction--reacted"
>
  <img
    src="https://emoji.slack-edge.com/workspace-id/otsukaresamadeshita.png"
    data-stringify-emoji=":otsukaresamadeshita:"
  /><span class="c-reaction__count">12</span>
</button>
```

以上のことから、`c-reaction--reacted` の有無で状態が判定できそうです。

#### クエリを考える

調査した「お疲れ様でした」のボタンを見ると、「`c-reaction--reacted` ではないボタン要素に内包されたおつかれアイコン」を検索すれば良さそうです。そのおつかれアイコンの親要素が押すためのボタンになります。
(「おつかれアイコンを含むボタン要素」は、意味が似ていますが子要素を条件にした検索条件はできません)

おつかれアイコンは `img` 要素で `data-stringify-emoji=":otsukaresamadeshita:"` のプロパティを持っているので、これを条件にします。

Chromium系の開発者ツールではデフォルトで `$` 、 `$$` といった要素検索のためのスクリプトが用意されているのでこちらで実験しましょう。

```javascript:css要素取得のテスト
// .c-reaction--reacted クラスを持っていない button タグで、子要素の
// imgタグのうち data-stringify-emoji プロパティが ":otsukaresamadeshita:" のものを検索
$('button:not(.c-reaction--reacted) > img[data-stringify-emoji=":otsukaresamadeshita:"]')
```

上記スクリプトを開発者ツールのコンソールで実行すると、対象のボタンが一つ取得できたと思います。
これでクエリの元ネタができました。

## background.js の作成

### 反応先を未読メッセージに限定する

過去のメッセージを見返すときにも反応してしまうとやりすぎなので、新鮮な「未読」メッセージのみ反応することにします。
そこで、反応するURLを未読ページに限定します。

ページのURL検査をするため、正規表現を用意します。
`[A-Z0-9]+` の部分にはワークスペースのIDが入ります。特定のワークスペースに限定したい場合はブラウザからURLを確認してください。

```javascript
  // 対象はSlackの未読ページのみとする
  const regTargetPage = new RegExp("^/client/[A-Z0-9]+/unreads");
```

※ Slackに「未読」リストが表示されていない場合、「その他」から開くか、`Ctrl + Shift + A` で開けます。
「環境設定」から、「サイドバーにいつも表示する項目」で選んでおくと良いです。


### 反応するリアクションのリストを作成

反応させたいアイコンを特定する文字列を列挙します。
`data-stringify-emoji` で確認した文字列を入れていきます。

```javascript
  // 自動反応するリアクションの種類
  const targetReactions = [
    "otsukaresamadeshita",
    "おはよう_ござます",
    "kawaii",
    "odaijini",
  ];
```

「おはよう/おつかれ」以外に、ペットチャンネルの「かわいい」や体調不良時の「お大事に」も定番なので入れておきました。

### ボタン特定のクエリを作成

対象が決まったので、リアクション文字列からjQueryにわたす検索クエリを作成します。
使い方はCSSセレクタとほぼ同じなので、条件を `,` で繋いでいけばOK。
先に定義したリアクション文字列から、検索クエリを作成します。

```javascript
  // queryを作成
  const query = targetReactions
    .map(
      (reaction) =>
        `button:not(.c-reaction--reacted) > img[data-stringify-emoji=":${reaction}:"]`
    )
    .join(",");
```

### 一定時間で検索・リアクションする関数を作成


SlackはSPAなので、DOMを直接監視する `MutationObserver` はちょっと使いにくいです。
そこで一定間隔で画面を監視する関数を作成します。

```javascript
  // 対象はSlackの未読ページのみとする
  const regTargetPage = new RegExp("^/client/[A-Z0-9]+/unreads");

  // 一定間隔で画面をチェック、リアクションボタンを押す
  const searchAndClick = () => {

    // 未読ページに限り、クリック対象のリアクションを探す
    if (regTargetPage.test(location.pathname)) {
      // リアクションアイコンが特定されるので、親のボタンをクリック
      $(query).parent().trigger("click");
    }

    // 3秒後に再検査
    setTimeout(searchAndClick, 3000);
  };

  // 初回実行
  searchAndClick();
```

## スクリプト全体像

以上をまとめて、`background.js` の全体像が以下になります。

```javascript:background.js
$(() => {
  // 対象はSlackの未読ページのみとする
  const regTargetPage = new RegExp("^/client/[A-Z0-9]+/unreads");

  // 自動反応するリアクションの種類
  const targetReactions = [
    "otsukaresamadeshita",
    "おはよう_ござます",
    "kawaii",
    "odaijini",
  ];

  // queryを作成
  const query = targetReactions
    .map(
      (reaction) =>
        `button:not(.c-reaction--reacted) > img[data-stringify-emoji=":${reaction}:"]`
    )
    .join(",");

  // 一定間隔で画面をチェック、リアクションボタンを押す
  const searchAndClick = () => {

    // 未読ページに限り、クリック対象のリアクションを探して親要素のボタンを押す
    if (regTargetPage.test(location.pathname)) {
      // リアクションアイコンが特定されるので、親のボタンをクリック
      $(query).parent().trigger("click");
    }

    // 3秒ごとに再検査をループ
    setTimeout(searchAndClick, 3000);
  };

  // 初回実行
  searchAndClick();
});
```

コメントでふくれていますが、先に提示した通り実質20行ちょいの実装ボリュームです。

## 完成！

ChormeExtensionの登録方法については[過去記事・拡張機能として登録する](https://qiita.com/fsd-tomo-yoshida/items/cf983b70d0fa4efd8718#%E6%8B%A1%E5%BC%B5%E6%A9%9F%E8%83%BD%E3%81%A8%E3%81%97%E3%81%A6%E7%99%BB%E9%8C%B2%E3%81%99%E3%82%8B)を参考にしてください。

あとは未読ページで新規メッセージを表示させつつ、誰かがリアクションするのを待ち構えるだけです！

ところで、リアクションは自動/手動のどちらで操作したのかはわかりません。
ですがここまで説明してきた仕様の裏をかいて「あ、コイツ自動だな」と判断する方法はあります。教えないけど。