開発情報・ナレッジ

投稿者: ShiningStar株式会社 2026年1月5日 (月)

DBトリガを用いてWEBミーティングツールと連携して会議URLを自動で発行するサンプルプログラム

SPIRALのDBトリガーを利用して、予約レコードの登録・更新をきっかけに
Web会議(Google Meet / Zoom)を自動作成し、
発行した会議URLを同じレコードへ書き戻すサンプルプログラムの実装方法を紹介します。
Web会議の発行までを自動化できるため、運用側の手作業(会議作成・URL転記)を削減できます。
また、レコード更新が完了したタイミングで アクション実行API を呼び出し、
後続処理(メール送信など)へつなげる構成です。

注意点

本記事は Google Meet / Zoom のみ対応です。
本番運用では、APIキーやOAuthクライアントシークレット等をソースに直書きせず、
環境変数や安全な保管方法を検討してください。
APIのレート制限、トークン期限、ネットワーク制限(外部通信可否)にご注意ください。
DBトリガーは同時実行の可能性があるため、同一レコードの二重発行を避ける設計が必要です。
(本コードでは
meeting_url
が既に入っていれば処理を終了します)。

実装の概要

今回のプログラムは、SPIRALのDBトリガーから起動され、以下の流れで動作します。

0(事前準備). 予約DB作成、必要フィールド追加、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 テキスト
メール mail メールアドレス
会議ツール webMTGTool セレクト(例: 1=Google Meet, 2=Zoom)必須
開始日時 start_datetime 日時(任意:招待先へのメール通知の差し替えに使用)
終了日時 end_datetime 日時(任意:招待先へのメール通知の差し替えに使用)
会議URL(書き戻し) meeting_url テキスト必須
会議パスワード(書き戻し) meeting_password テキスト
外部側ID meeting_id テキスト

設定方法

下記PHPの先頭にある定数を、環境に合わせて設定します。

認証情報(Google / Zoom)について

本プログラムの動作には、Google Meet と Zoom の 認証情報の取得・設定 が必要です。
取得画面や手順は下記リンクを参考にご確認ください。


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トリガーを起点に Google Meet / Zoom の会議URLを自動発行し、
SPIRALのDBへ書き戻すサンプルを紹介しました。
さらに更新完了後にアクション実行APIを呼び出すことで、
通知やワークフローなどの後続処理まで一気通貫で自動化できます。
不具合がある場合は、エラーメッセージ(HTTPコードとレスポンス)を確認し、
APIキー・OAuth設定・スコープ・権限を見直してください。

解決しない場合はこちら コンテンツに関しての
要望はこちら