© 2024 Merano Tu. All rights reserved.
Merano Tu
2026/6/7
我和室友長期在 LINE 群組互相記帳,格式很簡單:誰付了什麼、多少錢,月底再手動結算。
某天我把一堆截圖丟給 Claude,請它幫我算誰欠誰多少,它算完之後我突然想:「這件事能不能讓 Bot 在群組裡自動做?」
這篇文章記錄了從這個念頭出發,一路做到真正能用的完整過程,包含每個決策的思考、踩過的坑、以及後來加上的各種功能和防護。
在寫任何程式之前,先把記帳邏輯講清楚。
我們的群組記帳方式很直覺:
項目 -金額:發訊息的人幫對方付了這筆(對方欠我)
項目 +金額:對方幫發訊息的人付了這筆(我欠對方)
帳單類(水電費)會寫總額/2,表示兩人平分
範例:
生魚片 -350 → 我幫對方付了 $350
衛生紙 +60 → 對方幫我付了 $60
4月水費 898/2 ≈ -449 → 帳單各付一半,我幫對方付了 $449
天然氣 437/2 大約等於 -218
Bot 的指令設計:
結算 → 顯示誰欠誰多少
明細 → 列出所有未結清項目
清帳 → 結清並封存記錄
說明 → 顯示使用說明
在進入技術細節之前,先用白話把整個系統講清楚。這套記帳機器人一共用到六個服務,每個服務有自己的職責,彼此透過網路串接。
① LINE
使用者介面。你和室友在 LINE 群組裡傳訊息,LINE 平台負責接收這些訊息,並把事件即時推送(Webhook)到你的後端 server。Bot 的回覆也是透過 LINE API 送回群組。LINE 在這裡同時是「輸入端」和「輸出端」。
② Render
後端 server 的執行環境。你的 Node.js 程式跑在這裡,它做三件事:
③ Google Sheets
資料庫。每一筆記帳記錄都存在這張試算表裡,Render 上的程式用 Google Sheets API 讀寫它。清帳後的歷史記錄也封存在這裡。它只負責存資料,不做任何邏輯運算。
④ GitHub
程式碼的版本管理和部署觸發器。你把程式碼推到 GitHub,Render 偵測到新的 commit 就自動重新部署。這讓你不需要手動上傳或登入伺服器,git push 就等於更新上線。
⑤ cron-job.org
外部定時喚醒服務。Render 免費方案閒置 15 分鐘會讓 server 休眠,cron-job 每分鐘打一次你的 server,讓它保持清醒。它唯一做的事就是定期發一個 HTTP GET 請求給你的 server。
⑥ Google Cloud(Service Account)
授權橋樑。Google Sheets API 不是公開的,程式要存取試算表,必須先通過 Google 的身份驗證。在 Google Cloud 建立 Service Account 並下載金鑰,程式用這把金鑰換取存取試算表的權限。這個服務本身不做任何資料處理,只是讓 Render 上的程式有資格去讀寫 Google Sheets。
| # | 服務 | 角色定位 | 負責的事 | 不負責的事 |
|---|---|---|---|---|
| ① | LINE | 使用者介面 | 接收群組訊息、把事件推送到 Render(Webhook)、把 Bot 回覆顯示在群組 | 不做任何邏輯判斷,只負責傳遞訊息 |
| ② | Render | 後端大腦 | 接收 Webhook、解析訊息格式、執行記帳和結算邏輯、呼叫 Google Sheets 讀寫資料、組合 Flex Message 回覆 | 不存資料,資料都在 Google Sheets |
| ③ | Google Sheets | 資料庫 | 儲存所有未結清記帳記錄、封存清帳後的歷史記錄 | 不做任何邏輯運算,只負責存和取 |
| ④ | GitHub | 版本管理 + 部署觸發 | 存放程式碼、每次 git push 後通知 Render 自動重新部署 |
不執行程式,只管程式碼 |
| ⑤ | cron-job.org | 防休眠看門狗 | 每分鐘發一個 GET 請求到 Render,讓 server 保持清醒不休眠 | 不處理任何記帳邏輯,只負責定時敲門 |
| ⑥ | Google Cloud(Service Account) | 授權橋樑 | 提供金鑰讓 Render 上的程式有資格存取 Google Sheets API | 不碰任何資料,只處理身份驗證 |
以「輸入一筆記帳」為例,走一遍完整流程:
你在 LINE 群組輸入「生魚片 -350」
↓
LINE 平台收到這則訊息
↓
LINE 發一個 POST 請求到 Render 的 Webhook URL
(帶著:是誰說的、說了什麼、在哪個群組)
↓
Render 的 Node.js 程式收到請求
↓
先呼叫 LINE API 取得發話者的顯示名稱
↓
用 regex 解析「生魚片 -350」
→ 項目:生魚片,金額:-350,付款人:你
↓
呼叫 Google Sheets API,把這筆資料寫進試算表
↓
組合一張 Flex Message 確認卡片
↓
呼叫 LINE API 把卡片回覆進群組
↓
群組顯示:✅ 記帳:你 幫 室友 付了 $350(生魚片)
以「輸入結算」為例:
你在群組輸入「結算」
↓
LINE → Render(同上)
↓
Render 呼叫 Google Sheets API 讀取所有未結清記錄
↓
把每一筆的 +/- 加總,算出淨額
↓
組合結算 Flex Message 卡片
↓
LINE API 回覆進群組
↓
群組顯示:💰 你 欠 室友 $761 元
cron-job 的流程最單純:
每分鐘
↓
cron-job.org 發一個 GET 請求到你的 Render server
(帶著 token 做身份驗證)
↓
Render 確認 token 正確,回傳「LINE Bot is running ✅」
↓
結束(Render 的閒置計時器重置,不會休眠)
你 / 室友
↕ LINE App
LINE 平台
↕ Webhook / Reply API
Render(Node.js)──────→ Google Sheets API ──→ Google Sheets(資料)
↑ ↑
cron-job.org Google Cloud
(每分鐘喚醒) (Service Account 授權)
GitHub
(push → 自動部署到 Render)
| 服務 | 費用 |
|---|---|
| LINE Messaging API | 免費(每月 200 則免費回覆,超過才收費) |
| Render | 免費方案 |
| Google Sheets | 免費 |
| GitHub | 免費(Private repo) |
| cron-job.org | 免費 |
| Google Cloud Service Account | 免費 |
整套系統完全免費。
我原本想說這個後端 server 也可以放在自己這個站的網址上
我原本以為自己有 VPS,但查了才想起現在前端網站是放在 Vercel 上的,根本沒有自己的伺服器。(太久沒用根本忘記當初是怎麼架構
Vercel 是純前端託管平台,沒辦法跑 Node.js 後端 + 存檔案,所以需要另外找後端平台。
評估過幾個選項:
| 方案 | 費用 | 設置難度 | 問題 |
|---|---|---|---|
| Render 免費 | 免費 | 低 | 閒置 15 分鐘會休眠 |
| Railway | $5/月 | 低 | 要付費 |
| DigitalOcean VPS | $6/月 | 高 | 要自己管機器 |
| 繼續用 Vercel | 免費 | — | 不支援後端 |
最後選 Render 免費方案,部署也很方便,像 Vercel 一樣,給他 github repo 他會自己去抓最新的推送來部署,休眠問題之後另外處理。
第一版:JSON 檔存在 server 上
最簡單的方案,直接把資料寫成 data.json 存在 Render 的機器裡。
但後來發現一個致命問題:Render 免費方案用的是臨時檔案系統,每次重新部署(git push)或 Render 自己重啟,檔案就消失了。
等於每次推代碼,記帳資料就歸零。
就開始評估其他方案:
| 方案 | 費用 | 永久保存 | 問題 |
|---|---|---|---|
| JSON(Render) | 免費 | ❌ | 重啟消失 |
| Supabase | 免費 | ✅ | 閒置 7 天自動暫停 |
| Google Sheets | 免費 | ✅ | 無 |
Supabase 的問題:免費方案連續 7 天沒有活動就會自動暫停,API 無法存取,要手動去網站點「Resume」才能恢復。
對我們這種低頻使用(可能一個月才結算一次、有時候一週不一定會計帳到一次)的情境,這個限制非常麻煩。
最終選擇:Google Sheets
純文字回覆簡單,但看久了很單調。LINE 提供 Flex Message,可以用 JSON 定義卡片樣式。
最終設計:
| 動作 | 卡片顏色 | 顯示內容 |
|---|---|---|
| 記帳成功 | 藍綠色 | 項目、金額、付款說明 |
| 結算 | 紅色 | 誰欠誰多少(大字顯示) |
| 明細 | 紫色 | 表格列出所有明細 + 結算摘要 |
| 清帳 | 紫色 | 最終結果、封存提示 |
| 說明 | 純文字 | 不需要卡片 |
*但目前還沒有成功顯現,屬於可優化項目
這裡我參考了 newman 的技術筆記 來了解 LINE Messaging API 的基本架構,文章寫得蠻清楚,有引導圖文。
有幾個重要觀念先建立:
Provider vs Channel vs 官方帳號的關係
申請流程:
注意:兩個管理介面
LINE 有兩個介面,容易搞混:
Webhook URL 要在 Developers Console 的 Messaging API 頁籤設定,不是在 Manager 那邊。
必須關閉的設定:
去 LINE Official Account Manager → 回應設定,關閉:
開啟:
整個申請起來不會太困難,過程也蠻快速(大概半小時內可以解決),需要通過手機號碼驗證,就可以成立自己的官方帳號。
linebot-accounting/
├── src/
│ ├── index.js # Express server、Webhook 入口
│ ├── handler.js # 訊息解析與指令處理
│ ├── store.js # Google Sheets 資料讀寫
│ └── flex.js # Flex Message 卡片產生器
├── .env.example
├── package.json
└── README.md
app.post("/webhook", middleware(config), async (req, res) => {
res.sendStatus(200); // 先回 200,LINE 要求快速回應
const events = req.body.events;
for (const event of events) {
if (event.type !== "message" || event.message.type !== "text") continue;
const groupId = event.source.groupId || event.source.roomId;
if (!groupId) continue; // 只處理群組訊息
// 取得發話者真實顯示名稱
const profile = await client.getGroupMemberProfile(groupId, event.source.userId);
const senderName = profile.displayName;
const reply = await handleMessage({ senderName, text: event.message.text });
if (!reply) continue;
await client.replyMessage(event.replyToken, reply);
}
});
支援多種記帳格式,包含帳單分攤的寫法:
function parseRecord(text) {
const match =
text.match(/([+-]\d+(\.\d+)?)(?![\d/])\s*$/) ||
text.match(/≈\s*([+-]\d+(\.\d+)?)\s*$/) ||
text.match(/等於\s*([+-]\d+(\.\d+)?)\s*$/);
if (!match) return null;
const amount = parseFloat(match[1]);
const item = text
.replace(/[\d/]+\s*(≈|大約等於|約)?\s*[+-]\d+(\.\d+)?\s*$/, "")
.replace(/[+-]\d+(\.\d+)?\s*$/, "")
.trim();
if (!item) return null;
return { item, amount };
}
async function calcBalance(members) {
const records = await getRecords();
const balance = {};
members.forEach((m) => (balance[m] = 0));
records.forEach(({ payer, amount }) => {
const other = members.find((m) => m !== payer);
if (!other) return;
if (amount < 0) {
// payer 幫 other 付了 |amount| 元
balance[payer] += Math.abs(amount);
balance[other] -= Math.abs(amount);
} else {
// other 幫 payer 付了 amount 元
balance[payer] -= amount;
balance[other] += amount;
}
});
const [m1, m2] = members;
const diff = balance[m1] - balance[m2];
if (diff > 0) return { creditor: m1, debtor: m2, amount: diff };
if (diff < 0) return { creditor: m2, debtor: m1, amount: Math.abs(diff) };
return { creditor: null, debtor: null, amount: 0 };
}
使用 googleapis 套件,透過 Service Account 金鑰授權:
function getAuth() {
return new google.auth.JWT({
email: process.env.GOOGLE_CLIENT_EMAIL,
key: process.env.GOOGLE_PRIVATE_KEY.replace(/\\n/g, "\n"),
scopes: ["https://www.googleapis.com/auth/spreadsheets"],
});
}
試算表結構:
整個部署流程是:
git push → GitHub → Render 偵測到新 commit → 自動重新部署
每次推代碼,Render 就自動拉最新版本、npm install、重新啟動,不需要任何手動操作。
Render 設定:
| 欄位 | 值 |
|---|---|
| Runtime | Node |
| Build Command | npm install |
| Start Command | node src/index.js |
| Instance Type | Free |
| Region | Singapore |
環境變數:
LINE_CHANNEL_ACCESS_TOKEN=
LINE_CHANNEL_SECRET=
MEMBER1=成員A的LINE顯示名稱
MEMBER2=成員B的LINE顯示名稱
GOOGLE_SHEET_ID=
GOOGLE_CLIENT_EMAIL=
GOOGLE_PRIVATE_KEY=
HEALTH_CHECK_TOKEN=(隨機字串)
PORT=3000
部署完成後,回到 LINE Developers Console → Messaging API 頁籤:
Webhook URL: https://你的render網址.onrender.com/webhook
開啟 Use webhook → 點 Verify 確認回傳 success。
本機開發時用 npm install dotenv --save-dev 裝了 dotenv,但 Render 部署時只會安裝 dependencies,不會安裝 devDependencies,導致 server 啟動時找不到模組:
Error: Cannot find module 'dotenv'
解法:重新執行 npm install dotenv(不加任何 flag),確認 package.json 的 dependencies 裡有記錄,再推上去。
這個坑踩了兩次,因為第一次修完推上去,後來又改其他檔案時不小心又覆蓋掉了。
Render 的機器上沒有 data/ 資料夾,寫入 JSON 時報錯:
Error: ENOENT: no such file or directory, open '/opt/render/project/src/data/data.json'
解法:在 readData() 裡加自動建立資料夾的邏輯:
const dir = path.dirname(DATA_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
(後來改成 Google Sheets 後這個問題就不存在了,但過程中花了不少時間 debug)
測試時發現:
Bot 回覆「我欠對方 $60」,但正確答案應該是「我欠對方 $30」。
問題出在 + 號的方向搞反了。
解法:重新梳理邏輯:
amount < 0:payer 幫 other 付了 |amount| 元,other 欠 payeramount > 0:other 幫 payer 付了 amount 元,payer 欠 other設定好環境變數後,輸入「說明」有回應,但記帳指令完全沒反應。
原因:Bot 透過 LINE API 抓到的是對方帳號本身的顯示名稱,但我在 LINE 裡幫對方設的備註暱稱只有我自己看得到。
填入環境變數的 MEMBER1 / MEMBER2 必須和 LINE API 回傳的名稱完全一致,包含空格、大小寫、emoji。
確認方法:去群組點對方頭像,看最上面那行(不是自己備註的那行)。
設定 Webhook URL 時一直找不到 Verify 按鈕,後來才發現跑去了 LINE Official Account Manager 而不是 LINE Developers Console。
這兩個介面長得不一樣,功能也不同:
Bot 上線後突然想到:Render 的 server URL 是公開的,任何人只要知道網址就可以一直打,消耗我的免費額度。
加了一個簡單的 token 驗證:
app.get("/", (req, res) => {
const token = req.query.token;
if (token !== process.env.HEALTH_CHECK_TOKEN) {
return res.sendStatus(403);
}
res.send("LINE Bot is running ✅");
});
產生一串隨機 token:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
加到 Render 環境變數後,cron-job 的 URL 也要帶上這個 token,其他人打過來都是 403。
雖然最後改成每分鐘都要打,所以這個這樣驗證好像沒什麼效用
Render 免費方案閒置 15 分鐘會休眠,下次收到訊息時需要 30 秒 ~ 1 分鐘冷啟動,這段時間 LINE 打來的 Webhook 會直接被丟掉。
直覺上可以在程式內用 setInterval 每 10 分鐘打自己一次,但有個問題:
server 休眠 → 程式停止 → setInterval 也死了 → 永遠醒不來
就像靠鬧鐘叫自己起床,但鬧鐘跟你在同一個房間,你睡著時鬧鐘也關了。
一定需要外部服務來喚醒。
第一版設定(每 10 分鐘,只打白天):
https://你的網址.onrender.com/?token=你的tokenCrontab expression:
0,10,20,30,40,50 8-23 * * *
這樣設的邏輯是:深夜不用喚醒,節省 Render 的月度用量配額(750 小時)。
遇到的新問題:早上重啟收到 Failed (output too large)
實際跑了之後發現,cron-job 在早上 8 點第一次打過去時,Render 的 server 剛從休眠冷啟動,回傳的內容太大(好像是會還傳一個,cron-job 判定為失敗,記錄了一筆 Failed (output too large)。
雖然後來 server 正常喚醒了,但這個錯誤記錄會累積,最終觸發「太多失敗 → 自動停用 job」的機制。
最終調整:改成每分鐘全天候打
與其跟時段和冷啟動問題搏鬥,不如直接改成:
* * * * *
每分鐘打一次,全天候不間斷。
這樣的好處:
Render 月度用量:
24小時 × 30天 = 720小時
雖然接近 750 小時的上限,但只要 Render 沒有意外多重啟幾次,實際上還在免費額度內。
cron-job 的通知設定建議:
把「execution of the cronjob fails」通知打開,Notify after 改成 30:
連續失敗 30 次(= 30 分鐘)才通知
這樣如果 Bot 真的掛掉,30 分鐘內就能收到 email。
只要群組裡有人傳任何訊息,LINE 就會打一次 Webhook,server 就被喚醒。
LINE 群組
↓ 任何訊息
LINE Platform
↓ Webhook POST
Render(Node.js + Express)
├── 解析訊息格式
├── 確認發話者身份(對照 MEMBER1/MEMBER2)
├── 讀寫 Google Sheets
└── 回覆 Flex Message 卡片
Google Sheets(永久儲存)
├── Sheet1:未結清記錄
└── history:清帳封存記錄
cron-job.org(每10分鐘,白天時段)
↓ GET with token
Render health check
# LINE Bot
LINE_CHANNEL_ACCESS_TOKEN=
LINE_CHANNEL_SECRET=
# 群組成員(要和在賴上公開顯示的 LINE 名稱完全一致,不是個人設定對方暱稱的名字)
MEMBER1=
MEMBER2=
# Google Sheets
GOOGLE_SHEET_ID=
GOOGLE_CLIENT_EMAIL=
GOOGLE_PRIVATE_KEY=
# 安全防護
HEALTH_CHECK_TOKEN=
PORT=3000
整個從零到部署花了大概一個下午,大部分時間都在處理各種環境問題而不是寫功能本身。
對於兩個人的小需求,這套架構的維護成本幾乎是零:
整套服務費用:完全免費。
未來想加的功能: