f19: 声でテキストメッセージ送信 (slack + whisper)


.

2025/05/17 以下追記

　[ 05. スクリプト本体 ] if __name__ == "__main__": に追加
　# 最初に、slack user ID (user01 - user03)を取得するために、
　# logger.info(f'01 - {data1}')
　# のコメント外し、POST された json 全項目をログに出力して、
　# それぞれの ID を確認しておく

　[ 01. 仕様・条件等 ] に以下追加
　[chatGPT 側] 
　・過去の発言は記憶しない
　　(メンション飛ばされたその投稿だけがプロンプトとなる)
　　(本家のように連続会話は出来ない)

2025/06/14 以下追記

　(今回のソースでは、そのまま slack の別チャンネルへ投稿返し)
　の注意点を以下に補足
　[ 04. slack 設定 ] -「内部へPOST側」

---

ChatGPT や Whisper + API のお題目は
みなさま色々と展開されておりますので、
特に目新しいことはなく、備忘録になります

##### 実現すること

##### A. 「声で」テキストメッセージを送信したい

・slack で、指定メンション(user03) 宛てに、音声で投稿
-> 外部サーバー(webhook URL)へPOST され、
　whisper の文字起こし後、 テキスト応答が返る
-> その起こされたテキストを使って、SNSへAPI投稿や、メールへ送信する
　　(今回のソースでは、そのまま slack の別チャンネルへ投稿返し)

##### B.「テキスト入力(質問やただの入力)」も一応用意

・slack で、指定メンション(user02) 宛てに、テキスト質問
-> 外部サーバー(webhook URL)へPOST され、
　chatGPT からのテキスト回答が返る
-> その回答テキストを使って、SNSへAPI投稿や、メールへ送信する
　　(今回のソースでは、そのまま slack の別チャンネルへ投稿返し)

・slack で、メンションなし で、テキスト投稿
-> 外部サーバー(webhook URL)へPOST され、
-> そのテキストを使って、SNSへAPI投稿や、メールへ送信する
　　(今回のソースでは、そのまま slack の別チャンネルへ投稿返し)

---
### **[ 01. 仕様・条件等 ]**

##### [共通] 
・slack は、イベント駆動型(Event Subscriptions)で作成
・エラー検知はひとまずログ出力のみ (メールアラート送信追加予定)

##### [whisper 側] 
・ 音声は、25MB までのファイルを許可
・一応多言語対応(デフォルトは日本語、その他は投稿時言語指定でスイッチ)
　(例: @メンション(whisperユーザー) en -> 英語で音声解析)
　(例: @メンション(whisperユーザー) cn -> 中国語で音声解析)
・whisper 文字起こし後の 
　chatGPT による文書校正(補填)機能は未実装 (トークン倍消費？)

##### [chatGPT 側] 
・過去の発言は記憶しない
　(メンション飛ばされたその投稿だけがプロンプトとなる)
　(本家のように連続会話は出来ない)

---
### **[ 02. キッチン ]**

**・slack**

**・さくらのレンタルサーバー**
　今回の「外部サーバー(webhook URL 指定先)」
　「スクリプト本体 (slack-to-openai.py) 格納先」(CGImode: 755)

**・chatGPT (gpt-3.5-turbo)**
**・whisper**

**・python 3.9.13**
**・openai 0.28.1**

---
### **[ 03. OpenAI 設定 ]**

OpenAIのAPIキーを取得する (参考URL)
https://1-notes.com/openai-get-api-key/

クレジットカード登録
https://platform.openai.com/settings/organization/billing/overview
※ 2025/05 現在は、初回5ドルプレゼントもなく最初から課金です
※ オートチャージが怖い人は OFF

Usage limits (使いすぎ注意設定ページ)
https://platform.openai.com/settings/organization/limits

Set a Budget Alert (いわゆる soft limit)
-> 設定金額を超えたらメール通知くる

Enable Budget Limit (いわゆる hard limit)
-> 設定金額を超えたらAPIが停止する

---
### **[ 04. slack 設定 ]**

#### 「外部へPOST側」
(00) チャンネル1 (プライベートチャンネル) 作成 (投稿用)
(01) アプリ1(app1) 作成
(02) Event Subscriptions (Enable Events -> ON)
(03) Request URL 設定 + チャレンジ(verify)

(04) Subscribe to bot events に追加
　message.channels

(05) Subscribe to events on behalf of users に追加
　message.channels
　message.groups

(06) OAuth & Permissions -> Scopes 移動
(07)  「Bot User OAuth Token」キー取得 (xoxb-)

(08) Bot Token Scopes に追加
　channels:history
　channels:read
　chat:write
　files:read
　groups:history
　im:history
　incoming-webhook
　mpim:history
　users:read

(09) User Token Scopes に追加
　channels:history
　groups:history

(10) bot ユーザーが音声ファイルへアクセスするために
　チャンネル内に招待しておく
　/invite @app1

#### 「内部へPOST側」
(00) チャンネル2 (プライベートチャンネル) 作成 (結果テキスト戻し用)
(01) アプリ2(app2) 作成
(02) incoming webhook -> ON

(03) Add New Webhook キー取得
 -> https://hooks.slack.com/services/xxxx/xxxx/xxxx

**※ チャンネル1, 2 ともに、プライベートチャンネルにしているため、**
　**チャンネル1 で、message.groups を必要としている関係で、**
　**投稿条件 (例 cgiモードで例外エラー発生等) によっては、**
　**別チャンネルとしている チャンネル2 への投稿であっても、**
　**チャンネル1 が検知してしまい無限ループに陥ることがあります**
　**(スクリプト物理削除の強制終了が必要) ので、**
　**-> パブリックチャンネルにして、message.groups を外す**
　**-> または、別の slack ワークスペース のチャンネルへ投稿**
　**のほうが無難です**

---
### **[ 05. スクリプト本体 ]**

```python:slack-to-openai.py
# -*- coding: utf-8 -*-

import sys, os, logging
import json, base64, requests
import mimetypes
from urllib.parse import unquote
import openai

dire = os.path.dirname(os.path.abspath(__file__)) + '/'

# ログ設定
logger = logging.getLogger('slack')
logger.setLevel(logging.INFO)
file_handler = logging.FileHandler(dire+'slack.log')
file_handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s: %(name)s: %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
file_handler.setFormatter(formatter)

if not logger.hasHandlers():
	logger.addHandler(file_handler)

logger.info('start')
logger.info(f"Process started: PID = {os.getpid()}")

openai.api_key = 'xxxxxxxxxxxxx' # openai API KEY

# slack post user
user01 = 'U01XXXXXX'
# slack mention user for gpt
user02 = '<@U02XXXXXX>'
# slack mention user for whisper
user03 = '<@U03XXXXXX>'

# 音声ファイル、許可拡張子
allowed_audio_types = ["mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"]

# ===================================
# whisper による文字起こし
def transcribe_audio(file_url, headers, user_text, filetype):

	err01a = 0
	err02a = ''
	err01b = 0
	err02b = ''

	filetype = filetype.lower().strip()

	if filetype not in allowed_audio_types:
		logger.info('対応していないファイル形式です。mp3, mp4, mpeg, mpga, m4a, wav, webm のいずれかにしてください')
		return None

	# ファイル名は、temp_audio.xxx で、スクリプトパスに保存する
	filename = f"temp_audio.{filetype}"

	# 既存ファイル削除
	if os.path.exists(dire+filename):
		os.remove(dire+filename)

	try:
		response = requests.get(file_url, headers=headers)
		response.raise_for_status()
		with open(filename, "wb") as f:
			f.write(response.content)

	except Exception as e1:

		err01a = 1
		err02a = str(e1)
		logger.error('ファイルのダウンロードに失敗しました:  ' + str(err01a))
		logger.error(err02a)
		return None

	# ファイルサイズチェック（25MB以下）
	if os.path.getsize(filename) > 25 * 1024 * 1024:
		logger.info('ファイルサイズオーバー。ファイルサイズは25MBにしてください')
		return None

	# 言語判定（音声ファイルとは別に、メッセージ内に 'en', 'cn' のキーワードがあれば判断）
	# メッセージ内に何もなければ日本語で音声解析
	lowered = user_text.lower()
	if "en" in lowered:
		language = "en" # 英語
	elif "cn" in lowered:
		language = "zh" # 中国語
	else:
		language = "ja" # 日本語

	try:
		with open(filename, "rb") as audio_file:
			transcript = openai.Audio.transcribe("whisper-1", audio_file, language=language)
			return transcript["text"]

	except Exception as e2:

		err01b = 1
		err02b = str(e2)
		logger.error('音声の文字起こしに失敗しました:  ' + str(err01b))
		logger.error(err02b)
		return None

# ===================================
# chatGPT に質問
def ask_gpt(prompt):

	err01c = 0
	err02c = ''

	gmodels = 'gpt-3.5-turbo'

	SYS_PROMPT = """
	あなたは知識豊富で親切なAIアシスタントです。
	ユーザーの質問に対して、その言語に応じて自然な回答を提供してください。
	例えば日本語での質問には日本語で、英語での質問には英語で回答してください。
	日本語の場合は日本在住であることを考慮して日本語で丁寧に回答してください。
	必要に応じて簡潔な例やコードスニペットも含めてください。
	"""

	try:
		response = openai.ChatCompletion.create(
			model=gmodels,
			messages=[
				{"role": "system", "content": SYS_PROMPT},
				{"role": "user", "content": prompt}
			],
			temperature=0.7
		)
		return response["choices"][0]["message"]["content"]

	except Exception as e3:

		err01c = 1
		err02c = str(e3)
		logger.error('GPT 質問処理エラー:  ' + str(err01c))
		logger.error(err02c)
		return None

# ===================================
# slack から受け取って、分岐
def process_slack_event(event):

	user_text = event.get("text", "")
	user_id = event.get("user", "")
	files = event.get("files", [])
	mentions = user_text.split()

	slack_api_key = 'xoxb-xxxxxxxx' # Bot User OAuth Token
	headers = {"Authorization": f"Bearer {slack_api_key}"}

	# user02 宛てメンションの場合、chatGPT に質問
	if user02 in mentions:

		if user_text:
			prompt = user_text
			logger.info(f"Sending to GPT: {prompt}")
			return ask_gpt(prompt)

	# user03 宛てメンションの場合、whisper による文字起こし
	elif user03 in mentions:

		for file_info in files:

			mime = file_info.get("mimetype", "")
			filetype = file_info.get("filetype", "")

			if mime.startswith("audio/") and filetype in allowed_audio_types:

				file_url = file_info.get("url_private_download")
				logger.info(f"Transcribing audio: {file_url}")
				return transcribe_audio(file_url, headers, user_text, filetype)

	else:
		logger.info(f"メンションなし投稿: user={user_id}, text={user_text}")
		return user_text

# ===================================
# slack へ戻し
def to_slack_bk(replys):

	err01e1 = 0
	err02e1 = ''

	# ループ回避のため、別チャンネル(+別アプリ)へ戻すのが楽
	hooks = 'https://hooks.slack.com/services/XXXXXXXXXXXX'

	channels = '#abcde' # なんでもいいみたい

	messageB = {
		'channel': channels, 
		'text': replys, 
	}

	try:
		resps = requests.post(hooks, json=messageB)

	except Exception as e5:

		err01e1 = 1
		err02e1 = str(e5)

	return err01e1, err02e1

# ===================================
if __name__ == "__main__":

	err01d = 0
	err02d = ''

	err01e2 = 0
	err02e2 = ''

	print("Content-Type: text/plain; charset=utf-8\n")
	print("")

	# POSTデータ取得
	if os.environ.get("REQUEST_METHOD", "") == "POST":

		# Slackのリトライ検知（HTTPヘッダ）
		retry_num = os.environ.get("HTTP_X_SLACK_RETRY_NUM")
		retry_reason = os.environ.get("HTTP_X_SLACK_RETRY_REASON")

		# リトライ分は打ち切り(処理しないで終了する)
		if retry_num or retry_reason:
			logger.warning(f"Retry detected: num={retry_num}, reason={retry_reason}")
			logger.info('ends(リトライ強制終了)')
			sys.exit()

		length1 = int(os.environ.get("CONTENT_LENGTH", 0))
		post_data1 = sys.stdin.read(length1)

		try:
			data1 = json.loads(post_data1)

		except Exception as e4:
			err01d = 1
			err02d = str(e4)
			data1 = {}

		if err01d == 1:
			logger.error('01a: JSON decode エラー: ' + str(err01d))
			logger.error(err02d)

		else:

    		# 最初に、slack user ID (user01 - user03)を取得するために、
    		# logger.info(f'01 - {data1}')
    		# のコメント外し、POST された json 全項目をログに出力して、
    		# それぞれの ID を確認しておく

			if data1.get("type") == "url_verification":
				print(data1.get("challenge", ""))
				sys.exit()

			if data1.get("type") == "event_callback" and "event" in data1:

				event = data1["event"]

				if "subtype" in event and event["subtype"] == "bot_message":
					logger.info('bot_message なので処理しないで終了')
					sys.exit()

				else:
					puser = event.get("user")
					puser = puser.strip()

					# 指定 slack user (user01) からの投稿しか処理しない
					if puser != user01:

						logger.info('user01: [' + user01 + ']')
						logger.info('puser: [' + puser + ']')
						logger.error('投稿ユーザーが一致しません user: ' + puser)
						sys.exit()

					else: 
						# メッセージ処理へ(slack から受け取って、分岐)
						response_text = process_slack_event(event)

						if response_text:

							logger.info('最終テキスト: ' + response_text)

							# それぞれの戻りテキストを slack の別チャンネルへポスト(slack へ戻し)
							err01e2, err02e2 = to_slack_bk(response_text)

							if err01e2 == 1:
								logger.error('slack 戻しでエラー: ' + str(err01e2))
								logger.error(err02e2)
							else:
								logger.info('slack 戻し OK: ')

						else:
							logger.error('データが空です')

	else:
		logger.error('POSTでアクセスではありません')

	logger.info('end')

	sys.exit()

```
---
### **[ 06. 注意点(つまづき点) ]**

#### a) pip install openai -> rust インストール不可(多分)

さくらのレンタルサーバーなので、
おそらく rust のインストールができない(未実施)
ゆえに、
```
$ pip install openai
```
の v1.x系のインストールが失敗する
##### x Preparing metadata (pyproject.toml) did not run successfully.
##### Rust not found, installing into a temporary directory
v1.x系が使用できないため、以下 0.28.1 の旧版を使用
```
$ pip install "openai<1.0.0"
```
#### b) gpt-3.5-turbo では、Webブラウジング機能(web_search_preview tool) が使えない
(gpt-3.5-turbo-1106 以降では使えるそうです)

Webブラウジング機能(web_search_preview tool) が使えると、
日本語や日本の文化の設定が
"user_location": {"type": "approximate", "country": "JP"},
で済む

でも使えないものはしょうがないので、
システムプロンプトの "role": "system", "content" に
日本についての説明をより効果的に追記が必要

#### c) slack 上に格納された音声ファイルが取得できず
指定の外部サーバーへの音声ファイル転送が失敗する
(音声バイナリではなくhtmlファイルが転送される等)

#### 「音声ファイルの流れ」
a01. PC/スマホ等のデバイス内の slack で録音 -> 音声ファイル生成
a02. slack サーバーへ投稿メッセージとともにアップロード
a03. webhook URL 指定の外部サーバーへ
「投稿メッセージ」と「音声ファイル」がポストされる
a04. whisper へは、a03. の音声ファイルが利用される

##### 原因は、
上記の a03. で、a02. に対しての
音声ファイルへのスコープ、権限不足で、
アクセスに失敗しており、エラーが記述されたhtml ファイルを 
a03. のサーバーに転送しているため、
音声ファイルとして認識できない

##### 解決策は、
slack 設定「外部へPOST側」の通り、スコープ、権限設定を行う

#### d) 同じスクリプトが複数回呼び出される
これはみなさまよく記載している事象で、

##### Slack では、応答に3秒以上かかるとリトライしてくる仕様があります

通常のPOSTは、問題ないですが、
chatGPT や whisper に処理を投げると、3秒以上かかるため、
同じリクエスト処理が再度走ってしまいます
(トークン消費(課金)が無駄に増えるので危険です)

##### 回避策
本来の純粋なリトライもさせない状態になりますが、
Slackのリトライ検知を行い、リトライ分は破棄します
```
if retry_num or retry_reason:
```

---
### **[ 07. デザート ]**

##### whisper の精度、高いです
ぼそぼそしゃべっても、日本語、中国語と
誤字ほぼなくしっかり起こしてくれました

宛先の振り分け処理を工夫すれば、
起こされたテキストメッセージを
slack からさまざまなプラットフォーム、
例えば、
discord のサーバー内チャンネル宛てや、
Line 公式アカウントから「友達」への
文字メッセージ投稿や、
ちょっとした返信メールなどを、
ボイスメッセージ感覚で声で発信できます

##### Alexa + IFTTT, Siri, Gemini, dify+外部連携 などでも、
制限はあるものの、徐々に似たようなことが
できるようになってきているようですので、
slack にこだわりたい人向きですかね

##### ちなみに、調子に乗って、
外で、音声投稿を繰り返してたら
通信パケット(上り)もトークンも
ぐんぐん消費する(した)のでご注意ください

[ OpenAI の Usage(使用量) 画面 ]
![gpt-usage06.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/298080/5a376942-4ce4-4179-9a3d-fab35dc539a3.jpeg)


---
### **[ 08. 参考にさせていただいた記事 ]**

Slack上でOpenAI社のWhisper APIで文字書き起こし
（speech to text）するSlackBot作成方法

https://qiita.com/ina111/items/1a7c3aac1ca02259783f

　(違いは、記事：プロセス常駐型 (Bolt + WebSocket) に対し、
　イベント駆動型 (Event Subscriptions) で作成)

---
.