開発情報・ナレッジ

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

登録フォームからWEBミーティングツールと連携して会議URLを自動で発行するサンプルプログラム

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

注意点

本記事は Google Meet / Zoom のみ対応です。
本番運用では、APIキーやOAuthクライアントシークレット等をソースに直書きせず、
環境変数や安全な保管方法を検討してください。
APIのレート制限、トークン期限、ネットワーク制限(外部通信可否)にご注意ください。

実装の概要

今回のプログラムは、SPIRALのフォームのサンキューページのPHPから起動され、以下の流れで動作します。

0. 事前準備:予約DB作成、必要フィールド追加、各サービスの認証情報の取得
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 テキスト
メール mail メールアドレス
会議ツール 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 の 認証情報の取得・設定 が必要です。
取得画面や手順は下記リンクを参考にご確認ください。


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