⚒️

AWS Lambda 関数URL を使ってslackのスラッシュコマンドを実装する

2023/06/09に公開

概要

約一年ぐらい前(※)に、 AWS Lambdaに 関数 URL という機能が追加されました。
特定のURLを叩くだけという、従来よりも手軽にLambda関数を実行できるようになりました。

以前よりも手軽に実行できるようになった 関数URLを使用して、手軽に Slack の slash command を実装してみます。

(※)調べたら、2022年4月6日(米国時間)でした。

この記事で書かないこと

以下のような話は、先駆者の方々が多くの記事を公開されています。

  • slash command の作成
  • slash command から受け取ったデータをLambdaで処理する

そのため、本記事執筆(というか実装)にあたって参考にした記事のリンクを共有するにとどめます。

従来との違い

slack の slash command は HTTP POST で通信を行うため、従来 の Lambdaは直接連携できませんでした。
そのため、間にAPI Gateway を挟む、といった手段を取られていたそうです。

関数 URL を使用すると、 lambda を直接 HTTP POST で呼び出せるようになります。
そのため、間にAPI Gateway を挟む必要がなく、構成がシンプルになります。

image

設定

関数 URL の作成

公式ドキュメントに沿って作成します。

  • 認証タイプは None を選択
  • CORS は設定しない

作成に成功すると、lambda => 「設定」タブ => 「関数 URL」から、URLを確認できます。
image

関数 URL を slash command に設定

slash command の設定画面にて、 Request URLの欄に、関数 URLを張り付けるだけです。

image

実装における注意点

認証

本記事では関数URLの認証タイプを None に設定する想定なので、関数URLはインターネットに公開することになります。
関数URLを実行できるユーザを絞る場合は、認証が必要です。

「slash command からのリクエストか」を認証する方法を公式が公開しています。
基本は、この記事の手順に沿って実装します。
(認証処理のサンプルコードを後述します)

上記記事から引用。

  1. Retrieve the X-Slack-Request-Timestamp header on the HTTP request, and the body of the request.
  2. Concatenate the version number, the timestamp, and the body of the request to form a basestring. Use a colon as the delimiter between the three elements. For example, v0:123456789:command=/weather&text=94070. The version number right now is always v0.
  3. With the help of HMAC SHA256 implemented in your favorite programming language, hash the above basestring, using the Slack Signing Secret as the key.
  4. Compare this computed signature to the X-Slack-Signature header on the request.

3秒ルール

slash command が呼び出し先は、3秒以内にレスポンスを返す必要があります。
そのため、slash command で重たい処理を実行したい場合は、重たい処理部分を別途切り離す必要があります。

image

重たい処理の実行結果をSlackへ投下したい!という場合も、Slack へ投稿する処理(Lambda など)を切り離すことになりそうです。

image

実装

実装には、AWS SAM を使用しました。
実装言語は Typescript です。

SAMの導入、Typescript プロジェクトの作成は、以下の記事を参考にしました。

署名検証用 secret の保存には、SSM パラメータストアを使用しました。

以下に、最終的なディレクトリ構成、ソースコード、CFnテンプレートを掲載します。

ディレクトリ構成

.
├── README.md
├── app 
│   ├── app.ts ★
│   ├── jest.config.ts
│   ├── package.json
│   ├── src 
│   │   └── auth.ts ★
│   ├── tsconfig.json
│   └── yarn.lock
├── events
│   └── event.json
├── samconfig.toml
└── template.yaml ★

app.ts(ハンドラ関数)

import { Handler } from 'aws-lambda';

import { authentication } from './src/auth';

  
export const lambdaHandler: Handler = async (event, context) => {
    try {
        const authenticated = await authentication(event['headers'], event['body']);
        if (!authenticated) {
            return notAuthenticatedResponse();
        }

        /* insert main task code here. */
  
        return requestAcceptedResponse();
    } catch (err) {
        console.error(err);
        return someErrorHappenedResponse();
    }
};
  
const notAuthenticatedResponse = () => {
    return {
        statusCode: 401,
        body: JSON.stringify({
            message: 'unauthorized',
        }),
    };
};
  
const requestAcceptedResponse = () => {
    return {
        statusCode: 200,
        body: JSON.stringify({
            message: 'request accepted.',
        }),
    };
};
  
const someErrorHappenedResponse = () => {
    return {
        statusCode: 500,
        body: JSON.stringify({
            message: 'some error happened',
        }),
    };
};

src/auth.ts(認証処理)

import { createHmac } from 'crypto';
import { SSMClient, GetParametersCommand } from '@aws-sdk/client-ssm';
import { Buffer } from 'buffer';
  
export const authentication = async (headers, body: string): Promise<boolean> => {
    try {
        if (suspectedReplayAttack(headers)) {
            return false;
        }
  
        const actual_signature = headers['x-slack-signature'];
        const expected_signature = await calcExpectedSignature(headers, body);
  
        return actual_signature === expected_signature;
    } catch (e) {
        console.error(e);
        return false;
    }
};
  
const suspectedReplayAttack = (headers): boolean => {
    const request_ts = Number(headers['x-slack-request-timestamp']);
    const current_ts = Math.floor(new Date().getTime() / 1000);
  
    return Math.abs(request_ts - current_ts) > 60 * 5;
};
  
const calcExpectedSignature = async (headers, body: string): Promise<string> => {
    const getSlackSigningSecret = async (): Promise<string> => {
        const client = new SSMClient({});
        const input = {
            Names: ['slack_signing_secret'],// パラメータストアで設定した名前
            WithDecryption: true,
        };
        const command = new GetParametersCommand(input);
        const response = await client.send(command);
  
        if (!response.Parameters || response.Parameters.length === 0 || !response.Parameters[0].Value) {
            throw new Error('no slack signing secret');
        }
  
        const secret = response.Parameters[0].Value;
        return secret;
    };
    const slack_secret = await getSlackSigningSecret();
  
    const message = `v0:${headers['x-slack-request-timestamp']}:${Buffer.from(body, 'base64').toString()}`;
    const hmac = createHmac('sha256', slack_secret);
    const expected = `v0=${hmac.update(message).digest('hex')}`;
    return expected;
};

template.yml(CFn テンプレート)

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  slash_command_test
  
  Sample SAM Template for slash_command_test

Globals:
  Function:
    Timeout: 3
  
Resources:
  ControllerFunction:
    Type: AWS::Serverless::Function 
    Properties:
      CodeUri: app/
      Handler: app.lambdaHandler
      Runtime: nodejs18.x
      Architectures:
      - x86_64
      Policies:
      - Statement:
        - Sid: SSMGetParameterPolicy
          Effect: Allow
          Action:
          - ssm:GetParameters
          - ssm:GetParameter
          Resource: '*'
    Metadata: # Manage esbuild properties
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: es2020
        Sourcemap: true
        EntryPoints:
        - app.ts
        External:
        - "@aws-sdk/client-ssm"
  
  ApplicationResourceGroup:
    Type: AWS::ResourceGroups::Group
    Properties:
      Name:
        Fn::Sub: ApplicationInsights-SAM-${AWS::StackName}
      ResourceQuery:
        Type: CLOUDFORMATION_STACK_1_0
  ApplicationInsightsMonitoring:
    Type: AWS::ApplicationInsights::Application
    Properties:
      ResourceGroupName:
        Ref: ApplicationResourceGroup
      AutoConfigurationEnabled: 'true'
Outputs:
  ControllerFunction:
    Description: Controller Lambda Function ARN
    Value: !GetAtt ControllerFunction.Arn
  ControllerFunctionIamRole:
    Description: Implicit IAM Role created for Controller function
    Value: !GetAtt ControllerFunctionRole.Arn

Discussion