Slackの画像等のファイルを含めたメッセージをGitHubのIssueコメントとして同期する
開発に関する重要な会話でも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に同期するというものでした。
問い合わせのフォームにいっぱい情報を詰めこんでもらうようにするのも大事かもしれないですが、自分が書く側だとしてもそれがわりとしんどいのは理解しているつもりですし、後出しで情報を投げたいこともあります。
そういった心理を汲んでの、全部を同期する、という手法でした。
ちなみに、画像以外のものも色々と飛んでくるので、画像の拡張子でなければプレビューとしては表示せずに、GitHubリンクを表示する、という形にとどめたりしています。
欲をいえば、画像だと検索ができないので、自動でOCRして文字情報を埋め込んでキーワード検索できるようにするところまでやりたいですが、それはまた別の機会に…。
世界のラストワンマイルを最適化する、OPTIMINDのテックブログです。「どの車両が、どの訪問先を、どの順に、どういうルートで回ると最適か」というラストワンマイルの配車最適化サービス、Loogiaを展開しています。recruit.optimind.tech/
Discussion