🙆

Slackでリアクションした人/していない人のリストを取得するスラッシュコマンドを作ってみた

2021/06/12に公開

Slackでリアクションスタンプをつかって投票やアンケートをすることがあると思います。
↓こういうやつ。

https://slack.com/intl/ja-jp/help/articles/229002507-アンケートを作成する-

このときに、スラッシュコマンドで、リアクションした人/していない人を一覧表示できると便利だなと思いました。
探せば良いツールとかありそうな気もしましたが、ちょうどSlack APIに興味あったので勉強がてら作ってみました。

内容

以下のような動作をするスラッシュコマンドを作成します。

  1. Slackで集計をしたい記事のリンクをコピーする
  2. 同じチャンネル内で以下のスラッシュコマンドを使う
/reactionslist https:/***.slack.com/archives/**********/p99999999999999
  1. スラッシュコマンドの形式は /reactionlist [集計したい記事のURL]
  2. 以下のような投稿が返ってくる

環境

  • Node.js
  • TypeScript
  • Google App Engine(?)

GoogleAppEngineについては、必須ではありません。お好きな環境で実行していただけると思います。

API

SlackのAPIを使います

  • reactions.get: 記事を指定してリアクションのリストを取得します
  • users.list: ユーザー情報を取得します。reactions.get だけだとリアクションしたユーザーのIDしか取得できないので名前を取得するためにつかいます。
  • conversations.members: チャンネルに所属するユーザーの一覧を表示します

Slackアプリ作成

まずはSlackアプリの準備

https://api.slack.com/

こちらからアプリの作成をします。アプリの作成方法はドキュメントや色々親切な記事がたくさんあるのでそちらを参照してください。
いくつかポイントとなるところを紹介します。

参考記事

Slack アプリで使えるコマンドを作成する
Slackアプリ開発の第一歩、スラッシュコマンド「/echo」を利用した簡単なアプリの作り方

Botへのスコープの設定

OAuth & Permissions -> Slopes -> Bot Token Scopes にBotに許可するスコープを設定します。

スラッシュコマンドで使用

  • command

conversations.members で使用

  • channels:read, groups:read, im:read, mpim:read

reactions.get で使用

  • reactions:read

users.list で使用

  • users:read

これらのスコープを設定したら、ワークスペースにインストールします。

アプリをチャンネルに追加

このアプリを使いたいチャンネルに追加します。
追加したチャンネルでスラッシュコマンドが動作するようになります。

必要なトークンを取得

Bot User OAuth Token
OAuth & Permissions -> OAuth Tokens for Your Workspace から取得

Signing Secret
Basic Information -> App Credentials から取得

取得したトークンは環境変数として使うので .env などに記述

.env
SLACK_TOKEN="[Bot User OAuth Token]"
SLACK_SIGNING_SECRET="[Signing Secret]"

Google App Engine で動かす場合は

app.yaml
runtime: nodejs14

instance_class: F1

env_variables:
  SLACK_TOKEN: "[Bot User OAuth Token]"
  SLACK_SIGNING_SECRET: "[Signing Secret]"

みたいな感じになります。

コードを書く

https://github.com/shin1kt/slack-slash-command-reactions-list

こちらに公開していますので参考にどうぞ。

サーバーを作成

Slackのスラッシュコマンドは、設定したURLに送信されて、そこからのレスポンスをSlackのタイムラインに表示する。という仕組みですので、HTTPリクエストを処理できるサーバが必要になります。
今回はExpress.jsをつかってサーバを立ち上げます。

src/app.ts
import dotenv from 'dotenv'
import express from 'express'
import slackVerifying, {SlackRequest} from '@/includes/slack-verifying'
import slackServer from '@/server/slack-server'

// 環境変数からトークンを読み込み
dotenv.config()
const token = process.env.SLACK_TOKEN ?? ''
const signingSecret = process.env.SLACK_SIGNING_SECRET ?? ''

// Express.jsサーバー設定
const app = express()
// bodyをJsonとして取得
app.use(express.json())
// URLエンコードをパース
app.use(express.urlencoded({ extended: true,
  // スラッシュコマンドの送信元の検証のため、bodyの生データが必要なので取得できるようにする
  verify: (req: SlackRequest, res, buf, encoding) => {
    req.rawBody = buf.toString(encoding as BufferEncoding ?? 'utf8')
  }
}))

// スラッシュコマンド対応したURL
// ドメイン/slash/reactions をSlackアプリに設定する(*1)
app.post('/slash/reactions', async (req, res) => {
  // スラッシュコマンドの送信元検証
  slackVerifying(req as SlackRequest, res, signingSecret)
  // コマンド実行
  await slackServer(token, req.body, res)
});

// サーバー起動(ポートはご自由にどうぞ)
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
  console.log(`App listening on port ${PORT}`);
  console.log('Press Ctrl+C to quit.');
});

export default app;

サーバーは、カンタンにこんな感じにしました。
続いて処理内容を作成していきます。

Slackの送信元検証

スラッシュコマンドはHTTPを処理できるサーバーが必要なのですが、ということはスラッシュコマンドからだけでなく通常のリクエストも受け付けてしまいます。
これだとちょっと怖いので、送信元を検証して正しいワークスペースからの送信かどうかをチェックするようにします。

参考記事

Verifying requests from Slack
TypeScript で作る Slack Bot - Slash Commands 編

src/includes/slack-verifying.ts
import crypto from "crypto";
import express from "express";

// リクエストは上記のapp.ts内で生のbodyデータも格納するので、express.Request型を拡張します。
export interface SlackRequest extends express.Request {
  rawBody: string;
}

const slackVerifying = async (
  req: SlackRequest,
  res: express.Response,
  signingSecret: string
) => {
  const rawBody = req.rawBody;
  const timestamp = String(req.headers["x-slack-request-timestamp"]) ?? "";

  // リクエスト時間5分ずれたらエラー(上記参考記事からいただきました)
  if (
    Math.abs(
      parseInt(timestamp, 10) - Math.floor(new Date().getTime() / 1000)
    ) >
    60 * 5
  ) {
    res.sendStatus(403);
    return;
  }

  // 取得したrawBodyとSLACK_SIGNING_SECRETで認証処理
  const actualSignature = req.headers["x-slack-signature"];
  const sigBaseString = `v0:${timestamp}:${rawBody}`;
  const hmac = crypto.createHmac("sha256", signingSecret);
  const digest = hmac.update(sigBaseString).digest("hex");
  const expectedSignature = `v0=${digest}`;
  if (actualSignature !== expectedSignature) {
    res.sendStatus(403);
    return;
  }
};

export default slackVerifying;

正しくないリクエストのときは403でエラーにします。

リアクションを取得

reactions.getAPIにアクセスしてリアクションしたスタンプとユーザーの一覧を取得して整形します。

src/includes/slack-reactions-get.ts
import axios from "axios";

// reactions.getAPIはレスポンスが複雑なので、カンタンなカタチに整形して返すことにしました。
export interface Reaction {
  count: number;
  name: string;
  users: string[];
}

// reactions.getAPIからのレスポンスの型(必要部分だけ)
interface ReactionsGetResponse {
  message?: {
    reactions: Reaction[];
  };
  file?: {
    reactions: Reaction[];
  };
  ok: boolean;
}

/**
 * SlackAPIからリアクションリストを取得
 * @param {String} token
 * @param {String} channelId
 * @param {String} timestamp
 * @returns
 */
const reactionsGet = async (
  token: string,
  channelId: string,
  timestamp: string
) => {
  const url = `https://slack.com/api/reactions.get?channel=${channelId}&timestamp=${timestamp}&pretty=1`;
  const options = {
    headers: {
      "Content-type": "application/x-www-form-urlencoded",
      Authorization: `Bearer ${token}`,
    },
  };
  const users = await axios
    .get<ReactionsGetResponse>(url, options)
    .then((res) => {
      // メッセージへのリアクションかファイルへのリアクションかでレスポンスが変わるので
      // そのあたりを考慮しつつ必要な部分だけを取得
      return res.data.message?.reactions ?? res.data.file?.reactions ?? [];
    });

  return users;
};

export default reactionsGet;

axiosのリクエスト部分は、共通処理にしたりするラッパーを書いたりするほうがおすすめですが、ここでは、そのまま書いちゃいました。

同じ要領で、ユーザー情報の詳細や、チャンネル所属ユーザー一覧を取得するコードを書きます。

ユーザー一覧取得

src/includes/slack-users-list.ts
import axios from 'axios'

export interface SlackUser {
  id: string,
  name: string,
  real_name: string
}

interface UserListResponse {
  ok: boolean,
  members: [
    {
      id: string,
      name: string,
      profile: {
        real_name: string,
      }
    }
  ]
}

/**
 * SlackAPIからユーザーリストを取得
 * @param {String} token 
 * @returns 
 */
const usersList = (async (token: string) => {
  const url = `https://slack.com/api/users.list?pretty=1`
  const options = {
    headers: {
      "Content-type": "application/x-www-form-urlencoded",
      "Authorization": `Bearer ${token}`,
    }
  }
  const users = await axios.get<UserListResponse>(url, options).then((res): SlackUser[] => {
    return res.data.members.map((item) => {
      return {
        id: item.id,
        name: item.name,
        real_name: item.profile.real_name,
      }
    })
  })
  
  return users
})

export default usersList

チャンネル所属ユーザー一覧

src/includes/slack-conversations-members.ts
import axios from "axios";

interface ConversationsMembersResponse {
  ok: boolean;
  members: string[];
}

/**
 * チャンネルメンバーを取得
 * @param {String} token
 * @param {String} channelId
 * @returns
 */
const conversationsMembers = async (token: string, channelId: string) => {
  const url = `https://slack.com/api/conversations.members?channel=${channelId}&pretty=1`;
  const options = {
    headers: {
      "Content-type": "application/x-www-form-urlencoded",
      Authorization: `Bearer ${token}`,
    },
  };
  const users = await axios
    .get<ConversationsMembersResponse>(url, options)
    .then((res) => {
      return res.data.members ?? [];
    });

  return users;
};

export default conversationsMembers;

3つのAPIから値を取得して整形する

リアクション一覧、ユーザー詳細一覧、チャンネル所属ユーザー一覧を取得する関数を作ったので、あとはそれらを呼び出してゴニョゴニョしてSlackにかえしてあげる準備をします。

src/includes/reactions-list-command.ts
import usersList, { SlackUser } from "./slack-users-list";
import reactionsGet, { Reaction } from "./slack-reactions-get";
import conversationsMembers from "./slack-conversations-members";
import validator from "validator";

/**
 * SlackAPIからの値をつかってユーザとリアクションを突き合わせて整形
 * @param {String} token
 * @param {String} channelId
 * @param {String} timestamp
 * @returns
 */
const reactionsListCommand = async (
  token: string,
  channelId: string,
  timestamp: string
) => {
  
  // 3つのAPIを呼び出す。
  // ひとつずつ呼ぶと時間がかかるので Promise.all で並列で呼び出します
  
  // APIをまとめる
  const it = [
    await usersList(token),
    await reactionsGet(token, channelId, timestamp),
    await conversationsMembers(token, channelId),
  ];
  // まとめてAPIを利用
  return await Promise.all(it).then((values) => {
    // APIからレスポンスを取得
    const users = values[0] as SlackUser[];
    const reactions = values[1] as Reaction[];
    const channelUsers = values[2] as string[];
    
    // あとで未回答者算出するために回答者一覧を保存したい
    const answered: string[] = [];
    
    // Slack API からのレスポンスをSlackに返しやすいカタチに整形
    
    // リアクション一覧をmapで回す
    const blocks = reactions.map((reaction: Reaction) => {
      return {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `:${reaction.name}: (${  // リアクションアイコン名を : で囲むとアイコン表示される
            reaction.users.length // 人数を表示
          }人) \n ${reaction.users // リアクションしたメンバーをmapで回す
            .map((userId) => {
              answered.push(userId); // 回答者を配列に保存
              return validator.escape(
	        // リアクションしたメンバーのIDから、名前を取得。(万が一名前がないときはIDそのまま表示)
                users.find((user) => user.id === userId)?.real_name ?? userId
              );
            })
            .join("\n")}`, // メンバー名を改行で連結
        },
      };
    });

    // 未回答者一覧を取得(上記でつくった回答者一覧をチャンネル所属メンバーを突合)
    // 回答していないユーザー一覧を取得
    const unAnswered = channelUsers.reduce((res, userId) => {
      if (!answered.includes(userId)) {
        const name = validator.escape(
          users.find((user) => user.id === userId)?.real_name ?? userId
        );
        res.push(name);
      }
      return res;
    }, [] as string[]);
    
    // 未回答者分の表示を作成
    blocks.push({
      type: "section",
      text: {
        type: "mrkdwn",
        text: `:未回答: (${unAnswered.length}人) \n ${unAnswered.join("\n")}`,
      },
    });

    return blocks;
  });
};

export default reactionsListCommand;

https://api.slack.com/interactivity/slash-commands#responding_immediate_response

Slackのスラッシュコマンドのレスポンス形式はいろいろあるのですが、アイコンを表示したりできる以下の形式が便利そうだったので、それに合わせてまとめるようにしました。

{
    "blocks": [
		{
			"type": "section",
			"text": {
				"type": "mrkdwn",
				"text": "*It's 80 degrees right now.*"
			}
		},
		{
			"type": "section",
			"text": {
				"type": "mrkdwn",
				"text": "Partly cloudy today and tomorrow"
			}
		}
	]
}

Slackへのレスポンスをつくる

最後にSlackへレスポンスを送る部分を作成します。

src/server/slack-server.ts
import reactionsListCommand from "../includes/reactions-list-command";
import express from "express";
import validator from "validator";

interface SlackServerParam {
  channel_id: string;
  text: string;
}

/**
 * サーバー本処理
 * @param {String} token
 * @param {Object} params
 * @param {express.Response} res
 * @returns
 */
const slackServer = async (
  token: string,
  params: SlackServerParam,
  res: express.Response
) => {
  
  // パラメータからチャンネルIDを取得
  const channelId = params.channel_id;
  
  // パラメータから引数のURLを取得
  const url = params.text;
  if (!url || !validator.isURL(url)) {
    res.status(200).send("引数に対象コメントのURLを指定してください").end();
    return;
  }

  // 引数からタイムスタンプ取得
  // 引数のURLから、クエリパラメータを除いた部分の最後のパス(p9999999999形式)から数値のみを取得して、小数点6桁で合わせる。(リアクションを取得するためのタイムスタンプ)
  const timestamp = (
    Number(url.split("?")?.shift()?.split("/")?.pop()?.slice(1)) / 1000000
  ).toFixed(6);
  if (!Number(timestamp)) {
    res.status(200).send("引数の形式が正しくありません").end();
    return;
  }
  
  // 上記で作成したSlackAPIを取得するモジュールからデータ取得
  const blocks = await reactionsListCommand(token, channelId, timestamp);
  if (!blocks || blocks.length < 1) {
    res.status(200).send("データ取得失敗").end();
    return;
  }
  
  // ↑※エラーや引数にエラーがあるときもSlackにメッセージを返したほうが親切かなと思うので、200で文字列を返して終了しています。

  // 集計元URLも表示したほうがわかりやすいのでレスポンスに追記する
  blocks.push({
    type: "section",
    text: {
      type: "mrkdwn",
      text: params.text,
    },
  });

  res.json({ blocks });
};

export default slackServer;

以上でコードは一通り作成しました。
あとはサーバを起動すれば完了です。

webpack

TypeScript+webpackでビルドします。

tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "ES2020",
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"]
    },
    "sourceMap": true,
    "outDir": "./dist",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": [
    "./node_modules"
  ]
}
webpack.config.js
const nodeExternals = require('webpack-node-externals')

module.exports = {
  entry: './src/app.ts',
  target: 'node',
  cache: true, // watchとかするときはtrue
  output: {
    filename: 'app.js',
    path: `${process.cwd()}/dist`,
  },
  // devtool: 'inline-source-map',
  externals: [nodeExternals()],
  module: {
    rules: [{
      test: /\.ts$/,
      use: 'ts-loader',
      exclude: /node_modules/,
    }]
  },
  resolve: {
    extensions: [".ts", ".js"],
    alias: {
      "@": "./",
    },
  }
}

ビルド

$ npm run build

サーバ起動

$ npm run start

ngrok でテスト

とりあえず、ngrokなどでテスト稼働させると便利です。
ngrokに関する詳しい記事はいろいろあるので参照してください。

$ ./ngrok http 8080

参考サイト

ngrok
ngrokが便利すぎる

上記などを参考にしながら起動して仮アドレスを取得します

スラッシュコマンドを設定

Slackアプリの管理画面からスラッシュコマンドを設定します。

Slash Commands から設定します。

  • Command: reactionslist
  • Request URL: 上記で取得したngrokのドメイン等/slash/reactions
  • Short Description、Usage Hint はわかりやすい説明を入力

実行してみる

  1. Slackの集計したい記事の「︙」からリンクをコピー
  2. /reactionslist 上記1のペースト を送信
  3. 暫く待つと、集計結果が表示される

Google App Engineにデプロイ

あとはサーバーにデプロイしていつでも使えるようにします。
今回はGoogle App Engineを使ってみました。
Google App Engineの説明は公式ドキュメントなどに詳しく掲載されています。

  1. app.yaml を書く
app.yaml
runtime: nodejs14

instance_class: F1

env_variables:
  SLACK_TOKEN: "[Bot User OAuth Token]"
  SLACK_SIGNING_SECRET: "[Signing Secret]"
  1. デプロイコマンド
$ gcloud app deploy

以上でデプロイ完了です。
デプロイ時に公開URLが表示されるので、それを上記のスラッシュコマンドの設定で書き換えます。

  • 今回はGAEの無料枠範囲で使いたかったのでF1インスタンスを使いました
  • GAEのインスタンスは長らく使わないと、コールドスタートになってしまいます。
  • Slackのスラッシュコマンドは3秒以内にレスポンスがないとタイムアウトエラーになってしまうという仕様があります。なのでOFF状態から起動するとタイムアウトになってしまいます。
  • そんなときは、2,3秒おいてから、同じコマンドを送信するとうまくいきます。(自分の場合は、そういうゆるい感じで許される範囲だったので特に対応等はしていません)

まとめ

Slackのスラッシュコマンドは3秒ルールはありますが、基本的にHTTPリクエストにレスポンスを返すだけという、ウェブ周りをやっている者にとっては親しみやすい仕様でした。
いろいろ組み合わせて楽しいことができそうです。

長々と書きましたが、SlackAPIには便利なSDKが用意されています。

https://github.com/slackapi

これらを使うと、もっと短いコードで実装できそうです。
自分の場合、リアクション一覧の取得APIがうまく動かなかったので(なぜかは未確認、勘違いとかもあるかも)でゴリゴリ書きましたが、利用したほうがラクになると思います。

また、サーバもちょうどGoogleAppEngineの勉強中だったので利用しましたが、Slackのボットを実装するためのBoltというフレームワークが用意されており、Herokuなどへカンタンにデプロイできるようです。
こちらも利用したほうが楽しく実装できそう。

https://slack.dev/bolt-js/ja-jp/tutorial/getting-started

Web屋にとって親しみやすい仕様の上に、便利なライブラリやフレームワークが用意されているので、いろいろ楽しめそうですね。

Discussion