Slackで分報用のワークフローを作ってみた

# 分報について
私が所属している部署では、「分報」文化があります。分報というのは、今やっている業務タスクについてや困りごと等を分単位で報告するというものです。（分単位といっても1, 2時間くらいに1投稿くらいが平均）
その投稿を全員が参加しているタイムライン上で表示することで、誰が何をやっているのかが手軽に把握できます。そのため、他の人からのリアクションやレスでコミュニケーションが広がる機会づくりとして機能しています。

Slack公式でも分報について書かれた記事があります。

https://slack.com/intl/ja-jp/blog/collaboration/toppan-webinar-report

# 分報を運用するための要件
部署のコミュニケーションツールが今年度からSlackに移行したのに伴い、分報の仕組みをSlack上で整備することになりました。

分報を運用する上での要件として、下記の2点が必要ということになりました。

- **個人の投稿が部署の全員が参加しているチャンネルで見ることができる**
- **個人の投稿を絞って見ることができる**

この機能があることで、個人のログとして後から見返しやすかったり、他の人の分報を確認しやすいという利点があるからです。

![分報_要件.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547099/52770afb-3ed5-64f2-1727-0d35691cc863.jpeg)


# 検討・試してみた運用方法
上記の要件を満たしたSlackでの分報のやり方を探る中で、下記の方法を試しました。その上で「これだ！」という方法に行き着くことができましたが、途中で試してみた方法の方がマッチする組織もあると思うので紹介します。

:::note warn
方法1と2以外はSlackの有料プランが必要です。
:::

## 方法1 検索機能を使う
全体の投稿から、個人の投稿を抜き出したいなら、検索をすればよい。ということにはなるのですが、操作回数が多くて体験としてはいまいちです。
また、検索クエリのURLを保存してタブのCanvasに書いておいて、ワンクリックで検索結果に飛べたりできるかな？と思って調べてみましたが、Slackの検索の仕組み上、それはできないようでした。

## 方法2 個人スレを個人分報とする
「全員が参加する全体の分報チャンネルに、個人のスレを作成し、各人そこに分報を投稿する」という運用方法を行ってみました。

ただ、その運用方法だと下記の課題があることが分かりました。
- `このスレッドに返信しました: xxxxxxx`の文言が投稿に付くので、表示がごちゃごちゃする
- 投稿する際に、`以下にも投稿する: #チャンネル名`にチェックを入れるのが手間
- 個人のスレにレスすると、レスした人に以降の分報を投稿した際に、通知が行ってしまう（通知を解除すればよいだけではあるが、手間＆レスすることへの心理的なハードルになる）

この方法はワークフロー不要で素のSlackで運用ができますので、すぐに試すことができるというのはメリットですね。

## 方法3 個人チャンネルから全体チャンネル転送する 
次に個人の分報チャンネルと全体の分報チャンネルを別けた上で、「個人の分報チャンネルに投稿した内容を全体の分報チャンネルにパーマリンクを転送する」方式に切り替えました。

![分報_個人から全体.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547099/70e789eb-0a07-1979-9c25-2b32ee71a8ed.jpeg)

個人の分報チャンネルで投稿をトリガーとして設定して、その投稿のパーマリンクを取得して、全体の分報チャンネルにボットが投稿するというワークフローを作成しました。

基本的に方法2で発生していた課題は解決するものの、別の課題が生まれました。

- 全体の分報チャンネルにはパーマリングが投稿されるため、方法2とは違う感じで画面がごちゃごちゃする
- パーマリンクに絵文字リアクションつけても本人には届きにくい（アクティビティタブから気付けない＆オリジナルの投稿にリアクションするのも面倒）
- パーマリンクにレスしても本人には通知がいかない（メンションを付ければいいが、心理的にハードルが0じゃない）
- 個人の分報チャンネルと全体の分報チャンネルとのチャンネル切り替えが面倒

この方法では、個人の分報チャンネルを活用するため、他の人の投稿が流れ込むことなく、自分のペースで投稿できるのが特徴です。そのため、個人の作業に重きを置く組織には適しているかもしれないです。

ワークフローの実装については、こちらの記事を参考にさせていただきました。

https://zenn.dev/genda_jp/articles/c145e904ea93d4

# 定着した（しそうな）運用方法
これまでの方法だと、分報としての要件は満たしてはいるのですが、Slack本来のコミュニケーションツールとしての使い勝手を損なった体験になってしまっていました。
そこで、方法2での投稿の流れの方向を逆にして、「**全体チャンネルの投稿を個人チャンネルに流す**」にしたらどうだろう？と考えました。 


![分報_全体から個人.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547099/e18ce36e-cc7f-63ac-d208-eba73018b10f.jpeg)

また、各人の個人の分報チャンネルのURLをプロフィールのカスタムフィールドに設定してもらうことで、プロフィールから個人の分報への動線が確保でき、他の人の分報も確認しやすくなりました。

![分報_カスタムフィールド.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547099/c88a68cf-6647-2a8d-b446-af009d48d10b.jpeg)

実際に運用をしていますが、部署の方々からの評判は上々です。
（まだ運用開始してから1週間そこそこなので定着"しそうな"です）


## ワークフローの作り方
ワークフローの作成は、Slack CLIで開発を行いました。
実際の開発環境の構築方法等は、下記の記事を参考にしてください。

https://slack.com/intl/ja-jp/blog/developers/faster-simpler-way-build-apps

https://qiita.com/seratch/items/ecc16b845483c9d6f9e1

https://zenn.dev/blendthink/articles/f175b26f94261f

また、プロフィールに個人の分報チャンネルのURLを登録するための、カスタムフィールドの追加も行ってください。

https://slack.com/intl/ja-jp/help/articles/212281478-%E3%83%A1%E3%83%B3%E3%83%90%E3%83%BC%E3%81%AE%E3%83%97%E3%83%AD%E3%83%95%E3%82%A3%E3%83%BC%E3%83%AB%E3%81%AE%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%9E%E3%82%A4%E3%82%BA

なお、2024年の9月頃まで、下記の様なファンクションを利用したワークフローはプレミアムワークフローと呼ばれ、月の無料実行回数枠を超えての実行は追加の課金対象となっていました。しかし、Slackはこれらのワークフローへの追加課金は行わないとの発表がありました。

https://slack.com/intl/ja-jp/help/articles/31660994380179-Slack-%E3%81%AE%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8B%E4%BD%BF%E7%94%A8%E9%87%8F%E3%83%99%E3%83%BC%E3%82%B9%E3%81%AE%E8%AB%8B%E6%B1%82%E3%81%AB%E9%96%A2%E3%81%99%E3%82%8B%E5%A4%89%E6%9B%B4%E7%82%B9


### 実装したコード
作成したFunctionのコードです。
ワークフローに`addStep()`で追加してください。


<details><summary>Functionサンプルコード</summary>

```ts
import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
import { SlackAPI } from "deno-slack-api/mod.ts";

export const PostPermalinkMessageFunctionDefinition = DefineFunction({
  callback_id: "post_message_funciton",
  title: "Post a permalink message.",
  description: "Post a permalink message.",
  source_file: "functions/post_permalink_message.ts",
  input_parameters: {
    properties: {
      channel_id: {
        type: Schema.slack.types.channel_id,
        description: "from channel_id",
      },
      user_id: {
        type: Schema.slack.types.user_id,
        description: "user_id",
      },
      message_ts: {
        type: Schema.slack.types.message_ts,
        description: "message_ts",
      },
      message_text: {
        type: Schema.types.string,
        description: "message_text",
      },
    },
    required: ["channel_id", "user_id", "message_ts", "message_text"],
  },
  output_parameters: {
    properties: {},
    required: [],
  },
});

export default SlackFunction(
  PostPermalinkMessageFunctionDefinition,
  async ({ inputs, token }) => {
    const client = SlackAPI(token);
    const messageText = inputs.message_text; // 投稿されたメッセージのテキスト

    /**
     * チャンネルへメッセージ送信
     */
    const sendMessage = async (channelId: string, text: string) => {
      try {
        await client.chat.postMessage({
          channel: channelId,
          text,
          icon_emoji: ":slack:", // お好きなアイコンにしてください
        });
      } catch (e) {
        console.log(e);
      }
    };

    /**
     * メッセージのパーマリンクを取得
     */
    const getMessagePermalink = async (
      channelId: string,
      messageTs: string
    ) => {
      try {
        const permalinkRes = await client.chat.getPermalink({
          channel: channelId,
          message_ts: messageTs,
        });
        return permalinkRes.permalink;
      } catch (e) {
        console.log(e);
        return null;
      }
    };

    /**
     * メッセージ内のメンションされているユーザIDの取得
     */
    const getMentionUserIds = async (messageText: string) => {
      let userIds: string[] = [];
      // 個人向けのメンションを取得（複数人の場合も配列にする）
      const mentionUserIds = messageText
        .match(/<@U[A-Z0-9]+>/g)
        ?.map((mention) => mention.replace(/<@|>/g, ""));
      if (mentionUserIds) userIds.push(...mentionUserIds);

      // 全員向けのメンションがある場合は、チャンネルに所属しているユーザーIDを取得する
      const isMentionChannel = messageText.match(/<!channel>|<!here>/) !== null;
      if (isMentionChannel) {
        try {
          const channelMembers = await client.conversations.members({
            channel: inputs.channel_id,
          });
          userIds.push(...channelMembers.members);
        } catch (e) {
          console.log(e);
        }
      }
      return userIds;
    };

    /**
     * ユーザIDから分報の個人チャネルIDを取得（プロフィールのカスタムフィールドから取得）
     */
    const getUserTimesChannelId = async (userId: string) => {
      try {
        const fieldId = "xxxxxxxxxxxx"; // フィールドのIDを入力してください
        let profile: any = null;

        profile = await client.users.profile.get({
          user: userId,
        });

        if (!profile || !profile.profile || profile.profile.bot_id) return null; // ボットユーザはスキップ

        const key = Object.keys(profile.profile.fields).find(
          (k) => k === fieldId
        );
        if (!key) return null;
        const link = profile.profile.fields[key].value;

        // linkの末尾のチャンネルIDを取得
        const channelId = link.split("/").pop();

        return channelId;
      } catch (e) {
        console.log(e);
        return null;
      }
    };

    const permalinkRes = await getMessagePermalink(
      inputs.channel_id,
      inputs.message_ts
    );

    const text = permalinkRes
      ? `<${permalinkRes as string}| >`
      : "メッセージのパーマリンクが取得できませんでした。";

    // 重複したユーザIDを削除
    const sendMessageUserIds = [
      ...new Set([inputs.user_id, ...(await getMentionUserIds(messageText))]),
    ];

    // 送信先のチャンネルIDを取得
    for (const userId of sendMessageUserIds) {
      const channelId = await getUserTimesChannelId(userId);
      if (channelId) await sendMessage(channelId, text);
    }

    return { outputs: {} };
  }
);
```

</details>

# まとめ
Slackで分報用のワークフローを開発しました。
Slack本来の使い勝手を損なわずに、分報として役立つ仕組みを作ることができました。
組織で分報を検討している方の参考になっていれば幸いです。