
Google Chat Workspace Add-onsでスラッシュコマンドを実装したら3つの罠にハマった
はじめに
Google Chat上で動くRAGボットに /help スラッシュコマンドとウェルカムメッセージを追加しました。「ドキュメント通りに実装すれば簡単でしょ」と思っていたら、3つの罠にハマって解決まで何度もデプロイを繰り返すことになりました。基本構成についてこちらの記事をご参照ください。
この記事では、Google Workspace Add-ons形式のGoogle Chatアプリでスラッシュコマンドを実装する際に遭遇した問題と、その解決方法をまとめます。同じ構成で開発している方の参考になれば幸いです。

前提・環境
- Google Chat アプリ(Workspace Add-ons形式)
- Cloud Functions 2nd gen(Cloud Run ベース)
- Python 3.14 + functions-framework
googleapiclientでChat APIを利用
ボットの構成はこのようになっています。

- ユーザーがGoogle Chatでメッセージを送信
- Cloud Functions がイベントを受信
- バックグラウンドスレッドでRAGパイプラインを実行
- Chat APIでメッセージを非同期送信(プログレッシブカード更新)
やりたかったこと
- ウェルカムメッセージ: ボットをDMに追加した時に、使い方を説明するカードを表示
/helpコマンド: スラッシュコマンドで同じヘルプカードを表示

Google Chat API Configuration を開き、コマンド を設定します
- 一般質問のガード: 「あなたは何ですか?」のような質問にも適切に応答
Workspace Add-onsのイベント形式
まず押さえておくべき前提として、Google Chat アプリには2つのイベント形式があります。
- 旧 Chat API形式:
event.typeフィールドでイベント種別を判定(MESSAGE,ADDED_TO_SPACE等) - Workspace Add-ons形式: ペイロードのキーの存在でイベント種別を判定
現在のドキュメントはWorkspace Add-ons形式を推奨しており、この記事でもこちらを前提にしています。イベントの判定パターンは以下の通りです。
chat = body.get("chat", {})
if "addedToSpacePayload" in chat:
# ボットがスペースに追加された
pass
if "removedFromSpacePayload" in chat:
# ボットがスペースから削除された
pass
if "appCommandPayload" in chat:
# スラッシュコマンドが実行された
pass
if "buttonClickedPayload" in chat:
# カードのボタンがクリックされた
pass
if "messagePayload" in chat:
# 通常のメッセージが送信された
pass
旧形式の if event_type == "ADDED_TO_SPACE" のような判定は動きません。ペイロードのキー存在チェックが正しいパターンです。
罠1: appCommandIdがfloatにデシリアライズされる
症状
スラッシュコマンドのイベントは正しく受信できているのに、コマンドIDのマッチングが常に失敗する。
原因
Google Cloud Consoleでスラッシュコマンドを登録する際、コマンドIDは整数で設定します(例: 1)。公式ドキュメントのPythonサンプルでも ABOUT_COMMAND_ID = 1 と整数で定義し、== で比較しています。
# 公式サンプル(簡略版)
ABOUT_COMMAND_ID = 1
def handle_app_command(event):
if event["appCommandPayload"]["appCommandMetadata"]["appCommandId"] == ABOUT_COMMAND_ID:
return {"text": "About this app..."}
ところが、実際にCloud Functionsで受信したペイロードをデバッグログで確認すると、appCommandId の型が float になっていました。
{
"debug_cmd_id": 1,
"debug_cmd_id_type": "float",
"debug_str_match": false
}
PythonのJSON デシリアライズでは、JSONの数値(1)は int にも float にもなり得ます。Flask/Werkzeugの request.get_json() 内部で使われるJSONパーサーの挙動により、appCommandId: 1 が 1.0(float)としてパースされていました。
このため、str(cmd.get("appCommandId")) は "1.0" になり、"1" とは一致しません。
>>> str(1.0)
'1.0'
>>> str(1.0) == "1"
False
解決策
int() でキャストしてから文字列比較するようにしました。
HELP_COMMAND_ID = "1"
cmd = payload.get("appCommandMetadata", {})
if str(int(cmd.get("appCommandId", 0))) == HELP_COMMAND_ID:
# コマンド処理
pass
あるいは、公式サンプルのように整数同士で比較する方がシンプルです。
HELP_COMMAND_ID = 1
if int(cmd.get("appCommandId", 0)) == HELP_COMMAND_ID:
pass
なお、これはFlask/Werkzeug固有の挙動ではありません。Pythonの標準 json モジュールは、JSONの 1 を int に、1.0 を float にパースします。つまり、Google Chat API側が appCommandId を 1.0(小数点付き)としてJSONペイロードに含めて送信していることが根本原因です。
>>> import json
>>> type(json.loads('1')).__name__
'int'
>>> type(json.loads('1.0')).__name__
'float'
罠2: 同期レスポンスでメッセージが表示されない
症状
appCommandPayload(スラッシュコマンド)イベントに対して、ドキュメント通りの同期レスポンスを返しているのに、Google Chatに何も表示されない。
試したこと
公式ドキュメントに記載されているレスポンス形式で返却しました。
# 公式ドキュメント通りの同期レスポンス
return {
"hostAppDataAction": {
"chatDataAction": {
"createMessageAction": {
"message": {"text": "ヘルプメッセージ"}
}
}
}
}
HTTP 200で正常にレスポンスが返っていることはCloud Loggingで確認済みです。しかし、テキストでもカードでも、Google Chat上には何も表示されませんでした。
同じボット内で、buttonClickedPayload(ボタンクリック)に対するダイアログ表示(action.navigations 形式)は正常に動作していたため、Add-ons形式のレスポンス自体が壊れているわけではありませんでした。
解決策: Chat APIによる非同期送信に切り替え
このボットは通常のメッセージ応答も非同期パターン(HTTPレスポンスは {} を即返し、バックグラウンドスレッドでChat APIを使ってメッセージを送信)で処理しています。スラッシュコマンドのレスポンスも同じパターンに統一することで解決しました。
from bot.chat_api import ChatApiClient
from bot.cards import build_welcome_card
def _send_welcome_card(space_name, thread_name=None):
"""Chat API経由でウェルカムカードを非同期送信する"""
client = ChatApiClient()
client.create_message(
space_name,
build_welcome_card(),
thread_name=thread_name,
)
# スラッシュコマンドハンドラ
if "appCommandPayload" in chat:
payload = chat["appCommandPayload"]
cmd = payload.get("appCommandMetadata", {})
if int(cmd.get("appCommandId", 0)) == HELP_COMMAND_ID:
space_name = payload.get("space", {}).get("name")
thread_name = payload.get("message", {}).get(
"thread", {}).get("name")
if space_name:
threading.Thread(
target=_send_welcome_card,
args=(space_name, thread_name),
daemon=True,
).start()
return {}
ポイント: appCommandPayload には space.name と message.thread.name が含まれているため、Chat APIでメッセージを送信するのに必要な情報は揃っています。
ADDED_TO_SPACE(addedToSpacePayload)も同様に、addedToSpacePayload.space.name からスペース名を取得して非同期送信にしました。
if "addedToSpacePayload" in chat:
space_name = chat["addedToSpacePayload"].get("space", {}).get("name")
if space_name:
threading.Thread(
target=_send_welcome_card,
args=(space_name,),
daemon=True,
).start()
return {}
注意: バックグラウンドスレッドを使う場合、Cloud Runの --no-cpu-throttling 設定が必要です。デフォルトではHTTPレスポンス返却後にCPUがスロットリングされ、バックグラウンドスレッドが凍結されます。
# 初回のみ(設定はデプロイ間で永続化される)
gcloud run services update YOUR_SERVICE \
--region=asia-northeast1 \
--no-cpu-throttling
罠3: 一般質問にRAGパイプラインが走ってしまう
症状
「あなたは何が得意ですか?」「こんにちは」のような質問に対して、RAGパイプラインが実行され「ナレッジベースには該当する情報が見つかりませんでした」と返答してしまう。
アプローチの選択
この問題に対して、いくつかのアプローチを検討しました。
| アプローチ | メリット | デメリット |
|---|---|---|
| 事前RAG意図分類器 | 確実にフィルタ | 全クエリに追加レイテンシ+APIコスト |
| キーワード/正規表現 | 低コスト | 日本語では脆弱、メンテ負荷 |
| プロンプト調整 | 追加コスト0、パイプライン変更不要 | RAGは走る(結果は無駄になる) |
| ウェルカムカード | 根本原因(ユーザーがボットの用途を知らない)を解消 | 一般質問は完全には防げない |
結論: ウェルカムカード(根本原因の解消)+ プロンプト調整(残余ケースの対応)のハイブリッドアプローチを採用しました。低ボリュームの社内ボットでは、たまに一般質問がRAGを通過するコストより、全メッセージを分類するコストの方が高くつきます。
プロンプトの調整
システムプロンプトに「自己紹介・挨拶への対応」セクションを追加しました。
## 自己紹介・挨拶への対応
- ユーザーが挨拶や自己紹介を求めた場合:
- 簡潔に自分の役割を説明する
- is_answerable を true に設定する
- ナレッジベース検索結果が空でも回答できる
(ボット自身の情報はこのプロンプトに含まれるため)
- 対象業務と関係のない一般的な質問の場合:
- 特定業務の質問応答専用であることを伝える
- 一般的な質問には汎用チャットサービスを案内する
- is_answerable を true に設定する
ポイント: is_answerable = true に設定するよう指示しているのが重要です。このボットは is_answerable = false の場合にフォールバックメッセージ(「担当部署にお問い合わせください」)を強制付与します。挨拶や一般質問はナレッジベースに情報がなくても「回答できている」状態なので、true にしないとエスカレーション文が付いてしまいます。
ウェルカムカード
ボット追加時と /help コマンド時に、用途・使い方・質問例を表示するカードを送信するようにしました。これにより、ユーザーは最初からボットの目的を理解でき、一般的な質問を送る頻度が下がります。

Cloud Loggingでの段階的デバッグ
本番環境(Cloud Functions / Cloud Run)でのデバッグ手法についても触れておきます。
ローカルでは再現できない問題(Google Chatからのイベント形式、レスポンスのレンダリング等)は、本番でprint() + json.dumps() で段階的に切り分けるのが有効でした。
Step 1: イベント構造の確認
print(json.dumps({
"debug_chat_keys": list(chat.keys()),
"debug_body_keys": list(body.keys()),
}), flush=True)
これで appCommandPayload がイベントに含まれているか、キー名が正しいかを確認しました。
Step 2: ペイロード内部の確認
payload = chat["appCommandPayload"]
print(json.dumps({
"debug_payload_keys": list(payload.keys()),
}), flush=True)
space, message, appCommandMetadata が含まれているかを確認。
Step 3: 型と値の確認
cmd = payload.get("appCommandMetadata", {})
print(json.dumps({
"debug_cmd_id": cmd.get("appCommandId"),
"debug_cmd_id_type": type(cmd.get("appCommandId")).__name__,
"debug_str_match": str(cmd.get("appCommandId")) == "1",
}), flush=True)
ここで float 型であることが判明し、根本原因を特定できました。
重要: Pythonの logging モジュール(logger.info() 等)はCloud Runでデフォルトのログレベル設定では出力されないことがあります。確実にCloud Loggingに出力するには print(json.dumps(...), flush=True) が堅実です。flush=True を忘れるとバッファリングされてログが見えないこともあります。
また、構造化JSONで出力すると、Cloud Loggingで jsonPayload として自動パースされ、フィルタリングやクエリが容易になります。
# Cloud Loggingでの確認コマンド
gcloud logging read \
"resource.type=\"cloud_run_revision\" \
AND resource.labels.service_name=\"YOUR_SERVICE\"" \
--limit=10 \
--format="json(textPayload,jsonPayload,timestamp)" \
--project=YOUR_PROJECT
gcloud functions logs read はJSONペイロードを切り詰めるので、構造化ログの確認には gcloud logging read を使ってください。
まとめ
Google Chat Workspace Add-onsでスラッシュコマンドを実装する際の主な学びです。
appCommandIdはfloatにデシリアライズされる場合がある。str()で比較する場合はint()でキャストしてから。整数同士の比較がベター- 同期レスポンス(
hostAppDataAction)が表示されない場合は、Chat APIによる非同期送信を検討する。Cloud Runの--no-cpu-throttlingと組み合わせれば、バックグラウンドスレッドでメッセージを送信できる - 「ドキュメント通りに動かない」時は、段階的にデバッグログを仕込んで型・値・構造を確認する。本番でしか再現しない問題は
print(json.dumps(...), flush=True)が最も確実 - 一般質問のガードはプロンプト調整+ウェルカムカードのハイブリッドが低コストで効果的。事前RAG分類器は低ボリュームの社内ボットではオーバーエンジニアリング





