AWS サポートケースの履歴を自動で Wiki にナレッジ化する

## モチベーション
AWS Support のケース履歴は最大 12 ヶ月保存されます。

https://aws.amazon.com/jp/premiumsupport/faqs/ 

> **Q: ケース履歴の保存期間はどくらいですか?**
> ケース履歴情報は、作成後 12 か月間ご利用いただけます。


そのため、サポートケースの内容を社内 Wiki のようなところでナレッジとして長期的に蓄積したいというケースを想定しています。

## 構成
* EventBridge のイベントルールでサポートケースのクローズを検知
* AWS Lambda で AWS Support API を使用してケースの詳細を取得
* Markdown 形式に整形して Wiki に投稿

:::note info
AWS Support API はビジネスサポート以上で利用できます。
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125105/acadaece-2321-160c-8b57-dec24d54c944.png)




Wiki は API 経由で投稿できる機能を持つものであれば何でもよいと思いますが、本記事では GROWI を前提に書いています。

https://growi.org/ja/


## 考慮点など
### EventBridge イベントルール
event-name が `ResolveCase` であるイベントを検知するよう、以下を設定します。

```json
{
  "source": ["aws.support"],
  "detail-type": ["Support Case Update"],
  "detail": {
    "event-name": ["ResolveCase"]
  }
}
```

イベントの例は以下のドキュメントに記載があります。

https://docs.aws.amazon.com/awssupport/latest/user/event-bridge-support.html#example-event-logs-for-support-cases

```json
{
    "version": "0",
    "id": "1aa4458d-556f-732e-ddc1-4a5b2fbd14a5",
    "detail-type": "Support Case Update",
    "source": "aws.support",
    "account": "111122223333",
    "time": "2022-02-21T15:51:31Z",
    "region": "us-east-1",
    "resources": [],
    "detail": {
        "case-id": "case-111122223333-muen-2022-7118885805350839",
        "display-id": "1234563851",
        "communication-id": "",
        "event-name": "ResolveCase",
        "origin": ""
    }
}
````

### サポートケース情報の取得
EventBrige から渡されるイベントに Case ID が含まれているため、[DescribeCases API](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/support.html#Support.Client.describe_cases) で詳細を取得します。サポートとのやりとり (Communications) は別途取得するため、`includeCommunications=False` としています。

```py
def describe_case(case_id):
    response = support.describe_cases(
        caseIdList=[
            case_id
        ],
        includeResolvedCases=True,
        includeCommunications=False
    )
    return response['cases'][0]

def lambda_handler(event, context):
    case_info = describe_case(event['detail']['case-id'])
```

### 対象外とするサポートケース
上限緩和申請や Enterprise サポートのアカウント追加はナレッジ化する必要はないので、投稿の対象外とします。

```py
def lambda_handler(event, context):
    case_info = describe_case(event['detail']['case-id'])

    if case_info['serviceCode'] == "service-limit-increase" or \
       case_info['subject'] == "Enterprise Activation Request for Linked account.":
        return {
            'Result': 'Service limit increase or Enterprise support activation will not be posted.'
        }
```

### サポートとのやりとり (Communitaions) の取得
[DescribeCommunications API](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/support.html#Support.Client.describe_communications) を使用します。最新のやりとりから取得されるので、順番を入れ替えて連結します。

```py
def describe_communications(case_id):
    body = ""
    paginator = support.get_paginator('describe_communications')
    page_iterator = paginator.paginate(caseId=case_id)

    for page in page_iterator:
        for communication in page['communications']:
            body = re.sub(
                r'-{3,}|={3,}|\*{3,}|_{3,}', "－－－", communication['body']
            ) + '\n\n---\n\n' + body

    communications = '## Communications\n' + body
    return communications
```



3 文字以上の `=` や `-` については、Markdown 記法においてはヘッダーや水平線として表示されるため、意図しない変換が行われないように置換しています。

```text:置換前
---------------------------------------------------
サポートからの回答や引用は破線などで区切られがち
ヘッダーや水平線として認識されないように置換しておく
---------------------------------------------------
```

```test:置換後
－－－
サポートからの回答や引用は破線などで区切られがち
ヘッダーや水平線として認識されないように置換しておく
－－－
```

### GROWI への Post
API リファレンスは以下です。

https://docs.growi.org/en/api/rest-v3.html

新規ページ作成は `createPage` (POST `/pages`) を使用します。

```json
{
  "body": "string",
  "path": "/",
  "grant": 1,
  "grantUserGroupId": "5ae5fccfc5577b0004dbd8ab",
  "pageTags": [
    {
      "_id": "5e2d6aede35da4004ef7e0b7",
      "name": "daily",
      "count": 3
    }
  ],
  "createFromPageTree": true
}
```

`body` (記事本文) および、`path` (投稿するパス) のみ必須です。`grant` はページの公開範囲を指定します (1: 公開、2: リンクを知っている人のみなど) 。また実際には `access_token` (api_key) も含める必要があります。

```py
def create_payload(account_id, case_info):
    token = os.environ['API_KEY']
    title = '# ' + case_info['subject'] + '\n'
    information = '## Case Information\n' + \
        '* アカウント ID: ' + account_id + '\n' + \
        '* ケース ID: ' + case_info['displayId'] + '\n' +\
        '* 作成日: ' + case_info['timeCreated'] + '\n' + \
        '* 重要度: ' + case_info['severityCode'] + '\n' + \
        '* サービス: ' + case_info['serviceCode'] + '\n' + \
        '* カテゴリ: ' + case_info['categoryCode'] + '\n'
    communications = describe_communications(case_info['caseId'])
    return {
        'access_token': token,
        'path': '/投稿したいパス/' + case_info['subject'],
        'body': title + information + communications,
        'grant': 1,
    }

def lambda_handler(event, context):
    case_info = describe_case(event['detail']['case-id'])
    payload = create_payload(event['account'], case_info)
    url = os.environ['API_URL']
    headers = {
        'Content-Type': 'application/json',
    }
    req = Request(url, json.dumps(payload).encode('utf-8'), headers)
```

## Lambda 関数 の例
最終的な Lambda 関数の例です。関数の実行ロールには AWS Support の参照権限を付与してください。

```lambda_function.py
from logging import getLogger, INFO
import json
import os
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import re
from botocore.exceptions import ClientError
import boto3

logger = getLogger()
logger.setLevel(INFO)

support = boto3.client('support')

def describe_communications(case_id):
    body = ''
    try:
        paginator = support.get_paginator('describe_communications')
        page_iterator = paginator.paginate(caseId=case_id)
    except ClientError as err:
        logger.error(err.response['Error']['Message'])
        raise

    for page in page_iterator:
        for communication in page['communications']:
            body = re.sub(
                r'-{3,}|={3,}|\*{3,}|_{3,}', "－－－", communication['body']
            ) + '\n\n---\n\n' + body

    communications = '## Communications\n' + body
    return communications

def create_payload(account_id, case_info):
    token = os.environ['API_KEY']
    title = '# ' + case_info['subject'] + '\n'
    information = '## Case Information\n' + \
        '* アカウント ID: ' + account_id + '\n' + \
        '* ケース ID: ' + case_info['displayId'] + '\n' +\
        '* 作成日: ' + case_info['timeCreated'] + '\n' + \
        '* 重要度: ' + case_info['severityCode'] + '\n' + \
        '* サービス: ' + case_info['serviceCode'] + '\n' + \
        '* カテゴリ: ' + case_info['categoryCode'] + '\n'
    communications = describe_communications(case_info['caseId'])
    return {
        'access_token': token,
        'path': '/投稿したいパス/' + case_info['subject'],
        'body': title + information + communications,
        'grant': 1,
    }

def describe_case(case_id):
    try:
        response = support.describe_cases(
            caseIdList=[
                case_id
            ],
            includeResolvedCases=True,
            includeCommunications=False
        )
    except ClientError as err:
        logger.error(err.response['Error']['Message'])
        raise
    else:
        return response['cases'][0]

def lambda_handler(event, context):
    case_info = describe_case(event['detail']['case-id'])

    if case_info['serviceCode'] == "service-limit-increase" or \
       case_info['subject'] == "Enterprise Activation Request for Linked account.":
        return {
            'Result': 'Service limit increase or Enterprise support activation will not be posted.'
        }

    payload = create_payload(event['account'], case_info)
    url = os.environ['API_URL']
    headers = {
        'Content-Type': 'application/json',
    }
    req = Request(url, json.dumps(payload).encode('utf-8'), headers)

    try:
        response = urlopen(req)
        response.read()
    except HTTPError as e:
        return {
            'Result': f'''Request failed: {e.code}, {e.reason})'''
        }
    except URLError as e:
        return {
            'Result': f'''Request failed: {e.reason})'''
        }
    else:
        return {
            'Result' : 'Knowledge posted.'
        }

```

GROWI に POST した結果は以下のとおりです。といってもケースの内容は共有できないため、ほぼ黒塗りでごめんなさい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125105/ed7e3564-a515-7dae-3271-55c0c0c39b8d.png)


以上です。
参考になれば幸いです。