繼上一篇 健康管理 x Line Bot - 你今天POO了嗎 - 計屎者 (使用手冊篇) ,今天來到實作篇。
其實整個部屬流程跟 [Day 2] 群組不可缺的夥伴 - Line Bot Reminder 一樣,只是這次多了額外要存取的地方 (這裡使用Google Sheet 當資料庫)
const SPREADSHEET_ID = '...'; // 存聊天紀錄的試算表
const TZ = 'Asia/Taipei';
const KEYWORDS = /(?:💩|便便|大便|poop)/gi; // 支援中英與 emoji
const PROPS = PropertiesService.getScriptProperties();
function readToken_() {
const t = PROPS.getProperty('LINE_CHANNEL_ACCESS_TOKEN');
if (!t) throw new Error('缺少 LINE_CHANNEL_ACCESS_TOKEN(Script Properties)');
return t;
}
const GROUP_IDS_KEY = 'POOP_GROUP_IDS'; // 綁定的群組清單(JSON)
功能:集中放環境變數與規則。
說明:LINE_CHANNEL_ACCESS_TOKEN
放 Script Properties,比把金鑰硬寫在程式碼安全;KEYWORDS
用正則一次數出訊息內的所有關鍵字(大小寫/emoji 都能抓)。
小提醒:
Asia/Taipei
,避免時間落差。function getGroupIds() { ... } // 從 Script Properties 讀 JSON 陣列
function saveGroupIds(ids) { ... } // 去重後寫回
function addGroupId(gid) { ... } // #bind 時加入
function rmGroupId(gid) { ... } // #unbind 時移除
功能:以 Script Properties 維護一個「有在統計」的群組清單。
說明:不必手動抄 groupId
,在群內打一句 #bind
就啟用;不同群也能各自啟用/停用。
function getSheet() {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const name = 'messages';
const sheet = ss.getSheetByName(name) || ss.insertSheet(name);
if (sheet.getLastRow() === 0) {
sheet.appendRow(['ts_iso','ts_local','groupId','userId','text','poop_count']);
}
return sheet;
}
功能:打開目標試算表,若沒有 messages
工作表就新建,並補上表頭。
說明:保證第一次部署就能寫資料,不用手動準備表頭。
function replyText(replyToken, text) { ... } // /v2/bot/message/reply
function pushText(to, text) { ... } // /v2/bot/message/push
function getDisplayName(groupId, userId) { ... } // /v2/bot/group/{gid}/member/{uid}
功能:封裝三個常用 API 呼叫。
說明:
reply
:對使用者的指令即時回覆(有時效性)。push
:定時任務主動推播週報。getDisplayName
:把 userId
轉成群內顯示名稱(取不到時以 userId
後 6 碼替代)。CacheService
快取暱稱 6 小時,減少 API 呼叫量。function doPost(e) {
const out = ContentService.createTextOutput('OK').setMimeType(ContentService.MimeType.TEXT);
try {
const raw = (e && e.postData && e.postData.contents) ? e.postData.contents : '';
if (!raw) return out; // Verify/健康檢查 → 回 200
const body = JSON.parse(raw);
(body.events || []).forEach(ev => {
if (!ev.source || ev.source.type !== 'group' || !ev.source.groupId) return;
const gid = ev.source.groupId;
if (ev.type === 'message' && ev.message?.type === 'text') {
const text = ev.message.text || '';
const t = text.trim();
// 1) 管理指令
if (t === '#bind') { addGroupId(gid); replyText(ev.replyToken, '✅ 已綁定;本群開始紀錄關鍵字。'); return; }
if (t === '#unbind') { rmGroupId(gid); replyText(ev.replyToken, '🧹 已取消本群的統計。'); return; }
if (t === '#便便?' || t === '#poop?') {
replyText(ev.replyToken, '指令:#便便 7d|#便便 week|#便便 month|#便便 YYYY-MM-DD YYYY-MM-DD|#bind|#unbind');
return;
}
// 2) 查詢指令(區間)
const m = t.match(/^#便便\s+(\d+)d$/i) || t.match(/^#poop\s+(\d+)d$/i);
if (m) { replyText(ev.replyToken, buildReport(gid, daysAgo(parseInt(m[1],10)), new Date())); return; }
if (/^#便便\s+week$/i.test(t)) { replyText(ev.replyToken, buildReport(gid, startOfWeek(), new Date())); return; }
if (/^#便便\s+month$/i.test(t)) { replyText(ev.replyToken, buildReport(gid, startOfMonth(), new Date())); return; }
const m2 = t.match(/^#便便\s+(\d{4}-\d{2}-\d{2})\s+(\d{4}-\d{2}-\d{2})$/);
if (m2) { replyText(ev.replyToken, buildReport(gid, new Date(m2[1]+'T00:00:00'), new Date(m2[2]+'T23:59:59'))); return; }
// 3) 非指令:若本群已綁定 → 記錄訊息
if (getGroupIds().includes(gid)) {
const poopCount = (text.match(KEYWORDS) || []).length;
const now = new Date();
getSheet().appendRow([
now.toISOString(),
Utilities.formatDate(now, TZ, 'yyyy-MM-dd HH:mm:ss'),
gid, ev.source.userId || '', text, poopCount
]);
}
}
});
} catch (err) { console.error('doPost error:', err); }
return out;
}
功能:
#bind/#unbind/#便便?
)② 查詢區間(7d/week/month/起訖日
)③ 一般訊息紀錄(含 poop_count
)。function doGet(e) {
return ContentService.createTextOutput('GAS Webhook is active. Ready for LINE POST requests.');
}
功能:瀏覽器開 Web App URL 能看到固定字串,方便你確認部署成功。
說明:LINE 後台 Verify 主要看 POST 200,但 GET 端點讓你人為檢查也很直覺。
function buildReport(groupId, start, end) {
const values = getSheet().getDataRange().getValues();
values.shift(); // 去表頭
const startISO = start.toISOString();
const endISO = end.toISOString();
const perUser = {};
let total = 0;
values.forEach(r => {
const [ts_iso, , gid, uid, , count] = r;
if (gid !== groupId) return;
if (!ts_iso || ts_iso < startISO || ts_iso > endISO) return;
const c = Number(count) || 0;
if (!c) return;
perUser[uid] = (perUser[uid] || 0) + c;
total += c;
});
const rows = Object.entries(perUser)
.sort((a,b)=>b[1]-a[1]).slice(0,10)
.map(([uid, c], idx) => `${idx+1}. ${getDisplayName(groupId, uid)}:${c}`);
const title = `💩 統計 (${fmtDate(start)} ~ ${fmtDate(end)})`;
if (!rows.length) return `${title}\n這段期間沒有任何關鍵字紀錄~`;
return `${title}\n總數:${total}\n— 前 10 名 —\n${rows.join('\n')}`;
}
功能:讀整張表、用 ISO 時間做區間過濾、依 userId
彙總並排序,輸出一段可直接回覆的純文字報表。
說明:報表是「查詢再算」,平常寫表是 O(1);資料量小時直接全表掃最簡潔。
擴充:資料成長後可改為「每月分表」或在 GAS 端做「時間區間先篩再讀」。
function setupWeeklyTrigger() {
ScriptApp.getProjectTriggers().filter(t => t.getHandlerFunction()==='weeklyJob')
.forEach(t => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger('weeklyJob').timeBased().atHour(21).everyDays(1).create();
}
function weeklyJob() {
const today = new Date();
if (Utilities.formatDate(today, TZ, 'u') !== '6') return; // 只有週六送
const start = daysAgo(7);
getGroupIds().forEach(gid => pushText(gid, buildReport(gid, start, today)));
}
功能:建立每日 21:00 的觸發器,但 weeklyJob()
內部只在 週六 推送週報。
說明:把「頻率」交給排程、「週期條件」交給程式判斷,避免太複雜的 RRULE。
操作:在 GAS 執行一次 setupWeeklyTrigger()
就會建立觸發器。
function daysAgo(n){ const d=new Date(); d.setDate(d.getDate()-n); d.setHours(0,0,0,0); return d; }
function startOfWeek(){ const d=new Date(); const day=(d.getDay()+6)%7; d.setDate(d.getDate()-day); d.setHours(0,0,0,0); return d; } // 週一
function startOfMonth(){ const d=new Date(); d.setDate(1); d.setHours(0,0,0,0); return d; }
function fmtDate(d){ return Utilities.formatDate(d, TZ, 'yyyy-MM-dd'); }
功能:把時間對齊到日的起點,避免時分秒造成區間 off-by-one。
說明:報表以天為單位最符合人類閱讀;ISO 格式搭配字串比較也安全直覺。
/exec
,改程式記得重新部署新版本。replyToken
逾時:查詢報表請即時回覆;若要晚點送,改用 pushText
。getDisplayName
會失敗;程式已 fallback userId
後 6 碼。CacheService
)可減少 API 打點與延遲。#bind
→ 完成透過輸入寫好的指令查做操作
#bind 綁定本群開始統計
#unbind 取消本群統計
#便便? 顯示指令說明
#便便 7d 近 7 天
#便便 week 本週(週一至今)
#便便 month 本月
#便便 2025-09-01 2025-09-18 指定期間
Github: Code
今天就到這 掰掰