SPIRALのPHPを利用して、予約フォームの登録をきっかけに
Web会議(Google Meet / Zoom)を自動作成し、
発行した会議URLを同じレコードへ書き戻すサンプルプログラムの実装方法を紹介します。
Web会議の発行までを自動化できるため、運用側の手作業(会議作成・URL転記)を削減できます。
また、レコード更新が完了したタイミングで サンクスメール配信API を呼び出し、
メール配信処理へつなげる構成です。
注意点
・ 本番運用では、APIキーやOAuthクライアントシークレット等をソースに直書きせず、
環境変数や安全な保管方法を検討してください。
・ APIのレート制限、トークン期限、ネットワーク制限(外部通信可否)にご注意ください。
実装の概要
今回のプログラムは、SPIRALのフォームのサンキューページのPHPから起動され、以下の流れで動作します。
1.
$SPIRAL->getParamで登録されたレコードを取得
2.
webMTGToolを見て Google Meet / Zoom のどちらかを作成
3. 発行した
meeting_url等を SPIRAL API(内部呼び出し)で同一レコードへ書き戻し
4. 更新完了後に
$apiCommunicator->request('deliver_thanks', 'send', $request); を呼び出してサンクスメールを送信事前準備
予約DB(会議URLを書き戻すDB)に、最低限以下のフィールド(識別名)を用意してください。
サンクスメールについては別途メール送信だけを行うダミーフォームを作成して、
サンクスメール配信設定を行いそのメール配信IDを控えてください。
| 用途 | 識別名 | 型(例) |
|---|---|---|
| レコードID | recordId | 数字・記号・アルファベット(32byte) ※主キー |
| 予約者名 | name | テキスト |
| メール | メールアドレス | |
| 会議ツール | webMTGTool | セレクト(例: 1=Google Meet, 2=Zoom)必須 |
| 開始日時 | start_datetime | 日付(○年○月○日 ○時○分○秒)(任意:招待先へのメール通知の差し替えに使用) |
| 終了日時 | end_datetime | 日付(○年○月○日 ○時○分○秒)(任意:招待先へのメール通知の差し替えに使用) |
| 会議URL(書き戻し) | meeting_url | テキスト(例: 128byte以上)必須 |
| 会議パスワード(書き戻し) | meeting_password | テキスト |
| 外部側ID | meeting_id | テキスト |
設定方法
下記PHPの先頭にある定数を、環境に合わせて設定します。
認証情報(Google / Zoom)について
本プログラムの動作には、Google Meet と Zoom の 認証情報の取得・設定 が必要です。
取得画面や手順は下記リンクを参考にご確認ください。
https://developers.google.com/workspace/meet/api/guides/overview
・ OAuth 2.0(Google)
https://developers.google.com/identity/protocols/oauth2
・ Zoom Server-to-Server OAuth(Zoom)
https://developers.zoom.us/docs/internal-apps/
フォーム>サンキューページ>ソース編集
<?php
// ------------------------------------------------------------
// 読み取りフィールド(DB側の識別名):
// - recordId 自動発番 主キー
// - name
// - mail
// - webMTGTool (1: Google Meet, 2: Zoom)
// - start_datetime (例: 2025年12月30日1時0分0秒)
// - end_datetime (例: 2025年12月30日2時0分0秒)
//
// 更新フィールド(DB側の識別名):
// - meeting_url
// - meeting_password
// - meeting_id
// ------------------------------------------------------------
// --- SPIRAL(会議URLを書き戻すためのAPI設定) ---
define('API_TOKEN_TITLE', '');
define('DB_TITLE', '');
// 更新対象レコードを特定するためのキー(DBの主キー識別名)。
define('ID_FIELD_TITLE', 'recordId');
// サンクスメール配信設定の「メール配信ID(rule_id)」です。サンクスメールを使用しない場合は空で問題ありません。
define('THANKS_RULE_ID', '');
//原則変更不要
define('DEFAULT_TIMEZONE', 'Asia/Tokyo');
// --- Google Meet(OAuth refresh_token方式) ---
// Meet REST API の spaces.create を呼び出し、meetingUri を取得します。
// Google Cloudで作成したOAuthクライアントID/シークレット
define('GOOGLE_OAUTH_CLIENT_ID', '');
define('GOOGLE_OAUTH_CLIENT_SECRET', '');
// 初回手動で取得したrefresh_token(scope: https://www.googleapis.com/auth/meetings.space.created)
define('GOOGLE_OAUTH_REFRESH_TOKEN', '');
// Zoom Marketplaceで "Server-to-Server OAuth" アプリを作成。スコープ設定 meeting:write:admin
define('ZOOM_ACCOUNT_ID', '');
define('ZOOM_CLIENT_ID', '');
define('ZOOM_CLIENT_SECRET', '');
function updateSpiralRecordById(string $recordId, array $updateData): int
{
global $SPIRAL;
if (!isset($SPIRAL)) {
throw new Exception('SPIRAL runtime is not available.');
}
if (DB_TITLE === '') {
throw new Exception('DB_TITLE is empty.');
}
if (ID_FIELD_TITLE === '') {
throw new Exception('ID_FIELD_TITLE is empty.');
}
if (API_TOKEN_TITLE !== '') {
$SPIRAL->setApiTokenTitle(API_TOKEN_TITLE);
}
$db = $SPIRAL->getDataBase(DB_TITLE);
$db->addEqualCondition(ID_FIELD_TITLE, $recordId);
$count = $db->doUpdate($updateData);
return (int)$count;
}
function sendThanksMail(string $ruleId, string $recordId): void
{
global $SPIRAL;
if (!isset($SPIRAL)) {
throw new Exception('SPIRAL runtime is not available.');
}
$apiCommunicator = $SPIRAL->getSpiralApiCommunicator();
$request = new SpiralApiRequest();
$request->put('rule_id', $ruleId);
$request->put('id', $recordId);
$response = $apiCommunicator->request('deliver_thanks', 'send', $request);
if ((int)$response->get('code') !== 0) {
throw new Exception('deliver_thanks/send error (code:' . $response->get('code') . ')');
}
}
function parseRecordDateTime(string $value, string $timezone = DEFAULT_TIMEZONE): DateTimeImmutable
{
$trimmed = trim($value);
$jpPattern = '/^(\d{4})年(\d{1,2})月(\d{1,2})日(\d{1,2})時(\d{1,2})分(\d{1,2})秒$/u';
if (preg_match($jpPattern, $trimmed, $m) === 1) {
$normalized = sprintf(
'%04d-%02d-%02d %02d:%02d:%02d',
(int)$m[1],
(int)$m[2],
(int)$m[3],
(int)$m[4],
(int)$m[5],
(int)$m[6]
);
$dt = new DateTimeImmutable($normalized, new DateTimeZone($timezone));
return $dt->setTimezone(new DateTimeZone($timezone));
}
$dt = new DateTimeImmutable($trimmed);
return $dt->setTimezone(new DateTimeZone($timezone));
}
class MeetingFactory
{
public static function create($type, array $config)
{
switch (strtolower((string)$type)) {
case '1':
case 'google':
case 'google meet':
return new GoogleMeetClient($config['google']);
case '2':
case 'zoom':
return new ZoomClient($config['zoom']);
default:
throw new Exception('Unsupported meeting type: ' . $type);
}
}
}
class GoogleMeetClient
{
private array $config;
private ?string $accessToken = null;
public function __construct(array $config)
{
$this->config = $config;
}
public function createMeeting(string $subject, DateTimeImmutable $startTime, DateTimeImmutable $endTime): array
{
$token = $this->getAccessToken();
$url = 'https://meet.googleapis.com/v2/spaces';
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(new stdClass(), JSON_UNESCAPED_UNICODE));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 400) {
throw new Exception('Google Meet API Error: ' . $response);
}
$data = json_decode($response, true);
$joinUrl = $data['meetingUri'] ?? null;
if (!$joinUrl) {
throw new Exception('Failed to generate Google Meet URL');
}
return [
'join_url' => $joinUrl,
'password' => '',
'meeting_id' => $data['name'] ?? null,
];
}
private function getAccessToken(): string
{
if ($this->accessToken) {
return $this->accessToken;
}
if (!empty($this->config['oauth_refresh_token'])) {
$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'client_id' => $this->config['oauth_client_id'] ?? '',
'client_secret' => $this->config['oauth_client_secret'] ?? '',
'refresh_token' => $this->config['oauth_refresh_token'] ?? '',
'grant_type' => 'refresh_token',
]));
$response = curl_exec($ch);
$data = json_decode($response, true);
curl_close($ch);
if (!is_array($data)) {
throw new Exception('Google Auth Error: invalid response');
}
if (isset($data['error'])) {
$desc = $data['error_description'] ?? $data['error'];
throw new Exception('Google Auth Error: ' . $desc);
}
$this->accessToken = (string)($data['access_token'] ?? '');
if ($this->accessToken === '') {
throw new Exception('Google Auth Error: access_token is empty');
}
return $this->accessToken;
}
throw new Exception('Google auth config is missing. Set oauth_client_id/oauth_client_secret/oauth_refresh_token.');
}
}
class ZoomClient
{
private array $config;
private ?string $accessToken = null;
public function __construct(array $config)
{
$this->config = $config;
}
public function createMeeting(string $subject, DateTimeImmutable $startTime, DateTimeImmutable $endTime): array
{
$token = $this->getAccessToken();
$url = 'https://api.zoom.us/v2/users/me/meetings';
$durationMinutes = (int)round(($endTime->getTimestamp() - $startTime->getTimestamp()) / 60);
if ($durationMinutes <= 0) {
throw new Exception('Invalid meeting duration');
}
$data = [
'topic' => $subject,
'type' => 2,
'start_time' => $startTime->format('Y-m-d\TH:i:s'),
'duration' => $durationMinutes,
'timezone' => DEFAULT_TIMEZONE,
'settings' => [
'join_before_host' => true,
'waiting_room' => false,
],
];
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 400) {
throw new Exception('Zoom API Error: ' . $response);
}
$result = json_decode($response, true);
return [
'join_url' => $result['join_url'] ?? '',
'password' => $result['password'] ?? '',
'meeting_id' => $result['id'] ?? null,
];
}
private function getAccessToken(): string
{
if ($this->accessToken) {
return $this->accessToken;
}
$auth = base64_encode($this->config['client_id'] . ':' . $this->config['client_secret']);
$url = 'https://zoom.us/oauth/token?grant_type=account_credentials&account_id=' . rawurlencode((string)$this->config['account_id']);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, '');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Basic ' . $auth,
'Content-Type: application/x-www-form-urlencoded',
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
$data = json_decode((string)$response, true);
curl_close($ch);
if ($response === false) {
throw new Exception('Zoom Auth cURL Error: ' . $curlErr);
}
if ($httpCode < 200 || $httpCode >= 300) {
throw new Exception('Zoom Auth Error (HTTP ' . $httpCode . '): ' . $response);
}
if (!is_array($data)) {
throw new Exception('Zoom Auth Error: invalid JSON response: ' . $response);
}
if (isset($data['error'])) {
throw new Exception('Zoom Auth Error: ' . ($data['reason'] ?? $data['error']));
}
$this->accessToken = (string)($data['access_token'] ?? '');
if ($this->accessToken === '') {
throw new Exception('Zoom Auth Error: access_token is empty: ' . $response);
}
return $this->accessToken;
}
}
$config = [
'google' => [
'oauth_client_id' => GOOGLE_OAUTH_CLIENT_ID,
'oauth_client_secret' => GOOGLE_OAUTH_CLIENT_SECRET,
'oauth_refresh_token' => GOOGLE_OAUTH_REFRESH_TOKEN,
],
'zoom' => [
'account_id' => ZOOM_ACCOUNT_ID,
'client_id' => ZOOM_CLIENT_ID,
'client_secret' => ZOOM_CLIENT_SECRET,
],
];
$recordId = '';
$meetingUrlCurrent = '';
$meetingIdCurrent = '';
try {
if (!isset($SPIRAL)) {
throw new Exception('SPIRAL runtime is not available.');
}
$recordId = '';
if (method_exists($SPIRAL, 'getContextByFieldTitle')) {
$recordId = (string)($SPIRAL->getContextByFieldTitle(ID_FIELD_TITLE) ?? '');
}
if ($recordId === '') {
throw new Exception('Record ID is missing.');
}
$systemId = '';
if (method_exists($SPIRAL, 'getContextByFieldTitle')) {
$systemId = (string)($SPIRAL->getContextByFieldTitle('id') ?? '');
}
if ($systemId === '') {
throw new Exception('Record ID is missing.');
}
$meetingUrlCurrent = (string)($SPIRAL->getParam('meeting_url') ?? '');
$meetingIdCurrent = (string)($SPIRAL->getParam('meeting_id') ?? '');
if ($meetingUrlCurrent !== '' || $meetingIdCurrent !== '') {
return;
}
$toolType = (string)($SPIRAL->getParam('webMTGTool') ?? '');
$username = (string)($SPIRAL->getParam('name') ?? '');
$startRaw = (string)($SPIRAL->getParam('start_datetime') ?? '');
$endRaw = (string)($SPIRAL->getParam('end_datetime') ?? '');
if ($toolType === '' || $startRaw === '' || $endRaw === '') {
throw new Exception('Required fields are missing.');
}
$startAt = parseRecordDateTime($startRaw);
$endAt = parseRecordDateTime($endRaw);
if ($endAt <= $startAt) {
throw new Exception('Invalid start/end datetime.');
}
$subject = '予約会議: ' . $username . '様';
$client = MeetingFactory::create($toolType, $config);
$meetingData = $client->createMeeting($subject, $startAt, $endAt);
$joinUrl = (string)($meetingData['join_url'] ?? '');
if ($joinUrl === '') {
throw new Exception('Join URL is empty.');
}
$password = (string)($meetingData['password'] ?? '');
$meetingId = $meetingData['meeting_id'] ?? null;
$updateData = [
'meeting_url' => $joinUrl,
'meeting_password' => $password,
];
if ($meetingId !== null && $meetingId !== '') {
$updateData['meeting_id'] = (string)$meetingId;
}
$updatedCount = updateSpiralRecordById($recordId, $updateData);
if ($updatedCount <= 0) {
throw new Exception('DB update failed (updatedCount=0). Check DB_TITLE and ID_FIELD_TITLE.');
}
if (THANKS_RULE_ID !== '') {
sendThanksMail(THANKS_RULE_ID, $systemId);
}
} catch (Exception $e) {
try {
if (isset($SPIRAL)) {
if ($recordId !== '' && $meetingUrlCurrent === '' && $meetingIdCurrent === '') {
updateSpiralRecordById($recordId, [
'meeting_url' => '',
'meeting_password' => '',
]);
}
}
} catch (Exception $ignored) {
}
throw $e;
}
// --- SPIRAL(会議URLを書き戻すためのAPI設定) ---
define('API_TOKEN_TITLE', '');
define('DB_TITLE', '');
// 更新対象レコードを特定するためのキー(DBの主キー識別名)。
define('ID_FIELD_TITLE', 'recordId');
// サンクスメール配信設定の「メール配信ID(rule_id)」です。サンクスメールを使用しない場合は空で問題ありません。
define('THANKS_RULE_ID', '');
//原則変更不要
define('DEFAULT_TIMEZONE', 'Asia/Tokyo');
// --- Google Meet(OAuth refresh_token方式) ---
// Meet REST API の spaces.create を呼び出し、meetingUri を取得します。
// Google Cloudで作成したOAuthクライアントID/シークレット
define('GOOGLE_OAUTH_CLIENT_ID', '');
define('GOOGLE_OAUTH_CLIENT_SECRET', '');
// 初回手動で取得したrefresh_token(scope: https://www.googleapis.com/auth/meetings.space.created)
define('GOOGLE_OAUTH_REFRESH_TOKEN', '');
// Zoom Marketplaceで "Server-to-Server OAuth" アプリを作成。スコープ設定 meeting:write:admin
define('ZOOM_ACCOUNT_ID', '');
define('ZOOM_CLIENT_ID', '');
define('ZOOM_CLIENT_SECRET', '');
エラーハンドリング(失敗時の動き)
会議作成やAPI呼び出しで例外が発生した場合は、catchで「会議未発行」の状態に戻すために
meeting_urlを空に書き戻します。
ただし、既に
meeting_url/
meeting_idが入っている場合は、誤って消さないように空更新をスキップします。
実行結果
サンキューページで本プログラムが実行されると、対象レコードに以下が反映されます。
meeting_urlに会議参加URL(Google Meet: meetingUri / Zoom: join_url)
・
meeting_password(Zoomの場合に設定される可能性があります)
・ 必要に応じて
meeting_id(Google: spaces/xxx / Zoom: meeting id)
・ 更新完了後に
$apiCommunicator->request('deliver_thanks', 'send', $request); のAPIが実行されます(メール送信)まとめ
SPIRALのDBへ書き戻すサンプルを紹介しました。
さらに更新完了後にアクション実行APIを呼び出すことで、
通知やワークフローなどの後続処理まで一気通貫で自動化できます。
不具合がある場合は、エラーメッセージ(HTTPコードとレスポンス)を確認し、
APIキー・OAuth設定・スコープ・権限を見直してください。