Googleカレンダーの予定をChatworkへ通知する例です。
改訂版は二重の投稿呼び出しと壊れた構文を除き、予定IDと開始時刻を組にして、同じ送信先へ処理済みの予定を再投稿しないようにします。
日付をまたぐ予定で通知候補を確かめる
通知連携の練習コードとローカルテストをダウンロード(ZIP)。
各article-番号.jsを別々のApps Scriptプロジェクトへ保存します。
article-666.jsを別のApps Scriptプロジェクトへ保存し、demoCalendarを実行します。
10月2日23:50から15分以内にある翌日0:00の架空の予定を1件表示し、実カレンダーやChatworkへアクセスしません。
// 各記事の例は別々のApps Scriptプロジェクトへ保存します。
function props_() { return PropertiesService.getScriptProperties(); }
function required_(key) {
const value = props_().getProperty(key);
if (!value) throw new Error(key + 'をスクリプトプロパティへ設定してください');
return value;
}
function chatConfig_() {
const room = required_('CHATWORK_ROOM_ID');
if (!/^\d+$/.test(room)) throw new Error('ルームIDは数字で指定します');
return {room: room, token: required_('CHATWORK_API_TOKEN')};
}
function plain_(text) {
return String(text).replace(/\[/g, '[').replace(/\]/g, ']');
}
function stateKey_(kind, destination, id) {
const digest = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256,
kind + ':' + destination + ':' + id, Utilities.Charset.UTF_8);
return 'CGM_JOB_' + Utilities.base64EncodeWebSafe(digest);
}
function preview_(plans) {
// 実メールや予定の本文はログへ出さない。demo関数で架空の本文を確認。
Logger.log(JSON.stringify(plans.map(p => ({id: p.id, characters: p.text.length}))));
return plans;
}
function sendControlled_(kind, destination, buildPlans, transport) {
if (props_().getProperty('SEND_ENABLED') !== 'true') throw new Error('送信は無効です。まずプレビューしてください');
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('別の実行が処理中です');
try {
const plans = buildPlans();
for (const plan of plans) {
const key = stateKey_(kind, destination, plan.id);
const state = props_().getProperty(key);
if (state === 'done') continue;
if (state) throw new Error('結果不明の処理があります。投稿先と記録を照合してください');
// 通信前に記録。通信成功後の記録失敗でも自動で再送しない。
props_().setProperty(key, 'sending');
try {
transport(plan.text);
props_().setProperty(key, 'done');
} catch (error) {
props_().setProperty(key, 'unknown');
throw new Error('送信結果を確定できません。自動再送せず投稿先を確認してください');
}
}
} finally { lock.releaseLock(); }
}
function sendChatwork_(text, config) {
const response = UrlFetchApp.fetch('https://api.chatwork.com/v2/rooms/' + config.room + '/messages', {
method: 'post', headers: {'X-ChatworkToken': config.token},
payload: {body: text, self_unread: 1}, muteHttpExceptions: true
});
if (response.getResponseCode() !== 200) throw new Error('Chatwork HTTP ' + response.getResponseCode());
const result = JSON.parse(response.getContentText());
if (!result.message_id) throw new Error('message_idがありません');
}
function calendarPlans_(events, now, minutes) {
const limit = now.getTime() + minutes * 60000;
return events.filter(e => !e.allDay && e.start > now.getTime() && e.start <= limit)
.sort((a, b) => a.start - b.start)
.map(e => ({id: e.id + ':' + e.start,
text: '【予定の通知】' + Utilities.formatDate(new Date(e.start), 'Asia/Tokyo', 'yyyy-MM-dd HH:mm')
+ ' ' + plain_(e.title) + (e.location ? '\n場所: ' + plain_(e.location) : '')}));
}
function readCalendarPlans_() {
const now = new Date();
const minutes = 15;
const calendar = CalendarApp.getCalendarById(required_('CALENDAR_ID'));
if (!calendar) throw new Error('カレンダーを参照できません');
const events = calendar.getEvents(now, new Date(now.getTime() + minutes * 60000)).map(e => ({
id: e.getId(), start: e.getStartTime().getTime(), title: e.getTitle(),
location: e.getLocation(), allDay: e.isAllDayEvent()
}));
return calendarPlans_(events, now, minutes);
}
function demoCalendar() {
const now = new Date('2026-10-02T23:50:00+09:00');
const events = [{id:'demo-A',start:new Date('2026-10-03T00:00:00+09:00').getTime(),title:'練習会',location:'練習室',allDay:false},
{id:'demo-B',start:now.getTime(),title:'開始済み',location:'',allDay:false}];
const plans = calendarPlans_(events, now, 15);
Logger.log(JSON.stringify(plans));
return plans;
}
function previewCalendar() { return preview_(readCalendarPlans_()); }
function sendCalendarToChatwork() {
const config = chatConfig_();
sendControlled_('calendar', config.room, readCalendarPlans_, text => sendChatwork_(text, config));
}
予定の選択と1回の投稿を確認した結果
| ローカル模擬テストの条件 | 確認した結果 |
|---|---|
| 日付をまたぐ通常の予定 | 開始時刻が15分以内なら候補にする |
| 開始済み・終日・範囲外の予定 | 候補から除く |
| 同じ予定IDでも開始時刻が違う | 別の開催として区別する |
| 正常な投稿 | POSTは1回で、self_unreadは1 |
| ロック競合・通信エラー・記録失敗 | 二重起動や自動再送を止める |
実カレンダーと投稿先の設定
スクリプトプロパティにCALENDAR_ID、CHATWORK_ROOM_ID、CHATWORK_API_TOKENを設定します。
CALENDAR_IDには対象カレンダーのIDを指定し、previewCalendarで候補を確認します。
対象は今から15分以内に始まる通常の予定です。
時刻はDateの絶対時刻で比較し、表示はAsia/Tokyoにしています。
厳密な15分前の配信や、停止中に開始した予定の後追い通知は保証しません。
送信関数はsendCalendarToChatworkです。
現行の投稿APIでself_unreadを1にするため、投稿後に未読化の別APIを繰り返し呼ぶ処理は使いません。
self_unreadは自分から見た未読の指定です。
相手の通知設定、通知音、端末へのプッシュ通知は別に確認します。
現在の投稿仕様はChatworkの公式リファレンス、予定の取得はCalendarの仕様を参照してください。
送信を有効にする前の確認と止め方
送信先を管理する本人の許可を得て、練習用の自分宛てに送る内容と送信先を確認してから、スクリプトプロパティのSEND_ENABLEDをtrueにします。
デモやプレビューの関数に送信トリガーを付けません。
送信する関数を一度手動で試し、投稿先・内容・保存状態を照合してから、必要な時間主導トリガーを1つだけ設定します。
旧プロジェクトや別のトリガーが同時に送ると、このプロジェクトのロックだけでは二重投稿を防げません。
通信前にsending、成功応答後にdoneを保存します。
通信や記録に失敗した場合はunknownなどの状態を残して止まり、自動で再送しません。
結果が不明な場合はSEND_ENABLEDをfalseにし、トリガーも停止して、投稿先に同じ内容が届いていないか確認します。
送信が確認できた記録はdoneへ整え、未送信を確認できた場合だけ再実行の準備をします。
送信済みの記録を消すと同じ対象を再送する可能性があります。
長期運用では記録容量と整理方法を決め、旧コード・設定を控えてから変更します。
戻す際は新しいトリガーを止め、保存したコードと設定へ戻します。
練習コードの検証範囲
2026年10月2日に、Node.jsでGoogle Apps Script・HTTP応答・シート・ロック・保存状態を模した55ケースを確認しました。
ZIPを新しいフォルダーへ展開し、node test_integration.cjsでも同じ結果を確認できます。
実際のGoogle認可、APIへの投稿、シート書き込み、時間主導トリガー、受信側の通知音は未検証です。
ローカルでの成功を、実サービスでの配信成功と扱わないでください。
関連記事






