🤖

1絵文字に対して連携した複数チャンネルに転送投稿ができる、Slack Botを作ったよ

2022/12/25に公開

あっという間に今年も年末ですね。どうも、よしです。
今回は少し趣向を変えて、会社で開発して運用している Slack Bot に関するお話をしていきます。

簡単に自己紹介してみる

株式会社ゆめみでフロントエンドエンジニアをしている、よしと申します。
今年の頭に入社したばかりなので、まだまだ新参者ですが、日々精進しながら業務にあたっております。

いつもは会社の人間というより、個人として記事を書いているのですが。
今回は社内で開発・運用しているものの話をするので、会社の人間として自己紹介をしてみました。

開発の背景

ものすごくざっくりいうと「Slack に投稿された重要な共有情報を、一気に各チームチャンネルに転送したいよね」というやつです。

前提:Slack のリアク字チャンネラーでできること

皆さんお馴染みのチャットサービスとして Slack がありますね。
この Slack に、リアク字チャンネラーという機能があることをご存知でしょうか?

https://slack.com/intl/ja-jp/help/articles/360000482666-Slack-用リアク字チャンネラー

1対1で絵文字と投稿チャンネルの設定をした後、その絵文字を投稿につけることで連携したチャンネルへ転送してくれる、というものです。

気になる投稿をあるチャンネルに収集するだとか。
重要な連絡投稿を自チームのチャンネルに転送しておくとか。

主にそういう用途に使われているんじゃないかなと。
お気軽に使用できる機能なので便利は便利なのですが、あくまで1対1でしか設定ができないという課題がありました。

社内の重要連絡の難しさ

社内 Slack で参加人数が増えてくると、情報伝達の難しさといずれ直面する時が来るのではないでしょうか。
例えば、社内全体宛の連絡を全体メンション付きでやったとして。
社内全員へ確実に行き渡っているかどうかと言われると、やはり把握漏れをしている人は出てきてしまいます。
Slack 内で流れている情報が多いと流れていってしまったり、見ていても忘れてしまったりということもありますし。

弊社もその問題を抱えている部分があり、それに対するものとして各チームの代表者を一人決めておくようになりました。
返信が必要なタイプといった重要連絡は、各チームの代表者グループメンション宛で行う

各チームの代表者がそれを検知してチームメンバーに伝えて話し合い、返信をする
のような。

この際、チーム代表者の人が自チームにその連絡を持ち帰るにあたり、リアク字を活用しているのが多く見受けられました。
前述した通り、リアク字は1対1でしか連携設定ができないので、各チームが各々でリアク字を設定して自チームチャンネルへ転送していたわけです。

1対多で転送できたら良いのでは?

この現状に対して、自分が所属しているフロントエンドギルド(職能グループ)内で
1対多で一度に各チームチャンネルへ転送できたら、各々で転送しなくていいし楽なのでは?」
という声が上がりました。

確かにそれができたら楽だなぁと。
連絡する側としても、各チームへ伝えたいものである時に、1つの絵文字をつけるだけで各チームチャンネルに転送してくれると楽ですよね。
チームの代表者側としても検知の手間が減らせそう。

委員会による業務改善活動

弊社は案件業務とは別に活動する委員会というものがあります。
https://notion.yumemi.co.jp/c1e96b62e36c48cfbfd60b36fe4d800c

  • 業務改善
  • 技術開発
  • ブランディング

のいずれかに属している委員会が多数あり、その中で自分は業務改善に属する脚本家・ハッカー委員会に所属しています。
(元々は別々の委員会でしたが、人数の都合で合併しました)

この委員会として
1対多で一度に各チームチャンネルへ転送できたら、各々で転送しなくていいし楽なのでは?」
という声を聞き、じゃあそれが実現できるものを作ってみよう。
となって作ったのが、今回話をする Slack Bot です。

実際の技術構成

ベース

  • TypeScript:言語
  • Node.js:実行環境
  • esbuild:ビルドツール

フロントエンドで使い慣れてる TypeScript が使いたかったので、TypeScript × Node.js にしました。
esbuild は自分で採用したというより、後述する Serverless Framework のテンプレートをベースにした関係で使用している感じです。

ライブラリなど

Bolt

https://slack.dev/bolt-js/ja-jp/tutorial/getting-started

Slack API のフレームワークです。
Slack Bot のロジックの実装がやりやすくなりそうだったので使用。

Notion SDK

https://github.com/makenotion/notion-sdk-js

Notion API の SDK。

今回の Slack Bot を開発するにあたり、転送用の絵文字と転送先のチャンネルの紐付けデータをどこかで持たせる必要がありました。
インフラを AWS にしたので、データベースを DynamoDB にする手もあったのですが、
データ登録・確認などの操作をするのにわざわざマネジメントコンソール開いたりするのもな...と。
Web 上でデータ操作できるシステムを別途作るのも手間ですし。

そこで社内全体で活用されている Notion の DB を採用して、気軽にデータ操作できるようにしました。
(もちろん、事故防止で委員会メンバー以外は触らないようにとしていますが)
その Notion DB のデータを取得するために使用。

Serverless Framework

https://www.serverless.com/

サーバレス関数のデプロイ・リソース管理ツール。
内部的に AWS CLI、AWS CloudFormation が使用されています。

今回、インフラに AWS を採用したので、AWS へのデプロイとリソース管理をするのに使用。
テンプレートが多数用意されており、このテンプレートでプロジェクトの雛形を作りました。

ngrok

https://ngrok.com/

localhost のポートを外部公開してくれる CLI。
Slack Bot のローカルでの動作確認時に localhost ドメインが使用できない関係で活用。
(なので、実際にインフラへ乗せて動作させる分には使用しません)

AWS

  • AWS IAM:Serverless Framework でデプロイするのにあたり、必要な権限を管理
  • AWS CLI:aws コマンドで、Serverless Framework が内部的に使用
  • AWS CloudFormation:Serverless Framework が内部的に使用
  • Amazon S3:Serverless Framework がデプロイするリソースを保管するのに使用
  • AWS Lambda:Slack Bot の Node.js 関数実行環境
  • Amazon API Gateway:Lambda をAPI 化
  • Amazon CloudWatch Logs:ログ出力

Slack Bot の動作環境としては AWS にしました。
GAS や Cloud Function などを使う手もあったのですが、拡張性やメンテナンス性を考えたり。
社内ツールの環境として AWS 環境が多く使われていることもあり、結局は AWS とすることに。

実際に作ってみたもの

random チャンネルの投稿を、bot_test, bot_test2 チャンネルへ転送しているデモ
複数チャンネルに転送投稿のデモのGIF
(流石に社内ワークスペースを写すわけにはいかなかったので、個人のワークスペースでのデモです)

ここから実際にどうやって作ったかを書いていきます。
(道中、share-appと書いているのは、仮のアプリ名です)

この Slack Bot の処理フローイメージについては、こんな感じです。

前提

  • Slack ワークスペースがある
  • Notion が使える状態
  • AWS CLI 導入済み
  • AWS アカウントがある
  • Node.js 導入済み
    • 今回は 16.12.0 を使用

Slack

Slack App

Slack Bot を作るためにまずは Slack App を作成します。

  1. Slack ワークスペースの左上のワークスペース名メニューから「その他管理項目」→「アプリを管理する」でアプリ管理画面へ
  2. 右上の「ビルド」を選択
  3. 「Create New App」を選択(以下は From scratch の場合)
    1. 任意の名前と Bot を動作させたいワークスペースを入力して「Create App」
  4. 作成後、サイドバーの「OAuth & Permissions」へ
    1. Scopes の Bot Token Scopes に権限を追加
      • chat:write
      • chat:write:public
      • reactions:read
    2. OAuth Tokens for Your Workspace にある「Install to Workspace」で Slack App をインストール
    3. インストール後に発行される Bot User OAuth Token を控えておく
  5. サイドバーの「Basic Information」にある Signing Secret も控えておく

ちなみに Bot の名前やアイコンをカスタムしたい場合は、App Home から設定ができますので、なんか良さげな設定にしておきましょう。

転送用の絵文字を登録

転送投稿に使用するカスタム絵文字を登録しておきます。

  1. Slack ワークスペースの左上のワークスペース名メニューから「その他管理項目」→「以下をカスタマイズ」
  2. 絵文字タブで「カスタム絵文字を追加する」から絵文字を追加

登録の際は、絵文字が転送用のものと判別できるよう識別子にプレフィックスをつけるなどすると、わかりやすくなっておすすめです。

テキストの絵文字を作る際は、ジェネレーターを使うとぱっと作れるので、よく利用しています。
https://emoji-gen.ninja/

エラー通知用のチャンネル

後述のロジックでは、途中でエラーになった際、なるべくエラー通知用のチャンネルへ投稿するようにしています。
エラー通知用のチャンネルを用意して、そのチャンネル ID を控えておきましょう。

ちなみに最初は AWS 側の構成でエラー通知の仕組みを作ろうかとも思ったのですが、
一旦は1ロジックの中でエラーハンドリングをして、エラー通知するような実装としました。
(サブスクリプションフィルターを利用して、通知用の Lambda を分離するとより良いかもしれない...?)

Notion

Notion Integration

Notion API を利用するにあたり、Integration の作成が必要になるので作成します。
(その Notion ワークスペースの管理者権限がないと作れないようなので注意)

  1. Notion - My integrations
  2. 「New intergration」を選択
  3. 任意の情報を入力して「Submit」
    1. ワークスペースは DB を管理するワークスペースにする
    2. 権限は Read 権限のみで OK
  4. 作成した Integration を選択し、Secrets を控えておく

Notion DB

転送用絵文字と転送先チャンネルの紐付けデータを管理するデータベースを作成します。

投稿先としたいチャンネル一覧 DB を作った後、カスタム絵文字とその DB レコードをリレーションする形としました。
実際のデータ部分は、ご自身の環境に合わせて登録してください。

投稿先としたいチャンネル一覧 DB
  • channel_name:タイトル
  • channel_id:テキスト
  • (任意の名前):リレーション(後述の DB)
    投稿先としたいチャンネル一覧 DB

チャンネル名とチャンネル ID がわかるようにしておきます。
チャンネル ID に関しては、そのチャンネルを開いた状態で、チャンネル名のところから開けるメニューの下部からコピーできるのでそちらから。

カスタム絵文字と対応チャンネル DB
  • emoji_identifier:タイトル
  • (任意の名前):リレーション(前述の DB)
  • channel_id:ロールアップ(前述 DB の channel_id)
    カスタム絵文字と対応チャンネル DB

emoji_identifier は絵文字の識別子のうち、両端のコロンを取り除いたものです。

ロジックからはこちらの DB を参照することになります。
絵文字識別子を元に、転送先のチャンネル ID 一覧を取得するイメージ。
そのため、こちらの DB の ID を控えておきましょう。
DB の URL リンクから、ワークスペース名の後ろについているものが DB の ID となっています。

Notion DB を Integration 経由でアクセスできるようにする

Notion DB があるページもしくは、その親ページに作成した Integration を招待します。
(ここも管理者権限がなければ招待できないと思われます)

  1. ページの右上メニューから「Add connections」を選択
  2. 作成した Integration を選択し「Confirm」を選択

あとは、ロジックからこの Integration の権限を使いアクセスすることで、この DB の情報が取得できるようになります。

AWS

AWS 環境へのデプロイは Serverless Framework を使用しますが、それにあたりアクセス権限が必要です。
必要な権限を設定した IAM ポリシーを持つ IAM ユーザを発行し、アクセスキーを設定しておきます。

IAM ポリシーに必要な権限

以下の部分とリージョン部分は、ご自身の環境に合わせて置換してください

  • {アカウントID}:AWS のアカウント ID
  • {アプリ名}:アプリの名称
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "apigateway:GET",
                "apigateway:POST",
                "apigateway:PATCH",
                "apigateway:PUT",
                "apigateway:DELETE"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudformation:CancelUpdateStack",
                "cloudformation:ContinueUpdateRollback",
                "cloudformation:CreateChangeSet",
                "cloudformation:CreateStack",
                "cloudformation:CreateUploadBucket",
                "cloudformation:DeleteChangeSet",
                "cloudformation:DeleteStack",
                "cloudformation:Describe*",
                "cloudformation:EstimateTemplateCost",
                "cloudformation:ExecuteChangeSet",
                "cloudformation:Get*",
                "cloudformation:List*",
                "cloudformation:UpdateStack",
                "cloudformation:UpdateTerminationProtection"
            ],
            "Resource": "arn:aws:cloudformation:ap-northeast-1:{アカウントID}:stack/{アプリ名}*/*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudformation:ValidateTemplate"
            ],
            "Resource": "*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "iam:AttachRolePolicy",
                "iam:CreateRole",
                "iam:DeleteRole",
                "iam:DeleteRolePolicy",
                "iam:DetachRolePolicy",
                "iam:GetRole",
                "iam:PassRole",
                "iam:PutRolePolicy"
            ],
            "Resource": [
                "arn:aws:iam::*:role/{アプリ名}*-lambdaRole"
            ]
        },
          {
            "Effect": "Allow",
            "Action": [
                "iam:AttachRolePolicy",
                "iam:CreateRole",
                "iam:DeleteRole",
                "iam:DeleteRolePolicy",
                "iam:DetachRolePolicy",
                "iam:GetRole",
                "iam:PassRole",
                "iam:PutRolePolicy"
            ],
            "Resource": [
                "arn:aws:iam::*:role/{アプリ名}*-lambdaRole"
            ]
        },
       {
            "Effect": "Allow",
            "Action": [
                "lambda:List*"
            ],
            "Resource": [
                "*"
            ]
        },
               {
            "Effect": "Allow",
            "Action": [
                "lambda:CreateFunction",
                "lambda:DeleteFunctionEventInvokeConfig",
                "lambda:DeleteFunction",
                "lambda:Get*",
                "lambda:InvokeFunction",
                "lambda:Update*",
                "lambda:AddPermission",
                "lambda:PublishVersion",
                "lambda:PutFunctionEventInvokeConfig",
                "lambda:RemovePermission",
                "lambda:CreateAlias",
                "lambda:TagResource"
            ],
            "Resource": [
                "arn:aws:lambda:*:*:function:{アプリ名}*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:DescribeLogGroups"
            ],
            "Resource": "arn:aws:logs:ap-northeast-1:{アカウントID}:log-group::log-stream:*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:DescribeLogStreams",
                "logs:FilterLogEvents",
                "logs:DeleteLogGroup"
            ],
            "Resource": "arn:aws:logs:ap-northeast-1:{アカウントID}:log-group:/aws/lambda/{アプリ名}*:log-stream:*",
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:CreateBucket",
                "s3:DeleteBucket",
                "s3:DeleteBucketPolicy",
                "s3:DeleteObject",
                "s3:DeleteObjectVersion",
                "s3:Get*",
                "s3:List*",
                "s3:PutBucketNotification",
                "s3:PutBucketPolicy",
                "s3:PutBucketTagging",
                "s3:PutBucketWebsite",
                "s3:PutEncryptionConfiguration",
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::{アプリ名}*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:*"
            ],
            "Resource": [
                "arn:aws:s3:::{アプリ名}*/*"
            ]
        }
    ]
}

Slack トークンのところで最小権限の原則と言っておきながら、ちょっと権限緩いやんけ
というツッコミが怖いところではあります。
権限はもう少し絞る余地がありそうですね...(IAM 権限精査難しいのです...😢)

API Gateway のリソース指定については、後述の補足も参照していただければと。

アクセスキーを設定

プロファイル名やキーの値は、ご自身の環境に合わせて~/.aws/credentialsに登録しておきます。
開発と本番とで認証情報を分ける場合は、それぞれの環境用で設定。

[share-app]
aws_access_key_id=アクセスキー
aws_secret_access_key=シークレットキー
region=ap-northeast-1

ロジック

Slack Bot のロジック部分です。
Serverless Framework のテンプレートをベースとしましたが、Bolt と相性の悪そうなライブラリは一部取り除きました。

使用したライブラリとバージョン抜粋

  "dependencies": {
    "@notionhq/client": "^1.0.4",
    "@slack/bolt": "^3.11.3"
  },
  "devDependencies": {
    "@serverless/typescript": "^3.19.0",
    "@types/aws-lambda": "^8.10.101",
    "@types/node": "^16.11.43",
    "@types/serverless": "^3.12.7",
    "esbuild": "^0.14.47",
    "serverless": "^3.19.0",
    "serverless-dotenv-plugin": "^3.12.2",
    "serverless-esbuild": "^1.30.0",
    "serverless-offline": "^8.8.0",
    "ts-node": "^10.8.1",
    "tsconfig-paths": "^3.9.0",
    "typescript": "^4.6.4",
  },

ディレクトリ構成抜粋

(ルート)
├ src
│  ├ functions
│  │  ├ share
│  │  │  ├ handler.ts
│  │  │  └ index.ts
│  │  └ index.ts
│  └ libs
│     ├ handlers-resolver.ts
│     ├ notion.ts
│     ├ slack.ts
│     └ util.ts
├ .env.development
├ .env.example
├ .env.production
├ package.lock-json
├ package.json
├ serverless.ts
├ tsconfig.json
└ tsconfig.paths.json

環境変数

本ロジックでは以下の環境変数を使用します。
各 .env ファイルへ環境に応じた値を設定しておきましょう。
多くはこれまでの設定で控えてきたものです。

  • SLACK_WORKSPACE_URL:Slack Bot を動作させるワークスペースの URL(https:// から)
  • SLACK_BOT_TOKEN:Bot Token
  • SLACK_SIGNING_SECRET:Slack App のシークレット
  • SLACK_ALERT_CHANNEL:エラー通知用チャンネルの ID
  • NOTION_TOKEN:Notion API を利用するためのシークレット
  • NOTION_DATABASE_ID:転送用絵文字と転送先チャンネルの紐付けデータを持つ DB の ID

serverless-dotenv-pluginを利用すると、環境に応じた環境変数へ切り替えを自動で行ってくれるので便利です。
(このプラグインを適用しただけだとロジック上の環境変数の切り替えを行なってくれるだけなので、リソース定義ファイル内でも切り替えをしたい場合はuseDotenvを true にする必要があります)

tsconfig 設定

基本的にはテンプレートそのままにしました。

tsconfig.json
{
  "extends": "./tsconfig.paths.json",
  "compilerOptions": {
    "lib": ["ESNext"],
    "moduleResolution": "node",
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "removeComments": true,
    "sourceMap": true,
    "target": "ES2020",
    "outDir": "lib"
  },
  "include": ["src/**/*.ts", "serverless.ts"],
  "exclude": [
    "node_modules/**/*",
    ".serverless/**/*",
    ".webpack/**/*",
    "_warmup/**/*",
    ".vscode/**/*"
  ],
  "ts-node": {
    "require": ["tsconfig-paths/register"]
  }
}
tsconfig.paths.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@functions/*": ["src/functions/*"],
      "@libs/*": ["src/libs/*"]
    }
  }
}

libs 配下

handler-resolver.ts

ここはテンプレそのままです。

src/libs/handler-resolver.ts
export const handlerPath = (context: string) => {
  return `${context.split(process.cwd())[1].substring(1).replace(/\\/g, '/')}`;
};
Notion SDK のロジック

Notion DB からデータを取得〜データ構造をチェック〜転送先チャンネル ID 一覧を返すところまでやっています。

Notion DB の構造が想定と違うと、それだけでうまくいかなくなるので、検知できるようエラーとするように。
API レスポンス例を見てもらうとわかるのですが、なかなか階層が深くてですね...😇

APIレスポンス例
{
    "object": "list",
    "results": [
        {
            "object": "page",
            "id": "c1aabbbd-ab8f-402a-b68d-ac3c831ee5b3",
            "created_time": "2022-12-11T05:53:00.000Z",
            "last_edited_time": "2022-12-11T05:58:00.000Z",
            "created_by": {
                "object": "user",
                "id": "1c36cd3b-0bf9-4205-9d15-ef31439349a1"
            },
            "last_edited_by": {
                "object": "user",
                "id": "1c36cd3b-0bf9-4205-9d15-ef31439349a1"
            },
            "cover": null,
            "icon": null,
            "parent": {
                "type": "database_id",
                "database_id": "20c32631-43b5-4986-9cd7-f3dd712b5dc8"
            },
            "archived": false,
            "properties": {
                "channel_id": {
                    "id": "%5DbMK",
                    "type": "rollup",
                    "rollup": {
                        "type": "array",
                        "array": [
                            {
                                "type": "rich_text",
                                "rich_text": [
                                    {
                                        "type": "text",
                                        "text": {
                                            "content": "CBRR6BTCP",
                                            "link": null
                                        },
                                        "annotations": {
                                            "bold": false,
                                            "italic": false,
                                            "strikethrough": false,
                                            "underline": false,
                                            "code": false,
                                            "color": "default"
                                        },
                                        "plain_text": "CBRR6BTCP",
                                        "href": null
                                    }
                                ]
                            },
                            {
                                "type": "rich_text",
                                "rich_text": [
                                    {
                                        "type": "text",
                                        "text": {
                                            "content": "C04EP3LKDMY",
                                            "link": null
                                        },
                                        "annotations": {
                                            "bold": false,
                                            "italic": false,
                                            "strikethrough": false,
                                            "underline": false,
                                            "code": false,
                                            "color": "default"
                                        },
                                        "plain_text": "C04EP3LKDMY",
                                        "href": null
                                    }
                                ]
                            }
                        ],
                        "function": "show_original"
                    }
                },
                "channel_name": {
                    "id": "%5DedV",
                    "type": "relation",
                    "relation": [
                        {
                            "id": "8f967547-25be-40b0-9bce-cd71235f598e"
                        },
                        {
                            "id": "aa001db1-27d1-41c0-a8e5-e771b8e914c4"
                        }
                    ],
                    "has_more": false
                },
                "emoji_identifier": {
                    "id": "title",
                    "type": "title",
                    "title": [
                        {
                            "type": "text",
                            "text": {
                                "content": "share-app",
                                "link": null
                            },
                            "annotations": {
                                "bold": false,
                                "italic": false,
                                "strikethrough": false,
                                "underline": false,
                                "code": false,
                                "color": "default"
                            },
                            "plain_text": "share-app",
                            "href": null
                        }
                    ]
                }
            },
            "url": "※DBレコードのURL"
        }
    ],
    "next_cursor": null,
    "has_more": false,
    "type": "page",
    "page": {}
}
src/libs/notion.ts
import { Client } from '@notionhq/client';

const notion = new Client({
  auth: process.env.NOTION_TOKEN,
  notionVersion: '2022-02-22',
});

const dataStructureErrorMsg =
  'Notion から想定しないデータ構造のレスポンスが返されました:';

/**
 * カスタム絵文字と紐づくチャンネル ID 取得
 * @param reaction カスタム絵文字識別子(両端にコロンはつかない)
 * @returns カスタム絵文字と紐づくチャンネル ID 一覧
 */
export const getChannelIdsFromReaction = async (
  reaction: string
): Promise<string[]> => {
  const res = await notion.databases.query({
    database_id: process.env.NOTION_DATABASE_ID,
    filter: {
      property: 'emoji_identifier',
      title: {
        equals: reaction,
      },
      type: 'title',
    },
  });

  // NOTE: 絵文字の登録がない場合は results が空配列になるので空配列を返す
  if (res.results.length === 0) return [];

  // NOTE: 想定しないデータ構造のレスポンスの場合はエラーにする
  if (!('properties' in res.results[0])) {
    throw new Error(`${dataStructureErrorMsg} properties が存在しません。`);
  }
  if (!(res.results[0].properties.channel_id?.type === 'rollup')) {
    throw new Error(
      `${dataStructureErrorMsg} rollup の channel_id が存在しません。`
    );
  }
  if (!(res.results[0].properties.channel_id.rollup.type === 'array')) {
    throw new Error(
      `${dataStructureErrorMsg} rollup の type が array になっていません。`
    );
  }

  return res.results[0].properties.channel_id.rollup.array.map((d) => {
    if (!(d.type === 'rich_text')) {
      throw new Error(
        `${dataStructureErrorMsg} rollup 配列の中身が rich_text になっていません。`
      );
    }
    return d.rich_text[0].plain_text;
  });
};
Slack API のロジック

転送投稿どうやっているのか?というのはpostShareChannelにある通りです。

Slack は投稿内容に URL が含まれていると、プレビュー表示してくれるのはおなじみですね。
なので、転送元となる投稿の URL を組み立てて転送投稿に含めてあげれば、あとは勝手にプレビュー表示してくれるわけです。
投稿の URL は法則性があるため、その法則に応じた URL を組み立てるようにしてあります。

src/libs/slack.ts
import { AllMiddlewareArgs } from '@slack/bolt';
import { ReactionsGetResponse, UsersInfoResponse } from '@slack/web-api';
import { getFormatTs, getEscapeGroupMention } from '@libs/util';

const workSpace = process.env.SLACK_WORKSPACE_URL;
const archives = 'archives';
const alertChannel = process.env.SLACK_ALERT_CHANNEL;

/**
 * 転送投稿(転送元投稿のリンクのみ転送)
 * @param client Slack API クライアント
 * @param originalChannelId 転送元投稿のチャンネル ID
 * @param shareChannelId 転送したいチャンネル ID
 * @param ts タイムスタンプ(XXXXXXXXXX.XXXXXX)
 */
export const postShareChannel = async (
  client: AllMiddlewareArgs['client'],
  originalChannelId: string,
  shareChannelId: string,
  ts: string
): Promise<void> => {
  const formatTs = getFormatTs(ts);

  await client.chat.postMessage({
    channel: shareChannelId,
    text: `共有だよ。転送元は<${workSpace}/${archives}/${originalChannelId}/p${formatTs}|こちら>。`,
  });
};

/**
 * エラー通知投稿
 * @param client Slack API クライアント
 * @param errorStack エラースタックトレース
 */
export const postAlertChannel = async (
  client: AllMiddlewareArgs['client'],
  errorStack: string
): Promise<void> => {
  await client.chat.postMessage({
    channel: alertChannel,
    blocks: [
      {
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: errorStack,
        },
      },
    ],
    text: errorStack,
  });
};

/**
 * 投稿情報取得
 * @param client Slack API クライアント
 * @param channelId チャンネル ID
 * @param ts タイムスタンプ(XXXXXXXXXX.XXXXXX)
 * @returns 投稿情報
 */
export const getPostInfo = async (
  client: AllMiddlewareArgs['client'],
  channelId: string,
  ts: string
): Promise<ReactionsGetResponse> => {
  return await client.reactions.get({
    channel: channelId,
    timestamp: ts,
  });
};
ユーティリティ関数

Slack から受け取るパラメータのタイムスタンプはピリオド区切りな一方で、投稿 URL に含まれるタイムスタンプはピリオドなしなので、ピリオド除去の関数を作りました。
(別に関数化しなくてもよかったかも...)

src/libs/util.ts
/**
 * ピリオド区切りがないタイムスタンプ形式にフォーマット
 * @param ts タイプスタンプ(ピリオド区切りがあるもの)
 * @returns ピリオド区切りがないタイムスタンプ
 */
export const getFormatTs = (ts: string) => {
  return ts.replace('.', '');
};

function 配下

基幹ロジック

改めて見るとごちゃごちゃしているような気がしたりしなかったりですが、基幹ロジックはこんな感じ。
なるべくエラーハンドリングやロギングをやるようにしています。
今回は Lambda で動作させるのでAwsLambdaReceiverを使用していますが、環境に応じてレシーバーが変わってくると思われます。

src/functions/share/handler.ts
import { isNotionClientError } from '@notionhq/client';
import { App, AwsLambdaReceiver } from '@slack/bolt';
import { ReactionsGetResponse } from '@slack/web-api';
import { getChannelIdsFromReaction } from '@libs/notion';
import { postShareChannel, postAlertChannel, getPostInfo } from '@libs/slack';

const awsLambdaReceiver = new AwsLambdaReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
});

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  receiver: awsLambdaReceiver,
  /**
   * NOTE:
   * このオプションにより、Bolt フレームワークが `ack()` などでリクエストへの応答を返す前に
   * `app.message` などのメソッドが Slack からのリクエストを処理できるようになる。
   * FaaS では応答を返した後にハンドラーがただちに終了してしまうため、このオプションの指定が必要になる
   */
  processBeforeResponse: true,
});

app.event('reaction_added', async ({ event, client, logger }) => {
  logger.info(`reaction_added イベントを開始:${event.reaction}`);

  if (event.item.type !== 'message') {
    logger.info(
      'イベント発生元のタイプが message でなかったので処理を終了します。'
    );
    return;
  }
  const ts = event.item.ts;
  const originalChannelId = event.item.channel;

  let shareChannelIdList: string[];
  try {
    shareChannelIdList = await getChannelIdsFromReaction(event.reaction);
    if (shareChannelIdList.length === 0) {
      logger.info(
        `${event.reaction} 絵文字に対応した投稿チャンネルがなかったので処理を終了します。`
      );
      return;
    }
  } catch (e) {
    if (isNotionClientError(e)) {
      logger.error(
        'Notion API でエラーが発生し、カスタム絵文字に紐づくチャンネルID取得に失敗しました。処理を終了します。'
      );
      logger.error(e.stack);
      await postAlertChannel(client, e.stack);
      return;
    } else if (e instanceof Error) {
      logger.error(
        'カスタム絵文字に紐づくチャンネルID取得に失敗しました。処理を終了します。'
      );
      logger.error(e.stack);
      await postAlertChannel(client, e.stack);
      return;
    }
  }

  let originalPostInfo: ReactionsGetResponse;
  try {
    originalPostInfo = await getPostInfo(client, originalChannelId, ts);
  } catch (e) {
    if (e instanceof Error) {
      logger.error('転送元投稿情報の取得に失敗しました。処理を終了します。');
      logger.error(e.stack);
      await postAlertChannel(client, e.stack);
      return;
    }
  }
  // NOTE: 他に絵文字がついていない + 転送用絵文字が0個の状態から1つついて即時に削除された場合にキャンセルとみなして処理を終了する
  if (originalPostInfo.message.reactions === undefined) {
    logger.info(
      `この投稿に対して ${event.reaction} 絵文字は0(他の絵文字も0)で、転送がキャンセルされたので処理を終了します。`
    );
    return;
  }
  const reactionCount = originalPostInfo.message.reactions.find(
    (reaction) => reaction.name === event.reaction
  )?.count;
  // NOTE: 他に絵文字がある + 転送用絵文字が0個の状態から1つついて即時に削除された場合にキャンセルとみなして処理を終了する
  if (reactionCount === undefined) {
    logger.info(
      `この投稿に対して ${event.reaction} 絵文字は0で、転送がキャンセルされたので処理を終了します。`
    );
    return;
  } else if (reactionCount > 1) {
    // NOTE: 転送用絵文字が2個以上ついた時は転送済みとみなして処理を終了する
    logger.info(
      `この投稿に対して ${event.reaction} 絵文字は、すでに転送済みなので処理を終了します。`
    );
    return;
  }

  for (const shareChannelId of shareChannelIdList) {
    try {
      await postShareChannel(client, originalChannelId, shareChannelId, ts);
      logger.info(`チャンネルID: ${shareChannelId} に共有投稿しました。`);
    } catch (e) {
      if (e instanceof Error) {
        logger.error(
          `チャンネルID: ${shareChannelId} へ共有投稿に失敗しました。`
        );
        logger.error(e.stack);
        await postAlertChannel(client, e.stack);

        /**
         * NOTE: 以下の時は後続投稿を止めないよう return して次のループへ
         * - 存在しないチャンネル ID が指定されていた
         * - 投稿先チャンネルがアーカイブされていた
         */
        if (
          e.message.includes('channel_not_found') ||
          e.message.includes('is_archived')
        )
          return;
      }
    }
  }
  logger.info(`reaction_added イベントを終了:${event.reaction}`);
});

type AwsHandlerParameter = Parameters<
  ReturnType<typeof awsLambdaReceiver.toHandler>
>;

const share = async (
  event: AwsHandlerParameter[0],
  context: AwsHandlerParameter[1],
  callback: AwsHandlerParameter[2]
) => {
  /**
   * NOTE:
   * Slack Events API の仕様上、特定の条件を満たすとリクエストが再送される(最大3回)
   * その条件の1つに「3秒以内にレスポンスが返ってこない」(http_timeout)があり、
   * Lambda のコールドスタート + ロジックの速度で3秒超えてしまうことがあるので
   * http_timeout の再送リクエストの場合は何もせずにレスポンスを返すようにすることで
   * 転送投稿が重複しないようにする
   * 参考:https://dev.classmethod.jp/articles/slack-resend-matome/
   */
  if (
    event.headers['X-Slack-Retry-Num'] &&
    event.headers['X-Slack-Retry-Reason'] === 'http_timeout'
  ) {
    console.info(
      'http_timeout の再送リクエストのため、何も処理せず終了します。'
    );
    return { statusCode: 204 };
  }
  const handler = await awsLambdaReceiver.start();
  return handler(event, context, callback);
};

export const main = share;

ロジックの補足書こうかとも思いましたが、大体はコード上のコメントの通りです。

強いて補足するなら、転送用絵文字の数を見ているところについて。

今回は絵文字追加イベント検知時の処理を定義していて、そのevent引数に絵文字をつけた投稿の情報も一部含まれています。
シンプルに転送投稿するだけなら、その引数の情報を使えばよかったりするのですが。
今回は絵文字のついた数による制御も入れたかったので、転送元となる投稿の情報を API で取得した上で、そこに含まれる絵文字の数の情報を元に制御するよう対応しています。

キャンセルの方では絵文字が0個になることある?と思われそうですが、
これは絵文字がついてから実際にロジックが動き出すまでに数秒のラグがあるので、その間に絵文字を削除された場合を考慮したものです。
(誤って転送用絵文字2個目をつけてしまい1個削除した場合は、ロジック時点で1個になり転送されてしまう問題がありますが、これは運用でカバーするようにしています)

ハンドラーの設定

この Lambda 関数の設定を定義。
Bolt を使ったアプリはslack/eventsのパスが使われるようだったので、このパスにしています。
(もしかしたら Lambda にデプロイする場合は、任意のパスにできるのかもしれない...)

src/functions/share/index.ts
import { handlerPath } from '@libs/handler-resolver';

export default {
  handler: `${handlerPath(__dirname)}/handler.main`,
  description: 'チームの連絡をやりやすくする Slack Bot',
  events: [
    {
      http: {
        method: 'post',
        path: 'slack/events',
      },
    },
  ],
};
エクスポート
src/functions/index.ts
export { default as share } from './share';

Serverless Framework 設定

これも基本的にはテンプレベースです。
ご自身の環境に応じて適宜置換、カスタムしてください。

serverless.ts
import type { AWS } from '@serverless/typescript';

import share from '@functions/share';

const serverlessConfiguration: AWS = {
  service: 'share-app',
  frameworkVersion: '3',
  plugins: [
    'serverless-esbuild',
    'serverless-offline',
    'serverless-dotenv-plugin',
  ],
  provider: {
    name: 'aws',
    runtime: 'nodejs16.x',
    region: 'ap-northeast-1',
    stage: 'development',
    stackTags: {
      name: 'share-app',
    },
    apiGateway: {
      minimumCompressionSize: 1024,
      shouldStartNameWithService: true,
    },
    environment: {
      AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
      NODE_OPTIONS: '--enable-source-maps --stack-trace-limit=1000',
    },
  },
  // import the function via paths
  functions: { share },
  package: { individually: true },
  custom: {
    esbuild: {
      bundle: true,
      minify: false,
      sourcemap: true,
      exclude: ['aws-sdk'],
      target: 'node16',
      define: { 'require.resolve': undefined },
      platform: 'node',
      concurrency: 10,
    },
  },
};

module.exports = serverlessConfiguration;

npm scripts 定義

Serverless Framework を使用して各種コマンドを実行しますが、AWS のプロファイル指定など毎回入力するのは大変なので、npm scripts に定義しておくと良いです。

  • dev:ローカルでの開発時のサーバ起動
  • deploy:AWS 環境へのデプロイ
  • remove:リソース削除

--aws-profileオプションで使用するプロファイルの指定ができるので、ご自身の環境に合わせてご指定ください。

package.json(npm の場合の例)
"scripts": {
  "dev": "npx sls offline --noPrependStageInUrl --aws-profile share-app -s development",
  "deploy:dev": "npx sls deploy --aws-profile share-app -s development",
  "deploy:prod": "npx sls deploy --aws-profile share-app -s production",
  "remove:dev": "npx sls remove --aws-profile share-app -s development",
  "remove:prod": "npx sls remove --aws-profile share-app -s production",
},

デプロイ

先ほど定義したコマンドでデプロイできます。
もし失敗する場合は、恐らく権限が足りないなどのエラーが出ていると思いますので、IAM の権限を調整してみてください。

# development 環境
npm run deploy:dev

# production 環境
npm run deploy:prod

逆にリソースを削除したい時は、以下コマンドで削除が可能です。

# development 環境
npm run remove:dev

# production 環境
npm run remove:prod

リクエスト URL の確認

デプロイが成功したら、リクエスト URL がコンソールに出力されているので確認しておきましょう。

マネジメントコンソールから確認する場合

  1. AWS マネジメントコンソールの API Gateway へ
  2. デプロイしたアプリの API の詳細へ
  3. ステージ → 確認したいステージを選択、とすると URL の呼び出しというところに、公開 URL が書いてある
  4. これに指定した API のパスを末尾につけたものを控えておく(今回の場合は/slack/event

Event Subscription 設定

Slack Bot のロジックが用意できたので、あとは Slack Bot からこのロジックをリクエストするよう設定をしていきます。

  1. Slack ワークスペースの左上のワークスペース名メニューから「その他管理項目」→「アプリを管理する」でアプリ管理画面へ
  2. 今回作成した Slack App の設定画面へ
  3. サイドバーの「Event Subscription」へ
  4. Event Subscription を有効化
    1. Enable Events を On に
    2. Request URL に先ほど控えたリクエスト URL を入力
    3. 疎通テストがうまくいけば Verified と表示される
    4. Subscribe to bot events に reaction_added を追加(これにより、この Slack App が追加されているチャンネルの、絵文字追加イベントを検知できるようになる)
    5. 「Save Changes」を選択

動作確認

ここまでで設定が終わりました。
あとは実際に動作するか、動作確認していきましょう。

  1. Slack App を連携したワークスペースで、転送元としたいチャンネルに作成した Slack App を追加する
    1. チャンネル名から開けるメニューのインテグレーションタブ → App → アプリを追加する
    2. (そのチャンネルにアプリを追加しましたという表示が出ていれば OK)
  2. そのチャンネルの投稿に転送用絵文字をつける
  3. 転送用絵文字と紐づけている転送先チャンネルに転送投稿できているか確認

ここまでできれば完了です。
お疲れ様でした!

うまく動かなかったら

以下を確認してみてください

  • 転送元としたいチャンネルに Slack App を追加できているか
  • 転送用絵文字を間違えていないか
  • Notion DB に登録している転送用絵文字と転送先チャンネル情報は正しいか
  • Notion DB のページかその親ページに作成した Integration が連携されているか
  • デプロイした AWS リソースが存在しているか
  • Lambda 関数に環境変数は設定されているか
  • エラー通知用チャンネルに何か出力されていないか
  • AWS CloudWatch のログに何か出ていないか

その他補足など

普段の開発

開発時に毎回 AWS 環境へデプロイして動作確認するのは大変なので、普段はsls offline + ngrok を活用するようにしています。
sls offlineで localhost:3000 にてサーバが立ち上がるので、3000 ポートを ngrok で公開 URL 発行。
発行した URL を開発用の Slack App の Event Subscription のリクエスト URL に設定する感じです。

マルチワークスペースチャンネルからも転送したい

転送元となるチャンネルがマルチワークスペースチャンネルだった場合。
今回のロジックで使用している Bot Token では権限が足りないようで、転送元投稿を取得するところで失敗してしまいます。

この場合は、転送元投稿を取得する部分だけ User Token を使うようにすれば、一応対応は可能です。
Slack App を認証した人になりすます形となるので、その人がそのマルチワークスペースチャンネルにアクセスできる人であれば、アクセス権限があることになるわけです。
(属人化っぽくもなってしまうので、可能であれば管理者権限用のアカウントで Slack App を認証するようにすると良いかもです)

const userToken = process.env.SLACK_USER_TOKEN;

/**
 * 投稿情報取得
 * @param client Slack API クライアント
 * @param channelId チャンネル ID
 * @param ts タイムスタンプ(XXXXXXXXXX.XXXXXX)
 * @returns 投稿情報
 */
export const getPostInfo = async (
  client: AllMiddlewareArgs['client'],
  channelId: string,
  ts: string
): Promise<ReactionsGetResponse> => {
  /**
   * NOTE:
   * Bot Token だとマルチワークスペースチャンネルの情報取得ができないようなので
   * ここだけ User Token を使うようにする
   */
  return await client.reactions.get({
    channel: channelId,
    timestamp: ts,
    token: userToken,
  });
};

IAM ポリシーで API Gateway のリソース指定をしたい

IAM ポリシーを記述していたところで、API Gateway 部分は以下のようにしていました。

{
  "Effect": "Allow",
  "Action": [
    "apigateway:GET",
    "apigateway:POST",
    "apigateway:PATCH",
    "apigateway:PUT",
    "apigateway:DELETE"
  ],
  "Resource": [
    "*"
  ]
},

リソース指定できてねーじゃねぇかというやつですね😇

というのも、他の Lambda などはリソース定義のサービス名やステージ名を組み合わせた ARN になるので、予測して記述ができました。
一方で、API Gateway は API を作成した時に生成される ID を使った ARN になるようだったので、予測して記述ができなかったためです。

一応の解決策としては、あらかじめマネジメントコンソールの API Gateway で API を作成。
生成された API を開いて上部に書いてある API の ID と、API のルートリソース ID を控えておき。
リソース定義の中でその ID を指定して、作成した API 内へデプロイする形にすれば対応できます。

serverless.ts
apiGateway: {
  restApiId: '{API ID}',
  restApiRootResourceId: '{API のルートリソース ID}',
  minimumCompressionSize: 1024,
  shouldStartNameWithService: true,
},

IAM のリソース指定はこんな感じにできます(REST API として作成した場合)

{
    "Effect": "Allow",
    "Action": [
        "apigateway:GET",
        "apigateway:POST",
        "apigateway:PATCH",
        "apigateway:PUT",
        "apigateway:DELETE"
    ],
    "Resource": [
        "arn:aws:apigateway:ap-northeast-1::/restapis/{API ID}",
        "arn:aws:apigateway:ap-northeast-1::/restapis/{API ID}/*"
    ]
},

ちなみに環境ごとにどの API 内へデプロイするか向き先を変えたい場合。
リソース定義の中でuseDotenvを true にすると、リソース定義ファイル内でも環境変数を切り替えることが可能になります。
そして以下のような環境変数の埋め込み式の書き方をすると、デプロイ時に環境変数の値に置換した上でデプロイしてくれます。
あとは環境ごとの.envファイルにそれぞれの API の ID を書いておけば OK。

serverless.ts
useDotenv: true,
.
.
.
apiGateway: {
  restApiId: '${env:REST_API_ID}',
  restApiRootResourceId: '${env:REST_API_ROOT_RESOURCE_ID}',
  minimumCompressionSize: 1024,
  shouldStartNameWithService: true,
},

作ってみてどうだった?

最初はフロントエンドギルド内で beta 運用するところからはじめました。
好感触な声をいくつかいただいて、特に反対意見はなかったのでそのまま本番運用へ。
その後、他メンバーが広めてくださったこともあり、他のギルドでも一部使ってもらえるようになりました。

今回の背景にあった課題が劇的に改善されたか?と言われると、流石にそこまでは言い切らないです。
ただ、連絡する側の人やチーム代表者の人から、前より楽になったといった言葉はもらうことができました。
なので、作っていくらか貢献はできたのかなぁと。

これから

構成やロジックは最低限動く程度みたいなところがあるので、改善の余地はたくさんあるかなぁと思っています。
割と愚直に書いているところもありますし。
合間を見つけてリファクタなんかもやっていきたいですね。

ちなみに開発・運用管理は自分がほぼ1人で好き勝手にやっていたところがあるので、現在委員会メンバーで平準化できるよう、もろもろ整備中だったりします。
この記事を書こうと思ったのは、平準化の一環でもありました。
README をはじめ、ドキュメントをちゃんと整備して平準化するの大事。属人化はダメ。


今回は社内で開発・運用している Slack Bot をベースとしたお話をお送りしました。
やはりコードもある分、文字量がかさみますね😇
ここまで読んでいただいた方、ありがとうございます!

実はアドベントカレンダーに投稿するの初めてでした。
そして、12月前半に参加表明するも、その時はすでに9割くらい埋まっており...。
ちょっと時間かかりそうだったので後ろの方にしようとしていたら、無意識に大トリ枠をとっていたという〜。
大トリにふさわしい内容だったかは分かりませんが、何か参考になれば幸いです🙏

参考リンクまとめ

株式会社ゆめみ

Discussion