dsh-line-bot 0.7.2 → 0.8.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/AGENTS.md ADDED
@@ -0,0 +1,69 @@
1
+ # dsh-line-bot:給 DSH Agent 的除錯指南
2
+
3
+ 這份檔案是給「在這台電腦上除錯 LINE Bot」的 DSH Agent 看的。遇到使用者說「Bot 沒回」「叫他傳表情包沒傳」「圖片顯示不出來」時,照下面的順序做,不要先改程式。
4
+
5
+ ## 0. 先跑診斷
6
+
7
+ ```bash
8
+ node bin/cli.mjs doctor --json # 或 npx dsh-line-bot doctor --json
9
+ ```
10
+
11
+ - 伺服器在跑時,doctor 會打本機 `http://127.0.0.1:8791/doctor`,檢查:伺服器、Cloudflare Tunnel、公開網址健康、LINE webhook、dsh-meme 圖庫、表情包設定、轉圖工具、模型、圖片可送達測試、最近錯誤。
12
+ - 伺服器沒跑時會退成離線檢查(設定檔、憑證、dsh-meme、轉圖工具、日誌)。
13
+ - `--fix` 會做安全修復:重啟 tunnel、重設 webhook、重新讀 bot 資訊;不會改設定、不會刪資料。
14
+
15
+ 也可以直接看:
16
+
17
+ ```bash
18
+ curl -s http://127.0.0.1:8791/doctor | jq .
19
+ curl -s http://127.0.0.1:8791/health
20
+ ```
21
+
22
+ ## 1. 症狀對照表
23
+
24
+ | 症狀 | 先看哪個 check | 常見原因 | 安全修法 |
25
+ | --- | --- | --- | --- |
26
+ | 完全沒回覆 | `server`、`tunnel`、`webhook` | webhook 指到舊 tunnel;LINE「Use webhook」沒開;伺服器沒啟動 | `doctor --fix`;面板「套用 Webhook」;到 LINE console 開 Use webhook |
27
+ | 有回文字但沒圖片 | `dsh-meme`、`meme-config`、`public-url`、`media`、`converters` | 沒裝 dsh-meme;`stickers.enabled=false`;公開網址還沒就緒;GIF/webp 沒有轉圖工具 | 面板「表情包」開啟並看圖庫;`doctor --fix`;安裝 ffmpeg |
28
+ | 回覆出現 `[[meme:…]]`、`<meme#18>` 文字 | 版本 | 舊版不認得標記格式 | 升級到 0.6.1+(目前 0.8.x) |
29
+ | 貼圖看不懂 | `model` | 目前模型不吃圖(`inputModalities` 沒有 image) | 面板「模型與接口來源」換成有 `text+image` 的模型 |
30
+ | 群組不回 | 面板「群組」 | 沒被 @/引用、擲骰沒中、群組功能關閉 | 看面板回覆機率與開關 |
31
+ | 主動訊息沒出現 | 面板「主動傳訊」 | 機率、每日/每月上限、靜音時段、冷卻、沒有 session 的使用者 | 看 `status.nudge` 與日誌 |
32
+ | 餘額提醒沒出現 | `balance` | provider 不是 `deepseek-account`、餘額高於門檻、冷卻中、靜音 | 面板「DeepSeek 餘額」看 `applicable`、`lastAskAt` |
33
+ | Windows 安裝失敗 | CLI | 找不到 DSH 內建 pnpm;舊版 spawn `.cmd` 失敗 | 用面板安裝,或升級 CLI;必要時 `npm i -g pnpm` |
34
+ | npm `ETARGET` | — | registry/CDN 還沒傳播 | 等 1–5 分鐘再試 |
35
+
36
+ ## 2. 圖片沒送出的排查順序
37
+
38
+ 1. `dsh-meme` 是否可用、`count > 0`:`curl -s localhost:8791/doctor`。
39
+ 2. 面板「表情包」是否開啟、`maxPerReply >= 1`。
40
+ 3. `public-url` 與 `media` 是否 OK:`media` 會真的組一則 Flex 圖片並抓 `/media/<token>`。
41
+ 4. 看日誌關鍵字:`找不到表情包`、`表情包格式不支援`、`公開網址尚未就緒`、`LINE 400`。
42
+ 5. 轉圖工具:macOS 有 `sips`;GIF/APNG 需要 `ffmpeg`;Windows 沒有 `sips`,webp/gif 需要 `ffmpeg`。
43
+ 6. 模型有沒有照格式輸出?面板「測試 Agent 回覆」或 `meme-preview` 可驗證;支援標記:`[[meme:描述]]`、`[[meme:#編號]]`、`<meme#編號>`、`[表情: 描述]`。
44
+
45
+ ## 3. 檔案與端點
46
+
47
+ - `lib/host.mjs`:主要流程(webhook 事件、回覆、tunnel、人設、記憶、表情包、診斷)。
48
+ - `lib/line-api.mjs`:LINE API(reply/push/loading/webhook endpoint)。
49
+ - `lib/tunnel.mjs`:cloudflared 管理。
50
+ - `lib/meme-source.mjs`:讀 dsh-meme。
51
+ - `lib/chat-import.mjs`:LINE 匯出聊天解析。
52
+ - `lib/diagnostics.mjs`:doctor 用的檢查工具。
53
+ - `bin/cli.mjs`:安裝/`doctor`。
54
+ - HTTP:`/api/line-bot/status|config|action|doctor`(webServer)、`http://127.0.0.1:<port>/doctor|health|media/<id>|webhook`(外掛自己的伺服器)。
55
+ - 資料:`$DSH_HOME/line-bot/`(config.json 600、state.json、memories.json、logs、personas、cache、users)。
56
+
57
+ ## 4. 安全規則
58
+
59
+ - 不要把 `config.json`、state、token、userId 貼進回覆或 commit;doctor 輸出已避開秘密。
60
+ - 不要手改 `~/.dsh/profiles/*`;要改設定用面板或 `POST /api/line-bot/config`。
61
+ - 不要在未經使用者確認時清空記憶、群組或工作資料夾。
62
+ - `doctor --fix` 只會重連 tunnel/重設 webhook;其他修復請照 check 的 `fix` 指示。
63
+ - 改程式後:`node --check`,再 `POST /api/line-bot/dev/reload`(等 tunnel 換網址後重新套 webhook),最後再跑一次 `doctor` 驗證。
64
+
65
+ ## 5. 修好後要回報什麼
66
+
67
+ - 哪個 check 從 fail/warn 變成 ok。
68
+ - 做了哪個安全修復(例如「重設 webhook 到新 tunnel」)。
69
+ - 若需要使用者操作(LINE console 開 Use webhook、安裝 ffmpeg、換模型),明確列出來。
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ - 內建診斷:`GET /doctor`、`npx dsh-line-bot doctor [--fix] [--json]`,檢查伺服器/tunnel/公開網址/webhook/dsh-meme/表情包設定/轉圖工具/模型/圖片可送達/最近錯誤。
6
+ - 面板「進階設定」新增「執行診斷」與「自動修復(安全)」。
7
+ - 修正:Flex 圖片訊息(`hero.url`)在診斷中會被正確辨識;表情包描述改用圖庫樣本比對,長描述也能命中。
8
+ - 新增 `AGENTS.md`(給 DSH Agent 的除錯流程)與 `TROUBLESHOOTING.md`(症狀對照)。
9
+
3
10
  ## 0.7.2
4
11
 
5
12
  - 匯入聊天時若成員還沒對應到 LINE 帳號,先用「名字」記住;本人第一次在群組發言時,這些記憶也會一起帶入。
package/README.en.md CHANGED
@@ -206,6 +206,11 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
206
206
  - The panel's "Memes" card can disable the feature, limit images per reply (1–3), and preview library samples. To add or re-tag images, use dsh-meme's own settings.
207
207
  - When a LINE sticker arrives, the bot fetches the sticker image and shows it to the model (vision-capable models read the image; otherwise the bot falls back to the keywords LINE provides), so the persona can react to the mood. Turn "Understand stickers" off in the LINE credentials card to use keywords only.
208
208
  - Without dsh-meme installed, the feature stays off and replies are text only.
209
+ ## Diagnostics
210
+ - Panel → Advanced → Run diagnostics checks the server, tunnel, public URL, webhook, dsh-meme library, image converters, model, and recent errors; Safe repair only reconnects the tunnel and re-applies the webhook.
211
+ - CLI: `npx dsh-line-bot doctor`, `doctor --fix`, `doctor --json`.
212
+ - Agent playbook: `AGENTS.md`; symptom table: `TROUBLESHOOTING.md`.
213
+
209
214
  ## Troubleshooting
210
215
 
211
216
  - **The panel says "cannot connect to the LINE Bot runtime"**: webServer may not be ready yet; refresh. If it still fails, look for `[line-bot]` messages in the DSH logs.
package/README.ja.md CHANGED
@@ -206,6 +206,11 @@ Bot がグループ(または複数人のルーム)に追加されたとき
206
206
  - パネルの「表情パック」カードでオンオフ、1 回あたりの上限(1〜3)、ライブラリのサンプル表示ができます。画像の追加や分類の変更は dsh-meme 側の設定で行います。
207
207
  - LINE スタンプを受信すると、スタンプ画像を取得してモデルに見せます(画像対応モデルはそのまま読み、非対応なら LINE のキーワードを使います)。「LINE 認証情報」カードの「スタンプを理解する」でオフにできます。
208
208
  - dsh-meme がインストールされていない場合、この機能は無効になりテキストのみ返します。
209
+ ## 診断
210
+ - パネルの「詳細設定 → 診断を実行」でサーバー、Tunnel、公開 URL、webhook、dsh-meme 図庫、画像変換ツール、モデル、最近のエラーを確認できます。「安全な修復」は tunnel 再接続と webhook 再設定だけを行います。
211
+ - CLI:`npx dsh-line-bot doctor`、`doctor --fix`、`doctor --json`。
212
+ - Agent 向け手順は `AGENTS.md`、症状別は `TROUBLESHOOTING.md`。
213
+
209
214
  ## トラブルシューティング
210
215
 
211
216
  - **パネルに「LINE Bot ランタイムに接続できません」と出る**:webServer がまだ準備できていない可能性があります。再読み込みしてください。それでも失敗する場合は DSH ログの `[line-bot]` を確認します。
package/README.ko.md CHANGED
@@ -206,6 +206,11 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
206
206
  - 패널의 「이모티콘」 카드에서 켜고 끄기, 답장당 최대 장수(1~3), 라이브러리 샘플 미리보기를 할 수 있습니다. 이미지 추가나 분류 변경은 dsh-meme 설정에서 하세요.
207
207
  - LINE 스티커를 받으면 스티커 이미지를 가져와 모델에 보여 줍니다(이미지 지원 모델은 바로 읽고, 아니면 LINE 키워드로 대체). 「LINE 자격 증명」 카드에서 「스티커 이해」를 끌 수 있습니다.
208
208
  - dsh-meme이 설치되어 있지 않으면 이 기능은 꺼지고 텍스트만 답장합니다.
209
+ ## 진단
210
+ - 패널의 "고급 설정 → 진단 실행"에서 서버, Tunnel, 공개 URL, webhook, dsh-meme 라이브러리, 이미지 변환 도구, 모델, 최근 오류를 확인할 수 있습니다. "안전 복구"는 tunnel 재연결과 webhook 재설정만 수행합니다.
211
+ - CLI: `npx dsh-line-bot doctor`, `doctor --fix`, `doctor --json`.
212
+ - Agent용 절차는 `AGENTS.md`, 증상별 안내는 `TROUBLESHOOTING.md`.
213
+
209
214
  ## 문제 해결
210
215
 
211
216
  - **패널에 "LINE Bot 런타임에 연결할 수 없습니다"**: webServer가 아직 준비되지 않았을 수 있습니다. 새로 고침하세요. 그래도 실패하면 DSH 로그의 `[line-bot]` 메시지를 확인합니다.
package/README.md CHANGED
@@ -206,6 +206,11 @@ Bot 被加進群組(或多人聊天室)後:
206
206
  - 面板「表情包」卡可以開關、限制一次最多幾張(1~3),並預覽圖庫樣本。要新增圖片或改分類,請用 dsh-meme 自己的設定。
207
207
  - 收到 LINE 貼圖時,會抓貼圖圖片交給模型看(支援圖片的模型直接讀圖,否則用 LINE 提供的關鍵字),角色才接得住貼圖的情緒。可在「LINE 憑證」卡關閉「看得懂貼圖」。
208
208
  - 沒安裝 dsh-meme 時這個功能會自動停用,只回文字。
209
+ ## 診斷與修復
210
+ - 面板「進階設定 → 執行診斷」會檢查伺服器、Tunnel、公開網址、webhook、表情包圖庫、轉圖工具、模型與最近錯誤;「自動修復(安全)」只會重連 tunnel、重設 webhook。
211
+ - 終端機:`npx dsh-line-bot doctor`、`npx dsh-line-bot doctor --fix`、`doctor --json`。
212
+ - 給 Agent 的除錯流程在 `AGENTS.md`,症狀對照在 `TROUBLESHOOTING.md`。
213
+
209
214
  ## 疑難排解
210
215
 
211
216
  - **面板顯示「無法連線到 LINE Bot 執行期」**:webServer 可能還沒就緒,重新整理即可;仍失敗就看 DSH 日誌裡 `[line-bot]` 的訊息。
package/README.zh-CN.md CHANGED
@@ -206,6 +206,11 @@ Bot 被加入群组(或多人聊天室)后:
206
206
  - 面板「表情包」卡可以开关、限制每次最多几张(1~3),并预览图库样本。要新增图片或改分类,请用 dsh-meme 自己的设置。
207
207
  - 收到 LINE 贴图时,会抓贴图图片交给模型看(支持图片的模型直接读图,否则用 LINE 提供的关键词),角色才能接住贴图的情绪。可在「LINE 凭证」卡关闭「看得懂贴图」。
208
208
  - 没安装 dsh-meme 时该功能会自动停用,只回文字。
209
+ ## 诊断与修复
210
+ - 面板「高级设置 → 执行诊断」会检查服务器、Tunnel、公开网址、webhook、表情包图库、转图工具、模型与最近错误;「自动修复(安全)」只会重连 tunnel、重设 webhook。
211
+ - 终端:`npx dsh-line-bot doctor`、`npx dsh-line-bot doctor --fix`、`doctor --json`。
212
+ - 给 Agent 的调试流程在 `AGENTS.md`,症状对照在 `TROUBLESHOOTING.md`。
213
+
209
214
  ## 疑难排解
210
215
 
211
216
  - **面板显示「无法连接到 LINE Bot 运行时」**:webServer 可能还没就绪,刷新即可;仍失败就看 DSH 日志里 `[line-bot]` 的消息。
@@ -0,0 +1,55 @@
1
+ # 疑難排解
2
+
3
+ 先跑一次診斷,把結果貼給 DSH 的 Agent,它就能照 `AGENTS.md` 的流程修:
4
+
5
+ ```bash
6
+ npx dsh-line-bot doctor
7
+ npx dsh-line-bot doctor --fix # 安全修復:重連 tunnel、重設 webhook
8
+ ```
9
+
10
+ 面板「進階設定」也有「執行診斷」與「自動修復(安全)」。
11
+
12
+ ## 常見症狀
13
+
14
+ ### Bot 完全沒回
15
+ - 檢查 `server`、`tunnel`、`webhook` 三個檢查。
16
+ - LINE console → Messaging API → Webhook 設定要開「Use webhook」。
17
+ - quick tunnel 每次重啟會換網址;`doctor --fix` 會把 webhook 指到目前網址。
18
+ - LINE 不會重送失敗的 webhook,斷線期間的訊息會直接遺失。
19
+
20
+ ### 回文字但沒有圖片
21
+ - 面板「表情包」要開啟,且偵測到 `dsh-meme` 圖庫(`count > 0`)。
22
+ - `public-url`、`media` 檢查要 OK;quick tunnel 剛換網址的前幾秒 LINE 抓不到圖,會先不附圖。
23
+ - GIF/webp 需要轉圖:macOS 內建 `sips`,GIF/APNG 與 Windows 建議裝 `ffmpeg`。
24
+ - 日誌關鍵字:`找不到表情包`、`表情包格式不支援`、`公開網址尚未就緒`。
25
+ - 用面板「測試 Agent 回覆」或 `meme-preview` 確認模型有輸出標記。
26
+
27
+ ### 回覆裡出現 `[[meme:…]]` 或 `<meme#18>`
28
+ - 舊版不認得標記格式;升級到 0.6.1 以上(目前 0.8.x)。
29
+
30
+ ### 貼圖看不懂
31
+ - 需要模型支援圖片(`inputModalities` 含 `image`)。面板「模型與接口來源」可換模型。
32
+ - 模型不支援時仍會用貼圖關鍵字,不會壞掉。
33
+
34
+ ### APNG 不會動
35
+ - LINE 不播放 APNG,只顯示第一幀。保持 `stickers.apng=false` 即可。
36
+
37
+ ### Windows 安裝失敗
38
+ - 優先用 DSH 面板「設定 → 外掛 → 安裝 bundle」。
39
+ - 或用新版 CLI(0.3.1+)並先 `npm install -g pnpm`;沒有 `sips` 時裝 ffmpeg。
40
+
41
+ ### 群組不回
42
+ - 面板「群組」:被 @/引用一定回;其他訊息依機率擲骰(預設 15%)。
43
+ - 群組要開「在群組裡運作」。
44
+
45
+ ### 主動訊息沒出現
46
+ - 面板「主動傳訊」:機率、每日/每月上限、靜音時段、閒置分鐘、冷卻。
47
+ - 沒有對話過的使用者不會被主動找。
48
+
49
+ ### 餘額提醒沒出現
50
+ - 只有 `provider = deepseek-account` 才適用。
51
+ - 餘額要高於門檻才會提醒;冷卻(預設 12 小時)與靜音時段會跳過。
52
+ - 面板「DeepSeek 餘額」可看 `applicable`、`lastAskAt`、`nextCheckAt`。
53
+
54
+ ### npm 安裝出現 ETARGET
55
+ - registry/CDN 傳播延遲,等 1–5 分鐘;必要時 `npm cache clean --force`。
package/bin/cli.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  // npx dsh-line-bot install [--profile <名稱>] [--version <版本>] [--dry-run]
6
6
  // npx dsh-line-bot uninstall [--profile <名稱>] [--dry-run]
7
7
  // npx dsh-line-bot status [--profile <名稱>]
8
+ // npx dsh-line-bot doctor [--fix] [--json]
8
9
  //
9
10
  // 它做的事和 DSH 面板的「安裝 bundle」一樣:
10
11
  // 1. 用 DSH 內建的 pnpm 把套件加進 profile;
@@ -21,6 +22,8 @@ import readline from 'node:readline/promises'
21
22
  const IS_WIN = process.platform === 'win32'
22
23
  import { fileURLToPath } from 'node:url'
23
24
 
25
+ import { whichCommand, scanLogForErrors, summarizeChecks, check } from '../lib/diagnostics.mjs'
26
+
24
27
  const PKG = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
25
28
  const NAME = PKG.name || 'dsh-line-bot'
26
29
 
@@ -40,7 +43,7 @@ function fail(message) {
40
43
  }
41
44
 
42
45
  function parseArgs(argv) {
43
- const options = { command: '', profile: '', version: '', spec: '', dryRun: false, enable: true, companions: 'ask' }
46
+ const options = { command: '', profile: '', version: '', spec: '', dryRun: false, enable: true, companions: 'ask', fix: false, json: false }
44
47
  const rest = [...argv]
45
48
  options.command = (rest.shift() || 'help').toLowerCase()
46
49
  for (let index = 0; index < rest.length; index += 1) {
@@ -68,6 +71,10 @@ function parseArgs(argv) {
68
71
  options.companions = 'yes'
69
72
  } else if (arg === '--no-companions') {
70
73
  options.companions = 'no'
74
+ } else if (arg === '--fix') {
75
+ options.fix = true
76
+ } else if (arg === '--json') {
77
+ options.json = true
71
78
  } else if (arg === '--help' || arg === '-h') {
72
79
  options.command = 'help'
73
80
  } else {
@@ -289,6 +296,85 @@ async function setupCompanions(profile, options) {
289
296
  out('✔ 搭配外掛安裝完成。')
290
297
  }
291
298
 
299
+
300
+ async function httpJson(url, timeout = 45000) {
301
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeout) })
302
+ const body = await res.json().catch(() => null)
303
+ if (!res.ok || !body?.ok) throw new Error(body?.error || `HTTP ${res.status}`)
304
+ return body.value
305
+ }
306
+
307
+ function offlineDoctor(dataDir, reason) {
308
+ const checks = []
309
+ const configPath = path.join(dataDir, 'config.json')
310
+ let config = null
311
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')) } catch { /* ignore */ }
312
+ checks.push(check('config', '設定檔', config ? 'ok' : 'fail', config ? configPath : `找不到 ${configPath}`, config ? '' : '先在 DSH 面板安裝並啟動 dsh-line-bot'))
313
+ if (config) {
314
+ const line = config.line || {}
315
+ checks.push(check('line-config', 'LINE 憑證', line.channelSecret || line.accessToken ? 'ok' : 'fail',
316
+ line.channelSecret ? '已設定 Channel Secret' : line.accessToken ? '已設定 Access Token' : '沒有憑證',
317
+ line.channelSecret || line.accessToken ? '' : '面板「LINE 憑證」填入 Channel ID/Secret 或 Access Token'))
318
+ checks.push(check('server', 'Webhook 伺服器', 'warn', `無法連線(${reason})`, '確認 DSH 正在執行、外掛已啟動;面板「狀態總覽」可看伺服器狀態'))
319
+ }
320
+ const home = dshHome()
321
+ let memeFound = false
322
+ try {
323
+ for (const entry of fs.readdirSync(path.join(home, 'profiles'))) {
324
+ const candidate = path.join(home, 'profiles', entry, 'node_modules', 'dsh-meme', 'package.json')
325
+ if (fs.existsSync(candidate)) { memeFound = true; break }
326
+ }
327
+ } catch { /* ignore */ }
328
+ checks.push(check('dsh-meme', 'dsh-meme 圖庫', memeFound ? 'ok' : 'fail',
329
+ memeFound ? '已安裝' : '沒有在 profile 裡找到 dsh-meme',
330
+ memeFound ? '' : 'npx dsh-line-bot install --with-companions'))
331
+ const sips = whichCommand('sips')
332
+ const ffmpeg = whichCommand('ffmpeg')
333
+ checks.push(check('converters', '轉圖工具', (sips || ffmpeg) ? 'ok' : 'warn',
334
+ `sips=${sips ? '有' : '無'} ffmpeg=${ffmpeg ? '有' : '無'}`,
335
+ ffmpeg ? '' : '安裝 ffmpeg 以支援 GIF/webp 表情包'))
336
+ const today = new Date().toISOString().slice(0, 10)
337
+ const recent = scanLogForErrors(path.join(dataDir, 'logs', `${today}.log`))
338
+ checks.push(check('logs', '最近錯誤', recent.length ? 'warn' : 'ok',
339
+ recent.length ? recent[recent.length - 1].slice(0, 240) : '沒有最近錯誤',
340
+ recent.length ? `看完整日誌:${path.join(dataDir, 'logs', `${today}.log`)}` : ''))
341
+ return { version: PKG.version, at: Date.now(), offline: true, summary: summarizeChecks(checks), checks }
342
+ }
343
+
344
+ async function commandDoctor(options) {
345
+ const home = dshHome()
346
+ const dataDir = path.join(home, 'line-bot')
347
+ let config = null
348
+ try { config = JSON.parse(fs.readFileSync(path.join(dataDir, 'config.json'), 'utf8')) } catch { /* ignore */ }
349
+ const port = Number(config?.server?.port) || 8791
350
+ const url = `http://127.0.0.1:${port}/doctor${options.fix ? '?repair=1' : ''}`
351
+ let result = null
352
+ let reason = ''
353
+ try {
354
+ result = await httpJson(url)
355
+ } catch (error) {
356
+ reason = error?.message || String(error)
357
+ result = offlineDoctor(dataDir, reason)
358
+ }
359
+ if (options.json) {
360
+ out(JSON.stringify(result, null, 2))
361
+ process.exitCode = result.summary?.fail ? 1 : 0
362
+ return
363
+ }
364
+ const icon = { ok: '✅', warn: '⚠️ ', fail: '❌' }
365
+ out(`dsh-line-bot ${result.version || PKG.version} 診斷${result.offline ? '(離線)' : ''}`)
366
+ for (const item of result.checks) {
367
+ out(`${icon[item.status] || '•'} ${item.title}:${item.detail}`)
368
+ if (item.fix) out(` → ${item.fix}`)
369
+ }
370
+ const summary = result.summary || summarizeChecks(result.checks)
371
+ out(`\n結果:${summary.ok} 正常、${summary.warn} 提醒、${summary.fail} 失敗`)
372
+ if (result.offline) {
373
+ out('提示:伺服器在執行時,doctor 會自動測公開網址、webhook 與圖片可送達;修復請用 npx dsh-line-bot doctor --fix。')
374
+ }
375
+ process.exitCode = summary.fail ? 1 : 0
376
+ }
377
+
292
378
  async function commandInstall(options) {
293
379
  const profile = pickProfile(options.profile)
294
380
  const { dir, file, manifest } = readManifest(profile)
@@ -410,6 +496,10 @@ switch (options.command) {
410
496
  case 'remove':
411
497
  commandUninstall(options)
412
498
  break
499
+ case 'doctor':
500
+ case 'diagnose':
501
+ await commandDoctor(options)
502
+ break
413
503
  case 'status':
414
504
  case 'list':
415
505
  commandStatus(options)
package/client.js CHANGED
@@ -357,6 +357,7 @@ window.__ModuleLoader__.load({
357
357
  const [memoryData, setMemoryData] = useState(null)
358
358
  const [modelCatalog, setModelCatalog] = useState(null)
359
359
  const [importGroup, setImportGroup] = useState('')
360
+ const [doctorResult, setDoctorResult] = useState(null)
360
361
  const importInput = useRef(null)
361
362
  const [localEdit, setLocalEdit] = useState({ id: '', name: '', text: '' })
362
363
 
@@ -516,6 +517,22 @@ window.__ModuleLoader__.load({
516
517
  await loadMemory({ quiet: true })
517
518
  }
518
519
 
520
+ async function runDoctor(repair) {
521
+ setBusy('diagnose')
522
+ try {
523
+ const value = await jpost('/action', { action: 'diagnose', payload: { repair: repair === true } })
524
+ const result = value?.result || null
525
+ setDoctorResult(result)
526
+ if (value?.status) { setStatus(value.status); setDraft(stripSecrets(value.status.config || {})); }
527
+ const summary = result?.summary || {}
528
+ setToast({ kind: summary.fail ? 'err' : 'ok', text: `診斷完成:${summary.ok || 0} 正常、${summary.warn || 0} 提醒、${summary.fail || 0} 失敗。` })
529
+ } catch (error) {
530
+ setToast({ kind: 'err', text: error?.message || String(error) })
531
+ } finally {
532
+ setBusy('')
533
+ }
534
+ }
535
+
519
536
  async function importChatFile(event) {
520
537
  const file = event.target.files && event.target.files[0]
521
538
  event.target.value = ''
@@ -1305,7 +1322,19 @@ window.__ModuleLoader__.load({
1305
1322
  h('div', { style: S.row },
1306
1323
  h(Btn, { onClick: () => act('logs-clear'), disabled: !!busy, tone: 'danger' }, '清除日誌'),
1307
1324
  h(Btn, { onClick: devReload, disabled: !!busy }, '重新載入 host 模組(開發用)'),
1325
+ h(Btn, { onClick: () => runDoctor(false), disabled: !!busy }, busy === 'diagnose' ? '診斷中…' : '執行診斷'),
1326
+ h(Btn, { onClick: () => runDoctor(true), disabled: !!busy }, '自動修復(安全)'),
1308
1327
  ),
1328
+ doctorResult
1329
+ ? h('div', { style: S.panelBox },
1330
+ h('div', { style: S.fieldTitle }, `診斷結果(${(doctorResult.summary && doctorResult.summary.ok) || 0} 正常 / ${(doctorResult.summary && doctorResult.summary.warn) || 0} 提醒 / ${(doctorResult.summary && doctorResult.summary.fail) || 0} 失敗)`),
1331
+ (doctorResult.checks || []).map((item) => h('div', { key: item.id, style: { padding: '6px 0', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
1332
+ h('div', { style: { fontSize: 13 } }, `${item.status === 'ok' ? '✅' : item.status === 'warn' ? '⚠️' : '❌'} ${item.title}:${item.detail}`),
1333
+ item.fix ? h('div', { style: { ...S.hint, marginTop: 2 } }, `→ ${item.fix}`) : null,
1334
+ )),
1335
+ )
1336
+ : h('div', { style: S.hint }, '「執行診斷」會檢查伺服器、tunnel、公開網址、webhook、表情包圖庫、轉圖工具、模型與最近錯誤;「自動修復」只做重連 tunnel、重設 webhook 這類安全動作。'),
1337
+ h('div', { style: S.label }, '即時日誌'),
1309
1338
  h(KV, {
1310
1339
  rows: [
1311
1340
  ['資料夾', status.paths?.dataDir || '—'],
@@ -0,0 +1,61 @@
1
+ // 診斷工具:把「為什麼圖片沒送出去」這類問題變成可讀的檢查清單。
2
+ // 只讀取本機狀態,不修改設定;repair 由 host 另外執行安全修復。
3
+ import fs from 'node:fs'
4
+ import { spawnSync } from 'node:child_process'
5
+
6
+ export function whichCommand(name) {
7
+ const cmd = process.platform === 'win32' ? 'where' : 'which'
8
+ try {
9
+ const result = spawnSync(cmd, [name], { encoding: 'utf8', timeout: 5000, windowsHide: true })
10
+ if (result.status !== 0) return ''
11
+ return String(result.stdout || '')
12
+ .split(/\r?\n/)
13
+ .map((item) => item.trim())
14
+ .find(Boolean) || ''
15
+ } catch {
16
+ return ''
17
+ }
18
+ }
19
+
20
+ export function check(id, title, status, detail, fix = '') {
21
+ return { id, title, status, detail: String(detail || ''), fix: String(fix || '') }
22
+ }
23
+
24
+ export function summarizeChecks(checks) {
25
+ const counts = { ok: 0, warn: 0, fail: 0 }
26
+ for (const item of checks) counts[item.status] = (counts[item.status] || 0) + 1
27
+ const status = counts.fail > 0 ? 'fail' : counts.warn > 0 ? 'warn' : 'ok'
28
+ return { status, ...counts, total: checks.length }
29
+ }
30
+
31
+ export function scanLogForErrors(file, { limit = 8, maxBytes = 200000 } = {}) {
32
+ try {
33
+ const stat = fs.statSync(file)
34
+ if (!stat.size) return []
35
+ const start = Math.max(0, stat.size - maxBytes)
36
+ const fd = fs.openSync(file, 'r')
37
+ const buffer = Buffer.alloc(stat.size - start)
38
+ fs.readSync(fd, buffer, 0, buffer.length, start)
39
+ fs.closeSync(fd)
40
+ const lines = buffer.toString('utf8').split(/\r?\n/).filter(Boolean)
41
+ const hits = []
42
+ const seen = new Set()
43
+ for (const line of lines) {
44
+ if (!/\[(warn|error)\]/.test(line)) continue
45
+ const key = line.replace(/^\S+\s+/, '').slice(0, 160)
46
+ if (seen.has(key)) continue
47
+ seen.add(key)
48
+ hits.push(line.trim())
49
+ }
50
+ return hits.slice(-limit)
51
+ } catch {
52
+ return []
53
+ }
54
+ }
55
+
56
+ export function formatChecks(checks) {
57
+ const icon = { ok: '✅', warn: '⚠️ ', fail: '❌' }
58
+ return checks
59
+ .map((item) => `${icon[item.status] || '•'} ${item.title}:${item.detail}${item.fix ? `\n → ${item.fix}` : ''}`)
60
+ .join('\n')
61
+ }
package/lib/host.mjs CHANGED
@@ -19,6 +19,7 @@ const nudgeMod = await import(withVersion('./nudge.mjs'))
19
19
  const tiebaSyncMod = await import(withVersion('./tieba-sync.mjs'))
20
20
  const localPersonasMod = await import(withVersion('./local-personas.mjs'))
21
21
  const chatImport = await import(withVersion('./chat-import.mjs'))
22
+ const diagnosticsMod = await import(withVersion('./diagnostics.mjs'))
22
23
  const tiebaContextMod = await import(withVersion('./tieba-context.mjs'))
23
24
  const balanceMod = await import(withVersion('./deepseek-balance.mjs'))
24
25
  const memoryMod = await import(withVersion('./memory.mjs'))
@@ -42,7 +43,7 @@ const { createMemeSource } = memeSourceMod
42
43
  const { createStickerImages } = stickerImageMod
43
44
  const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep } = utilMod
44
45
 
45
- export const VERSION = '0.7.2'
46
+ export const VERSION = '0.8.0'
46
47
 
47
48
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
48
49
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -1289,6 +1290,25 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1289
1290
  }
1290
1291
  }
1291
1292
 
1293
+ function findMemeChoice(desc, choices = runtime.memeChoices || []) {
1294
+ const query = String(desc || '').trim()
1295
+ if (!query || choices.length === 0) return null
1296
+ const exact = choices.find((item) => item.caption === query)
1297
+ if (exact) return exact
1298
+ const contained = choices.find((item) => item.caption && (item.caption.includes(query) || query.includes(item.caption)))
1299
+ if (contained) return contained
1300
+ const tokens = query.split(/[,,。.!!??、\s]+/).filter((item) => item.length >= 2)
1301
+ let best = null
1302
+ let bestScore = 0
1303
+ for (const item of choices) {
1304
+ const caption = item.caption || ''
1305
+ let score = 0
1306
+ for (const token of tokens) if (caption.includes(token)) score += token.length
1307
+ if (score > bestScore) { bestScore = score; best = item }
1308
+ }
1309
+ return bestScore >= 2 ? best : null
1310
+ }
1311
+
1292
1312
  async function buildOutgoingMessages(text) {
1293
1313
  const cfg = store.get()
1294
1314
  const parsed = splitStickerMarkers(text)
@@ -1300,7 +1320,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1300
1320
  for (const desc of parsed.memes.slice(0, max)) {
1301
1321
  const indexMatch = /^#?(\d{1,3})$/.exec(String(desc).trim())
1302
1322
  const index = indexMatch ? Number(indexMatch[1]) - 1 : -1
1303
- let file = index >= 0 ? (sample[index]?.path || '') : ''
1323
+ let file = index >= 0 ? (sample[index]?.path || '') : (findMemeChoice(desc, sample)?.path || '')
1304
1324
  if (!file) {
1305
1325
  const picked = memes.pick({ query: desc })
1306
1326
  file = picked?.ok && picked.path ? picked.path : ''
@@ -1521,7 +1541,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1521
1541
  const stickerImages = createStickerImages({ ctx, logger, dir })
1522
1542
  const mediaTokens = new Map()
1523
1543
 
1524
- const server = createWebhookServer({ getConfig: () => store.get(), logger, dispatch: handleEvent, getMedia })
1544
+ const server = createWebhookServer({ getConfig: () => store.get(), logger, dispatch: handleEvent, getMedia, getDoctor: (options) => runDiagnostics(options) })
1525
1545
 
1526
1546
  const tunnel = createTunnelManager({
1527
1547
  getConfig: () => withEnvCloudflare(store.get()),
@@ -1681,6 +1701,126 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1681
1701
  return buildStatus()
1682
1702
  }
1683
1703
 
1704
+ async function runDiagnostics({ repair = false } = {}) {
1705
+ const cfg = store.get()
1706
+ const checks = []
1707
+ const check = diagnosticsMod.check
1708
+ if (repair) {
1709
+ try {
1710
+ const tunnelState = await tunnel.status()
1711
+ if (cfg.tunnel?.autoStart && tunnelState.phase !== 'running' && tunnelState.phase !== 'external') await tunnel.start()
1712
+ await refreshBotInfo()
1713
+ await reconcileWebhook({ force: true })
1714
+ } catch (error) {
1715
+ logger.warn(`診斷修復失敗:${errorText(error)}`)
1716
+ }
1717
+ }
1718
+ const serverState = server.status()
1719
+ checks.push(check('server', 'Webhook 伺服器', serverState.running ? 'ok' : 'fail',
1720
+ serverState.running ? `127.0.0.1:${serverState.port}` : (serverState.lastError || '沒有啟動'),
1721
+ serverState.running ? '' : '面板按「啟動伺服器」,或執行 npx dsh-line-bot doctor --fix'))
1722
+ const tunnelState = await tunnel.status().catch((error) => ({ phase: 'error', error: errorText(error) }))
1723
+ const phaseOk = tunnelState.phase === 'running' || tunnelState.phase === 'external'
1724
+ checks.push(check('tunnel', 'Cloudflare Tunnel', phaseOk ? 'ok' : 'fail',
1725
+ `${tunnelState.phase}${tunnelState.url ? `:${tunnelState.url}` : ''}${tunnelState.error ? `(${tunnelState.error})` : ''}`,
1726
+ phaseOk ? '' : '檢查 tunnel.mode、cloudflared 路徑或 API Token,然後重啟 tunnel'))
1727
+ const base = publicBaseSync()
1728
+ checks.push(check('public-url', '公開網址', base ? 'ok' : 'fail', base || '沒有公開網址,LINE 抓不到圖片',
1729
+ base ? '' : '啟動 tunnel,或設定 tunnel.publicUrl'))
1730
+ if (base) {
1731
+ let reachable = false
1732
+ let detail = ''
1733
+ try {
1734
+ const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(6000) })
1735
+ reachable = res.ok
1736
+ detail = `HTTP ${res.status}`
1737
+ } catch (error) {
1738
+ detail = errorText(error)
1739
+ }
1740
+ checks.push(check('public-health', '公開網址健康檢查', reachable ? 'ok' : 'warn', detail || '無法連線',
1741
+ reachable ? '' : 'quick tunnel 換網址後 LINE 可能還抓不到舊圖;重啟 tunnel 並重設 webhook'))
1742
+ }
1743
+ try {
1744
+ const token = await getToken()
1745
+ const endpoint = await lineApi.getWebhookEndpoint(token)
1746
+ const expected = base ? `${base}/webhook` : ''
1747
+ const matches = expected && String(endpoint?.endpoint || '') === expected
1748
+ checks.push(check('webhook', 'LINE Webhook', endpoint?.active && matches ? 'ok' : 'fail',
1749
+ `active=${!!endpoint?.active} endpoint=${endpoint?.endpoint || '(未設定)'}`,
1750
+ endpoint?.active && matches ? '' : '面板按「套用 Webhook」,或執行 doctor --fix'))
1751
+ } catch (error) {
1752
+ checks.push(check('webhook', 'LINE Webhook', 'warn', errorText(error), '確認 Channel Access Token 與網路'))
1753
+ }
1754
+ let memeStats = { available: false, count: 0 }
1755
+ try {
1756
+ memeStats = memes.stats()
1757
+ checks.push(check('dsh-meme', 'dsh-meme 圖庫', memeStats.available && memeStats.count > 0 ? 'ok' : 'fail',
1758
+ `available=${!!memeStats.available} count=${memeStats.count || 0}`,
1759
+ memeStats.available ? '' : '安裝 dsh-meme 外掛(npx dsh-line-bot install --with-companions)'))
1760
+ } catch (error) {
1761
+ checks.push(check('dsh-meme', 'dsh-meme 圖庫', 'fail', errorText(error), '安裝 dsh-meme 外掛'))
1762
+ }
1763
+ const stickerCfg = cfg.stickers || {}
1764
+ checks.push(check('meme-config', '表情包設定', stickerCfg.enabled === false ? 'warn' : 'ok',
1765
+ `enabled=${stickerCfg.enabled !== false} maxPerReply=${stickerCfg.maxPerReply || 1} apng=${!!stickerCfg.apng}`,
1766
+ stickerCfg.enabled === false ? '面板「表情包」打開「允許角色使用表情包」' : ''))
1767
+ const sips = diagnosticsMod.whichCommand('sips')
1768
+ const ffmpeg = diagnosticsMod.whichCommand('ffmpeg')
1769
+ checks.push(check('converters', '轉圖工具', (sips || ffmpeg) ? (process.platform === 'win32' && !ffmpeg ? 'warn' : 'ok') : 'warn',
1770
+ `sips=${sips ? '有' : '無'} ffmpeg=${ffmpeg ? '有' : '無'}(GIF/APNG 需要 ffmpeg;webp 需要 sips 或 ffmpeg)`,
1771
+ process.platform === 'win32' && !ffmpeg ? 'Windows 建議安裝 ffmpeg,否則 GIF/webp 表情包會送不出去' : '安裝 ffmpeg(brew install ffmpeg)可轉 GIF/APNG'))
1772
+ try {
1773
+ const selected = modelSelection()
1774
+ const info = await ctx.get?.('llm')?.resolveModelInfo?.(selected.provider, selected.model)
1775
+ const modalities = info?.inputModalities || []
1776
+ checks.push(check('model', '回覆模型', 'ok',
1777
+ `${selected.provider}/${selected.model}${modalities.length ? `(${modalities.join('+')})` : ''}`,
1778
+ modalities.includes('image') ? '' : '這個模型不吃圖;貼圖理解會退回關鍵字,但不影響送表情包'))
1779
+ } catch (error) {
1780
+ checks.push(check('model', '回覆模型', 'warn', errorText(error), '在面板「模型與接口來源」重新選模型'))
1781
+ }
1782
+ if (memeStats.available && base) {
1783
+ try {
1784
+ const sample = memes.sample({ limit: 1 })[0]
1785
+ if (sample) {
1786
+ const marker = `測試 [[meme:${sample.caption || sample.tag || ''}]]`
1787
+ const built = await buildOutgoingMessages(marker)
1788
+ let imageUrl = ''
1789
+ for (const item of built.messages) {
1790
+ if (item.type === 'image') imageUrl = item.originalContentUrl
1791
+ else if (item.type === 'flex') {
1792
+ const contents = item.contents || {}
1793
+ if (contents.type === 'bubble') imageUrl = contents.hero?.url || ''
1794
+ else if (contents.type === 'carousel') imageUrl = contents.contents?.[0]?.hero?.url || ''
1795
+ }
1796
+ if (imageUrl) break
1797
+ }
1798
+ if (imageUrl) {
1799
+ const res = await fetch(imageUrl, { signal: AbortSignal.timeout(8000) })
1800
+ checks.push(check('media', '圖片可送達測試', res.ok ? 'ok' : 'fail',
1801
+ res.ok ? `HTTP ${res.status} ${imageUrl}` : `HTTP ${res.status}`,
1802
+ res.ok ? '' : '公開網址或 /media 路由有問題,重啟 tunnel 後再試'))
1803
+ } else {
1804
+ checks.push(check('media', '圖片可送達測試', 'fail',
1805
+ `組不出圖片訊息(caption=${String(sample.caption || '').slice(0, 30)},messages=${built.messages.length},markers=${built.parsed.memes.length})`,
1806
+ '確認 dsh-meme 圖庫與公開網址'))
1807
+ }
1808
+ }
1809
+ } catch (error) {
1810
+ checks.push(check('media', '圖片可送達測試', 'warn', errorText(error), '重試或查看日誌'))
1811
+ }
1812
+ }
1813
+ try {
1814
+ const today = new Date().toISOString().slice(0, 10)
1815
+ const recent = diagnosticsMod.scanLogForErrors(path.join(dir, 'logs', `${today}.log`))
1816
+ checks.push(check('logs', '最近錯誤', recent.length ? 'warn' : 'ok',
1817
+ recent.length ? recent[recent.length - 1].slice(0, 240) : '沒有最近錯誤',
1818
+ recent.length ? `看完整日誌:${path.join(dir, 'logs', `${today}.log`)}` : ''))
1819
+ } catch { /* ignore */ }
1820
+ const summary = diagnosticsMod.summarizeChecks(checks)
1821
+ return { version: VERSION, at: Date.now(), summary, checks }
1822
+ }
1823
+
1684
1824
  // ── 狀態快照 ────────────────────────────────────────────────────────────
1685
1825
  async function buildStatus() {
1686
1826
  const cfg = store.get()
@@ -2126,6 +2266,9 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2126
2266
  }
2127
2267
  return { userId, personaId, personaName: picked.name || '', personaSource: picked.source || 'dafeiyu' }
2128
2268
  }
2269
+ case 'diagnose':
2270
+ return runDiagnostics({ repair: payload.repair === true })
2271
+
2129
2272
  case 'group-import': {
2130
2273
  const groupId = String(payload.groupId || '').trim()
2131
2274
  const text = String(payload.text || '')
@@ -2228,6 +2371,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2228
2371
  jsonOk(res, await buildStatus())
2229
2372
  return
2230
2373
  }
2374
+ if (req.method === 'GET' && sub === '/doctor') {
2375
+ jsonOk(res, await runDiagnostics({ repair: url.searchParams.get('repair') === '1' }))
2376
+ return
2377
+ }
2231
2378
  if (req.method === 'GET' && sub.startsWith('/media/')) {
2232
2379
  const media = getMedia(decodeURIComponent(sub.slice('/media/'.length)))
2233
2380
  if (!media || !media.bytes) {
@@ -17,7 +17,7 @@ function verifySignature(secret, raw, signature) {
17
17
  }
18
18
  }
19
19
 
20
- export function createWebhookServer({ getConfig, logger, dispatch, getMedia }) {
20
+ export function createWebhookServer({ getConfig, logger, dispatch, getMedia, getDoctor }) {
21
21
  let server = null
22
22
  let port = 0
23
23
  let startedAt = 0
@@ -60,6 +60,17 @@ export function createWebhookServer({ getConfig, logger, dispatch, getMedia }) {
60
60
  const url = new URL(req.url || '/', 'http://127.0.0.1')
61
61
  lastRequestAt = Date.now()
62
62
 
63
+ if (req.method === 'GET' && url.pathname === '/doctor') {
64
+ if (typeof getDoctor !== 'function') return jsonFail(res, '診斷功能尚未載入', 503)
65
+ try {
66
+ const repair = url.searchParams.get('repair') === '1'
67
+ const result = await getDoctor({ repair })
68
+ return jsonOk(res, result)
69
+ } catch (error) {
70
+ return jsonFail(res, error?.message || String(error), 500)
71
+ }
72
+ }
73
+
63
74
  if (req.method === 'GET' && url.pathname === '/health') {
64
75
  jsonOk(res, {
65
76
  ok: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -23,7 +23,9 @@
23
23
  "README.*.md",
24
24
  "bin",
25
25
  "LICENSE",
26
- "CHANGELOG.md"
26
+ "CHANGELOG.md",
27
+ "TROUBLESHOOTING.md",
28
+ "AGENTS.md"
27
29
  ],
28
30
  "engines": {
29
31
  "node": ">=22",