Azure function(python)でmattermost botを開発してみる

# はじめに

Azure functionが用意してくれているテンプレートを使えば何となく動かすことはできますが、実際に自分のやりたい処理を実現するにはどうするか少し分かりづらく、備忘録ながら公式ドキュメントを元にHttpTriggerテンプレートを改造し自作Functionを開発する方法をまとめます。

何となく動かすまでは以下のAzureLearnが参考になりますので必要に応じて参照ください。

* [Azure Functions Core Tools を使用した Azure Functions の開発、テスト、および発行](https://docs.microsoft.com/ja-jp/learn/modules/develop-test-deploy-azure-functions-with-core-tools/index)

## 目的

* Azure functionの使い勝手を知る
* 公式ドキュメントに従いつつ応用を効かせて実践的な関数を開発する
* Azure functionのHttpTriggerテンプレートに機能追加し自作関数の作り方を学ぶ

## 環境を整備する

OSはubuntu18.04をベースとし、以下を参考にして開発環境(pythonランタイム)を整備します。

* [クイック スタート:Azure で HTTP によってトリガーされる Python 関数を作成する](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-create-first-function-python)

コマンドは以下の感じでpython3環境とazure cli、そしてazure function core toolsをインストールします。

* python3のインストール

```bash
apt-get -y update
apt-get -y install python3-venv
```

* azure cliのインストール

```bash
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
```

* azure function coreのインストール

```bash
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg
mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg
sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-ubuntu-$(lsb_release -cs)-prod $(lsb_release -cs) main" > /etc/apt/sources.list.d/dotnetdev.list'
apt-get -y update
apt-get -y install azure-functions-core-tools
```

* venvの起動

```
python -m venv .venv
source .venv/bin/activate
```

## FunctionAppを構築します

以下のARM templateを利用してfunctionをデプロイするリソースを作成します。
[デプロイ方法は公式ドキュメントを参照](https://docs.microsoft.com/ja-jp/azure/azure-resource-manager/templates/deploy-portal)しておこなってください。
  
```yaml
{
    "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "suffix": {
            "type": "string",
            "defaultValue": "2020012603"
        },
        "storageAccountSKU": {
            "type": "string",
            "defaultValue": "Standard_LRS",
            "allowedValues": [
                "Standard_LRS",
                "Standard_GRS"
            ]
        },
        "storageAccountKind": {
            "type": "string",
            "defaultValue": "StorageV2"
        },
        "appServicePlanSku": {
            "type": "string",
            "defaultValue": "B1",
            "allowedValues": [
                "D1",
                "B1"
            ]
        }
    },
    "variables": {
        "storageAccountName": "[concat('stracc', parameters('suffix'))]",
        "appServicePlanName": "[concat('appsvr', parameters('suffix'))]",
        "functionAppName": "[concat('funcapp', parameters('suffix'))]"
    },
    "resources": [
        {
            "name": "[variables('storageAccountName')]",
            "type": "Microsoft.Storage/storageAccounts",
            "apiVersion": "2019-04-01",
            "location": "[resourceGroup().location]",
            "tags": {
                "displayName": "[variables('storageAccountName')]"
            },
            "sku": {
                "name": "[parameters('storageAccountSKU')]"
            },
            "kind": "[parameters('storageAccountKind')]"
        },
        {
            "name": "[variables('appServicePlanName')]",
            "type": "Microsoft.Web/serverfarms",
            "apiVersion": "2018-02-01",
            "location": "[resourceGroup().location]",
            "sku": {
                "name": "[parameters('appServicePlanSku')]",
                "capacity": 1
            },
            "kind": "linux",
            "properties": {
                "name": "[variables('appServicePlanName')]",
                "reserved": true
            }
        },
        {
            "apiVersion": "2019-08-01",
            "type": "Microsoft.Web/sites",
            "name": "[variables('functionAppName')]",
            "location": "[resourceGroup().location]",
            "kind": "functionapp",
            "dependsOn": [
                "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]",
                "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]"
            ],
            "properties": {
                "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]",
                "siteConfig": {
                    "appSettings": [
                        {
                            "name": "AzureWebJobsDashboard",
                            "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';AccountKey=', listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')),'2015-05-01-preview').key1)]"
                        },
                        {
                            "name": "AzureWebJobsStorage",
                            "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';AccountKey=', listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')),'2015-05-01-preview').key1)]"
                        },
                        {
                            "name": "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING",
                            "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';AccountKey=', listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')),'2015-05-01-preview').key1)]"
                        },
                        {
                            "name": "WEBSITE_CONTENTSHARE",
                            "value": "[toLower(variables('functionAppName'))]"
                        },
                        {
                            "name": "FUNCTIONS_EXTENSION_VERSION",
                            "value": "~3"
                        },
                        {
                            "name": "FUNCTIONS_WORKER_RUNTIME",
                            "value": "python"
                        },
                        {
                            "name": "WEBSITE_RUN_FROM_PACKAGE",
                            "value": "1"
                        }
                    ]
                }
            }
        }
    ],
    "outputs": {
        "functionName": {
        "type": "string",
         "value": "[variables('functionAppName')]"
        }
    }
}
```

## HttpTriggerテンプレートを生成する

テンプレートのfunctionは以下を参考にすれば簡単に作成できます。  

* [サーバーレス コード実行用の関数アプリを作成する](https://docs.microsoft.com/ja-jp/azure/azure-functions/scripts/functions-cli-create-serverless)
* [AzureCLIでAzureFunctionsの構築](https://qiita.com/kadoi82uma/items/1ab33a3d428fa11cef42)

以下コマンドでHTTP Triggerというサンプル関数を生成し、前手順で作成したFunctionApp上にデプロイできます。

```bash
func init MyFunctionProj --python
cd MyFunctionProj
func new --name HttpTrigger --template "HTTP trigger"
func azure functionapp publish <function_app_name>
```

Azureにpublishしたら以下のurlでアクセスができます。

```
<Azureポータル上で確認できるURL>/api/HttpTrigger?name=yourname
```

Azureであらかじめ準備されたHttpTrigger関数は動くようになりました。
しかし、自分のやりたい処理をするためにどう改造すれば良いか分からないため、さらに一歩進めてみます。

## HttpTriggerテンプレートを改造してmattermost botにしてみる

azure functionを実装する上でどういう具合が良いかは[Azure Functions の Python 開発者向けガイド](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-reference-python?source=docs)を参照します。Mattermostに任意の文字を投稿するには以下のdriverを利用します。

* [python-mattermost-driver](https://vaelor.github.io/python-mattermost-driver/)

matermostに任意のメッセージを投稿するコードは以下の感じです。optionsは上記driverの仕様を参照し適切なパラメータを渡します。WEBHOOK_IDはmattermostでincoming webhookを作成したときに取得できるIDを指定します。

```python
import requests
from mattermostdriver import Driver


options = {}
WEBHOOK_ID=""
foo = Driver(options)
foo.login()
options["text"] = msg
response = foo.webhooks.call_webhook(WEBHOOK_ID, options)
```

上記optionsはMattermost連携時の認証情報を持つため、少し工夫をして外出ししたいと思います。[12 factor apps](https://12factor.net/ja/config)に従い、外出しするパラメータは環境変数にします。[Azure Functions での環境変数の切り替え](https://qiita.com/TsuyoshiUshio@github/items/190437a7099ded9ce731)も参照して実装を進めます。
環境変数から取得するコードはenviron.get(...)を使います。以下のような感じになります。

```python
options = {
    'scheme': os.environ.get("MM_SCHEME", "https"),
    'url': os.environ.get("MM_HOST_OR_IP"),
    'port': int(os.environ.get("MM_PORT", "443")),
    'basepath': os.environ.get("MM_BASEPATH", "/api/v4"),
    'token': os.environ.get("MM_ACCESS_TOKEN"),
    'verify': _str2bool(os.environ.get("MM_SSL_VERIFY")),
    'channnel_id': os.environ.get("MM_CHANNEL_NAME", "test")
}
WEBHOOK_ID = os.environ.get("MM_WEBHOOK_ID")
```

改造するにあたってmattermost driverパッケージを追加する必要があるため以下を参考にrequirements.txtを編集します。

* [Azure Functions Python(preview)でpipを使う](https://qiita.com/yuppejp/items/36b0fee8fb9ac09e75d0)

* `requirements.txt`

```text
azure-functions
mattermostdriver
requests
```

## サンプル関数HttpTriggerを改造した結果

前述を考慮して改造した結果、以下のコードとなります。

* `HttpTrigger/__init__.py`

```python
import azure.functions as func

import logging
import os

import requests
from mattermostdriver import Driver


def _str2bool(v):
    return v.lower() in ("true")

def _send_msg(msg):
    options = {
        'scheme': os.environ.get("MM_SCHEME", "https"),
        'url': os.environ.get("MM_HOST_OR_IP"),
        'port': int(os.environ.get("MM_PORT", "443")),
        'basepath': os.environ.get("MM_BASEPATH", "/api/v4"),
        'token': os.environ.get("MM_ACCESS_TOKEN"),
        'verify': _str2bool(os.environ.get("MM_SSL_VERIFY")),
        'channnel_id': os.environ.get("MM_CHANNEL_NAME", "test")
    }
    WEBHOOK_ID = os.environ.get("MM_WEBHOOK_ID")

    try:
        foo = Driver(options)
        foo.login()
        options["text"] = msg
        logging.info("mattemost call_webhook starting")
        response = foo.webhooks.call_webhook(WEBHOOK_ID, options)
        logging.info("mattemost call_webhook finished")
    except Exception as e:
        logging.error("Failed to call_webhook")
        raise(e)

def main(req: func.HttpRequest) -> func.HttpResponse:
    logging.info('Python HTTP trigger function processed a request.')

    msg = req.params.get('msg')
    if not msg:
        try:
            req_body = req.get_json()
        except ValueError:
            pass
        else:
            msg = req_body.get('msg')

    if msg:
        _send_msg(msg)
        return func.HttpResponse("OK", status_code=200)
    else:
        return func.HttpResponse(
             "Please pass a name on the query string or in the request body",
             status_code=400
        )
```

## Azureにfunctionをデプロイする

function appを作成したら以下のコマンドでfunctionを発行します。
（すでに同じfunctionがあれば上書きしてくれます）。

```bash
func azure functionapp publish <function_app_name>
```

## mattermost側の設定

mattermost側の設定は画面上から行います。
分かりやすいGUIのためメモレベルで設定方法を説明します。

### ACCESS_TOKENを発行する

ACCESS_TOKENを発行するには管理者権限のユーザーでログインする必要があります。
Mattermostの左メニューから以下を押下します。

* **SystemConsole** > **Integration Management** > **Enable Personal Access Tokens** を **true** にします
* SystemConsoleから離れ MainMenuのAccount Settings からSecurityを押下しPersonal Access Tokensを発行します
* 発行したAccess Tokenを控えます

### Incomming WEB_HOOKを設定する

* MainMenuのIntegrations > Incomming Webhooks > Add Incoming Webhookでwebhookを作成します
* webhookのURLが発行されるためもっとも末尾のランダムな文字列をWebhook IDとして控えます

### bot用チャンネルtestを作成する

* チャンネル名をtestとしてPUBLIC CHANNELSを追加します

## 環境変数を読み込むように改造したためfunction appに環境変数をCLIで設定する

```bash
az functionapp config appsettings set --name <APPSERVICE_NAME> -g <RG_NAME> --settings \
"MM_SCHEME=https" \
"MM_HOST_OR_IP=<mattermost host or ip ex) xx.xx.xx.xx>" \
"MM_PORT=443" \
"MM_BASEPATH=/api/v4" \
"MM_ACCESS_TOKEN=<ACCESS_TOKEN>" \
"MM_SSL_VERIFY=False" \
"MM_CHANNEL_NAME=test" \
"MM_WEBHOOK_ID=<WEBHOOK_ID>"
```

## 改造したHTTPTriggerを動作確認する

以下のURLでfunctionを実行しmattermostにメッセージをポストできます。
ただし`HttpTrigger/function.json`のauthLevelがfunctionのため、functionにアクセスするにはAPIリクエストにクエリパラメータcodeに承認キーを含める必要があります。
詳細は以下を参照します。

* [Azure Functions のトリガーとバインド: トリガー - 構成](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-http-webhook?tabs=csharp#trigger---configuration)
* [Azure Functions のトリガーとバインド: 承認キー](https://docs.microsoft.com/ja-jp/azure/azure-functions/functions-bindings-http-webhook?tabs=csharp#authorization-keys)

クエリパラメータも加味してこのURLで動作確認できます。

```
https://<function_app_name>.azurewebsites.net/api/HttpTrigger?msg=<mattermostにポストしたいメッセージ>&code=<host key>"
```

## 最後に

HttpTriggerをベースとしてmattermost botとして改造をしてみました。ドキュメントもCLIも充実しており慣れれば開発も進めやすいと思いました。
ただAzureポータルで参照できるfunctionの管理画面が非常にレスポンスが遅く１画面遷移するのに数秒かかるとか、pythonランタイムだとGUI上のエディタが制限であることなど色々と課題も見えましたので、そこはこれからのAzureのエンハンスに期待したいと思います。

以上