Salesforce→Slack→Salesforceを連動させるBolt for Python の書き方

最近、Slackのbot開発をちょこっと進めています。
弊社では、Salesforceに営業報告を書くと、Slackの営業報告チャンネルにその内容が自動的にpostされる仕組みがあります。
加えて、Salesforce側では、レコードに「いいね」ボタンを設置し、それ読んだよ！いいね！っていうアクションを記録できるようにしてあります。
参考：https://qiita.com/geeorgey/items/4f41c84a9d53cfe5431b

今回の実装では、
- block kit builderを使って、Slackへのpost時にいいねボタンをくっつける
- いいねボタンを押した時に、slack側の表示を変更する
- いいねボタンを押した時に、Salesforce側のいいねレコードを追加/削除する

の3つの実装を行いました。

#block kit builderを使って、Slackへのpost時にいいねボタンをくっつける
Salesforce側からSlackのAPIを使ってチャンネルにpostします。
基本的な書き方は @seratch さんのこれを参考にすると良いです。
https://qiita.com/seratch/items/ef9fb81828ba3c5d24c2

上述のページだと
 'text' =>  'Hello World!' // blocks を使うともっといろいろできますが、ここではシンプルに
と書いてあるので、今回はそこの書き方について記します。

###ブロックキットに対応したブロックの書き方について
Apexで書くとかなり面倒な印象があります。
最初に定義するのはこちら

```
        LIST<Object> blocks = new LIST<Object>();
```
最終的にはこれを

```
    new Map<String, Object> {
        'channel' => '#random', // 本当は ID の方がよいです
        'text' =>  'Hello World!', // blocks を使うともっといろいろできますが、ここではシンプルに
        'blocks' => JSON.serialize(blocks)
    }
```
こういう形式で渡します。
ちなみに、最初textブロックを削除して送ったら、ベストプラクティス的には、通知画面で使うから入れておいてくれよな！ってアラートが出ました。textも忘れずに付けておきましょう。

```JSON.json
        LIST<Object> blocks = new LIST<Object>();

```

Block Kit Builderからテンプレを持ってきましょう。
https://app.slack.com/block-kit-builder/

![Slack___Block_Kit_Builder.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22294/c5e1ea2a-776f-a2f6-2db8-c2fff4f4d1ce.png)

これを使うことにします。
右カラムのPayloadを見ると、

```payload.json
{
	"blocks": [
		{
			"type": "section",
			"text": {
				"type": "mrkdwn",
```

こんな形になっています。今回 blocksの配列で作ろうとしているのは
二行目の"blocks": []
の配列の中身です。

```createJson.cls
            Map<String,Object> section0 = new Map<String,Object>();
            section0.put('type','section');
            Map<String,Object> section0_text = new Map<String,Object>();
            section0_text.put('type','mrkdwn');
            section0_text.put('text',colorString + ' [' + myrep.C_Item_SL_Result__c + ']' + myrep.Subject + '(' + o.postUserName + ' <@' + myrep.OwnerSlackID__c + '>)\n*実施日* ' + myrep.ActivityDate);
            section0.put('text',section0_text);


            Map<String,Object> section0_accessory = new Map<String,Object>();
            section0_accessory.put('type','button');
                Map<String,Object> section0_accessory_text = new Map<String,Object>();
                section0_accessory_text.put('type','plain_text');
                section0_accessory_text.put('emoji',true);
                if(runningInASandbox() == true){
                    section0_accessory_text.put('text','testaction :thumbsup:');
                }else{
                    section0_accessory_text.put('text','いいね :thumbsup:');
                }    
            section0_accessory.put('text',section0_accessory_text);
            section0_accessory.put('style','primary');
            section0_accessory.put('value',myrep.id);//Event.id を埋め込む

            if(runningInASandbox() == true){
                section0_accessory.put('action_id','testaction');
            }else{
                section0_accessory.put('action_id','bizrep');
            }
            section0.put('accessory',section0_accessory);

            blocks.add(section0);

```

作り方はこんな感じになります。
基本はMapオブジェクトにキーとオブジェクトを入れて渡す形になります。
たまに、JSONの中でelementsみたいな複数項目が入るセクションが入ってきたりします。
そこは配列を作ってその中にデータを入れる形にする必要があるので注意してみてください。

このblocksをJSON.serializeしてAPIを叩きましょう。
こんな感じでpostされれば第一段階は完成です。

![Slack_____04-businessreport___Leave_a_Nest_Co__ltd_.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22294/52070b18-54e7-3f90-721e-c6784babd22a.png)

##いいねボタンを処理するslack botを作る
こちらを参考に作ると楽かなと思います。
https://qiita.com/geeorgey/items/656b59de6d738c740760
ボタンを押した時のレスポンスは,Slackアプリの「Interactivity & Shortcuts」のRequest URLに返ってきますのでその設定だけはしましょう。ボタンアクションに関しては特にscopeの設定は不要です。
![Slack_API__Applications___Leave_a_Nest_Co__ltd__Slack.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22294/17a3cd2b-5f0c-a8c9-acdd-ecbfbe6b17b4.png)

botには Slack bolt for pythonを使っています。

```app.py
@app.action("bizrep")
def addLike(body, ack, say, action):
    ack()
    user_id = body['user']['id']
    #処理書く

```

この中に出てきた
app.action("bizrep")
のbizrepっていうのはどこで定義したかというと、先程書いたApexの中のボタンのセクションです。

```actionid.cls
section0_accessory.put('action_id','bizrep');
```

こうやってaction_idを指定することでデータがbot側に渡ってきます。

##ボタンを押した時にSalesforceのREST APIを叩く為に
###Salesforce側にREST APIを作る
書き方はこちらを参照
https://qiita.com/TaaaZyyy/items/336ba4c49e112c08534c

```api_for_slackbot.cls
@RestResource(urlMapping='/api_for_slackbot/*')
global with sharing class api_for_slackbot {

    @HttpPost
    global static LIST<LID_Timeline__c> handlePost(String user_id,String event_id) {
    	RestRequest req = RestContext.request;
        //https://qiita.com/TaaaZyyy/items/336ba4c49e112c08534c
        System.debug(LoggingLevel.DEBUG, 'user_id');
        System.debug(LoggingLevel.DEBUG, user_id);
        System.debug(LoggingLevel.DEBUG, '===== request =====');
        System.debug(LoggingLevel.DEBUG, '▼ PATH');
        System.debug(LoggingLevel.DEBUG, req.resourcePath);
        System.debug(LoggingLevel.DEBUG, '▼ URI');
        System.debug(LoggingLevel.DEBUG, req.requestURI);
        System.debug(LoggingLevel.DEBUG, '▼ METHOD');
        System.debug(LoggingLevel.DEBUG, req.httpMethod);
        System.debug(LoggingLevel.DEBUG, '▼ HEADER');
        System.debug(LoggingLevel.DEBUG, req.headers);
        System.debug(LoggingLevel.DEBUG, '▼ PARAM');
        System.debug(LoggingLevel.DEBUG, req.params);

        //処理書く
```

色々試してみたのですが、paramsは取れなかったですね。slack側からの渡し方が悪いのかもしれない。

REST APIが完成したら、次はbolt for python側からSalesforceで作ったREST APIを叩くための準備をしましょう。
REST APIを叩くには、pythonのsimple-salesforceというライブラリを使わせてもらいます。
https://github.com/simple-salesforce/simple-salesforce

pip install simple-salesforce
でインストールして下さい。

```app.py
from simple_salesforce import Salesforce

@app.action("bizrep")
def addLike(body, ack, say, action):
    ack()

    user_id = body['user']['id']
    #print(user_id)#slackのユーザーID


    #SalesforceのREST APIを呼ぶ
    session = requests.Session()
    sf = Salesforce(
        instance_url=os.environ.get("SALESFORCE_INSTANCE_URL"),
        username=os.environ.get("SALESFORCE_USER_ID"), 
        password=os.environ.get("SALESFORCE_USER_PASS"), 
        organizationId=os.environ.get("SALESFORCE_ORG_ID")
        )

```

こんな感じでSalesforceのセッションを作ることが出来ます。
SALESFORCE_INSTANCE_URL：SalesforceのURLです。
https://xxxxxxxxxx.lightning.force.com
こんな形式のやつですね。

SALESFORCE_USER_ID：接続に使うユーザーのメールアドレスを入れて下さい
SALESFORCE_USER_PASS：当該ユーザのパスワードです。
SALESFORCE_ORG_ID：設定＞組織の設定＞会社情報 に記載された組織IDを使います。

ちなみにですが、Sandbox環境で使う場合は

```sandbox.cls

    sf = Salesforce(
        domain='test',
        username=os.environ.get("SALESFORCE_USER_ID"), 
        password=os.environ.get("SALESFORCE_USER_PASS"), 
        organizationId=os.environ.get("SALESFORCE_ORG_ID")
        )

```

instance urlがdomain='test'に変わりますので注意して下さい。


```app.py

    #SalesforceのREST APIを呼ぶ

    session = requests.Session()
    sf = Salesforce(
        domain='test',
        username='yoursandbox@email.com', 
        password='yourpassord', 
        organizationId='your organization Id'
        )

    payload = {'user_id': user_id,'event_id': action['value']}

    result = sf.apexecute('api_for_slackbot', method='POST', data=payload)
    print(json.dumps(result, indent=4))
    #SalesforceのREST APIを呼ぶ

```


こんな風にしてapexecuteを使うと、先程設定したSalesforceのREST APIを使うことが出来ます。
payloadのデータは引数に入りますのでそれをApex Classで受け取りましょう。

```api_for_slackbot.cls
global static LIST<LID_Timeline__c> handlePost(String user_id,String event_id) 

```
ここにある
String user_id,String event_id
の部分に入ります。
これで受け取ったデータを元に、レコードを足したり引いたりして、その結果を戻してあげれば
bolt側のresponseにデータが返ってくるという形になります。

##返ってきたデータを元にして slack の表示を変更しよう

今度はboltの出番ですね。

```app.py

from slack_sdk import WebClient

@app.action("bizrep")
def addLike(body, ack, say, action):
    #中略
    client = WebClient(token=os.environ.get("SLACK_BOT_TOKEN"))
    try:
        userinfo = client.users_info(
            user=user_id
        )
    except SlackApiError as e:
        print("Error fetching conversations: {}".format(e))
```

こんな感じにするとuserinfoデータが取得できます。
SLACK_BOT_TOKENはSlackアプリページの「OAuth & Permissions」ページにあるものです。

![Slack_API__Applications___Leave_a_Nest_Co__ltd__Slack.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22294/227e1026-c7aa-32da-0b1c-31741f4504ce.png)

書き換えるべきは、最初に作ったblocksです。
blocksはどこにあるかというと、ここです。

blocks = body['message']['blocks']

この中に、最初にApexで作ったブロックが入っていますのでこれを書き換えてください。
書き換え終わったら

```app.py
@app.action("bizrep")
def addLike(body, ack, say, action):
    #中略
    try:
        # Call the chat.chatDelete method using the built-in WebClient
        update_result = client.chat_update(
            channel=body['channel']['id'],
            ts=body['container']['message_ts'],
            blocks=blocks,
        )
        logger.info(update_result)
    except SlackApiError as e:
        logger.error(f"Error deleting message: {e}")
```

こんな形でchat_updateを使うことで上書きすることが出来ます。


![legf6-ip607.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22294/8b6cd1c3-7df9-c020-5c0b-a55a1e339896.gif)

もちろんSalesforce側にもデータが作られていることが確認できます。


![test___Salesforce.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22294/32e0357e-3138-2833-f59c-97d1af4803ce.png)


処理の順番は以下の通りです

- Slackのボタンが押される
- SalesforceのREST APIを叩いてSalesforce側にレコードを作る
- そのレコードがapp.py側に返ってくる
- app.pyで処理したデータをSlackのpost宛にchat.updateをかける

以上、長くなりましたが、要点をまとめました。ぜひチャレンジしてみてください。


## Tips

### elementsの最大数は10
Slackのblocks内でアイコンを表示させている部分はcontextブロックを使っています。
contextブロックの中に入っているelementsブロックが配列を入れられるブロックになるのですが、elementsブロックに11以上のelementを突っ込むとエラーが出るので注意してください。

### データはmessageの本体のみで持たないようにする

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">あ、例えば二人が同時に押した時に競合状態が発生して、どっちかが反映さないことが起きうるとかそういうのですね。まぁ reaction_added を使ったとしても実装次第では発生しうるのですが。。</p>&mdash; Kazuhiro Sera (瀬良) (@seratch_ja) <a href="https://twitter.com/seratch_ja/status/1405064621485019138?ref_src=twsrc%5Etfw">June 16, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

データを外で持たない場合、処理が競合しちゃうことがあるようです。
今回はSalesforce側にレコードを持って処理することで、データが壊れないように配慮しています。
メッセージではなく、reactionを追加するようなやり方も、message本体ではないのでありのようですよ。