AWS Lambda 関数URL を使ってslackのスラッシュコマンドを実装する
概要
約一年ぐらい前(※)に、 AWS Lambdaに 関数 URL という機能が追加されました。
特定のURLを叩くだけという、従来よりも手軽にLambda関数を実行できるようになりました。
以前よりも手軽に実行できるようになった 関数URLを使用して、手軽に Slack の slash command を実装してみます。
(※)調べたら、2022年4月6日(米国時間)でした。
この記事で書かないこと
以下のような話は、先駆者の方々が多くの記事を公開されています。
- slash command の作成
- slash command から受け取ったデータをLambdaで処理する
そのため、本記事執筆(というか実装)にあたって参考にした記事のリンクを共有するにとどめます。
- https://qiita.com/kobanyan/items/e805e57bbf997335db51
- https://dev.classmethod.jp/articles/lets-make-slack-commands-synchronous-execution-version/
従来との違い
slack の slash command は HTTP POST で通信を行うため、従来 の Lambdaは直接連携できませんでした。
そのため、間にAPI Gateway を挟む、といった手段を取られていたそうです。
関数 URL を使用すると、 lambda を直接 HTTP POST で呼び出せるようになります。
そのため、間にAPI Gateway を挟む必要がなく、構成がシンプルになります。
設定
関数 URL の作成
公式ドキュメントに沿って作成します。
- 認証タイプは None を選択
- CORS は設定しない
作成に成功すると、lambda => 「設定」タブ => 「関数 URL」から、URLを確認できます。
関数 URL を slash command に設定
slash command の設定画面にて、 Request URL
の欄に、関数 URLを張り付けるだけです。
実装における注意点
認証
本記事では関数URLの認証タイプを None
に設定する想定なので、関数URLはインターネットに公開することになります。
関数URLを実行できるユーザを絞る場合は、認証が必要です。
「slash command からのリクエストか」を認証する方法を公式が公開しています。
基本は、この記事の手順に沿って実装します。
(認証処理のサンプルコードを後述します)
上記記事から引用。
- Retrieve the
X-Slack-Request-Timestamp
header on the HTTP request, and the body of the request.- 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 alwaysv0
.- With the help of HMAC SHA256 implemented in your favorite programming language, hash the above basestring, using the Slack
Signing Secret
as the key.- Compare this computed signature to the
X-Slack-Signature
header on the request.
3秒ルール
slash command が呼び出し先は、3秒以内にレスポンスを返す必要があります。
そのため、slash command で重たい処理を実行したい場合は、重たい処理部分を別途切り離す必要があります。
重たい処理の実行結果をSlackへ投下したい!という場合も、Slack へ投稿する処理(Lambda など)を切り離すことになりそうです。
実装
実装には、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