iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0
自我挑戰組

一路side project - 學習筆記系列 第 5

[Day 5] 健康管理 x Line Bot - 你今天POO了嗎 - 計屎者 (實作篇)

  • 分享至 

  • xImage
  •  

繼上一篇 健康管理 x Line Bot - 你今天POO了嗎 - 計屎者 (使用手冊篇) ,今天來到實作篇。

其實整個部屬流程跟 [Day 2] 群組不可缺的夥伴 - Line Bot Reminder 一樣,只是這次多了額外要存取的地方 (這裡使用Google Sheet 當資料庫)


Part 0 |準備清單

  • 一個 Google 帳號(可用 GAS)
  • LINE Developers 帳號、Channel (記得要再額外創建 不要跟上一個應用混在一起用喔)
  • 一個要放 Bot 的 LINE 群組
  • 一個Google Sheet(等等要複製網址中的 SPREADSHEET_ID(/d/<這串>/edit 中間那串),備用)

Part 1 | 程式碼實作

1. 設定區(安全參數、常數、關鍵字)

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,避免時間落差。
  • 剛剛初始步驟 Google Sheet 所存的 SPREADSHEET_ID 記得要貼上。

2. 小工具:群組綁定清單(bind/unbind)

function getGroupIds() { ... }      // 從 Script Properties 讀 JSON 陣列
function saveGroupIds(ids) { ... }  // 去重後寫回
function addGroupId(gid) { ... }    // #bind 時加入
function rmGroupId(gid) { ... }     // #unbind 時移除

功能:以 Script Properties 維護一個「有在統計」的群組清單。
說明:不必手動抄 groupId,在群內打一句 #bind 就啟用;不同群也能各自啟用/停用。


3. Google Sheet 取用(自動建立表頭)

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 工作表就新建,並補上表頭。
說明:保證第一次部署就能寫資料,不用手動準備表頭。


4. LINE API 小工具(reply / push / 抓暱稱)

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 呼叫量。

5. Webhook 入口:記錄訊息 + 指令處理

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)。
    說明:把「狀態管理」、「查詢」、「資料蒐集」拆清楚;任何事件都回 200(避免 LINE 健檢失敗)。

6. 健康檢查(GET)

function doGet(e) {
  return ContentService.createTextOutput('GAS Webhook is active. Ready for LINE POST requests.');
}

功能:瀏覽器開 Web App URL 能看到固定字串,方便你確認部署成功。
說明:LINE 後台 Verify 主要看 POST 200,但 GET 端點讓你人為檢查也很直覺。


7. 統計與報表(彙總 Top 10、總數)

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 端做「時間區間先篩再讀」。


8. 排程:每晚 21:00 檢查、週六才送週報

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() 就會建立觸發器。


9. 日期工具(區間計算)

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 格式搭配字串比較也安全直覺。


11. 常見坑位

  • Webhook Verify 失敗:WebApp 存取權一定要「任何人」,URL 用 /exec,改程式記得重新部署新版本。
  • replyToken 逾時:查詢報表請即時回覆;若要晚點送,改用 pushText
  • 顯示名稱抓不到:Bot 必須在群裡,否則 getDisplayName 會失敗;程式已 fallback userId 後 6 碼。
  • 大量名稱查詢:加上快取(CacheService)可減少 API 打點與延遲。

Part 3|部署 & 測試

1. GAS → 部署 > 新部署

  • 類型選「網路應用程式」
  • 執行身份:我
  • 存取權限:任何人
  • 取得部署後的 Web App URL

2. 到 LINE Developers → Messaging API > Webhook settings

  • Webhook URL 貼上剛才的 Web App URL
  • Use webhook: Enable (要記得!)
  • 點 Verify 應該會顯示 200 OK
  • (本米當時一直跳 302 error,但後來直接加入群組可以使用就沒管XD[誤])

3. 把 Bot 加進你要的 LINE 群組

4. 在群組中輸入 #bind → 完成

https://ithelp.ithome.com.tw/upload/images/20250917/20154764FCDBvS6C9f.jpg

https://ithelp.ithome.com.tw/upload/images/20250917/20154764JIeNWpgZiO.jpg


Part 4 | 指令總表

透過輸入寫好的指令查做操作

#bind        綁定本群開始統計
#unbind      取消本群統計
#便便?       顯示指令說明
#便便 7d     近 7 天
#便便 week   本週(週一至今)
#便便 month  本月
#便便 2025-09-01 2025-09-18  指定期間

Github: Code

今天就到這 掰掰


上一篇
[Day 4] 健康管理 x Line Bot - 你今天POO了嗎 - 計屎者 (使用手冊篇)
下一篇
[Day 6] GU價格提醒系統 (1) - 需求拆解、資料源盤點
系列文
一路side project - 學習筆記10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言