dsh-line-bot 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ 第一個公開版本。
6
+
7
+ - LINE Messaging API webhook server(`X-Line-Signature` 驗章、事件去重、每位使用者佇列、reply 失敗自動改 push、loading 動畫)。
8
+ - Cloudflare Tunnel:quick tunnel(`*.trycloudflare.com`)與 API Token 具名隧道(自動建立 tunnel、ingress、DNS CNAME、`cfargotunnel.com` 目標),取得網址後自動設定並測試 LINE webhook。
9
+ - LINE 憑證:Channel ID + Channel Secret 自動簽發/續簽 access token;也可貼上既有 token。
10
+ - 回覆走 DSH 行程內的 Agent(`agents.create`/`resume`),可逐 session 指定人設。
11
+ - 人設來源:大肥魚(`dsh-persona-dafeiyu`,選用)或插件內建的自訂人設(`<DSH_HOME>/line-bot/personas.json`);面板可新增/編輯/刪除/把大肥魚人設複製成自訂。選自訂人設時會請大肥魚在該 session 停用,再由插件注入 system prompt,不需要任何個性外掛。
12
+ - 每位 LINE 使用者獨立 session 與工作資料夾(記憶不互通),可用「人設」指令自行切換人設。
13
+ - 主動傳訊排程:機率、間隔、每日/每月上限、最短間隔、安靜時段、idle、指定對象、提示詞,Agent 可回 `SKIP`。
14
+ - 完整設定面板(`settings.section`):狀態總覽、LINE 憑證、Cloudflare Tunnel、人設、貼吧學習同步、主動傳訊、使用者、進階設定;固定儲存列與二級快速選單。
15
+ - 貼吧學習同步:把「刷貼吧」演化的語氣樣本與性格數值寫回指定自訂人設,寫入前備份、寫入後重載大肥魚外掛。
16
+ - 安裝工具:`npx dsh-line-bot install` / `uninstall` / `status`,自動選擇 profile 並寫入 `dsh.profile.bundles`。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hanson0901
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,147 @@
1
- # Temporary Holding Version
1
+ # dsh-line-bot
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件。回覆由 DSH 的 Agent 在**同一個行程內**產生,等於在 LINE 上跟你的 DSH 助理聊天;人設有兩種來源:有安裝大肥魚(`dsh-persona-dafeiyu`)時可以直接選它的人設;也可以只用插件內建的自訂人設(不需要任何個性外掛)。
4
+
5
+ 內建:
6
+
7
+ - **Webhook 伺服器**:在本機 `127.0.0.1` 開一個 HTTP server,處理 LINE 的 `X-Line-Signature` 驗章、事件去重、每位使用者各自的佇列。
8
+ - **Cloudflare Tunnel**:一鍵開 quick tunnel(`*.trycloudflare.com`,免 Cloudflare 帳號),或貼上 Cloudflare API Token 建立具名隧道+DNS,取得固定網址;取得網址後自動把 webhook 端點寫回 LINE 並跑一次 `webhook/test`。
9
+ - **LINE 憑證**:填 Channel ID+Channel Secret 就能自動簽發 access token(`/v2/oauth/accessToken`,約 30 天,快到期會自動續簽);也可以直接貼上既有的 Channel Access Token。
10
+ - **人設**:面板可選大肥魚插件裡任何一個內建/自訂人設,或插件內建的自訂人設(存在 `<DSH_HOME>/line-bot/personas.json`,可新增/編輯/匯入)。人設是「逐 session」指定,不會改到你 GUI 裡其他對話的人設。
11
+ - **主動傳訊**:插件內建排程器(不用 launchd),可調機率、間隔、每日/每月上限、最短間隔、安靜時段、對方安靜多久才可打擾、指定對象與提示詞;Agent 覺得不適合時可以回 `SKIP` 跳過。
12
+ - **完整設定面板**:所有設定都在「設定 → LINE Bot」裡,存到 `<DSH_HOME>/line-bot/config.json`(權限 600)。
13
+
14
+ > 關於「用 LINE Business ID 登入、由面板代建 bot」:LINE 自 2024-09 起只允許在 [LINE Official Account Manager](https://manager.line.biz/) 手動建立 Messaging API channel,沒有公開 API;LINE Login 也只是身分驗證,不能取得官方帳號的管理權。所以面板不代建 bot,但會給你正確的入口連結,並把「拿到 Channel ID / Secret 之後」的所有步驟自動化(簽 token、設 webhook、測試)。
15
+
16
+ ## 安裝
17
+
18
+ 需求:DeepSeek Harness **≥ 0.2.0-rc.1**、Node.js **≥ 22**。人設外掛(大肥魚 `dsh-persona-dafeiyu`)是**選用**的——不裝也能用插件內建的自訂人設。
19
+
20
+ ### 方式一:面板安裝(推薦)
21
+
22
+ DSH → **設定 → 外掛 → 安裝 bundle**,輸入 `dsh-line-bot` 後安裝,重新整理頁面。設定左側就會多一個 **LINE Bot**。
23
+
24
+ ### 方式二:npx 一鍵安裝
25
+
26
+ 先完全結束 DSH(桌面版關閉視窗後,確認選單列/程序沒有殘留),再執行:
27
+
28
+ ```bash
29
+ npx dsh-line-bot install
30
+ ```
31
+
32
+ 它會自動判斷要裝進哪個 profile(預設 `desktop`,可用 `--profile <名稱>` 指定),用 DSH 內建的 pnpm 安裝套件、把 bundle 加進 profile,最後提醒你重開 DSH。
33
+
34
+ ```bash
35
+ npx dsh-line-bot status # 看目前裝在哪個 profile、有沒有啟用
36
+ npx dsh-line-bot uninstall # 移除(設定與憑證會保留)
37
+ ```
38
+
39
+ ### 方式三:手動
40
+
41
+ ```bash
42
+ dsh plugin --profile desktop add dsh-line-bot
43
+ ```
44
+
45
+ 再把 `dsh-line-bot` 加進 `~/.dsh/profiles/desktop/package.json` 的 `dsh.profile.bundles` 陣列(可參考 `npx dsh-line-bot install` 的做法),或改用面板安裝。
46
+
47
+ > 還沒發佈到 npm 時,也可以直接從 GitHub 安裝:
48
+ >
49
+ > ```bash
50
+ > npx github:Hanson0901/dsh-line-bot install --spec github:Hanson0901/dsh-line-bot
51
+ > ```
52
+ >
53
+ > 或從原始碼:`git clone https://github.com/Hanson0901/dsh-line-bot.git`,在 DSH 面板「安裝 bundle」選那個資料夾,或 `dsh plugin --profile desktop add /path/to/dsh-line-bot`。
54
+
55
+ ## 第一次上線(5 步)
56
+
57
+ 1. **建立/取得憑證**:到 [LINE Official Account Manager](https://manager.line.biz/) 建立官方帳號 → [LINE Developers Console](https://developers.line.biz/console/) 建立(或選用)Messaging API channel → 在「Messaging API」分頁把 **Use webhook** 打開 → 複製 **Channel ID** 與 **Channel secret**。
58
+ 2. **填入面板**:「LINE Bot → LINE 憑證」貼上 Channel ID、Channel Secret,按「儲存變更」→「驗證並讀取 bot 資訊」。成功會看到 bot 名稱與 Basic ID。
59
+ 3. **啟動 webhook 伺服器**:面板「狀態總覽」按「啟動全部」,或只按「啟動」讓 webhook server 與 tunnel 都起來(預設會自動啟動)。
60
+ 4. **開隧道**:Quick tunnel 直接按「啟動隧道」;要固定網址就在「Cloudflare Tunnel」貼 API Token →「驗證 API Token」→「建立/更新隧道與 DNS」(API Token 權限:Account → Cloudflare Tunnel → Edit、Zone → DNS → Edit、Zone → Zone → Read)。quick tunnel 每次重啟網址都會變(插件會自動把新網址寫回 LINE webhook);具名隧道的網址則固定。
61
+ 5. **自動設定 webhook**:隧道就緒後會自動 PUT 到 LINE;若沒成功,按「把隧道網址套到 webhook」再「測試 webhook」。之後在 LINE 加 bot 好友、傳訊息即可。
62
+
63
+ ## 面板各區
64
+
65
+ 面板最上方是**固定(sticky)儲存列**:捲到任何位置都看得到「儲存變更/重新整理」;儲存列下方是二級快速選單,按一下就會跳到對應區塊(捲動時也會自動高亮目前區塊)。
66
+
67
+ | 區塊 | 內容 |
68
+ | --- | --- |
69
+ | 狀態總覽 | webhook server/隧道/LINE/人設/主動傳訊的綠燈狀態、公開網址、一鍵上線與啟停 |
70
+ | LINE 憑證 | Channel ID、Channel Secret、Access Token、自動簽發、loading 動畫、bot 資訊、webhook 套用/測試、白名單 |
71
+ | Cloudflare Tunnel | quick/API Token 模式、Token、hostname、隧道名稱、Account/Zone ID、下載 cloudflared、啟停、自動設定 webhook、自行提供的公開網址 |
72
+ | 回覆人設 | 選擇人設(大肥魚或插件自訂)、新增/編輯/刪除自訂人設、把目前人設複製成自訂、強制套用、允許使用者自己換人設、切換大肥魚 session 模式、測試 Agent 回覆 |
73
+ | 主動傳訊 | 開關、機率、間隔、每日/每月上限、最短間隔、安靜時段、idle、指定對象、提示詞、立即試傳、統計 |
74
+ | 使用者 | 每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
75
+ | 進階設定 | 連接埠、Agent provider/model/effort/cwd/逾時、一人一個工作資料夾(含根目錄)、歡迎/錯誤/空回覆、日誌、開發用 host 重新載入 |
76
+
77
+ ## 設定檔
78
+
79
+ - `<DSH_HOME>/line-bot/config.json`:所有設定,含 LINE secret/Cloudflare token(權限 600,不會回傳明文給面板)。
80
+ - `<DSH_HOME>/line-bot/state.json`:LINE 使用者 ↔ DSH session 對應、訊息數、主動傳訊計數、webhook 狀態。
81
+ - `<DSH_HOME>/line-bot/logs/YYYY-MM-DD.log`:每日日誌(面板也會顯示最近 400 行)。
82
+
83
+ `DSH_HOME` 預設是 `~/.dsh`。
84
+
85
+ ## 運作方式
86
+
87
+ 1. LINE 把訊息 POST 到 Cloudflare 隧道 → 插件 webhook server(驗章後立刻回 200)。
88
+ 2. 依 `userId` 找到(或建立)對應的 DSH session 與工作資料夾,決定這個 session 的人設:選大肥魚時透過它的 HTTP bridge 指定 session 人設;選插件自訂人設時,先請大肥魚在該 session 讓開(`none`),再由插件把自訂人設注入 system prompt。
89
+ 3. 用 `agents.create`/`agents.resume` 在同一個 DSH 行程內建立/接續 Agent,`followup` 一則使用者訊息,等到 turn 結束、子代理收斂、session flush。
90
+ 4. 從 session 事件取出最後一段 assistant 文字,用 LINE reply 回覆;reply token 過期就自動改 push。
91
+ 5. 主動傳訊排程器每分鐘檢查一次(間隔、安靜時段、上限、機率),符合條件就請 Agent 主動說一句,再 push 出去。
92
+
93
+ ## 人設來源
94
+
95
+ - **大肥魚(選用)**:有安裝 `dsh-persona-dafeiyu` 時,面板會列出它的人設(內建+你自訂的),LINE 會逐 session 指定,不會改到 GUI 其他對話。需要它處於 `session` 模式;面板會提示並可一鍵切換。
96
+ - **插件內建自訂人設**:不需要任何個性外掛。面板「回覆人設 → 自訂人設」可新增/編輯/刪除,資料存在 `<DSH_HOME>/line-bot/personas.json`(0600)。選到自訂人設時,插件會請大肥魚在該 session 停用(若它有安裝),再把自訂人設注入這個 agent 的 system prompt。
97
+ - 兩種人設可以在同一個下拉選單裡選;使用者自己用 LINE 指令切換時也一樣(`人設`、`人設 2`、`人設 名字`)。
98
+
99
+ ## 每位使用者各自的記憶與人設
100
+
101
+ - **記憶不共用**:每個 LINE 使用者都有自己的 DSH session,以及自己的 Agent 工作資料夾 `<DSH_HOME>/line-bot/users/<LINE userId>/`。Agent 看到的檔案、`AGENTS.md`、工作區記憶都是該使用者專屬的,不會讀到別人的。
102
+ 可在「進階設定 → 一人一個資料夾」關閉(關閉後全部共用 `agent.cwd`,等於回到舊行為);也能用 `agent.workspaceRoot` 指定放哪個根目錄。
103
+ - **人設可自選**:「回覆人設 → 允許使用者自己換人設」開啟時,LINE 使用者可以傳:
104
+ - `人設`(或 `人格`/`persona`)→ 列出可用人設與目前使用中的人設。
105
+ - `人設 2` → 用清單編號切換;`人設 阿茶` → 用名字切換;`人設 預設` → 回到面板預設。
106
+ 切換只影響該使用者,下一次回覆就會套用;管理員也可以直接在「使用者」表的人設下拉幫他改。
107
+ - **清空記憶**:使用者表按「清空記憶」會刪掉該使用者的 session 與專屬工作資料夾(含裡面的檔案),重建後從空白開始;只按「重設 session」則保留資料夾。
108
+
109
+ ## 貼吧學習自動同步
110
+
111
+ 「刷貼吧」外掛每天最多演化一次自己的語氣(存在 `<DSH_HOME>/tieba-duty/persona.json`),這和 LINE 用的大肥魚人設(`<DSH_HOME>/storages/dsh-persona-dafeiyu/personas.json`)原本是兩套、互不相通。這個外掛可以把它接起來:
112
+
113
+ - 面板「回覆人設 → 貼吧學習同步」可設定開關、同步目標人設(只能選大肥魚的人設;預設關閉,請先選一個)、檢查間隔(預設 60 分鐘)、是否同步性格數值、寫入後是否重載大肥魚外掛。這項功能需要安裝大肥魚外掛。
114
+ - 每次檢查會比對貼吧人設版本;有新版本才動作。寫入時只替換目標人設正文中的 `## 貼吧學來的口氣` 段落(沒有就附加),不會覆蓋其他手寫內容;性格數值(溫柔/毒舌/抽象/話癆/發圖傾向)可一併更新。
115
+ - 寫入前會備份到 `<DSH_HOME>/line-bot/persona-backups/personas-<時間>.json`。
116
+ - 寫入後預設會透過 Cordis loader 重啟 `dsh-persona-dafeiyu`,讓它重新讀取 store;重啟失敗只會記警告,人設文字仍已寫入,下次外掛重載時生效。
117
+ - 貼吧外掛若沒在跑、或貼吧人設不存在,檢查會跳過(`pending` 顯示尚未同步的原因)。
118
+
119
+ ## 疑難排解
120
+
121
+ - **面板顯示「無法連線到 LINE Bot 執行期」**:webServer 可能還沒就緒,重新整理即可;仍失敗就看 DSH 日誌裡 `[line-bot]` 的訊息。
122
+ - **webhook 測試失敗**:確認 `Use webhook` 已在 LINE console 打開、隧道網址是 https、且本機 webhook server 正在跑(面板綠燈)。
123
+ - **改了 lib/*.mjs 沒生效**:按「進階設定 → 重新載入 host 模組(開發用)」,不用重裝插件。
124
+ - **貼吧同步沒動作**:先看面板同步狀態的 `lastError`;確認貼吧外掛的人設檔存在、目標人設 id 正確。按「預覽差異」可比對目前段落與即將寫入的內容。
125
+ - **cloudflared 找不到**:按「下載 cloudflared」,插件會抓官方 release 放到 `<DSH_HOME>/line-bot/bin/`;也可以自己放一份並在 `tunnel.binaryPath` 指定。
126
+ - **Agent 回覆 403(模型只開放給 Codex)**:在「進階設定 → Agent 模型」把 provider/model 改成 `deepseek-account` / `deepseek-flash`(本機預設已設好),儲存後再測一次。
127
+ - **人設沒套用**:選大肥魚時,它必須是 `session` 模式;面板會提示,按「切換到大肥魚 session 模式」即可(這是它官方支援的設定,只影響「可不可以逐 session 指定人設」)。選插件自訂人設則不受此限制。
128
+ - **Cloudflare 回 530 / error code 1016**:具名隧道的 DNS CNAME 目標必須是 `<tunnel-id>.cfargotunnel.com`(不是 `.cfd.tunnel`);另外該子網域若同時被 A 記錄佔用,邊緣會先回那台主機,請確認 DNS 只留一筆 CNAME。
129
+
130
+ ## 隱私
131
+
132
+ - 這個插件不連任何第三方伺服器(除了你設定的 Cloudflare API、LINE API)。沒有遙測、沒有帳號。
133
+ - 所有設定與憑證只存在本機 `<DSH_HOME>/line-bot/config.json`(權限 600);面板只顯示遮罩後的值。
134
+ - LINE 使用者的 session、工作資料夾與人設選擇都存在本機,不會上傳。
135
+ - 倉庫與 npm 套件不含任何人的憑證、LINE userId、Cloudflare 帳號或網域。
136
+
137
+ ## 安全提醒
138
+
139
+ - 這等同把 DSH Agent 接到公開的 LINE 帳號:拿到 bot 的人可以讓 Agent 使用你電腦上的工具。建議在「白名單使用者 ID」填入自己的 LINE userId,只允許自己使用。
140
+ - 所有憑證都存在本機 `config.json`(600),面板只會看到遮罩後的值;API 只掛在 `127.0.0.1` 的 DSH webServer 上。
141
+ - Cloudflare API Token 建議只給必要權限,並在不用時撤銷。
142
+
143
+ ## 開發
144
+
145
+ - host 邏輯:`lib/*.mjs`(`util`/`config`/`line-api`/`persona`/`agent-runner`/`webhook-server`/`cloudflare`/`tunnel`/`nudge`/`tieba-sync`/`host`)。
146
+ - 面板:`client.js`(`window.__ModuleLoader__` 註冊 `settings.section`)。
147
+ - 進入點:`index.js`(掛 `/api/line-bot`、dev reload、卸載清理)。
package/bin/cli.mjs ADDED
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env node
2
+ // dsh-line-bot 安裝小工具。
3
+ //
4
+ // 用法:
5
+ // npx dsh-line-bot install [--profile <名稱>] [--version <版本>] [--dry-run]
6
+ // npx dsh-line-bot uninstall [--profile <名稱>] [--dry-run]
7
+ // npx dsh-line-bot status [--profile <名稱>]
8
+ //
9
+ // 它做的事和 DSH 面板的「安裝 bundle」一樣:
10
+ // 1. 用 DSH 內建的 pnpm 把套件加進 profile;
11
+ // 2. 把套件名稱寫進 profile package.json 的 dsh.profile.bundles;
12
+ // 3. 提示重開 DSH。
13
+ // 不需要 DSH 正在執行;建議先關掉 DSH 再安裝,避免 node_modules 被同時使用。
14
+
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+ import { spawnSync } from 'node:child_process'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ const PKG = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
22
+ const NAME = PKG.name || 'dsh-line-bot'
23
+
24
+ function out(line = '') {
25
+ process.stdout.write(`${line}\n`)
26
+ }
27
+ function fail(message) {
28
+ process.stderr.write(`\n✖ ${message}\n`)
29
+ process.exit(1)
30
+ }
31
+
32
+ function parseArgs(argv) {
33
+ const options = { command: '', profile: '', version: '', spec: '', dryRun: false, enable: true }
34
+ const rest = [...argv]
35
+ options.command = (rest.shift() || 'help').toLowerCase()
36
+ for (let index = 0; index < rest.length; index += 1) {
37
+ const arg = rest[index]
38
+ if (arg === '--profile' || arg === '-p') {
39
+ options.profile = rest[index + 1] || ''
40
+ index += 1
41
+ } else if (arg.startsWith('--profile=')) {
42
+ options.profile = arg.slice('--profile='.length)
43
+ } else if (arg === '--version' || arg === '-v') {
44
+ options.version = rest[index + 1] || ''
45
+ index += 1
46
+ } else if (arg.startsWith('--version=')) {
47
+ options.version = arg.slice('--version='.length)
48
+ } else if (arg === '--dry-run' || arg === '-n') {
49
+ options.dryRun = true
50
+ } else if (arg === '--spec') {
51
+ options.spec = rest[index + 1] || ''
52
+ index += 1
53
+ } else if (arg.startsWith('--spec=')) {
54
+ options.spec = arg.slice('--spec='.length)
55
+ } else if (arg === '--no-enable') {
56
+ options.enable = false
57
+ } else if (arg === '--help' || arg === '-h') {
58
+ options.command = 'help'
59
+ } else {
60
+ fail(`不認得的參數:${arg}`)
61
+ }
62
+ }
63
+ return options
64
+ }
65
+
66
+ function dshHome() {
67
+ return process.env.DSH_HOME ? path.resolve(process.env.DSH_HOME) : path.join(os.homedir(), '.dsh')
68
+ }
69
+
70
+ function listProfiles() {
71
+ const dir = path.join(dshHome(), 'profiles')
72
+ if (!fs.existsSync(dir)) return []
73
+ return fs
74
+ .readdirSync(dir, { withFileTypes: true })
75
+ .filter((entry) => entry.isDirectory())
76
+ .filter((entry) => fs.existsSync(path.join(dir, entry.name, 'package.json')))
77
+ .map((entry) => entry.name)
78
+ .sort()
79
+ }
80
+
81
+ function pickProfile(requested) {
82
+ const wanted = requested || process.env.DSH_PROFILE || ''
83
+ const profiles = listProfiles()
84
+ if (wanted) {
85
+ if (!profiles.includes(wanted)) {
86
+ fail(`找不到 profile「${wanted}」。可用的 profile:${profiles.join('、') || '(沒有)'}`)
87
+ }
88
+ return wanted
89
+ }
90
+ for (const candidate of ['desktop', 'web', 'headless', 'tui']) {
91
+ if (profiles.includes(candidate)) return candidate
92
+ }
93
+ if (profiles.length === 1) return profiles[0]
94
+ fail(`偵測不到要安裝到哪個 profile,請加 --profile <名稱>。可用:${profiles.join('、') || '(沒有)'}`)
95
+ }
96
+
97
+ function profileDir(profile) {
98
+ return path.join(dshHome(), 'profiles', profile)
99
+ }
100
+
101
+ function readManifest(profile) {
102
+ const dir = profileDir(profile)
103
+ const file = path.join(dir, 'package.json')
104
+ if (!fs.existsSync(file)) fail(`profile 不存在:${dir}`)
105
+ try {
106
+ return { dir, file, manifest: JSON.parse(fs.readFileSync(file, 'utf8')) }
107
+ } catch (error) {
108
+ fail(`讀不懂 ${file}:${error.message}`)
109
+ }
110
+ }
111
+
112
+ function writeManifestAtomic(file, manifest) {
113
+ const backup = `${file}.bak-${Date.now()}`
114
+ fs.copyFileSync(file, backup)
115
+ const tmp = `${file}.tmp-${process.pid}`
116
+ fs.writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`)
117
+ fs.renameSync(tmp, file)
118
+ return backup
119
+ }
120
+
121
+ function bundlesOf(manifest) {
122
+ const bundles = manifest?.dsh?.profile?.bundles
123
+ return Array.isArray(bundles) ? [...bundles] : []
124
+ }
125
+
126
+ function withBundle(manifest, enabled) {
127
+ const bundles = bundlesOf(manifest)
128
+ if (enabled) {
129
+ if (!bundles.includes(NAME)) bundles.push(NAME)
130
+ } else {
131
+ const index = bundles.indexOf(NAME)
132
+ if (index >= 0) bundles.splice(index, 1)
133
+ }
134
+ return {
135
+ ...manifest,
136
+ dsh: {
137
+ ...(manifest.dsh || {}),
138
+ profile: { ...(manifest.dsh?.profile || {}), bundles },
139
+ },
140
+ }
141
+ }
142
+
143
+ function findPnpm() {
144
+ const localPnpm = process.platform === 'darwin'
145
+ ? '/Applications/DeepSeek Harness.app/Contents/Resources/runtime/primary-runtime/dependencies/pnpm/bin/pnpm.cjs'
146
+ : ''
147
+ if (localPnpm && fs.existsSync(localPnpm)) return { kind: 'pnpm', command: process.execPath, prefix: [localPnpm] }
148
+ const pnpm = spawnSync('pnpm', ['--version'], { stdio: 'ignore' })
149
+ if (pnpm.status === 0) return { kind: 'pnpm', command: 'pnpm', prefix: [] }
150
+ return null
151
+ }
152
+
153
+ function findDsh() {
154
+ const probe = spawnSync('dsh', ['--version'], { stdio: 'ignore' })
155
+ return probe.status === 0 ? 'dsh' : ''
156
+ }
157
+
158
+ function runInstall(profile, versionSpec, { dryRun }) {
159
+ const dir = profileDir(profile)
160
+ const dsh = findDsh()
161
+ let result
162
+ if (dsh) {
163
+ const args = ['plugin', '--profile', profile, 'add', versionSpec]
164
+ out(`→ ${dsh} ${args.join(' ')}`)
165
+ if (!dryRun) result = spawnSync(dsh, args, { cwd: dir, stdio: 'inherit' })
166
+ } else {
167
+ const pnpm = findPnpm()
168
+ if (!pnpm) {
169
+ fail(
170
+ '找不到 dsh 或 pnpm。請先安裝 DeepSeek Harness 桌面版(內含 pnpm),或改用面板「設定 → 外掛 → 安裝」。',
171
+ )
172
+ }
173
+ const args = [...pnpm.prefix, 'add', versionSpec, '--dir', dir]
174
+ out(`→ ${pnpm.command} ${args.join(' ')}`)
175
+ if (!dryRun) result = spawnSync(pnpm.command, args, { cwd: dir, stdio: 'inherit' })
176
+ }
177
+ if (!dryRun && result && result.status !== 0) {
178
+ fail(`安裝套件失敗(pnpm 結束碼 ${result.status})。請確認網路與 ${dir} 的權限後重試。`)
179
+ }
180
+ }
181
+
182
+ function commandInstall(options) {
183
+ const profile = pickProfile(options.profile)
184
+ const { dir, file, manifest } = readManifest(profile)
185
+ const versionSpec = options.spec || (options.version ? `${NAME}@${options.version}` : `${NAME}@${PKG.version}`)
186
+ out('')
187
+ out(`dsh-line-bot ${PKG.version}`)
188
+ out(`profile:${profile}(${dir})`)
189
+ out(`套件:${versionSpec}`)
190
+ if (options.dryRun) out('(dry-run:只顯示,不真的寫入)')
191
+ out('')
192
+
193
+ runInstall(profile, versionSpec, options)
194
+
195
+ if (options.enable) {
196
+ const next = withBundle(manifest, true)
197
+ if (JSON.stringify(next) !== JSON.stringify(manifest)) {
198
+ if (options.dryRun) {
199
+ out(`→ 會把 ${NAME} 加進 ${file} 的 dsh.profile.bundles`)
200
+ } else {
201
+ const backup = writeManifestAtomic(file, next)
202
+ out(`✔ 已啟用 bundle,原檔備份:${path.basename(backup)}`)
203
+ }
204
+ } else {
205
+ out('• bundle 已經在啟用清單裡,略過。')
206
+ }
207
+ }
208
+
209
+ out('')
210
+ out('完成。下一步:')
211
+ out(' 1. 完全結束並重開 DeepSeek Harness(桌面版請確認選單列沒有殘留)。')
212
+ out(' 2. 設定 → LINE Bot,填入 Channel ID / Channel Secret。')
213
+ out(' 3. 按「一鍵上線」,到 LINE 加 bot 好友測試。')
214
+ }
215
+
216
+ function commandUninstall(options) {
217
+ const profile = pickProfile(options.profile)
218
+ const { dir, file, manifest } = readManifest(profile)
219
+ out('')
220
+ out(`移除 dsh-line-bot(profile:${profile})`)
221
+ if (options.dryRun) out('(dry-run:只顯示,不真的寫入)')
222
+
223
+ const next = withBundle(manifest, false)
224
+ if (JSON.stringify(next) !== JSON.stringify(manifest)) {
225
+ if (options.dryRun) out(`→ 會把 ${NAME} 從 ${file} 的 dsh.profile.bundles 移除`)
226
+ else {
227
+ const backup = writeManifestAtomic(file, next)
228
+ out(`✔ 已從 bundle 清單移除,原檔備份:${path.basename(backup)}`)
229
+ }
230
+ }
231
+
232
+ const dsh = findDsh()
233
+ if (dsh) {
234
+ const args = ['plugin', '--profile', profile, 'remove', NAME]
235
+ out(`→ ${dsh} ${args.join(' ')}`)
236
+ if (!options.dryRun) {
237
+ const result = spawnSync(dsh, args, { cwd: dir, stdio: 'inherit' })
238
+ if (result.status !== 0) out('(pnpm 移除失敗,可能本來就沒安裝;bundle 清單已處理)')
239
+ }
240
+ } else {
241
+ out('找不到 dsh 指令,請手動移除依賴,或用面板「外掛」卸載。')
242
+ }
243
+
244
+ out('')
245
+ out('重開 DSH 後生效。設定檔與 LINE 憑證會留在 <DSH_HOME>/line-bot/,不會被刪除。')
246
+ }
247
+
248
+ function commandStatus(options) {
249
+ const profile = pickProfile(options.profile)
250
+ const { dir, manifest } = readManifest(profile)
251
+ const dependency = manifest.dependencies?.[NAME] || manifest.devDependencies?.[NAME] || ''
252
+ const enabled = bundlesOf(manifest).includes(NAME)
253
+ out('')
254
+ out(`dsh-line-bot ${PKG.version}`)
255
+ out(`DSH_HOME:${dshHome()}`)
256
+ out(`profile:${profile}(${dir})`)
257
+ out(`依賴:${dependency || '(未安裝)'}`)
258
+ out(`bundle 啟用:${enabled ? '是' : '否'}`)
259
+ if (fs.existsSync(path.join(dshHome(), 'line-bot', 'config.json'))) {
260
+ out('設定檔:<DSH_HOME>/line-bot/config.json(已存在)')
261
+ }
262
+ }
263
+
264
+ function commandHelp() {
265
+ out('')
266
+ out(`dsh-line-bot ${PKG.version} — 把 LINE Bot 裝進 DeepSeek Harness`)
267
+ out('')
268
+ out('用法:')
269
+ out(' npx dsh-line-bot install 安裝並啟用(可加 --profile <名稱>)')
270
+ out(' npx dsh-line-bot uninstall 移除')
271
+ out(' npx dsh-line-bot status 看目前裝在哪個 profile')
272
+ out('')
273
+ out('選項:')
274
+ out(' --profile, -p <名稱> DSH profile(預設讀 DSH_PROFILE,否則 desktop)')
275
+ out(' --version, -v <版本> 指定要安裝的版本(預設跟這個 CLI 相同)')
276
+ out(' --spec <pnpm 規格> 直接指定套件規格,例如 github:Hanson0901/dsh-line-bot')
277
+ out(' --no-enable 只安裝套件,不寫入 bundle 清單')
278
+ out(' --dry-run, -n 只顯示會做什麼,不寫入')
279
+ out(' --help, -h 顯示這段說明')
280
+ out('')
281
+ out('不用這個 CLI 也可以:DSH 設定 → 外掛 → 安裝 bundle,輸入 dsh-line-bot。')
282
+ }
283
+
284
+ const options = parseArgs(process.argv.slice(2))
285
+ switch (options.command) {
286
+ case 'install':
287
+ case 'add':
288
+ commandInstall(options)
289
+ break
290
+ case 'uninstall':
291
+ case 'remove':
292
+ commandUninstall(options)
293
+ break
294
+ case 'status':
295
+ case 'list':
296
+ commandStatus(options)
297
+ break
298
+ case 'help':
299
+ case '--help':
300
+ commandHelp()
301
+ break
302
+ default:
303
+ commandHelp()
304
+ process.exitCode = 1
305
+ }