ちょっと便利な Slack bot を作ってみた (key value 型のブックマークbot)

# 概要

* 普段、Slack を利用している中で社内のドキュメントのリンクや、Slack で一度聞いたけど重要な情報が流れていってしまって、調べるのが大変ということありませんか？
* 今回は、そんな悩みを解決する Bot を作成してみたという記事になります。

## 作成した bot の紹介

### ざっくり説明

* ざっくり開発したものを説明すると、Slackのブックマークbotのようなものです。
* 特定のキーワードを使用すると、key: Valueの形で登録でき、Key を bot に対して投げかけると検索して返してくれるような機能を持っています。

### 何ができる?

* 例えば、社内ドキュメントのリンクを検索する際に、bot にキーワードとリンクを登録しておくと、後々調べる際にbotに話しかければ良くなります。
* また、登録する言葉にふざけたキーワードを登録する人が出てくると面白いです。w

### AWS のアーキテクチャ

* aws-cdk で作成しており、よくある API Gateway + Lambda + DynamoDB で作成しました。
* ただ、作った後にふと気づく。
* API Gateway いらなくねと。
* **今年の4月に出た Lambda から HTTPS のエンドポイントを生やせるの用になったので、こっちの方がよかったですね。**

https://aws.amazon.com/jp/about-aws/whats-new/2022/04/aws-lambda-function-urls-built-in-https-endpoints/

![スクリーンショット 2022-12-17 21.27.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/563635/920d3293-3081-cc86-405b-bf35f3b7eea0.png)


### 実際のコード

* ここで重要なことは、Slack の bot を作成する際に使用した Bolt というslackアプリ開発用のフレームワークを使用していることです。

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

```src/lambda/handlers/app.ts
import { App, AwsLambdaReceiver, LogLevel } from "@slack/bolt";
import { APIGatewayProxyEvent, Context } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  GetCommand,
  ScanCommand,
  PutCommand,
  DeleteCommand
} from "@aws-sdk/lib-dynamodb";
import * as dotenv from 'dotenv';
dotenv.config();


const REGION = "ap-northeast-1";
const ddbClient = new DynamoDBClient({ region: REGION });

const awsLambdaReceiver = new AwsLambdaReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET || "",
  customPropertiesExtractor: (req) => {
    return {
      "headers": req.headers,
    };
  }
});
const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  receiver: awsLambdaReceiver,
  logLevel: LogLevel.ERROR,
  processBeforeResponse: true,
});

export const handler = async (event: APIGatewayProxyEvent,
  context: Context, callback: any) => {
  const handler = await awsLambdaReceiver.start();
  return handler(event, context, callback);
}

app.message(/^!add(.*)/, async ({ context, say }) => {
  const greeting = context.matches[1];
  const trim = greeting.trim();

  if (context.headers["x-slack-retry-num"] || context.headers["X-Slack-Retry-Num"]) {
    console.log('retry処理のために握る潰す' + context);
    return;
  }

  const keyword: RegExpExecArray | null = (/[^,]*/).exec(trim);
  const meaning: RegExpExecArray | null = (/(?<=,)(.*)/).exec(trim);

  const castKeyword = keyword ? keyword[0].trim() : '';
  const castMeaning = meaning ? meaning[0].trim() : '';

  if (!castKeyword || !castMeaning) {
    await say('入力に誤りがあるみたい。');
    return;
  }

  const params = {
    TableName: "SlackBotTable",
    Item: {
      keyword: castKeyword,
      meaning: castMeaning,
    },
  };

  try {
    const data = await ddbClient.send(new PutCommand(params));
    console.log("Success - item added or updated", data);
    await say(castKeyword + ` を覚えたよ。`);
  } catch (err) {
    console.log("Error: ", err);
    await say('登録が上手くできなかったごめんね。。再度登録し直してーー！');
  }
});

app.message(/^!word(.*)/, async ({ context, say }) => {
  const greeting = context.matches[1];
  const trim = greeting.trim();

  if (context.headers["x-slack-retry-num"] || context.headers["X-Slack-Retry-Num"]) {
    console.log('retry処理のために握る潰す' + context);
    return;
  }

  const keyword: RegExpExecArray | null = (/[^,]*/).exec(trim);
  const castKeyword = keyword ? keyword[0].trim() : '';

  if (!castKeyword) {
    await say('入力に誤りがあるみたい。');
    return;
  }

  const params = {
    TableName: "SlackBotTable",
    Key: {
      keyword: castKeyword,
    },
  };

  try {
    const data: any = await ddbClient.send(new GetCommand(params));
    console.log("Success :", data.Item);

    if (data.Item) {
      const res = data.Item['meaning']
      await say(res);

    } else {
      try {
        const scanParams = {
          TableName: "SlackBotTable",
          ExpressionAttributeValues: {
            ':keyword': castKeyword
          },
          ProjectionExpression: 'keyword',
          FilterExpression: 'contains(keyword, :keyword)',
          ScanIndexForward: true,
        };

        const data = await ddbClient.send(new ScanCommand(scanParams));
        console.log("success: ", data.Items);

        if (data.Items?.length === 0) {
          await say('見つからなかった。ごめんよぉ');
        } else {
          const candidate = data.Items?.map(i => i.keyword).join(', ') || '';
          await say('見つからなかった。ごめんよぉ\n近いキーワードは発見！。[ ' + candidate + ' ]');
        }

      } catch (err) {
        console.log("Error: ", err);
      }
    }
  } catch (err) {
    console.log("Error: ", err);
  }

});

app.message(/^!del(.*)/, async ({ context, say }) => {
  const greeting = context.matches[1];
  const trim = greeting.trim();

  if (context.headers["x-slack-retry-num"] || context.headers["X-Slack-Retry-Num"]) {
    console.log('retry処理のために握る潰す' + context);
    return;
  }

  const keyword: RegExpExecArray | null = (/[^,]*/).exec(trim);
  const castKeyword = keyword ? keyword[0].trim() : '';

  if (!castKeyword) {
    await say('入力に誤りがあるみたい。');
    return;
  }

  const params = {
    TableName: "SlackBotTable",
    Key: {
      keyword: castKeyword,
    },
  };

  try {
    const data: any = await ddbClient.send(new GetCommand(params));
    if (data.Item) {
      try {
        await ddbClient.send(new DeleteCommand(params));
        console.log("Success :");
        await say(castKeyword + ` が削除しといたゼッ。`);
      } catch (err) {
        console.log("Error: ", err);
      }
    } else {
      await say('見つからなかった。ごめんよぉ');
    }
  } catch (err) {
    console.log("Error: ", err);
  }
});

app.message('hello', async ({ message, say }) => {
  await say(`Hey`);
});


app.message('!help', async ({ message, say }) => {
  await say('!add <keyword>, <meaning> : keywordの意味を登録する ※ ファイルの登録はできません。\n!word <keyword> : keywordの意味を表示する\n!del <keyword> : 登録されたkeywordを削除する');
});

if (process.env.ENV === "local") {
  (async () => {
    await app.start(process.env.PORT || 3000);

    console.log("⚡️ Bolt app is running!");
  })();
}
```

```bin/slack-bolt-cdk-stack.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { SlackBoltCdkStackStack } from '../lib/slack-bolt-cdk-stack-stack';

const app = new cdk.App();
new SlackBoltCdkStackStack(app, 'SlackBoltCdkStackStack', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }
});
```

```lib/slack-bolt-cdk-stack-stack.ts
import { Construct } from 'constructs';
import {
  Stack,
  StackProps,
  aws_apigateway,
  aws_s3,
  RemovalPolicy,
  aws_dynamodb,
  aws_lambda_nodejs,
  aws_iam,
  Duration
} from 'aws-cdk-lib';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import * as dotenv from 'dotenv';
dotenv.config();

export class SlackBoltCdkStackStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const slackBotLambdaRole = new aws_iam.Role(this, 'SlackBoltLambdaPolicy', {
      assumedBy: new aws_iam.ServicePrincipal('lambda.amazonaws.com'),
    });

    const appLambda = new aws_lambda_nodejs.NodejsFunction(this, "slackApi", {
      entry: "./src/lambda/handlers/app.ts",
      runtime: Runtime.NODEJS_16_X,
      handler: 'handler',
      environment: {
        SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN || "",
        SLACK_SIGNING_SECRET: process.env.SLACK_SIGNING_SECRET || "",
      },
      bundling: {
        nodeModules: [
          '@slack/bolt',
          'aws-sdk',
          '@aws-sdk/types',
          '@aws-sdk/lib-dynamodb',
          '@aws-sdk/client-dynamodb'
        ],
      },
      role: slackBotLambdaRole,
      timeout: Duration.minutes(3),
      memorySize: 300,
    });

    slackBotLambdaRole.addManagedPolicy(aws_iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonDynamoDBFullAccess"));
    slackBotLambdaRole.addManagedPolicy(aws_iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambdaExecute"));

    new aws_apigateway.LambdaRestApi(this, 'slackApiGateway', {
      handler: appLambda,
    });

    new aws_dynamodb.Table(this, 'slackApiTable', {
      tableName: 'SlackBotTable',
      partitionKey: { name: 'keyword', type: aws_dynamodb.AttributeType.STRING },
      tableClass: aws_dynamodb.TableClass.STANDARD_INFREQUENT_ACCESS,
    });
  }
}
```

```.env
SLACK_SIGNING_SECRET="xxxxxxxx"
SLACK_BOT_TOKEN="xoxb-xxxxxx-xxxxxxxx-xxxxxxxxxxx"
ENV="prd"
PORT="8080"
```

### 詰まったところ

* 実は Bolt というか Slack × Lambda で bot を作成する際には注意点があります。
* 下記にもあるように Lambda のコールドスタートと Slack の3秒 ack ルールが兼ね合わせが悪く、動きとして、Slack 側は3秒以内にackが来ないと自動で再送信を送ります。これが lambda のコールドスタートのせいで初回だけ時間がかかり必ず再送信からのイベントが再送されてしまうことが起こりました。

https://github.com/slackapi/bolt-js/issues/816

そのため、対応方法はいくつか考えられるのですが、なるべくライトに回避したかったので、下記のように再送処理が Lambda 側に来た場合は無視するような実装にした感じです。

```sample.py
  if (context.headers["x-slack-retry-num"] || context.headers["X-Slack-Retry-Num"]) {
    console.log('retry処理のために握る潰す' + context);
    return;
  }
```

### 使用できるコマンド

* **!add [keyword], [meaning]** : keywordの意味を登録する ※ ファイルの登録はできません。
* **!word [keyword]** : keyword の意味を表示する
* **!del [keyword]** : 登録された keyword を削除する
* **!help** : bot の操作方法を表示します。


### Slack に設定する内容

* Slack に作成したアプリのエンドポイントを叩くアプリを作成する必要があります。
* 作成の仕方は、下記の記事を見ていただくことをオススメします。

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

https://zenn.dev/mokomoka/articles/6d281d27aa344e


* また、slack app に設定する権限は下記辺りの権限があれば良いはずです。

```
app_mentions:read
channels:history
channels:read
chat:write
im:history
im:read
im:write
mpim:history
mpim:read
mpim:write
users:read
users:write
```

### DynamoDBのテーブル設計

* 真面目に考えていませんが、下記のようにしました。

|  Partition key  |  Item  |
| ---- | ---- |
|  keyword  |  meaning  |


# 参考資料

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

https://dev.classmethod.jp/articles/ohakuma-bot/