Slack App で匿名投稿機能 with Cloudflare Workers

Slack App で匿名投稿機能 with Cloudflare Workers

気軽に質問できるチャンネルが欲しい、いつもクールを装っているが本当はくだらないダジャレを言いたい… そんなときは Slack App で匿名投稿機能を実現させましょう。

Slack

では、早速作りましょう!

1. Cloudflare Workers の作成 #

ローカルに TypeScript ファイルを作ります。(例:main.ts)を作成し、以下のコードをコピペします。その後 npx esbuild main.ts --outfile=main.js コマンドを実行して ESBuild で JavaScript にトランスパイルしましょう。

/**
 * Anonymous post Slack App running on Cloudflare Workers
 *
 * Use cases:
 * - Trigger global shortcut -> Open modal -> Write message and submit -> Post message with a reply button to a channel as bot
 * - Click a reply button -> Open modal for thread reply -> Post message into the thread as bot
 *
 * Required Slack App Permissions:
 * - commands
 * - chat:write
 * - chat:write.customize
 *
 * Required Environment variables:
 * - SLACK_CHANNEL_ID: The cannel ID a bot post message to
 * - SLACK_SIGNING_SECRET: Slack App signing secret
 * - SLACK_BOT_TOKEN: Slack App bot OAuth token
 */

type Env = {
  SLACK_CHANNEL_ID: string;
  SLACK_SIGNING_SECRET: string;
  SLACK_BOT_TOKEN: string;
};

type Ctx = any;

type SlackShortcutPayload = {
  type: "shortcut";
  trigger_id: string;
};

type SlackViewSubmissionPayload = {
  type: "view_submission";
  view: {
    private_metadata: string;
    state: {
      values: {
        message_block: {
          message_input: {
            value: string;
          };
        };
      };
    };
  };
};

type SlackBlockActionsPayload = {
  type: "block_actions";
  trigger_id: string;
  message: {
    ts: string;
  };
};

type SlackPayload =
  | SlackShortcutPayload
  | SlackViewSubmissionPayload
  | SlackBlockActionsPayload;

type ModalMetadata =
  | { mode: "new"; timestamp: null }
  | { mode: "reply"; timestamp: string };

export default {
  async fetch(request: Request, env: Env, ctx: Ctx): Promise<Response> {
    return main(request, env, ctx);
  },
};

async function main(request: Request, env: Env, ctx: Ctx): Promise<Response> {
  // ---------------------------------------------------------------------------
  // Validate environment variables
  // ---------------------------------------------------------------------------
  const requiredEnvKeys = [
    "SLACK_CHANNEL_ID",
    "SLACK_SIGNING_SECRET",
    "SLACK_BOT_TOKEN",
  ] as const;
  for (const key of requiredEnvKeys) {
    const value = env[key];
    if (value == null || value === "") {
      log.error(`Environment variable ${key} is not set or empty`);
      return new Response("Internal Server Error", { status: 500 });
    }
  }

  // ---------------------------------------------------------------------------
  // Validate request
  // ---------------------------------------------------------------------------
  if (request.method !== "POST") {
    log.error(`Requested method is ${request.method}`);
    return new Response("Method Not Allowed", { status: 405 });
  }

  const contentType = request.headers.get("content-type") ?? "";
  if (!contentType.includes("application/x-www-form-urlencoded")) {
    log.error(`Content type is ${contentType}`);
    return new Response("Unsupported Content-Type", { status: 415 });
  }

  if (!(await verifySlackSignature(request, env))) {
    log.error("Could not verify Slack signature");
    return new Response("Unauthorized", { status: 401 });
  }

  const rawBody = await request.clone().text();
  const form = new URLSearchParams(rawBody);
  const payloadString = form.get("payload");
  if (!payloadString) {
    return new Response("Unprocessable Content", { status: 422 });
  }

  // ---------------------------------------------------------------------------
  // Main logic
  // ---------------------------------------------------------------------------
  const payload = JSON.parse(payloadString) as SlackPayload;
  log.info("Main process started");

  switch (payload.type) {
    // When triggering global shortcut
    case "shortcut":
      return handleShortcutEvent(payload, env);
    // When submitting modal form
    case "view_submission":
      return handleViewSubmissionEvent(payload, env, ctx);
    // When clicking reply button
    case "block_actions":
      return handleBlockActionsEvent(payload, env);
    default:
      return new Response("", { status: 200 });
  }
}

async function handleShortcutEvent(
  payload: SlackShortcutPayload,
  env: Env,
): Promise<Response> {
  await openModal(env.SLACK_BOT_TOKEN, payload.trigger_id, {
    mode: "new",
    timestamp: null,
  });
  return new Response("", { status: 200 });
}

async function handleViewSubmissionEvent(
  payload: SlackViewSubmissionPayload,
  env: Env,
  ctx: Ctx,
): Promise<Response> {
  const message = payload.view.state.values.message_block.message_input.value;
  const metadata = JSON.parse(payload.view.private_metadata) as ModalMetadata;

  switch (metadata.mode) {
    case "new":
      ctx.waitUntil(
        postMessage(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, message, {
          threadTimestamp: null,
          includeReplyButton: true,
        }),
      );
      break;
    case "reply":
      ctx.waitUntil(
        postMessage(env.SLACK_BOT_TOKEN, env.SLACK_CHANNEL_ID, message, {
          threadTimestamp: metadata.timestamp,
          includeReplyButton: false,
        }),
      );
      break;
  }

  return new Response(JSON.stringify({ response_action: "clear" }), {
    status: 200,
    headers: { "Content-Type": "application/json; charset=utf-8" },
  });
}

async function handleBlockActionsEvent(
  payload: SlackBlockActionsPayload,
  env: Env,
): Promise<Response> {
  await openModal(env.SLACK_BOT_TOKEN, payload.trigger_id, {
    mode: "reply",
    timestamp: payload.message.ts,
  });
  return new Response("", { status: 200 });
}

/**
 * Open modal.
 */
async function openModal(
  token: string,
  triggerId: string,
  metadata: ModalMetadata,
): Promise<void> {
  const view = {
    type: "modal",
    private_metadata: JSON.stringify({
      mode: metadata.mode,
      timestamp: metadata.timestamp,
    }),
    title: { type: "plain_text", text: "匿名投稿" },
    submit: { type: "plain_text", text: "送信" },
    close: { type: "plain_text", text: "キャンセル" },
    blocks: [
      {
        type: "input",
        block_id: "message_block",
        label: { type: "plain_text", text: "メッセージ" },
        element: {
          type: "plain_text_input",
          action_id: "message_input",
          multiline: true,
          placeholder: { type: "plain_text", text: "メッセージを書いてね" },
        },
      },
    ],
  };
  await fetch("https://slack.com/api/views.open", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json; charset=utf-8",
    },
    body: JSON.stringify({ view, trigger_id: triggerId }),
  });
}

/**
 * Post message.
 */
async function postMessage(
  token: string,
  channelId: string,
  text: string,
  option: {
    threadTimestamp: string | null;
    includeReplyButton: boolean;
  },
): Promise<void> {
  const iconEmojis = [
    ":monkey_face:",
    ":gorilla:",
    ":orangutan:",
    ":dog:",
    ":poodle:",
    ":wolf:",
    ":fox_face:",
    ":cat:",
    ":lion_face:",
    ":tiger:",
    ":horse:",
    ":unicorn_face:",
    ":zebra_face:",
    ":deer:",
    ":cow:",
    ":pig:",
    ":ram:",
    ":sheep:",
    ":goat:",
    ":dromedary_camel:",
    ":camel:",
    ":llama:",
    ":giraffe_face:",
    ":elephant:",
    ":rhinoceros:",
    ":hippopotamus:",
    ":mouse:",
    ":hamster:",
    ":rabbit:",
    ":chipmunk:",
    ":hedgehog:",
    ":bear:",
    ":polar_bear:",
    ":koala:",
    ":panda_face:",
    ":sloth:",
    ":otter:",
    ":kangaroo:",
    ":chicken:",
    ":baby_chick:",
    ":bird:",
    ":penguin:",
    ":dove_of_peace:",
    ":eagle:",
    ":duck:",
    ":swan:",
    ":owl:",
    ":dodo:",
    ":flamingo:",
    ":peacock:",
    ":parrot:",
    ":frog:",
    ":turtle:",
    ":dragon_face:",
    ":t-rex:",
    ":whale:",
    ":dolphin:",
  ];
  const iconEmoji = iconEmojis[Math.floor(Math.random() * iconEmojis.length)];

  const blockBase = [{ type: "section", text: { type: "mrkdwn", text } }];
  const blocks = option.includeReplyButton
    ? [
        ...blockBase,
        {
          type: "actions",
          elements: [
            {
              type: "button",
              action_id: "open_thread_reply_modal",
              text: {
                type: "plain_text",
                text: "このスレッドに投稿する",
                emoji: true,
              },
              value: "open",
            },
          ],
        },
      ]
    : blockBase;

  await fetch("https://slack.com/api/chat.postMessage", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json; charset=utf-8",
    },
    body: JSON.stringify({
      channel: channelId,
      thread_ts: option.threadTimestamp,
      username: "匿名さん",
      icon_emoji: iconEmoji,
      blocks: blocks,
    }),
  });
}

/**
 * Verify a Slack request signature using the signing secret and request body.
 * Rejects requests older or newer than 5 minutes to prevent replay attacks.
 * Returns true only if the signature and timestamp are valid.
 */
async function verifySlackSignature(
  request: Request,
  env: Env,
): Promise<boolean> {
  const timestamp = request.headers.get("x-slack-request-timestamp");
  const signature = request.headers.get("x-slack-signature");
  if (!timestamp || !signature) {
    return false;
  }
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > 60 * 5) {
    return false;
  }
  const rawBody = await request.clone().text();
  const base = `v0:${timestamp}:${rawBody}`;
  const expected = `v0=${await hmacSha256Hex(base, env.SLACK_SIGNING_SECRET)}`;
  return timingSafeEqual(expected, signature);
}

/**
 * Compute an HMAC-SHA256 signature and return it as a hexadecimal string.
 * Generates an HMAC using SHA-256 with the given secret key and message, then encodes the result as a lowercase hexadecimal string.
 * This logic is used to verify Slack request signatures.
 */
async function hmacSha256Hex(message: string, key: string): Promise<string> {
  const encoder = new TextEncoder();
  const cryptoKey = await crypto.subtle.importKey(
    "raw",
    encoder.encode(key),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const mac = await crypto.subtle.sign(
    "HMAC",
    cryptoKey,
    encoder.encode(message),
  );
  return [...new Uint8Array(mac)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

/**
 * Compare two strings in constant time to prevent timing attacks.
 * c.f. Node.js crypto.timingSafeEqual - https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b
 */
function timingSafeEqual(a: string, b: string): boolean {
  const encoder = new TextEncoder();
  const ba = encoder.encode(a);
  const bb = encoder.encode(b);
  if (ba.length !== bb.length) {
    return false;
  }
  let diff = 0;
  for (let i = 0; i < ba.length; i++) {
    diff |= ba[i] ^ bb[i];
  }
  return diff === 0;
}

const log = {
  info: (obj: unknown) => console.info(`INFO: ${String(obj)}`),
  error: (obj: unknown) => console.error(`ERROR: ${String(obj)}`),
};

Cloudflare のダッシュボード(https://dash.cloudflare.com/)から Cloudflare Workers の新しいアプリを作成しましょう。

その後、コード中で必要となる3つの環境変数を登録 …

SLACK_CHANNEL_ID
SLACK_SIGNING_SECRET
SLACK_BOT_TOKEN

… したいのですが、これは Slack App 作成しないと取得できない値なのでまた後ほどやりましょう。

2. Slack App の作成 #

ざっくり箇条書きでいきます。以前に投稿した記事で Slack App の作成方法を詳細に説明しています。必要でしたらそちらを参照ください。

  1. https://api.slack.com/apps から新しいアプリを作成
  2. Interactivity & Shortcuts を開く
    • On にする
    • Request URL に Cloudflare Workers の URL を設定する
    • Create New Shortcut からショートカットを作成する
  3. OAuth & Permissions を開く
    • Bot Token Scopes に以下権限を設定
      • commands
      • chat:write
      • chat:write.customize
  4. App Home を開く
    • App Display Name を設定する
  5. OAuth & Permissions を開く
    • ワークスペースにインストールする

3. Cloudflare Workers の環境変数を設定 #

さきほど後回しにした作業をやりましょう。Cloudflare Workers に環境変数を設定します。

  • SLACK_CHANNEL_ID:匿名アプリを動作させたいチャンネルの ID を設定
  • SLACK_SIGNING_SECRET:Slack App の Signing Secret を設定
  • SLACK_BOT_TOKEN:Slack App の Bot User OAuth Token を設定

Cloudflare Workers

4. Slack チャンネルに Slack App を追加 #

Slack を開き、チャンネル設定のところから作成したアプリを追加しましょう。

5. 動作確認 #

本記事冒頭に貼ったように動作すれば成功です!