Amazonで商品を見るセール会場へ

Chatwork│GASを使った投稿方法 4選

Chatworkへ予約投稿する場合は、送信前に予約表を消去せず、成功した行の送信日時だけを記録します。

改訂版は現在のApps Scriptで動かないfor eachを置き換え、予約本文・日時・画像を残したまま失敗を確認できる例です。

目次

未来の予約を除く練習

通知・予約投稿の練習コードと画像をダウンロード(ZIP)。

各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列・予約IDpractice-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を使い、送信先と現行の仕様を確認します。

次に学ぶ・作業環境を選ぶ

学習を続けたい方や、作業環境を整えたい方は、目的に合うガイドをご覧ください。

仕事効率化のおすすめ書籍

仕事用PCの選び方

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次