SalesforceとSlackの連携をゴリゴリカスタマイズしてみる（Salesforceライセンスの節約術）

# はじめに
SalesforceとSlackを連携させて業務の効率化を行っている現場は多いと思います。
ここ数年でSalesforceとSlack間の連携機能はどんどん追加されていき、「SalesforceからSlackに通知する」という要件程度だと、フローを使えば、ほとんど実現できる状態になっています。

ただ、現時点（2022/12月）だと、SlackからSalesforce上のレコードを更新する場合には、若干かゆいところに手が届かないという状態でもあるので、今回はカスタマイズ開発するとこんなこともできます。というご紹介をしたいと思います。

# カスタマイズ開発の概要
今回はSalesforceのレコード詳細に配置したLWCのボタンを押すと、Slackにレコード情報がエスカレーションされ、Slack上のボタンを押して、更新内容を入力するとSalesfoce上のレコードが更新される。というカスタマイズを実装します。

ポイントとしては、Slack上に表示するモーダルからのSalesfoceレコード更新は、**Salesfoceユーザではなくても実行が可能なので、使い方によってはライセンス数の節約ができる** という点です。
（ライセンスの節約を考えなくていいのではれば、Salesforceレコードへのリンクを通知するほうがシンプルでいいと思います。）
~~関係者全員にSalesforceライセンスを付与できるのであれば、全員Salesforceにログインしてデータの更新を行ってもらえればいいのですが、Salesforceライセンスってそれなりに高いじゃないですか、、、月に数回しかログインしない人にも付与していくのってコストパフォーマンスが、、、~~
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/503339/e93dcbc1-9b35-a6d8-1c73-ee198cd74812.png)
モーダルの入力内容が、Salesforceレコードの`テクニカルサポート解析結果`項目に反映されるようにします。


## 実装の流れ

### Slack側の準備
まずはSlack APIを実行するためのトークンを取得します。Slackアプリの管理画面から`OAuth&Permissions`の`Bot User OAuth Token`をコピペします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/503339/7e2eff28-2e47-dcdd-2c6e-88b5c1843ef0.png)


### Salesforce側の処理(Apex)
ApexでSlack APIの呼び出しを行います。以下はレコード詳細ページのLWCからApexを呼び出す前提のコードになっています。(LWC側のソースは割愛します)

`caseEscalation`がLWCから呼び出せる関数になっていて、画面表示しているレコードのID（今回はケースID）を`recordId`に、投稿するSlackチャンネルを`slackChannel`に指定して呼び出します。

`generateEscalationSummary`の処理では、Block Kit Builderで作成したフォーマットを生成しています。`action_id`に指定している`handle_open_modal`は、以降で説明しているLambdaの処理を呼び出すトリガーになっています。

`postMessage`の処理で、カスタム表示ラベルに設定した、`Bot User OAuth Token`をヘッダーに追加して、Slack APIを呼び出します。

処理が正常に実行されると、Slackの指定したチャンネルにレコードの情報が投稿されます。

```Apex:Apex
@AuraEnabled(cacheable=false)
public static string caseEscalation(String recordId, String slackChannel) {
    try {
        Case record = [
            SELECT Id, CaseNumber, Subject
            FROM Case
            WHERE Id =: recordId
        ];
        PostMessageResponse result = postMessage(slackChannel, generateEscalationSummary(record));

        return 'Success: Slackにエスカレーションしました';
    }
    catch (Exception e) {
        throw new AuraHandledException('The following exception has occurred: ' + e.getMessage());
    }
}

public static String generateEscalationSummary(Case record){
    List<String> summary = new List<String>(); 
    summary.add('[');
    summary.add('    {');
    summary.add('        "type": "section",');
    summary.add('        "text": {');
    summary.add('            "type": "plain_text",');
    summary.add('            "text": "');
    summary.add('■ケース\n' + record.CaseNumber + '\n');
    summary.add('■件名\n' + record.Subject + '\n');
    summary.add('"');
    summary.add('        }');
    summary.add('    },');
    summary.add('    {');
    summary.add('        "type": "actions",');
    summary.add('        "elements": [');
    summary.add('            {');
    summary.add('                "type": "button",');
    summary.add('                "text": {');
    summary.add('                    "type": "plain_text",');
    summary.add('                    "text": "解析入力"');
    summary.add('                },');
    summary.add('                "value": "' + record.Id + '",');
    summary.add('                "action_id": "handle_open_modal"');
    summary.add('            },');
    summary.add('        ]');
    summary.add('    }');
    summary.add(']');

    return String.join(summary, '');
}

public static PostMessageResponse postMessage(String slackChannel, String message){
    String body = 'channel='+ EncodingUtil.urlEncode(slackChannel, 'UTF-8')
               += '&blocks='+ EncodingUtil.urlEncode(message,'UTF-8');

    Http h = new Http();
    HttpRequest req = new HttpRequest();
    req.setEndpoint('https://slack.com/api/chat.postMessage');
    req.setMethod('POST');
    req.setTimeout(60000);
    req.setHeader('Content-Type', 'application/x-www-form-urlencoded');
    req.setHeader('Authorization', 'Bearer ' + System.label.SlackToken);
    req.setBody('channel='+ EncodingUtil.urlEncode(slackChannel, 'UTF-8'));
    req.setBody(body);
    try {
        HttpResponse res = h.send(req);
        Map<String,Object> response = (Map<String,Object>)System.JSON.deserializeUntyped(res.getBody());
        return new PostMessageResponse((String)response.get('channel'), (String)response.get('ts'));
    }
    catch (Exception e) {
        throw new AuraHandledException('SlackAPIの呼び出しに失敗しました: ' + e.getMessage());
    }
}
```

### Slackから呼び出す処理を準備(Lambda)
上記の処理でSlackに投稿すると、「解析入力」というアクションボタンが表示されます。
このままではボタンを押しても何も起きないので、ボタンを押したときに、モーダルを表示させるAPIを用意します。
今回はLambdaにPythonで実装しましたが、複数言語でSDKが用意されているので、好きな言語で実装できます。
また、LambdaなどのFaas上に構築すると、コールドスタートの考慮をしないといけないので、安定性を保証したい場合は、HerokuやGAE上に構築するほうが無難だと思います。

以下のサンプルコードでは、AWS SecretsManagerに登録した、SlackとSalesforceの認証情報を取得しています。またデプロイ時の環境変数で本番環境とSandbox環境を切り替えれるようにしています。

ポイントとしては、モーダル画面に、SFID、チャンネルID、タイムスタンプを仕込んでおくことで、モーダル上の更新ボタンを押下したときのイベントを検知して実行される`update_salesforce_case`の処理内でも、更新対象のSalesforceレコードを特定できるようにしていたり、投稿するスレッドを特定できるようにしている点になります。（もう少しスマートなやり方があるかもしれないです。）

```Python:Lambda
import json
import logging
import os
import ast
import re
from pprint import pformat
from typing import Dict

import boto3
from slack_bolt import Ack, App
from slack_bolt.adapter.aws_lambda import SlackRequestHandler
from slack_sdk import WebClient
from simple_salesforce import Salesforce
import requests

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

secrets = boto3.client(service_name='secretsmanager')
slack_credentials = secrets.get_secret_value(SecretId='SlackCredentials'+os.environ.get("ENV"))
slack_secret = ast.literal_eval(slack_credentials['SecretString'])

app = App(
    process_before_response=True,
    token=slack_secret['SLACK_BOT_TOKEN'],
    signing_secret=slack_secret['SLACK_BOT_SIGNING_SECRET'],
)

def request_modal_view(case_id, initial_value, channel_id, thread_ts):
    return {
        "type": "modal",
        "callback_id": "update_salesforce_case",
        "title": {"type": "plain_text", "text": "Case"},
        "submit": {"type": "plain_text", "text": "更新"},
        "close": {"type": "plain_text", "text": "閉じる"},
        "blocks": [
            {
                "type": "input",
                "block_id": "notes-block",
                "element": {
                    "type": "plain_text_input",
                    "action_id": "input-element",
    				"initial_value": initial_value,
                    "multiline": True,
                },
                "label": {"type": "plain_text", "text": "解析入力"},
                "optional": True,
            },
            {
                "type": "context",
                "block_id": "case_id",
                "elements": [
                    {
                        "type": "plain_text",
                        "text": case_id,
                        "emoji": True
                    }
                ]
            },
            {
                "type": "context",
                "block_id": "channel_id",
                "elements": [
                    {
                        "type": "plain_text",
                        "text": channel_id,
                        "emoji": True
                    }
                ]
            },
            {
                "type": "context",
                "block_id": "thread_ts",
                "elements": [
                    {
                        "type": "plain_text",
                        "text": thread_ts,
                        "emoji": True
                    }
                ]
            }
        ],
    }


def auth_salesforce():
    res = secrets.get_secret_value(SecretId=os.environ.get("ENV")+"SalesforceCredentials1")
    secret = ast.literal_eval(res['SecretString'])

    access_token_url = os.environ.get("ACCESS_TOKEN_URL")
    data = {
        'grant_type': 'password',
        'client_id' : secret["ConsumerKey"],
        'client_secret' : secret["ConsumerSecret"],
        'username'  : secret["APIUser"],
        'password'  : secret["Password"]
    }
    headers = { 'content-type': 'application/x-www-form-urlencoded' }
    response = requests.post(access_token_url, data=data, headers=headers)
    response = response.json()

    if response.get('error'):
        raise Exception(response.get('error_description'))

    session = requests.Session()
    logger.info(f"response:\n{response}")
    return Salesforce(
        instance_url=response['instance_url'],
        session_id=response['access_token'],
        session=session
    )


def respond_to_slack_within_3_seconds(ack):
    ack()


def handle_open_modal(ack: Ack, body: Dict, client: WebClient):
    logger.info(f"body:\n{pformat(body)}")
    case_id = body["actions"][0]["value"]
    channel_id = body["channel"]["id"]
    thread_ts = body["container"]["message_ts"]

    sf = auth_salesforce()

    try:
        res = sf.query(f"SELECT id, RepairAnalysis__c FROM Case WHERE Id = '{case_id}'")
        initial_value = dict(dict(res)["records"][0])["RepairAnalysis__c"]

        if initial_value is None:
            initial_value = ""

    except Exception as e:
        logger.exception(f"Failed to post a message {e}")

    client.views_open(
        trigger_id=body["trigger_id"],
        view=request_modal_view(case_id, initial_value, channel_id, thread_ts),
    )


def update_salesforce_case(body: Dict, view: Dict, client: WebClient):
    logger.info(f"body:\n{pformat(body)}")
    inputs = view["state"]["values"]
    blocks = body["view"]["blocks"]
    case_id = list(filter(lambda item : item['block_id'] == 'case_id', blocks))
    case_id_value = case_id[0]["elements"][0]["text"]
    channel_id = list(filter(lambda item : item['block_id'] == 'channel_id', blocks))
    channel_id_value = channel_id[0]["elements"][0]["text"]
    thread_ts = list(filter(lambda item : item['block_id'] == 'thread_ts', blocks))
    thread_ts_value = thread_ts[0]["elements"][0]["text"]
    notes = inputs.get("notes-block", {}).get("input-element", {}).get("value")
    trigger_id = body["trigger_id"]
    user_name = body["user"]["name"]

    sf = auth_salesforce()

    try:
        # Salesforceを更新
        sf.Case.update(
            case_id_value,
            {'RepairAnalysis__c': notes}
        )
        # Salesforceに更新した内容をSlackのスレッドにも投稿
        response = client.chat_postMessage(
            channel=channel_id_value,
            thread_ts=thread_ts_value,
            text=f"```{notes}```"
        )
    except Exception as e:
        logger.exception(f"Failed to post a message {e}")


app.view("update_salesforce_case")(
    ack=respond_to_slack_within_3_seconds,
    lazy=[update_salesforce_case]
)


# 以下の部分で「解析入力」ボタンが押された時のイベントを検知して、モーダル表示する処理を呼び出しています。
app.action("handle_open_modal")(
    ack=respond_to_slack_within_3_seconds,
    lazy=[handle_open_modal]
)


def handler(event, context):
    logger.info(f"event:\n{pformat(event)}")


    # 予期しない重複実行を排除する
    if event["headers"].get("x-slack-retry-num") is not None and event["headers"]['x-slack-retry-reason'] == "http_timeout":
        return {
            "statusCode": 200,
            "headers": {
                "Content-Type": "application/json"
            },
            "body": {
                "message": "No need to resend"
            }
        }

    slack_handler = SlackRequestHandler(app=app)
    return slack_handler.handle(event, context)


if __name__ == "__main__":
    app.start()
```

### SlackからLambdaを呼び出せるようにする
上記のLambdaで準備したAPIのエンドポイントをSlackに登録します。Slackアプリの管理画面から`Interactivity & Shortcuts`の`Request URL`から登録します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/503339/39c67c1c-7172-bc64-74d6-de32cc9adf1e.png)

### Slackアプリの権限設定
`OAuth&Permissions`でAPI実行に必要な権限を付与して、Slackワークスペースと再認証します。（割愛）

### Slackアプリをワークスペースに招待
SalesforceからSlack投稿するチャンネルにSlackアプリを招待しておきます。
これで一通り実装と設定が完了します。

# まとめ
SalesforceとSlack間の連携機能は今後もどんどん追加されていくはずなので、数年後にはこういったカスタマイズが不要になる可能性がありますが、SalesforceとSlack連携をすぐにパワーアップさせたい！という方はカスタマイズするのも悪くないと思います！

弊社ではさらに、SalesforceユーザとSalesoforceライセンスを持たないSlackユーザがコミュニケーションをとれるようにする機能も実装しています。紺色の背景テキストがSalesforceユーザで、灰色の背景テキストがライセンス無しユーザがSlackからコメントした内容になっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/503339/9943d4e4-eccf-424b-4bf0-b41c6e941042.png)


カスタマイズ開発のきっかけとしては、Salesforceライセンスを節約したい、、という理由からだったのですが、SlackAPIの実装を通して、より業務改善の引き出しを増やすことができたので、結果的にはいい経験になりました。
みなさんもぜひカスタマイズ実装をご検討してみてはいかがでしょうか？



# 参考
https://qiita.com/seratch/items/0b1790697281d4cf6ab3

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