1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LINE WORKSの予定をGoogleカレンダーに流し込む

1
Posted at

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 Scopescalendaruser.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日以内

fromDateTimeuntilDateTime の間は 31 日以内という制限がある。
1年分取得したい場合は 30 日ごとに分割して複数回リクエストする必要がある。


セットアップ手順

  1. Developer Console でアプリ作成(OAuth Scopes:calendaruser.read
  2. 下記コードを GAS に貼り付け、設定欄を埋める
  3. GAS をウェブアプリとしてデプロイ(アクセス:全員)
  4. デプロイ URL を Developer Console の Redirect URL に設定して保存
  5. GAS で authorize() を実行 → ログの URL をブラウザで開く
  6. LINE WORKS にログインして認証を許可(「認証成功」と表示されれば完了)
  7. syncLineWorksToGoogle() を実行して動作確認
  8. 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を快く設定してくれるように普段からコミュケーションを取って置くことも大事。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?