SPIRALのDBトリガーを利用して、予約レコードの登録・更新をきっかけに
Web会議(Google Meet / Zoom)を自動作成し、
発行した会議URLを同じレコードへ書き戻すサンプルプログラムの実装方法を紹介します。
Web会議の発行までを自動化できるため、運用側の手作業(会議作成・URL転記)を削減できます。
また、レコード更新が完了したタイミングで アクション実行API を呼び出し、
後続処理(メール送信など)へつなげる構成です。
注意点
・ 本番運用では、APIキーやOAuthクライアントシークレット等をソースに直書きせず、
環境変数や安全な保管方法を検討してください。
・ APIのレート制限、トークン期限、ネットワーク制限(外部通信可否)にご注意ください。
・ DBトリガーは同時実行の可能性があるため、同一レコードの二重発行を避ける設計が必要です。
(本コードでは
meeting_urlが既に入っていれば処理を終了します)。
実装の概要
今回のプログラムは、SPIRALのDBトリガーから起動され、以下の流れで動作します。
1. DBトリガーで
$SPIRAL->getRecord()から更新対象レコードを取得
2.
webMTGToolを見て Google Meet / Zoom のどちらかを作成
3. 発行した
meeting_url等を SPIRAL API(PATCH)で同一レコードへ書き戻し
4. 更新完了後に
/apps/{appId}/actions/{actionId}/run を呼び出してアクションを実行事前準備
予約DB(会議URLを書き戻すDB)に、最低限以下のフィールド(識別名)を用意してください。
| 用途 | 識別名 | 型(例) |
|---|---|---|
| 予約者名 | name | テキスト |
| メール | メールアドレス | |
| 会議ツール | webMTGTool | セレクト(例: 1=Google Meet, 2=Zoom)必須 |
| 開始日時 | start_datetime | 日時(任意:招待先へのメール通知の差し替えに使用) |
| 終了日時 | end_datetime | 日時(任意:招待先へのメール通知の差し替えに使用) |
| 会議URL(書き戻し) | meeting_url | テキスト必須 |
| 会議パスワード(書き戻し) | 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/
DB>トリガ>登録トリガ>非同期アクション>PHP実行
<?php
// ------------------------------------------------------------
// 読み取りフィールド(DB側の識別名):
// - name
// - mail
// - webMTGTool (1: Google Meet, 2: Zoom)
// - start_datetime (例: 2025-12-29T16:00:00Z)
// - end_datetime (例: 2025-12-29T17:00:00Z)
//
// 更新フィールド(DB側の識別名):
// - meeting_url
// - meeting_password
// - meeting_id
// ------------------------------------------------------------
// --- SPIRAL(会議URLを書き戻すためのAPI設定) ---
define('API_URL', 'https://api.spiral-platform.com/v1');
// SPIRAL管理画面で発行したAPIキー
define('API_KEY', '');
// アプリロールが必要な場合のみ指定(空なら未指定)
define('APP_ROLE', '');
// 対象アプリID / DB ID(会議URLを書き戻すDB)
define('APP_ID', '');
define('DB_ID', '');
define('ACTION_ID', '');
//原則変更不要
define('SPIRAL_API_VERSION', '1.1');
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 apiRequest(string $method, string $path, ?array $data = null): array
{
$headers = [
'Authorization:Bearer ' . API_KEY,
'X-Spiral-Api-Version: ' . SPIRAL_API_VERSION,
'Content-Type:application/json',
];
if (APP_ROLE !== '') {
$headers[] = 'X-Spiral-App-Role: ' . APP_ROLE;
}
$url = rtrim(API_URL, '/') . $path;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
if ($data !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data, JSON_UNESCAPED_UNICODE));
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
if ($response === false) {
throw new Exception('cURL Error: ' . $curlErr);
}
$decoded = json_decode($response, true);
if ($httpCode < 200 || $httpCode >= 300) {
throw new Exception('SPIRAL API Error (HTTP ' . $httpCode . '): ' . $response);
}
return is_array($decoded) ? $decoded : [];
}
function updateSpiralRecord(string $recordId, array $updateData): array
{
$path = '/apps/' . APP_ID . '/dbs/' . DB_ID . '/records/' . $recordId;
return apiRequest('PATCH', $path, $updateData);
}
function runSpiralAction(string $actionId, string $recordId): array
{
$path = '/apps/' . APP_ID . '/actions/' . $actionId . '/run';
return apiRequest('POST', $path, [
'recordId' => (string)$recordId,
]);
}
function parseRecordDateTime(string $value, string $timezone = DEFAULT_TIMEZONE): DateTimeImmutable
{
$dt = new DateTimeImmutable($value);
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,
],
];
try {
if (!isset($SPIRAL)) {
throw new Exception('SPIRAL runtime is not available.');
}
$record = $SPIRAL->getRecord();
$item = $record['item'] ?? null;
if (!is_array($item)) {
throw new Exception('Record is empty.');
}
$recordId = (string)($item['_id'] ?? '');
$revision = (string)($item['_revision'] ?? '');
if ($recordId === '') {
throw new Exception('Record ID is missing.');
}
if ($revision === '') {
throw new Exception('Record revision is missing.');
}
if (!empty($item['meeting_url']) || !empty($item['meeting_id'])) {
return;
}
$toolType = (string)($item['webMTGTool'] ?? '');
$username = (string)($item['name'] ?? '');
$startRaw = (string)($item['start_datetime'] ?? '');
$endRaw = (string)($item['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 = [
'_revision' => $revision,
'meeting_url' => $joinUrl,
'meeting_password' => $password,
];
if ($meetingId !== null && $meetingId !== '') {
$updateData['meeting_id'] = (string)$meetingId;
}
updateSpiralRecord($recordId, $updateData);
$actionId = ACTION_ID;
$actionId = (string)$actionId;
if ($actionId !== '') {
runSpiralAction($actionId, $recordId);
}
} catch (Exception $e) {
try {
if (isset($SPIRAL)) {
$record = $SPIRAL->getRecord();
$item = $record['item'] ?? null;
$recordId = is_array($item) ? (string)($item['_id'] ?? '') : '';
$revision = is_array($item) ? (string)($item['_revision'] ?? '') : '';
$hasMeetingUrl = is_array($item) && !empty($item['meeting_url']);
$hasMeetingId = is_array($item) && !empty($item['meeting_id']);
if ($recordId !== '' && $revision !== '' && APP_ID !== '' && DB_ID !== '' && !$hasMeetingUrl && !$hasMeetingId) {
updateSpiralRecord($recordId, [
'_revision' => $revision,
'meeting_url' => '',
'meeting_password' => '',
]);
}
}
} catch (Exception $ignored) {
}
throw $e;
}
// --- SPIRAL(会議URLを書き戻すためのAPI設定) ---
define('API_URL', 'https://api.spiral-platform.com/v1');
// SPIRAL管理画面で発行したAPIキー
define('API_KEY', '');
// アプリロールが必要な場合のみ指定(空なら未指定)
define('APP_ROLE', '');
// 対象アプリID / DB ID(会議URLを書き戻すDB)
define('APP_ID', '');
define('DB_ID', '');
define('ACTION_ID', '');
//原則変更不要
define('SPIRAL_API_VERSION', '1.1');
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が入っている場合は、誤って消さないように空更新をスキップします。
実行結果
DBトリガーで本プログラムが実行されると、対象レコードに以下が反映されます。
meeting_urlに会議参加URL(Google Meet: meetingUri / Zoom: join_url)
・
meeting_password(Zoomの場合に設定される可能性があります)
・ 必要に応じて
meeting_id(Google: spaces/xxx / Zoom: meeting id)
・ 更新完了後に
ACTION_IDのアクションが実行されます(メール送信や通知などの後続処理)
まとめ
SPIRALのDBへ書き戻すサンプルを紹介しました。
さらに更新完了後にアクション実行APIを呼び出すことで、
通知やワークフローなどの後続処理まで一気通貫で自動化できます。
不具合がある場合は、エラーメッセージ(HTTPコードとレスポンス)を確認し、
APIキー・OAuth設定・スコープ・権限を見直してください。