Chatworkで自分宛てのメッセージだけを選び、Slackへ転送する例です。
旧コードは1件が条件に合うとルーム内の全件を繰り返し投稿していたため、改訂版はTo記法で対象を選び、対象1件から投稿1件を作ります。
自分宛てと似た数字を区別する練習
各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,'&').replace(/</g,'<').replace(/>/g,'>');
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_ID | Toの対象にする自分の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への通知転送を実現できます。
