📝

LINEから始まるAI共生メモ環境(Obsidian × Google Drive × GitHub)

に公開

はじめに

PCでのテキスト管理にはObsidianが強力な選択肢になりますが、モバイル端末(iPhone/iPad)からの運用には、同期の面(有料プランやiCloudの制約)でハードルがあります。

また、私自身の職場環境の制約として在宅勤務が難しく、日中の思考整理や開発(iOSアプリ経由でのClaude Code運用など)はスマホが主戦場になっています。
そのため、 「スマホで素早くインプット・思考整理を行い、PCを開いた際にはスムーズにその作業を継続できる環境」 を模索し続けています。

目指しているのは、以下の2点を両立するメモ環境です。

  1. 人間の入力ハードルを極限まで下げること
  2. 自分の情報資産(DriveやGitHub)を、マルチデバイス(iosモバイル端末 + WindowsPC)からAIエージェント(Claude Code / Antigravity / Gemini)へシームレスに参照・同期させること

試行錯誤の末、今回紹介する方法でのメモが、現在のライフスタイルにぴったりかなとひとまず落ち着いたため、備忘録を兼ねてその仕組みを共有します。

本記事では、システム全体の構成図と、それを支える「LINE → Google Drive連携用」のGAS(Google Apps Script)のソースコードを公開します。

システム構成図

この環境の最大の特徴は、人間が書きやすいツール(LINE / Obsidian)と、AIやモバイルが読みやすい環境(Google Drive / GitHub)を綺麗に分離しつつ、裏側で常にほぼ同期させている点です。

この構成のいい点

  • スマホからの入力はLINEで1秒完結
    外出先で思い付いたことは、自分専用のLINE公式アカウントにテキストを送るだけ。
    裏側でGASが作動し、Google Drive内の「月別Markdownファイル(例: 2026-05.md)」の末尾にタイムスタンプ付きで自動追記されます。

  • AI(Gemini / Claude)からの圧倒的な参照しやすさ
    スマホの各種AIアプリからナレッジを参照する際、直接クラウド(Google DriveやGitHub)を叩きにいくのが一番スマートです。この構成なら、GeminiでもClaudeでも参照しやすく、常に最新のメモをコンテキストとして利用できます。

  • モバイル環境でのビューアーとして「GitHubアプリ」が優秀
    スマホやiPadから「自分が書いた過去のメモを見たい」ときは、GitHubの公式モバイルアプリを利用しています。Markdownのレンダリングが見やすくて、軽量かつ快適なコード・メモビューアーとして機能しています。(iosのファイルアプリからGoogleDriveを覗きに行くのもいいなと思ってます)

LINE入力を支えるGASソースコード

LINEから送られたメッセージを一時的にGoogleスプレッドシート(Queueシート)にバッファし、1分間隔の定時実行(Cron)でGoogle DriveのMarkdownへ一括追記する、重複・連投でも安定して追記できるようにしています。

// ==========================================
// 環境設定(ご自身の環境に合わせて書き換えてください)
// ==========================================
const LINE_ACCESS_TOKEN = 'YOUR_LINE_CHANNEL_ACCESS_TOKEN'; // LINEのチャネルアクセストークン
const DRIVE_FOLDER_ID   = 'YOUR_GOOGLE_DRIVE_FOLDER_ID';   // Markdownを保存するフォルダID
const SPREADSHEET_ID    = '';                               // 空欄のままでOK(コンテナバインド前提)
const SHEET_NAME        = 'Queue';                          // キュー保存用のシート名

/**
 * 1. [受信・キューイングフェーズ] LINEからのWebhook受付(重複検出・連投強化)
 */
function doPost(e) {
  const lock = LockService.getScriptLock();
  
  try {
    // ロック待ち時間を10秒に延長
    const hasLock = lock.tryLock(10000);
    if (!hasLock) {
      throw new Error('Lock timeout: サーバーが混雑しています。');
    }

    const json = JSON.parse(e.postData.contents);
    const events = json.events;
    
    if (!events || events.length === 0) {
      return ContentService.createTextOutput(JSON.stringify({ status: 'ok' })).setMimeType(ContentService.MimeType.JSON);
    }

    // スプレッドシートの準備
    const ss = SPREADSHEET_ID ? SpreadsheetApp.openById(SPREADSHEET_ID) : SpreadsheetApp.getActiveSpreadsheet();
    let sheet = ss.getSheetByName(SHEET_NAME);
    if (!sheet) {
      sheet = ss.insertSheet(SHEET_NAME);
      sheet.appendRow(['Timestamp', 'UserId', 'Message']);
    }

    // 重複検出のためのキャッシュサービスを呼び出し
    const cache = CacheService.getScriptCache();

    // テキストメッセージのみを抽出してキューに追加
    events.forEach(event => {
      if (event.type === 'message' && event.message.type === 'text') {
        const eventId = event.webhookEventId; // LINE固有のイベントID

        if (eventId) {
          // 1. キャッシュにすでにこのイベントIDが存在するか確認
          if (cache.get(eventId) !== null) {
            console.warn(`重複Webhookを検知、処理をスキップしました。ID: ${eventId}`);
            return; // 重複している場合はスプレッドシートへの追加をスキップ
          }
          
          // 2. 存在しない場合は、処理済みとしてキャッシュに登録(有効期限は最大の6時間 = 21600秒)
          cache.put(eventId, 'true', 21600);
        }

        const timestamp = new Date(event.timestamp);
        const userId = event.source.userId;
        const text = event.message.text;
        sheet.appendRow([timestamp, userId, text]);
      }
    });

    lock.releaseLock();
    return ContentService.createTextOutput(JSON.stringify({ status: 'ok' })).setMimeType(ContentService.MimeType.JSON);

  } catch (error) {
    console.error('doPostエラー: ' + error.message);
    lock.releaseLock();
    
    // エラー時はLINE側に429を返して再送を促す
    return ContentService.createTextOutput(JSON.stringify({ error: error.message }))
                         .setMimeType(ContentService.MimeType.JSON)
                         .setHttpStatusCode(429);
  }
}

/**
 * 2. [書き込みフェーズ] 定期実行バッチ処理(1分間隔のトリガーで起動)
 */
function cronJob() {
  const lock = LockService.getScriptLock();
  let tasks = [];
  let sheet;

  try {
    // I/O処理(MD書き込み)が終わるまでロックを保持するため、長めに待機
    if (!lock.tryLock(30000)) {
      console.warn('cronJob: 前回の処理が実行中のため、今回の実行をスキップします。');
      return;
    }

    const ss = SPREADSHEET_ID ? SpreadsheetApp.openById(SPREADSHEET_ID) : SpreadsheetApp.getActiveSpreadsheet();
    sheet = ss.getSheetByName(SHEET_NAME);
    if (!sheet) return;

    const lastRow = sheet.getLastRow();
    if (lastRow <= 1) {
      lock.releaseLock();
      return; // キューなし
    }

    // 1. キューデータを取得
    const numRows = lastRow - 1; // 取得する行数
    const range = sheet.getRange(2, 1, numRows, 3);
    tasks = range.getValues();
    
    // 2. Markdownファイルへの書き込みを実行(※ここではまだロックを解除しない)
    // 万が一ここでエラーが起きれば、下の削除処理は実行されず catch へ飛ぶ
    writeToMarkdown(tasks);

    // 3. 【書き込み成功時のみ】処理した行数分だけスプシから削除する
    sheet.deleteRows(2, numRows);
    
    // 全て成功してからロックを解放
    lock.releaseLock();

  } catch (error) {
    console.error('cronJob内でエラーが発生しました: ' + error.message);
    
    // エラー時は deleteRows が実行されていないため、データはスプシに「そのまま」残っている。
    // そのため、書き戻し(復元)の処理は一切不要です。
    lock.releaseLock();

    // LINE宛てにエラー通知を送信
    if (tasks.length > 0) {
      const targetUserId = tasks[0][1]; 
      const errMsg = `【メモシステムエラー】\nMarkdownへの書き込みに失敗しました。データはスプレッドシートに保持されているため、次回のバッチで再試行されます。\n\n詳細: ${error.message}`;
      sendLineNotification(targetUserId, errMsg);
    }
  }
}

/**
 * 3. データを年月ごとに分類してMarkdownへ書き込む内部関数
 */
function writeToMarkdown(tasks) {
  // データを年月(yyyy-MM)ごとにグループ化
  const groups = {};
  tasks.forEach(task => {
    const date = new Date(task[0]);
    const yearMonth = Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy-MM');
    if (!groups[yearMonth]) groups[yearMonth] = [];
    groups[yearMonth].push(task);
  });

  const folder = DriveApp.getFolderById(DRIVE_FOLDER_ID);

  // 年月ごとにファイル処理
  for (const yearMonth in groups) {
    const fileName = `${yearMonth}.md`;
    const files = folder.getFilesByName(fileName);
    let file;
    let content = '';

    if (files.hasNext()) {
      file = files.next();
      content = file.getBlob().getDataAsString();
    } else {
      // ファイルが存在しない場合は新規作成
      file = folder.createFile(fileName, '', MimeType.PLAIN_TEXT);
    }

    // 追記テキストの組み立て(例:箇条書き + タイムスタンプ)
    let appendText = '';
    groups[yearMonth].forEach(task => {
      const date = new Date(task[0]);
      
      // ▼ ここを変更しました('HH:mm' -> 'yyyyMMdd-HH:mm')
      const timeStr = Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyyMMdd-HH:mm');
      const text = task[2];
      
      // 出力例: - [20260530-15:30] メッセージ内容
      appendText += `\n- [${timeStr}] ${text}`;
    });

    // 既存のテキストの末尾に結合して保存
    content += appendText;
    file.setContent(content);
  }
}

/**
 * 4. LINEへのプッシュ通知送信
 */
function sendLineNotification(userId, message) {
  const url = 'https://api.line.me/v2/bot/message/push';
  const payload = {
    to: userId,
    messages: [{ type: 'text', text: message }]
  };
  const options = {
    method: 'post',
    headers: {
      'Authorization': 'Bearer ' + LINE_ACCESS_TOKEN,
      'Content-Type': 'application/json'
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };
  
  try {
    UrlFetchApp.fetch(url, options);
  } catch (e) {
    console.error('LINEへのエラー通知送信自体に失敗しました: ' + e.message);
  }
}

導入・設定手順

このシステムを実際に動かすための手順です。すべてブラウザ上の操作だけで完結します。

1.Googleスプレッドシートの作成

新規のGoogleスプレッドシートを作成し、シート名を「Queue」に変更します。このスプレッドシートのメニューから「拡張機能」→「Apps Script」を開きます(これによりコンテナバインドスクリプトになります)。

2.コードの貼り付けと環境設定

開いたGASエディタに上記のコードをすべて貼り付け(置き換え)ます。
コード内の LINE_ACCESS_TOKEN と、Markdownを保存したいGoogleドライブ上の「フォルダID(URLの最後の文字列)」を書き換えて、プロジェクトを保存(Ctrl+S / Cmd+S)します。

3.Webアプリとしてデプロイ

GAS画面右上の「デプロイ」→「新しいデプロイ」を選択します。種類の選択(歯車マーク)で「Webアプリ」を選び、次の設定でデプロイします:次のユーザーとして実行: 「自分」アクセスできるユーザー: 「全員」デプロイ後に発行される「WebアプリURL」をコピーしておきます。

4.LINE DevelopersでのWebhook設定

LINE Messaging APIを使用するために、LINE Developersの管理画面に入り、LINE公式アカウントを作成します。
LINE公式アカウント側でMessaging API自体を有効化したあと、LINE Developers側でWebhookの利用ON、Webhookの再送設定ON、Webhook URLの項目に先ほどコピーしたGASのWebアプリURLを貼り付けて保存します。

5.1分間隔の起動トリガーを設定

GASの画面左側にある時計マーク(トリガー)をクリックし、右下の「トリガーを追加」を押します。以下の通りに設定して保存します

  • 実行する関数を選択: cronJob
  • イベントのソースを選択: 時間主導型
  • 時間ベースのトリガーのタイプを選択: 分ベースのタイマー
  • 時間の間隔を選択(分): 1分おき

おわりに

この環境を構築してから、普段から使い慣れているLINEにテキストを投げるだけで自動的にGoogle Driveへ集約されるようになり、メモを残す心理的ハードルが圧倒的に下がりました。

さらに、DriveやGitHubに溜まった未整理のテキストを、使い慣れたAI(スマホからはGeminiやClaude、PCからはAntigravityなど)に定期的に読み込ませることで、以下のような「二次利用」が劇的に捗るようになっています。

  • 雑多なメモをMarkdownフォーマットへ綺麗に構造化する
  • Obsidian用の適切なメタデータ(タグなど)を自動で付与する

スマホからのObsidian運用や同期の壁に限界を感じている方、あるいはAIエージェントと共生するパーソナルな開発・メモ環境を構築したいと考えている方の参考になれば幸いです。

Discussion