LINEから始まるAI共生メモ環境(Obsidian × Google Drive × GitHub)
はじめに
PCでのテキスト管理にはObsidianが強力な選択肢になりますが、モバイル端末(iPhone/iPad)からの運用には、同期の面(有料プランやiCloudの制約)でハードルがあります。
また、私自身の職場環境の制約として在宅勤務が難しく、日中の思考整理や開発(iOSアプリ経由でのClaude Code運用など)はスマホが主戦場になっています。
そのため、 「スマホで素早くインプット・思考整理を行い、PCを開いた際にはスムーズにその作業を継続できる環境」 を模索し続けています。
目指しているのは、以下の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