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

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

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

このコードは当番表を読み、通知の成功後に順番を更新します。

タスク追加を有効にした場合は、その成功も必要です。

通知とシート更新は別の処理のため、途中で失敗したら投稿先と当番表を照合します。

目次

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にそろえます。

getEventsForDayの公式仕様では、日付の範囲をカレンダーのタイムゾーンで判定します。

コードが自動確認するのはApps Script側だけです。

休業日専用のカレンダーを使い、通常の会議などを登録しないでください。

予定が1件でもある日は休業日になります。

休業日カレンダーの予定がある日と土日を除き、日本時間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日に確認しました。

練習から送信設定までの確認手順

練習の段階から順に当てはめると、実際のルームへ通知する前に確認することが決まります。

2026年10月2日の記事の確認内容にもとづく目安です。

実際のChatworkへの投稿やタスク追加は、この記事では未検証で、通知・タスク・当番表の3つが、そろっているかの確認が必要です。

  1. まず、架空の2名で、練習する。article-2506.jsを別のApps Scriptプロジェクトに保存し、demoDutyを実行する。練習 太郎への通知文と、次の順番を表示するだけで、外部への投稿やシートの書き込みは行わない。
  2. 当番シートを用意する。A列に名前、B列にChatworkのID、C列に通知順(1から人数分まで、重複なく)を入力する。本文シートのA2に、通知文を入力する。実運用の前に、許可された担当者のIDへ変更する。
  3. Apps Scriptの「プロジェクトの設定」の「スクリプト プロパティ」にSPREADSHEET_ID、HOLIDAY_CALENDAR_ID、CHATWORK_ROOM_ID、CHATWORK_API_TOKENを保存する。トークンは本文や共有ログに載せない。SEND_ENABLEDは確認が終わるまでfalseとし、ADD_TASKはタスクが必要な場合だけtrueにする。シート・スクリプト・休業日カレンダーのタイムゾーンをAsia/Tokyoにそろえる。
  4. 自動化するなら、notifyDutyの時間主導型トリガーを、5分間隔で1つだけ作る。実行が遅れて、対象の時間帯を過ぎると通知しないため、10:00ちょうどの到着は保証しない。
  5. 土日・休業日・時間帯の外は、通知と順番更新を行わない。同じ日に再実行しても、完了記録があれば、再投稿・再更新しない。
  6. 結果が不明なときは、SEND_ENABLEDをfalseにし、トリガーを停止して、通知・タスク・当番表の3つを照合する。

通知に成功するまで、当番の順番は変更されません。

通知だけ成功して、タスク追加や順番更新が失敗した場合は、手動でそろえてから、再開してください。

送信結果が不明なときの停止と戻し方

スクリプトプロパティのSEND_ENABLEDがtrueになるまで、送信関数は入力の取得前に停止します。

送信先を管理する本人の許可を得て、送信する内容と練習用の送信先を確かめてから有効にします。

通信前にsending、処理完了後にdoneを保存し、通信や記録に失敗した場合はunknownなどの状態を残して止めます。

成功応答がなくても投稿先へ届いている可能性があるため、自動で再送しません。

結果が不明な場合はSEND_ENABLEDをfalseにし、トリガーを停止して、投稿先とシートを照合します。

送信が確認できた記録はdoneへ整え、未送信を確認できた場合だけ再実行の準備をします。

このコードの処理IDは日本時間の日付です。

結果不明の記録がある同日に本文・担当者・当番表を変えると、入力変更として停止します。

一方、その日のdone記録があれば入力比較の前に終了します。

送信済みの記録を消したり、新しいIDを付けたりすると再送できてしまうため、再実行の代わりに記録を消さないでください。

ロックは同じプロジェクトで同じロックを使う実行同士を制御します。

別プロジェクト・旧トリガー・シートの手動編集は防ぎません。

当番表の編集は送信を無効にして実行が終わってから行います。

入力の再確認と書き込みは一体の処理ではないため、その間の変更を必ず検出できるわけではありません。

移行時は旧トリガーを止め、長期運用では保存件数と整理方法を決めます。

戻す際は新しいトリガーを止め、保存した旧コード・設定・シートのコピーを参照します。

処理済みの対象がある場合は、旧コードで同じ対象を再送しないように確認します。

検証した範囲と実サービスの確認

2026年10月2日の検証記録では、Node.jsでApps Script・HTTP応答・ロック・保存状態・シートを模した83ケースを確認しています。

2026年10月24日は本文コードとZIPのarticle-2506.jsが改行形式を除いて一致すること、公式APIと日時判定の仕様を照合しました。

今回83ケースの再実行や実サービスへの送信はしていません。

ZIPを新しいフォルダーへ展開し、node test_integration.cjsを実行すると同じ条件を確認できます。

Googleの認可、実シートの更新、Chatwork・Slackへの実投稿、Driveからの実画像アップロード、トリガーの実行時刻は未検証です。

実サービスを使う際は、練習用の入力・送信先・認可範囲を確認してから1回手動で実行し、投稿先と保存状態を照合します。

関連記事・旧実装の参考資料

外部の参考コードと旧実装の説明は、今回の練習ZIPと内容が異なる場合があります。

現在の練習では本稿のZIPを使い、送信先と現行の仕様を確認します。

スポンサーリンク

よくある質問:Chatworkの当番通知で迷ったときのチェックリスト

当番の順番は、いつ更新されますか?

通常の通知では、本文のPOSTを1回行い、成功した後に、C列の順番を更新します。

タスク追加で429や通信失敗が起きたときは、順番を更新せず、結果不明として停止します。

同じ日に、再実行するとどうなりますか?

完了の記録があれば、再投稿も、再更新もしません。

送信済みの記録を消したり、新しいIDを付けたりすると、再送できてしまうため、再実行の代わりに、記録を消さないでください。

土日や休業日は、通知されますか?

されません。

土日・休業日・時間帯の外では、通知と順番更新を行いません。

休業日は、HOLIDAY_CALENDAR_IDに、参照できる、会社の休業日のカレンダーを設定して判定します。

トリガーは、どう設定しますか?

notifyDutyの時間主導型トリガーを、5分間隔で1つだけ作ります。

送信前のプレビュー関数には、投稿のトリガーを付けません。

通知だけ成功して、タスク追加が失敗したときは?

通知・タスク・当番表の3つを照合し、手動でそろえてから、再開します。

タスクは、通知とは別のAPIです。

記事のコードは、実際にChatworkに投稿しますか?

最初に実行するdemoDutyは、外部への投稿やシートの書き込みを行いません。

実際のChatworkへの投稿やタスク追加は、この記事では未検証です。

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

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

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

仕事用PCの選び方

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

この記事を書いた人

須澤 元貴のアバター 須澤 元貴 代表社員

MOTOKI合同会社 代表社員。
アニメ・映像業界とゲーム業界で、2D・UI・3D・アニメーション制作、映像編集、ツール制作に携わってきました。
現在はWordPressを中心にWebサイトの制作とカスタマイズを手がけています。
著書に『Unity デザイナーズ・バイブル』『Unityゲーム プログラミング・バイブル 2nd Generation』(ボーンデジタル)。
岡山県出身、埼玉県在住。

目次