Chatworkで自分宛てのメッセージだけを選び、Slackへ転送する例です。
旧コードは1件が条件に合うとルーム内の全件を繰り返し投稿していたため、改訂版はTo記法で対象を選び、対象1件から投稿1件を作ります。
ChatworkからSlackへ転送する前の確認順
先に全体の順番を確認しておくと、誤送信を防ぎやすくなります。
内容は、このページの説明にもとづく目安です。
Chatwork、Slack、Apps Scriptの画面や仕様は変わることがあるため、最新の公式の説明で確認してください。
- 練習用ZIPのコードを、別々のApps Scriptプロジェクトに保存する。
- まず練習用のdemoForwardを実行し、練習用ID 101へのToだけが選ばれることを確認する。この段階では、ChatworkにもSlackにもアクセスしない。
- スクリプトプロパティに、許可されたルームID、自分のChatwork ID、APIトークン、転送を許可された練習用チャンネルのIncoming Webhookを設定する。
- previewForwardで、転送候補のIDと文字数だけを表示して確認する。本文や氏名はログに出さず、元のChatworkで内容を確認する。
- 練習用のチャンネルで、ChatworkToSlackを実行する。対象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にし、設定したトリガーを停止して、Slackの投稿とスクリプトプロパティの送信記録を照合します。
この転送例はスプレッドシートを使いません。
送信が確認できた記録はdoneへ整え、未送信を確認できた場合だけ再実行の準備をします。
同じルーム・メッセージIDについて、保存済みの転送文と今回作った転送文が異なると停止します。
この例には予約日時の設定はありません。
また本文1800文字より後ろだけの変更は転送文に含まれず、変更検出の対象外です。
送信済みの記録を消したり、新しいIDを付けたりすると再送できてしまうため、再実行の代わりに記録を消さないでください。
ロックは同じプロジェクト内の競合を防ぐもので、別のプロジェクトや旧トリガーからの投稿は防ぎません。
移行時は旧トリガーを止め、長期運用では保存件数と整理方法を決めます。
戻す際は新しいトリガーを止め、保存したコード・設定・スクリプトプロパティの記録を参照します。
記録の値を変更する前に元の内容を保存してください。
処理済みの対象がある場合は、旧コードで同じ対象を再送しないように確認します。
検証した範囲と実サービスの確認
2026年10月2日の記録では、通知・予約投稿の練習ZIP全体について、Node.jsでApps Script・HTTP応答・ロック・保存状態・シートを模した83ケースを確認しています。
83ケースすべてがこの記事の転送機能だけのテストという意味ではありません。
ZIPを新しいフォルダーへ展開し、node test_integration.cjsを実行すると同じ条件を確認できます。
Googleの認可、実シートの更新、Chatwork・Slackへの実投稿、Driveからの実画像アップロード、トリガーの実行時刻は未検証です。
実サービスを使う際は、練習用の入力・送信先・認可範囲を確認してから1回手動で実行し、投稿先と保存状態を照合します。
関連記事・旧実装の参考資料
外部の参考コードと旧実装の説明は、今回の練習ZIPと内容が異なる場合があります。
現在の練習では本稿のZIPを使い、送信先と現行の仕様を確認します。




Apps ScriptからChatwork APIとSlack Incoming Webhookを呼ぶ構成です。
利用できるAPI・アプリの権限、各サービスのプランと実行上限を確認してください。
追加の連携サービスを使わない構成でも、無制限の送信や全件回収はできません。
よくある質問:ChatworkからSlackへ転送するときのチェックリスト
なぜ、旧コードから改訂したのですか?
旧コードは、1件が条件に合うと、ルーム内の全件を繰り返し投稿していました。
改訂版は、To記法で対象を選び、対象1件から投稿1件を作ります。
本文に「101」と書かれただけのメッセージは、対象ですか?
対象に含めません。
本文中の数字検索をやめ、To記法のIDを完全一致で確認します。
1010へのToも、対象に含めません。
引用の中にだけ自分宛てのToがある場合は?
対象外にします。
引用ブロックや入れ子の引用は、直接の宛先として扱いません。
添付ファイルは転送されますか?
転送されません。
送信者名と、対象本文の先頭1800文字を転送します。
過去の全メッセージを、転送できますか?
できません。
取得は、各ルームの最新100件までです。
1回の間に100件を超える投稿があると、未取得の対象を取り逃がすため、大量の投稿を全件回収する仕組みとして使わないでください。
Slackで送信結果が不明なときは、どうしますか?
再送せず、止めてください。
同じメッセージを重複して投稿しないよう、ページでは完了記録がある場合は再投稿しない設計を説明しています。
