LINE WORKS カレンダーを Google カレンダーに自動同期する(GAS)
はじめに
LINE WORKS をメインで使っているが、Apple Watch でも予定を確認したい。
Apple Watch は Google カレンダーと連携できるため、LINE WORKS → Google カレンダーへの自動同期を GAS(Google Apps Script)で実現した。
同じことをしようとして詰まっている人が多そうだったので、ハマりポイントも含めてまとめる。
Google カレンダーにつながれば大体そこから同期できるはず。
完成した機能
| 機能 | 状態 |
|---|---|
| LW → Google 新規イベント同期 | ✅ |
| LW → Google 削除の反映 | ✅ |
| LW → Google 編集(時間変更等)の反映 | ✅ |
| 重複登録の防止 | ✅ |
| 今日から1年先までの取得 | ✅ |
| 1時間ごとの自動同期(トリガー) | ✅ |
事前準備
LINE WORKS Developer Console でアプリを作成
https://dev.worksmobile.com にアクセスし、アプリを新規作成する。
設定項目:
-
OAuth Scopes:
calendarとuser.readにチェック - Redirect URL:GAS のデプロイ URL(後述)
作成後、以下の情報をメモしておく。
- Client ID
- Client Secret
GAS プロジェクトの作成
Google ドライブ → 新規 → その他 → Google Apps Script でプロジェクトを作成。
プロジェクト設定で Chrome V8 ランタイムを有効にする こと(BigInt を使うため必須)。
ハマりポイント
① Service Account ではユーザーのカレンダーにアクセスできない
Developer Console には Service Account 認証という仕組みがある。
最初はこれを使ってトークンを取得しようとしたが、ユーザーのカレンダーを取得しようとすると常に [] が返ってくる。
原因: LINE WORKS スタンダードプランでは、Service Account に対してユーザーカレンダーへの委任アクセスを設定する管理者コンソール機能がない。
解決: OAuth 2.0 認証コードフロー(ユーザーが一度ブラウザで認証する方式)に切り替える。
② カレンダー一覧 API のエンドポイントが特殊
/v1.0/users/{userId}/calendars(複数形)を叩くと MISSING PARAMETER: calendarIds というエラーが返ってくる。これはカレンダーIDを指定して取得するエンドポイントであり、一覧取得用ではなかった。
解決: ユーザーの基本カレンダーは /v1.0/users/{userId}/calendar(単数形) で取得できる。
③ イベント登録のリクエストボディ形式
ドキュメントを読んでいると分かりにくいが、イベント登録時のボディは eventComponents という配列でラップする必要がある。
NG:
{
"summary": "タイトル",
"start": { "dateTime": "2026-06-03T09:00:00" }
}
OK:
{
"eventComponents": [{
"summary": "タイトル",
"start": { "dateTime": "2026-06-03T09:00:00", "timeZone": "Asia/Tokyo" },
"end": { "dateTime": "2026-06-03T10:00:00", "timeZone": "Asia/Tokyo" }
}]
}
④ 日時フォーマットが用途によって違う
| 用途 | 形式 |
|---|---|
| イベント登録(start/end) |
YYYY-MM-DDTHH:mm:ss(タイムゾーンオフセットなし) |
| クエリパラメータ(fromDateTime 等) |
YYYY-MM-DDTHH:mm:ss+09:00(オフセット必須) |
⑤ イベント取得範囲は31日以内
fromDateTime と untilDateTime の間は 31 日以内という制限がある。
1年分取得したい場合は 30 日ごとに分割して複数回リクエストする必要がある。
セットアップ手順
- Developer Console でアプリ作成(OAuth Scopes:
calendar・user.read) - 下記コードを GAS に貼り付け、設定欄を埋める
- GAS をウェブアプリとしてデプロイ(アクセス:全員)
- デプロイ URL を Developer Console の Redirect URL に設定して保存
- GAS で
authorize()を実行 → ログの URL をブラウザで開く - LINE WORKS にログインして認証を許可(「認証成功」と表示されれば完了)
-
syncLineWorksToGoogle()を実行して動作確認 - GAS のトリガーに
syncLineWorksToGoogleを設定(1時間おき推奨)
Developer Console
https://dev.worksmobile.com
Google Apps Script
https://script.google.com/home
コード
// ==========================================
// 作成日 : 2026-06-03
// 目的 : LINE WORKS カレンダー → Google カレンダー 同期
// 使用方法 :
// 【初回セットアップ】
// 1. GASを「ウェブアプリ」としてデプロイ(アクセス:全員)
// 2. LINE WORKS Developer Console の Redirect URL にデプロイURLを設定
// 3. authorize() を実行 → ログのURLをブラウザで開く
// 4. LINE WORKSにログインして認証を許可
// 【同期実行】
// 5. syncLineWorksToGoogle() を実行して確認
// 6. GASのトリガーで syncLineWorksToGoogle を1時間おきに設定
// ==========================================
// ========== 設定欄 ==========
const CLIENT_ID = 'YOUR_CLIENT_ID';
const CLIENT_SECRET = 'YOUR_CLIENT_SECRET';
const USER_ID = 'yourname@yourdomain'; // LWのログインID
const REDIRECT_URI = 'https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec';
//デプロイで作られるwebアプリURL
// ============================
const LW_AUTH_URL = 'https://auth.worksmobile.com/oauth2/v2.0/authorize';
const LW_TOKEN_URL = 'https://auth.worksmobile.com/oauth2/v2.0/token';
// ==========================================
// 初回のみ実行:認証URL生成
// ==========================================
function authorize() {
const authUrl = LW_AUTH_URL
+ '?response_type=code'
+ '&client_id=' + encodeURIComponent(CLIENT_ID)
+ '&redirect_uri=' + encodeURIComponent(REDIRECT_URI)
+ '&scope=calendar%20user.read'
+ '&state=lwsync';
Logger.log('=== 以下のURLをブラウザで開いてください ===');
Logger.log(authUrl);
}
// ==========================================
// LINE WORKS からのコールバック受信
// ==========================================
function doGet(e) {
const code = e.parameter.code;
const error = e.parameter.error;
if (error) return HtmlService.createHtmlOutput('❌ 認証エラー: ' + error);
if (!code) return HtmlService.createHtmlOutput('❌ 認証コードがありません');
const res = UrlFetchApp.fetch(LW_TOKEN_URL, {
method: 'post',
contentType: 'application/x-www-form-urlencoded',
payload: {
grant_type : 'authorization_code',
code : code,
client_id : CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri : REDIRECT_URI
},
muteHttpExceptions: true
});
const json = JSON.parse(res.getContentText());
if (!json.access_token) {
return HtmlService.createHtmlOutput('❌ トークン取得失敗: ' + res.getContentText());
}
const props = PropertiesService.getScriptProperties();
props.setProperty('LW_ACCESS_TOKEN', json.access_token);
props.setProperty('LW_REFRESH_TOKEN', json.refresh_token || '');
props.setProperty('LW_TOKEN_EXPIRY',
String(Date.now() + (parseInt(json.expires_in || '86400') - 300) * 1000)
);
return HtmlService.createHtmlOutput(
'<h2>✅ 認証成功!</h2><p>このウィンドウを閉じてGASに戻ってください。</p>'
);
}
// ==========================================
// アクセストークン取得(期限切れなら自動更新)
// ==========================================
function getLineWorksToken() {
const props = PropertiesService.getScriptProperties();
const accessToken = props.getProperty('LW_ACCESS_TOKEN');
const refreshToken = props.getProperty('LW_REFRESH_TOKEN');
const expiry = parseInt(props.getProperty('LW_TOKEN_EXPIRY') || '0');
if (!accessToken) throw new Error('未認証です。authorize() を実行してください。');
if (Date.now() < expiry) return accessToken;
Logger.log('トークンを更新中...');
const res = UrlFetchApp.fetch(LW_TOKEN_URL, {
method: 'post',
contentType: 'application/x-www-form-urlencoded',
payload: {
grant_type : 'refresh_token',
refresh_token: refreshToken,
client_id : CLIENT_ID,
client_secret: CLIENT_SECRET
},
muteHttpExceptions: true
});
const json = JSON.parse(res.getContentText());
if (!json.access_token) throw new Error('トークン更新失敗。再度 authorize() を実行してください。');
props.setProperty('LW_ACCESS_TOKEN', json.access_token);
if (json.refresh_token) props.setProperty('LW_REFRESH_TOKEN', json.refresh_token);
props.setProperty('LW_TOKEN_EXPIRY',
String(Date.now() + (parseInt(json.expires_in || '86400') - 300) * 1000)
);
return json.access_token;
}
// ==========================================
// 日時フォーマット
// ==========================================
// イベント登録用(オフセットなし)
function toLwDateString(date) {
const jst = new Date(date.getTime() + 9 * 60 * 60 * 1000);
return jst.toISOString().substring(0, 19);
}
// クエリパラメータ用(+09:00 必須)
function toLwParamString(date) {
const jst = new Date(date.getTime() + 9 * 60 * 60 * 1000);
return jst.toISOString().substring(0, 19) + '+09:00';
}
// ==========================================
// LINE WORKS → Google カレンダー 同期(メイン)
// ==========================================
function syncLineWorksToGoogle() {
try {
const token = getLineWorksToken();
// 基本カレンダーID取得(※/calendar 単数形エンドポイントを使う)
const calRes = UrlFetchApp.fetch(
'https://www.worksapis.com/v1.0/users/' + encodeURIComponent(USER_ID) + '/calendar',
{ headers: { Authorization: 'Bearer ' + token }, muteHttpExceptions: true }
);
const calendarId = JSON.parse(calRes.getContentText()).calendarId;
Logger.log('LWカレンダーID: ' + calendarId);
// 31日制限のため30日ずつ分割して365日分取得
const now = new Date();
const allEvents = [];
const CHUNK_DAYS = 30;
const TOTAL_DAYS = 365;
for (var offset = 0; offset < TOTAL_DAYS; offset += CHUNK_DAYS) {
var from = new Date(now.getTime() + offset * 24 * 60 * 60 * 1000);
var until = new Date(now.getTime() + Math.min(offset + CHUNK_DAYS, TOTAL_DAYS) * 24 * 60 * 60 * 1000);
var chunkRes = UrlFetchApp.fetch(
'https://www.worksapis.com/v1.0/users/' + encodeURIComponent(USER_ID)
+ '/calendars/' + calendarId + '/events'
+ '?fromDateTime=' + encodeURIComponent(toLwParamString(from))
+ '&untilDateTime=' + encodeURIComponent(toLwParamString(until)),
{ headers: { Authorization: 'Bearer ' + token }, muteHttpExceptions: true }
);
if (chunkRes.getResponseCode() !== 200) continue;
var chunk = JSON.parse(chunkRes.getContentText());
if (chunk.events) chunk.events.forEach(function(e) { allEvents.push(e); });
}
Logger.log('LWイベント合計: ' + allEvents.length + '件');
if (allEvents.length === 0) return;
const gCal = CalendarApp.getDefaultCalendar();
const props = PropertiesService.getScriptProperties();
const mapping = JSON.parse(props.getProperty('LW_GOOGLE_MAPPING') || '{}');
const currentLwIds = {};
let created = 0, deleted = 0, skipped = 0;
allEvents.forEach(function(evGroup) {
(evGroup.eventComponents || []).forEach(function(ev) {
const lwId = ev.eventId;
const title = ev.summary || '(タイトルなし)';
var startDt, endDt, isAllDay;
if (ev.start.date) {
isAllDay = true;
startDt = new Date(ev.start.date + 'T00:00:00+09:00');
endDt = new Date(ev.end.date + 'T00:00:00+09:00');
} else {
isAllDay = false;
startDt = new Date(ev.start.dateTime);
endDt = new Date(ev.end.dateTime);
}
currentLwIds[lwId] = true;
const lwUpdated = ev.updatedTime ? ev.updatedTime.dateTime : '';
if (mapping[lwId]) {
if (mapping[lwId].updatedTime === lwUpdated) { skipped++; return; }
// 変更あり → 古いGoogleイベントを削除してから再作成
const oldStart = new Date(mapping[lwId].startMs - 60000);
const oldEnd = new Date(mapping[lwId].startMs + 60000);
gCal.getEvents(oldStart, oldEnd).filter(function(e) {
return e.getTitle() === mapping[lwId].title;
}).forEach(function(e) { e.deleteEvent(); });
Logger.log('[更新] ' + title);
} else {
Logger.log('[登録] ' + title);
created++;
}
if (isAllDay) {
gCal.createAllDayEvent(title, startDt);
} else {
gCal.createEvent(title, startDt, endDt);
}
mapping[lwId] = { title: title, startMs: startDt.getTime(), endMs: endDt.getTime(), isAllDay: isAllDay, updatedTime: lwUpdated };
});
});
// LWから削除されたイベントをGoogleからも削除
Object.keys(mapping).forEach(function(lwId) {
if (currentLwIds[lwId]) return;
const info = mapping[lwId];
gCal.getEvents(new Date(info.startMs - 60000), new Date(info.startMs + 60000))
.filter(function(e) { return e.getTitle() === info.title; })
.forEach(function(e) { e.deleteEvent(); });
Logger.log('[削除] ' + info.title);
delete mapping[lwId];
deleted++;
});
props.setProperty('LW_GOOGLE_MAPPING', JSON.stringify(mapping));
Logger.log('✅ 同期完了: 登録=' + created + ' 削除=' + deleted + ' スキップ=' + skipped);
} catch (e) {
Logger.log('❌ エラー: ' + e.toString());
}
}
// ==========================================
// ユーティリティ
// ==========================================
// マッピングリセット(再同期したいときに使用)
function resetMapping() {
PropertiesService.getScriptProperties().deleteProperty('LW_GOOGLE_MAPPING');
Logger.log('マッピングをリセットしました');
}
おわりに
LINE WORKS の API ドキュメントは日本語で整備されているものの、実際に動かすといくつかの罠があった。特に /calendar(単数形)のエンドポイントの存在と eventComponents 配列形式は、ドキュメントだけでは気づきにくい。
同じ構成で社内のメンバーに展開する場合は、コードの USER_ID を各自のLWメールアドレスに変えてデプロイし、そのDeployment URLをDeveloper ConsoleのRedirect URLに追加登録するだけでよい。
管理者がDeveloper Consoleを快く設定してくれるように普段からコミュケーションを取って置くことも大事。