/** * index.js (覆蓋版) * - LINE webhook: POST /webhook * - GAS proxy: * POST /api/places_get * POST /api/base_get * POST /api/override_set * * 需要 .env (你原本就有): * PORT=3000 * LINE_CHANNEL_SECRET=xxxx * LINE_CHANNEL_ACCESS_TOKEN=xxxx * * # 你現有命名(建議沿用) * GAS=https://script.google.com/macros/s/XXXX/exec * SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx * * #(可選)也支援這些命名 * GAS_WEBAPP_URL=... * CAREZGO_SHARED_SECRET=... * * #(可選)你現有可能也有: * PUBLIC_BASE=https://webhook.carezgo.com.tw * LOG_LEVEL=info * LOG_BODY=1 */ require("dotenv").config(); const express = require("express"); const crypto = require("crypto"); const line = require("@line/bot-sdk"); // Node 18+ 有 global fetch;若沒有,請 npm i node-fetch let fetchFn = global.fetch; if (!fetchFn) { fetchFn = (...args) => import("node-fetch").then(({ default: fetch }) => fetch(...args)); } // ====== ENV ====== const PORT = Number(process.env.PORT || 3000); const LINE_CHANNEL_SECRET = process.env.LINE_CHANNEL_SECRET || ""; const LINE_CHANNEL_ACCESS_TOKEN = process.env.LINE_CHANNEL_ACCESS_TOKEN || ""; // 你現在 log 裡用 GAS / SECRET const GAS_URL = process.env.GAS || process.env.GAS_WEBAPP_URL || ""; const SHARED_SECRET = process.env.SECRET || process.env.CAREZGO_SHARED_SECRET || ""; const PUBLIC_BASE = process.env.PUBLIC_BASE || ""; const LOG_LEVEL = (process.env.LOG_LEVEL || "info").toLowerCase(); const LOG_BODY = String(process.env.LOG_BODY || "0") === "1"; // 額外(你截圖看過的 EVENT_INGEST_TOKEN 之類)不影響這份功能 const CAT = process.env.LINE_CHANNEL_ACCESS_TOKEN || ""; // 只是沿用你 log 風格顯示長度 // ====== Logging helpers ====== function logInfo(...args) { if (["info", "debug"].includes(LOG_LEVEL)) console.log(new Date().toISOString(), ...args); } function logDebug(...args) { if (LOG_LEVEL === "debug") console.log(new Date().toISOString(), ...args); } function logWarn(...args) { console.warn(new Date().toISOString(), ...args); } function logErr(...args) { console.error(new Date().toISOString(), ...args); } // ====== LINE Client ====== const lineClient = new line.Client({ channelAccessToken: LINE_CHANNEL_ACCESS_TOKEN, }); // ====== Express app ====== const app = express(); // health app.get("/healthz", (req, res) => res.json({ ok: true, t: Date.now() })); // JSON body for normal APIs app.use(express.json({ limit: "1mb" })); // ====== GAS signature helpers ====== function hmacHex(secret, raw) { return crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex"); } async function callGas(action, payload) { if (!GAS_URL) { return { status: 500, json: { ok: false, error: "GAS url missing (env GAS or GAS_WEBAPP_URL)" } }; } // 這裡跟你 GAS 那份 doPost 的驗簽規格一致: // sig = HMAC( JSON.stringify({action,payload,ts}), secret ) const bodyObj = { action, payload: payload || {}, ts: Date.now() }; const rawForSign = JSON.stringify(bodyObj); const signature = SHARED_SECRET ? hmacHex(SHARED_SECRET, rawForSign) : ""; const raw = JSON.stringify({ ...bodyObj, signature }); const resp = await fetchFn(GAS_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: raw, }); const text = await resp.text(); let json; try { json = JSON.parse(text); } catch { json = { ok: false, error: "GAS returned non-JSON", raw: text }; } return { status: resp.status, json }; } // ====== GAS Proxy Routes ====== app.post("/api/places_get", async (req, res) => { try { if (LOG_BODY) logDebug("[/api/places_get] body:", req.body); const { status, json } = await callGas("places_get", req.body); res.status(status).json(json); } catch (e) { res.status(500).json({ ok: false, error: String(e) }); } }); app.post("/api/base_get", async (req, res) => { try { if (LOG_BODY) logDebug("[/api/base_get] body:", req.body); const { status, json } = await callGas("base_get", req.body); res.status(status).json(json); } catch (e) { res.status(500).json({ ok: false, error: String(e) }); } }); app.post("/api/override_set", async (req, res) => { try { if (LOG_BODY) logDebug("[/api/override_set] body:", req.body); const { status, json } = await callGas("override_set", req.body); res.status(status).json(json); } catch (e) { res.status(500).json({ ok: false, error: String(e) }); } }); // ====== LINE webhook (raw body + signature verify) ====== app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { try { const signature = req.headers["x-line-signature"]; const bodyBuf = req.body; // Buffer if (!LINE_CHANNEL_SECRET) { logErr("[LINE] missing LINE_CHANNEL_SECRET"); return res.status(500).send("missing channel secret"); } const expected = crypto .createHmac("sha256", LINE_CHANNEL_SECRET) .update(bodyBuf) .digest("base64"); if (signature !== expected) { logWarn("[LINE] bad signature"); return res.status(401).send("bad signature"); } // ACK ASAP res.status(200).send("OK"); let data; try { data = JSON.parse(bodyBuf.toString("utf8")); } catch (e) { return logErr("[LINE] invalid json:", e); } if (LOG_BODY) logDebug("[LINE] webhook:", data); // async handle handleLineEvents(data).catch((err) => logErr("[LINE] handle error:", err)); } catch (e) { logErr("[LINE] webhook error:", e); res.status(500).send("error"); } }); // ====== LINE event handler (你之後可把你原本邏輯貼回來) ====== async function handleLineEvents(webhookBody) { const events = webhookBody.events || []; for (const ev of events) { // 基本 demo:ping -> pong if (ev.type === "message" && ev.message?.type === "text") { const text = (ev.message.text || "").trim(); if (text.toLowerCase() === "ping") { await safeReply(ev.replyToken, [{ type: "text", text: "pong" }]); } // 你也可以在這裡測 GAS 連通性(用戶發 base 就查) // if (text === "base") { // const r = await callGas("base_get", { lineUserId: ev.source?.userId }); // await safeReply(ev.replyToken, [{ type: "text", text: JSON.stringify(r.json) }]); // } } } } async function safeReply(replyToken, messages) { if (!replyToken) return; if (!LINE_CHANNEL_ACCESS_TOKEN) { logWarn("[LINE] missing LINE_CHANNEL_ACCESS_TOKEN, skip reply"); return; } try { await lineClient.replyMessage(replyToken, messages); } catch (e) { logErr("[LINE] reply failed:", e?.originalError || e); } } // ====== Start ====== app.listen(PORT, () => { logInfo(`LINE webhook listening on :${PORT}`); if (PUBLIC_BASE) logInfo(`PUBLIC_BASE: ${PUBLIC_BASE}`); if (GAS_URL) logInfo(`GAS: ${GAS_URL}`); logInfo( `ENV check: CAT len= ${String(CAT || "").length} SECRET len= ${String(SHARED_SECRET || "").length} LOG_LEVEL=${LOG_LEVEL} LOG_BODY=${LOG_BODY ? 1 : 0}` ); if (!SHARED_SECRET) logWarn("[WARN] SECRET / CAREZGO_SHARED_SECRET is empty (GAS signature will be empty)"); if (!LINE_CHANNEL_SECRET) logWarn("[WARN] LINE_CHANNEL_SECRET is empty"); });