KMC活動ブログ

京大マイコンクラブの活動の様子を紹介します!!

独自のSlack転送システムを作った話

こんにちは!現会長のryokohbato (KMC ID: rhato, ID: ryokohbato) です。普段はたまに雑用業務をしつつKMCライフを満喫しています。今回はKMCの新歓で使うために開発した、独自のSlackメッセージ転送システムについて紹介するよ!

概要

現在のKMCではSlackが主な活動の場になっており、KMC Slackには部員のいろいろなことが書き込まれているわけですが、体験入部時にはアカウントがMCG (マルチチャンネルゲスト) に設定されているため、それらを見ることはできません。しかし今年の新歓では新たな試みとして、体験入部時にもその一部を見えるようにすることで、KMCの空気感を知ってもらおうという話になりました。

Slackにはreacji (リアク字)と呼ばれる機能があり、特定のリアクションが付けられた投稿を自動で別のチャンネルに転送することができます。これを使って、外部に見せても良い投稿をゲストアカウントからも見えるチャンネルに転送するようにすれば良さそうです。

Reacji Channeler
reacji (Reacji Channeler) によりメッセージが転送された様子

ところが、この機能はSlackのゲストアカウントとの相性が悪いです。(当然といえば当然ですが、) ゲストアカウントからは見えないチャンネルからゲスト用チャンネルにreacjiで転送されたメッセージは、ゲストアカウントからは見えません。

それぞれのアカウント種別から見ることができるメッセージの範囲
それぞれのアカウント種別から見ることができるメッセージの範囲

そこで、reacji相当の機能を独自に開発することにしました。

そうしてできたのが、kmc-reacjiです!宗教上の理由によりオープンソースとなっております。

登録された部員が、自分の投稿に特定のリアクションを付けた場合に、全く同じ内容をゲスト用チャンネルに投稿します。テキストだけなく画像も転送されますが、画像はそのまま投稿するのではなく、Gyazo APIを使ってアップロードされた画像のリンクを投稿します。

kmc-reacjiによりメッセージが転送されている様子
kmc-reacjiによりメッセージが転送されている様子

詳しい実装の話

転送が発生する条件は以下の通りとしました。

  • kmc-reacjiのシステムに登録されていること
  • :transfer: のリアクションが 本人により 押されること
  • リアクションが押された投稿が その本人の投稿 であること

kmc-reacjiでは、「誰の投稿をどのチャンネルに転送するか」を src/transfer-rule.ts で管理しており、「システムへの登録」というのはこのファイルに自分のユーザーIDを記入することを指しています。

ここからは詳しい実装についてささっと書いていきたいと思います。ちなみに私は axiosAPIを叩くのが好きなので、有名な node-slack-sdk などは使わず、express でサーバーを立てて axios で Slack APIGyazo APIを叩いています。

プログラムの全体的な動作は以下のような感じです。

  1. reaction_added イベントを受け取る (KMC Slackのチャンネルで押された絵文字リアクションの全てを受け取っている)
  2. イベントを発生させたユーザー、すなわち絵文字リアクションを付けたユーザーを特定して、そのユーザーがシステムに登録されているかどうかを確認する
  3. 自分の投稿に自分で押したリアクションであることを確認する
  4. リアクションが :transfer: であることを確認する

プログラムの本体は src/kmc-reacji.ts で全てです。簡単に紹介します。

まずは express でサーバーを立てます。先頭で response.end(); していますが、これはSlack APIあるあるで、独自のUIを作ってユーザーに選択させる、みたいなものを作るときには3秒以内に応答する必要がある 1 のでそのクセでこう書いています。今回は応答が遅くなっても大丈夫なはずですが、わざわざ遅らせる意味もないので先頭で応答しておきます。

const express = require("express");
const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.post("/", async (request: any, response: any) => {
  response.end();

  // ここから先にプログラムが続く
}

次に、転送するメッセージを取得します。絵文字リアクションが押されたメッセージのテキストを取得したいですね、絵文字リアクションイベントにはこの情報は含まれていないので、ts (タイムスタンプ) を使って頑張って取得する必要があります。それをしているのが下の部分です。

絵文字リアクションが押されたメッセージの投稿時間はイベントに含まれているので、この時間の1ms前から1ms後までの間に投稿されたメッセージを検索しています。 正直ここはもうちょっとなんとかしたい。。。

const requestBody = request.body;
// 略
const target_channel: string = requestBody.event.item.channel;
const target_ts: string = requestBody.event.item.ts;
const latestTs = `${target_ts.split(".")[0]}.${Number.parseInt(target_ts.split(".")[1]) + 1}`;
const oldestTs = `${target_ts.split(".")[0]}.${Number.parseInt(target_ts.split(".")[1]) - 1}`;

const target_message = await axios.get("https://slack.com/api/conversations.history", {
  headers: {
    Authorization: `Bearer ${token.slack.user}`,
    "Content-Type": "application/json",
  },
  params: {
    channel: target_channel,
    latest: latestTs,
    oldest: oldestTs,
  },
});

さて、次に必要なのは投稿時のアイコンとユーザー名の設定です。Nullish coalescing operator (??・Null合体演算子)、便利ですねー

const profile = target_user_info.data.user.profile;
const icon_url: string =
  profile.image_original ??
  profile.image_512 ??
  profile.image_192 ??
  profile.image_72 ??
  profile.image_48 ??
  profile.image_32 ??
  profile.image_24 ??
  "";
const user_name = target_user_info.data.user.name;

ちなみに、ユーザー名やアイコンを個別に設定してメッセージを投稿する場合、chat:write.customize permission を与えた上でBot User OAuth Tokenを使う必要があります。

さて、最後に画像をGyazo APIを使ってアップロードしていきます。ハマりポイントがたくさんあって大変でした。

まず、メッセージに添付された画像のデータを取得するには、url_private に書かれたURLにBot User OAuth Tokenを記載したAuthorizationヘッダを付けてGETリクエストを投げる必要があります。

以下のようにして、添付された全ての画像のデータをArrayBufferで受け取ることができます。(MIME typeがimage/から始まるものを画像と判断しています。)

const images_data: { data: any; mimetype: string }[] = await Promise.all(
  target_message.data.messages[0].files
    .filter((x: any) => /image\/.*/.test(x.mimetype))
    .map(async (x: any) => {
      return {
        data: (
          await axios.get(x.url_private, {
            headers: {
              Authorization: `Bearer ${token.slack.user}`,
            },
            responseType: "arraybuffer",
          })
        ).data,
        mimetype: x.mimetype,
      };
    })
);

また、Gyazo APIのUploadを叩く際は、filenameを指定しないと動きません。

かなり適当にfilenameを決めてしまっていますが、とりあえず動くのでヨシ!

const gyazo_urls = await Promise.all(
  images_data.map((x) => {
    const form = new FormData();
    form.append("access_token", token.gyazo);
    form.append("imagedata", x.data, {
      filename: `${target_ts}__kmc-reacji`,
      contentType: x.mimetype,
    });
    return axios.post("https://upload.gyazo.com/api/upload", form);
  })
);

そんなこんなで完成です!メッセージに添付された画像データを取得するところでかなりハマってしまい、画像の転送が実装されるまでに1ヶ月以上かかってしまいました。。。

App Manifestも置いておきます。(event_subscriptionsrequest_url は隠しています)

display_information:
  name: kmc-reacji
  description: お試し用memoチャンネルにメッセージを転送
  background_color: "#008000"
features:
  bot_user:
    display_name: kmc-reacji
    always_online: true
oauth_config:
  scopes:
    user:
      - channels:history
      - reactions:read
    bot:
      - channels:read
      - chat:write
      - chat:write.customize
      - chat:write.public
      - users:read
settings:
  event_subscriptions:
    request_url: https://example.com/
    user_events:
      - reaction_added
  org_deploy_enabled: false
  socket_mode_enabled: false
  token_rotation_enabled: false

あとがき

継ぎ足しのソースコードで型定義も最悪なので、このあたりはなんとかしたいですね。今度Twitterへの転送機能も付けたいなー。

宣伝

今年もKMCでは 新入生プロジェクト を開催します。また、今年の Webサービス勉強会 は私が担当します。今回紹介したようなBotの開発やWebサービスの開発に興味のある方は、以下の入部案内を参考にメールやTwitterのDMなどでいつでもご連絡ください。お待ちしております!

www.kmc.gr.jp

補足

実際には、間違って転送してしまった際に転送されたメッセージを消去するための :cancel-transfer: という絵文字リアクションも実装していますが、chat.delete しているだけで大したことは何もないので省略します。


  1. 気になる方は 公式ドキュメントのこのあたり を読むと書いてあります。