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

Chatwork│Slackへメッセージを転送する方法

Chatworkで自分宛てのメッセージだけを選び、Slackへ転送する例です。

旧コードは1件が条件に合うとルーム内の全件を繰り返し投稿していたため、改訂版はTo記法で対象を選び、対象1件から投稿1件を作ります。

目次

自分宛てと似た数字を区別する練習

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

各article-番号.jsを別々のApps Scriptプロジェクトへ保存します。

article-161.jsを保存し、demoForwardを実行します。

練習用ID101へのToだけを選び、本文に101と書かれただけの連絡と、1010へのToを除きます。

Chatwork・Slackへアクセスしません。

// 各記事を別々の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 slackUrl_() {
  const u = required_('SLACK_WEBHOOK_URL');
  if (!/^https:\/\/hooks\.slack\.com\/services\/[^/]+\/[^/]+\/[^/]+$/.test(u)) throw new Error('Webhookの形式を確認してください');
  return u;
}
function slackPost_(text, url) {
  if (text.length > 2800) throw new Error('通知文が長すぎます');
  const escaped = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
  const r = UrlFetchApp.fetch(url, {method:'post', contentType:'application/json',
    payload:JSON.stringify({text:escaped, blocks:[{type:'section',text:{type:'plain_text',text:text,emoji:false}}]}), muteHttpExceptions:true});
  if (r.getResponseCode() !== 200 || r.getContentText().trim() !== 'ok') throw new Error('Slackの応答を確認してください');
}
function directText_(body) {
  let depth=0, start=0, out='';
  for (const m of body.matchAll(/\[(\/?qt)\]/g)) {
    if (depth===0) out+=body.slice(start,m.index);
    depth+=m[1]==='qt' ? 1 : -1;
    if (depth<0) throw new Error('引用の形式が不正です');
    start=m.index+m[0].length;
  }
  if (depth) throw new Error('引用が閉じられていません');
  return out+body.slice(start);
}
function forwardPlans_(messages, room, myId) {
  const who=numericId_(myId), source=numericId_(room);
  if (!Array.isArray(messages)) throw new Error('メッセージ配列ではありません');
  const seen=new Set(), plans=[];
  for (const m of messages) {
    const id=numericId_(m.message_id);
    if (seen.has(id)) continue;
    seen.add(id);
    if (typeof m.body!=='string' || !m.account || typeof m.account.name!=='string') throw new Error('メッセージ形式が不正です');
    // 本文内の数字だけではなく、ChatworkのTo記法を完全一致で確認します。
    const recipients=[...directText_(m.body).matchAll(/\[To:(\d+)(?:\s[^\]]*)?\]/g)].map(x=>x[1]);
    if (!recipients.includes(who)) continue;
    const text='【Chatworkから転送】ルーム ' + source + '\n' + m.account.name + '\n' + m.body.slice(0,1800);
    plans.push({id:source+':'+id,text:text,payload:{text:text}});
  }
  return plans;
}
function readForwardPlans_() {
  const roomIds=required_('CHATWORK_ROOM_IDS').split(',').map(s=>numericId_(s.trim()));
  if (new Set(roomIds).size!==roomIds.length) throw new Error('ルームIDが重複しています');
  const mine=required_('CHATWORK_MY_ID'); const plans=[];
  for (const room of roomIds) {
    // force=0の取得差分を進めず、最新100件を再取得して保存済みIDで判定します。
    const r=UrlFetchApp.fetch('https://api.chatwork.com/v2/rooms/'+room+'/messages?force=1',{
      method:'get',headers:{'X-ChatworkToken':required_('CHATWORK_API_TOKEN')},muteHttpExceptions:true});
    if (r.getResponseCode()===204) continue;
    if (r.getResponseCode()!==200) throw new Error('Chatworkの取得応答を確認してください');
    plans.push(...forwardPlans_(JSON.parse(r.getContentText()),room,mine));
  }
  return plans;
}
function demoForward() {
  const p=forwardPlans_([
    {message_id:'1',account:{name:'練習 太郎'},body:'[To:101]練習の連絡'},
    {message_id:'2',account:{name:'練習 花子'},body:'数字101だけの連絡'},
    {message_id:'3',account:{name:'練習 花子'},body:'[To:1010]別の宛先'}],'500','101');
  Logger.log(JSON.stringify(p)); return p;
}
function previewForward() { return preview_(readForwardPlans_()); }
function ChatworkToSlack() {
  return runLocked_(() => {
    const url=slackUrl_(), plans=readForwardPlans_();
    for (const p of plans) once_('forward',url,p,() => slackPost_(p.text,url));
  });
}

複数ルームと送信先の設定

スクリプトプロパティ条件
CHATWORK_ROOM_IDS許可されたルームIDをカンマ区切りで指定。例は500,501
CHATWORK_MY_IDToの対象にする自分のChatwork ID
CHATWORK_API_TOKEN対象ルームを参照できるAPIトークン
SLACK_WEBHOOK_URL転送を許可された練習用チャンネルのIncoming Webhook

previewForwardは対象ルームの最新メッセージを読み、転送候補のIDと文字数を表示します。

本文や氏名をログへ出さず、元のChatworkで内容を確認します。

ChatworkToSlackが送信関数で、旧コードのgetChatworkや存在しない別名を選ばないようにします。

本文中の数字検索をやめ、To記法のIDを完全一致で確認します。

引用ブロックの中にしか自分宛てToがないメッセージは対象外にします。

送信者名と対象本文の先頭1800文字を転送し、添付ファイルは転送しません。

対象選択と投稿回数の確認結果

条件結果
3件のうち2件が自分宛て対象2件だけを2回投稿し、関係ない本文を含めない
本文の数字101・1010へのTo対象に含めない
引用内のTo・入れ子の引用直接の宛先と扱わない
同じルームとメッセージIDを再取得完了記録があれば再投稿しない
同じメッセージIDを別ルームから取得ルームIDとの組で区別
204・401・403・429・JSON形式の異常0件は続行、失敗は投稿前に停止

取得はforce=1で各ルームの最新100件までです。

過去全件の転送ではなく、初回は取得できた過去の対象メッセージも候補になります。

1回の間に100件を超える投稿があると未取得の対象を取り逃がすため、大量の投稿を全件回収する仕組みとして使わないでください。

force=0の取得差分だけに頼ると、取得後にSlack側で失敗した対象を再取得できないことがあります。

最新範囲の再取得と保存済みIDを組み合わせ、通信結果が不明な対象は再送せず止めます。

Slackの本文はplain_textのブロックにし、転送文に含まれるSlackの通知記法をそのまま通知指定として実行しない形にしています。

Webhookは発行時のチャンネルへ送るため、旧コードのchannelやusernameによる上書きは使いません。

2026年10月2日にChatworkの最新100件とforceの仕様、SlackのWebhook仕様を確認しました。

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

スクリプトプロパティの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を使い、送信先と現行の仕様を確認します。

Google Apps Scriptを活用することで、追加サービスを契約せずにChatworkからSlackへの通知転送を実現できます。

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

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

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

仕事用PCの選び方

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