🤖

AWS Lambda が HTTPS エンドポイントから実行可能となったので、Slack Bot を作ってみる

2022/04/22に公開

初めに

vercel で運用しているサービスを slack からデプロイしたくて、どうしようかと悶々と考えておりました。vercel のデプロイには WebHook が用意されているので、そのURLをキックするだけなのですが、、

一番簡単なのは slack のスラッシュコマンドを使うことです。ただ、スラッシュコマンドは誰でも実行でき、かつ、誰が実行したのか分からないため避けたいです。

そこで slack bot を作るしかないかなと考えていました。ただ、このためだけに、heroku とかにサーバーを建てるのも、嫌だな〜 Lambda かな〜。でも Lambda だと、API Gateway + Lambda の構成か、腰が重いな〜と悶々と考えていました。

そんな時に!!

「AWS Lambda が HTTPS エンドポイントから実行可能になった」というニュースが飛び込んできました。

よし! Lambdaでやろう!!そう思いました。

vercel のデプロイは副次的な話で、主たる話は AWS Lambdaslack bot を動かすこととなりますので、slack bot を作成したい方や、 AWS Lambda を触ってみたいみたい方は是非、挑戦してみて下さい。

(slack bot は 以後 bot として記載します)

作成する bot は以下のイメージです。

実装は GitHub にあります。
https://github.com/fukurose/vercel-deploy-slack-bot-on-lambda

HTTPS エンドポイントから実行可能な Lambda を作成する

兎にも角にも、まずは Lambda を用意します。
AWS のコンソールに入って、 Lambda を作成しましょう。作成時に、詳細設定にある「関数 URL を有効化」にチェックを入れて、NONEを選択しましょう。

作成されたら、index.jsindex.mjs にリネームします。これにより、Lambda上で、ESモジュールが使えるようになります。

そして、index.mjs の内容を書き換えて、デプロイしましょう。

index.mjs
export async function handler(event) {
  const response = {
    statusCode: 200,
    body: JSON.stringify("Hello from Lambda!"),
  };
  return response;
}

デプロイ完了したら、「関数の概要」欄の右下にある関数URLのリンクを押下してみて下さい。"Hello from Lambda!" と表示されていれば、OKです。

bot を用意する

では、次に bot を用意致しましょう。api slack にアクセスして、App(bot)を新規作成します。

作成したら、「Event Subscriptions」からEvent を有効にして、 Request URLLambda のHTTPS エンドポイントURLを入れます。

すると

Your request URL didn’t respond with the correct challenge value. Update your URL to receive a new request and value.

というメッセージが表示されるかと思います。

これは、slack が実施しているチェックをパスしなかったためです。slack は入力された URLに対して、challenge 値を含めてリクエストを投げます。受け取った方は、 この challenge 値をそのまま、レスポンスに返す必要があり、これにより slack は正しいURLと検証できます。

それでは、 index.mjs を challenge 値を返すように変更致しましょう。

index.mjs
export async function handler(event) {
  const body = JSON.parse(event.body);
  let responseBody = "Hello from Lambda!";
  // bodyに challenge がある場合は、それをそのまま返す
  if (body.challenge) {
    responseBody = {
      challenge: body.challenge,
    };
  }

  const response = {
    statusCode: 200,
    body: JSON.stringify(responseBody),
  };
  return response;
}

デプロイしてから、Event Subscriptions のURL検証をリトライしてみて下さい。今度は上手くいくはずです。

それでは bot の設定を実施していきましょう。

まず、 bot にメンションした時に反応して欲しいので、 Subscribe to bot eventsapp_mentions を追加して、保存します。

次に「App Home」にて、botDisplay Name を設定します。

次に「OAuth & Permissions」にて、Scopeschat:write の権限を付与します。
また、「OAuth & Permissions」に「Bot User OAuth Token」がありますので、値を控えておきます。

これで作成出来ました!!
「Install App to Your Team」より作成した botinstall しましょう。

ただ、この時点では slack 上で bot に話しかけても何も反応がありません。Lambda からレスポンスを返すように致しましょう。

Bot を動かす

bot を反応させるためには、 http の post 処理が必要となりますので、 先に http.mjs を作成しておきます。

http.mjs
import https from "https";

export const postRequest = async (url, headers, message) => {
  const options = { method: "POST", headers: headers };

  return new Promise((resolve, reject) => {
    let req = https
      .request(url, options, (res) => {
        res.on("end", () => {
          console.log("completed postRequest");
          resolve(res.statusCode);
        });
      })
      .on("error", (e) => {
        console.log("error postRequest:" + e.message);
        reject(e);
      });
    req.write(message);
    req.end();
  });
};

では、bot が呼ばれたら反応するように index.mjs を変更致しましょう。

index.mjs
import { postRequest } from "./http.mjs";

export async function handler(event) {
  const body = JSON.parse(event.body);
  let responseBody = "Hello from Lambda!";
  // bodyに challenge がある場合は、それをそのまま返す
  if (body.challenge) {
    responseBody = {
      challenge: body.challenge,
    };
  }

  if (body.event.type == "app_mention") {
    const headers = {
      "Content-Type": "application/json",
      Authorization: "Bearer " + process.env["SLACK_BOT_USER_ACCESS_TOKEN"],
    };

    const data = {
      channel: body.event.channel,
      text: responseBody,
    };

    await postRequest(
      process.env["SLACK_POST_MESSAGE_URL"],
      headers,
      JSON.stringify(data)
    );
  }

  const response = {
    statusCode: 200,
    body: JSON.stringify(responseBody),
  };
  return response;
}

お気づきかもしれませんが、一部の情報を環境変数に保持しています。Lambdaの設定 → 環境変数から以下を設定下さい。

SLACK_BOT_USER_ACCESS_TOKEN: Bot User OAuth Token の値
SLACK_POST_MESSAGE_URL: https://slack.com/api/chat.postMessage

こちらで、デプロイして、slack から再度 bot 話しかけてみましょう。slack 上に Hello from Lambda! が返ってきたら成功です。

Interactive Message

bot が返答できるようになったので、会話をしていくことにしましょう。
slackbot 設定画面から 「Interactivity & Shortcuts」を選択し、Interactivity を 有効にします。URL入力欄が表示されるので、 Lambda のエンドポイントURLを入れましょう。

次に bot が返信する内容も短文ではなく、ボタンなどのアクションを含んだメッセージに変更します。今回は vercel のデプロイ用なので、以下のように設定します。
https://github.com/fukurose/vercel-deploy-slack-bot-on-lambda/blob/main/slack.mjs

独自のメッセージを作成したい人は slackの Block Kit Builder を使うのをおすすめします。

それでは、ブロックで構築したメッセージを返すように変更します。

index.mjs
import { blocks } from "./slack.mjs";

...

    const data = {
      channel: body.event.channel,
      // ここを text から blocks に変更
      blocks: blocks,
    };

...

これで、ボタン付きのメッセージを bot が返してくれます。ただ、現状ではボタンを押下しても何も起こらないので、ボタン押下時の受け口を作っていきます。

ボタン押下時のリクエストは content-typex-www-form-urlencoded となります。(今までは json でした)

ここらへんから実装が複雑になってくるので、処理を分けた方が良さそうです。上述した通り、content-typejsonx-www-form-urlencoded の2種類あるので、 handleJson handleUrlEncoded 追加します。

index.mjs
import { postRequest } from "./http.mjs";
import { blocks } from "./slack.mjs";

export async function handler(event) {
  const contentType = event.headers["content-type"];

  let responseBody = "Hello from Lambda!";
  
  //contentType により処理を分ける
  if (contentType == "application/json") {
    responseBody = await handleJSON(event.body);
  } else if (contentType == "application/x-www-form-urlencoded") {
    responseBody = await handleUrlEncoded(event.body);
  }

  const response = {
    statusCode: 200,
    body: JSON.stringify(responseBody),
  };
  return response;
}

const handleUrlEncoded = async (requestBody) => {
  // 後で実装する
  console.log(requestBody);
};

const handleJSON = async (requestBody) => {
  const body = JSON.parse(requestBody);

  if (body.challenge) {
    const responseBody = {
      challenge: body.challenge,
    };
    return responseBody;
  }

  if (body.event.type == "app_mention") {
    const headers = {
      "Content-Type": "application/json",
      Authorization: "Bearer " + process.env["SLACK_BOT_USER_ACCESS_TOKEN"],
    };

    const data = {
      channel: body.event.channel,
      blocks: blocks,
    };

    await postRequest(
      process.env["SLACK_POST_MESSAGE_URL"],
      headers,
      JSON.stringify(data)
    );

    return "messageを送信しました";
  }
};

それでは handleUrlEncoded を実装していきます。実際のデータは base64 エンコードされているので、まず、デコードして、その後 querystring を使って、URLクエリパラメーター形式をパースします。データが取り出せたら handleAction で押されたボタンに紐づくメッセージを返すようにします。

index
import querystring from "querystring";

...

const handleUrlEncoded = async (requestBody) => {
  const queryParameter = Buffer.from(requestBody, "base64").toString();
  const body = querystring.parse(queryParameter);
  const payload = JSON.parse(body.payload);
  console.log(JSON.stringify(payload));

  const headers = {
    "Content-Type": "application/json",
  };

  const result = handleAction(payload);
  const data = {
    text: result,
  };
  await postRequest(payload.response_url, headers, JSON.stringify(data));

  return result;
};

...

const handleAction = (payload) => {
  const user = payload.user.username;
  switch (payload.actions[0].action_id) {
    case "main":
      return `<@${user}> が本番環境をデプロイしました。`;
    case "staging":
      return `<@${user}> がステージング環境をデプロイしました。`;
    case "cancel":
      return "キャンセルしました。";
    default:
      return "よう分からんわ。";
  }
};

これで、ボタンを押下したら、bot が反応してくれるようになります。ここまできたら、もう、ほぼ完成で、あとは、 handleAction で実施した処理を書くだけです。
今回実施したい処理は、 vercel のデプロイ(webhook の kick) だったので、そちらを追加します。

index.mjs
const handleAction = (payload) => {
  // WebHook を Kick するだけなので、設定不要
  const headers = {};
  const data = "";

  const user = payload.user.username;
  switch (payload.actions[0].action_id) {
    case "main":
      postRequest(process.env["MAIN_WEBHOOK_URL"], headers, data);
      return `<@${user}> が本番環境をデプロイしました。`;
    case "staging":
      postRequest(process.env["STAGING_WEBHOOK_URL"], headers, data);
      return `<@${user}> がステージング環境をデプロイしました。`;
    case "cancel":
      return "キャンセルしました。";
    default:
      return "よう分からんわ。";
  }
};

こちらで完成です!!

お疲れ様でした。AWS LambdaHTTPS エンドポイントをサポートしてくれたおかげで、bot 作成が簡単にできるようになりました。 (ただ、API Gateway が不要になっただけ)

皆様も色々な bot を作成して、楽していきましょう!!

Discussion