SlackのSlash commandの処理をAWS Lambdaで実装

シナリオ
========

-   Slack → API Gateway → Lambda

Slack 側では Custom Integrations の Slash Commands を使用します。
AWS 側は blueprint にある slack-echo-command-python を使ってコマンドを実行する Lambda 関数を作成します。
Lambda 関数の処理では 3 秒以内にSlack 側へ応答を返す必要があります。
時間がかかる処理を行うために、Lambda で受信したコマンドは、SNS を経由して別の Lambda 関数をイベント起動します。

-   Lambda → SNS → Lambda → Slack

SNS イベントから起動される Lambda 関数では STS を利用して、Slash Command を発行したユーザのロールを引き受けてAPI コマンドを実行します。

![lambda-bot.png](https://qiita-image-store.s3.amazonaws.com/0/68901/1ebca89c-8023-8769-8c85-5be97013cba4.png)


-   利用したblueprint
    -   slack-echo-command-python
        Slack 側は Custom Integrations の Slash Commands を使用します。
    -   cloudwatch-alarm-to-slack-python  
        Incoming Webhooks でなく `response_url` を使って応答を返すように修正しました。

Slack → API Gateway → Lambda
============================

Lambda 関数の作成
-----------------

blueprint として slack-echo-command-python を選択して、Lambda 関数の登録を行います。

![step1.png](https://qiita-image-store.s3.amazonaws.com/0/68901/f0b955fe-f297-6b54-727c-fa0d26442bbc.png)

![step2.png](https://qiita-image-store.s3.amazonaws.com/0/68901/3d0781b0-92b4-4500-703d-91a7a8a70fdc.png)

ロールを作成します。内容はひとまずデフォルトのままでかまいません。

![lambda-role1.png](https://qiita-image-store.s3.amazonaws.com/0/68901/400c4177-ad7e-1ebd-f077-c9e377b454d1.png)

![lambda-role2.png](https://qiita-image-store.s3.amazonaws.com/0/68901/dc64c4f0-dcb5-fd94-1b7a-6bab2a15e042.png)

![lambda-role3.png](https://qiita-image-store.s3.amazonaws.com/0/68901/b36c5142-ba7c-209f-b5fc-0f80a89ae917.png)

エンドポイント設定では **Method を POST に**、**セキュリティ設定は Open** で作成します。

![step3.png](https://qiita-image-store.s3.amazonaws.com/0/68901/de014c35-57ac-013a-1145-5d7e5fea53b8.png)

最初は関数の本体はbuleprint のままでかまいません。

Slash Command App の登録
------------------------

blueprint の先頭にコメントで設定手順が書かれていますので、これにそって進めます。

This function handles a Slack slash command and echoes the details back to the user.

Follow these steps to configure the slash command in Slack:  

1.  Navigate to https://<your-team-domain>.slack.com/services/new

2.  Search for and select "Slash Commands".

    ![slash2.png](https://qiita-image-store.s3.amazonaws.com/0/68901/bfcaef39-bf48-1ec5-133b-35d9f6d8d12c.png)

3.  Enter a name for your command and click "Add Slash Command Integration".

    ![shash3.png](https://qiita-image-store.s3.amazonaws.com/0/68901/4e06fea7-2505-b33b-5f7e-472c342fd555.png)

4.  Copy the token string from the integration settings and use it in the next section.  
    tokenの文字列はこのあとの設定で使いますので控えておいてください。

    ![shalsh4.png](https://qiita-image-store.s3.amazonaws.com/0/68901/210bda08-f9c6-1f58-3c41-416e488a54bc.png)

5.  After you complete this blueprint, enter the provided API endpoint URL in the URL field.

    ![slash5-1.png](https://qiita-image-store.s3.amazonaws.com/0/68901/865d4348-57a4-df46-4b3f-56b998de48cf.png)

    ![shash5-2.png](https://qiita-image-store.s3.amazonaws.com/0/68901/b587c09c-a58c-d018-76d2-1bd8f8b65b8a.png)

KMSの設定
---------

Follow these steps to encrypt your Slack token for use in this function:

1.  Create a KMS key - http://docs.aws.amazon.com/kms/latest/developerguide/create-keys.html.  
    IAMのマネジメントコンソールから暗号化キーの設定を行います。

    ![kms1-1.png](https://qiita-image-store.s3.amazonaws.com/0/68901/9e401693-a962-4dc4-4e94-e14ec902856a.png)

    ![kms1-2.png](https://qiita-image-store.s3.amazonaws.com/0/68901/b1a246dc-6c2e-1b22-6a72-0420a117de2c.png)

    ![kms1-3.png](https://qiita-image-store.s3.amazonaws.com/0/68901/c9796d45-2044-9afb-a2fa-c9d654898987.png)

2.  Encrypt the token using the AWS CLI.  
    $ aws kms encrypt --key-id alias/<KMS key name> --plaintext "<COMMAND_TOKEN>"

    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    $ aws kms encrypt --key-id alias/slack-token --plaintext="XXXXXXXXXXXXXXXXXXXXXXXX"
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    {    "KeyId": "arn:aws:kms:ap-northeast-1:XXXXXXXXXXXX:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
         "CiphertextBlob": "BASE64-encoded-string"}
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

3.  Copy the base-64 encoded, encrypted key (CiphertextBlob) to the kmsEncyptedToken variable.

    出力された CiphertextBlob の値を Lambda 関数の `ENCRYPTED_EXPECTED_TOKEN` 変数の値として設定します。

    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    ENCRYPTED_EXPECTED_TOKEN = "BASE64-encoded-string" # Enter the base-64 encoded, encrypted Slack command token (CiphertextBlob)
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

4.  Give your function's role permission for the kms:Decrypt action.  
    Example:

    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
       {
         "Version": "2012-10-17",
         "Statement": [
           {
             "Effect": "Allow",
             "Action": [
               "kms:Decrypt"
             ],
             "Resource": [
               "<your KMS key ARN>"
             ]
           }
         ]
       }
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    この例を参考に先ほど作成したポリシーを修正します。

    lambda_basic_execution

    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Effect": "Allow",
                "Action": [
                    "logs:CreateLogGroup",
                    "logs:CreateLogStream",
                    "logs:PutLogEvents"
                ],
                "Resource": "arn:aws:logs:*:*:*"
            },
            {
                "Effect": "Allow",
                "Action": [
                    "kms:Decrypt"
                ],
                "Resource": [
                    "arn:aws:kms:us-east-1:XXXXXXXXXXXX:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
                ]
            }
        ]
    }
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

エンドポイントの設定
--------------------

Follow these steps to complete the configuration of your command API endpoint

1.  When completing the blueprint configuration select "POST" for method and  
    "Open" for security on the Endpoint Configuration page.

    すでに、この内容で作成しているはずです。設定が合っていないときは変更してください。

2.  After completing the function creation, open the newly created API in the  
    API Gateway console.

3.  Add a mapping template for the application/x-www-form-urlencoded content type with the following body: { "body": \$input.json("\$") }

    API Gateway の Resource で、Integration Request の Body Mapping Templates を追加します。

    ![api-gw-1.png](https://qiita-image-store.s3.amazonaws.com/0/68901/e6b46a3b-1a9d-f786-4d1b-d3fad15a33e8.png)

    ![api-gw-2.png](https://qiita-image-store.s3.amazonaws.com/0/68901/ce132fc0-6258-1561-e75b-937ef9c8c4b8.png)

4.  Deploy the API to the prod stage.

    修正後、Deply API で反映しておきます。

5.  Update the URL for your Slack slash command with the invocation URL for the created API resource in the prod stage.    
    エンドポイントの設定ができましたので、Slack 側の Slash Commands の Integration Settings でエンドポイントのURLを設定しておきます。

    ![api-gw-3.png](https://qiita-image-store.s3.amazonaws.com/0/68901/cc771c44-f345-abb4-d774-2e31b3a9f77f.png)


最初のコード
------------

この時点で、Lambda 関数は ENCRYPTED_EXPECTED_TOKEN の値を設定しただけで、blueprint のままになっているはずです。

**lambda-botコード**

~~~
import boto3
from base64 import b64decode
from urlparse import parse_qs
import logging

ENCRYPTED_EXPECTED_TOKEN = "<KMS出力のCiphertextBlobのRVALUEであるBASE64文字列">" # Enter the base-64 encoded, encrypted Slack command token (CiphertextBlob)

kms = boto3.client('kms')
expected_token = kms.decrypt(CiphertextBlob = b64decode(ENCRYPTED_EXPECTED_TOKEN))['Plaintext']

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    req_body = event['body']
    params = parse_qs(req_body)
    token = params['token'][0]
    if token != expected_token:
        logger.error("Request token (%s) does not match exptected", token)
        raise Exception("Invalid request token")

    user = params['user_name'][0]
    command = params['command'][0]
    channel = params['channel_name'][0]
    command_text = params['text'][0]

    return "%s invoked %s in %s with the following text: %s" % (user, command, channel, command_text)
~~~

テスト
------

マネジメントコンソールからテストするときのテストイベントは次のようになります。

tokenの値は Slash Commands の設定で控えた値を指定してください。

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
{
"body":
"token=XXXXXXXXXXXXXXXXXXXXXXXX&command=/lambda&text=ec2&user_name=Steve&channel_name=test&response_url=https://hooks.slack.com/commands/1234/5678"
}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

ここまでエラーなく動くようになったら、Slack からテストしてみます。

![test1.png](https://qiita-image-store.s3.amazonaws.com/0/68901/342d2af1-185b-47f2-11bc-f8d45de81a40.png)

1回目はタイムアウトでエラーになるかもしれません。そのときはもう一度コマンドを発行してみてください。

応答が返ってくれば成功です。

![test2.png](https://qiita-image-store.s3.amazonaws.com/0/68901/c16c47e5-2c87-02a2-ee36-62e07227ec45.png)

blueprint のサンプルでは return の引数は文字列となっていますが、これでは応答の前後に二重引用符がついて表示されます。Slash Commands の[解説](https://api.slack.com/slash-commands)にあるように、JSON 形式で返せば二重引用符はつきません。

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
return { "text": "%s invoked %s in %s with the following text: %s" % (user, command, channel, command_text) }
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

あとは自由にコードを足して、好みの振る舞いをするボットを作成しましょう。

Lambda → SNS → Lambda → Slack
=============================

blueprint にある cloudwatch-alarm-to-slack-python を利用して notify-to-lambda を作成します。

Incoming Webhooks は使用しないため、blueprint の先頭に書かれている手順は実行しなくてもかまいません。

Slash Commands の説明で `response_url` を利用した応答の返しかたが記載されています。

この内容をもとに Incoming Webhooks でなく `response_url` を使って応答を返すように修正します。

[Slash Commands | Slack](https://api.slack.com/slash-commands)より引用
>   **Delayed responses and multiple responses**
>   If you want to provide additional command response messages, or if you're unable to immediately respond to a command within 3000 milliseconds, use the specific `response_url` we send with our initial execution of your URL to respond to a command at your leisure. With this approach, you can respond to a user commands up to 5 times within 30 minutes of the user's invocation.
>   Sending HTTP requests to this `response_url` is easy. Just build a JSON POST body in the same format as used when responding to our command invocation request to your registered URL. It supports all of the same fields (`response_type`, `text`, and `attachments`). Then, send that data as an HTTP POST with a `content-type` of `application/json` to the destination specified as the `response_url`.
>   The only user-facing difference between immediate responses and delayed responses is that "in channel" delayed responses will not include the initial command sent by the user. To echo the command back to the channel, you'll still need to provide a response to Slack's original visit to your invocation URL.

SNS Topic の作成
----------------

SNS の受信イベントで Lamba 関数を起動するために、SNS Topic を作成し、Subscription を Protocol lambda で作成します。

![pic16.png](https://qiita-image-store.s3.amazonaws.com/0/68901/16325c29-433f-8a3d-a3ad-9d44e7900e5b.png)

Lambda関数の作成
----------------

![pic17.png](https://qiita-image-store.s3.amazonaws.com/0/68901/8fb9bce9-fc84-3915-d04c-0d624ba4bd1c.png)

![pic18.png](https://qiita-image-store.s3.amazonaws.com/0/68901/1649c0e7-fe89-36ed-9f56-171568c98fc2.png)

![pic19.png](https://qiita-image-store.s3.amazonaws.com/0/68901/d831de3e-87fd-ce34-2add-f1ec5dff3927.png)

![pic20.png](https://qiita-image-store.s3.amazonaws.com/0/68901/6179eb02-70cb-c772-e051-b86782626517.png)

blueprintのコードの修正
-----------------------

lambda-bot 側では、パラメータを JSON 形式で SNS メッセージとして送ります。

[Publish - Amazon Simple Notification Service](http://docs.aws.amazon.com/sns/latest/api/API_Publish.html) より引用 (太字部分は筆者による強調）
> **MessageStructure**
>   Set `MessageStructure` to `json` if you want to send a different message for each protocol. For example, using one publish action, you can send a short message to your SMS subscribers and a longer message to your email subscribers. If you set`MessageStructure` to `json`, the value of the `Message` parameter must:
>   be a syntactically valid JSON object; and
>   **contain at least a top-level JSON key of "default" with a value that is a string.**
>   You can define other top-level keys that define the message you want to send to a specific transport protocol (e.g., "http").
>   For information about sending different messages for each protocol using the AWS Management Console, go to [Create Different Messages for Each Protocol](http://docs.aws.amazon.com/sns/latest/gsg/Publish.html#sns-message-formatting-by-protocol) in the*Amazon Simple Notification Service Getting Started Guide*.
>   Valid value: `json`
>   Type: String
>   Required: No

このために関数の本体を次のように変更します。

**lambda-botコード**

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def lambda_handler(event, context):
    req_body = event['body']
    params = parse_qs(req_body)
    token = params['token'][0]
    if token != expected_token:
        logger.error("Request token (%s) does not match exptected", token)
        raise Exception("Invalid request token")

    user = params['user_name'][0]
    command = params['command'][0]
    channel = params['channel_name'][0]
    if params.has_key('text'):
        command_text = params['text'][0]
    else:
        command_text = ''
    response_url = params['response_url'][0]
    arg = command_text.split(' ')

    sns = boto3.client('sns')
    topic_arn = sns.create_topic(Name='sns-lambda')['TopicArn']
    message={"user_name": user, "command": command, "channel": channel, "command_text": command_text, "response_url": response_url}
    message=json.dumps(message)
    message=json.dumps({'default': message, 'lambda': message})
    response = sns.publish(
        TopicArn=topic_arn,
        Subject='/lambda',
        MessageStructure='json',
        Message=message
    )
    return { "text": "%s %s\nroger" % (command, command_text) }
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

受信側は blueprint のコードを次のように修正します。
`HOOK_URL` のかわりに `response_url` を使用し、JSON 形式で応答を返すようにしています。

**notify-to-slackコード**

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def lambda_handler(event, context):
    logger.info("Event: " + str(event))
    message = event['Records'][0]['Sns']['Message']
    try:
        message = json.loads(message)
        user_name = message['user_name']
        command = message['command']
        command_text = message['command_text']
        response_url = message['response_url']
        arg = command_text.split(' ')
        # if response_type is not specified, act as the same as ephemeral
        # ephemeral, response message will be visible only to the user
        slack_message = {
            'channel': '@%s' % user_name,
            #'response_type': 'in_channel',
            'response_type': 'ephemeral',
            'isDelayedResponse': 'true',
            'text': "response for: %s %s" % (command, command_text)
        }
        logger.info("Send message to %s %s", response_url, slack_message)
        req = Request(response_url)
        req.add_header('Content-Type', 'application/json')
        response = urlopen(req, json.dumps(slack_message))
        response.read()
        logger.info("Message posted to %s", slack_message['channel'])
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Lambda関数のロールの修正
------------------------

SNS トピックへの送信を許可します。

**lambda_basic_execution**

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        {
            "Effect": "Allow",
            "Action": [
                "sns:*"
            ],
            "Resource": [
                "arn:aws:sns:us-east-1:XXXXXXXXXXXX:sns-lambda"
            ]
        },
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

テスト
------

マネジメントコンソールからテストするときのテストイベントは次のようになります。  
`response_url` の値は、Slash Commands で送信されてきたものをログに残すなどして入手してください。

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
{
  "Records": [
    {
      "Sns": {
        "Message": "{\"command\": \"/lambda\", \"command_text\": \"ec2 console i-XXXXXXXX\", \"user_name\": \"Steve\", \"channel_name\": \"test\", \"response_url\": \"https://hooks.slack.com/commands/1234/5678\"}"
      }
    }
  ]
}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

テストで問題がなければ、Lamba 関数で Event source にSNSトピックを指定し State を Enable にしておきます。

Slack からテストしてみます。

![pic14.png](https://qiita-image-store.s3.amazonaws.com/0/68901/8bee66f7-d2df-8313-cf66-d36e228e2aef.png)

このような応答が得られれば大丈夫です。

![pic29.png](https://qiita-image-store.s3.amazonaws.com/0/68901/db7e2819-3160-e279-4749-fe80f015d27d.png)

もう少し実用的なコード
======================

EC2インスタンスの一覧やコンソールの出力の取得ができるようにします。

またS3バケットの一覧も取得してみます。

notify-to-slack 側 slack_message = { … } の後につぎのコードを追加します。

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        if arg[0] == 'ec2':
            if len(arg) == 3:
                if  arg[1] == 'console':
                    ec2 = boto3.resource('ec2')
                    instance = ec2.Instance(arg[2])
                    response = instance.console_output()
                    output = '\n'.join([x.rstrip() for x in response['Output'].split('\n')[-20:]])
                    slack_message['text'] = output
            else:
                ec2 = boto3.client('ec2')
                response = ec2.describe_instances()
                if response.has_key('Reservations') and len(response['Reservations']) >= 1:
                    status = [(lambda x: (x[u'InstanceId'], ', '.join([t[u'Value'] for t in x[u'Tags']]), x[u'State'][u'Name']))(i) for i in response[u'Reservations'][0][u'Instances']]
                    slack_message['text'] = '%s %s\n%s' % (command, command_text, '\n'.join(['%s: (%s) %s' % x for x in status]))
                else:
                    slack_message['text'] = "%s %s\nno instance found" % (command, command_text)
        elif arg[0] == 's3':
            s3 = boto3.client('s3')
            buckets = s3.list_buckets()
            slack_message['text'] = "%s %s\n%s" % (command, command_text, ' '.join([x[u'Name'] for x in buckets[u'Buckets']]))
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Lambda関数のロールの修正

EC2 インスタンス、S3 バケットの情報を参照できるようにします。

**lambda_basic_execution**

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        {
            "Effect": "Allow",
            "Action": [
                "ec2:Describe*",
                "ec2:Get*"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudwatch:Get*"
            ],
            "Resource": [
                "*"
            ]
        },
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

![pic30.png](https://qiita-image-store.s3.amazonaws.com/0/68901/2a0d748d-5346-62d0-a5ac-ed2e4e17c63c.png)

![pic31.png](https://qiita-image-store.s3.amazonaws.com/0/68901/d7b50de7-018c-ed76-45da-cdf29159256f.png)

![pic32.png](https://qiita-image-store.s3.amazonaws.com/0/68901/dbfb026c-11fb-03b1-9595-68f74a06afe2.png)

![pic33.png](https://qiita-image-store.s3.amazonaws.com/0/68901/cc058a8e-8f03-7d67-29a1-712481a23e97.png)

![pic34.png](https://qiita-image-store.s3.amazonaws.com/0/68901/f252bc51-f9c6-0a5b-a39a-0485129b4f9a.png)

![pic35.png](https://qiita-image-store.s3.amazonaws.com/0/68901/773115bb-094e-1bd5-ee28-790794d976ab.png)

STSの利用
=========

ボットを呼び出すユーザに応じて、コマンドを実行できる権限を変えることを考えます。

クロスアカウントアクセスのロール virginia-ec2-delegate を作成して、信頼関係で Lambda サービスを追加しました。

STS の設定についての理解が十分でないため、これで必要十分性なのかよくわかりませんが、動いているのでおそらく大丈夫だと思います。

ユーザ名をそのまま利用しているため、AWS の IAM ユーザ名と Slack のユーザ名は合わせておく必要があります。

ロール**virginia-ec2-delegate**

![pic25.png](https://qiita-image-store.s3.amazonaws.com/0/68901/73701fc8-0b5b-77ce-8fc2-2580e825a33a.png)

![pic26.png](https://qiita-image-store.s3.amazonaws.com/0/68901/4ac84a64-44bc-0405-0128-a24dc2db5673.png)

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

ポリシー**ec2-full-access**

![pic28.png](https://qiita-image-store.s3.amazonaws.com/0/68901/2ca1ae5b-0c61-bda8-8dfe-64ba3018d75e.png)

ロール**assume-role**

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "sts:AssumeRole"
            ],
            "Effect": "Allow",
            "Resource": "*"
        }
    ]
}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Lambda関数のロールの修正

**lambda_basic_execution**

![pic27.png](https://qiita-image-store.s3.amazonaws.com/0/68901/2a51566c-cb95-4b97-5581-790da0f46e89.png)

IAMの必要な参照ができるようにロールのポリシーを修正します。

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        {
            "Effect": "Allow",
            "Action": [
                "iam:GetUser",
                "iam:GetRole",
                "iam:UserPolicy"
            ],
            "Resource": [
                "*"
            ]
        }
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

**notify-to-slackコード**

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    iam = boto3.client('iam')
    response = iam.get_user(UserName=user_name)
    if response.has_key('User'):
        uid = response['User']['Arn'].split(':')[4]
        if uid and boto3.resource('iam').UserPolicy(user_name, 'ec2-full-access'):
            response = iam.get_role(RoleName='virginia-ec2-delegate')
            if response.has_key('Role'):
                sts = boto3.client('sts')
                assumedRoleObject = sts.assume_role(RoleArn="arn:aws:iam::%s:role/%s" % (uid, 'virginia-ec2-delegate'), RoleSessionName='session')
                credentials = assumedRoleObject['Credentials']
                ec2 = boto3.resource(
                    'ec2',
                    aws_access_key_id=credentials['AccessKeyId'],                                    aws_secret_access_key=credentials['SecretAccessKey'],
                    aws_session_token = credentials['SessionToken'],
                    )
                instance = ec2.Instance(arg[2])
                state = 'unknown'
                if arg[1] == 'start':
                    response = instance.start()
                    if response.has_key('StartingInstances'):
                        state = response['StartingInstances'][0]['CurrentState']['Name']
                        slack_message['text'] = '%s %s' % (arg[2], state)
                elif arg[1] == 'stop':
                    response = instance.stop()
                    if response.has_key('StoppingInstances'):
                        state = response['StoppingInstances'][0]['CurrentState']['Name']
                slack_message['text'] = '%s %s' % (arg[2], state)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

テスト
------

Lambda 関数には EC2 インスタンスの起動/停止を行う権限は持たせていませんが、STS を利用してユーザ権限を引き受けて実行します。

![pic36.png](https://qiita-image-store.s3.amazonaws.com/0/68901/9a99683c-6355-8b46-7555-943534ff3baa.png)

![pic37.png](https://qiita-image-store.s3.amazonaws.com/0/68901/a002d66c-ab90-4799-efa4-05b353323e3a.png)

![pic38.png](https://qiita-image-store.s3.amazonaws.com/0/68901/cde595f1-11c2-9136-0c5b-28f042cf241e.png)

![pic39.png](https://qiita-image-store.s3.amazonaws.com/0/68901/0a96e0a3-cf22-a9ec-825e-3f7fa417fbde.png)

サンプルコード
==============

lambda-bot
----------

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
import boto3
from base64 import b64decode
from urlparse import parse_qs
from datetime import datetime, timedelta
import json
import logging

ENCRYPTED_EXPECTED_TOKEN = "<KMS出力のCiphertextBlobのRVALUEであるBASE64文字列">" # Enter the base-64 encoded, encrypted Slack command token (CiphertextBlob)

kms = boto3.client('kms')
expected_token = kms.decrypt(CiphertextBlob = b64decode(ENCRYPTED_EXPECTED_TOKEN))['Plaintext']

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    req_body = event['body']
    params = parse_qs(req_body)
    token = params['token'][0]
    if token != expected_token:
        logger.error("Request token (%s) does not match exptected", token)
        raise Exception("Invalid request token")

    user = params['user_name'][0]
    command = params['command'][0]
    channel = params['channel_name'][0]
    if params.has_key('text'):
        command_text = params['text'][0]
    else:
        command_text = ''
    response_url = params['response_url'][0]
    arg = command_text.split(' ')

    if arg[0] in ['ec2', 's3']:
        sns = boto3.client('sns')
        topic_arn = sns.create_topic(Name='sns-lambda')['TopicArn']
        message={"user_name": user, "command": command, "channel": channel, "command_text": command_text, "response_url": response_url}
        message=json.dumps(message)
        message=json.dumps({'default': message, 'lambda': message})
        response = sns.publish(
            TopicArn=topic_arn,
            Subject='/lambda',
            MessageStructure='json',
            Message=message
        )
        return { "text": "%s %s\nroger" % (command, command_text) }
    elif arg[0] == 'help':
        return { "text": "ec2 [console|start|stop instance-id]\ns3 [usage bucket]" }
    else:
        return { "text": "%s invoked %s in %s with the following text: %s" % (user, command, channel, command_text) }
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

notify-to-slack
---------------

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
from __future__ import print_function

import boto3
import json
import logging

from base64 import b64decode
from urllib2 import Request, urlopen, URLError, HTTPError
from time import sleep

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def send_response(response_url, message):
    req = Request(response_url)
    req.add_header('Content-Type', 'application/json')
    try:
        response = urlopen(req, json.dumps(message))
        response.read()
    except HTTPError as e:
        logger.error("Request failed: %d %s", e.code, e.reason)
    except URLError as e:
        logger.error("Server connection failed: %s", e.reason)

def ec2_command(arg, user_name, response_url):
    if len(arg) == 3:
        if  arg[1] == 'console':
            ec2 = boto3.resource('ec2')
            instance = ec2.Instance(arg[2])
            response = instance.console_output()
            output = '\n'.join([x.rstrip() for x in response['Output'].split('\n')[-20:]])
            return output
        elif arg[1] in ['start', 'stop']:
            iam = boto3.client('iam')
            response = iam.get_user(UserName=user_name)
            if response.has_key('User'):
                uid = response['User']['Arn'].split(':')[4]
                if uid and boto3.resource('iam').UserPolicy(user_name, 'ec2-full-access'):
                    response = iam.get_role(RoleName='virginia-ec2-delegate')
                    if response.has_key('Role'):
                        sts = boto3.client('sts')
                        assumedRoleObject = sts.assume_role(RoleArn="arn:aws:iam::%s:role/%s" % (uid, 'virginia-ec2-delegate'), RoleSessionName='session')
                        credentials = assumedRoleObject['Credentials']
                        ec2 = boto3.resource(
                                'ec2',
                                aws_access_key_id=credentials['AccessKeyId'],
                                aws_secret_access_key=credentials['SecretAccessKey'],
                                aws_session_token = credentials['SessionToken'],
                                )
                        instance = ec2.Instance(arg[2])
                        state = instance.state['Name']
                        if arg[1] == 'start':
                            if state == 'stopped':
                                response = instance.start()
                                if response.has_key('StartingInstances'):
                                    state = response['StartingInstances'][0]['CurrentState']['Name']
                                    if state == 'pending':
                                        if response_url:
                                            send_response(response_url, { 'text': 'start %s' % arg[2] })
                                        ec2client = boto3.client('ec2')
                                        for wait in range(0,20):
                                            response = ec2client.describe_instances(InstanceIds=[arg[2]])
                                            state = response[u'Reservations'][0][u'Instances'][0]['State']['Name']
                                            if state == 'running': break
                                            sleep(3)
                                return '%s become %s' % (arg[2], state)
                            else:
                                return '%s is already %s' % (arg[2], state)
                        elif arg[1] == 'stop':
                            if state == 'running':
                                response = instance.stop()
                                if response.has_key('StoppingInstances'):
                                    state = response['StoppingInstances'][0]['CurrentState']['Name']
                                    if state == 'stopping':
                                        if response_url:
                                            send_response(response_url, { 'text': 'stop %s' % arg[2] })
                                        ec2client = boto3.client('ec2')
                                        for wait in range(0,20):
                                            response = ec2client.describe_instances(InstanceIds=[arg[2]])
                                            state = response[u'Reservations'][0][u'Instances'][0]['State']['Name']
                                            if state == 'stopped': break
                                            sleep(3)
                                    return '%s become %s' % (arg[2], state)
                            else:
                                return '%s is already %s' % (arg[2], state)
                        else:
                            return '%s: unknown' % arg[2]
            else:
                return '%s: user unknown' % user_name
    else:
        ec2 = boto3.client('ec2')
        response = ec2.describe_instances()
        if response.has_key('Reservations') and len(response['Reservations']) >= 1:
            status = [(lambda x: (x[u'InstanceId'], ', '.join([t[u'Value'] for t in x[u'Tags']]), x[u'State'][u'Name']))(i) for i in response[u'Reservations'][0][u'Instances']]
            return '\n'.join(['%s: (%s) %s' % x for x in status])
        else:
            return "no instance found"

def s3_command(arg, user_name, response_url):
    if len(arg) == 3 and arg[1] == 'usage':
        cloudwatch = boto3.client('cloudwatch')
        now = datetime.utcnow()
        response = cloudwatch.get_metric_statistics(
                    Namespace='AWS/S3',
                    MetricName='BucketSizeBytes',
                    Dimensions=[
                        { 'Name': 'BucketName', 'Value': arg[2] },
                        { 'Name': 'StorageType', 'Value': 'StandardStorage' }
                    ],
                    StartTime=now - timedelta(days=1),
                    EndTime=now,
                    Period=86400,
                    Statistics=['Average'],
                    Unit='Bytes'
                    )
        if response.has_key('Datapoints') and len(response[u'Datapoints']) >= 1:
            return response[u'Datapoints'][0][u'Average']
        else:
            return "no metric found"
    else:
        s3 = boto3.client('s3')
        buckets = s3.list_buckets()
        return ' '.join([x[u'Name'] for x in buckets[u'Buckets']])

def lambda_handler(event, context):
    message = event['Records'][0]['Sns']['Message']
    logger.info("Event: " + str(message))
    if True:
        message = json.loads(message)
        user_name = message['user_name']
        command = message['command']
        command_text = message['command_text']
        response_url = message['response_url']
        arg = command_text.split(' ')

        # if response_type is not specified , act as the same as ephemeral
        # ephemeral, response message will be visible only to the user
        slack_message = {
            'channel': '@%s' % user_name,
            #'response_type': 'in_channel',
            'response_type': 'ephemeral',
            'isDelayedResponse': 'true',
            'text': "%s %s" % (command, command_text)
        }

        if arg[0] == 'ec2':
            slack_message['text'] = ec2_command(arg, user_name, response_url)
            send_response(response_url, slack_message)
        elif arg[0] == 's3':
            slack_message['text'] = s3_command(arg, user_name, response_url)
            send_response(response_url, slack_message)

    else:
        slack_message = { 'text': "command failed" }
        send_response(response_url, slack_message)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~