用 Apps Script 建立 LINE Bot 查詢資料小幫手

通常在開發 LINE Bot 的時候,想要正式上線給使用者使用,因為要保持 Webhook 連線,就必須申請一台雲端主機,但通常都需要支付費用。但我發現其實可以用 Google Apps Script (GAS) 來開發,GAS 支援 HTTPS Webhook、排程觸發器,重點有免費額度,不必額外花錢,也省下了伺服器維護的負擔。

在這篇文章中,我將分享用 Google Apps Script (GAS) 建立 LINE Bot 查詢資料小幫手,可以協助查詢產品銷售數據,並透過 LINE 訊息自動回覆使用者或群組。

LINE Bot 查詢資料小幫手的功能有:

  • 記錄使用者ID是否啟用小幫手
  • 記錄群組ID是否啟用小幫手
  • 取消啟用小幫手及刪除紀錄
  • 讀取產品銷售資料及排序
  • 以LINE文字或Flex訊息回覆
  • 自動記錄錯誤資訊供Debug用
💡 歡迎加入我的官方 LINE 帳號 👉 史戴拉寫扣
點擊圖文選單的「小幫手」,然後輸入以下關鍵字即可啟用對應功能:
- 啟用小幫手: 啟用小幫手
- 取消啟用小幫手: 取消啟用小幫手
- 統計產品銷售數據: 查看Flex卡片訊息統計資料

LINE token

  1. 建立 LINE 官方帳號
  2. LINE OA 設定啟用 Messaging API 功能
  3. 前往 LINE Developers
  4. 選擇一個 LINE 官方帳號
  5. 進入「Messaging API」設定頁
  6. 取得 Channel access token

設定環境變數

新增 Google Sheet,進入 Apps Script 編輯器的設定,在 Script Properties 中新增以下變數。

  • LINE_CHANNEL_TOKEN: 你的 LINE Channel access token
  • SHEET_ID: 你的 Google Sheet ID

宣告全域變數

1
2
3
4
5
6
// 全域變數區
const LINE_CHANNEL_TOKEN = PropertiesService.getScriptProperties().getProperty('LINE_CHANNEL_TOKEN');
const SHEET_ID = PropertiesService.getScriptProperties().getProperty('SHEET_ID');
const ACTIVATE_KEYWORD = '啟用小幫手';
const DEACTIVATE_KEYWORD = '取消啟用小幫手';
const PRODUCTS_KEYWORD = '統計產品銷售數據';

回覆LINE訊息

replyTextMessage

傳送一般純文字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 發送LINE文字訊息
function replyTextMessage(replyToken, text) {
const url = 'https://api.line.me/v2/bot/message/reply';
const options = {
method: 'post',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + LINE_CHANNEL_TOKEN
},
payload: JSON.stringify({
replyToken: replyToken,
messages: [{ type: 'text', text: text }]
}),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
Logger.log('回覆訊息的response: ' + response.getContentText());
logError('回覆訊息的response: ' + response.getContentText());
}

replyTextMessage

replyFlexMessage

傳送 Flex Message 格式的圖文訊息。

可使用 FLEX MESSAGE SIMULATOR 設計Flex訊息的內容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 發送LINE Flex訊息
function replyFlexMessage(replyToken, flexContents, altText) {
const url = 'https://api.line.me/v2/bot/message/reply';
const payload = JSON.stringify({
replyToken: replyToken,
messages: [{
type: 'flex',
altText: altText,
contents: flexContents
}]
});

const response = UrlFetchApp.fetch(url, {
method: 'post',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + LINE_CHANNEL_TOKEN
},
payload: payload,
muteHttpExceptions: true
});

Logger.log(response.getContentText());
logError('回覆Flex訊息的response: ' + response.getContentText());
}

replyFlexMessage

啟用小幫手

addRecord

當使用者傳來「啟用小幫手」訊息時:

  • 檢查 line_ids 表中是否已有紀錄
  • 如果沒有,就新增一筆紀錄(包含時間、來源類型、使用者ID、群組ID、原始訊息)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 加入紀錄(私訊或群組)
function addRecord(sourceType, groupId, userId, message) {
const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('line_ids');
const rows = sheet.getDataRange().getValues();

groupId = groupId || '';
userId = userId || '';

// 檢查是否已經存在
const exists = rows.some(row => {
return row[1] === sourceType &&
row[2] === userId &&
row[3] === groupId;
});

if (exists) return false; // 如果存在就不新增

// 新增一筆記錄
const timestamp = new Date();
sheet.appendRow([timestamp, sourceType, userId, groupId, message]);
return true;
}

addRecord

removeRecord

當使用者傳來「取消啟用小幫手」訊息時:

  • 在 line_ids 表格中搜尋符合條件的資料列
  • 若找到,就刪除該列紀錄
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 移除紀錄
function removeRecord(sourceType, groupId, userId) {
const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('line_ids');
const rows = sheet.getDataRange().getValues();

groupId = groupId || '';
userId = userId || '';

for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const match =
row[1] === sourceType &&
row[2] === userId &&
row[3] === groupId;
if (match) {
sheet.deleteRow(i + 1); // Google Sheet從1開始計,所以要+1
return true;
}
}
return false;
}

產品銷售資料

當使用者傳來「統計產品銷售數據」訊息時:

  • 讀取 products 工作表的產品銷售資料
  • 將產品按照名稱分組,並且每組內依照日期排序
  • 將資料轉成 Flex Bubble 卡片格式

getProductData

從 products 工作表讀取產品銷售資料。

每筆資料包含:

  • 日期
  • 產品名稱
  • 售價
  • 銷售數量
1
2
3
4
5
6
7
8
9
10
11
12
13
// 讀取產品資料
function getProductData() {
const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('products');
const rows = sheet.getDataRange().getValues(); // 讀取全部資料

const products = rows.slice(1).map(row => ({ // 跳過第一列標題,開始整理資料
date: row[0], // A欄 = 日期
product_name: row[1], // B欄 = 產品名稱
sell_price: row[2], // C欄 = 售價
sell_quantity: row[3] // D欄 = 銷售數量
}));
return products; // 回傳整理好的產品資料陣列
}

products-data

groupAndSortProducts

將產品按照名稱分組,並且每組內依照日期排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 將產品分組排序
function groupAndSortProducts(products) {
const grouped = {}; // 建立空的分組物件

// 分組
products.forEach(p => {
if (!grouped[p.product_name]) grouped[p.product_name] = [];
grouped[p.product_name].push(p);
});

// 每組內部排序(依日期從小到大)
for (let name in grouped) {
grouped[name].sort((a, b) => new Date(a.date) - new Date(b.date));
}

return grouped; // 回傳分好組、排序好的資料
}

資料格式處理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 數字加上千分位逗號
function formatNumber(num) {
return Number(num).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}

// 日期格式
function formatDateToYMD(dateStr) {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '未知日期'; // 日期錯誤時回傳"未知日期"

const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');

return `${year}/${month}/${day}`; // 格式化成2025/04/28
}

createFlexBubbles

把整理好的資料轉成一個一個 Flex Bubble 卡片格式。

  • 資料包含產品名稱、日期、售價(加上千分位)、銷售數量
  • 不同產品設定不同背景顏色
  • 將不同卡片再組成一個 Flex Carousel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 建立Flex訊息內容
function createFlexBubbles(products) {
const grouped = groupAndSortProducts(products); // 先分組、排序好

// 每個產品群用不同顏色背景
const colorMap = {
'Trek 820': '#00C853',
'Ritchey': '#AA00FF',
'Surly': '#2962FF',
'Trek Fuel': '#FF6D00',
};
const defaultColor = '#888888'; // 預設顏色
const bubbles = [];

for (let productName in grouped) {
const productGroup = grouped[productName];
const color = colorMap[productName] || defaultColor;

productGroup.forEach(product => {
const safeProductName = (product.product_name || '未知產品').toString().substring(0, 50);
const safeDate = formatDateToYMD(product.date);
const price = parseFloat(product.sell_price);
const safePrice = isNaN(price) ? 0 : price;
const safeQuantity = typeof product.sell_quantity === 'number' ? product.sell_quantity : 0;

const bubble = {
type: 'bubble',
size: 'micro',
body: {
type: 'box',
layout: 'vertical',
spacing: 'sm',
backgroundColor: color,
paddingAll: '20px',
contents: [
{
type: 'text',
text: safeProductName,
weight: 'bold',
size: 'md',
color: '#ffffff',
wrap: true,
},
{
type: 'text',
text: `📅 ${safeDate}`,
size: 'sm',
color: '#ffffff',
},
{
type: 'text',
text: `🏷️ $${formatNumber(safePrice)}`,
size: 'sm',
color: '#ffffff',
},
{
type: 'text',
text: `📦 銷售 ${safeQuantity} 台`,
size: 'sm',
color: '#ffffff',
}
]
}
};
bubbles.push(bubble); // 把這個小卡片加入陣列
});
}

return bubbles; // 回傳全部bubble
}

操作記錄

將操作記錄到 Google Sheet 的 logs 工作表中,幫助追蹤bug。

1
2
3
4
5
6
// 操作記錄
function logError(message) {
const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('logs');
const timestamp = new Date();
sheet.appendRow([timestamp, message]);
}

log

主程式

這是整個應用的主入口。
當 LINE Webhook 送來訊息時:

  • 解析訊息內容與來源
  • 根據文字內容判斷要執行的動作
    • 啟用小幫手
    • 取消啟用小幫手
    • 回傳產品銷售資料
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 主程式
function doPost(e) {
try {
const contents = JSON.parse(e.postData.contents);
logError('webhook 收到: ' + JSON.stringify(contents));
Logger.log('webhook 收到: ' + JSON.stringify(contents));

const events = contents.events;
if (!events || !Array.isArray(events) || events.length === 0) {
return ContentService.createTextOutput('NO EVENTS');
}

for (const event of events) {
const messageText = event.message?.text?.trim();
const replyToken = event.replyToken;
const source = event.source || {};
const sourceType = source.type;
const userId = source.userId;
const groupId = source.groupId;

if (!messageText || !replyToken) continue;

if (messageText === ACTIVATE_KEYWORD) {
const isNew = addRecord(sourceType, groupId, userId, messageText);
replyTextMessage(replyToken, isNew ? "✅ 已啟用小幫手" : '🎉 你已經啟用過囉!');

} else if (messageText === DEACTIVATE_KEYWORD) {
const removed = removeRecord(sourceType, groupId, userId);
replyTextMessage(replyToken, removed ? "❌ 已取消啟用小幫手" : '⚠️ 尚未啟用,無需取消。');

} else if (messageText === PRODUCTS_KEYWORD) {
const products = getProductData();
const bubbles = createFlexBubbles(products.slice(0, 12));
if (bubbles.length === 0) {
replyTextMessage(replyToken, '⚠️ 目前沒有產品銷售數據喔!');
continue;
}
const flexContents = {
type: 'carousel',
contents: bubbles
};
replyFlexMessage(replyToken, flexContents, '統計產品銷售數據');
}
}
return ContentService.createTextOutput('OK');
} catch (error) {
Logger.log('錯誤訊息: ' + error);
logError('錯誤訊息: ' + error);
return ContentService.createTextOutput('錯誤: ' + error);
}
}

部署

  1. 點選「Deploy」 > 「New deployment」
    • 類型: 網路應用程式
    • 說明: 填寫本次部署的說明
    • 執行應用程式的身份: 自己
    • 誰可以存取: 任何人
  2. 成功部署後會出現一個網址
  3. 前往 LINE Developers
  4. 選擇一個 LINE 官方帳號
  5. 進入「Messaging API」設定頁
  6. 找到「Webhook URL」欄位,貼上部署後https://script.google.com/.../exec網址
  7. 之後有更新,可以點擊「Manage deployments」,新增一個新版本的部署

免費配額和限制

雖然 Google Apps Script (GAS) 提供了免費額度,但在開發和部署 LINE Bot 時,還是有一些需要特別注意的配額和使用限制,避免因為超出限制導致服務中斷。

功能 消費者帳戶(如gmail.com) Google Workspace帳戶 注意事項
觸發條件總執行階段
(Triggers total runtime)
90分鐘/天 每天6小時 每天執行的總時長上限,超過後當天將無法再觸發腳本
📈 網址擷取呼叫次數
(URL Fetch calls)
20,000次/天 100,000次/天 每次使用UrlFetchApp.fetch()呼叫 LINE API 都會消耗,例如發送文字replyTextMessage、發送Flex訊息replyFlexMessage
⏱️ 指令碼執行時間
(Script runtime)
6分鐘/次 6分鐘/次 每次腳本執行,不論是來自Webhook或觸發器,都必須在6分鐘內結束,否則會自動中斷

完整配額和限制表請看 Apps Script Quotas 官方文件

配額估算小工具

我寫了一個配額估算小工具,可以協助你估算可能會消耗的「觸發條件總執行階段(Triggers total runtime)」和「網址擷取呼叫次數(URL Fetch calls)」使用量。

觸發條件總執行階段

估算每天的總執行時間。

觸發器每次執行時間

可以進入觸發器的執行項目中查看觸發器每次執行的時間。
triggers-total-runtime

觸發器間隔時間

可以進入觸發器的設定中查看觸發器所設定的間隔時間。
url-fetch-calls

網址擷取呼叫次數

估算每天的網址擷取呼叫次數。

以本篇文章的程式碼為例:

  • 每次使用者啟用小幫手 → 呼叫 LINE API 1 次
  • 每次查詢產品銷售資料 → 呼叫 LINE API 1 次
  • 若每天有 100 位使用者,平均每人互動 2 次
  • → 則每天會消耗 100 × 2 = 200 次呼叫

配額估算小工具

如: 假設每次執行時會呼叫1次LINE API,每天有200個使用者,平均每位互動2次,請輸入200。

如: 觸發器每次執行的時間,假設觸發器每次執行需要15.379秒,請輸入15.379。

如: 觸發器每次執行的間隔時間,假設設定每10分鐘要啟動觸發器,請輸入10。