diff --git a/README.en.md b/README.en.md index d862bcd..4abd7ee 100644 --- a/README.en.md +++ b/README.en.md @@ -52,6 +52,7 @@ You can also use the root-level [management scripts](#management-scripts) to han Open `http://127.0.0.1:3789`. If `session.json` can be reused, you enter the chat interface directly; otherwise, click 「產生登入 QR code」 (Generate login QR code), scan with a secondary account, and confirm the displayed PIN. The QR URL and PIN are sent only once over WebSocket to the connection that clicked the button; they are not logged or written to files, and refreshing does not replay them. Failures do not trigger automatic retries; click the button again. The left side has 「聊天」 (Chats) and 「好友」 (Friends) tabs, including profile pictures and unread counts. The Chats tab is ordered by recent activity; each row shows the last message (prefixed with the sender's name in groups and OpenChat) and its time (the clock today, 「昨天」 (yesterday), otherwise the date), taken from LINE's chat summaries on load and then kept current by live messages, edits and take-backs. Messages appear on the right: consecutive messages from the same person share a profile picture and name (full names do not wrap; OpenChat administrators have a crown badge and co-administrators a shield badge), timestamps appear after messages in 24-hour format, with a date separator above each day's messages (今天/昨天 (today/yesterday) or the date, with the year when it differs), and scrolling upward loads older history. When you scroll away from the newest message, a 「↓ 最新訊息」 (↓ Latest) button appears at the bottom right (it becomes 「↓ N 則新訊息」 (↓ N new messages) when others' messages arrive meanwhile; click to return to the bottom). Your own messages display 「已讀」 (Seen) above the timestamp in 1:1 chats, or 「已讀 N」 (Seen by N) in groups and chat rooms; OpenChat has no read receipts. Opening a chat with unread messages shows an unread separator. You can send text (Enter to send, Shift+Enter for a new line), images (click 「媒體」 (Media) to pick several at once, or **paste/drag** multiple images/videos into the input field; they preview first and, on 送出 (Send), upload one at a time with a 「第 n/N 個」 (item n/N) and upload-percentage indicator, and any draft text is sent after the whole selection; PNG/JPEG/GIF images and MP4/MOV videos only), and stickers (the 「貼圖」 (Stickers) panel lists packs owned by this account; click a sticker to send it, or send manually by ID). Received images/GIFs display directly (click to enlarge), videos load only when you press play, and voice messages can be played directly, and message search (searches only loaded messages: type a keyword in 「搜尋」 (Search) in the header, or press Ctrl/⌘+K; matches are highlighted, and clicking a result jumps to that message and opens its chat). Locations, contacts, files and rich (flex) cards are shown as small cards with the text LINE attaches (place name, address and an 「在地圖上開啟」 (Open in map) link; contact name; file name, size and a 「下載」 (Download) link; the card's alternative text). Files are always served as attachments (`Content-Disposition: attachment`, `application/octet-stream`; the browser never opens or runs them in this page), only for file messages this service has received, never after a take-back, and E2EE OpenChat files cannot be downloaded. Calls and other types show type placeholders only. **Sending read receipts**: when the chat is open and the page is visible, the client reports to LINE that messages up to the latest one have been read (the other person sees 「已讀」 (Seen)); disable this with `chat.sendReadReceipts: false`. Clicking 「登出」 (Log out) at the top right opens a confirmation dialog; confirming revokes the LINE-side login and clears `session.json` and the cache. **Other web behavior**: +- **Optional daily-summary panel**: On Windows, when a compatible external summary companion is installed, the chat header shows 「每日摘要」 (Daily summary). It lets you search, select, and save chats, then manually create a summary for today. The button stays hidden without the companion. Scheduling and Notion/n8n configuration belong to the external tool and are not included in this repository. See the [companion integration contract](docs/daily-summary-companion.md). - **Links**: `http://` and `https://` URLs in message text become clickable links that open in a new tab (with `rel="noopener noreferrer"`, so the destination cannot access this page and receives no Referer). Trailing punctuation and extra closing parentheses are excluded from URLs; other protocols (such as `ftp://`) are not linked. The server does not fetch any URLs, and there are no link preview cards (deliberately: CSP and CORS prevent browser-side access, while server-side fetching risks SSRF and IP disclosure). - **System messages**: LINE membership events (`CHATEVENT`, currently supporting `C_MI`) display as small, centered gray pills, such as 「XX 新增 OO 至群組」 (XX added OO to the group). Other event types whose meanings have not been confirmed always display the placeholder 「[系統訊息]」 ([System message]); their content is not guessed. - **Take-backs**: when someone (or you, on another device) takes a message back, it becomes 「XX 已收回訊息」 (XX took back a message; your own read 「你已收回訊息」), replies quoting it show 「[已收回的訊息]」 ([Message taken back]), and its picture/video/voice/file is no longer served. If the take-back notice arrives before the message itself, only the placeholder is shown. Only message ids this service has already received are affected; nothing is guessed from the notice. Your own messages can be taken back with 「收回」 (Take back) in the context menu (after a confirmation; LINE only allows this for a limited time after sending, and a refusal shows a generic error). In OpenChat, the service recognizes your member id there only after you have sent a message from this page. diff --git a/README.ja.md b/README.ja.md index 52ada5a..fb503da 100644 --- a/README.ja.md +++ b/README.ja.md @@ -52,6 +52,7 @@ npm start `http://127.0.0.1:3789` を開きます。再利用できる `session.json` があれば、そのままチャット画面に進みます。それ以外は「產生登入 QR code」(ログイン用 QR code を生成)をクリックし、サブアカウントで読み取って画面の PIN を確認します。QR URL と PIN は、ボタンを押した接続にだけ WebSocket で 1 回送信され、ログやファイルには保存されません。ページを再読み込みしても再送されません。失敗時に自動で繰り返すことはなく、もう一度ボタンを押す必要があります。左側には「聊天」(チャット)と「好友」(友だち)の 2 つのタブがあり、プロフィール画像と未読数も表示します。「聊天」タブは最近のやり取り順に並び、各行に最後のメッセージ(グループとオープンチャットでは送信者名を先頭に付けます)と時刻(今日は時刻、昨日は「昨天」(昨日)、それ以前は日付)を表示します。読み込み時は LINE のチャット概要から取得し、その後はリアルタイムのメッセージ、編集、送信取消に合わせて更新します。右側にはメッセージを表示します。同じ人の連続したメッセージは画像と名前をまとめて表示します(名前は省略・改行せず、オープンチャットの管理者には王冠、共同管理者には盾のバッジを表示)。時刻は 24 時間表記でメッセージの後ろに表示し、日ごとのメッセージの上に日付の区切り(今天/昨天(今日/昨日)または月日。年が違う場合は年も)を表示し、上にスクロールすると以前の履歴を読み込みます。最新メッセージから離れてスクロールすると、右下に「↓ 最新訊息」(↓ 最新)ボタンを表示します(その間にほかの人のメッセージが届くと「↓ N 則新訊息」(↓ 新着 N 件)に変わり、クリックで最下部に戻ります)。自分が送信したメッセージは時刻の上に「已讀」(既読、1 対 1)または「已讀 N」(既読 N、グループとチャットルーム)を表示します。オープンチャットには既読通知はありません。未読のあるチャットを開くと未読の区切り線を表示します。テキスト(Enter で送信、Shift+Enter で改行)、画像(「媒體」(メディア)で複数まとめて選ぶか、入力欄に画像/動画を複数**貼り付け/ドラッグ**。先にプレビューを表示し、「送出」(送信)で「第 n/N 個」(n/N 個目)とアップロード率を表示しながら 1 件ずつ送信し、下書きのテキストがあれば最後のファイルの後に送信します。PNG/JPEG/GIF 画像と MP4/MOV 動画のみ)、スタンプ(「貼圖」(スタンプ)パネルにこのアカウントの所有済みスタンプパックを表示し、クリックすると即送信。ID による手動送信も可能)を送信できます。受信した画像/GIF は直接表示し(クリックで拡大)、動画は再生を押してから読み込み、音声は直接再生できます。また、メッセージ検索(読み込み済みのメッセージのみを検索。タイトルバーの「搜尋」(検索)または Ctrl/⌘+K でキーワードを入力すると一致箇所を強調表示し、結果をクリックするとそのメッセージへ移動してそのチャットを開きます)。位置情報、連絡先、ファイル、カードメッセージは、LINE が付ける文字情報を小さなカードで表示します(位置の名前、住所と「在地圖上開啟」(地図で開く)リンク、連絡先の名前、ファイル名とサイズと「下載」(ダウンロード)リンク、カードの代替テキスト)。ファイルは常に添付ファイルとして提供します(`Content-Disposition: attachment`、`application/octet-stream`。ブラウザーはこのページで開いたり実行したりしません)。このサービスが受信したファイルメッセージのみ対象で、送信取消後は提供せず、E2EE のオープンチャットのファイルはダウンロードできません。通話などそのほかの種類は、種類のプレースホルダーのみ表示します。**既読通知**:チャット画面を開き、ページが表示されている間、最新メッセージまで既読として LINE に通知します(相手には「已讀」(既読)と表示されます)。`chat.sendReadReceipts: false` で無効にできます。右上の「登出」(ログアウト)を押すと確認ダイアログを表示し、確認後に LINE 側のログインを取り消して `session.json` とキャッシュを消去します。 **そのほかの Web ページの動作**: +- **任意の毎日要約パネル**:Windows で互換性のある外部要約ツールがインストールされている場合、チャットの見出しに「每日摘要」(毎日要約)が表示されます。チャットの検索・選択・保存と、今日の要約の手動作成ができます。外部ツールがない場合、ボタンは表示されません。スケジュールと Notion/n8n の設定は外部ツール側で管理し、このリポジトリには含まれません。[連携仕様](docs/daily-summary-companion.md)を参照してください。 - **リンク**:メッセージ内の `http://`、`https://` URL はクリック可能なリンクになり、新しいタブで開きます(`rel="noopener noreferrer"` を付けるため、相手のサイトはこのページを参照できず、Referer も受け取りません)。文末の句読点や余分な閉じ括弧は URL に含めず、ほかのプロトコル(`ftp://` など)はリンクに変換しません。サーバーは URL を取得せず、リンクプレビューカードもありません(意図的に実装していません。ブラウザー側では CSP と CORS により読み取れず、サーバーで代わりに取得すると SSRF と IP 漏えいのリスクがあります)。 - **システムメッセージ**:LINE のメンバー変更イベント(`CHATEVENT`、現在は `C_MI` に対応)は、中央寄せの小さな灰色のカプセルとして表示します。例:「XX 新增 OO 至群組」(XX が OO をグループに追加)。意味を確認できていないほかのイベントは、すべて「[系統訊息]」(システムメッセージ)のプレースホルダーとし、内容を推測しません。 - **送信取消**:相手(または別の端末のあなた)がメッセージの送信を取り消すと、そのメッセージは「XX 已收回訊息」(XX がメッセージの送信を取り消しました。自分の場合は「你已收回訊息」)に変わり、それを引用した返信は「[已收回的訊息]」(取り消されたメッセージ)と表示し、画像/動画/音声も提供しなくなります。取り消し通知がメッセージ本体より先に届いた場合も、プレースホルダーのみ表示します。対象はこのサービスが受信済みのメッセージ id のみで、通知の内容から推測はしません。自分のメッセージは右クリックメニューの「收回」(送信取消)で取り消せます(先に確認します。LINE が取り消しを認めるのは送信後の一定時間内のみで、拒否された場合は汎用エラーを表示します)。オープンチャットでは、このページから一度メッセージを送ると、サービスがそのオープンチャットでのあなたのメンバー id を認識します。 diff --git a/README.md b/README.md index 405f48d..ccab84d 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ npm start 開啟 `http://127.0.0.1:3789`。有可復用的 `session.json` 時直接進入聊天視窗;否則點「產生登入 QR code」,用次要帳號掃描並確認畫面 PIN。QR URL 與 PIN 只經 WebSocket 送給按下按鈕的那個連線一次,不入日誌或檔案;重新整理不會重播。失敗不自動循環,須再按按鈕。左側分「聊天」與「好友」兩個分頁(含大頭照與未讀數);「聊天」分頁依最近活動排序,每列顯示最後一則訊息(群組與社群前面加寄件者名稱)與時間(今天顯示時刻、昨天顯示「昨天」,更早顯示日期),載入時取自 LINE 的聊天摘要,之後隨即時訊息、編輯與收回更新;右側為訊息:連續同一人的訊息合併顯示頭像與名稱(名稱完整不斷行;社群管理員旁有皇冠、共同管理員旁有盾牌徽章)、時間(24 小時制)顯示在訊息後方、每天的訊息上方有日期分隔(今天/昨天/月日,跨年加年份)、往上捲動載入更早的歷史、捲離最新訊息時右下角出現「↓ 最新訊息」按鈕(期間收到他人的訊息會改為「↓ N 則新訊息」,點一下回到最底)、自己發的訊息在時間上方顯示「已讀」(1 對 1)或「已讀 N」(群組與聊天室;社群沒有已讀回執)、開啟有未讀的聊天會標出未讀分隔線。可傳送文字(Enter 送出、Shift+Enter 換行)、圖片(按「媒體」可一次選多個,或直接在輸入框**貼上/拖入**多張圖片/影片;先出現預覽,按「送出」後依序逐檔上傳,顯示「第 n/N 個」與上傳百分比,最後若有草稿文字則一併在全部檔案之後送出;僅 PNG/JPEG/GIF 圖片與 MP4/MOV 影片)與貼圖(「貼圖」面板列出此帳號已擁有的貼圖包,點一下即送出;也可用 ID 手動送出);收到的圖片/GIF 直接顯示(點擊放大)、影片按下播放才載入、語音可直接播放、訊息搜尋(只搜尋已載入的訊息:點標題列「搜尋」或按 Ctrl/⌘+K 輸入關鍵字,符合的訊息以底色標示,點結果會跳到該訊息並開啟所屬聊天室)。位置、聯絡人、檔案與卡片訊息以小卡顯示 LINE 附帶的文字資訊(位置的名稱、地址與「在地圖上開啟」連結;聯絡人名稱;檔名、大小與「下載」連結;卡片訊息的替代文字)。檔案一律以附件下載(`Content-Disposition: attachment`、`application/octet-stream`,瀏覽器不會在此頁開啟或執行內容),只限本服務收過的檔案訊息,收回後不再提供,E2EE 社群檔案無法下載;通話等其他類型只顯示類型佔位。**已讀回報**:聊天視窗開著且頁面可見時,會對 LINE 回報已讀到最新訊息(對方會看到「已讀」),可用 `chat.sendReadReceipts: false` 關閉。右上「登出」會先跳出確認框,確認後撤銷 LINE 端登入並清除 `session.json` 與快取。 **其他網頁行為**: +- **選用的每日摘要面板**:Windows 上有安裝相容的外部摘要工具時,聊天標題列顯示「每日摘要」,可搜尋、勾選並儲存聊天室,再手動建立今天的摘要。沒有安裝時按鈕不顯示;排程與 Notion/n8n 設定由外部工具管理,不包含在本倉庫。介面契約見 [摘要工具整合說明](docs/daily-summary-companion.md)。 - **連結**:訊息文字中的 `http://`、`https://` 網址會變成可點的連結,在新分頁開啟(帶 `rel="noopener noreferrer"`,對方網站拿不到本頁,也不會收到 Referer);句尾標點與多餘的右括號不算進網址,其他協定(如 `ftp://`)不轉連結。伺服器不會去抓取任何網址,也沒有連結預覽卡片(刻意不做:瀏覽器端受 CSP 與 CORS 限制讀不到,伺服器代抓則有 SSRF 與洩露 IP 的風險)。 - **系統訊息**:LINE 的成員異動事件(`CHATEVENT`,目前支援 `C_MI`)顯示為置中的灰色小膠囊,例如「XX 新增 OO 至群組」;其他未確認含義的事件類型一律顯示「[系統訊息]」佔位,不猜測內容。 - **收回**:對方(或你在其他裝置上)收回訊息時,該訊息改為「XX 已收回訊息」(自己的顯示「你已收回訊息」),引用它的回覆顯示「[已收回的訊息]」,圖片/影片/語音/檔案也不再提供下載;收回通知比訊息本身先到時同樣只顯示佔位。只處理本服務已收過的訊息 id,不依通知內容猜測是哪一則。自己的訊息可在右鍵選單按「收回」(會先確認;LINE 只允許收回一段時間內送出的訊息,被拒時顯示一般錯誤);社群裡要先從此頁送過訊息,服務才認得你在該社群的成員 id。 diff --git a/docs/daily-summary-companion.md b/docs/daily-summary-companion.md new file mode 100644 index 0000000..cf5f880 --- /dev/null +++ b/docs/daily-summary-companion.md @@ -0,0 +1,15 @@ +# Optional daily-summary companion + +LINE.js does not schedule summaries or configure Notion or n8n. On Windows, it can show controls for a separately installed local companion. The button is hidden unless all three companion scripts exist. The default companion directory is `../LINE-daily-summary` relative to the LINE.js working directory; set `LINE_DIGEST_DIR` to an absolute directory to use another location. + +The companion must provide these scripts: + +| Script | Invocation | Contract | +| --- | --- | --- | +| `picker-data.mjs` | `node picker-data.mjs ` | Write `{ "profileId": string, "profileName": string, "selectedIds": string[], "chats": [{ "id": string, "kind": string, "name": string }] }`. The current LINE account must match the saved selection. | +| `picker-save.mjs` | `node picker-save.mjs ` | Read `{ "profileId": string, "selectedIds": string[] }`; verify the account and chat IDs, then persist the selection. | +| `scheduled-run.ps1` | `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scheduled-run.ps1 -Mode now` | Produce today's summary. Exit 0 on success; send a human-readable success line to stdout. Write failures to the companion's private log and exit nonzero. | + +The companion can manage its own recurring schedule independently. LINE.js never passes a webhook URL, token, or arbitrary command from the browser to these scripts. The new `/summary/*` routes require the browser cookie; mutations additionally require same-origin requests. The run is asynchronous and its status is kept in memory until the LINE.js service restarts. A manual run may transmit selected chat content to the companion's configured service, so the browser only starts it after the user presses 「立即摘要今天」. + +The scripts, selection state, webhook configuration, and scheduled task are **not part of this repository**. Keep private configuration outside Git. A compatible companion should itself enforce chat selection, account binding, and output limits before publishing data. diff --git a/src/http/digest.ts b/src/http/digest.ts new file mode 100644 index 0000000..5c58e16 --- /dev/null +++ b/src/http/digest.ts @@ -0,0 +1,91 @@ +import { execFile, spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const companion = resolve(process.env.LINE_DIGEST_DIR || "../LINE-daily-summary"); + +export interface DigestStatus { + running: boolean; + startedAt?: string; + finishedAt?: string; + result?: "success" | "error"; + message?: string; +} + +let status: DigestStatus = { running: false }; + +async function available(): Promise { + try { + await Promise.all(["picker-data.mjs", "picker-save.mjs", "scheduled-run.ps1"].map((name) => stat(join(companion, name)))); + return true; + } catch { return false; } +} + +export async function digestAvailable(): Promise { + return process.platform === "win32" && available(); +} + +async function temporaryFile(name: string, callback: (path: string) => Promise): Promise { + const folder = await mkdtemp(join(tmpdir(), "linejs-digest-")); + try { return await callback(join(folder, name)); } + finally { await rm(folder, { recursive: true, force: true }); } +} + +export async function digestSelection(): Promise { + if (!await available()) throw new Error("每日摘要工具尚未安裝"); + return temporaryFile("selection.json", async (file) => { + await execFileAsync(process.execPath, [join(companion, "picker-data.mjs"), file], { + cwd: dirname(companion), windowsHide: true, timeout: 45_000, maxBuffer: 64 * 1024, + }); + return JSON.parse(await readFile(file, "utf8")); + }); +} + +export async function saveDigestSelection(input: unknown): Promise { + if (!await available()) throw new Error("每日摘要工具尚未安裝"); + if (!input || typeof input !== "object") throw new Error("選取資料無效"); + const { profileId, selectedIds } = input as { profileId?: unknown; selectedIds?: unknown }; + if (typeof profileId !== "string" || !Array.isArray(selectedIds) || selectedIds.length > 1000 || + !selectedIds.every((id) => typeof id === "string" && id.length < 256)) throw new Error("選取資料無效"); + await temporaryFile("selection.json", async (file) => { + await writeFile(file, JSON.stringify({ profileId, selectedIds }), { mode: 0o600 }); + await execFileAsync(process.execPath, [join(companion, "picker-save.mjs"), file], { + cwd: dirname(companion), windowsHide: true, timeout: 45_000, maxBuffer: 64 * 1024, + }); + }); +} + +export function digestStatus(): DigestStatus { return { ...status }; } + +export async function runDigestNow(): Promise { + if (!await available()) throw new Error("每日摘要工具尚未安裝"); + if (process.platform !== "win32") throw new Error("手動摘要目前只支援 Windows"); + if (status.running) throw new Error("摘要正在執行,請等候完成"); + // Fixed script and arguments; no caller-controlled command or path reaches PowerShell. + const startedAt = new Date().toISOString(); + status = { running: true, startedAt, message: "正在整理今天 00:00 至現在的訊息…" }; + const child = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(companion, "scheduled-run.ps1"), "-Mode", "now"], { + cwd: dirname(companion), windowsHide: true, stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + let finished = false; + const append = (part: Buffer): void => { output = (output + part.toString("utf8")).slice(-4000); }; + child.stdout?.on("data", append); + // Child stderr may contain integration secrets; keep it in the companion's private log only. + child.stderr?.resume(); + const done = (ok: boolean, message: string): void => { + if (finished) return; + finished = true; + status = { running: false, startedAt, finishedAt: new Date().toISOString(), result: ok ? "success" : "error", message: message.trim().slice(-2000) }; + }; + child.once("error", () => done(false, "摘要工具無法啟動,請查看本機記錄。")); + child.once("close", (code) => done(code === 0, code === 0 ? output || "摘要完成。" : "摘要失敗,請查看本機摘要工具記錄。")); + // Avoid a stuck process holding the UI in a permanent running state. + const timeout = setTimeout(() => { if (!finished) child.kill(); }, 20 * 60_000); + timeout.unref(); + child.once("close", () => clearTimeout(timeout)); + return digestStatus(); +} diff --git a/src/http/server.ts b/src/http/server.ts index 6f58cbd..a3596f0 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -6,6 +6,7 @@ import type { Config } from "../config.js"; import { SlidingWindowLimiter } from "../limit.js"; import { isMediaId, sniffUpload, type MediaService } from "../media/service.js"; import type { ApiTokenStore } from "./apiToken.js"; +import { digestAvailable, digestSelection, digestStatus, runDigestNow, saveDigestSelection } from "./digest.js"; export interface WebServer { server: Server; @@ -121,6 +122,39 @@ export function createWebServer(config: Config, webRoot: string, media: MediaSer json(response, 200, { mediaId: media.putUpload({ mime: detected, bytes }) }); } + async function handleDigest(request: IncomingMessage, response: ServerResponse, path: string): Promise { + if (!hasBrowserCookie(request) || (request.method === "POST" ? !sameOrigin(request) : request.headers.origin !== undefined && !sameOrigin(request))) { + json(response, 403, { code: "FORBIDDEN" }); + return; + } + try { + if (request.method === "GET" && path === "/summary/availability") json(response, 200, { enabled: await digestAvailable() }); + else if (request.method === "GET" && path === "/summary/selection") json(response, 200, await digestSelection()); + else if (request.method === "GET" && path === "/summary/status") json(response, 200, digestStatus()); + else if (request.method === "POST" && path === "/summary/run") json(response, 202, await runDigestNow()); + else if (request.method === "POST" && path === "/summary/selection") { + if (request.headers["content-type"]?.split(";")[0] !== "application/json") { + json(response, 415, { code: "UNSUPPORTED_MEDIA_TYPE" }); + return; + } + let body = ""; + for await (const chunk of request) { + body += chunk.toString("utf8"); + if (body.length > 64_000) { + json(response, 413, { code: "TOO_LARGE" }); + return; + } + } + await saveDigestSelection(JSON.parse(body)); + json(response, 200, { saved: true }); + } else json(response, 405, { code: "METHOD_NOT_ALLOWED" }); + } catch (error) { + const message = error instanceof Error ? error.message : "摘要工具發生錯誤"; + const safe = ["每日摘要工具尚未安裝", "選取資料無效", "手動摘要目前只支援 Windows", "摘要正在執行,請等候完成"].includes(message); + json(response, 400, { code: "DIGEST_ERROR", message: safe ? message : "摘要工具無法完成操作,請查看本機記錄。" }); + } + } + let webRootPath: Promise | undefined; async function handle(request: IncomingMessage, response: ServerResponse): Promise { @@ -134,6 +168,10 @@ export function createWebServer(config: Config, webRoot: string, media: MediaSer return; } const url = new URL(request.url ?? "/", `http://${host}`); + if (url.pathname.startsWith("/summary/")) { + await handleDigest(request, response, url.pathname); + return; + } if (request.method === "POST" && url.pathname === "/media/upload") { await handleUpload(request, response); return; diff --git a/test/composer.test.mjs b/test/composer.test.mjs index 0ad09bb..d2ca2ee 100644 --- a/test/composer.test.mjs +++ b/test/composer.test.mjs @@ -1,9 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { loadWeb } from "./load-web.mjs"; // The composer is browser code: it reaches for DOM nodes and XHR at call time. These shims stand in // for them so the send queue can be driven headlessly. The web build bundles the module away, so the -// test imports the TypeScript source directly (Node ≥ 22 strips types). +// test transpiles the TypeScript source for Node 22 builds without native type stripping. function makeElement(id) { const listeners = new Map(); return { @@ -90,7 +91,7 @@ const file = (name) => ({ name, size: 1024, type: "image/png" }); test("a selection of several files is sent one at a time, then the draft text follows", async () => { await withShims(async (nodes) => { - const { createComposer } = await import("../web/composer.ts"); + const { createComposer } = await loadWeb("composer"); const sent = []; const composer = createComposer((frame) => { sent.push(frame); @@ -134,7 +135,7 @@ test("a selection of several files is sent one at a time, then the draft text fo test("the draft text is sent only after the selection, and only when it has one", async () => { await withShims(async (nodes) => { - const { createComposer } = await import("../web/composer.ts"); + const { createComposer } = await loadWeb("composer"); const sent = []; const composer = createComposer((frame) => { sent.push(frame); diff --git a/test/digest.test.mjs b/test/digest.test.mjs new file mode 100644 index 0000000..a79f8d9 --- /dev/null +++ b/test/digest.test.mjs @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +test("daily summary bridge reads, saves and launches only the configured companion", async (t) => { + const folder = await mkdtemp(join(tmpdir(), "linejs-digest-test-")); + t.after(async () => { delete process.env.LINE_DIGEST_DIR; await rm(folder, { recursive: true, force: true }); }); + const saved = join(folder, "saved.json"); + await writeFile(join(folder, "picker-data.mjs"), `import { writeFile } from 'node:fs/promises'; await writeFile(process.argv[2], JSON.stringify({ profileId: 'owner', profileName: 'Owner', selectedIds: ['chat-1'], chats: [{ id: 'chat-1', kind: 'group', name: 'Test' }] }));`); + await writeFile(join(folder, "picker-save.mjs"), `import { copyFile } from 'node:fs/promises'; await copyFile(process.argv[2], ${JSON.stringify(saved)});`); + await writeFile(join(folder, "scheduled-run.ps1"), `Write-Output 'summary finished'\n`); + process.env.LINE_DIGEST_DIR = folder; + const { digestAvailable, digestSelection, saveDigestSelection, digestStatus, runDigestNow } = await import(`../dist/http/digest.js?test=${Date.now()}`); + assert.equal(await digestAvailable(), process.platform === "win32"); + const selection = await digestSelection(); + assert.equal(selection.profileId, "owner"); + assert.deepEqual(selection.selectedIds, ["chat-1"]); + await assert.rejects(saveDigestSelection({ profileId: "owner", selectedIds: [3] }), /選取資料無效/); + await saveDigestSelection({ profileId: "owner", selectedIds: ["chat-1"] }); + assert.deepEqual(JSON.parse(await readFile(saved, "utf8")), { profileId: "owner", selectedIds: ["chat-1"] }); + if (process.platform === "win32") { + assert.equal((await runDigestNow()).running, true); + for (let n = 0; n < 100 && digestStatus().running; n++) await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(digestStatus().result, "success", digestStatus().message); + assert.match(digestStatus().message, /summary finished/); + } +}); diff --git a/test/load-web.mjs b/test/load-web.mjs new file mode 100644 index 0000000..c74897c --- /dev/null +++ b/test/load-web.mjs @@ -0,0 +1,14 @@ +import { readFile } from "node:fs/promises"; +import { URL } from "node:url"; +import ts from "typescript"; + +// The web build bundles these modules; transpile source-only imports for test runners whose Node 22 +// binary was built without native TypeScript stripping (for example Ubuntu's packaged Node). +export async function loadWeb(name) { + const source = await readFile(new URL(`../web/${name}.ts`, import.meta.url), "utf8"); + const { outputText } = ts.transpileModule(source, { + fileName: `${name}.ts`, + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + }); + return import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`); +} diff --git a/test/notify.test.mjs b/test/notify.test.mjs index 44bce9d..45705fa 100644 --- a/test/notify.test.mjs +++ b/test/notify.test.mjs @@ -1,6 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { createNotifier } from "../web/notify.ts"; +import { loadWeb } from "./load-web.mjs"; + +const { createNotifier } = await loadWeb("notify"); // Session cleanup must close OS notifications as well as the hidden chat view. test("ending a session closes every chat notification without changing the notification preference", () => { diff --git a/test/update.test.mjs b/test/update.test.mjs index 8e65240..c836323 100644 --- a/test/update.test.mjs +++ b/test/update.test.mjs @@ -165,8 +165,12 @@ async function fixture(t, { npmExit = 0 } = {}) { const bin = join(base, "bin"); await mkdir(bin); const log = join(base, "npm.log"); - await writeFile(join(bin, "npm"), `#!/bin/sh\necho "$@" >> '${log}'\nexit ${npmExit}\n`); - await chmod(join(bin, "npm"), 0o755); + if (process.platform === "win32") { + await writeFile(join(bin, "npm.cmd"), `@echo off\r\necho %*>>"${log}"\r\nexit /b ${npmExit}\r\n`); + } else { + await writeFile(join(bin, "npm"), `#!/bin/sh\necho "$@" >> '${log}'\nexit ${npmExit}\n`); + await chmod(join(bin, "npm"), 0o755); + } const update = async (...args) => { try { const { stdout, stderr } = await run(process.execPath, [SCRIPT, ...args], { cwd: work, env: { ...process.env, ...GIT_ENV, PATH: `${bin}${delimiter}${process.env.PATH}` } }); @@ -176,7 +180,7 @@ async function fixture(t, { npmExit = 0 } = {}) { } }; const version = async () => JSON.parse(await readFile(join(work, "package.json"), "utf8")).version; - const npmCalls = async () => (await readFile(log, "utf8").catch(() => "")).split("\n").filter(Boolean); + const npmCalls = async () => (await readFile(log, "utf8").catch(() => "")).split("\n").map((line) => line.trimEnd()).filter(Boolean); return { base, origin, work, update, version, npmCalls }; } diff --git a/test/web-format.test.mjs b/test/web-format.test.mjs index c7d2757..79cbfc8 100644 --- a/test/web-format.test.mjs +++ b/test/web-format.test.mjs @@ -1,7 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -// Browser code without DOM or page state: imported from source (Node ≥ 22 strips types). -import { dayLabel, formatBytes, listTime, previewOf } from "../web/format.ts"; +import { loadWeb } from "./load-web.mjs"; +// Browser code without DOM or page state, transpiled from source for all Node 22 builds. +const { dayLabel, formatBytes, listTime, previewOf } = await loadWeb("format"); const at = (y, m, d, h = 12, min = 0) => new Date(y, m - 1, d, h, min).getTime(); const NOW = at(2026, 10, 10, 9); diff --git a/web/index.html b/web/index.html index 36161cf..c05d1b9 100644 --- a/web/index.html +++ b/web/index.html @@ -63,6 +63,7 @@

使用 LINE 掃碼登入

選擇一個聊天室

+ + +

每日摘要

只摘要勾選的聊天室;排程與儲存位置由摘要工具管理。

+

+ +
+

+

+
+

立即摘要會先儲存勾選,再整理今天 00:00 至執行當下的訊息;日期時區與結果保存方式由摘要工具決定。

+
+ diff --git a/web/main.ts b/web/main.ts index 02d91af..8796327 100644 --- a/web/main.ts +++ b/web/main.ts @@ -13,6 +13,7 @@ import { registerServiceWorker } from "./pwa.js"; import { createUpdateNotice } from "./update.js"; import { copyImage, downloadMedia } from "./save.js"; import { createNotifier } from "./notify.js"; +import { setupSummary } from "./summary.js"; import { CONTENT_LABEL, cardTitle, dayLabel, formatBytes, formatTime, listTime, previewOf } from "./format.js"; const $ = (selector: string): T => document.querySelector(selector)!; @@ -44,6 +45,7 @@ const tabFriends = $("#tab-friends"); const chatsUnread = $("#chats-unread"); const notice = createUpdateNotice($("#notice")); const offline = $("#offline"); +setupSummary(); const menuButton = $("#menu"); const drawerBackdrop = $("#drawer-backdrop"); diff --git a/web/style.css b/web/style.css index b395cc4..14a5086 100644 --- a/web/style.css +++ b/web/style.css @@ -63,6 +63,24 @@ footer { color: #667c73; font-size: 12px; line-height: 1.7; } .search-bar .ghost { flex: none; } mark { background: #ffe7a3; color: #6b4a00; padding: 0 2px; border-radius: 3px; } .conversation-head h2 { margin: 0; font-size: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#summary-toggle { margin-left: auto; flex: none; color: var(--accent); border-color: var(--accent); } +#summary-dialog { width: min(680px, calc(100vw - 24px)); max-height: min(740px, calc(100vh - 24px)); padding: 20px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); color: inherit; box-shadow: 0 16px 56px #0003; } +#summary-dialog::backdrop { background: #10251ca8; } +.summary-head { display: flex; align-items: start; justify-content: space-between; gap: 12px; } +.summary-head h2 { margin: 0; } +.summary-head p, .summary-hint { margin: 4px 0 12px; font-size: 13px; color: var(--muted); } +#summary-account, #summary-count, #summary-status { margin: 8px 0; font-size: 14px; } +#summary-status { min-height: 2em; white-space: pre-wrap; overflow-wrap: anywhere; } +.summary-search { display: grid; gap: 5px; font-size: 13px; } +.summary-search input { width: 100%; padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px; font: inherit; background: transparent; color: inherit; } +.summary-list { max-height: min(50vh, 360px); overflow-y: auto; margin: 10px 0; border: 1px solid var(--line); border-radius: 8px; } +.summary-chat { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 10px; border-bottom: 1px solid var(--line); cursor: pointer; } +.summary-chat:last-child { border-bottom: 0; } +.summary-chat:hover { background: var(--soft); } +.summary-chat span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.summary-chat small { color: var(--muted); } +.summary-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.summary-actions button { flex: 1; min-width: 140px; } #channel-meta { color: var(--muted); font-size: 13px; } .timeline { position: relative; flex: 1; min-height: 0; display: flex; } #messages { flex: 1; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 14px; } @@ -188,6 +206,12 @@ dialog#confirm::backdrop { background: #0a1410a0; backdrop-filter: blur(3px); } footer { color: #95b0a1; } } @media (max-width: 720px) { + .conversation-head { gap: 6px; padding: 10px 12px; } + .conversation-head h2 { flex: 1; min-width: 0; } + #channel-meta { display: none; } + #summary-toggle { margin-left: 0; padding: 5px 7px; } + .summary-chat { grid-template-columns: auto minmax(0, 1fr); } + .summary-chat small { grid-column: 2; } #login { margin: 4vh auto; padding: 18px; } #login section { padding: 20px; } #app { grid-template-columns: 1fr; grid-template-rows: 1fr; } diff --git a/web/summary.ts b/web/summary.ts new file mode 100644 index 0000000..50ec74e --- /dev/null +++ b/web/summary.ts @@ -0,0 +1,118 @@ +interface Chat { id: string; kind: string; name: string } +interface Selection { profileId: string; profileName: string; selectedIds: string[]; chats: Chat[] } +interface Status { running: boolean; startedAt?: string; finishedAt?: string; result?: "success" | "error"; message?: string } + +const byId = (id: string): T => document.getElementById(id) as T; + +async function api(path: string, body?: unknown): Promise { + const response = await fetch(`/summary/${path}`, { + method: body === undefined ? "GET" : "POST", + headers: body === undefined ? undefined : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + cache: "no-store", + credentials: "same-origin", + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || `摘要工具回應 ${response.status}`); + return data as T; +} + +export function setupSummary(): void { + const toggle = byId("summary-toggle"); + void api<{ enabled: boolean }>("availability").then((result) => { toggle.hidden = !result.enabled; }).catch(() => {}); + const dialog = byId("summary-dialog"); + const list = byId("summary-list"); + const search = byId("summary-search"); + const account = byId("summary-account"); + const count = byId("summary-count"); + const statusText = byId("summary-status"); + const save = byId("summary-save"); + const run = byId("summary-run"); + let data: Selection | undefined; + let selected = new Set(); + let busy = false; + let poll: ReturnType | undefined; + + function render(): void { + list.replaceChildren(); + if (!data) return; + const query = search.value.trim().toLocaleLowerCase(); + for (const chat of data.chats.filter((item) => item.name.toLocaleLowerCase().includes(query))) { + const label = document.createElement("label"); + label.className = "summary-chat"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = selected.has(chat.id); + checkbox.addEventListener("change", () => { + if (checkbox.checked) selected.add(chat.id); + else selected.delete(chat.id); + count.textContent = `已選 ${selected.size} / ${data?.chats.length ?? 0} 個聊天室`; + }); + const name = document.createElement("span"); + name.textContent = chat.name; + const kind = document.createElement("small"); + kind.textContent = `${chat.kind} · …${chat.id.slice(-6)}`; + label.append(checkbox, name, kind); + list.append(label); + } + count.textContent = `已選 ${selected.size} / ${data.chats.length} 個聊天室`; + } + + function showStatus(state: Status): void { + if (state.running) statusText.textContent = state.message || "摘要正在執行…"; + else if (state.finishedAt) statusText.textContent = `${state.result === "success" ? "完成" : "失敗"}:${state.message || ""}`; + else statusText.textContent = "目前沒有手動任務;自動排程由摘要工具管理。"; + run.disabled = busy || state.running || !data || selected.size === 0; + save.disabled = busy || state.running || !data; + if (!state.running && poll) { clearInterval(poll); poll = undefined; } + if (state.running && !poll) poll = setInterval(() => { void refreshStatus(); }, 3000); + } + + async function refreshStatus(): Promise { + try { showStatus(await api("status")); } + catch (error) { statusText.textContent = String(error); } + } + + async function load(): Promise { + data = undefined; + list.replaceChildren(); + statusText.textContent = "正在載入聊天室…"; + save.disabled = run.disabled = true; + try { + data = await api("selection"); + selected = new Set(data.selectedIds); + account.textContent = `登入帳號:${data.profileName}`; + render(); + await refreshStatus(); + } catch (error) { statusText.textContent = `載入失敗:${error instanceof Error ? error.message : String(error)}`; } + } + + async function saveSelection(): Promise { + if (!data) return; + await api("selection", { profileId: data.profileId, selectedIds: [...selected] }); + statusText.textContent = `已儲存 ${selected.size} 個聊天室。`; + } + + toggle.addEventListener("click", () => { dialog.showModal(); void load(); }); + byId("summary-close").addEventListener("click", () => dialog.close()); + dialog.addEventListener("close", () => { if (poll) clearInterval(poll); poll = undefined; }); + search.addEventListener("input", render); + save.addEventListener("click", async () => { + if (busy) return; + busy = true; save.disabled = run.disabled = true; + let message = ""; + try { await saveSelection(); message = `已儲存 ${selected.size} 個聊天室。`; } + catch (error) { message = `儲存失敗:${error instanceof Error ? error.message : String(error)}`; } + finally { busy = false; await refreshStatus(); if (message) statusText.textContent = message; } + }); + run.addEventListener("click", async () => { + if (busy || selected.size === 0) return; + busy = true; save.disabled = run.disabled = true; + let errorMessage = ""; + try { + await saveSelection(); + showStatus(await api("run", {})); + } catch (error) { errorMessage = `執行失敗:${error instanceof Error ? error.message : String(error)}`; } + finally { busy = false; await refreshStatus(); if (errorMessage) statusText.textContent = errorMessage; } + }); +}