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

Chatwork│平日の特定の時間帯に当番の人へ通知する方法

当番の通知は、担当者・営業日・送信する時間帯を確認してから投稿します。

改訂版では存在しないTouban関数を呼ぶ設定を外し、シートを選んでから読み取り、通知に成功するまで当番の順番を変更しません。

目次

2名の当番表で通知と次の順番を確かめる

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

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

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

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

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

仕事用PCの選び方

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