📕

Slackの画像等のファイルを含めたメッセージをGitHubのIssueコメントとして同期する

2024/10/07に公開

開発に関する重要な会話でもSlackでやりとりがされがちですが、GitHubに連携したいという気持ちがあります。
もちろん、GitHubになるべく書いてください、といって解決すればよいですが、Slackのほうが距離が近いとか、そもそも開発者以外と会話するということもあります。

そのときに、SlackのポストをうまくGitHubに同期する方法を持っておけば、色々工夫できます。この記事では、その同期の部分のみに着目しますが、実際はさらにそれをどう活用するかというのを考える必要があります。例えば、既存のスレッドに !gh-link 123 としたらそこから先のメッセージが Issue 123 に連携される、みたいな。その活用の部分はアイデア次第になります。

同期をするときに、画像やファイルもやはり同期したくなります。今回はその方法を中心に紹介します。

記事内のコードは Slack Deno SDK と GitHub octokit を利用したコードです。

概観

  • まず、事前にアセットを置くためのGitHubリポジトリを用意しておきます。
  • GitHubのコメント投稿用のアカウントとGitHub Tokenも準備しておきます。Appsでもいいと思いますがこの記事ではアカウントで説明します。
  • イベント slack#/events/message_posted でメッセージの投稿を検知
  • なにかしらの情報に基づいて投稿先のGitHub Issueを特定する (この記事では立ち入らない)
    • 例えばスレッドの先頭に埋めこんでおいたりする
  • 対象のSlack投稿から画像などのファイル情報を取ります
  • ファイル情報から実ファイルデータを取ります
  • 実ファイルデータをGitHubのアセットリポジトリにコミットとして入れていきます
  • アセットリポジトリに入れたファイルへの参照を利用して目的の画像を含めたGitHubコメントを作成します

イベントトリガーの作成

詳細には立ち入らないですが以下のような実装で実現できます。これを、さらに別のイベントを契機に作るようなイメージです。(それに限らないですが)

console.log("creating trigger for on message posted event");
const trigger = await client.workflows.triggers.create({
  type: "event",
  name: "message_posted event trigger",
  workflow: `#/workflows/${OnMessagePostedWorkflow.definition.callback_id}`,
  event: {
    event_type: "slack#/events/message_posted",
    channel_ids: [someChannelId],
    filter: {
      version: 1,
      root: {
        statement: `1 == 1`,
      },
    },
  },
  inputs: {
    channelId: { value: "{{data.channel_id}}" },
    messageTs: { value: "{{data.message_ts}}" },
    threadTs: { value: "{{data.thread_ts}}" },
    userId: { value: "{{data.user_id}}" },
    text: { value: "{{data.text}}" },
  },
});
if (!trigger.ok) {
  console.error("Failed to create trigger for message_posted event");
  throw new Error(trigger.error);
}

ここから先は inputs のマッピングは上記を前提にします。 filter の値は 1 == 1 以外を利用したほうが良いケースもあるかと思いますのでユースケースに応じて変更してください。

Slackの画像を取る方法

SlackFunction では引数から token が取れます。

Your application's access token.
https://api.slack.com/automation/functions/custom#context Function context properties より

これをBearerトークンとして、なにかしらのSlack APIでとってきた File の中の .url_private に対して GET を投げるとその画像等のファイルが取得できます。

const res = await fetch(file.url_private, {
  headers: {
    "Accept": "image/png,image/jpeg,image/gif",
    "Authorization": `Bearer ${token}`,
  },
});

上記では雑にAccept書いてますが、正直すべてのファイルがこれで取れます。書く必要ないです、たぶん。
.url_private_download もありますが、こちらは、編集可能なファイルの場合に、ブラウザで強制的にダウンロードの挙動をしてもらうためのものなので、 .url_private で問題ないと思います。多分。 .url_private_download ?? .url_private のほうがいいのかもしれないです。

GitHubに画像を保管

octokit restの.repos.createOrUpdateFileContentsを利用します。これで、上記で得たファイルをBase64エンコードして渡して送ります。

import { encodeBase64 } from "@std/encoding";

await gh.repos.createOrUpdateFileContents({
  owner: ghOwner,
  repo: assetsRepo,
  path,
  message:
    `add file ${file.name} by ${userInfo.user.name} via on_message_posted`,
  content: encodeBase64(await res.arrayBuffer()),
});

path はリポジトリ内のファイルパスですね。 assetsRepo はあらかじめ作成しておいたアセット用のリポジトリです。

これでファイルごとにコミットが作られることになります。

アップロードした画像をGitHub内で表示する

const repoUrl =
  `https://github.com/${ghOwner}/${assetsRepo}/blob/${assetsRepoBranch}/${
    encodeURI(path)
  }`;

const previewUrl =
  `https://github.com/${ghOwner}/${assetsRepo}/blob/${assetsRepoBranch}/${
    encodeURI(path)
  }?raw=true`;

上記の repoUrl は GitHub でそのファイルを開くためのURLになります。 previweUrl は直接そのファイルを見るためのURLで、このURLを ![](...) で埋め込んであげれば綺麗にプレビューされます。

ただし、閲覧する人がアセットリポジトリへの読み権限を持っている必要があるので、そこだけ注意です。

Slackの画像をGitHubへ

あとはすべてを繋げてあげます。一部、Slack SDKの型情報が不完全なので as を利用して補ってあげている箇所があります。最新のバージョンでは解決している問題かもしれないので、ご確認の上、ご利用ください。

functions/on_message_posted.ts などの名前で以下のようなファイルができる想定です。

import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
import { Octokit } from "octokit";
import {
  ConversationHistoryResult,
  UsersInfoResult,
} from "<下記参照>";
// 以下はユースケースごとに自分で用意するものの例
import { getConstants } from "...";
import { parseSupportSummaryMessage } from "...";
import {
  epicNewAddInfo,
  EpicNewAddInfoParams,
  EpicNewBodySummaryParams,
} from "...";

export default SlackFunction(def, async ({ inputs, client, env, token }) => {
  console.log("on_message_posted_function start");
  const {
    ghOwner,
    epicRepo,
    assetsRepo,
    assetsRepoBranch,
    supportChannelId,
    slackDomain,
  } = getConstants(
    env,
  );
  const gh = new Octokit({ auth: env.GITHUB_TOKEN });
  const userInfo: UsersInfoResult = await client.users.info({
    user: inputs.userId,
  }) as any;
  if (inputs.threadTs === undefined) {
    console.log(`threadTs is undefined for ${inputs.messageTs}`);
    return { outputs: {} };
  }
  const infoMessageFiles = await (async () => {
    const conversations: ConversationHistoryResult = await client.conversations.replies({
      ts: inputs.threadTs,
      channel: inputs.channelId,
      latest: inputs.messageTs,
      inclusive: true,
      limit: 1,
    }) as any;
    const message = conversations.messages[1];
    return message?.files ?? [];
  })();
  const supportSummaryMessage: ConversationHistoryResult = await (async () => {
    const conversations = await client.conversations.history({
      channel: inputs.channelId,
      latest: inputs.threadTs,
      inclusive: true,
      limit: 1,
    }) as any;
    const message = conversations.messages[0];
    return message;
  })();
  const fileUrls: EpicNewAddInfoParams["fileUrls"][number][] = [];
  const prefix = (() => {
    const d = new Date();
    return `${d.getFullYear()}-${`0${d.getMonth() + 1}`.slice(-2)}-${
      `0${d.getDate()}`.slice(-2)
    }`;
  })();
  for (const file of infoMessageFiles) {
    const res = await fetch(file.url_private, {
      headers: {
        "Accept": "image/png,image/jpeg,image/gif",
        "Authorization": `Bearer ${token}`,
      },
    }).catch((e) => {
      console.log(`fetch error ${e}`);
      throw e;
    });

    if (!res.ok) {
      console.error(`fetch error: ${res.status} ${res.statusText}`);
      console.error(`text: ${await res.text()}`);
      continue;
    }

    const randomPrefix = Math.random().toString(36).slice(-8);
    const path =
      `slack_support_files/${prefix}/${file.id}__${randomPrefix}__${file.name}`;
    await gh.repos.createOrUpdateFileContents({
      owner: ghOwner,
      repo: assetsRepo,
      path,
      message:
        `add file ${file.name} by ${userInfo.user.name} via on_message_posted`,
      content: base64.encodeBase64(await res.arrayBuffer()),
    });
    const repoUrl =
      `https://github.com/${ghOwner}/${assetsRepo}/blob/${assetsRepoBranch}/${
        encodeURI(path)
      }`;
    const previewUrl =
      `https://github.com/${ghOwner}/${assetsRepo}/blob/${assetsRepoBranch}/${
        encodeURI(path)
      }?raw=true`;
    fileUrls.push({
      previewUrl,
      repoUrl,
      name: file.name,
    });
  }
  const summary = parseSupportSummaryMessage(supportSummaryMessage);
  if (summary === null) {
    console.log(`summary is null for ${inputs.messageTs}`);
    return { outputs: {} };
  }
  const { epicNumber } = summary;
  await gh.issues.createComment({
    owner: ghOwner,
    repo: epicRepo,
    issue_number: epicNumber,
    body: epicNewAddInfo({
      threadTs: inputs.threadTs,
      messageTs: inputs.messageTs,
      slackDomain,
      text: inputs.text,
      supportChannelId,
      userInfo,
      fileUrls,
    }),
  });
  return { outputs: {} };
});

getConstants, parseSupportSummaryMessage, epicNewAddInfo, EpicNewAddInfoParams, EpicNewBodySummaryParams あたりは、今回の我々の利用用途に特化したものになります。これらの詳細に立ち入らずとも、この関数が成したいことは伝わるかと思います。

UsersInfoResult 型情報は以下です。

slack-types.ts
export type JSONValue = string | number | boolean | null | undefined | {
  readonly [key: string]: JSONValue | undefined;
} | readonly JSONValue[];

export type UsersInfoResult = Readonly<{
  user: Readonly<{ id: string; name: string; real_name: string }>;
}>;

export type MessageFile = Readonly<{
  "id": string;
  "created": number;
  "timestamp": number;
  "name": string;
  "title": string;
  "mimetype": string;
  "filetype": string;
  "pretty_type": string;
  "user": string;
  "user_team": string;
  "editable": number;
  "size": number;
  "mode": string;
  "is_external": number;
  "external_type": string;
  "is_public": boolean;
  "public_url_shared": boolean;
  "display_as_bot": boolean;
  "username": string;
  "url_private": string;
  "url_private_download": string;
  "media_display_type": string;
  "thumb_64": string;
  "thumb_80": string;
  "thumb_360": string;
  "thumb_360_w": number;
  "thumb_360_h": number;
  "thumb_160": string;
  "original_w": number;
  "original_h": number;
  "thumb_tiny": string;
  "permalink": string;
  "permalink_public": string;
  "is_starred": boolean;
  "has_rich_preview": boolean;
  "file_access": string;
}>;

export type Message = Readonly<{
  text: string;
  blocks: JSONValue;
  ts: string;
  files?: readonly MessageFile[];
}>;

export type ConversationHistoryResult = Readonly<{
  messages: readonly Message[];
}>;

余談

今回のコード例は、社内の問い合わせの内容をGitHubに同期するというものでした。
問い合わせのフォームにいっぱい情報を詰めこんでもらうようにするのも大事かもしれないですが、自分が書く側だとしてもそれがわりとしんどいのは理解しているつもりですし、後出しで情報を投げたいこともあります。
そういった心理を汲んでの、全部を同期する、という手法でした。

Slackの投稿を実際にGitHubのコメントとして同期している様子のデモ

ちなみに、画像以外のものも色々と飛んでくるので、画像の拡張子でなければプレビューとしては表示せずに、GitHubリンクを表示する、という形にとどめたりしています。

欲をいえば、画像だと検索ができないので、自動でOCRして文字情報を埋め込んでキーワード検索できるようにするところまでやりたいですが、それはまた別の機会に…。

GitHubで編集を提案
OPTIMINDテックブログ

Discussion