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のマルチプロセス機能で表示を実現した。
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)を用いて作成する。
App内で定時実行の時間管理まですると、トリガーを監視するlistnerのthreadと、指定時刻に自主的に起動するtimer threadを実装した上に、トリガーを受けてコマンドを解釈し、時刻設定を行うインターフェースも実装する必要が生じる。これらは面倒なので、あるものはそのまま使う方針で、定時実行のトリガーは、Slack botがもともと持っている/remind機能を利用することとする。
/remindは、たとえば以下のように設定できる。
/remind me to ask @hellobot2000 to shot a photo every weekday at 7:05
使い方の解説はこちら
https://api.slack.com/appsで 「Create New App」から新規のAppを作成できる。
手順は以下のとおり
Appの作り方については以下の記事を参照
設定が終わるとWorkspaceへのAppのインストールを勧められる
manifest_capture_bot.ymlをExportできる。
users.list Lists all users in a Slack team. からweb apiを使ってユーザーIDのリストを取得できる。詳細は下記を参照。
$ 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
opencvはカメラデバイスを0から始まるデバイス番号で識別して指定する。 カメラデバイスが複数接続されている場合には、0番をハードコーディングで指定してもうまくいかない。MacではSwiftスクリプトを用いてデバイス名を取得する方法がある。
詳細はこちらを参照
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']
実装例は以下を参考に
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 shortcutsとmessage 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 | 調査中 |
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)
= os.path.dirname(os.path.abspath(__file__))
script_path = "swift " + script_path + "/avfcam_list.swift"
script_path print(script_path)
# Check camera devices with a swift script
= subprocess.check_output(script_path, shell=True)
camera_devices = json.loads(camera_devices, object_pairs_hook=OrderedDict)
json_dict
= camera_devices.decode("utf-8")
camera_devices print(camera_devices)
if json_dict.get('SPCameraDataType') == None:
print("No camera device found")
sys.exit()
= len(json_dict['SPCameraDataType'])
num_devices if num_devices == 0:
print("No camera device found")
sys.exit()
= 0
val if num_devices > 1:
print("Select a camera devie:")
for s in range(num_devices):
= json_dict['SPCameraDataType'][s].get("_name")
name print(" %d: %s" % (s, name))
try:
= int(input())
val except ValueError:
print("Wrong device id")
= 0
val
if val > num_devices-1 or val < 0:
print("Wrong device id")
= 0
val
= val
device_id = json_dict['SPCameraDataType'][device_id].get("_name")
device_name print(" Use Camera device %d: %s" % (val, device_name))
print("Type 'Ctl'+c quit capturing")
= cv2.VideoCapture(device_id)
cap
###############################################
# init Socket Mode client settings
###############################################
= WebClient(token=os.environ["SLACK_BOT_TOKEN"])
client
=logging.DEBUG)
logging.basicConfig(level= logging.getLogger(__name__)
logger
= App(token=os.environ["SLACK_BOT_TOKEN"])
app
###############################################
# 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 ### -----------------------------------")
f"In 'app_mention'\nこんにちは <@{body['event']['user']}> さん!")
say(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.read()
ret, frame = get_time().strftime('./Screen Shot %Y-%m-%d at %H.%M.%S.jpg')
filename
cv2.imwrite(filename,frame)
print(" Sent screen shot ***********************")
try:
# Call the files.upload method using the WebClient
# Uploading files requires the 'files:write' scope
= client.files_upload(
result =message['channel'],
channels=filename,
initial_commentfile=filename
)
logger.info(result)print(" Sent screen shot ***********************")
except SlackApiError as e:
"Error uploading file: {}".format(e))
logger.error(
print("### MESSAGE ### -----------------------------------")
print(message)
print("### SAY ### -----------------------------------")
print(say)
print("### Send say ### -----------------------------------")
f"In 'shot' こんにちは <@{message['user']}> さん!")
say(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 ### -----------------------------------")
f"In 'help' こんにちは <@{message['user']}> さん!\nこれはhelpです。")
say(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 ### -----------------------------------")
f"In 'message'\nこんにちは <@{body['event']['user']}> さん!")
say(print("### end handler ### -----------------------------------")
if __name__ == "__main__":
= SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
handler handler.start()
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()