当番の通知は、担当者・営業日・送信する時間帯を確認してから投稿します。
改訂版では存在しないTouban関数を呼ぶ設定を外し、シートを選んでから読み取り、通知に成功するまで当番の順番を変更しません。
2名の当番表で通知と次の順番を確かめる
各article-番号.jsを別々のApps Scriptプロジェクトへ保存します。
article-2506.jsを保存し、demoDutyを実行します。
架空の2名と10月2日10:05を使い、練習 太郎への通知文と次の順番を表示します。
外部への投稿やシートの書き込みはありません。
// 各記事を別々のApps Scriptプロジェクトへ保存します。
function props_() { return PropertiesService.getScriptProperties(); }
function required_(key) {
const v = props_().getProperty(key);
if (!v) throw new Error(key + 'を設定してください');
return v;
}
function numericId_(v) {
if (typeof v === 'number' && !Number.isSafeInteger(v)) throw new Error('IDの精度が失われています');
const s = String(v);
if (!/^[1-9]\d*$/.test(s)) throw new Error('IDは正の数字で指定します');
return s;
}
function digest_(s) {
return Utilities.base64EncodeWebSafe(Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256, s, Utilities.Charset.UTF_8));
}
function key_(kind, destination, id) { return 'CGM5_' + digest_(kind + ':' + destination + ':' + id); }
function runLocked_(work) {
if (props_().getProperty('SEND_ENABLED') !== 'true') throw new Error('送信は無効です');
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('別の実行が処理中です');
try { return work(); } finally { lock.releaseLock(); }
}
function once_(kind, destination, plan, perform) {
const key = key_(kind, destination, plan.id);
const fingerprint = digest_(JSON.stringify(plan.payload));
const previous = props_().getProperty(key);
if (previous) {
const record = JSON.parse(previous);
if (record.fingerprint !== fingerprint) throw new Error('同じIDの入力が変更されています');
if (record.status === 'done') return false;
throw new Error('結果不明の処理を投稿先と照合してください');
}
// 通信前に記録。記録に失敗した場合は送信しません。
props_().setProperty(key, JSON.stringify({status:'sending', fingerprint:fingerprint}));
try {
perform();
props_().setProperty(key, JSON.stringify({status:'done', fingerprint:fingerprint}));
return true;
} catch (error) {
props_().setProperty(key, JSON.stringify({status:'unknown', fingerprint:fingerprint}));
throw new Error('処理結果を確定できません。自動再送せず照合してください');
}
}
function preview_(plans) {
Logger.log(JSON.stringify(plans.map(p => ({id:p.id, characters:p.text.length}))));
return plans;
}
function jst_(date, pattern) { return Utilities.formatDate(date, 'Asia/Tokyo', pattern); }
function validDate_(d) { return d instanceof Date && Number.isFinite(d.getTime()); }
function plainChat_(s) { return String(s).replace(/\[/g,'[').replace(/\]/g,']'); }
function chatPost_(room, endpoint, payload, responseKey) {
const r = UrlFetchApp.fetch('https://api.chatwork.com/v2/rooms/' + numericId_(room) + '/' + endpoint, {
method:'post', headers:{'X-ChatworkToken':required_('CHATWORK_API_TOKEN')},
payload:payload, muteHttpExceptions:true
});
if (r.getResponseCode() !== 200) throw new Error('Chatworkの応答を確認してください');
const d = JSON.parse(r.getContentText());
if (!d[responseKey] || (Array.isArray(d[responseKey]) && !d[responseKey].length)) throw new Error('成功IDがありません');
return d;
}
function dutyPlan_(rows, now, holiday, text) {
if (!validDate_(now)) throw new Error('日時が不正です');
const day = jst_(now,'yyyy-MM-dd'), hm = jst_(now,'HH:mm');
const week = new Date(day + 'T00:00:00Z').getUTCDay();
if (week === 0 || week === 6 || holiday || hm < '10:00' || hm >= '10:15') return null;
if (!rows.length) throw new Error('当番表が空です');
const ids = new Set(), ranks = new Set();
const people = rows.map(r => {
const id = numericId_(r[1]), rank = r[2];
if (!r[0] || !Number.isInteger(rank) || rank < 1 || rank > rows.length || ids.has(id) || ranks.has(rank)) throw new Error('名前・ID・順番を確認してください');
ids.add(id); ranks.add(rank); return {name:String(r[0]),id:id,rank:rank};
});
const person = people.find(p => p.rank === 1);
const body = plainChat_(text).replace(/\{NAME\}/g, plainChat_(person.name));
if (!body || body.length > 5000) throw new Error('通知文を確認してください');
return {id:day, text:'[To:' + person.id + ']\n' + body,
payload:{date:day,person:person.id,body:body},
next:people.map(p => [p.rank === 1 ? people.length : p.rank-1])};
}
function dutyInput_() {
if (Session.getScriptTimeZone() !== 'Asia/Tokyo') throw new Error('プロジェクトのタイムゾーンをAsia/Tokyoにしてください');
const now = new Date(), book = SpreadsheetApp.openById(required_('SPREADSHEET_ID'));
const sheet = book.getSheetByName('当番'), settings = book.getSheetByName('本文');
if (!sheet || !settings) throw new Error('当番・本文シートが必要です');
const rows = sheet.getLastRow() < 2 ? [] : sheet.getRange(2,1,sheet.getLastRow()-1,3).getValues();
const cal = CalendarApp.getCalendarById(required_('HOLIDAY_CALENDAR_ID'));
if (!cal) throw new Error('休業日カレンダーを参照できません');
const holiday = cal.getEventsForDay(now).length > 0;
return {sheet:sheet, rows:rows, now:now, plan:dutyPlan_(rows,now,holiday,settings.getRange(2,1).getValue())};
}
function demoDuty() {
const p = dutyPlan_([['練習 太郎','101',1],['練習 花子','102',2]],new Date('2026-10-02T10:05:00+09:00'),false,'{NAME}さん、掃除当番です');
Logger.log(JSON.stringify(p)); return p;
}
function previewDuty() { const x=dutyInput_(); return preview_(x.plan ? [x.plan] : []); }
function notifyDuty() {
return runLocked_(() => {
const x=dutyInput_(); if (!x.plan) return;
const room=numericId_(required_('CHATWORK_ROOM_ID'));
const done=props_().getProperty(key_('duty',room,x.plan.id));
// ローテーション後の入力は変わるので、その日の完了はここで判定します。
if (done && JSON.parse(done).status === 'done') return;
x.plan.payload.rotation=x.rows;
once_('duty',room,x.plan,() => {
chatPost_(room,'messages',{body:x.plan.text,self_unread:1},'message_id');
if (props_().getProperty('ADD_TASK') === 'true') {
chatPost_(room,'tasks',{body:x.plan.payload.body,to_ids:x.plan.payload.person,
limit:Math.floor((x.now.getTime()+4*86400000)/1000),limit_type:'time'},'task_ids');
}
const latest=x.sheet.getRange(2,1,x.rows.length,3).getValues();
if (x.sheet.getLastRow()!==x.rows.length+1 || JSON.stringify(latest)!==JSON.stringify(x.rows)) throw new Error('当番表が変更されています');
x.sheet.getRange(2,3,x.rows.length,1).setValues(x.plan.next);
});
});
}
当番表と営業日の設定
| 入力場所 | 入力例・条件 |
|---|---|
| 当番シートのA列 | 名前。A2は練習 太郎、A3は練習 花子 |
| 当番シートのB列 | ChatworkのID。練習値は101、102。実運用前に実際の許可された担当者IDへ変更 |
| 当番シートのC列 | 通知順。C2は1、C3は2。1から人数分までを重複なく入力 |
| 本文シートのA2 | {NAME}さん、掃除当番です |
| SPREADSHEET_ID | 当番と本文を置いた練習用スプレッドシートのID |
| HOLIDAY_CALENDAR_ID | 参照可能な、会社の休業日を登録したカレンダーID |
| CHATWORK_ROOM_ID / CHATWORK_API_TOKEN | 許可された練習用ルームのIDとAPIトークン |
スプレッドシートとApps ScriptのタイムゾーンをAsia/Tokyoにそろえます。
休業日カレンダーの予定がある日と土日を除き、日本時間10:00以上10:15未満の実行だけを対象にします。
公開の祝日カレンダーが会社の営業日と一致するとは限らないため、対象の休業日を別に確認します。
previewDutyは当番表と休業日を読み、候補のIDと文字数を表示します。
候補の本文は元のシートでも確認してからnotifyDutyを実行します。
ADD_TASKをtrueにした場合だけ、同じ担当者のタスクを追加し、期限を実行時刻の4暦日後にします。
営業日で4日を数えるものではありません。
送信失敗と順番更新の確認結果
| 条件 | 結果 |
|---|---|
| 通常の通知 | 本文POSTを1回行い、成功後にC列の順番を更新 |
| 同じ日に再実行 | 完了記録があれば再投稿・再更新しない |
| タスク追加で429や通信失敗 | 順番を更新せず結果不明として停止 |
| 送信中に当番表の入力・行数が変わる | 他の入力を上書きせず停止 |
| 土日・休業日・時間帯の外 | 通知と順番更新を行わない |
| 空の表・重複したIDや順番 | 投稿前に停止 |
自動化する場合は、notifyDutyの時間主導型トリガーを5分間隔で1つだけ作ります。
実行が遅れて対象時間帯を過ぎると通知しないため、10:00ちょうどの到着は保証しません。
送信前のプレビュー関数へ投稿トリガーを付けません。
タスクは通知とは別のAPIです。
通知だけ成功した後にタスク追加や順番更新が失敗した場合は、通知・タスク・当番表の3つを照合し、手動でそろえてから再開します。
現行のタスク追加仕様と時間主導型トリガーの仕様を2026年10月2日に確認しました。
送信結果が不明なときの停止と戻し方
スクリプトプロパティのSEND_ENABLEDがtrueになるまで、送信関数は入力の取得前に停止します。
送信先を管理する本人の許可を得て、送信する内容と練習用の送信先を確かめてから有効にします。
通信前にsending、処理完了後にdoneを保存し、通信や記録に失敗した場合はunknownなどの状態を残して止めます。
成功応答がなくても投稿先へ届いている可能性があるため、自動で再送しません。
結果が不明な場合はSEND_ENABLEDをfalseにし、トリガーを停止して、投稿先とシートを照合します。
送信が確認できた記録はdoneへ整え、未送信を確認できた場合だけ再実行の準備をします。
同じIDの本文や予約日時を変更すると入力変更として停止します。
送信済みの記録を消したり、新しいIDを付けたりすると再送できてしまうため、再実行の代わりに記録を消さないでください。
ロックは同じプロジェクト内の競合を防ぐもので、別のプロジェクトや旧トリガーからの投稿は防ぎません。
移行時は旧トリガーを止め、長期運用では保存件数と整理方法を決めます。
戻す際は新しいトリガーを止め、保存した旧コード・設定・シートのコピーを参照します。
処理済みの対象がある場合は、旧コードで同じ対象を再送しないように確認します。
検証した範囲と実サービスの確認
2026年10月2日に、Node.jsでApps Script・HTTP応答・ロック・保存状態・シートを模した83ケースを確認しました。
ZIPを新しいフォルダーへ展開し、node test_integration.cjsを実行すると同じ条件を確認できます。
Googleの認可、実シートの更新、Chatwork・Slackへの実投稿、Driveからの実画像アップロード、トリガーの実行時刻は未検証です。
実サービスを使う際は、練習用の入力・送信先・認可範囲を確認してから1回手動で実行し、投稿先と保存状態を照合します。
関連記事・旧実装の参考資料
外部の参考コードと旧実装の説明は、今回の練習ZIPと内容が異なる場合があります。
現在の練習では本稿のZIPを使い、送信先と現行の仕様を確認します。








