用 GAS + LINE Bot 把「隨手想法」自動丟進 Notion

(更新於

完全不會寫程式也能做完這篇。 唯一要做的是「複製貼上一段程式碼」,就像你在 Word 貼文字一樣。跟著五步驟走,30 分鐘設定好,之後不用再碰。

為什麼要做這個

三種人會用得上:

  • 想到一個點子但 Apple 備忘錄 / Google Keep 越開越雜,最後等於沒記
  • 用 Notion 做知識庫但每次要打開 app、找到 database、新增、輸入,麻煩到不想記
  • 想要「想到 → 一句話 → 進系統」就好,不要再開 app 翻 database

LINE 你本來就一直開著、傳訊息順手。Notion 有 API、GAS(Google Apps Script,就是 Google 幫你免費跑的小程式)又能當中間橋梁,黏起來就是這篇做的事。

整體架構

你的手機 LINE
    ↓ 傳訊息給「自己建的官方帳號」
LINE Messaging API
    ↓ Webhook POST
Google Apps Script (Web App)
    ↓ 呼叫 Notion API
Notion Database

三個環節個人用都免費,沒 SaaS 月費、沒主機要顧。設定完一次,幾年不用碰。


第一步:設定 Notion 資料庫

1. 建立 Database 並準備 4 個欄位

欄位名稱類型用途
訊息內容Title預設標題欄,存 LINE 訊息原文
建立時間Date寫入時間
分類Select例如:工作 / 生活 / 靈感 / 待辦
狀態Status例如:待處理 / 已完成

2. 複製 Database ID

打開你的 database 頁面,網址長這樣:

https://www.notion.so/2d742f1e4f33810ab51bd78dc1975fff?v=2
                     └─────── Database ID ───────┘

notion.so/ 之後到 ?v= 之前那串 32 位英數字就是 Database ID。

3. 建立 Integration

  1. 前往 https://www.notion.so/profile/integrations
  2. New integration → 取個名字(例如 LINE-to-Notion)→ 儲存
  3. 複製 ntn_ 開頭的 Internal Integration Token

4. 把 Integration 加入你的 Database

這步最容易漏,沒做的話 API 會回 404。

  1. 回到你的 Notion database 頁面
  2. 右上角 Connections
  3. 加入你剛剛建立的 Integration

第二步:設定 LINE 官方帳號

1. 建 Provider 和 Channel

  1. 前往 LINE Developers Console
  2. Create a new provider(隨便取名)
  3. 在 provider 底下 → Create a Messaging API channel
  4. 填基本資料 → 建立

2. 啟用 Messaging API

  1. 前往 LINE Official Account Manager
  2. 選擇剛建立的官方帳號
  3. 設定Messaging API啟用 Messaging API

3. 關掉自動回覆(不然每則都會回機器人預設訊息,超吵)

  1. 同一個 Manager 介面
  2. 主頁自動回應訊息 → 全部關掉

4. 拿兩個密鑰

回到 LINE Developers Console → 你的 channel:

  • Basic settings 分頁 → Channel secret(複製)
  • Messaging API 分頁 → Channel access token → 點 Issue 產生 → 複製

第三步:開 Google Sheet 貼 GAS 程式碼

開一個全新的 Google Sheet(內容會被 init 函式自動填,先不用做任何事)。

1. 進 Apps Script 貼程式碼

Sheet 上方選單 → 擴充功能Apps Script → 把預設的 Code.gs 內容全部刪掉,貼下面這份。

不懂程式沒關係,這段 code 不需要看懂,只要完整複製貼上就好。就像你在 Word 貼一段文字,貼完之後按存檔,系統就會自己跑。

原作者是 麥哥(maigo.tom),v1.3.0 開源版本,已實戰過。

/**
 * LINE to Notion 自動化串接
 *
 * 作者:maigo.tom
 * 版本:1.3.0
 */

const SETUP_SHEET_NAME = 'Setup';
const ERROR_LOG_SHEET_NAME = 'Error Log';
const NOTION_API_VERSION = '2022-06-28';
const NOTION_API_ENDPOINT = 'https://api.notion.com/v1/pages';
const LINE_REPLY_API = 'https://api.line.me/v2/bot/message/reply';
const NOTION_TITLE_MAX_LENGTH = 2000;
const ERROR_LOG_MAX_ROWS = 1000;

// ============================================
// 初始化
// ============================================

function initializeSheet() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  initializeSetupSheet(spreadsheet);
  initializeErrorLogSheet(spreadsheet);
  try {
    SpreadsheetApp.getUi().alert(
      '初始化完成!\n\n請在 Setup 分頁的 B 欄填入 4 個 token / id。\n\nNotion Database 需要這 4 個欄位:\n- 訊息內容 (Title)\n- 建立時間 (Date)\n- 分類 (Select)\n- 狀態 (Status)'
    );
  } catch (e) { Logger.log('初始化完成'); }
}

function initializeSetupSheet(spreadsheet) {
  let setupSheet = spreadsheet.getSheetByName(SETUP_SHEET_NAME);
  if (!setupSheet) setupSheet = spreadsheet.insertSheet(SETUP_SHEET_NAME);
  const labels = [
    ['LINE Channel Access Token', ''],
    ['LINE Channel Secret', ''],
    ['Notion Integration Token', ''],
    ['Notion Database ID', ''],
    ['允許的 LINE User ID', '']
  ];
  setupSheet.getRange('A1:B5').setValues(labels);
  setupSheet.getRange('A1:A5').setFontWeight('bold').setBackground('#f3f3f3');
  setupSheet.getRange('B1:B5').setBackground('#fff9c4');
  setupSheet.setColumnWidth(1, 250);
  setupSheet.setColumnWidth(2, 400);
}

function initializeErrorLogSheet(spreadsheet) {
  let errorSheet = spreadsheet.getSheetByName(ERROR_LOG_SHEET_NAME);
  if (!errorSheet) errorSheet = spreadsheet.insertSheet(ERROR_LOG_SHEET_NAME);
  errorSheet.getRange('A1:D1').setValues([['時間', '錯誤類型', '訊息內容', '錯誤詳情']]);
  errorSheet.getRange('A1:D1').setFontWeight('bold').setBackground('#e3f2fd');
  errorSheet.setColumnWidth(1, 180);
  errorSheet.setColumnWidth(2, 120);
  errorSheet.setColumnWidth(3, 300);
  errorSheet.setColumnWidth(4, 400);
}

// ============================================
// 設定讀取
// ============================================

function getConfig() {
  const setupSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SETUP_SHEET_NAME);
  if (!setupSheet) throw new Error('找不到 Setup 分頁,請先執行 initializeSheet()');
  const values = setupSheet.getRange('B1:B5').getValues();
  const allowedRaw = values[4][0] ? values[4][0].toString().trim() : '';
  const config = {
    lineChannelAccessToken: values[0][0] ? values[0][0].toString().trim() : '',
    lineChannelSecret: values[1][0] ? values[1][0].toString().trim() : '',
    notionToken: values[2][0] ? values[2][0].toString().trim() : '',
    notionDatabaseId: values[3][0] ? values[3][0].toString().trim() : '',
    allowedUserIds: allowedRaw
      ? allowedRaw.split(',').map(id => id.trim()).filter(id => id)
      : []
  };
  const required = [
    { key: 'lineChannelAccessToken', label: 'LINE Channel Access Token' },
    { key: 'lineChannelSecret', label: 'LINE Channel Secret' },
    { key: 'notionToken', label: 'Notion Integration Token' },
    { key: 'notionDatabaseId', label: 'Notion Database ID' }
  ];
  const missing = required.filter(f => !config[f.key]).map(f => f.label);
  if (missing.length > 0) throw new Error('設定缺失:' + missing.join(', '));
  return config;
}

// ============================================
// Notion / LINE
// ============================================

function truncateText(text, maxLength) {
  if (!text || text.length <= maxLength) return text || '';
  const suffix = '... (訊息過長已截斷)';
  return text.substring(0, maxLength - suffix.length) + suffix;
}

function replyToLine(replyToken, message, accessToken) {
  if (!replyToken || replyToken === '00000000000000000000000000000000') return;
  try {
    UrlFetchApp.fetch(LINE_REPLY_API, {
      method: 'post',
      contentType: 'application/json',
      headers: { 'Authorization': 'Bearer ' + accessToken },
      payload: JSON.stringify({ replyToken, messages: [{ type: 'text', text: message }] }),
      muteHttpExceptions: true
    });
  } catch (e) { console.log('LINE 回覆失敗:' + e.message); }
}

function saveToNotion(messageData, config) {
  const createdTime = new Date(messageData.timestamp).toISOString();
  const truncated = truncateText(messageData.text, NOTION_TITLE_MAX_LENGTH);
  const payload = {
    parent: { database_id: config.notionDatabaseId },
    properties: {
      '訊息內容': { title: [{ text: { content: truncated } }] },
      '建立時間': { date: { start: createdTime } },
      '分類': { select: { name: '生活' } },
      '狀態': { status: { name: '待處理' } }
    }
  };
  const response = UrlFetchApp.fetch(NOTION_API_ENDPOINT, {
    method: 'post',
    contentType: 'application/json',
    headers: {
      'Authorization': 'Bearer ' + config.notionToken,
      'Notion-Version': NOTION_API_VERSION
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });
  const code = response.getResponseCode();
  const body = JSON.parse(response.getContentText());
  if (code === 200) return body;
  if (code === 401) throw new Error('Notion 認證失敗:請檢查 Integration Token');
  if (code === 404) throw new Error('Notion Database 不存在或無權限:請確認 ID 正確、Integration 已連結');
  if (code === 429) throw new Error('Notion API 請求過於頻繁,請稍後再試');
  throw new Error('Notion API 錯誤 (' + code + '): ' + (body.message || '未知錯誤'));
}

// ============================================
// Error Log
// ============================================

function logError(errorType, messageContent, errorDetail) {
  try {
    const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    let errorSheet = spreadsheet.getSheetByName(ERROR_LOG_SHEET_NAME);
    if (!errorSheet) {
      initializeErrorLogSheet(spreadsheet);
      errorSheet = spreadsheet.getSheetByName(ERROR_LOG_SHEET_NAME);
    }
    const ts = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss');
    errorSheet.appendRow([
      ts, errorType,
      (messageContent || '').toString().substring(0, 100),
      (errorDetail || '').toString().substring(0, 500)
    ]);
    const lastRow = errorSheet.getLastRow();
    if (lastRow > ERROR_LOG_MAX_ROWS + 1) {
      errorSheet.deleteRows(2, lastRow - ERROR_LOG_MAX_ROWS - 1);
    }
  } catch (e) { console.error('記錄錯誤失敗:', e.message); }
}

// ============================================
// Webhook 主函式
// ============================================

function doPost(e) {
  const ok = ContentService.createTextOutput(JSON.stringify({ status: 'ok' }))
    .setMimeType(ContentService.MimeType.JSON);
  try {
    if (!e || !e.postData || !e.postData.contents) {
      logError('請求錯誤', '', '無效的請求內容');
      return ok;
    }
    let config;
    try { config = getConfig(); }
    catch (err) { logError('設定錯誤', '', err.message); return ok; }

    let payload;
    try { payload = JSON.parse(e.postData.contents); }
    catch (err) { logError('JSON 解析錯誤', '', err.message); return ok; }

    if (payload.events && Array.isArray(payload.events)) {
      for (const event of payload.events) processEvent(event, config);
    }
  } catch (error) {
    logError('系統錯誤', '', error.message);
  }
  return ok;
}

function processEvent(event, config) {
  if (!event || event.type !== 'message' || !event.message || event.message.type !== 'text') return;
  if (!event.source || event.source.type !== 'user') return;

  const userId = event.source.userId || '';
  if (config.allowedUserIds.length > 0 && !config.allowedUserIds.includes(userId)) return;

  const messageText = event.message.text || '';

  if (messageText.trim() === '我的ID') {
    if (event.replyToken) {
      replyToLine(
        event.replyToken,
        '你的 LINE User ID 是:\n' + userId +
          '\n\n請複製到 Setup 分頁的 B5(允許的 LINE User ID)',
        config.lineChannelAccessToken
      );
    }
    return;
  }

  if (!messageText.trim()) return;

  const messageData = {
    text: messageText,
    userId: userId || 'unknown',
    timestamp: event.timestamp || Date.now()
  };

  try {
    saveToNotion(messageData, config);
    if (event.replyToken) {
      replyToLine(event.replyToken, '已記錄到 Notion', config.lineChannelAccessToken);
    }
  } catch (error) {
    logError('Notion 寫入失敗', messageData.text.substring(0, 50), error.message);
    if (event.replyToken) {
      replyToLine(event.replyToken, '記錄失敗,請稍後再試', config.lineChannelAccessToken);
    }
  }
}

function doGet(e) {
  return ContentService.createTextOutput(JSON.stringify({
    status: 'ok',
    message: 'LINE to Notion Webhook is running',
    version: '1.3.0',
    timestamp: new Date().toISOString()
  })).setMimeType(ContentService.MimeType.JSON);
}

// ============================================
// 測試函式
// ============================================

function testConfig() {
  try { getConfig(); SpreadsheetApp.getUi().alert('設定讀取成功,所有必要欄位都填了。'); }
  catch (error) { SpreadsheetApp.getUi().alert('設定讀取失敗:' + error.message); }
}

function testNotionConnection() {
  try {
    const config = getConfig();
    saveToNotion({
      text: '測試訊息 - ' + new Date().toLocaleString('zh-TW'),
      userId: 'TEST_USER',
      timestamp: Date.now()
    }, config);
    SpreadsheetApp.getUi().alert('Notion 連線測試成功!請至 Notion Database 確認。');
  } catch (error) {
    SpreadsheetApp.getUi().alert('Notion 連線測試失敗:' + error.message);
  }
}

完整版(含 cleanup helper、更詳細註解)在 這份 GSheet 的 Apps Script。上面是精華版,能跑。

2. 跑 initializeSheet 自動建分頁

存檔(⌘S)→ 上方下拉選 initializeSheet → 點 執行

第一次會跳授權,授完後 Sheet 會自動多兩個分頁:SetupError Log

3. 填 Setup 分頁

回 Sheet → Setup 分頁 → B 欄填:

欄位
B1LINE Channel Access Token
B2LINE Channel Secret
B3Notion Integration Token(ntn_ 開頭)
B4Notion Database ID
B5允許的 LINE User ID(選填,先空著)

注意:貼上時前後不要有空白,最常見的踩雷點。

4. 跑兩個測試函式驗證

回 Apps Script → 跑 testConfig → 應該跳「設定讀取成功」。 再跑 testNotionConnection → 應該跳「Notion 連線測試成功」+ Notion 資料庫多一筆「測試訊息」。

兩個都過才繼續,紅了照訊息回頭檢查 Setup 設定 / Notion Integration 是否有加進 database。


第四步:部署為 Web App

  1. Apps Script 介面 → 右上 部署新增部署作業
  2. 類型選 網頁應用程式
  3. 設定:
    • 執行身分:我
    • 存取權:所有人
  4. 部署

第一次部署會跳警告

Google 會說「未經驗證的應用程式」,照下面做:

  1. 授予資料存取權
  2. 跳到警示頁 → 左下角 Advanced
  3. Go to (your project name) (unsafe)
  4. Select allContinue
  5. 完成後複製產生的網址(這是你的 Webhook URL)

程式碼是你自己貼的(不是惡意),警告是 Google 對未驗證 OAuth 的標準提示,可以忽略。


第五步:把 Webhook 接回 LINE

  1. LINE Developers Console → 你的 channel → Messaging API 分頁
  2. Webhook URL 貼上剛剛複製的網址
  3. 打開 Use webhook 開關
  4. Verify 測試

顯示 Success 就完成了。


測試

加你的官方帳號為 LINE 好友 → 傳一句話 → 打開 Notion database → 應該秒看到那句話新增進去。


進階:只允許自己的訊息進 Notion

預設下任何加你官方帳號的人,傳訊息都會進你的 Notion。要鎖只有自己能用:

  1. 用 LINE 傳「我的ID」給你的官方帳號(GAS 範本支援這個指令)
  2. LINE 會回覆你的 User ID
  3. 把這個 ID 貼到 Sheet 的 B5
  4. 之後只有這個 User ID 的訊息會被處理,其他人傳的會被忽略

常見問題

訊息沒進 Notion?

  1. 看 Sheet 的 Error Log 分頁有沒有錯誤
  2. 檢查 4 個設定值都對(特別是前後空白)
  3. 確認 Integration 已加進 database 的 Connections

LINE Verify 失敗? 重新部署:部署管理部署作業編輯建立新版本部署

改完程式碼沒效果? GAS 跟一般網站不一樣,改完每次都要重新部署才會吃到新版本。新手最常卡這裡。


完整範本下載

完整 GAS 程式碼 + Google Sheet 範本連結在這邊:直接複製這份 GSheet,裡面的 Apps Script 已經貼好了,照 Setup 分頁的說明填入 4 個設定值就能跑。


想做更複雜的自動化

LINE → Notion 是最簡單的入門款。同樣的 webhook + GAS pattern 可以擴出去:

  • LINE → Google Calendar 建行程
  • LINE → Trello / Asana 建任務
  • LINE 傳照片 → Google Drive 自動分類存檔
  • LINE 傳語音 → AI 轉文字 → Notion 自動分類

如果你的事業有「重複手工想變自動」的場景,寫信給我聊 15 分鐘,看 fit 不 fit。

常見問題

完全免費嗎?
是。GAS 每天有 6 分鐘執行配額(免費版),LINE 官方帳號免費版每月 200 則訊息可寄出,Notion API 個人用戶完全免費。對個人用途綽綽有餘。
訊息會延遲多久進到 Notion?
GAS 接到 LINE webhook 通常 1-3 秒內就寫入 Notion。冷啟動偶爾會慢到 5-10 秒,但體感像即時。
別人用我的 LINE 官方帳號傳訊息會不會也進我的 Notion?
預設會。教學最後有「只允許特定 User ID」的設定步驟,可以鎖定只有你的訊息會處理,其他人傳的會被忽略。
分享: XFacebookLinkedIn
Raiy

Raiy

數位轉型/創新/自動化與 AI 愛好者