こんにちは、櫻井です。
今回は前に公開した「SlackとAWSを組み合わせた電話番号登録アプリの開発 – Boltフレームワーク+CDKで作ってみた」で利用したBoltフレームワークとCDKのソースコードを解説します。アプリのデプロイ方法については上記のリンクから確認できます。
ソースコードを抜粋しながら解説を行います。ソースコードの全文を確認したい場合はGit Hubで公開しているので、こちらから確認してください!
目次
CDK
ここでは、CDKのスタック層(lib配下のファイル)とアプリ層(bin配下のファイル)についての解説を行います。
CDKではL2コンストラクタを使用して記述しています。コードを書くときはこちらを参考にしました。
SlackAppRouterStack
スタック層にある、slackAppRouterStack.tsは本アプリのメインとなる部分です。
このスタックで作成されるリソースは以下の4点です。
- Boltフレームワークを使うためのlambda
- lambdaにアタッチするロール
- API GateWay
- ユーザの権限を管理するためのDynamoDBテーブル
以下のコードは “SlackAppRouterStackProps”という名前で、”Stack Props”を拡張しています。
“envName”, “projectName” などは、後ほど解説するアプリ層から値を受け取ります。
この “SlackAppRouterStackProps” を スタックの “constructor” の “props”にわたすことで、スタック内から外部の値を参照することができるようになります。
1 2 3 4 5 6 |
export interface SlackAppRouterStackProps extends StackProps { envName: string; projectName: string; authorityManagementChannelId: string; managePhoneNumbersStateMachine: StateMachine; } |
“const SLACK_BOT_TOKEN” は 前回の記事でSSMパラメータストアに保存した 値を取得し、変数に割り当てています。
TypeScriptでは、(バッククオート)内で ${変数名} を使うと文字列の中に変数を埋め込むことができます。つまり、今回の場合はSSMパラメータストアから “/slackAppRouter/geekBlog/SLACK_BOT_TOKEN”の値を取得しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
export class SlackAppRouterStack extends Stack { constructor(scope: Construct, id: string, props: SlackAppRouterStackProps) { super(scope, id, props); const { accountId, region } = new ScopedAws(this); // SSMパラメータストアから値を取得する const SLACK_BOT_TOKEN = StringParameter.valueForStringParameter( this, `/${props.projectName}/${props.envName}/SLACK_BOT_TOKEN` ); const SLACK_BOT_SIGNING_SECRET = StringParameter.valueForStringParameter( this, `/${props.projectName}/${props.envName}/SLACK_BOT_SIGNING_SECRET` ); |
“boltAppRole” では boltApp(lambda)にアタッチするロールを作成しています。CDKではlambdaを作成する場合、ロールを定義してアタッチしなくても自動でロールを作成してくれますが、今回は明示的に定義しています。理由は後述します。
“boltApp”ではlambdaを作成しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
// boltApp lambda用のロール const boltAppRole = new Role(this, `${props.envName}-boltAppRole`, { assumedBy: new ServicePrincipal("lambda.amazonaws.com"), managedPolicies: [ ManagedPolicy.fromManagedPolicyArn( this, `${props.envName}-lambdaBasickExecution`, "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" ), ], }); // lambda const boltApp = new Function(this, `${props.envName}-boltApp`, { functionName: `${props.envName}-boltApp`, runtime: Runtime.PYTHON_3_9, code: Code.fromAsset("lambda/boltApp"), handler: "boltApp.handler", role: boltAppRole, timeout: Duration.seconds(30), layers: [ new LayerVersion(this, `${props.envName}-boltAppLayer`, { code: AssetCode.fromAsset("layer/boltAppLayer/"), }), ], environment: { SLACK_BOT_TOKEN: SLACK_BOT_TOKEN, SLACK_BOT_SIGNING_SECRET: SLACK_BOT_SIGNING_SECRET, USER_TABLE_NAME: authorizationManagementUserTable.tableName, AUTHORITY_MANAGEMENT_CHANNEL_ID: props.authorityManagementChannelId, MANAGE_PHONE_NUMBERS_STATE_MACHINE_ARN: props.managePhoneNumbersStateMachine.stateMachineArn, TZ: "Asia/Tokyo", }, }); |
ここでは先ほど作成した”boltAppRole”に権限を追加しています。
“authorizationManagementUserTable.grantReadWriteData(boltAppRole)” は どのリソースの、どのような権限を、どのリソースに対して付与するかという構成になっています。
以下のコードの場合だと、”authorizationManagementUserTable” の読み取りと書き込み権限を “boltAppRole”に対して付与している。というふうになります。
1 2 |
// boltAppRoleに権限を追加 authorizationManagementUserTable.grantReadWriteData(boltAppRole); |
今回のlambdaは自分自身を呼び出す権限が必要なため、以下のコードでboltAppRoleにboltAppの実行権限を付与しています。
1 2 3 4 5 6 7 8 |
boltAppRole.addToPolicy( new PolicyStatement({ resources: [ `arn:aws:lambda:${region}:${accountId}:function:${props.envName}-boltApp`, ], actions: ["lambda:GetFunction", "lambda:InvokeFunction"], }) ); |
“boltAppRole”に”boltApp”の実行権限を与えたいだけであれば以下のように権限を追加すればいいのではないかと思いますが、以下のような書き方をすると、なぜか循環依存を起こしてしまいます。
今のところ上記の方法以外で循環依存を解決する方法を見つけられていないため、上記のような書き方をしました。他に循環依存を解決するいい方法があった場合はアップデートする予定です。
1 2 |
//循環依存を起こしてしまうパターン boltApp.grantInvoke(boltAppRole) |
ManagePhoneNumbersStack
ManagePhoneNumberStackは、電話番号管理アプリで、電話番号の追加、削除、一覧表示を行ったときに必要なリソースを定義しています。
このスタックで作成されるリソースは以下の2点です。
- 電話番号を管理するDynamoDBテーブル
- SlackAppRouterStackで作成したlambdaからのリクエストで起動するStepFunctions
以下のコードの “public readonly managePhoneNumbersStateMachine: StateMachine;” の部分は、このスタック内のmanagePhoneNumbersStateMachineを外部から参照できるようにするために必要なコードです。今回この値は、アプリ層で受け取り “slackAppRouterStack” に渡しています。
1 2 3 4 5 6 7 8 |
export class ManagePhoneNumbersStack extends Stack { public readonly managePhoneNumbersStateMachine: StateMachine; constructor( scope: Construct, id: string, props: ManagePhoneNumbersStackProps ) { super(scope, id, props); |
次にStepFunctionsの部分のコードを確認します。
StepFunctionsのワークフローは以下の画像のようになります。このステートマシンはboltAppのlambdaからjsonを受け取り、json内の”selected_type”で分岐します。
以下はboltAppから受け取ったjsonの”selected_type” が “delete”だった場合の処理です。”DynamoDeleteItem”というクラスがあるのでそれを利用して削除処理を行います。
1 2 3 4 5 6 7 8 |
const deleteTask = new DynamoDeleteItem(this, "deleteRecord", { table: managePhoneNumbersTbl, key: { phoneNumber: DynamoAttributeValue.fromString( JsonPath.stringAt("$.phone_number") ), }, }); |
以下はboltAppから受け取ったjsonの”selected_type” が “summary”だった場合の処理です。先程の”selected_type” が “delete” だった場合は “DynamoDeleteItem”というクラスを使いました。しかし今回は “CallAwsService” というクラスを使っています。”DynamoScanItem”のようなクラスがあればよかったのですが、今の所無いらしく、このような書き方になっています。
1 2 3 4 5 6 7 8 9 |
const summaryTask = new CallAwsService(this, "scanRecord", { service: "dynamodb", action: "scan", parameters: { TableName: managePhoneNumbersTbl.tableName, }, iamResources: [managePhoneNumbersTbl.tableArn], iamAction: "dynamodb:Scan", }); |
以下はステートマシンを定義している部分です。”stateMachineType”が “EXPRESS”の場合、明示的にログを残すコードを書かないとログが出力されないので、”logs”の部分を記述しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const managePhoneNumbersStateMachine = new StateMachine( this, `${props.projectName}-${props.envName}-managePhoneNumbers`, { definition: definition, stateMachineType: StateMachineType.EXPRESS, logs: { destination: new aws_logs.LogGroup( this, `${props.projectName}-${props.envName}-managePhoneNumbersLogGroup` ), level: LogLevel.ALL, }, } ); |
以下は”public readonly managePhoneNumbersStateMachine: StateMachine;”に上記で作成したステートマシンを割り当てています。
1 |
this.managePhoneNumbersStateMachine = managePhoneNumbersStateMachine; |
slack_app_router.ts
slack_app_router.tsはアプリ層にあたります。アプリ層はスタック層で定義したスタック郡のAWSリソースをデプロイするために必要なコードを実行する部分です。
1行目は、cdk.jsonから環境変数のキーを取得しています。デプロイ時にこのメソッドに環境変数のキーを渡すことで、cdk.jsonのcontextから環境ごとの値を取得することができるようになります。
2行目は取得した環境変数のキーを利用して、実際の環境変数の値を取得しています。
1 2 |
const envKey = app.node.tryGetContext("environment"); const envValues = app.node.tryGetContext(envKey); |
以下は”ManagePhoneNumbersStack”のリソースをデプロイするためのコードです。”envName”, “projectName”, “authorityManagementChannelId”を cdk.json から取得して、propsに渡しています。
1 2 3 4 5 6 7 8 9 |
const managePhoneNumbersStack = new ManagePhoneNumbersStack( app, `${envValues.env}-${projectName}-managePhoneNumbersStack`, { envName: envValues.env, projectName: projectName, authorityManagementChannelId: envValues.authorityManagementChannelId, } ); |
以下は”SlackAppRouterStack”のリソースをデプロイするためのコードです。”ManagePhoneNumbersStack”と違う点は propsで”managePhoneNumbersStateMachine” を定義し”ManagePhoneNumbersStack” で作成されたステートマシンの情報を渡しているところです。
1 2 3 4 5 6 7 8 9 10 |
const slackAppRouterStack = new SlackAppRouterStack( app, `${envValues.env}-${projectName}-SlackAppRouterStack`, { envName: envValues.env, projectName: projectName, authorityManagementChannelId: envValues.authorityManagementChannelId, managePhoneNumbersStateMachine: managePhoneNumbersStack.managePhoneNumbersStateMachine, } |
Slack Bolt (lambda)
次に、Boltフレームワークを使ったソースコードについて解説していきます。
BoltはSlackワークスペースで発生するイベントを受け取り、そのイベントに対応する処理をすることができます。
Boltに関する基本的な概念はこちらにまとまっているので参考にしてみてください。
@app.event(“app_home_opened”)はアプリのホーム画面が開かれたときに動く処理です。
ここでは、ホームを開いたユーザがどのアプリの権限を持っているかを確認し、権限を持っているアプリのボタンだけを表示するような処理を行っています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
home_tab_view_blocks_dict = { "manage_phone_numbers": manage_phone_numbers_block, "authority_register": authority_register_block, } """ ホームタブのviewを表示 """ @app.event("app_home_opened") def update_home_tab(client: WebClient, event: Dict): user_id = event["user"] allowed_apps = get_allowed_apps(user_id) home_tab_view_blocks = [] for allowed_app in allowed_apps: home_tab_view_blocks.append(home_tab_view_blocks_dict[allowed_app]) home_tab_view = { "type": "home", "blocks": home_tab_view_blocks } try: client.views_publish(user_id=user_id, view=home_tab_view) except Exception as e: logger.error(f"Error publishing home tab:\n {e}") """ ユーザがどのアプリの権限を持っているか確認する関数 """ def get_allowed_apps(user_id): table_name = os.environ["USER_TABLE_NAME"] key_condition_expression = "userId = :pk" expression_attribute_values ={ ":pk": {"S":user_id} } response = dynamodb.query( TableName=table_name, KeyConditionExpression=key_condition_expression, ExpressionAttributeValues=expression_attribute_values ) apps = [] items = response["Items"] for item in items: apps.append(item["appName"]["S"]) return apps |
“アプリ権限管理”の権限しか持っていない場合は以下のように表示されます。
“アプリ権限管理” と “電話番号管理” の権限を持っている場合は以下のように表示されます。
“boltApp.py”はSlackワークスペースからのイベントに応じて処理を行う関数を登録するためのコードが含まれています。
“boltApp.py”ファイル自体には、具体的な処理の実装は含まれておらず、処理の実装は別のファイルに分割しています。
たとえば、以下は”アプリ権限管理” で “Submit”ボタンをクリックしたときに呼び出されれる”boltApp.py”の処理です。
1 2 3 4 5 6 7 |
""" アプリ権限管理のモーダルでSubmitをクリックしたときの処理 """ @app.view("authority_register_submission_click") def call_handle_request_authority_register_modal_view_events(ack: Ack, body: Dict, client: WebClient): //authority_register.pyの関数を呼び出す handle_request_authority_register_modal_view_events(ack, body, client) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
""" authority_registerのモーダルの入力情報を取り出してDBに保存する """ def handle_request_authority_register_modal_view_events(ack: Ack, body: Dict, client: WebClient): app = App(token=os.environ["SLACK_BOT_TOKEN"]) input_value = body["view"]["state"]["values"] request_type = input_value["request-type-block"]["authority_register_select-action"]["selected_option"]["value"] select_user_id = input_value["select_user"]["authority_register_select-action"]["selected_user"] select_app = input_value["select_app"]["authority_register_select-action"]["selected_option"]["value"] user_name = app.client.users_info(user=select_user_id)["user"]["real_name"] click_user_id = body["user"]["id"] if request_type == "registration": put_user_data(select_user_id, select_app, user_name) message = f"<@{click_user_id}>さんが<@{select_user_id}>さんに{select_app}の権限を追加しました" elif request_type == "delete": delete_user_data(select_user_id, select_app) message = f"<@{click_user_id}>さんが<@{select_user_id}>さんの{select_app}の権限を削除しました" client.chat_postMessage(channel=os.environ["AUTHORITY_MANAGEMENT_CHANNEL_ID"], text=message) ack() |
以下はアプリのホーム画面で “電話番号管理” ボタンをクリックしたときの処理です。slack api はリクエストがあった時3秒以内にレスポンスを返さないといけないといけないという決まりがあるため、”ack”の部分でslackに対して先に空のレスポンスを返して、”lazy”で後続の処理を行います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
""" Lazy listeners機能の使用時に、3秒以内にレスポンスを返す処理 """ def respond_to_slack_within_3_seconds(ack): ack() """ アプリのホームで電話番号管理ボタンを押したときの処理 """ app.action("request_manage_phone_numbers_button_click")( ack=respond_to_slack_within_3_seconds, lazy=[handle_open_manage_phone_numbers_modal_button_clicks] ) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
manage_phone_numbers_block = { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "電話番号管理", "emoji": True }, "style": "primary", "value": "manage_phone_numbers", "action_id": "request_manage_phone_numbers_button_click" } ] } |
以下は上記の画面を描画するためのviewのコードです。
モーダルビューでは “callback_id”というプロパティを設定できます。callback_idは、文字列で指定された一意の識別子で、アプリケーションがモーダルの送信時に特定のモーダルを識別することができます。以下のコードでは、callback_idに “request_manage_phone_numbers_type_select_modal” が設定されています。
このモーダルが “submit” されると、Boltはこのcallback_idを使用して送信されたモーダルを特定し、適切な処理を行います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
request_manage_phone_numbers_modal_view = { "type": "modal", "callback_id": "request_manage_phone_numbers_type_select_modal", "title": { "type": "plain_text", "text": "電話番号編集", "emoji": True }, "submit": { "type": "plain_text", "text": "Next", "emoji": True, }, "close": { "type": "plain_text", "text": "Cancel", "emoji": True }, "blocks": [ { "type": "input", "block_id": "request-type-block", "element": { "type": "radio_buttons", "action_id": "manage_phone_numbers-input-element", "options": [ { "value": "registration", "text": { "type": "plain_text", "text": "電話番号の登録" } }, { "value": "delete", "text": { "type": "plain_text", "text": "電話番号の削除" } }, { "value": "summary", "text": { "type": "plain_text", "text": "電話番号一覧の取得" } } ] }, "label": { "type": "plain_text", "text": "実行する処理" } }, ] } |
以下は、”request_manage_phone_numbers_type_select_modal” を持つviewで “submit” されたときの処理です。
先程と同様に “boltApp.py” では具体的な処理は書かれていません。具体的な処理が書かれている別ファイルにルーティングするだけです。
1 2 3 4 5 6 |
""" 電話番号管理でいずれかのラジオボタンを選択してNextをクリックしたときの処理 """ @app.view("request_manage_phone_numbers_type_select_modal") def call_handle_request_manage_phone_numbers_modal_view_events(ack: Ack, view: Dict): handle_request_manage_phone_numbers_type_select_modal_view_events(ack, view) |
具体的な処理は “manage_phone_numbers.py” に書かれています。
この関数では “request_manage_phone_numbers_modal_view” の “blocks”プロパティ内の “options” プロパティにある “value” から選択したタイプを判別して、それぞれの処理に分岐させています。
選択したタイプが “registration” だった場合は、電話番号登録処理に必要な情報を入力してもらうための新しい”manage_phone_numbers_registration_view” というview にモーダルをアップデートします。
選択したタイプが “summary” だった場合はStepFunctionsを起動して、DynamoDBに登録されているレコードをスキャンし、レスポンスをviewに格納してモーダルをアップデートします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
def handle_request_manage_phone_numbers_type_select_modal_view_events(ack: Ack, view: Dict): inputs = view["state"]["values"] selected_type = inputs["request-type-block"]["manage_phone_numbers-input-element"]["selected_option"]["value"] if selected_type == "registration": ack( response_action="update", view=manage_phone_numbers_registration_view ) return elif selected_type == "delete": ack( response_action="update", view=manage_phone_numbers_delete_view ) return elif selected_type == "summary": print("summary") input_data = {"selected_type": selected_type} response = step_functions.start_sync_execution( stateMachineArn=os.environ["MANAGE_PHONE_NUMBERS_STATE_MACHINE_ARN"], input=json.dumps(input_data) ) ack( response_action="update", view=manage_phone_numbers_summary_result_view(response) ) |
以下は選択したタイプが “registration” だった場合のモーダルです。
以下は選択したタイプが “sumarry” だった場合のモーダルです。
まとめ
今回はCDKとBoltフレームワークを使ったlambdaのソースコードの解説を行いました。
今回説明したこと以外でも、AWSとSlackを連携させることで、いろいろなことを簡単にできるようになると思うのでぜひ使ってみてください!
- 特定の時間あたりのlambda実行数をSlackに通知する - 2023-12-23
- 公衆電話からでも使える電話帳サービスをLEX + AmazonConnectで作ってみた - 2023-12-19
- SQSを使ってlambdaを10秒ごとに定期実行する - 2023-12-14
- Slack と AWS の電話番号登録アプリ – Bolt + CDK ソースコード解説 - 2023-03-26
- SlackとAWSを組み合わせた電話番号登録アプリの開発 – Boltフレームワーク+CDKで作ってみた - 2023-03-26