Korat

お客さまに応対するチャットボットを作る

このドキュメントは、まずコードを書かない店舗オーナーでも進められるように書き、ページの後半で開発者向けの技術的な詳細に入ります。読みたいところへ飛ぶには、右の目次をお使いください。

ボットとは何か、何に使えるか

ボットとは、画面の向こうで人が入力しているわけではない、Korat のもうひとつの「利用者アカウント」です。あなた自身のプログラムがリクエストを送ると、ボットが招待されているチャットルームにメッセージが現れます。実際の使い道はたとえば次のとおりです。

  • 新しい注文をチャットに通知する——店舗の販売システムに新しい注文が入った ⇒ ボットがお客さまとのチャットルームやスタッフのルームにすぐ書き込みます
  • 在庫切れ間近を知らせる——在庫が設定した数を下回った ⇒ ボットが店舗のスタッフのルームに警告を書き込みます
  • サーバーやシステムの状態を知らせる——自前の死活監視スクリプトが実行を終えた ⇒ ボットが情シスの見ているグループのルームに結果を報告します

ボットにできないこと(一時的な制約ではなく、データベースから来る実際の事実です)。

  • ボットはルームのメッセージを読めません——送信専用です。ただし、ご自身で受信用のウェブフックを設定した場合は別です(ページ後半の技術リファレンスをご覧ください)。なお、ウェブフックを設定しても、現在は実際に何も送信されません。キューを取り出して送信するタイマーが、本番環境でまだ設定されていないためです
  • ボットは自分でルームに入れません——すでにそのルームにいる人(またはそのルームを管理する権限を持つ人)が招待する必要があります
  • 1つのボットが同時に持てる有効なトークンは2枚までで、1つのアカウントで作れるボットは最大5つです

始め方——4つの手順

  1. 1ボットを作る。ボットの名前とユーザー名を決めます(ユーザー名は orderbot のように bot で終わる必要があります)。自分個人ではなく店舗のものにしたい場合は、作成時に店舗を選べます
  2. 2トークンをコピーする。作成が終わったときに一度だけトークンが表示されます。すぐ安全な場所にコピーして保管してください。画面を閉じると、実際の値を二度と取り出せません(新しく発行し直すしかありません)
  3. 3ボットをルームに招待する。ボットに発言させたいチャットルーム、グループのルーム、スタッフのルームを選びます。招待するには、あなた自身がそのルームにいる(またはそのルームを管理する権限を持つ)必要があります
  4. 4最初のメッセージを送る。手順2のトークンを使って Bot API を呼びます。実際に試せる例は次の項目にあります

手順1〜3は Korat アプリの中で行います

メニューは設定 › アカウント ›「マイボット」にあります。そこでボットの作成、トークンの発行・更新・失効、チャットルーム/グループのルーム/スタッフのルームへの招待まで、1行もコードを書かずに行えます。このメニューは Korat アプリの次のバージョンで登場します。現在ダウンロードできるバージョンにはまだありません。設定を開いても「マイボット」が見当たらない場合は、アプリの更新をお待ちください。それまでは、Supabase の RPC を直接呼んで手順1〜3を行うこともできます(ページ後半の技術リファレンスをご覧ください)。

🔒 トークンはボットのパスワードです

このトークンを持っている人は、誰でもすぐにボットの名前でメッセージを送れます。チャット、画像、暗号化されていないメールでトークンを送らないでください。ご自身のサーバーやシークレット保管庫にだけ置いてください。Korat の担当者がトークンを求めることは決してありません。チャットでもメールでも電話でもです。Korat のチームを名乗る相手がトークンを求めてきたら、詐欺とみなしてお問い合わせからすぐご連絡ください。トークンが漏れた場合は、アプリからその1枚をすぐ失効させられます(取り消せないので、新しく発行し直す必要があります)。

例——最初のメッセージを送る

宛先は POST https://koratland.com/api/bot/send の1つだけです。リクエストヘッダーにトークンを入れ、どのルームに何を言うかを伝えます。

curl の場合(ターミナルに慣れているかた向け)

curl -X POST "https://koratland.com/api/bot/send" \
  -H "X-Bot-Token: kbot_12_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "chat", "chat_id": 9931, "text": "新しいご注文が入りました" }'

kbot_12_xxxxxxxxxxxxxxxxxxxx を実際のボットのトークンに、9931 をボットを招待したルームの番号に置き換えてください(ルームの番号は、アプリのチャットのリンクか、「マイボット」画面のルーム一覧で確認できます)。

コードを書かない場合(Google Apps Script)

自分のサーバーがなくても、Google アカウント1つで Google Apps Script を無料で使えます。script.google.com を開き → 新しいプロジェクト → 次のコードを貼り付け →「実行」を押します。初回はインターネットへのアクセス許可を求められます(自分自身のスクリプトなので、許可して問題ありません)。

function sendKoratMessage() {
  var url = "https://koratland.com/api/bot/send";
  var payload = {
    kind: "chat",
    chat_id: 9931,              // ご自身のルーム番号に置き換えてください
    text: "新しいご注文が入りました"   // 送りたい文面に置き換えてください
  };
  var options = {
    method: "post",
    contentType: "application/json",
    headers: { "X-Bot-Token": "kbot_12_xxxxxxxxxxxxxxxxxxxx" },  // ご自身のボットのトークンに置き換えてください
    payload: JSON.stringify(payload)
  };
  var res = UrlFetchApp.fetch(url, options);
  Logger.log(res.getContentText());  // 結果は「実行ログ」(Execution log)で確認できます
}

「実行」を1回押すだけですぐ試せます。自動で送りたい場合、たとえば注文を記録している Google スプレッドシートに新しい行が追加されるたびに送りたいときは、Apps Script のトリガー(Triggers)を使い、シートが編集されたとき(onEdit)や、何分おきかにこの関数を呼ぶよう設定します。

同じやり方は、n8n、Make、Zapier のように「HTTP request」や「Webhook」のアクションをすでに持つ自動化ツールでも使えます。同じ URL に POST するよう設定し、X-Bot-Token のヘッダーと、上の例と同じ JSON のボディを指定してください。

よくある問題

こう出る(reasonどういう意味かどう直すか
not_invitedボットがこのルームにまだ招待されていません(あるいは招待されたあと外されました)「マイボット」の画面か bot_invite から、ボットをもう一度そのルームに招待してください
invite_staleボットをこのルームに招待した人が、そのルームでの権限を失いました(グループを退会した、役割を下げられた、スタッフのルームの管理権限を外された、ルームが閉じられた)⇒ 誰かがボットを外したわけでもないのに、ボットが自分から黙りますそのルームで実際にまだ権限を持っている人に、ボットをもう一度招待してもらってください。権限は常に最後に招待した人を基準に計算されます
conversation_not_openこのルームは店舗の受信箱で、お客さまがまだ一度も話しかけていません。店舗側(店舗のボットも含みます)から先に話しかけることはできませんお客さまから話しかけられるのを待ってから、ボットに返信させてください
rate_limitedボットの送信が、設定された上限(毎分または毎日)を超えましたretry_after_sec に示された時間だけ待ってから送り直してください。これより高い上限が必要な場合はお問い合わせからチームにご連絡ください
blockedそのチャットルームの利用者がこのボットをブロックしています利用者側を変えることはできません。必要であれば別のルームやボットをお使いください
ボットが話せていたのに、急に黙ったもっとも多い原因は、上の invite_stale です。ボットが壊れたわけではありませんボットをそのルームに招待した人が、まだルームにいて権限を持っているかを確認してください

エラーコードの全一覧は、下の技術リファレンスの表にあります。

技術リファレンス(開発者向け)

以下は、ご自身で Bot API を呼ぶプログラムを書くかた向けです。すべてのパラメーターの詳細、エラーコードの完全な表、受信用のウェブフックを扱います。

手順1〜3を RPC から直接行う(アプリを使わずに)

お使いのアプリのバージョンに「マイボット」の画面がまだない場合や、自動化したい場合は、ログイン中の利用者のアクセストークン(ボットのトークンではありません)を使い、PostgREST 経由で Supabase の RPC を直接呼びます。3つの手順です。

1. ボットを作る——bot_create

curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_create" \
  -H "apikey: <SUPABASE_ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "p_name": "通知ボット",
    "p_username": "notifybot",
    "p_business_id": null,
    "p_about": "注文の状況をお知らせします"
  }'

p_usernamea–z 0–9 _ で4〜31文字、かつ必ずbot で終わる必要があります(正規表現 ^[a-z][a-z0-9_]{2,29}bot$)。ボットを店舗のものにする場合は p_business_id を渡します。呼び出す人はその店舗のメンバーで、bot.manage の権限を持っている必要があります。成功すると token が返りますが、そのトークンの生涯で実際の値が見えるのはこの一度だけです。データベースは sha256 のハッシュしか保存しません。取り逃したら新しく発行し直すしかなく(bot_rotate_token)、実際の値を再び読む方法はありません。

{ "ok": true, "bot_id": 12, "user_id": 4821, "username": "notifybot", "token": "kbot_12_…" }

2. ボットをチャットルームに招待する——bot_invite

そのルームに実際にいる人(またはそのルームが店舗の受信箱である場合は店舗のスタッフ)で、かつこのボットを管理できる(can_manage_bot)必要があります。p_targetchats.id です。

curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_invite" \
  -H "apikey: <SUPABASE_ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{ "p_bot": 12, "p_kind": "chat", "p_target": 9931 }'

2a. グループのルーム/スタッフのルームに招待する——チャットルームより権限が厳しい

p_kind: "community_room" または "business_room" を、p_targetcommunity_rooms.idbusiness_rooms.id を添えて送れるようになっています。ただし招待する人は、そのルームの意味に見合った権限の関門を通る必要があり、単なるメンバーであるだけでは足りません。

  • グループのルーム——招待する人は、そのルーム自身の規則(read_rolepost_role)に照らして実際にそのルームに投稿できる必要があります。普通の雑談のルームならメンバーが招待できますが、管理者専用に設定されたお知らせや裏方のルームでは、グループのオーナーか管理者である必要があります
  • 店舗のスタッフのルーム——そのルーム本来の関門(閲覧権限・投稿権限・受信者の規則)をすべて満たしたうえで、さらに teamroom.manage のケイパビリティが常に必要です。会社の内部のルームにボットを連れてくることは「ルームに投稿する」ではなく「ルームを管理する」とみなされるからです

どちらの場合も、そのボットの管理者である(can_manage_bot)ことが常に必要です。共有のルームは、誰かが他人のボットを引っ張ってきて発言させてよい場所ではありません。

🔴 invite_stale——使う前にもっとも知っておくべきこと

招待した人の権限は招待の時点だけで検査されるのではありませんbot_send は、メッセージを送るたびに、同じ招待者の名前でルームの規則を問い直します。⇒ 招待者がグループを退会した/役割を下げられた/teamroom.manage を外された/ルームが閉じられた時点で、ボットの次のメッセージはすぐ reason: "invite_stale" を受け取ります。誰かが外したわけでもないのに、ボットは自分から黙ります。バックグラウンドのジョブも、漏れなく発火させるべきトリガーもありません。純粋に権限を計算し直した結果です。

メッセージを送る——POST /api/bot/send

トークンの渡し方は2通りあり、どちらか一方を選びます。

# 方法1——Authorization: Bearer
curl -X POST "https://koratland.com/api/bot/send" \
  -H "Authorization: Bearer kbot_12_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-9931-shipped" \
  -d '{ "kind": "chat", "chat_id": 9931, "text": "商品を発送しました" }'
# 方法2——X-Bot-Token
curl -X POST "https://koratland.com/api/bot/send" \
  -H "X-Bot-Token: kbot_12_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "chat", "chat_id": 9931, "text": "商品を発送しました" }'

トークンをまったく送らないと 401 が返ります。

{ "ok": false, "reason": "bad_token", "detail": "トークンは Authorization: Bearer … か X-Bot-Token でのみ送ってください" }

ボディのフィールド

フィールド必須意味
kindstringいいえ(既定は "chat"宛先の種類を決めます。実際に使える値は3つです。"chat" · "community_room" · "business_room"。それ以外の値は、データベースから直接 reason: "bad_target" が返ります(黙って "chat" にフォールバックすることはありません。この値を固定していた以前の不具合は修正済みです)
chat_idinteger > 0はい(または target_idボットが招待済みの宛先ルームの id です。kind: "chat" なら chats.id"community_room" なら community_rooms.id"business_room" なら business_rooms.id(ボディのフィールド名は常に chat_idtarget_id のままで、kind ごとに別名になることはありません)
textstringはいメッセージ本文です。空にはできず、レジストリの text_max_message が定める上限を超えられません(上限は調整可能で固定の数値ではありません。too_long のエラーに付く max の値をご覧ください)
client_keystringいいえメッセージの重複を防ぐキーで、Idempotency-Key ヘッダーの代わりに使えます。8〜128文字で、使える文字は A–Z a–z 0–9 _ . : - だけです

個人のチャットと店舗の受信箱——どのフィールドで決まるか

kind は宛先のテーブルの種類(チャットルーム/グループのルーム/店舗のスタッフのルーム)を決めます。一方、個人のチャットと店舗の受信箱kind では区別しません。どちらも同じ chats テーブルの行で、違うのは chats.business_id に値があるかどうかです(値がある=店舗の受信箱)。招待の権限の検査、利用者のブロックの検査、そして「店舗から先にお客さまへ話しかけられない(conversation_not_open)」という規則も、この列から読み取ります。送る chat_id は、すでに正しいルームである必要があり、API に「個人/店舗」を選ぶパラメーターはありません。

Idempotency-Key と送信の上限

標準の Idempotency-Key ヘッダーか、ボディの client_key フィールドの、どちらか一方を送ります。両方送る場合は値が一致していなければならず、一致しないと 422 idempotency_key_conflict になります。同じキー(同じルーム+同じキー)で同じリクエストを送り直すと、以前と同じ 200 の応答に "duplicate": true が付いて返り、メッセージが重複して作られることはありません。

上限は2層あります。IP ごと(Worker 自身で 120 リクエスト/分)とボットごと(既定で1つのボットにつき 20/分、1000/日。管理者が個別に調整できます)。どちらも 429 を返し、Retry-After ヘッダーに秒数が入ります(IP ごとは常に 60・ボットごとの毎分は 60・ボットごとの毎日は 3600)。

メッセージを受け取る——ウェブフック

ボットが招待済みのチャットルームに誰かがメッセージを書き込むと(かつ送信者がボット自身のアカウントでないとき。ボット自身のメッセージが自分に跳ね返るのを防ぎます)、Korat は設定された URL に POST を送ります。設定は、トークンを取得するときと同じく、ログイン中の利用者アカウントで、そのボットを管理する権限(can_manage_bot)を持って RPC から行います。

curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_set_webhook" \
  -H "apikey: <SUPABASE_ANON_KEY>" \
  -H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{ "p_bot": 12, "p_url": "https://your-server.example.com/korat-webhook" }'
{ "ok": true, "secret": "a1b2c3…", "note": "この secret が見えるのは一度だけです——署名の検証用に保管してください" }

URL は https:// でなければならず、内部・ループバック・リンクローカルのホスト(localhost127.0.0.110.0.0.0/8172.16.0.0/12192.168.0.0/16169.254.0.0/16、および各クラウドのメタデータのエンドポイントすべて)は使えません。設定の時点で拒否し、さらに送信の直前には毎回 DNS を実際に解決して再検査します。あとから DNS が別の場所を指すのを防ぐためです。secret が見えるのは、設定時と更新時の一度だけです。bot_rotate_webhook_secret(p_bot) でいつでも更新でき、bot_disable_webhook(p_bot)bot_enable_webhook(p_bot) で自分で無効・有効にでき、bot_webhook_status(p_bot) で状態を確認できます(secret は返しません)。

現在、実際に送信されるものはまだありません

送信のキュー(bot_webhook_deliveries)は正しく動作し、データベース上で検証済みです。配送経路のシークレット(BOT_WEBHOOK_DISPATCH_SECRET)もオーナーが設定済みです。しかし、キューを取り出して実際に送る仕組み(毎分 Worker を呼ぶ pg_cron)が本番環境でまだ設定されていません。ウェブフックを設定してメッセージをキューに積むことはできますが、pg_cron のタイマーが設定されるまで、あなたの受け口には何も届きません。

あなたの受け口に届くリクエストの形

POST /korat-webhook HTTP/1.1
Content-Type: application/json
X-Korat-Signature: sha256=<hex>
X-Korat-Timestamp: 1750000000000

{
  "event": "message.created",
  "chat_id": 9931,
  "message_id": 88213,
  "text": "赤い色のものはありますか",
  "time": "14:02",
  "sender": { "id": 4821, "username": "somchai_p", "name": "ソムチャイ P." }
}

これだけです。ボディには電話番号もメールアドレスも、誰のトークンも入りません。ボットが見えるのは、返信に必要な範囲のルーム・メッセージ・送信者の情報だけです。

信じる前に署名を検証する

X-Korat-SignatureHMAC-SHA256(secret, "<timestamp>." + rawBody) を16進数で表したものです。Node.js の例を示します。

const crypto = require("crypto");
function verify(rawBody, signatureHeader, timestampHeader, secret) {
  const age = Date.now() - Number(timestampHeader);
  if (!(age >= 0 && age < 5 * 60_000)) return false; // リプレイ対策: 5分以内であること
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(`${timestampHeader}.${rawBody}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}

8秒以内に 2xx を返せば送信成功とみなします。それ以外の応答(Korat が追跡しない 3xx のリダイレクトを含みます)や、まったく応答がない場合は失敗とみなし、再送のキューに入ります。

受け口が落ちているときの再送

失敗すると指数的に間隔を空けて(1、2、4、8、16、32、64、128分)、1つのメッセージにつき最大8回まで再送し、そのあとはそのメッセージの送信をあきらめます。受け口が15回連続で失敗した場合(複数のメッセージにまたがっても)、ウェブフックは自動的に無効化され、アプリでボットのオーナーに通知されます。死んだ受け口に永遠に送り続けることはありません。受け口の問題を直したら bot_enable_webhook で再び有効にできます。

エラーコードの全一覧

失敗したときの応答には常に {"ok": false, "reason": "…"} が入ります。判断に使うべきは HTTP のステータスではなく reason です。

HTTPreason出どころ意味と対処
401bad_tokenWorker / DBトークンを送っていない、トークンが誤っている・失効済み・ボットが無効。総当たりでの推測を防ぐため、意図的にすべて同じ文言を返します
403not_invitedDBボットがこのルームに一度も招待されていないか、外されています。先に bot_invite を呼んでください
403blockedDBルームの利用者がこのボットをブロックしています(店舗の受信箱ではないルームのみ)
403account_suspendedDBルームのどちらかの側のアカウントが停止されています。リクエストの誤りではなく権限の問題です
404no_such_chatDBchat_id が存在しません
404no_such_endpointWorker経路が誤っています。あるのは POST /api/bot/send だけです
405method_not_allowedWorkerPOST 以外のメソッドを使っています
409conversation_not_openDBこのルームは店舗の受信箱で、お客さまがまだ一度も話しかけていません。店舗側(ボットを含む)から先に話しかけることはできません
409chat_closedDBマッチング(出会い)のルームがメッセージの受付を終了しています
422empty_textDB空白を取り除くと text が空になります
422too_longDBメッセージが上限を超えています。一緒に返る max の値をご覧ください
422bad_targetDBkind"chat""community_room""business_room" のいずれでもありません
404no_such_roomDBchat_id に対応するグループ/スタッフのルームが存在しません(ルームが削除済みです)
409room_archivedDBこのルームは閉じられています。人もボットも投稿できません
403invite_staleDB招待者の権限が再計算され、通らなくなりました。招待者がグループを退会した/役割を下げられた/teamroom.manage を外された/ルームが閉じられた、のいずれかです。細かい理由は detail をご覧ください(not_a_memberroom_rolenot_teamroom_manager)。直すには、まだ権限のある人にボットをそのルームへ招待し直してもらう必要があります(bot_invite)。待つだけでは直りません
422bad_jsonWorkerリクエストのボディが正しい JSON ではありません
422bad_chat_idWorkerchat_idtarget_id は正の整数である必要があります
422bad_textWorkertext は文字列である必要があります
422idempotency_key_conflictWorkerIdempotency-Keyclient_key を同時に送り、値が一致していません。どちらか一方だけで十分です
422bad_client_keyWorker重複防止キーの形式が合っていません(8〜128文字、A–Z a–z 0–9 _ . : -
429rate_limitedWorker / DB上限を超えました。Retry-After ヘッダーと、windowretry_after_sec のフィールドをご確認ください
502database_errorWorkerデータベースは応答しましたが 2xx ではありませんでした。一緒に返る pg_codedetail は Postgres 自身のメッセージです
503service_key_not_configuredWorkerサーバー側でサービスキーが未設定です。呼び出し側ではなく Korat 側の問題です
504database_unreachableWorkerデータベースの呼び出しに失敗したか、時間切れになりました。safe_to_retry をご覧ください。安全に送り直せるのは、client_keyIdempotency-Key を一緒に送っている場合だけです

成功したときの応答

{ "ok": true, "duplicate": false, "message_id": 88213 }

duplicate: true は、このリクエストがすでに以前に送られていたことを意味します(client_keyIdempotency-Key による重複防止)。新しいメッセージは作られませんが、呼び出し側が求めた結果はすでに実際に起きているため、成功(200)として扱います。