定時にビデオキャプチャして画像を投稿するSlack botの作成(2021.7.11, 2021.7.15追記)

Summary

scheduleモジュールなどを使ってApp内で時間をカウントしても良いけれど、それだと時間を監視するthreadが別に必要となったり、時刻指定用のIFを実装する必要があったりと面倒。
Slack上の特定のトリガーイベントを監視してキャプチャーを実行するlistner botとして実装し、Slackの/remind機能などでトリガーイベントを定期的に発生させることにした。
この方法なら、listner botは実行時刻などは気にせず、ただchannelを監視しておくだけで良いし、定点観測以外に個別にキャプチャを実行したい場合にも簡単に対応できる。
Slackのchannelで’shot’/‘Shot’(大文字小文字の区別なし)という文字列を投稿すると、MacのBuilt-inビデオカメラからの画像をキャプチャしてslackに画像ファイルをアップロードしてくれるSlack botを作成する。

【2021.7.15追記】concurrent.futuresでマルチプロセスを実現
SocketModeHandlerと、cv2.imshow()は同じメインスレッドに入れる必要があり、 cv2.imshow()やcv2.waitKey()のために専用のwhileループを組めないので、 キャプチャ中のカメラデバイスの画像をウィンドウに表示することができていなかった。 今回、concurrent.futuresのマルチプロセス機能で表示を実現した。

image captured and uploaded by CaptureBot

Slack APIのページにメインで書かれている方法は、AWSやAzureなどのHTTP postを受け付けられるpublic URL endpointと契約してメッセージを中継してもらえないと、Slackのメッセージをbotが受け取ることができない。
これに対して、Socket Modeで通信を行う場合には、サーバが不要というメリットがある。

Socket Modeに関する紹介記事(2021.1.13)はこちら

動作仕様

商用エンドポイントサービスの契約が不要なslackのSocket Modeを用いてSlack botを作成する。

Slackに特定の文字列を含む文をポストすると、そのポストをトリガーとしてbotがローカルのMacのカメラを用いて撮影した画像をポストし返してくれるアプリケーションをslack_bolt (Python)を用いて作成する。

定時にslackにメッセージイベントを送信するには/remindを使う

App内で定時実行の時間管理まですると、トリガーを監視するlistnerのthreadと、指定時刻に自主的に起動するtimer threadを実装した上に、トリガーを受けてコマンドを解釈し、時刻設定を行うインターフェースも実装する必要が生じる。これらは面倒なので、あるものはそのまま使う方針で、定時実行のトリガーは、Slack botがもともと持っている/remind機能を利用することとする。

/remindは、たとえば以下のように設定できる。

/remind me to ask @hellobot2000 to shot a photo every weekday at 7:05

使い方の解説はこちら

Appの新規作成と設定

https://api.slack.com/appsで 「Create New App」から新規のAppを作成できる。

手順は以下のとおり

  1. ボットを設置したいSlack Workspaceとそのchannelを作成する
  2. https://api.slack.com/にWorkspaceのアカウントでログイン
  3. https://api.slack.com/appsで「Create New App」
  4. tokenの生成(メモしておく)やmanifestoの設定(users.readも設定)をしておく
    (Socket Modeの許可とApp-level tokenの生成も忘れずに)
  5. Slack Workspaceを指定してAppをインストール
  6. tokenの再発行や設定の追加は「OAuth & Permissions」の「scopes」などから行う
  7. pythonからAppを起動

Appの作り方については以下の記事を参照

create new app

create new app

create new app

設定が終わるとWorkspaceへのAppのインストールを勧められる

create new app

create new app

create new app

manifest_capture_bot.ymlをExportできる。

create new app

channelへのAppの追加

create new app

create new app

create new app

create new app

App-Level Tokensの生成

create new app

create new app

create new app

create new app

mentionのためのUser ID情報取得方法

users.list Lists all users in a Slack team. からweb apiを使ってユーザーIDのリストを取得できる。詳細は下記を参照。

Pythonの設定

Requirements

$ pip install slack_bolt
$ pip install opencv-python

slack_token.shにSigning Secret, OAuth token (xoxb-で始まる), App-level token (xapp-で始まる)を設定する。

#!/bin/zsh

export SLACK_SIGNING_SECRET='<your-signing-secret>'
export SLACK_BOT_TOKEN='xoxb-<your-bot-token>'
export SLACK_APP_TOKEN='xapp-<your-app-token>'

Appスクリプトを配置したディレクトリで以下を実行する

$ source ./slack_token.sh

export -pで設定できているかどうか確認。

$ export -p

Tokenが設定されていればOK

openvcを用いたビデオキャプチャ

opencvはカメラデバイスを0から始まるデバイス番号で識別して指定する。 カメラデバイスが複数接続されている場合には、0番をハードコーディングで指定してもうまくいかない。MacではSwiftスクリプトを用いてデバイス名を取得する方法がある。

詳細はこちらを参照

Workspaceに常駐しイベントに反応するslack_bolt bot

channel内のイベントを監視して、イベントに応じた処理を自動で行うlistener |||botを作るには、slack_boltが利用できる。
webにはslackbotやslackclientなどが紹介されているが、2019年のSlack APIの仕様変更に対応しておらず、使えなくなっている。この記事を執筆している時点(2021.7.11)では、常駐型してイベントを監視するタイプのbotにはslack_bolt(とslack_sdk)を使うのが良い。

やりたいことを自在にこなすには、まだ微妙に情報が少ない。

上記のリンクにあるコードでは、通信方式としてHTTP postを用いたサンプルが主に紹介されており、比較的新しく提供されたSocket Modeの実装例は見つかりにくい。
一番参考になったのは、いつも最強のprint()とQiitaにあったSlack ソケットモードの最も簡単な始め方という記事。

Slack Workspaceのchannelに常駐して、イベントに反応するbotでは、@app.event()@app.message()でイベントをフックし、ハンドラを起動する。

@app.message()と@app.event(“message”)は、どちらか先に該当した方が実行され、それ以降のハンドラは無視されます。なので、@app.event(“message”)より後ろに@app.message()を書いても実行されないようだ。
@app.event(“app_mention”)は、@app.message()や@app.event(“message”)があっても、それとは独立のイベントとして処理される。

使いたい必要なエッセンスだけに限定して書いた基本的なコードは下記のようになる。

@app.event("message")でbotがインストールされているchannelの投稿が監視できる。 def handler_message_events(body, say, logger)以下でイベントハンドラの挙動を指定している。
@app.event()はイベント全般を扱えるが、指定しなければならない変数が多く、文書投稿に限定した対応が求められる場合は@app.message()を使う。

import logging
import os
from slack_sdk import WebClient
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

logging.basicConfig(level=logging.DEBUG)

app = App(token=os.environ["SLACK_BOT_TOKEN"])

# deafault handler for 'message' event
@app.event("message")
def handle_messge_events(body, say, logger):
    print("### BODY ### -----------------------------------")
    print(body)
    print("### SAY ### -----------------------------------")
    print(say)
    print("### LOGGER ### -----------------------------------")
    print(logger)
    print("### Send say ### -----------------------------------")
    say(f"In 'message'\nこんにちは <@{body['event']['user']}> さん!")
    print("### end handler ### -----------------------------------")


if __name__ == "__main__":
    handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
    handler.start()

@app.event()では、body, say, loggerを帰り値として受け取ることができる。これらをprintして中身を確認すると、それぞれリストになっていることがわかる。

@app.message()が帰すのはmessageというリスト。

# check if the text matches with "shot"/"SHOT"/"Shot"
@app.message(re.compile("([Ss][Hh][Oo][Tt])"))
def handle_messge(message, say):
    print(message)

としてみるとmessageの中身は次のような辞書になっていることがわかる。

{
    'client_msg_id': '544e53d0-f705-4078-b273-c9c12794cb7b', 
    'type': 'message', 
    'text': 'shot', 
    'user': 'U01234XXXXXX', 
    'ts':   '1626071325.002000', 
    'team': 'T0123YYYYYYY', 
    'blocks': [
        {
            'type': 'rich_text', 
            'block_id': 'S7g', 
            'elements': [
                {
                    'type': 'rich_text_section', 
                    'elements': [
                        {
                            'type': 'text', 
                            'text': 'shot'
                        }
                    ]
                }
            ]
        }
    ], 
    'channel': 'C0XXXXXXXXXX', 
    'event_ts': '1626071325.002000', 
    'channel_type': 'group'
}

投稿されたchannelのidは次のようにして求まる。

channel_id = message['channel']

boltが扱うイベントは以下のようになっているらしい(調査中)

実装例は以下を参考に

listener type 引数 どんな時に使う?
@app.message(pattern) message, say メッセージ投稿を扱う
@app.event(eventType,) event, client イベント全般を扱う(eventTypeを指定)
@app.event(‘message’) body, say, logger eventに’message’を指定すると@app.message()と同等。 subtypeを指定可能
@app.event(‘app_home_opened’) client, event, logger 調査中
@app.action(callback_id | block_id | action_id) body, client, ack ack()を返す必要がある。
@app.action(‘approve_button’) ack, say 調査中
@app.view(view_submission) ack, body, client, view, logger 調査中
@app.shortcut(trigger_id) shortcut, ack, client global shortcutsmessage shortcutsに対応しているらしい。調査中
@app.command(commandName) command, ack, say commandNameの正規表現で複数の@app.command()にマッチした場合、全てが実行される
@app.command(‘/ticket’) ack, body, client 調査中(Using modals in Slack apps
@app.options() ack 調査中
@app.error() error, body, logger 調査中

slack_sdkを使ってSlack channelにファイルをアップロードする

Socket Modeを利用したイベントハンドラは、slack_boltを利用するのが便利だが、ファイルの送信などローレベルのAPIを利用したい場合には、slack_sdkのAPIを呼ぶ必要がある。
高レベルAPIを提供するslack_boltも実際にはslack_sdkを用いて実装されている。

ファイルをアップロードするには、slack_sdkのfiles_upload()を用いる。
概要は下記を参照

from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
import logging

###############################################
# init Socket Mode client settings
###############################################

filename = "./filename.jpg"
channel_id ="<channel id>"

client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
logger = logging.getLogger(__name__)

# Call the files.upload method using the WebClient
# Uploading files requires the 'files:write' scope
try:
    result = client.files_upload(
        channels=channel_id,
        initial_comment=filename,
        file=filename
    )
    logger.info(result)
    
except SlackApiError as e:
    logger.error(Error uploading file: {}".format(e))

プログラムはこちら
test_capture_bot.py
プログラムと同じフォルダにavfcam_list.swiftをおいて実行する。 事前にtokenを環境変数としてexportするのを忘れないように。(下記)

$ source ./slack_token.sh

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# =======================================================
#  slack_boltでpythonからslackにキャプチャ画像を返信
#
#  test_capture_bot.py
#  coded by Noboru Harada (noboru@ieee.org)
#
#  Changes:
#  2021/07/11: First version
# =======================================================

import re
import datetime
import sys
import os
import subprocess
import json
from collections import OrderedDict
import cv2
import logging
import os
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler


###############################################
#  Check camera devices
###############################################

# Get path for the swift script (supporse to be in the same location with this python script)
script_path = os.path.dirname(os.path.abspath(__file__))
script_path = "swift " + script_path + "/avfcam_list.swift"
print(script_path)

# Check camera devices with a swift script
camera_devices = subprocess.check_output(script_path, shell=True)
json_dict = json.loads(camera_devices, object_pairs_hook=OrderedDict)

camera_devices = camera_devices.decode("utf-8")
print(camera_devices)

if json_dict.get('SPCameraDataType') == None:
    print("No camera device found")
    sys.exit()
     
num_devices = len(json_dict['SPCameraDataType'])
if num_devices == 0:
    print("No camera device found")
    sys.exit()

val = 0
if num_devices > 1:
    print("Select a camera devie:")
    for s in range(num_devices):
        name = json_dict['SPCameraDataType'][s].get("_name")
        print(" %d: %s" % (s, name))
    try:
        val = int(input())
    except ValueError:
        print("Wrong device id")
        val = 0

if val > num_devices-1 or val < 0:
        print("Wrong device id")
        val = 0

device_id = val
device_name = json_dict['SPCameraDataType'][device_id].get("_name")
print(" Use Camera device %d: %s" % (val, device_name))
print("Type 'Ctl'+c quit capturing")

cap = cv2.VideoCapture(device_id)


###############################################
# init Socket Mode client settings
###############################################

client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)

app = App(token=os.environ["SLACK_BOT_TOKEN"])


###############################################
# event handler functions
###############################################

# get time string
def get_time():
       return (datetime.datetime.now())

# respond mentions to this bot
@app.event("app_mention")
def handle_mention_events(body, say, logger):
    print("### BODY ### -----------------------------------")
    print(body)
    print("### SAY ### -----------------------------------")
    print(say)
    print("### LOGGER ### -----------------------------------")
    print(logger)
    print("### Send say ### -----------------------------------")
    say(f"In 'app_mention'\nこんにちは <@{body['event']['user']}> さん!")
    print("### end handler ### -----------------------------------")

# check if the text matches with "shot"/"SHOT"/"Shot"
@app.message(re.compile("([Ss][Hh][Oo][Tt])"))
def handle_messge(message, say):
    ret, frame = cap.read()
    filename = get_time().strftime('./Screen Shot %Y-%m-%d at %H.%M.%S.jpg')
    cv2.imwrite(filename,frame)

    print(" Sent screen shot ***********************")
    try:
        # Call the files.upload method using the WebClient
        # Uploading files requires the 'files:write' scope
        result = client.files_upload(
            channels=message['channel'],
            initial_comment=filename,
            file=filename
        )
        logger.info(result)
        print(" Sent screen shot ***********************")

    except SlackApiError as e:
        logger.error("Error uploading file: {}".format(e))

    print("### MESSAGE ### -----------------------------------")
    print(message)
    print("### SAY ### -----------------------------------")
    print(say)
    print("### Send say ### -----------------------------------")
    say(f"In 'shot' こんにちは <@{message['user']}> さん!")
    print("### end handler ### -----------------------------------")

# check if the text matchs with "help"
@app.message("help")
def handle_help(message, say):
    print("### MESSAGE ### -----------------------------------")
    print(message)
    print("### SAY ### -----------------------------------")
    print(say)
    print("### Send say ### -----------------------------------")
    say(f"In 'help' こんにちは <@{message['user']}> さん!\nこれはhelpです。")
    print("### end handler ### -----------------------------------")

# deafault handler for 'message' event
@app.event("message")
def handle_messge_events(body, say, logger):
    print("### BODY ### -----------------------------------")
    print(body)
    print("### SAY ### -----------------------------------")
    print(say)
    print("### LOGGER ### -----------------------------------")
    print(logger)
    print("### Send say ### -----------------------------------")
    say(f"In 'message'\nこんにちは <@{body['event']['user']}> さん!")
    print("### end handler ### -----------------------------------")


if __name__ == "__main__":
    handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
    handler.start()

【2021.7.15追記】concurrent.futuresでマルチプロセスを実現

SocketModeHandlerと、cv2.imshow()は同じメインスレッドに入れる必要があり、 cv2.imshow()やcv2.waitKey()のために専用のwhileループを組めないので、 キャプチャ中のカメラデバイスの画像をウィンドウに表示することができていなかった。 今回、concurrent.futuresのマルチプロセス機能で表示を実現した。

ソースコードはこちら
test_capture_bot_concurrent.py
プログラムと同じフォルダにavfcam_list.swiftをおいて実行する。


#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# =======================================================
#  slack_boltでpythonからslackにキャプチャ画像を返信
#
#  test_capture_bot_concurrent.py
#  coded by Noboru Harada (noboru@ieee.org)
#
#  Changes:
#  2021/07/11: First version
#  2021/07/15: Added concurrent.future for multiple process
# =======================================================

#https://note.com/npaka/n/neb7147a2a9df
#https://rightcode.co.jp/blog/information-technology/python-asynchronous-program-multithread-does-not-work-approach

import concurrent.futures
import time
import re
import datetime
import sys
import os
import subprocess
import json
from collections import OrderedDict
import cv2
import logging
import os
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

global device_id

def check_camera_devices():
    ###############################################
    #  Check camera devices
    ###############################################

    # Get path for the swift script (supporse to be in the same location with this python script)
    script_path = os.path.dirname(os.path.abspath(__file__))
    script_path = "swift " + script_path + "/avfcam_list.swift"
    print(script_path)

    # Check camera devices with a swift script
    camera_devices = subprocess.check_output(script_path, shell=True)
    json_dict = json.loads(camera_devices, object_pairs_hook=OrderedDict)

    camera_devices = camera_devices.decode("utf-8")
    print(camera_devices)

    if json_dict.get('SPCameraDataType') == None:
        print("No camera device found")
        sys.exit()
     
    num_devices = len(json_dict['SPCameraDataType'])
    if num_devices == 0:
        print("No camera device found")
        sys.exit()

    val = 0
    if num_devices > 1:
        print("Select a camera devie:")
        for s in range(num_devices):
            name = json_dict['SPCameraDataType'][s].get("_name")
            print(" %d: %s" % (s, name))
        try:
            val = int(input())
        except ValueError:
            print("Wrong device id")
            val = 0

    if val > num_devices-1 or val < 0:
        print("Wrong device id")
        val = 0

    device_id = val
    device_name = json_dict['SPCameraDataType'][device_id].get("_name")
    print(" Use Camera device %d: %s" % (val, device_name))
    return device_id

###############################################
# init Socket Mode client settings
###############################################

client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])

logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)

app = App(token=os.environ["SLACK_BOT_TOKEN"])

###############################################
# event handler functions
###############################################
# show video image in thread
def show_image(device_id):
    cap = cv2.VideoCapture(device_id)
    cv2.namedWindow('Type [q] to quit capturing')
    #print("video_thread.start()")
    while True:
        # Capture frame-by-frame
        ret, frame1 = cap.read()

        # show the frame
        cv2.imshow('Type [q] to quit capturing', frame1)

        key = cv2.waitKey(1) & 0xFF
        if key == ord('q'):
            break
    cap.release()
    return device_id

# get time string
def get_time():
       return (datetime.datetime.now())

# respond mentions to this bot
@app.event("app_mention")
def handle_mention_events(body, say, logger):
    print("### BODY ### -----------------------------------")
    print(body)
    print("### SAY ### -----------------------------------")
    print(say)
    print("### LOGGER ### -----------------------------------")
    print(logger)
    print("### Send say ### -----------------------------------")
    say(f"In 'app_mention'\nこんにちは <@{body['event']['user']}> さん!")
    print("### end handler ### -----------------------------------")

# check if the text matches with "shot"/"SHOT"/"Shot"
@app.message(re.compile("([Ss][Hh][Oo][Tt])"))
def handle_messge(message, say):
    cap = cv2.VideoCapture(device_id)
    ret, frame = cap.read()
    filename = get_time().strftime('./Screen Shot %Y-%m-%d at %H.%M.%S.jpg')
    cv2.imwrite(filename,frame)
    cap.release()
    print(" Sent screen shot ***********************")
    try:
        # Call the files.upload method using the WebClient
        # Uploading files requires the 'files:write' scope
        result = client.files_upload(
            channels=message['channel'],
            initial_comment=filename,
            file=filename
        )
        logger.info(result)
        print(" Sent screen shot ***********************")

    except SlackApiError as e:
        logger.error("Error uploading file: {}".format(e))
    print("### MESSAGE ### -----------------------------------")
    print(message)
    print("### SAY ### -----------------------------------")
    print(say)
    print("### Send say ### -----------------------------------")
    say(f"In 'shot' こんにちは <@{message['user']}> さん!")
    print("### end handler ### -----------------------------------")

# check if the text matchs with "help"
@app.message("help")
def handle_help(message, say):
    print("### MESSAGE ### -----------------------------------")
    print(message)
    print("### SAY ### -----------------------------------")
    print(say)
    print("### Send say ### -----------------------------------")
    say(f"In 'help' こんにちは <@{message['user']}> さん!\nこれはhelpです。")
    print("### end handler ### -----------------------------------")

# deafault handler for 'message' event
@app.event("message")
def handle_messge_events(body, say, logger):
    print("### BODY ### -----------------------------------")
    print(body)
    print("### SAY ### -----------------------------------")
    print(say)
    print("### LOGGER ### -----------------------------------")
    print(logger)
    print("### Send say ### -----------------------------------")
    say(f"In 'message'\nこんにちは <@{body['event']['user']}> さん!")
    print("### end handler ### -----------------------------------")


if __name__ == "__main__":

    ##################
    # Working example
    ##################
    device_id = check_camera_devices()
    executor = concurrent.futures.ProcessPoolExecutor()
    future = executor.submit(show_image, device_id)
    print("===== SocketModelHandler =====")
    handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
    handler.start()
 
    # terminate resources
    cv2.destroyAllWindows()

参照

Slack appで定期的にタスクを実行する方法

Slack API (Socket Mode API)

単にメッセージを送信するだけならこちらが簡単

opencvを用いたビデオキャプチャ

課題(boltとopenCVをマルチスレッド対応にできていない)

Back to Index