🪦

一定期間投稿のないSlackチャンネルを自動アーカイブする

2024/03/25に公開

株式会社 IVRy (アイブリー) 社員番号 7番 エンジニアのボルドーです。

今回は以前以下の記事で軽く触れた「一定期間投稿のないチャンネルを自動アーカイブする」機能の実装方法について紹介したいと思います。

概要

弊社は Slack を用いたコミュニケーションが活発なので Slack チャンネルが日夜増え続けています。
放っておくととんでもない数になってしまうため、100日間[1]動きがないチャンネルはアーカイブしてほしいという要望を受けて開発しました。


月初に実行されている様子

機能の概要は以下の通りです。

  • Node.js CLI のコマンドとして動作する
  • 自動アーカイブ対象は社内のパブリックチャンネルのみとする
    • コマンドのオプションで特定のチャンネルを除外可能
    • BOT が参加していない対象チャンネルがあった場合は警告を表示する[2]
  • コマンド引数で指定した日数以上 投稿のないチャンネルをアーカイブする
    • channel_join や channel_leave といった情報が混ざることがあるので除外する
  • dry-run モードでアーカイブ対象をリマインドすることができる
Command:
  archive:inactive-channels   指定された期間以上更新のないパブリックチャンネルをアーカイブする

Usage:
  slack-cli archive:inactive-channels --days 100 [options]

Required:
  --days              指定された日数以上更新のないチャンネルをアーカイブする

Options:
  --channel-id        投稿先チャンネルID。結果を投稿したい場合に指定する。
  --channel-name      投稿先チャンネル名。結果を投稿したい場合に指定する。
  --excludes          除外する文字列。カンマ区切りで複数指定可能。その場合の条件は OR
  --excludes-prefix   除外するチャンネル名接頭辞。カンマ区切りで複数指定可能
  --excludes-suffix   除外するチャンネル名接尾辞。カンマ区切りで複数指定可能
  --help, -h          このヘルプを表示
  --debug             デバッグモードで実行
  --dry-run           アーカイブせずにログ出力する

実装

概要の通りに実装していきます。

0. 事前準備

Slack ワークスペース上で BOT の準備をします。
必要な権限は channels:history, channels:read です。

また、Slack Web API のクライアントが提供されているのでこちらをインストールします。
この SDK を利用することで使用制限が生じた際の処理を気にする必要がなくなります。[3]

yarn add @slack/web-api

1. CLI の準備

CLI として動作するようにコマンドを作成します。
今回は zenn-dev/zenn-cli を参考にさせていただいたので詳細は省きますが、zenn-cli のように commands/index.ts から呼び出します。

commands/index.ts
import { Log } from '@/lib/log';
import { commandListText } from '@/lib/messages';
import { Commands, ExecOptions } from '@/types';
import { archiveChannel, archiveInactiveChannels } from './archive';
import { help } from './help';

export async function exec(
  execCommandName: string,
  execCommandArgs: string[],
  options: ExecOptions = {}
) {
  const commands: Commands = {
    'archive:inactive-channels': async (a, b) =>
      archiveInactiveChannels.exec(a, b),
    '--help': async (a) => help.exec(a),
    '-h': async (a) => help.exec(a),
  };

  if (!commands[execCommandName]) {
    Log.error('該当するCLIコマンドが存在しません');
    Log.warn(commandListText);
    return;
  }

  return commands[execCommandName](execCommandArgs, options.progress);
}

2. 引数のチェック

意図しない引数があった場合にエラーを表示、オプションに -h --help が指定された場合はヘルプメッセージを表示します。

commands/archive/inactive-channels.ts
const helpText = 'ヘルプを表示';
const invalidOptionText = 'エラー時の必須とオプションリストを表示';

function parseArgs(argv?: string[]) {
  try {
    return arg(
      {
        // Types
        '--days': Number,
        '--channel-id': String,
        '--channel-name': String,
        '--excludes': String,
        '--excludes-prefix': String,
        '--excludes-suffix': String,
        '--help': Boolean,
        '--debug': Boolean,
        '--dry-run': Boolean,

        // Alias
        '-h': '--help',
      },
      { argv }
    );
  } catch (e: any) {
    if (e.code === 'ARG_UNKNOWN_OPTION') {
      Log.error(invalidOptionText);
    } else {
      Log.error(e);
    }
    Log.error(helpText);
    return null;
  }
}

export const exec: CliExecFn = async (argv, progress) => {
  const error = { error: invalidOptionText + '\n' + helpText };
  if (!argv || argv.length === 0) return error;
  const args = parseArgs(argv);
  if (args === null) return error;

  if (args['--help']) {
    return { text: helpText };
  }
  if (!args['--days']) {
    Log.error('--days で日数を指定してください。');
    return error;
  }

  const options = parseOptions(args);
  const days = args['--days'];
  const targetDate = new Date();
  targetDate.setDate(targetDate.getDate() - days);
  const excludes = args['--excludes']?.split(',') || [];
  const excludesPrefix = args['--excludes-prefix']?.split(',') || [];
  const excludesSuffix = args['--excludes-suffix']?.split(',') || [];

// 続く

};

3. 対象チャンネルのリストアップ

2 まででコマンドが正しく指定されなければエラーになるようになったので 次はオプションに従って対象チャンネルをリストアップします。
その際に BOT が未参加のチャンネルがあった場合は警告を表示します。チャンネルの過去の投稿を取得するためにはチャンネルに参加している必要があるためです。

チャンネルリストの取得は conversations.list を利用しています。コード中では getAllChannels に切り出していますが cursor に応じて全件取得するだけの薄いラッパーです。

commands/archive/inactive-channesl.ts 続き
  // 共有チャンネル、プライベートチャンネル等を除外したパブリックチャンネルのリスト
  const allChannels = (
    await getAllChannels({ exclude_archived: true }, options)
  ).filter(
    (c) =>
      c.is_channel &&
      !c.is_archived &&
      !c.is_private &&
      !c.is_mpim &&
      !c.is_shared &&
      !c.is_org_shared
  );
  // オプションに応じてフィルターした結果のリスト
  const filteredChannels = allChannels
    .filter((c) =>
      excludes.length === 0 ? true : !excludes.some((i) => c.name?.includes(i))
    )
    .filter((c) =>
      excludesPrefix.length === 0
        ? true
        : !excludesPrefix.some((i) => c.name?.startsWith(i))
    )
    .filter((c) =>
      excludesSuffix.length === 0
        ? true
        : !excludesSuffix.some((i) => c.name?.endsWith(i))
    );

  // BOT が未参加のチャンネルがあったら警告
  const notJoinedChannels = allChannels.filter((c) => !c.is_member);
  if (notJoinedChannels.length > 0)
    Log.warn(
      `unparticipated channels: \n${notJoinedChannels
        .map((c) => `${c.name} (${c.id})`)
        .join('\n')}`
    );

// 続く

4. 各対象チャンネルの最終投稿日を確認してアーカイブ

対象チャンネルのリストそれぞれの最終投稿日を確認していきます。どこまで正確に確認するかは時間とのトレードオフになりますが、今回は最新の投稿 30件と、そのスレッド全件を最終投稿日の確認対象とします。[4]

投稿の取得は conversations.history, スレッドの取得は conversations.replies を利用します。コード中では getAllConversations として切り出していますがやっていることは limit 件の投稿を取得してスレッド(thread_ts)があればスレッドも取得した上で時系列順に並べて返しています。

commands/archive/inactive-channesl.ts 続き
  const limit = 30;
  for (let index = 0; filteredChannels.length > index; index++) {
    const channel = filteredChannels[index];
    Log.debug(`channel: ${channel.name}, ${index + 1}/${channels.length}`);
    if (!channel.is_member) continue;
    try {
      const targetMessages = (
        await getAllConversations(
          { channel: channel.id!, limit },
          1,
          (m) =>
            m.ts === undefined
              ? false
              : // スレ元が既にアーカイブ対象外ならスレッドを取得しない
                !isWithinByDate(convertTsToDate(m.ts), targetDate),
          options
        )
      ).reverse();
      const createdDate = new Date((channel.created || 0) * 1000);
      const createdTs = convertDateToTs(createdDate);

        // チャンネル内で一番新しいメッセージを取得。投稿と関係ない情報が混ざることがあるので除外する
      const latestMessage =
        targetMessages
          .filter((m) => m.ts)
          .filter((m) => m.type === 'message' &&
             m.subtype !== 'channel_leave' &&
             m.subtype !== 'channel_join')
          .sort((a, b) => Number(b.ts!) - Number(a.ts!))[0] || undefined;
      // 最新の投稿日がアーカイブ対象ではないならこのチャンネルには何もしない
      if (
        isWithinByDate(
          convertTsToDate(latestMessage?.ts || createdTs),
          targetDate
        )
      )
        continue;

      const latestPostDate = convertTsToSimpleDate(
        latestMessage?.ts || createdTs
      );

      // 指定日数以上投稿がなかったのでアーカイブする(後述で補足)
      await archiveChannel(
        {
          channelId: channel.id!,
          latestPostDate,
        },
        options
      );
    } catch (e) {
      Log.error(`something happened with ${JSON.stringify(channel)}`);
      Log.error(e);
      continue;
    }
  }

5. アーカイブ処理

アーカイブ処理(archiveChannel)は以前ワークフローで作成済みの Webhook にリクエストを送信することで実現しています。作成した Webhook URL を環境変数として SLACK_ARCHIVE_WORKFLOW_WEBHOOK_URL に設定の上利用します。
dry-run モードの場合はログ出力を行い実際にはアーカイブしないようにしています。

Webhook を用いたアーカイブ
import { Log } from '@/lib/log';
import { SlackDemoOptions } from '@/types';

type Args = {
  channelId: string;
  latestPostDate: string;
};

export const archiveChannel = async (
  args: Args,
  options?: SlackDemoOptions
) => {
  if (options?.dryRun) {
    Log.success(`[dry-run] ${args.channelId} will be archived.`);
    return;
  }
  const url = process.env.SLACK_ARCHIVE_WORKFLOW_WEBHOOK_URL;
  if (!url) {
    Log.error('SLACK_ARCHIVE_WORKFLOW_WEBHOOK_URL is required.');
    process.exit(1);
  }
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
    },
    body: JSON.stringify({
      channel_id: args.channelId,
      latest_post_date: args.latestPostDate,
    }),
  }).then((res) => res.json());

  return response;
};

以上で、Node.js CLI として実行することで指定した日数以上投稿がないチャンネルをアーカイブできるようになりました。あとは適宜 GitHub Actions や crontab などで定期実行を行えばアーカイブの自動化完了です。

実績とこれから

これまでの7ヶ月間で 132チャンネルをアーカイブしました。

  • '23年 9月 32チャンネル
  • '23年10月 9チャンネル
  • '23年11月 13チャンネル
  • '23年12月 4チャンネル
  • '24年 1月 12チャンネル
  • '24年 2月 20チャンネル
  • '24年 3月 42チャンネル
    • アーカイブ条件を 100日から 60日へ変更した

私は試験に合格できず参加していなかったのですが、HUNTER×HUNTER チャンネルも遂にアーカイブされてしまいました。[5]

今後は以下の改善を考えています。

  • アーカイブ前にチャンネルの内容を要約して投稿しておく
    • 後から内容を確認したい場面が度々あるのであったら便利そうです
  • アーカイブ前にチャンネルに所属していたメンバーを投稿しておく
    • チャンネルを復活させてもメンバーは戻らないようなので元々誰がいたか記録しておけると便利そうです

We are hiring!!

最後に、IVRy では一緒に働く仲間を絶賛募集中です。
今の所 順調に成長してきていますが、今後の更なる成長のためには圧倒的に仲間が不足しています。皆さまのご応募お待ちしております!

脚注
  1. '24年3月現在は 60日間 ↩︎

  2. BOT が参加していないチャンネルの投稿リストは取得できないため ↩︎

  3. automatic-retries, rate-limits ↩︎

  4. '24年3月時点でチャンネル情報取得 API(conversations.info)等からは最後に投稿があった日時が取得できないため自力で探す必要があります。 ↩︎

  5. 余談ですが私のハンドルネームは某旅団の No.7 から拝借しています。社内でもあまり知られていないようなので改めて書き記しておきます。 ↩︎

IVRyテックブログ

Discussion