Chatworkへ予約投稿する場合は、送信前に予約表を消去せず、成功した行の送信日時だけを記録します。
改訂版は現在のApps Scriptで動かないfor eachを置き換え、予約本文・日時・画像を残したまま失敗を確認できる例です。
未来の予約を除く練習
各article-番号.jsを別々のApps Scriptプロジェクトへ保存します。
article-169.jsを保存し、demoReservationsを実行します。
10月2日10:05を基準に10:00の予約1件だけを候補にし、11:00の未来の予約を除きます。
Chatwork・Driveへのアクセスとシートの変更はありません。
// 各記事を別々の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 reservationPlans_(rows, now) {
const ids=new Set(), plans=[];
rows.forEach((r,i) => {
const id=String(r[0]), due=r[1], room=numericId_(r[2]), text=String(r[3]||''), file=String(r[4]||''), sent=r[5];
if (!/^[A-Za-z0-9_-]+$/.test(id) || ids.has(id) || !validDate_(due) || (sent!=='' && !validDate_(sent)) || (!text && !file) || text.length>5000 || (file && !/^[A-Za-z0-9_-]+$/.test(file))) throw new Error('予約行のID・日時・本文・ファイルIDを確認してください');
ids.add(id);
if (sent!=='' || due>now) return;
if (now-due>86400000) throw new Error('24時間より古い未送信の予約があります');
plans.push({id:id,row:i+2,room:room,text:plainChat_(text),file:file,
payload:{due:due.toISOString(),room:room,text:plainChat_(text),file:file},source:r});
});
return plans;
}
function fileBlob_(id) {
const file=DriveApp.getFileById(id), blob=file.getBlob();
if (!['image/png','image/jpeg','image/gif'].includes(blob.getContentType()) || blob.getBytes().length>5000000) throw new Error('画像形式か5MB上限を確認してください');
return blob;
}
function sendReservationPlan_(p, blob) {
if (p.file) {
const payload={file:blob}; if (p.text) payload.message=p.text;
// 画像付き本文を1回のファイル投稿にまとめ、別の本文POSTを行いません。
chatPost_(p.room,'files',payload,'file_id');
} else chatPost_(p.room,'messages',{body:p.text,self_unread:1},'message_id');
}
function reservationSheet_() {
const s=SpreadsheetApp.openById(required_('SPREADSHEET_ID')).getSheetByName('予約');
if (!s) throw new Error('予約シートがありません'); return s;
}
function readReservationPlans_(s, now) {
const rows=s.getLastRow()<2 ? [] : s.getRange(2,1,s.getLastRow()-1,6).getValues();
return reservationPlans_(rows,now);
}
function demoReservations() {
const p=reservationPlans_([
['practice-1',new Date('2026-10-02T10:00:00+09:00'),'500','練習の予約投稿','', ''],
['practice-2',new Date('2026-10-02T11:00:00+09:00'),'500','未来の予約','', '']],new Date('2026-10-02T10:05:00+09:00'));
Logger.log(JSON.stringify(p)); return p;
}
function previewReservations() { return preview_(readReservationPlans_(reservationSheet_(),new Date())); }
function sendReservations() {
return runLocked_(() => {
const s=reservationSheet_(), now=new Date(), plans=readReservationPlans_(s,now);
// 全候補の画像を投稿前に確認。Driveの同名検索は使いません。
const blobs=plans.map(p=>p.file ? fileBlob_(p.file) : null);
plans.forEach((p,i) => once_('reservation',p.room,p,() => {
if (JSON.stringify(s.getRange(p.row,1,1,6).getValues()[0])!==JSON.stringify(p.source)) throw new Error('予約行が変更されています');
sendReservationPlan_(p,blobs[i]);
// 成功後にも入力を照合し、送信日時の1セルだけを書き込みます。
if (JSON.stringify(s.getRange(p.row,1,1,6).getValues()[0])!==JSON.stringify(p.source)) throw new Error('予約行が変更されています');
s.getRange(p.row,6).setValue(now);
}));
});
}
function sendSingleReservation() {
return runLocked_(() => {
const input=required_('DUE_AT');
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/.test(input)) throw new Error('予約日時にはISO形式でタイムゾーンを付けてください');
const due=new Date(input);
const p=reservationPlans_([[required_('JOB_ID'),due,required_('CHATWORK_ROOM_ID'),required_('MESSAGE_TEXT'),'', '']],new Date())[0];
if (p) once_('single',p.room,p,()=>sendReservationPlan_(p,null));
});
}
function sendClock() {
return runLocked_(() => {
const now=new Date(), room=numericId_(required_('CHATWORK_ROOM_ID'));
const bucket=jst_(now,'yyyy-MM-dd HH'), text=bucket+':00の時刻通知(実行時刻には幅があります)';
const p={id:bucket,text:text,payload:{text:text}};
once_('clock',room,p,()=>chatPost_(room,'messages',{body:text,self_unread:1},'message_id'));
});
}
function sendPracticeImage() {
return runLocked_(() => {
const room=numericId_(required_('CHATWORK_ROOM_ID')), file=required_('PRACTICE_FILE_ID');
const p={id:required_('JOB_ID'),room:room,file:file,text:'練習用の画像です',payload:{file:file,text:'練習用の画像です'}};
const blob=fileBlob_(file); once_('image',room,p,()=>sendReservationPlan_(p,blob));
});
}
単発予約と時刻通知の設定
| 用途 | 関数と設定 |
|---|---|
| 単発の本文予約 | JOB_ID、DUE_AT、CHATWORK_ROOM_ID、MESSAGE_TEXT、CHATWORK_API_TOKENを設定してsendSingleReservation |
| DUE_ATの書き方 | 例は2026-10-02T10:00:00+09:00。実行前に今後の練習日時へ変更 |
| 毎時の時刻通知 | CHATWORK_ROOM_IDとCHATWORK_API_TOKENを設定してsendClock |
| 練習用画像1枚 | JOB_ID、PRACTICE_FILE_ID、CHATWORK_ROOM_ID、CHATWORK_API_TOKENを設定してsendPracticeImage |
JOB_IDはpractice-1のように各処理へ付ける英数字等の識別子です。
単発の予約は未来なら投稿せず、予定時刻を過ぎて24時間以内なら候補になります。
文字列の日時にはタイムゾーンを付け、過去の例の日付をそのまま送信設定に使わないでください。
sendClockは実行された日本時間の時間帯につき1回だけ投稿し、日付が変わると別の対象になります。
時間主導型トリガーのeveryHours(1)相当を1つ設定しますが、毎時0分ちょうどの到着は保証しません。
画像と予約シートを用意する
画像の練習にはZIPのpractice.pngを使えます。
個人情報を含まない模様で、許可された練習用Driveへ保存したファイルのIDを指定します。
同じ名前のファイルを検索して最初の1件を送る処理は使いません。
画像はPNG・JPEG・GIFで、5MB以下に限定します。
画像と本文がある場合はファイル投稿のmessageへ本文を付け、本文を別に二重投稿しません。
MP4を画像のMIMEタイプへ見立てて送る旧例は対象外にします。
| 予約シートの列 | 条件 |
|---|---|
| 予約シートのA列・予約ID | practice-1など、重複しない識別子 |
| B列・予約日時 | 日付時刻型で入力。予定を過ぎて24時間以内が候補 |
| C列・ルームID | 数字のID。桁を壊さないよう文字列として入力 |
| D列・本文 | 文字列。画像だけなら空欄でも可 |
| E列・DriveファイルID | 画像のID。本文だけなら空欄 |
| F列・送信日時 | 未送信は空欄。成功後にコードが日付時刻を書き込む |
SPREADSHEET_IDで練習用の表を指定し、previewReservationsで候補を確認します。
元の表をコピーしてから、A列とF列を含む6列構成へ移行します。
既存の5列構成へそのまま貼り付けず、予約日時と送信日時を日付時刻型で入力します。
スプレッドシートとApps ScriptのタイムゾーンをAsia/Tokyoにそろえ、現在の練習日時と許可されたルームIDを使います。
送信関数はsendReservationsで、5分間隔のトリガーを1つだけ設定する例です。
候補の画像は全件を送信前に確認します。
入力保持と失敗時の確認結果
| 条件 | 結果 |
|---|---|
| 正常な本文予約 | POSTは1回。成功した行のF列だけを更新 |
| 画像付き本文 | ファイルPOSTを1回行い、本文の別POSTは行わない |
| 通信・送信日時の保存に失敗 | 予約入力を消去せず、結果不明として自動再送を止める |
| 送信中に予約行が変更された | 入力を上書きせず停止 |
| 画像が見つからない・形式やサイズが対象外 | 投稿前に停止 |
| 空の予約表・未来の予約 | 投稿しない |
| 重複ID・文字列の日時・24時間超の未送信予約 | 入力を消さずエラーで停止 |
F列の書き込みに失敗しても投稿先へ届いている場合があります。
送信先と予約表を照合し、処理済み記録とF列をそろえてから再開します。
スクリプトのロックは人の編集を止めないため、確認とセル更新の間の編集を完全に排除するものではありません。
送信中は練習表を編集せず、処理後にも照合します。
同じ予約IDの本文・日時・画像IDを変えると停止します。
画像の内容を同じDrive IDのまま変更する運用も避け、確認した練習ファイルを使います。
送信済みをF列だけ空欄に戻しても、保存済みの完了記録があれば再投稿しません。
2026年10月2日にChatworkのファイル投稿と5MB上限、Driveファイルの仕様を確認しました。
ライブラリキーの追加は不要です。
送信結果が不明なときの停止と戻し方
スクリプトプロパティの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を使い、送信先と現行の仕様を確認します。









