和 AI 一起寫程式:創意生活應用 WEEK 6 延伸 LINE bot 簡單版

LINE bot 簡單版:一支 Python 程式,接上你電腦裡的本地模型

W5 你的 bot 住在 Google 的 Apps Script,問的是 Gemini。這一版換個做法:一支 Python 程式 line_bot_local.py 就是整個 bot,跑在你自己的電腦上,問的是你電腦裡的 Ollama 本地模型。它會:回答問題(需要就自己上網查、附來源)、看照片、把行程排進 Google 日曆、照片存進雲端硬碟;問題、行程、照片都先記進 Google 試算表才回覆(打招呼和看不懂的格式不記)。

這一頁怎麼用:從上往下做,每一步有「指令或畫面 → 你應該看到 → 測一下 → 卡住了」,做完打勾(勾選只存在這台瀏覽器)。第一次做全部約 40–60 分鐘;第 1 段的安裝做過 W6 課堂的人大多可以跳過。之後再開 bot,只要做第 3 段。

已勾 0/0 步
順序
  1. 先看懂——它是什麼、跟 W5 差在哪、一則訊息怎麼走
  2. 第 1 段 安裝(只有第一次)——Python、Ollama、gws、cloudflared、下載程式
  3. 第 2 段 準備三個值——試算表 ID、LINE 的 Channel secret 和長效通行碼
  4. 第 3 段 開 bot——一個指令,它會自己接上 LINE
  5. 第 4 段 用手機試四種訊息——打招呼、問問題、講行程、傳照片
  6. 第 5 段 結束、把 LINE 還給 Apps Script
  7. 程式怎麼讀、它叫了哪些 Ollama API
  8. 卡關總表——出錯了先用關鍵字查這裡

先看懂:它是什麼、跟 W5 差在哪

看完你會知道:為什麼這支程式能直接叫本地模型、為什麼還是要一條「通道」。

一則訊息怎麼走

📱 手機傳文字或照片
 → LINE 的伺服器
 → Cloudflare 通道(給你電腦一個臨時的 https 網址;LINE 才找得到你的電腦)
 → 你電腦上的 line_bot_local.py(先對簽章,確定真的是 LINE 送來的)
 → 本機的 Ollama(http://127.0.0.1:11434):回答問題、看照片、整理行程
 → gws:照片存雲端硬碟、行程排日曆、寫一列進試算表
 → 全部做完才回覆手機(只講已經做完的事)

為什麼不用轉接站:bot 就跑在有 Ollama 的那台電腦上,程式裡寫 localhost(本機)就叫得到 Ollama。W5 的 Apps Script 跑在 Google 機房,它說 localhost 是指 Google 自己,才需要另外搭轉接站(那是下週的進階版)。

為什麼還要通道:LINE 的伺服器要把訊息「送進來」,得找得到你的電腦;教室和家裡的電腦都躲在網路後面,外面找不到。Cloudflare 的免費臨時通道(tunnel)給你電腦一個 https 網址,程式開起來會自己開、自己把 LINE 的 Webhook(LINE 把訊息送過去的網址)換成它,結束時再換回去。

W5 Apps Script 版這一版(簡單版)下週進階版
程式在哪跑Google 機房你的電腦Google 機房+你電腦的轉接站
問誰Gemini(免費版沒有搜尋)你電腦的 Ollama,會自己上網查你電腦的 Ollama
要的鑰匙Gemini 金鑰、LINE 通行碼LINE 通行碼、Channel secret(開程式時貼,不存檔)Gemini 金鑰、LINE 通行碼、轉接站密碼
電腦關了照樣運作bot 停(LINE 換回 Apps Script 後,W5 那支照常)「查」功能停,其他照常

它會做的四件事

你傳它做
hi、你好回使用說明,不叫模型、不記表
問題(「2026 年諾貝爾物理獎得主是誰?」)本地模型回答;覺得需要就自己上網查,回覆附來源網址;記一列進試算表
行程(「明天下午三點要跟專題組開會」)算出實際日期時間、排進 Google 日曆;記一列進試算表
照片(活動海報、便條紙)照片存進雲端硬碟「LINE待辦照片」;模型看照片說上面有什麼;看得到日期時間就排進日曆;記一列進試算表(含照片連結)

第 1 段 安裝(只有第一次)

這段做完你會有:Python、Ollama+本地模型、gws(登入好、有日曆權限)、cloudflared,和一個放好 line_bot_local.py 的資料夾。

開 PowerShell(開始功能表搜「PowerShell」,出現的 Windows PowerShell 就可以,不用系統管理員)。每一步裝完,新開一個 PowerShell 視窗再測:舊視窗看不到剛裝好的程式。

  1. 先看看電腦有沒有:

    PowerShell
    python --version

    印出 Python 3. 開頭就跳到 1-2。說「不是指令」或跳出微軟商店,就用 winget(Windows 內建的安裝指令)裝:

    PowerShell
    winget install --id Python.Python.3.13 -e --accept-package-agreements --accept-source-agreements
    你應該看到winget 最後說「已成功安裝」。關掉這個視窗、新開一個,再打一次 python --version,印出 Python 3.13.…(老師電腦 10/10 是 3.13;3.10 以上都可以)。
    測一下新視窗的 python --version 印得出版本就過。這支 bot 只用 Python 內建的功能,不用 pip install 任何東西。
    卡住了
    • 打 python 跳出微軟商店→ 那是 Windows 的空殼捷徑,不是真的 Python。關掉商店,用上面 winget 那行裝,裝完新開視窗。
    • 裝完還是說 python 不是指令→ 沒有新開視窗。真的開新的還不行,改打 py --version,之後的指令把 python 都換成 py。
    • 「使用者帳戶控制」要管理員密碼→ 在教室就舉手;在家按「是」。
  2. Ollama 是在自己電腦跑 AI 模型的程式;gemma4:e4b 是 Google 的開放模型(6.6 GB),看得懂文字也看得懂照片。

    PowerShell
    winget install --id Ollama.Ollama -e --accept-package-agreements --accept-source-agreements --silent
    $env:PATH += ";$env:LOCALAPPDATA\Programs\Ollama"
    ollama pull gemma4:e4b
    你應該看到先是安裝,接著 pulling manifest 和一條往 6.6 GB 走的進度條(老師電腦約三分半,看網速),最後 success。工作列右下角多一個羊駝圖示=Ollama 在背景跑。
    測一下新視窗打 ollama list,清單裡有 gemma4:e4b 就過。
    卡住了
    • winget 被擋→ 到 ollama.com 下載 Windows 安裝檔雙擊裝,再跑第二、三行。
    • ollama list 說 could not connect→ Ollama 沒在跑:開始功能表打開「Ollama」,再試一次。
    • 顯示卡太小、之後回答很慢或出錯→ 改用小一號的 ollama pull gemma4:e2b,開 bot 前先打 $env:MODEL="gemma4:e2b"(同一個視窗)。
  3. gws 是 Google 做的命令列工具,bot 用它存照片、排日曆、寫試算表;它要 Node.js 才跑得動。

    PowerShell
    winget install --id OpenJS.NodeJS.LTS -e --accept-package-agreements --accept-source-agreements --silent
    $env:PATH += ";$env:ProgramFiles\nodejs;$env:APPDATA\npm"
    npm install -g --allow-scripts=@googleworkspace/cli @googleworkspace/cli
    你應該看到npm 那行最後印 added … packages。
    測一下新視窗打 gws --version,印出 gws 0.22.5 之類的版本就過。
    卡住了
    • gws 不是指令→ 沒有新開視窗;或 npm 那行沒跑完,重跑第二、三行。
    • npm 印 EACCES 或權限錯誤→ 重開 PowerShell 再跑一次。
  4. W6 第 2 段你建了自己的 Google Cloud 專案,啟用了 Drive 和 Sheets 兩個 API。這次 bot 還要排日曆,所以多啟用一個 Google Calendar API。還沒做過第 2 段的,先照 W6 互動頁第 2 段的 2-1~2-6 做完(建專案、同意畫面、測試使用者、桌面用戶端、放好 client_secret.json;2-2 那步三個 API 一起啟用),做完回到這裡的 1-5。

    瀏覽器開 console.cloud.google.com(用跟 bot 同一個個人 Gmail)→ 上方確認是你 W6 建的那個專案 → 左上 ≡ →「API 和服務」→「程式庫」→ 搜尋 Google Calendar API → 點進去 → 按「啟用」。

    你應該看到按鈕從「啟用」變成「管理」,跟 W6 啟用 Drive、Sheets 時一樣。
    測一下「API 和服務」→「已啟用的 API 和服務」清單裡有 Google Drive API、Google Sheets API、Google Calendar API 三個。
    卡住了
    • 搜到好幾個名字像的→ 要的是「Google Calendar API」,不是 CalDAV API。
    • 找不到 W6 建的專案→ 右上頭像換成你建專案的那個 Gmail;上方專案選單切換。
  5. W6 課堂登入時只要了雲端硬碟和試算表,這次加上日曆。引號要照打(少了引號,PowerShell 會把逗號拆開):

    PowerShell
    gws auth login -s "drive,sheets,calendar"
    你應該看到它印一條很長的網址:整條複製、貼到瀏覽器 → 選你的 Gmail →(個人 Gmail 可能出現「Google 尚未驗證這個應用程式」→ 按「進階」→「前往…」)→ 權限勾選頁全部勾選 → 按繼續 → 瀏覽器說可以關掉;終端機印出 "status": "success"。
    測一下打 gws auth status,scopes 清單裡要有 …/auth/drive、…/auth/spreadsheets、…/auth/calendar 三個。
    卡住了
    • 瀏覽器說 Access blocked→ 你的 Gmail 不在專案的測試使用者名單:回 Cloud Console「目標對象」把自己加進去(W6 互動頁 2-4)。
    • scopes 只有 openid、email、profile→ 引號沒打,重跑上面那行。
    • 重登了 scopes 還是沒有 calendar、瀏覽器也沒出現授權頁→ 舊的登入被沿用了:先 gws auth logout,再跑上面那行。
    • 說找不到 client_secret.json→ W6 互動頁 2-6 沒做,或那台電腦是還原卡、重開機被清掉了:重下載 JSON 放進 %USERPROFILE%\.config\gws。
  6. PowerShell
    winget install --id Cloudflare.cloudflared -e --accept-package-agreements --accept-source-agreements
    你應該看到winget 說已成功安裝。關掉視窗、新開一個。
    測一下新視窗打 cloudflared --version,印出 cloudflared version 2026.… 就過。
    卡住了
    • cloudflared 不是指令→ 沒有新開視窗。
    • winget 被擋→ 到 Cloudflare 官方文件「Downloads」頁下載 Windows 版,或在教室舉手。
  7. 按下面「下載」,瀏覽器會存到「下載」資料夾。再貼這段,在「下載」裡開一個 line-bot 資料夾、把程式搬進去、進到那裡:

    line_bot_local.py
    # LINE bot 簡單版:一支 Python 程式就是整個 bot,跑在你自己的電腦上
    #
    # 傳什麼進來,它做什麼:
    #   問問題(「2026 年諾貝爾物理獎得主是誰?」)→ 交給你電腦上的 Ollama 本地模型,需要就自己上網搜,回覆附來源
    #   講行程(「明天下午三點開會」)            → 整理出日期時間,排進 Google 日曆
    #   傳照片(活動海報、便條紙)               → 照片存進雲端硬碟;模型看照片說上面有什麼;有日期時間就排進日曆
    #   問題、行程、照片都先寫一列進 Google 試算表,才回覆(只講已經做完的事;打招呼和看不懂的格式不記)
    # 雲端硬碟、試算表、日曆都用第 2 段登入好的 gws;模型一律打本機 Ollama 的 API(http://127.0.0.1:11434)。
    #
    # 為什麼還要「通道」:LINE 的伺服器要把訊息送進來,得找得到你的電腦;
    # 教室和家裡的電腦都躲在網路後面,所以用 Cloudflare 的免費臨時通道給它一個 https 網址。
    # 程式開起來會自己把 LINE 的 Webhook 換成這個網址,按 Ctrl+C 結束時再換回原本的(例如你 W5 的 Apps Script)。
    #
    # 需要:Python 3(只用內建功能,不用 pip install)、Ollama 開著且有 gemma4:e4b、
    #       gws 已登入(要有 drive、sheets、calendar 權限)、cloudflared、LINE 的長效通行碼(W5 申請過)
    # 用法(PowerShell,在這個檔所在的資料夾):
    #   python line_bot_local.py
    # 開起來會問你 LINE 長效通行碼、Channel secret(畫面不顯示)和試算表 ID,貼上按 Enter 就好。
    
    import base64, getpass, hashlib, hmac, json, os, queue, re, shutil, subprocess, sys, tempfile, threading, time, urllib.error, urllib.request
    from datetime import datetime, timedelta
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    
    MODEL = os.environ.get("MODEL", "gemma4:e4b")
    PORT = int(os.environ.get("PORT", "8000"))
    CALENDAR_ID = os.environ.get("CALENDAR_ID", "primary")          # 排進哪個日曆;primary=你的主日曆
    PHOTO_FOLDER = os.environ.get("PHOTO_FOLDER", "LINE待辦照片")   # 照片存進雲端硬碟的哪個資料夾(沒有就建)
    OLLAMA = "http://127.0.0.1:11434"
    GREETING = re.compile(r"^(hi|hello|你好|嗨|哈囉|測試|test)$", re.I)
    HELP = ("你好!我是跑在同學電腦上的本地模型 🤖\n"
            "• 問我問題:需要的話我會上網查,附來源\n• 講行程(「明天下午三點開會」):我排進 Google 日曆\n"
            "• 傳照片:我存進雲端硬碟、告訴你上面有什麼,有日期時間也幫你排日曆\n問題、行程、照片都會記進試算表。")
    TOOLS = [
        {"type": "function", "function": {"name": "web_search", "description": "搜尋網路,回傳前幾筆標題、網址與內文。",
         "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}},
        {"type": "function", "function": {"name": "web_fetch", "description": "讀一個網頁的主要文字。",
         "parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}},
    ]
    EVENT_SHAPE = {"type": "object", "required": ["isEvent", "title", "date", "time"], "properties": {
        "isEvent": {"type": "boolean"}, "title": {"type": "string"},
        "date": {"type": "string", "description": "YYYY-MM-DD,沒提到就填空字串"},
        "time": {"type": "string", "description": "HH:MM(24 小時制);有結束時間就寫 HH:MM-HH:MM;沒提到就填空字串"}}}
    
    LINE_TOKEN = LINE_SECRET = SHEET_ID = ""  # main() 開起來時問你,再填進這裡
    GWS = None
    _folder_id = None
    _turn = threading.Lock()  # 一次只處理一則:顯示卡一次只答一題,照片資料夾也不會被同時建兩個
    _procs = []               # 開出去的 cloudflared,結束時一定要關
    TUNNEL_WAIT = 60         # 等 cloudflared 給網址最多幾秒
    
    
    def log(s):
        print(f"[{datetime.now():%H:%M:%S}] {s}", flush=True)
    
    
    def today():
        return datetime.now().strftime("%Y-%m-%d(%A)")  # 附上星期幾,模型才算得準「下週五」
    
    
    # ===== 1. 設定:開起來時問一次,不寫進檔案 =====
    def ask_settings():
        token = os.environ.get("LINE_CHANNEL_ACCESS_TOKEN") or getpass.getpass("貼上 LINE 長效通行碼(Channel access token,畫面不顯示):").strip()
        secret = os.environ.get("LINE_CHANNEL_SECRET") or getpass.getpass("貼上 Channel secret(LINE Developers → Basic settings;必填;畫面不顯示):").strip()
        sheet = os.environ.get("SHEET_ID") or input("試算表 ID(網址 /d/ 和 /edit 中間那段;不寫試算表就直接 Enter):").strip()
        return token, secret, sheet
    
    
    # ===== 2. 叫 LINE、叫 Ollama、叫 gws 的小工具 =====
    def http_json(method, url, body=None, headers=None, timeout=60):
        data = json.dumps(body).encode() if body is not None else None
        req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json", **(headers or {})})
        try:
            with urllib.request.urlopen(req, timeout=timeout) as r:
                raw = r.read()
                return r.status, (json.loads(raw) if raw.strip() else {})
        except urllib.error.HTTPError as e:
            return e.code, {"error": e.read().decode("utf-8", "replace")[:200]}
    
    
    def line(method, path, body=None):
        return http_json(method, "https://api.line.me" + path, body, {"Authorization": "Bearer " + LINE_TOKEN})
    
    
    def reply(token, text):
        code, res = line("POST", "/v2/bot/message/reply", {"replyToken": token, "messages": [{"type": "text", "text": text[:5000]}]})
        if code != 200:
            log(f"⚠️ LINE 回覆失敗 {code}:{res}")
    
    
    def download_image(message_id):
        req = urllib.request.Request(f"https://api-data.line.me/v2/bot/message/{message_id}/content",
                                     headers={"Authorization": "Bearer " + LINE_TOKEN})
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.read()
    
    
    def ollama(path, body, timeout=60):
        code, res = http_json("POST", OLLAMA + path, body, timeout=timeout)
        if code != 200:
            raise RuntimeError(f"Ollama {path} 回應 {code}:{res}")
        return res
    
    
    def chat(messages, timeout=60, **extra):
        body = {"model": MODEL, "messages": messages, "stream": False, "think": False, "keep_alive": "1h", "options": {"num_ctx": 16384}}
        return ollama("/api/chat", {**body, **extra}, timeout).get("message", {})
    
    
    def gws(*args, upload=None, cwd=None):
        """跑一條 gws 指令,回傳它印出的 JSON。(gws 只肯上傳「目前資料夾」裡的檔,所以上傳時用 cwd 指到檔案所在的資料夾)"""
        cmd = [GWS, *args] + (["--upload", upload] if upload else [])
        r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=cwd)
        if r.returncode != 0:
            raise RuntimeError((r.stderr or r.stdout).strip()[-300:])
        return json.loads(r.stdout or "{}")
    
    
    def find_gws():
        # npm 裝的 gws 背後有一個真正的 gws.exe;直接叫它,JSON 的引號才不會被 Windows 的 .cmd 轉接弄壞
        roots = [os.path.join(os.environ.get("APPDATA", ""), "npm", "node_modules")]
        try:  # npm 裝到別的地方時,問 npm 它的全域資料夾在哪
            roots.append(subprocess.run("npm root -g", shell=True, capture_output=True, text=True, timeout=30).stdout.strip())
        except Exception:
            pass
        for root in roots:
            exe = os.path.join(root, "@googleworkspace", "cli", "bin", "gws.exe")
            if root and os.path.exists(exe):
                return exe
        return None
    
    
    # ===== 3. 模型:回答問題(自己決定要不要搜)、看照片、找出行程 =====
    def ask_text(question):
        """模型自己決定要不要搜;回傳(答案, 來源網址清單)。"""
        msgs = [{"role": "system", "content": f"你是 LINE 上的助理,今天是 {today()},可能比你的訓練資料新。"
                 "遇到最近的事、版本號、價格或任何你不確定的事實,先呼叫 web_search;不夠明確再用 web_fetch 讀官方頁面。"
                 "回答用台灣的繁體中文、純文字(不要用星號或井字號),150 字以內,先講結論;不用自己寫網址,程式會附上來源。"},
                {"role": "user", "content": question}]
        sources = []
        for _ in range(4):  # 最多來回四次工具呼叫
            m = chat(msgs, tools=TOOLS)
            msgs.append(m)
            if not m.get("tool_calls"):
                return m.get("content", "").replace("**", "").strip(), sources
            for c in m["tool_calls"]:
                name, args = c["function"]["name"], c["function"].get("arguments") or {}
                try:
                    if name == "web_search":
                        log(f"  🔍 模型決定搜:{args.get('query')}")
                        rows = ollama("/api/experimental/web_search", {"query": args.get("query", ""), "max_results": 3}).get("results", [])
                        sources += [r["url"] for r in rows]
                        out = json.dumps([{"title": r["title"], "url": r["url"], "content": r["content"][:1500]} for r in rows], ensure_ascii=False)
                    else:
                        log(f"  📄 模型決定讀網頁:{args.get('url')}")
                        page = ollama("/api/experimental/web_fetch", {"url": args.get("url", "")})
                        sources.insert(0, args.get("url", ""))
                        out = page.get("content", "")[:3000]
                except Exception as e:
                    out = f"ERROR: {e}"
                msgs.append({"role": "tool", "tool_name": name, "content": out})
        return "查了幾次還整理不出答案,換個問法再試一次。", sources
    
    
    def ask_image(jpg):
        m = chat([{"role": "user", "images": [base64.b64encode(jpg).decode()],
                   "content": "這是使用者用 LINE 傳來的照片。用台灣的繁體中文、三句以內說明照片裡有什麼;"
                              "看得到日期、時間、地點、活動或繳費資訊就列出來,看不清楚的不要猜。"}])
        return re.sub(r"^\s*[*-]\s+", "• ", m.get("content", "").replace("**", ""), flags=re.M).strip()  # 星號清單在 LINE 上很亂,換成 •
    
    
    def find_event(text):
        """請模型照固定格式回答「這是不是有日期和時間的行程」;欄位驗算過才算數,不照單全收。"""
        m = chat([{"role": "user", "content": f"今天是 {today()},台北時間。下面這段文字如果是一個有日期和時間的行程、活動或提醒"
                   "(不是在問問題),把「明天」「下週五」換算成實際日期,整理成 JSON;不是就 isEvent 填 false。\n\n" + text}],
                 format=EVENT_SHAPE)
        try:
            ev = json.loads(m.get("content", "{}"))
            times = re.findall(r"\d{1,2}:\d{2}", ev["time"])  # 「15:10-17:00」這種區間:第一個是開始、第二個是結束
            ev["start"] = datetime.strptime(f"{ev['date']} {times[0]}", "%Y-%m-%d %H:%M")  # 2/30、25:99 這種不存在的會在這裡被擋掉
            ev["end"] = datetime.strptime(f"{ev['date']} {times[1]}", "%Y-%m-%d %H:%M") if len(times) > 1 else ev["start"] + timedelta(minutes=30)
            ev["time"] = f"{ev['start']:%H:%M}" + (f"–{ev['end']:%H:%M}" if len(times) > 1 else "")
        except (ValueError, KeyError, TypeError, IndexError):
            return None
        # 原文寫了「幾月幾日」或「10/20」,模型整理出來的日期要對得上:模型會偷偷把「2 月 30 日」改成 2 月 28 日
        said = re.search(r"(\d{4})\s*[-/年]\s*(\d{1,2})\s*[-/月]\s*(\d{1,2})|(\d{1,2})\s*月\s*(\d{1,2})\s*[日號]|(?<!\d)(\d{1,2})/(\d{1,2})(?!\d)", text)
        nums = [int(x) for x in said.groups() if x] if said else []
        if nums and nums != [ev["start"].year, ev["start"].month, ev["start"].day][-len(nums):]:
            log(f"  ⚠️ 模型整理的日期 {ev['date']} 跟原文「{said.group(0)}」對不上,不排日曆")
            return None
        return ev if ev.get("isEvent") is True and str(ev.get("title", "")).strip() and ev["end"] > ev["start"] else None
    
    
    # ===== 4. Google:照片存雲端硬碟、排日曆、寫試算表(都用 gws) =====
    def save_photo(jpg):
        """照片存進 PHOTO_FOLDER 資料夾(沒有就建),回傳檔案連結。"""
        global _folder_id
        if not _folder_id:
            q = f"name='{PHOTO_FOLDER}' and mimeType='application/vnd.google-apps.folder' and trashed=false"
            found = gws("drive", "files", "list", "--params", json.dumps({"q": q, "fields": "files(id)"}, ensure_ascii=False)).get("files", [])
            _folder_id = found[0]["id"] if found else gws("drive", "files", "create", "--json", json.dumps(
                {"name": PHOTO_FOLDER, "mimeType": "application/vnd.google-apps.folder"}, ensure_ascii=False))["id"]
        with tempfile.TemporaryDirectory() as tmp:  # 照片先存成暫存檔,上傳完整個暫存資料夾自動刪掉
            with open(os.path.join(tmp, "photo.jpg"), "wb") as fh:
                fh.write(jpg)
            f = gws("drive", "files", "create", "--params", json.dumps({"fields": "id,webViewLink"}),
                    "--json", json.dumps({"name": f"line-{datetime.now():%Y%m%d-%H%M%S}.jpg", "parents": [_folder_id]}),
                    upload="photo.jpg", cwd=tmp)
        log("  📷 照片存進雲端硬碟了")
        return f.get("webViewLink", "")
    
    
    def add_calendar(ev):
        gws("calendar", "events", "insert", "--params", json.dumps({"calendarId": CALENDAR_ID}),
            "--json", json.dumps({"summary": ev["title"], "description": "由 LINE bot 簡單版建立",
                                  "start": {"dateTime": f"{ev['start']:%Y-%m-%dT%H:%M:00}", "timeZone": "Asia/Taipei"},
                                  "end": {"dateTime": f"{ev['end']:%Y-%m-%dT%H:%M:00}", "timeZone": "Asia/Taipei"}}, ensure_ascii=False))
        log(f"  🗓️ 排進日曆了:{ev['date']} {ev['time']}")
    
    
    def write_sheet(row):
        """寫一列進試算表:時間、類型、傳了什麼、bot 回了什麼、照片連結、日曆。"""
        gws("sheets", "spreadsheets", "values", "append",
            "--params", json.dumps({"spreadsheetId": SHEET_ID, "range": "A1", "valueInputOption": "USER_ENTERED"}),
            "--json", json.dumps({"values": [row]}, ensure_ascii=False))
        log("  📝 寫進試算表了")
    
    
    def try_do(what, fn, *args):
        """Google 那邊哪一件失敗,都不讓整則訊息掛掉;回傳(結果, 錯誤訊息;成功時是空字串)。"""
        try:
            return fn(*args), ""
        except Exception as e:
            log(f"⚠️ {what}失敗:{e}")
            return None, str(e)[:120] or "不明原因"
    
    
    # ===== 5. 處理一則訊息:先把事情做完(存照片、排日曆、寫試算表)→ 才回覆,只講已經做完的事 =====
    def handle(event):
        with _turn:
            _handle(event)
    
    
    def _handle(event):
        if event.get("type") != "message" or not event.get("replyToken"):
            return
        msg, token = event["message"], event["replyToken"]
        user = event.get("source", {})
        if user.get("type") == "user":  # 「對方正在輸入…」動畫,最多 60 秒
            line("POST", "/v2/bot/chat/loading/start", {"chatId": user["userId"], "loadingSeconds": 60})
        t0, notes, link, cal = time.time(), [], "", ""
        try:
            if msg["type"] == "text":
                text = msg["text"].strip()
                if GREETING.match(text):
                    return reply(token, HELP)
                log(f"📩 收到文字({len(text)} 字)")  # 不印內容:螢幕可能正在投影
                kind, said, ev = "文字", text, find_event(text)
                if ev:  # 講行程:排日曆就好,不必上網查
                    answer = f"📌 {ev['title']}\n📅 {ev['date']} ⏰ {ev['time']}"
                else:   # 問問題:交給模型,需要就自己搜
                    answer, sources = ask_text(text)
                    answer += ("\n\n來源:\n" + "\n".join("• " + u for u in list(dict.fromkeys(sources))[:3]) if sources
                               else "\n\nℹ️ 這題沒有上網查,只憑模型記憶,請自己核對。")
            elif msg["type"] == "image":
                log("📩 收到照片")
                jpg = download_image(msg["id"])
                kind, said = "照片", "(照片)"
                link, err = try_do("存照片", save_photo, jpg)
                notes.append(f"📷 照片已存進雲端硬碟「{PHOTO_FOLDER}」" if not err else "⚠️ 照片沒存進雲端硬碟:" + err)
                answer = ask_image(jpg)
                ev = find_event(answer)
            else:
                return reply(token, "目前我看得懂文字和照片,其他類型還不行喔。")
            if ev:
                _, err = try_do("排日曆", add_calendar, ev)
                cal = "已建立" if not err else "失敗:" + err
                notes.append(f"🗓️ 已排進 Google 日曆({ev['date']} {ev['time']})" if not err else "⚠️ 日曆沒排成:" + err)
            if SHEET_ID:
                _, err = try_do("寫試算表", write_sheet, [f"{datetime.now():%Y-%m-%d %H:%M:%S}", kind, said, answer[:500], link or "", cal])
                notes.append("📝 已記進試算表" if not err else "⚠️ 這則沒寫進試算表:" + err)
            reply(token, f"{answer}\n\n" + "\n".join(notes + [f"(本地模型 {time.time() - t0:.0f} 秒)"]))
            log(f"✅ 回覆了({time.time() - t0:.0f} 秒)")
        except Exception as e:
            log(f"❌ {e}")
            reply(token, "⚠️ 出錯了:" + str(e)[:300])
    
    
    class Webhook(BaseHTTPRequestHandler):
        def do_POST(self):
            body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
            if LINE_SECRET:  # 對簽章:確定真的是 LINE 送來的,不是知道網址的路人
                sig = base64.b64encode(hmac.new(LINE_SECRET.encode(), body, hashlib.sha256).digest()).decode()
                if not hmac.compare_digest(sig, self.headers.get("x-line-signature", "")):
                    log("⛔ 簽章不對,不是 LINE 送來的,丟掉")
                    self.send_response(401)
                    self.end_headers()
                    return
            self.send_response(200)  # 先跟 LINE 說收到了(LINE 要 2 秒內回應),再慢慢處理
            self.end_headers()
            for ev in json.loads(body or b"{}").get("events", []):
                threading.Thread(target=handle, args=(ev,), daemon=True).start()
    
        def do_GET(self):
            self.send_response(200)
            self.end_headers()
            self.wfile.write("LINE bot 簡單版運作中".encode())
    
        def log_message(self, *args):  # 不要印每一個連線
            pass
    
    
    # ===== 6. 開通道、把 LINE 的 Webhook 換過來;結束時換回去 =====
    def open_tunnel():
        """開 Cloudflare 臨時通道,最多等 60 秒拿網址;拿不到就把它關掉,回傳 None。"""
        if not shutil.which("cloudflared"):
            log("⚠️ 找不到 cloudflared,外面連不到這裡。另開 PowerShell 貼:winget install --id Cloudflare.cloudflared -e")
            log("   裝完關掉這個視窗、新開一個再跑一次。")
            return None
        cf = subprocess.Popen(["cloudflared", "tunnel", "--no-autoupdate", "--url", f"http://127.0.0.1:{PORT}"],
                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace")
        _procs.append(cf)
        found = queue.Queue()
    
        def watch():  # 另一條執行緒一行一行讀它的輸出(之後的也照讀照丟,免得塞住);主程式只等 60 秒
            for row in cf.stdout:
                m = re.search(r"https://[a-z0-9-]+\.trycloudflare\.com", row)
                if m:
                    found.put(m.group(0))
            found.put(None)
    
        threading.Thread(target=watch, daemon=True).start()
        try:
            url = found.get(timeout=TUNNEL_WAIT)
        except queue.Empty:
            url = None
        if not url:
            log(f"⚠️ {TUNNEL_WAIT} 秒內通道沒拿到網址:看網路(學校網路可能擋),或再跑一次")
        return url
    
    
    def restore(old):
        """把 LINE 的 Webhook 換回原本的,再讀一次確認真的換回去了(換的那條路說成功不算數)。"""
        if not old:
            log("ℹ️ 原本沒有 Webhook 網址,現在指著一個已經關掉的通道;要用 Apps Script 版就回 LINE Developers 貼回 /exec 網址")
            return
        line("PUT", "/v2/bot/channel/webhook/endpoint", {"endpoint": old})
        code, now = line("GET", "/v2/bot/channel/webhook/endpoint")
        if code == 200 and now.get("endpoint") == old:
            log("↩️ LINE 的 Webhook 換回原本的了(重新讀過一次,確認是原本那個)")
        else:
            log(f"⚠️ 沒能確認換回去:到 LINE Developers →「Messaging API」→ Webhook URL 手動貼回:{old}")
    
    
    def main():
        global LINE_TOKEN, LINE_SECRET, SHEET_ID, GWS
        LINE_TOKEN, LINE_SECRET, SHEET_ID = ask_settings()
        GWS = find_gws()
        code, info = line("GET", "/v2/bot/info")
        if code != 200:
            sys.exit(f"LINE 通行碼不對({code}):回 LINE Developers 重新複製長效通行碼")
        log(f"✅ LINE bot「{info.get('displayName')}」")
        if not LINE_SECRET:
            sys.exit("沒有 Channel secret 就不開通道:沒有它,知道通道網址的人都能假冒 LINE 送訊息進來。"
                     "回 LINE Developers →「Basic settings」複製,再跑一次。")
        if not GWS:
            log("⚠️ 找不到 gws.exe:照片、日曆、試算表都會失敗(先裝 gws:指南 1-3)")
        code, cur = line("GET", "/v2/bot/channel/webhook/endpoint")
        if code == 200:
            old = cur.get("endpoint")
        elif code == 404:  # LINE 說這個 channel 原本就沒設 Webhook
            old = None
        else:  # 讀不到原本的網址就不換,免得換了回不去
            sys.exit(f"讀不到 LINE 現在的 Webhook 網址({code}):先不換。等一下再跑一次。{cur}")
        log(f"   原本的 Webhook:{old or '(沒有設定)'}——先抄下來,萬一沒換回去要手動貼回")
        log(f"載入模型 {MODEL}(第一次要等一下)…")
        chat([], timeout=300)
        log("✅ 模型已在顯示卡上")
    
        server = ThreadingHTTPServer(("127.0.0.1", PORT), Webhook)  # 只聽本機:外面只能從通道進來
        threading.Thread(target=server.serve_forever, daemon=True).start()
        switched = False
        try:  # 從開通道起就在 try 裡:只要 Webhook 換過,不管怎麼結束都會換回去
            url = open_tunnel()
            if url:
                code, res = line("PUT", "/v2/bot/channel/webhook/endpoint", {"endpoint": url + "/callback"})
                if code != 200:
                    log(f"⚠️ 換 Webhook 失敗 {code}:{res}。可以手動貼到 LINE Developers:{url}/callback")
                else:
                    switched = True
                    log(f"✅ LINE 的 Webhook 換到這台電腦了:{url}/callback(按 Ctrl+C 會換回去)")
                    log("   LINE 說換網址最多要 1 分鐘才生效;等一下再用手機傳訊息")
                    time.sleep(5)
                    code, test = line("POST", "/v2/bot/channel/webhook/test", {})
                    log(f"   LINE 測試連線:{'✅ 通了' if test.get('success') else '還沒通(' + str(test.get('reason')) + '),等一分鐘再傳'}")
            log("開好了。手機傳訊息給 bot 試試看;這個視窗不要關,按 Ctrl+C 結束。" if switched
                else "⚠️ 這次沒接上 LINE(看上面的訊息)。按 Ctrl+C 結束。")
            while True:
                time.sleep(1)
        except KeyboardInterrupt:
            pass
        finally:
            if switched:
                restore(old)
            for cf in _procs:
                cf.terminate()
            server.shutdown()
    
    
    if __name__ == "__main__":
        main()

    上面只露出開頭幾行(可以捲動);整份會被下載或複製。

    PowerShell
    New-Item -ItemType Directory -Force "$env:USERPROFILE\Downloads\line-bot" | Out-Null
    Move-Item "$env:USERPROFILE\Downloads\line_bot_local.py" "$env:USERPROFILE\Downloads\line-bot\" -Force
    cd "$env:USERPROFILE\Downloads\line-bot"
    dir
    你應該看到dir 列出一個檔:line_bot_local.py(約 15 KB)。
    測一下檔名剛好是 line_bot_local.py,不是 line_bot_local (1).py 或 .txt 結尾。
    卡住了
    • Move-Item 說找不到檔→ 瀏覽器存到別的地方、或檔名多了 (1):用檔案總管把它拖進 下載\line-bot,改名成 line_bot_local.py。
    • 下載鈕沒反應→ 按「複製」,開記事本貼上,另存新檔:存檔類型選「所有檔案」、檔名 line_bot_local.py、編碼 UTF-8。

第 2 段 準備三個值

這段做完你手上會有:試算表 ID、LINE 的 Channel secret、長效通行碼(三個都必填)。後兩個等於鑰匙:不截圖、不貼進對話、不傳給別人。

  1. 用跟 gws 登入同一個 Gmail 開雲端硬碟 → 新增 → Google 試算表,取名(例如「LINE bot 簡單版紀錄」)。第一列打上標題(可做可不做,bot 會從下一列開始記):

    時間|類型|傳了什麼|bot 回了什麼|照片連結|日曆

    看網址:docs.google.com/spreadsheets/d/一長串英數字/edit,中間那一長串就是試算表 ID,複製起來(等一下貼)。

    你應該看到一長串大約 44 個英文字母、數字、底線或減號,沒有斜線。
    測一下複製的內容裡沒有 /、也沒有 edit。
    卡住了
    • 之後 bot 說「這則沒寫進試算表」→ ID 多複製了斜線,或試算表是另一個 Google 帳號開的(gws 登入的帳號看不到它)。
  2. 瀏覽器開 LINE Developers(developers.line.biz)→ 登入 → 你 W5 建的 Provider → 你的 bot 那個 channel →「Basic settings」分頁 → 往下找 Channel secret,按旁邊的複製。

    你應該看到一串 32 個英文字母和數字。
    測一下複製到的是 Channel secret,不是 Channel ID(那個只有數字)。
    卡住了
    • 為什麼一定要它→ bot 用它「對簽章」:LINE 送來的每則訊息都附一個用這把鑰匙算出來的簽章,對得上才處理,路人假冒 LINE 送的會被擋掉。沒有它程式不開通道(開了等於誰都能假冒 LINE)。
    • 找不到 Basic settings→ 要點進 channel(不是 Provider 那層);分頁在頁面上方。
  3. 同一個 channel →「Messaging API」分頁 → 拉到最下面 Channel access token (long-lived) → 按複製。這就是 W5 你貼進 Apps Script 指令碼屬性的那一把。

    你應該看到一長串(一百多個字元)英數字和符號。
    測一下等一下開 bot 時,它會先拿這把去問 LINE「這支 bot 叫什麼名字」,答得出來就對了。
    卡住了
    • 不要按 Reissue(重發)→ 重發會換一把新的,W5 Apps Script 指令碼屬性裡的舊那把可能跟著失效。直接複製現在這把。
    • 那裡是空的→ W5 沒發過:按 Issue 發一把,記得也貼回 Apps Script 的指令碼屬性。
  4. LINE Official Account Manager(manager.line.biz)→ 你的官方帳號 → 右上「設定」→「回應設定」。

    你應該看到「Webhook」是開的、「自動回應訊息」是關的(W5 設好的樣子)。
    測一下手機傳 hi 給 bot,W5 的 Apps Script 版會回你——這是換過去之前的樣子。
    卡住了
    • 手機每則都收到兩個回覆→「自動回應訊息」沒關。

第 3 段 開 bot

這段做完:bot 在你電腦上跑起來,LINE 的訊息會送到你的電腦。之後每次要用,只要做這一段。

  1. PowerShell
    ollama list
    你應該看到清單裡有 gemma4:e4b。
    測一下沒有出現 could not connect。
    卡住了
    • could not connect→ 開始功能表打開「Ollama」,等右下角出現羊駝圖示再試。
    • Ollama app 卡在 Loading→ 工作管理員把 ollama app.exe 和 ollama.exe 都結束,再打開。
  2. PowerShell
    cd "$env:USERPROFILE\Downloads\line-bot"
    python line_bot_local.py

    它會依序問三件事,貼上按 Enter:① 長效通行碼 ② Channel secret ③ 試算表 ID。前兩個貼上去畫面不會出現任何字、也沒有星號,是正常的:每個值只貼一次(在 PowerShell 視窗按一下滑鼠右鍵就是貼上),貼完直接按 Enter。⚠️ 這個「看不到字的貼上」老師彩排時才第一次在 E403 那種視窗試,以當天畫面為準。

    你應該看到(⚠️ 下面是照程式寫的預期畫面:模型載入那兩行老師電腦 10/10 實測過;LINE bot 名字、通道、換 Webhook、測試連線要彩排接上真的 LINE 才第一次跑,以你的畫面為準)
    [15:20:01] ✅ LINE bot「你的 bot 名字」
    [15:20:01]    原本的 Webhook:(你 W5 的 /exec 網址)——先抄下來,萬一沒換回去要手動貼回
    [15:20:01] 載入模型 gemma4:e4b(第一次要等一下)…
    [15:20:17] ✅ 模型已在顯示卡上
    [15:20:21] ✅ LINE 的 Webhook 換到這台電腦了:https://(一串英文字).trycloudflare.com/callback(按 Ctrl+C 會換回去)
    [15:20:21]    LINE 說換網址最多要 1 分鐘才生效;等一下再用手機傳訊息
    [15:20:26]    LINE 測試連線:✅ 通了
    [15:20:26] 開好了。手機傳訊息給 bot 試試看;這個視窗不要關,按 Ctrl+C 結束。
    測一下看到「開好了」就過。把「原本的是:…」那行的網址抄下來(萬一程式沒換回去,要手動貼回 LINE 後台)。這個視窗不要關,縮小就好。
    卡住了
    • LINE 通行碼不對(401)→ 貼成 Channel secret、或少複製了字:回 2-3 重新複製,再跑一次。
    • 沒有 Channel secret 就不開通道→ 第二個問題直接按了 Enter:回 2-2 複製,再跑一次。
    • 讀不到 LINE 現在的 Webhook 網址→ LINE 那邊一時沒回應;程式為了不讓你換了回不去,先不換。等一下再跑一次。
    • 60 秒內通道沒拿到網址→ 網路擋住 Cloudflare(學校網路有時會):換手機熱點,或下課回家再試。
    • ⚠️ 找不到 cloudflared→ 1-6 裝完沒新開視窗。關掉、新開、從 cd 那行再來。
    • ⚠️ 找不到 gws→ 1-3 沒做或沒新開視窗;這次照片、日曆、試算表都會失敗。
    • Ollama … 回應 404:model not found→ 沒有 gemma4:e4b:ollama pull gemma4:e4b。
    • 換 Webhook 失敗→ 照畫面印的網址,自己到 LINE Developers →「Messaging API」→ Webhook URL 按 Edit 貼上、按 Update。
    • LINE 測試連線:還沒通→ 正常,LINE 換網址要等最多 1 分鐘;等一下直接用手機傳。
    • 想換成小一號的模型→ 開 bot 前先打 $env:MODEL="gemma4:e2b"(同一個視窗)。

第 4 段 用手機試四種訊息

這段做完:問題會查、行程進日曆、照片進雲端硬碟,四種都記在試算表裡。電腦視窗會一步一步印出它在做什麼。

下面「你應該看到」的回覆,是老師 10/10 在自己電腦用同一支程式實測的結果(模型、搜尋、雲端硬碟、日曆、試算表都是真的;LINE 那端換成記錄用的假函式),你的字句會不一樣。第一則要等十幾秒是正常的。

  1. 你應該看到
    你好!我是跑在同學電腦上的本地模型 🤖
    • 問我問題:需要的話我會上網查,附來源
    • 講行程(「明天下午三點開會」):我排進 Google 日曆
    • 傳照片:我存進雲端硬碟、告訴你上面有什麼,有日期時間也幫你排日曆
    問題、行程、照片都會記進試算表。
    測一下收到這段就表示:LINE 真的把訊息送到你電腦了(通道、Webhook、程式三樣都通)。
    卡住了
    • 收到的是 W5 Apps Script 的回覆→ Webhook 還沒換過來,等一分鐘再傳。
    • 完全沒反應→ 看電腦視窗:有「⛔ 簽章不對」=Channel secret 貼錯,Ctrl+C 後重開重貼;什麼都沒印=LINE 還沒換過來,再等一下。
    • 手機出現兩個回覆→ LINE 官方帳號的「自動回應訊息」沒關(2-4)。
  2. 傳給 bot
    2026 年諾貝爾物理獎得主是誰?
    你應該看到電腦視窗:📩 收到文字 → 🔍 模型決定搜:2026年諾貝爾物理獎得主 → 📝 寫進試算表了 → ✅ 回覆了。手機:
    結論:2026 年的諾貝爾物理獎得主是弗朗西斯·哈爾岑(Francis Halzen),他因對冰立方中微子天文台的決定性貢獻以及發現具有天體物理起源的高能中微子而獲獎。……
    
    來源:
    • https://www.nobelprize.org/prizes/physics/2026/press-release/
    • https://www.cas.cn/kj/202610/t20261007_5121442.shtml
    • https://www.chinanews.com.cn/gj/2026/10-06/10708477.shtml
    
    📝 已記進試算表
    (本地模型 6 秒)
    測一下點第一個來源,是諾貝爾獎官網的新聞稿。注意:模型照搜到的對岸網站寫「中微子」,台灣說「微中子」——引用 AI 的答案前,用語和來源都要自己看過。
    卡住了
    • 回答最後寫「ℹ️ 這題沒有上網查」→ 模型判斷不用查就直接答了;小模型不是每次都穩,題目講具體一點再問一次。
    • ⚠️ 出錯了:Ollama … timed out→ 顯示卡太忙或太小:關掉其他跑模型的程式(例如 LM Studio),或換 gemma4:e2b。
  3. 傳給 bot
    明天下午三點要跟專題組開會
    你應該看到電腦視窗:🗓️ 排進日曆了 → 📝 寫進試算表了。手機(下面是老師 10/10 傳的,所以是 10/11;你看到的是你傳的隔天):
    📌 跟專題組開會
    📅 2026-10-11 ⏰ 15:00
    
    🗓️ 已排進 Google 日曆(2026-10-11 15:00)
    📝 已記進試算表
    (本地模型 2 秒)
    測一下打開 Google 日曆,明天 15:00 有「跟專題組開會」(30 分鐘)。再試一個陷阱:傳「2027 年 2 月 30 日下午三點交報告」——模型會偷偷把它改成 2 月 28 日,程式比對原文發現對不上,不會排進日曆。這就是「AI 給的不照單全收」。
    卡住了
    • ⚠️ 日曆沒排成:…403…Calendar API has not been used→ 1-4 沒啟用 Calendar API,或啟用在別的專案。
    • ⚠️ 日曆沒排成:…insufficient authentication scopes→ 1-5 登入時沒要 calendar,重跑那行。
    • 它把行程當成問題回答了→ 沒講清楚日期或時間(「下午三點」比「三點」清楚);換個說法再傳。
  4. 拍一張有日期、時間、地點的海報或公告傳過去(下面是老師用的測試海報:生成式 AI 實作講座、10/20 下午 3:10–5:00、理工二館 A101)。

    你應該看到電腦視窗:📩 收到照片 → 📷 照片存進雲端硬碟了 → 🗓️ 排進日曆了 → 📝 寫進試算表了。手機:
    這是一則關於「生成式 AI 實作講座」的活動通知。
    資訊摘要:
    • 活動名稱: 生成式 AI 實作講座
    • 日期: 2026 年 10 月 20 日 (二)
    • 時間: 下午 3:10–5:00
    • 地點: 理工二館 A101
    • 費用資訊: 免費入場,請帶筆電
    
    📷 照片已存進雲端硬碟「LINE待辦照片」
    🗓️ 已排進 Google 日曆(2026-10-20 15:10–17:00)
    📝 已記進試算表
    (本地模型 8 秒)
    測一下三個地方都去看:雲端硬碟多一個「LINE待辦照片」資料夾,裡面有 line-日期-時分秒.jpg;日曆 10/20 有那場講座;試算表那一列的「照片連結」點得開。
    卡住了
    • ⚠️ 照片沒存進雲端硬碟→ gws 登入時沒要 drive(1-5),或 Drive API 沒啟用。
    • 照片上的字它讀錯→ 小模型看圖不是百分之百;拍正、拍近一點。讀錯的日期一樣會被排進日曆(程式只能拿模型自己的描述來比對,比對不到照片本身),所以回覆裡的日期一定要自己看一眼,錯了就去日曆刪掉。
    • 不是活動的照片也存進雲端硬碟了→ 正常:照片一律先存,有日期時間的才排日曆。
  5. 打開 2-1 那張試算表。

    你應該看到每傳一則(打招呼除外)就多一列:時間、類型(文字/照片)、你傳了什麼、bot 回了什麼、照片連結(照片才有)、日曆(排了就是「已建立」)。
    測一下手機上每則回覆都說「📝 已記進試算表」,試算表的列數就要對得上——bot 是先寫表、確定寫進去了才回覆的。
    卡住了
    • 手機說記了、表裡卻沒有→ 看的是不是同一張表(ID);重新整理頁面。

第 5 段 結束、把 LINE 還給 Apps Script

這段做完:bot 關掉,LINE 的訊息回到你 W5 的 Apps Script bot。

  1. 你應該看到↩️ LINE 的 Webhook 換回原本的了(重新讀過一次,確認是原本那個),然後回到 PowerShell 的提示字元。
    測一下等一分鐘,手機傳 hi,回你的是 W5 的 Apps Script 版(說明文字不一樣)。
    卡住了
    • 直接按視窗右上角 ✕ 關掉了→ 程式來不及換回去,LINE 還指著已經關掉的通道(手機傳了沒反應)。到 LINE Developers →「Messaging API」→ Webhook URL → Edit,貼回 3-2 抄下的 /exec 網址 → Update。
    • ⚠️ 沒能確認換回去→ 同上,照畫面印的網址手動貼回去。
  2. 還原卡電腦重開機會清掉,但下一個人可能在你之前就坐下來。在家用自己電腦的可以跳過。

    PowerShell
    gws auth logout
    gws auth status
    Remove-Item -Recurse -Force "$env:USERPROFILE\.config\gws"
    你應該看到gws auth status 說沒有登入(none)。
    測一下瀏覽器也登出 Google、LINE Developers;「要儲存密碼嗎」一律按不要。
    卡住了
    • Remove-Item 說找不到路徑→ 本來就沒有,沒事。
下次再開:只要做第 3 段(3-1、3-2),三個值再貼一次(程式不會幫你存,這是故意的)。通道網址每次都會換,程式會自己換好。在 E403 這種還原卡電腦,第 1 段每次都要重裝。

程式怎麼讀、它叫了哪些 Ollama API

看完你會知道:一則訊息在程式裡經過哪幾個函式;本地模型是怎麼被「叫」的。

它叫的 Ollama API(全部打本機 http://127.0.0.1:11434)

要做的事Ollama API程式裡的函式
回答問題;模型自己決定要不要搜POST /api/chat(帶 tools:web_search、web_fetch)ask_text
真的去搜、去讀網頁(Ollama 內建,免登入)POST /api/experimental/web_search、/api/experimental/web_fetch也在 ask_text 裡
看照片POST /api/chat(訊息裡放 images:照片轉成 base64 文字)ask_image
判斷是不是行程、整理成日期時間POST /api/chat(帶 format:規定只能照固定格式回 JSON)find_event

一則訊息的旅程(照函式順序)

  1. Webhook.do_POST:收到 LINE 送來的請求 → 對簽章 → 先回 LINE「收到了」(LINE 要 2 秒內回應)→ 另開一條執行緒去做 handle。
  2. handle:看是文字還是照片。文字先問 find_event「這是不是行程」:是 → 準備排日曆;不是 → ask_text 回答問題。照片 → save_photo 存雲端硬碟 → ask_image 看照片 → find_event 從描述裡找行程。
  3. add_calendar、write_sheet:用 gws 排日曆、寫試算表;任何一件失敗都不讓整則掛掉,回覆裡會老實寫哪一件沒做成。
  4. reply:全部做完才回手機。
  5. main:開起來時問三個值(沒有 Channel secret 就不開)、先讀下原本的 Webhook(讀不到就不換)、把模型載進顯示卡、開通道(最多等 60 秒)、換 Webhook;不管怎麼結束都換回去,換完再讀一次確認。訊息一次處理一則,同時傳來的會排隊。
程式全文 line_bot_local.py(約 280 行)
line_bot_local.py
# LINE bot 簡單版:一支 Python 程式就是整個 bot,跑在你自己的電腦上
#
# 傳什麼進來,它做什麼:
#   問問題(「2026 年諾貝爾物理獎得主是誰?」)→ 交給你電腦上的 Ollama 本地模型,需要就自己上網搜,回覆附來源
#   講行程(「明天下午三點開會」)            → 整理出日期時間,排進 Google 日曆
#   傳照片(活動海報、便條紙)               → 照片存進雲端硬碟;模型看照片說上面有什麼;有日期時間就排進日曆
#   問題、行程、照片都先寫一列進 Google 試算表,才回覆(只講已經做完的事;打招呼和看不懂的格式不記)
# 雲端硬碟、試算表、日曆都用第 2 段登入好的 gws;模型一律打本機 Ollama 的 API(http://127.0.0.1:11434)。
#
# 為什麼還要「通道」:LINE 的伺服器要把訊息送進來,得找得到你的電腦;
# 教室和家裡的電腦都躲在網路後面,所以用 Cloudflare 的免費臨時通道給它一個 https 網址。
# 程式開起來會自己把 LINE 的 Webhook 換成這個網址,按 Ctrl+C 結束時再換回原本的(例如你 W5 的 Apps Script)。
#
# 需要:Python 3(只用內建功能,不用 pip install)、Ollama 開著且有 gemma4:e4b、
#       gws 已登入(要有 drive、sheets、calendar 權限)、cloudflared、LINE 的長效通行碼(W5 申請過)
# 用法(PowerShell,在這個檔所在的資料夾):
#   python line_bot_local.py
# 開起來會問你 LINE 長效通行碼、Channel secret(畫面不顯示)和試算表 ID,貼上按 Enter 就好。

import base64, getpass, hashlib, hmac, json, os, queue, re, shutil, subprocess, sys, tempfile, threading, time, urllib.error, urllib.request
from datetime import datetime, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

MODEL = os.environ.get("MODEL", "gemma4:e4b")
PORT = int(os.environ.get("PORT", "8000"))
CALENDAR_ID = os.environ.get("CALENDAR_ID", "primary")          # 排進哪個日曆;primary=你的主日曆
PHOTO_FOLDER = os.environ.get("PHOTO_FOLDER", "LINE待辦照片")   # 照片存進雲端硬碟的哪個資料夾(沒有就建)
OLLAMA = "http://127.0.0.1:11434"
GREETING = re.compile(r"^(hi|hello|你好|嗨|哈囉|測試|test)$", re.I)
HELP = ("你好!我是跑在同學電腦上的本地模型 🤖\n"
        "• 問我問題:需要的話我會上網查,附來源\n• 講行程(「明天下午三點開會」):我排進 Google 日曆\n"
        "• 傳照片:我存進雲端硬碟、告訴你上面有什麼,有日期時間也幫你排日曆\n問題、行程、照片都會記進試算表。")
TOOLS = [
    {"type": "function", "function": {"name": "web_search", "description": "搜尋網路,回傳前幾筆標題、網址與內文。",
     "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}},
    {"type": "function", "function": {"name": "web_fetch", "description": "讀一個網頁的主要文字。",
     "parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}},
]
EVENT_SHAPE = {"type": "object", "required": ["isEvent", "title", "date", "time"], "properties": {
    "isEvent": {"type": "boolean"}, "title": {"type": "string"},
    "date": {"type": "string", "description": "YYYY-MM-DD,沒提到就填空字串"},
    "time": {"type": "string", "description": "HH:MM(24 小時制);有結束時間就寫 HH:MM-HH:MM;沒提到就填空字串"}}}

LINE_TOKEN = LINE_SECRET = SHEET_ID = ""  # main() 開起來時問你,再填進這裡
GWS = None
_folder_id = None
_turn = threading.Lock()  # 一次只處理一則:顯示卡一次只答一題,照片資料夾也不會被同時建兩個
_procs = []               # 開出去的 cloudflared,結束時一定要關
TUNNEL_WAIT = 60         # 等 cloudflared 給網址最多幾秒


def log(s):
    print(f"[{datetime.now():%H:%M:%S}] {s}", flush=True)


def today():
    return datetime.now().strftime("%Y-%m-%d(%A)")  # 附上星期幾,模型才算得準「下週五」


# ===== 1. 設定:開起來時問一次,不寫進檔案 =====
def ask_settings():
    token = os.environ.get("LINE_CHANNEL_ACCESS_TOKEN") or getpass.getpass("貼上 LINE 長效通行碼(Channel access token,畫面不顯示):").strip()
    secret = os.environ.get("LINE_CHANNEL_SECRET") or getpass.getpass("貼上 Channel secret(LINE Developers → Basic settings;必填;畫面不顯示):").strip()
    sheet = os.environ.get("SHEET_ID") or input("試算表 ID(網址 /d/ 和 /edit 中間那段;不寫試算表就直接 Enter):").strip()
    return token, secret, sheet


# ===== 2. 叫 LINE、叫 Ollama、叫 gws 的小工具 =====
def http_json(method, url, body=None, headers=None, timeout=60):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json", **(headers or {})})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            raw = r.read()
            return r.status, (json.loads(raw) if raw.strip() else {})
    except urllib.error.HTTPError as e:
        return e.code, {"error": e.read().decode("utf-8", "replace")[:200]}


def line(method, path, body=None):
    return http_json(method, "https://api.line.me" + path, body, {"Authorization": "Bearer " + LINE_TOKEN})


def reply(token, text):
    code, res = line("POST", "/v2/bot/message/reply", {"replyToken": token, "messages": [{"type": "text", "text": text[:5000]}]})
    if code != 200:
        log(f"⚠️ LINE 回覆失敗 {code}:{res}")


def download_image(message_id):
    req = urllib.request.Request(f"https://api-data.line.me/v2/bot/message/{message_id}/content",
                                 headers={"Authorization": "Bearer " + LINE_TOKEN})
    with urllib.request.urlopen(req, timeout=30) as r:
        return r.read()


def ollama(path, body, timeout=60):
    code, res = http_json("POST", OLLAMA + path, body, timeout=timeout)
    if code != 200:
        raise RuntimeError(f"Ollama {path} 回應 {code}:{res}")
    return res


def chat(messages, timeout=60, **extra):
    body = {"model": MODEL, "messages": messages, "stream": False, "think": False, "keep_alive": "1h", "options": {"num_ctx": 16384}}
    return ollama("/api/chat", {**body, **extra}, timeout).get("message", {})


def gws(*args, upload=None, cwd=None):
    """跑一條 gws 指令,回傳它印出的 JSON。(gws 只肯上傳「目前資料夾」裡的檔,所以上傳時用 cwd 指到檔案所在的資料夾)"""
    cmd = [GWS, *args] + (["--upload", upload] if upload else [])
    r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=cwd)
    if r.returncode != 0:
        raise RuntimeError((r.stderr or r.stdout).strip()[-300:])
    return json.loads(r.stdout or "{}")


def find_gws():
    # npm 裝的 gws 背後有一個真正的 gws.exe;直接叫它,JSON 的引號才不會被 Windows 的 .cmd 轉接弄壞
    roots = [os.path.join(os.environ.get("APPDATA", ""), "npm", "node_modules")]
    try:  # npm 裝到別的地方時,問 npm 它的全域資料夾在哪
        roots.append(subprocess.run("npm root -g", shell=True, capture_output=True, text=True, timeout=30).stdout.strip())
    except Exception:
        pass
    for root in roots:
        exe = os.path.join(root, "@googleworkspace", "cli", "bin", "gws.exe")
        if root and os.path.exists(exe):
            return exe
    return None


# ===== 3. 模型:回答問題(自己決定要不要搜)、看照片、找出行程 =====
def ask_text(question):
    """模型自己決定要不要搜;回傳(答案, 來源網址清單)。"""
    msgs = [{"role": "system", "content": f"你是 LINE 上的助理,今天是 {today()},可能比你的訓練資料新。"
             "遇到最近的事、版本號、價格或任何你不確定的事實,先呼叫 web_search;不夠明確再用 web_fetch 讀官方頁面。"
             "回答用台灣的繁體中文、純文字(不要用星號或井字號),150 字以內,先講結論;不用自己寫網址,程式會附上來源。"},
            {"role": "user", "content": question}]
    sources = []
    for _ in range(4):  # 最多來回四次工具呼叫
        m = chat(msgs, tools=TOOLS)
        msgs.append(m)
        if not m.get("tool_calls"):
            return m.get("content", "").replace("**", "").strip(), sources
        for c in m["tool_calls"]:
            name, args = c["function"]["name"], c["function"].get("arguments") or {}
            try:
                if name == "web_search":
                    log(f"  🔍 模型決定搜:{args.get('query')}")
                    rows = ollama("/api/experimental/web_search", {"query": args.get("query", ""), "max_results": 3}).get("results", [])
                    sources += [r["url"] for r in rows]
                    out = json.dumps([{"title": r["title"], "url": r["url"], "content": r["content"][:1500]} for r in rows], ensure_ascii=False)
                else:
                    log(f"  📄 模型決定讀網頁:{args.get('url')}")
                    page = ollama("/api/experimental/web_fetch", {"url": args.get("url", "")})
                    sources.insert(0, args.get("url", ""))
                    out = page.get("content", "")[:3000]
            except Exception as e:
                out = f"ERROR: {e}"
            msgs.append({"role": "tool", "tool_name": name, "content": out})
    return "查了幾次還整理不出答案,換個問法再試一次。", sources


def ask_image(jpg):
    m = chat([{"role": "user", "images": [base64.b64encode(jpg).decode()],
               "content": "這是使用者用 LINE 傳來的照片。用台灣的繁體中文、三句以內說明照片裡有什麼;"
                          "看得到日期、時間、地點、活動或繳費資訊就列出來,看不清楚的不要猜。"}])
    return re.sub(r"^\s*[*-]\s+", "• ", m.get("content", "").replace("**", ""), flags=re.M).strip()  # 星號清單在 LINE 上很亂,換成 •


def find_event(text):
    """請模型照固定格式回答「這是不是有日期和時間的行程」;欄位驗算過才算數,不照單全收。"""
    m = chat([{"role": "user", "content": f"今天是 {today()},台北時間。下面這段文字如果是一個有日期和時間的行程、活動或提醒"
               "(不是在問問題),把「明天」「下週五」換算成實際日期,整理成 JSON;不是就 isEvent 填 false。\n\n" + text}],
             format=EVENT_SHAPE)
    try:
        ev = json.loads(m.get("content", "{}"))
        times = re.findall(r"\d{1,2}:\d{2}", ev["time"])  # 「15:10-17:00」這種區間:第一個是開始、第二個是結束
        ev["start"] = datetime.strptime(f"{ev['date']} {times[0]}", "%Y-%m-%d %H:%M")  # 2/30、25:99 這種不存在的會在這裡被擋掉
        ev["end"] = datetime.strptime(f"{ev['date']} {times[1]}", "%Y-%m-%d %H:%M") if len(times) > 1 else ev["start"] + timedelta(minutes=30)
        ev["time"] = f"{ev['start']:%H:%M}" + (f"–{ev['end']:%H:%M}" if len(times) > 1 else "")
    except (ValueError, KeyError, TypeError, IndexError):
        return None
    # 原文寫了「幾月幾日」或「10/20」,模型整理出來的日期要對得上:模型會偷偷把「2 月 30 日」改成 2 月 28 日
    said = re.search(r"(\d{4})\s*[-/年]\s*(\d{1,2})\s*[-/月]\s*(\d{1,2})|(\d{1,2})\s*月\s*(\d{1,2})\s*[日號]|(?<!\d)(\d{1,2})/(\d{1,2})(?!\d)", text)
    nums = [int(x) for x in said.groups() if x] if said else []
    if nums and nums != [ev["start"].year, ev["start"].month, ev["start"].day][-len(nums):]:
        log(f"  ⚠️ 模型整理的日期 {ev['date']} 跟原文「{said.group(0)}」對不上,不排日曆")
        return None
    return ev if ev.get("isEvent") is True and str(ev.get("title", "")).strip() and ev["end"] > ev["start"] else None


# ===== 4. Google:照片存雲端硬碟、排日曆、寫試算表(都用 gws) =====
def save_photo(jpg):
    """照片存進 PHOTO_FOLDER 資料夾(沒有就建),回傳檔案連結。"""
    global _folder_id
    if not _folder_id:
        q = f"name='{PHOTO_FOLDER}' and mimeType='application/vnd.google-apps.folder' and trashed=false"
        found = gws("drive", "files", "list", "--params", json.dumps({"q": q, "fields": "files(id)"}, ensure_ascii=False)).get("files", [])
        _folder_id = found[0]["id"] if found else gws("drive", "files", "create", "--json", json.dumps(
            {"name": PHOTO_FOLDER, "mimeType": "application/vnd.google-apps.folder"}, ensure_ascii=False))["id"]
    with tempfile.TemporaryDirectory() as tmp:  # 照片先存成暫存檔,上傳完整個暫存資料夾自動刪掉
        with open(os.path.join(tmp, "photo.jpg"), "wb") as fh:
            fh.write(jpg)
        f = gws("drive", "files", "create", "--params", json.dumps({"fields": "id,webViewLink"}),
                "--json", json.dumps({"name": f"line-{datetime.now():%Y%m%d-%H%M%S}.jpg", "parents": [_folder_id]}),
                upload="photo.jpg", cwd=tmp)
    log("  📷 照片存進雲端硬碟了")
    return f.get("webViewLink", "")


def add_calendar(ev):
    gws("calendar", "events", "insert", "--params", json.dumps({"calendarId": CALENDAR_ID}),
        "--json", json.dumps({"summary": ev["title"], "description": "由 LINE bot 簡單版建立",
                              "start": {"dateTime": f"{ev['start']:%Y-%m-%dT%H:%M:00}", "timeZone": "Asia/Taipei"},
                              "end": {"dateTime": f"{ev['end']:%Y-%m-%dT%H:%M:00}", "timeZone": "Asia/Taipei"}}, ensure_ascii=False))
    log(f"  🗓️ 排進日曆了:{ev['date']} {ev['time']}")


def write_sheet(row):
    """寫一列進試算表:時間、類型、傳了什麼、bot 回了什麼、照片連結、日曆。"""
    gws("sheets", "spreadsheets", "values", "append",
        "--params", json.dumps({"spreadsheetId": SHEET_ID, "range": "A1", "valueInputOption": "USER_ENTERED"}),
        "--json", json.dumps({"values": [row]}, ensure_ascii=False))
    log("  📝 寫進試算表了")


def try_do(what, fn, *args):
    """Google 那邊哪一件失敗,都不讓整則訊息掛掉;回傳(結果, 錯誤訊息;成功時是空字串)。"""
    try:
        return fn(*args), ""
    except Exception as e:
        log(f"⚠️ {what}失敗:{e}")
        return None, str(e)[:120] or "不明原因"


# ===== 5. 處理一則訊息:先把事情做完(存照片、排日曆、寫試算表)→ 才回覆,只講已經做完的事 =====
def handle(event):
    with _turn:
        _handle(event)


def _handle(event):
    if event.get("type") != "message" or not event.get("replyToken"):
        return
    msg, token = event["message"], event["replyToken"]
    user = event.get("source", {})
    if user.get("type") == "user":  # 「對方正在輸入…」動畫,最多 60 秒
        line("POST", "/v2/bot/chat/loading/start", {"chatId": user["userId"], "loadingSeconds": 60})
    t0, notes, link, cal = time.time(), [], "", ""
    try:
        if msg["type"] == "text":
            text = msg["text"].strip()
            if GREETING.match(text):
                return reply(token, HELP)
            log(f"📩 收到文字({len(text)} 字)")  # 不印內容:螢幕可能正在投影
            kind, said, ev = "文字", text, find_event(text)
            if ev:  # 講行程:排日曆就好,不必上網查
                answer = f"📌 {ev['title']}\n📅 {ev['date']} ⏰ {ev['time']}"
            else:   # 問問題:交給模型,需要就自己搜
                answer, sources = ask_text(text)
                answer += ("\n\n來源:\n" + "\n".join("• " + u for u in list(dict.fromkeys(sources))[:3]) if sources
                           else "\n\nℹ️ 這題沒有上網查,只憑模型記憶,請自己核對。")
        elif msg["type"] == "image":
            log("📩 收到照片")
            jpg = download_image(msg["id"])
            kind, said = "照片", "(照片)"
            link, err = try_do("存照片", save_photo, jpg)
            notes.append(f"📷 照片已存進雲端硬碟「{PHOTO_FOLDER}」" if not err else "⚠️ 照片沒存進雲端硬碟:" + err)
            answer = ask_image(jpg)
            ev = find_event(answer)
        else:
            return reply(token, "目前我看得懂文字和照片,其他類型還不行喔。")
        if ev:
            _, err = try_do("排日曆", add_calendar, ev)
            cal = "已建立" if not err else "失敗:" + err
            notes.append(f"🗓️ 已排進 Google 日曆({ev['date']} {ev['time']})" if not err else "⚠️ 日曆沒排成:" + err)
        if SHEET_ID:
            _, err = try_do("寫試算表", write_sheet, [f"{datetime.now():%Y-%m-%d %H:%M:%S}", kind, said, answer[:500], link or "", cal])
            notes.append("📝 已記進試算表" if not err else "⚠️ 這則沒寫進試算表:" + err)
        reply(token, f"{answer}\n\n" + "\n".join(notes + [f"(本地模型 {time.time() - t0:.0f} 秒)"]))
        log(f"✅ 回覆了({time.time() - t0:.0f} 秒)")
    except Exception as e:
        log(f"❌ {e}")
        reply(token, "⚠️ 出錯了:" + str(e)[:300])


class Webhook(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if LINE_SECRET:  # 對簽章:確定真的是 LINE 送來的,不是知道網址的路人
            sig = base64.b64encode(hmac.new(LINE_SECRET.encode(), body, hashlib.sha256).digest()).decode()
            if not hmac.compare_digest(sig, self.headers.get("x-line-signature", "")):
                log("⛔ 簽章不對,不是 LINE 送來的,丟掉")
                self.send_response(401)
                self.end_headers()
                return
        self.send_response(200)  # 先跟 LINE 說收到了(LINE 要 2 秒內回應),再慢慢處理
        self.end_headers()
        for ev in json.loads(body or b"{}").get("events", []):
            threading.Thread(target=handle, args=(ev,), daemon=True).start()

    def do_GET(self):
        self.send_response(200)
        self.end_headers()
        self.wfile.write("LINE bot 簡單版運作中".encode())

    def log_message(self, *args):  # 不要印每一個連線
        pass


# ===== 6. 開通道、把 LINE 的 Webhook 換過來;結束時換回去 =====
def open_tunnel():
    """開 Cloudflare 臨時通道,最多等 60 秒拿網址;拿不到就把它關掉,回傳 None。"""
    if not shutil.which("cloudflared"):
        log("⚠️ 找不到 cloudflared,外面連不到這裡。另開 PowerShell 貼:winget install --id Cloudflare.cloudflared -e")
        log("   裝完關掉這個視窗、新開一個再跑一次。")
        return None
    cf = subprocess.Popen(["cloudflared", "tunnel", "--no-autoupdate", "--url", f"http://127.0.0.1:{PORT}"],
                          stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace")
    _procs.append(cf)
    found = queue.Queue()

    def watch():  # 另一條執行緒一行一行讀它的輸出(之後的也照讀照丟,免得塞住);主程式只等 60 秒
        for row in cf.stdout:
            m = re.search(r"https://[a-z0-9-]+\.trycloudflare\.com", row)
            if m:
                found.put(m.group(0))
        found.put(None)

    threading.Thread(target=watch, daemon=True).start()
    try:
        url = found.get(timeout=TUNNEL_WAIT)
    except queue.Empty:
        url = None
    if not url:
        log(f"⚠️ {TUNNEL_WAIT} 秒內通道沒拿到網址:看網路(學校網路可能擋),或再跑一次")
    return url


def restore(old):
    """把 LINE 的 Webhook 換回原本的,再讀一次確認真的換回去了(換的那條路說成功不算數)。"""
    if not old:
        log("ℹ️ 原本沒有 Webhook 網址,現在指著一個已經關掉的通道;要用 Apps Script 版就回 LINE Developers 貼回 /exec 網址")
        return
    line("PUT", "/v2/bot/channel/webhook/endpoint", {"endpoint": old})
    code, now = line("GET", "/v2/bot/channel/webhook/endpoint")
    if code == 200 and now.get("endpoint") == old:
        log("↩️ LINE 的 Webhook 換回原本的了(重新讀過一次,確認是原本那個)")
    else:
        log(f"⚠️ 沒能確認換回去:到 LINE Developers →「Messaging API」→ Webhook URL 手動貼回:{old}")


def main():
    global LINE_TOKEN, LINE_SECRET, SHEET_ID, GWS
    LINE_TOKEN, LINE_SECRET, SHEET_ID = ask_settings()
    GWS = find_gws()
    code, info = line("GET", "/v2/bot/info")
    if code != 200:
        sys.exit(f"LINE 通行碼不對({code}):回 LINE Developers 重新複製長效通行碼")
    log(f"✅ LINE bot「{info.get('displayName')}」")
    if not LINE_SECRET:
        sys.exit("沒有 Channel secret 就不開通道:沒有它,知道通道網址的人都能假冒 LINE 送訊息進來。"
                 "回 LINE Developers →「Basic settings」複製,再跑一次。")
    if not GWS:
        log("⚠️ 找不到 gws.exe:照片、日曆、試算表都會失敗(先裝 gws:指南 1-3)")
    code, cur = line("GET", "/v2/bot/channel/webhook/endpoint")
    if code == 200:
        old = cur.get("endpoint")
    elif code == 404:  # LINE 說這個 channel 原本就沒設 Webhook
        old = None
    else:  # 讀不到原本的網址就不換,免得換了回不去
        sys.exit(f"讀不到 LINE 現在的 Webhook 網址({code}):先不換。等一下再跑一次。{cur}")
    log(f"   原本的 Webhook:{old or '(沒有設定)'}——先抄下來,萬一沒換回去要手動貼回")
    log(f"載入模型 {MODEL}(第一次要等一下)…")
    chat([], timeout=300)
    log("✅ 模型已在顯示卡上")

    server = ThreadingHTTPServer(("127.0.0.1", PORT), Webhook)  # 只聽本機:外面只能從通道進來
    threading.Thread(target=server.serve_forever, daemon=True).start()
    switched = False
    try:  # 從開通道起就在 try 裡:只要 Webhook 換過,不管怎麼結束都會換回去
        url = open_tunnel()
        if url:
            code, res = line("PUT", "/v2/bot/channel/webhook/endpoint", {"endpoint": url + "/callback"})
            if code != 200:
                log(f"⚠️ 換 Webhook 失敗 {code}:{res}。可以手動貼到 LINE Developers:{url}/callback")
            else:
                switched = True
                log(f"✅ LINE 的 Webhook 換到這台電腦了:{url}/callback(按 Ctrl+C 會換回去)")
                log("   LINE 說換網址最多要 1 分鐘才生效;等一下再用手機傳訊息")
                time.sleep(5)
                code, test = line("POST", "/v2/bot/channel/webhook/test", {})
                log(f"   LINE 測試連線:{'✅ 通了' if test.get('success') else '還沒通(' + str(test.get('reason')) + '),等一分鐘再傳'}")
        log("開好了。手機傳訊息給 bot 試試看;這個視窗不要關,按 Ctrl+C 結束。" if switched
            else "⚠️ 這次沒接上 LINE(看上面的訊息)。按 Ctrl+C 結束。")
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        pass
    finally:
        if switched:
            restore(old)
        for cf in _procs:
            cf.terminate()
        server.shutdown()


if __name__ == "__main__":
    main()

安全與隱私

下週的進階版:bot 留在 W5 的 Apps Script(電腦關了 bot 照樣記待辦),只有「查 …」開頭的問題轉到你電腦的本地模型——中間要多搭一個有密碼的轉接站。做法在參考頁 〈延伸・Apps Script 版〉。

卡關總表

上面每一步的「卡住了」全部集中在這裡,打關鍵字就過濾(例如 401、cloudflared、日曆、簽章)。

步驟症狀 → 處理

本頁內容來源:starter/line_bot_local.py 與老師 2026-10-10 在自己電腦的實測(真的 Ollama、搜尋、雲端硬碟、日曆、試算表,LINE 端用記錄用的假函式;紀錄 qa/simple-bot/);LINE 換 Webhook 與回覆碼時效依 LINE Messaging API 官方文件。⚠️ 通道與真的 LINE 手機端,老師彩排時才第一次實走。

↑