全体像
0:00-0:05受付担当がフォームに来場者情報を入力すると、スプレッドシートへの回答追加をきっかけにGASが動き、LINE Messaging API経由で運営グループに通知します。
1受付フォーム来場者情報を入力
2スプレッドシート回答を自動保存
3GASトリガー回答追加時に実行
4Messaging APILINEへPush送信
5運営LINEグループ来場通知を受信
LINE Notifyは2025年3月31日に終了済みです。この手順では、現在使えるLINE Messaging APIを使います。
このハンズオンで大事にすること
- フォーム回答を「見に行く」のではなく、いつも見るLINEへ「届く」状態にします。
- AIに聞くと古いLINE Notifyのコードが出ることがあります。今回はMessaging APIだけを使います。
- LINE設定が一番の山場です。GASコードはコピーして、設定確認に時間を使います。
参考にした内容
参考参考ウェビナーから反映したポイント
参考: 問い合わせもトラブル報告も即キャッチ!GoogleフォームをLINE通知で対応遅れゼロに 基礎編
上記ウェビナーの流れをもとに、当日のハンズオンでつまずきやすい箇所を先回りして補強しています。
- Googleフォームは手作業で作ってOK。GASでフォーム自体を作るのは発展編に回します。
- LINE Developersは、個人LINEとは分けたビジネスアカウントで作るのがおすすめです。
- チャネルアクセストークンと送信先IDは秘密情報です。画面共有や公開ページに出さないようにします。
- トリガー設定で詰まりやすいので、自動設定と手動設定の両方を用意します。
事前準備
0:05-0:10参加者が用意するもの
- Googleアカウント
- LINEアカウント
- LINE Developersにログインできる状態
- LINE Developers用のビジネスアカウント。個人LINEとは分けると安心です
- 通知先にする運営用LINEグループ
今日のハンズオンで作る項目
| 項目 | 内容 | 例 |
|---|---|---|
| お名前 | 来場者名 | 山田 太郎 |
| 会社名・所属 | 所属情報 | 株式会社サンプル |
| 参加枠 | セミナー枠 | 午前の部 |
| メモ | 受付時の補足 | 資料1部追加 |
Googleフォームを作る
0:10-0:20Step 1
フォームを新規作成する
作業場所Googleフォーム
- forms.new を開きます。
- タイトルを セミナー来場受付フォーム にします。
- 下の項目を追加します。
項目フォーム項目リスト
お名前
会社名・所属
参加枠
メモ
Step 2
回答先のスプレッドシートを作る
作業場所Googleフォームの「回答」タブ → Googleスプレッドシート
- フォーム上部の「回答」タブを開きます。
- スプレッドシートアイコンを押します。
- 新しいスプレッドシートを作成します。
このスプレッドシートが、GASを置く場所になります。
LINE公式アカウントを準備する
0:20-0:35Step 3
LINE公式アカウントを作成し、Messaging APIを有効にする
作業場所LINE公式アカウント作成画面 / LINE Official Account Manager / LINE Developers Console
- Messaging APIを始めよう の「LINE公式アカウントを作成する」を開きます。
- ページ内の「LINE公式アカウントを作成する」から、LINE公式アカウントを作成します。
- LINE Official Account Managerで対象アカウントを開きます。
- 「設定」→「Messaging API」から、Messaging APIの利用を有効にします。
- 有効化後、LINE Developers Consoleで対象チャネルを開き、「チャネルアクセストークン(長期)」を発行します。
現在の注意: LINE Developers ConsoleからMessaging APIチャネルを直接作成することはできなくなっています。公式ドキュメントの「LINE公式アカウントを作成する」手順に沿ってLINE公式アカウントを作成してから、LINE Official Account Manager上でMessaging APIを有効にします。
今回はメールアドレスで新規登録する流れです。 LINEヤフー Business ID画面では「ビジネスアカウント」を選び、メールアドレスに届いたリンクから登録を進めます。
チャネルアクセストークンは、LINE Developers Consoleの「Messaging API設定」にある「チャネルアクセストークン(長期)」を使います。Channel IDやChannel secret、別チャネルのトークンを貼るとLINE送信に失敗します。トークンはパスワードのようなものなので、公開ページやGitHubには貼らず、GASのスクリプトプロパティに保存します。
LINE公式アカウント管理画面とLINE Developers Consoleを行き来します。ここが一番迷いやすいので、画面名を確認しながら進めます。
Step 4
グループ参加とWebhookを有効にする
作業場所LINE Official Account Manager / 手元のLINEグループ
- LINE Official Account Managerの「設定」→「アカウント設定」を開き、「トークへの参加」で「グループ・複数人トークへの参加を許可」を選びます。ここがオフだと、招待してもLINE公式アカウントがすぐ退会します。
- 応答メッセージはオフにします。オンのままだと、接続テスト時に定型の自動返信も一緒に届きます。
- Webhookの利用をオンにします。
- 設定を保存してから、LINE公式アカウントを運営用LINEグループに招待し直します。
Webhook URLは、次のGASデプロイ後に発行されるWebアプリURLを入れます。LINE公式ドキュメントでは、このグループ参加設定はデフォルトでオフです。同じグループに複数のLINE公式アカウントは同時参加できないため、別の公式アカウントが入っている場合は外してから招待します。
GASを貼り付ける
0:35-0:50Step 5
Apps Scriptを開く
作業場所Googleスプレッドシート / Apps Script
- 回答用スプレッドシートを開きます。
- メニューの「拡張機能」から「Apps Script」を開きます。
- 既存コードを消して、下のコードを貼り付けます。
- コード内にチャネルアクセストークンは貼り付けません。保存後、スプレッドシート側のサイドバーから設定します。
- 貼り付けたら、上部の保存アイコンを押します。
GASコピーして貼り付けるコード
const PROP_TOKEN = 'LINE_CHANNEL_ACCESS_TOKEN';
const PROP_GROUP_ID = 'LINE_GROUP_ID';
const PROP_TIMEZONE = 'TIMEZONE';
const DEFAULT_TIMEZONE = 'Asia/Tokyo';
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('LINE通知設定')
.addItem('設定サイドバーを開く', 'showLineSettingsSidebar')
.addSeparator()
.addItem('テスト送信', 'testSend')
.addItem('フォーム送信トリガーを設定', 'installTrigger')
.addItem('設定状態をログに表示', 'checkSettings')
.addToUi();
}
function showLineSettingsSidebar() {
const html = HtmlService
.createHtmlOutput(getLineSettingsSidebarHtml_())
.setTitle('LINE通知設定');
SpreadsheetApp.getUi().showSidebar(html);
}
function getLineSettings() {
const props = PropertiesService.getScriptProperties();
return {
hasToken: Boolean(props.getProperty(PROP_TOKEN)),
groupId: props.getProperty(PROP_GROUP_ID) || '',
timezone: props.getProperty(PROP_TIMEZONE) || DEFAULT_TIMEZONE
};
}
function saveLineSettingsFromSidebar(settings) {
const props = PropertiesService.getScriptProperties();
const currentToken = props.getProperty(PROP_TOKEN);
const token = String(settings.token || '').trim();
const groupId = String(settings.groupId || '').trim();
const timezone = String(settings.timezone || DEFAULT_TIMEZONE).trim() || DEFAULT_TIMEZONE;
if (!token && !currentToken) {
throw new Error('チャネルアクセストークン(長期)を入力してください。');
}
const values = {
[PROP_GROUP_ID]: groupId,
[PROP_TIMEZONE]: timezone
};
if (token) {
values[PROP_TOKEN] = token;
}
props.setProperties(values, false);
return {
message: '設定を保存しました。',
hasToken: true,
groupId: groupId,
timezone: timezone
};
}
function doPost(e) {
const body = e.postData && e.postData.contents ? e.postData.contents : '{}';
const data = JSON.parse(body);
const events = data.events || [];
const props = PropertiesService.getScriptProperties();
Logger.log('LINE webhook body: ' + body);
events.forEach(function(event) {
const source = event.source || {};
const targetId = source.groupId || source.roomId;
Logger.log('LINE webhook source: ' + JSON.stringify(source));
if (!targetId) {
Logger.log('groupId/roomIdがないイベントです。type=' + (source.type || 'unknown'));
return;
}
props.setProperty(PROP_GROUP_ID, targetId);
Logger.log('LINE_GROUP_IDを保存しました: ' + targetId);
const text = event.message && event.message.type === 'text'
? event.message.text
: '';
if (event.replyToken && /接続テスト|groupid|グループID/i.test(text)) {
const idType = source.groupId ? 'groupId' : 'roomId';
replyText_(event.replyToken, '接続できました。' + idType + 'をLINE_GROUP_IDとしてGASに保存しました。');
}
});
return ContentService.createTextOutput('OK');
}
function installTrigger() {
ScriptApp.getProjectTriggers().forEach(function(trigger) {
if (trigger.getHandlerFunction() === 'onFormSubmit') {
ScriptApp.deleteTrigger(trigger);
}
});
ScriptApp.newTrigger('onFormSubmit')
.forSpreadsheet(SpreadsheetApp.getActive())
.onFormSubmit()
.create();
Logger.log('フォーム送信時トリガーを設定しました。');
}
function onFormSubmit(e) {
const groupId = getRequiredProperty_(PROP_GROUP_ID);
const message = buildAttendanceMessage_(e);
pushText_(groupId, message);
}
function testSend() {
const groupId = getRequiredProperty_(PROP_GROUP_ID);
pushText_(groupId, 'GASからLINE運営グループへのテスト通知です。');
}
function checkSettings() {
const props = PropertiesService.getScriptProperties();
Logger.log('TOKEN: ' + (props.getProperty(PROP_TOKEN) ? '保存済み' : '未保存'));
Logger.log('LINE_GROUP_ID: ' + (props.getProperty(PROP_GROUP_ID) || '未取得'));
Logger.log('TIMEZONE: ' + (props.getProperty(PROP_TIMEZONE) || DEFAULT_TIMEZONE));
}
function resetGroupId() {
PropertiesService.getScriptProperties().deleteProperty(PROP_GROUP_ID);
Logger.log('LINE_GROUP_IDを削除しました。もう一度グループで接続テストを送ってください。');
}
function buildAttendanceMessage_(e) {
const namedValues = e && e.namedValues ? e.namedValues : {};
const name = pick_(namedValues, ['お名前', '名前', '氏名', '参加者名']) || '参加者';
const company = pick_(namedValues, ['会社名・所属', '会社名', '所属', '団体名']);
const session = pick_(namedValues, ['参加枠', '参加回', 'セミナー枠']);
const memo = pick_(namedValues, ['メモ', '備考', '受付メモ']);
const timestamp = pick_(namedValues, ['タイムスタンプ', '受付時刻']);
const lines = [
'【来場受付】',
name + 'さんが来場しました。',
'',
'受付時刻: ' + formatDate_(timestamp),
'会社名・所属: ' + (company || '-'),
'参加枠: ' + (session || '-')
];
if (memo) {
lines.push('メモ: ' + memo);
}
return lines.join('\n');
}
function pushText_(to, text) {
const token = getRequiredProperty_(PROP_TOKEN);
const url = 'https://api.line.me/v2/bot/message/push';
const payload = {
to: to,
messages: [{ type: 'text', text: text }]
};
requestLine_(url, payload, token);
}
function replyText_(replyToken, text) {
const token = getRequiredProperty_(PROP_TOKEN);
const url = 'https://api.line.me/v2/bot/message/reply';
const payload = {
replyToken: replyToken,
messages: [{ type: 'text', text: text }]
};
requestLine_(url, payload, token);
}
function requestLine_(url, payload, token) {
const response = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
headers: {
Authorization: 'Bearer ' + token
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
const status = response.getResponseCode();
const body = response.getContentText();
Logger.log('LINE API status: ' + status);
Logger.log(body);
if (status < 200 || status >= 300) {
throw new Error('LINE API error: ' + status + ' ' + body);
}
}
function pick_(namedValues, keys) {
for (let i = 0; i < keys.length; i++) {
const value = namedValues[keys[i]];
if (Array.isArray(value) && value[0]) {
return String(value[0]).trim();
}
if (value) {
return String(value).trim();
}
}
return '';
}
function formatDate_(value) {
if (!value) {
return Utilities.formatDate(new Date(), getTimeZone_(), 'yyyy/MM/dd HH:mm');
}
const date = value instanceof Date ? value : new Date(value);
if (isNaN(date.getTime())) {
return String(value);
}
return Utilities.formatDate(date, getTimeZone_(), 'yyyy/MM/dd HH:mm');
}
function getTimeZone_() {
return PropertiesService.getScriptProperties().getProperty(PROP_TIMEZONE) || DEFAULT_TIMEZONE;
}
function getRequiredProperty_(key) {
const value = PropertiesService.getScriptProperties().getProperty(key);
if (!value) {
throw new Error(key + ' が未設定です。スプレッドシートの「LINE通知設定」メニューから設定してください。');
}
return value;
}
function getLineSettingsSidebarHtml_() {
return `
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
body{font-family:-apple-system,BlinkMacSystemFont,"Noto Sans JP",sans-serif;color:#1f2937;padding:14px;line-height:1.6}
h2{font-size:18px;margin:0 0 12px}
label{display:block;font-weight:700;margin-top:14px;font-size:13px}
input{box-sizing:border-box;width:100%;border:1px solid #d1d5db;border-radius:6px;padding:9px 10px;font-size:13px}
button{width:100%;border:0;border-radius:999px;padding:10px 12px;margin-top:12px;color:#fff;background:#0f9f9a;font-weight:700;cursor:pointer}
button.secondary{color:#0f9f9a;background:#eaf7f6}
.help{color:#64748b;font-size:12px;margin-top:4px}
.status{background:#f1f5f9;border-radius:6px;padding:9px 10px;font-size:12px;margin:10px 0}
#message{min-height:20px;margin-top:12px;font-size:12px;color:#166534}
#message.error{color:#dc2626}
</style>
</head>
<body>
<h2>LINE通知設定</h2>
<div class="status" id="tokenStatus">読み込み中...</div>
<label for="token">チャネルアクセストークン(長期)</label>
<input id="token" type="password" autocomplete="off" placeholder="新しく保存する時だけ貼り付け">
<div class="help">空欄で保存すると、保存済みトークンをそのまま使います。</div>
<label for="groupId">LINE_GROUP_ID(roomIdも可)</label>
<input id="groupId" type="text" placeholder="接続テストで自動取得。手入力も可">
<div class="help">運営LINEグループまたは複数人トークで「接続テスト」と送ると自動保存されます。</div>
<label for="timezone">タイムゾーン</label>
<input id="timezone" type="text" value="Asia/Tokyo">
<button onclick="save()">設定を保存</button>
<button class="secondary" onclick="testSend()">テスト送信</button>
<button class="secondary" onclick="installTrigger()">フォーム送信トリガーを設定</button>
<div id="message"></div>
<script>
function setMessage(text, isError) {
var el = document.getElementById('message');
el.textContent = text || '';
el.className = isError ? 'error' : '';
}
function load() {
google.script.run.withSuccessHandler(function(settings) {
document.getElementById('tokenStatus').textContent = settings.hasToken ? 'TOKEN: 保存済み' : 'TOKEN: 未保存';
document.getElementById('groupId').value = settings.groupId || '';
document.getElementById('timezone').value = settings.timezone || 'Asia/Tokyo';
}).withFailureHandler(function(error) {
setMessage(error.message, true);
}).getLineSettings();
}
function save() {
setMessage('保存中...');
google.script.run.withSuccessHandler(function(settings) {
document.getElementById('token').value = '';
document.getElementById('tokenStatus').textContent = settings.hasToken ? 'TOKEN: 保存済み' : 'TOKEN: 未保存';
document.getElementById('groupId').value = settings.groupId || '';
document.getElementById('timezone').value = settings.timezone || 'Asia/Tokyo';
setMessage(settings.message || '保存しました。');
}).withFailureHandler(function(error) {
setMessage(error.message, true);
}).saveLineSettingsFromSidebar({
token: document.getElementById('token').value,
groupId: document.getElementById('groupId').value,
timezone: document.getElementById('timezone').value
});
}
function testSend() {
setMessage('テスト送信中...');
google.script.run.withSuccessHandler(function() {
setMessage('テスト送信しました。LINEグループを確認してください。');
}).withFailureHandler(function(error) {
setMessage(error.message, true);
}).testSend();
}
function installTrigger() {
setMessage('トリガー設定中...');
google.script.run.withSuccessHandler(function() {
setMessage('フォーム送信トリガーを設定しました。');
}).withFailureHandler(function(error) {
setMessage(error.message, true);
}).installTrigger();
}
load();
</script>
</body>
</html>`;
}
Step 6
サイドバーでLINE設定を保存する
作業場所Googleスプレッドシートの「LINE通知設定」サイドバー / LINE Developers Console
- Apps Scriptを保存したら、回答用スプレッドシートを再読み込みします。
- メニューの「LINE通知設定」→「設定サイドバーを開く」を押します。
- 初回は「認証が必要です」と表示されるので「OK」を押し、Googleアカウントの権限確認を許可します。
- LINE Developers Consoleの「チャネルアクセストークン(長期)」を貼り付けます。Channel IDやChannel secretではありません。
- TIMEZONE は通常 Asia/Tokyo のままでOKです。
- LINE_GROUP_ID は接続テストで自動取得するため、最初は空欄でOKです。
- 「設定を保存」を押します。
保存される値は、GASのスクリプトプロパティです。コード内や公開ページにはアクセストークンを残しません。
Step 7
Webアプリとしてデプロイする
作業場所Apps Script / LINE Official Account ManagerのMessaging API画面
- Apps Script右上の「デプロイ」から「新しいデプロイ」を選びます。
- 種類は「ウェブアプリ」を選びます。
- 実行するユーザーは「自分」、アクセスできるユーザーは「全員」にします。
- デプロイ後に表示されるWebアプリURLをコピーします。
- LINE Official Account ManagerのMessaging API画面にあるWebhook URLへ貼り付け、「保存」を押します。
- Webhookの利用をオンにします。
アクセス権が「自分のみ」や「Googleアカウントが必要」だと、LINEからWebhookを呼び出せません。
接続テスト
0:50-0:58Step 8
LINEグループIDを自動取得する
作業場所手元のLINEグループ / Googleスプレッドシートのサイドバー
- 運営用LINEグループに、作成したLINE公式アカウントを招待します。
- グループ内で下の文を送ります。
- LINE公式アカウントから「接続できました」と返信が来るか確認します。
- サイドバーを開き直し、LINE_GROUP_ID が入っていることを確認します。
LINEグループで送るテスト文
接続テスト
LINE_GROUP_IDが入らない場合:
招待直後にLINE公式アカウントが退会する時は、Step 4の「グループ・複数人トークへの参加」がオフです。オンにして保存してから招待し直します。同じグループに別のLINE公式アカウントが入っている場合は、先に外します。「接続できました」の返信が来ない時は、WebhookがGASに届いていません。Step 7のWebアプリURLが /exec のURLか、Webhook URLを保存したか、Webhookの利用がオンか、Webアプリを新しいバージョンで再デプロイしたかを確認します。
招待直後にLINE公式アカウントが退会する時は、Step 4の「グループ・複数人トークへの参加」がオフです。オンにして保存してから招待し直します。同じグループに別のLINE公式アカウントが入っている場合は、先に外します。「接続できました」の返信が来ない時は、WebhookがGASに届いていません。Step 7のWebアプリURLが /exec のURLか、Webhook URLを保存したか、Webhookの利用がオンか、Webアプリを新しいバージョンで再デプロイしたかを確認します。
Step 9
GASからLINEにテスト送信する
作業場所Googleスプレッドシートのサイドバー / 手元のLINEグループ
- サイドバーの「テスト送信」を押します。
- 運営LINEグループに「GASからLINE運営グループへのテスト通知です。」と届けば成功です。
Step 10
フォーム送信時トリガーを設定する
作業場所Googleスプレッドシートのサイドバー / Googleフォーム / 手元のLINEグループ
- サイドバーの「フォーム送信トリガーを設定」を押します。
- Googleフォームにテスト回答を送信します。
- 運営LINEグループに来場通知が届けば完成です。
トリガー設定で失敗した場合:
Apps Script左メニューの「トリガー」→「トリガーを追加」から、実行する関数 onFormSubmit、イベントのソース「スプレッドシートから」、イベントの種類「フォーム送信時」を手動で選びます。
Apps Script左メニューの「トリガー」→「トリガーを追加」から、実行する関数 onFormSubmit、イベントのソース「スプレッドシートから」、イベントの種類「フォーム送信時」を手動で選びます。
例完成時に届く通知
【来場受付】
山田 太郎さんが来場しました。
受付時刻: 2026/06/28 13:05
会社名・所属: 株式会社サンプル
参加枠: 午前の部
メモ: 資料1部追加
この手順書をCloudflare Pagesで公開する
講師向けこのページは静的HTMLなので、Cloudflare Pagesにそのまま公開できます。GitHub連携なら更新も楽です。
Cloudflare Pagesの設定例
| Framework preset | None |
|---|---|
| Build command | 空欄 |
| Build output directory | / または、このHTMLを置いたディレクトリ |
| 公開URL | https://プロジェクト名.pages.dev/ |
CLIWranglerで直接アップロードする場合
npx wrangler pages deploy . --project-name shiftai-yurutto-line-gas
受講者向けの手順書にはLINEアクセストークンなどの秘密情報を載せません。受講者はLINE公式アカウントでMessaging APIを有効化したあと、自分のLINE Developers画面で発行したトークンを、自分のGASにだけ貼り付けます。
困った時の確認リスト
0:58-1:00LINEに届かない: checkSettings でTOKENとLINE_GROUP_IDを確認します。
テスト送信でLINE APIエラーになる: LINE Developers Consoleの「チャネルアクセストークン(長期)」を貼っているか確認します。Channel ID、Channel secret、別チャネルのトークンは使えません。
招待したLINE公式アカウントがすぐ退会する: LINE Official Account Managerの「アカウント設定」→「トークへの参加」で、グループ・複数人トークへの参加を許可します。別のLINE公式アカウントが同じグループにいる場合は外します。
LINE_GROUP_IDが取れない: グループで「接続テスト」と送って自動返信が来るか確認します。返信がなければWebhook URL、Webhookオン、グループ・複数人トーク参加許可、Webアプリの再デプロイを確認します。
GASを直したのに動きが変わらない: Webアプリは保存だけでは反映されません。デプロイを編集して新しいバージョンに更新します。
Messaging APIチャネルが作れない: LINE Developersから直接作るのではなく、LINE公式アカウント作成後にOfficial Account ManagerでMessaging APIを有効化します。
AIに聞いたコードが動かない: LINE Notify向けの古いコードになっていないか確認します。
GASの権限エラー: 「LINE通知設定」サイドバーの保存や installTrigger 実行時に、Googleの権限確認を許可します。
フォーム送信で動かない: 単純トリガーではなく、インストール型トリガーを使います。自動設定が失敗したら手動で追加します。
401エラー: チャネルアクセストークンの貼り間違い、古いトークン、余計な空白を確認します。
403/404エラー: Webアプリの公開範囲が「全員」になっているか確認します。
通知数が多い: LINE公式アカウントには送信数の上限があります。大量通知の前に管理画面で現在の上限を確認します。
Teams/Slackにも送りたい: 仕組みは可能ですが別設計です。TeamsはPower Automate、SlackはSlackアプリやWebhookを使う発展編にします。
ここまでできたら、Googleフォーム受付からLINE運営グループ通知までの小さな業務自動化が完成です。