dsh-line-bot 0.7.2 → 0.8.2

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2
4
+ - 送出的圖片改存到 `<DSH_HOME>/line-bot/media/`(含索引,保留 90 天),不再只放記憶體、不再 1 小時過期;LINE 之後才打開聊天室也能抓到。
5
+ - 圖片回應改 `cache-control: public, max-age=31536000, immutable`,讓 LINE/CDN 快取。
6
+ - 注意:quick tunnel 重啟會換網址,舊訊息裡的圖片仍會失效;要永久固定請用 API Token 隧道+自訂網域。
7
+
8
+ ## 0.8.1
9
+ - LINE 代理會濾掉 dsh-meme 外掛的「陪伴模式」提示詞(`dsh-expression:companion`):那套規則叫模型用 `send_meme`/`[表情:]`,在 LINE 沒有對應的配圖器,會和本外掛的標記打架。
10
+ - 表情包提示詞收緊:清單加上情緒與編號、只准 `[[meme:#N]]`、不要呼叫 `send_meme`、不要把標記解釋成文字。
11
+ - 測試:要求表情包時回 `[[meme:#15]]` 並組出 Flex 圖片;一般回覆不會硬加圖。
12
+
13
+ ## 0.8.0
14
+
15
+ - 內建診斷:`GET /doctor`、`npx dsh-line-bot doctor [--fix] [--json]`,檢查伺服器/tunnel/公開網址/webhook/dsh-meme/表情包設定/轉圖工具/模型/圖片可送達/最近錯誤。
16
+ - 面板「進階設定」新增「執行診斷」與「自動修復(安全)」。
17
+ - 修正:Flex 圖片訊息(`hero.url`)在診斷中會被正確辨識;表情包描述改用圖庫樣本比對,長描述也能命中。
18
+ - 新增 `AGENTS.md`(給 DSH Agent 的除錯流程)與 `TROUBLESHOOTING.md`(症狀對照)。
19
+
3
20
  ## 0.7.2
4
21
 
5
22
  - 匯入聊天時若成員還沒對應到 LINE 帳號,先用「名字」記住;本人第一次在群組發言時,這些記憶也會一起帶入。
package/README.en.md CHANGED
@@ -199,6 +199,9 @@ The character keeps its own memory at `<DSH_HOME>/line-bot/memories.json`, separ
199
199
  - The "Personal memory" panel card shows what it remembers, lets you delete entries, prune expired ones, or trigger a review right away.
200
200
  ## Memes (dsh-meme)
201
201
 
202
+ - Sent images are copied to `<DSH_HOME>/line-bot/media/` and kept for 90 days, so LINE can still fetch them when the chat is opened later. With a quick tunnel the public URL changes on restart and older images stop loading; use a named tunnel (API token + your own hostname) for permanent URLs.
203
+
204
+
202
205
  The persona can attach a sticker to a text reply, or reply with just an image. Images come from the installed dsh-meme plugin's library (the built-in "大肥鱼" pack has 24 images):
203
206
 
204
207
  - Each turn injects a numbered sample of the library into context; when the persona wants a picture, it appends `[[meme:#N]]` (or a caption) at the end of its reply, and the plugin turns that into a LINE image message.
@@ -206,6 +209,11 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
206
209
  - 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
210
  - 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
211
  - Without dsh-meme installed, the feature stays off and replies are text only.
212
+ ## Diagnostics
213
+ - 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.
214
+ - CLI: `npx dsh-line-bot doctor`, `doctor --fix`, `doctor --json`.
215
+ - Agent playbook: `AGENTS.md`; symptom table: `TROUBLESHOOTING.md`.
216
+
209
217
  ## Troubleshooting
210
218
 
211
219
  - **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
@@ -199,6 +199,9 @@ Bot がグループ(または複数人のルーム)に追加されたとき
199
199
  - パネルの「個人メモリ」カードで、記録内容の確認、手動削除、期限切れの一括削除、今すぐの見直しができます。
200
200
  ## 表情パック(dsh-meme)
201
201
 
202
+ - 送信した画像は `<DSH_HOME>/line-bot/media/` にコピーされ 90 日間保持されます。後からチャットを開いても LINE が取得できます。quick tunnel は再起動で URL が変わるため古い画像は表示できません。固定 URL が必要なら API Token の named tunnel を使ってください。
203
+
204
+
202
205
  キャラクターはテキストの返信にスタンプ画像を添えたり、画像だけを返したりできます。画像はインストール済みの dsh-meme プラグインのライブラリ(内蔵「大肥魚」24 枚)から取得します。
203
206
 
204
207
  - 毎ターン、ライブラリのサンプル(番号+説明)を context に注入します。画像を添えたいときは返信の最後に `[[meme:#番号]]`(または説明)を付けます。プラグインが LINE の image message に変換します。
@@ -206,6 +209,11 @@ Bot がグループ(または複数人のルーム)に追加されたとき
206
209
  - パネルの「表情パック」カードでオンオフ、1 回あたりの上限(1〜3)、ライブラリのサンプル表示ができます。画像の追加や分類の変更は dsh-meme 側の設定で行います。
207
210
  - LINE スタンプを受信すると、スタンプ画像を取得してモデルに見せます(画像対応モデルはそのまま読み、非対応なら LINE のキーワードを使います)。「LINE 認証情報」カードの「スタンプを理解する」でオフにできます。
208
211
  - dsh-meme がインストールされていない場合、この機能は無効になりテキストのみ返します。
212
+ ## 診断
213
+ - パネルの「詳細設定 → 診断を実行」でサーバー、Tunnel、公開 URL、webhook、dsh-meme 図庫、画像変換ツール、モデル、最近のエラーを確認できます。「安全な修復」は tunnel 再接続と webhook 再設定だけを行います。
214
+ - CLI:`npx dsh-line-bot doctor`、`doctor --fix`、`doctor --json`。
215
+ - Agent 向け手順は `AGENTS.md`、症状別は `TROUBLESHOOTING.md`。
216
+
209
217
  ## トラブルシューティング
210
218
 
211
219
  - **パネルに「LINE Bot ランタイムに接続できません」と出る**:webServer がまだ準備できていない可能性があります。再読み込みしてください。それでも失敗する場合は DSH ログの `[line-bot]` を確認します。
package/README.ko.md CHANGED
@@ -202,10 +202,16 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
202
202
  캐릭터는 텍스트 답장에 스티커 이미지를 붙이거나 이미지만 보낼 수 있습니다. 이미지는 설치된 dsh-meme 플러그인의 라이브러리(내장 「대비어」 24장)에서 가져옵니다.
203
203
 
204
204
  - 매 턴 라이브러리 샘플(번호+설명)을 context에 넣습니다. 이미지를 붙이려면 답장 끝에 `[[meme:#번호]]`(또는 설명)를 붙입니다. 플러그인이 LINE image message로 바꿔 보냅니다.
205
- - 이미지는 현재 Cloudflare 공개 URL(`<URL>/media/<token>`)을 통해 LINE에 전달되며, quick tunnel이 재시작돼 URL이 바뀌어도 자동으로 따라갑니다.
205
+ - 이미지는 현재 Cloudflare 공개 URL(`<URL>/media/<token>`)을 통해 LINE에 전달됩니다. 보낸 이미지는 `<DSH_HOME>/line-bot/media/`에 90일간 보관되므로 나중에 채팅을 열어도 LINE이 가져갈 수 있습니다.
206
+ - quick tunnel은 재시작하면 URL이 바뀌므로 이전 메시지의 이미지는 표시되지 않습니다. 영구 URL이 필요하면 API Token named tunnel과 사용자 도메인을 사용하세요.
206
207
  - 패널의 「이모티콘」 카드에서 켜고 끄기, 답장당 최대 장수(1~3), 라이브러리 샘플 미리보기를 할 수 있습니다. 이미지 추가나 분류 변경은 dsh-meme 설정에서 하세요.
207
208
  - LINE 스티커를 받으면 스티커 이미지를 가져와 모델에 보여 줍니다(이미지 지원 모델은 바로 읽고, 아니면 LINE 키워드로 대체). 「LINE 자격 증명」 카드에서 「스티커 이해」를 끌 수 있습니다.
208
209
  - dsh-meme이 설치되어 있지 않으면 이 기능은 꺼지고 텍스트만 답장합니다.
210
+ ## 진단
211
+ - 패널의 "고급 설정 → 진단 실행"에서 서버, Tunnel, 공개 URL, webhook, dsh-meme 라이브러리, 이미지 변환 도구, 모델, 최근 오류를 확인할 수 있습니다. "안전 복구"는 tunnel 재연결과 webhook 재설정만 수행합니다.
212
+ - CLI: `npx dsh-line-bot doctor`, `doctor --fix`, `doctor --json`.
213
+ - Agent용 절차는 `AGENTS.md`, 증상별 안내는 `TROUBLESHOOTING.md`.
214
+
209
215
  ## 문제 해결
210
216
 
211
217
  - **패널에 "LINE Bot 런타임에 연결할 수 없습니다"**: webServer가 아직 준비되지 않았을 수 있습니다. 새로 고침하세요. 그래도 실패하면 DSH 로그의 `[line-bot]` 메시지를 확인합니다.
package/README.md CHANGED
@@ -199,6 +199,9 @@ Bot 被加進群組(或多人聊天室)後:
199
199
  - 面板的「個人記憶」卡可以看目前記了什麼、手動刪除、立即清理到期、或現在就叫它整理一次。
200
200
  ## 表情包(dsh-meme)
201
201
 
202
+ - 送出的圖片會複製到 `<DSH_HOME>/line-bot/media/`(保留 90 天),LINE 之後才打開聊天室也抓得到。公開網址若用 quick tunnel,重啟後網址會變,舊訊息裡的圖片就會失效;要永久固定請改用自訂網域(API Token 隧道)。
203
+
204
+
202
205
  角色可以回文字時配一張貼圖,也可以只回一張圖。圖片來自已安裝的 dsh-meme 外掛的圖庫(內建「大肥魚」24 張):
203
206
 
204
207
  - 每輪會把圖庫樣本(編號+描述)帶進 context;角色想配圖時在回覆最後加 `[[meme:#編號]]`(或描述),插件再組成 LINE 的 image message。
@@ -206,6 +209,11 @@ Bot 被加進群組(或多人聊天室)後:
206
209
  - 面板「表情包」卡可以開關、限制一次最多幾張(1~3),並預覽圖庫樣本。要新增圖片或改分類,請用 dsh-meme 自己的設定。
207
210
  - 收到 LINE 貼圖時,會抓貼圖圖片交給模型看(支援圖片的模型直接讀圖,否則用 LINE 提供的關鍵字),角色才接得住貼圖的情緒。可在「LINE 憑證」卡關閉「看得懂貼圖」。
208
211
  - 沒安裝 dsh-meme 時這個功能會自動停用,只回文字。
212
+ ## 診斷與修復
213
+ - 面板「進階設定 → 執行診斷」會檢查伺服器、Tunnel、公開網址、webhook、表情包圖庫、轉圖工具、模型與最近錯誤;「自動修復(安全)」只會重連 tunnel、重設 webhook。
214
+ - 終端機:`npx dsh-line-bot doctor`、`npx dsh-line-bot doctor --fix`、`doctor --json`。
215
+ - 給 Agent 的除錯流程在 `AGENTS.md`,症狀對照在 `TROUBLESHOOTING.md`。
216
+
209
217
  ## 疑難排解
210
218
 
211
219
  - **面板顯示「無法連線到 LINE Bot 執行期」**:webServer 可能還沒就緒,重新整理即可;仍失敗就看 DSH 日誌裡 `[line-bot]` 的訊息。
package/README.zh-CN.md CHANGED
@@ -199,6 +199,9 @@ Bot 被加入群组(或多人聊天室)后:
199
199
  - 面板的「个人记忆」卡可以看目前记了什么、手动删除、立即清理到期、或现在就叫它整理一次。
200
200
  ## 表情包(dsh-meme)
201
201
 
202
+ - 送出的图片会复制到 `<DSH_HOME>/line-bot/media/`(保留 90 天),LINE 之后才打开聊天室也抓得到。公开网址若用 quick tunnel,重启后网址会变,旧消息里的图片会失效;要永久固定请改用自定义域名(API Token 隧道)。
203
+
204
+
202
205
  角色可以在文字后面配一张贴图,也可以只回一张图。图片来自已安装的 dsh-meme 插件的图库(内置「大肥鱼」24 张):
203
206
 
204
207
  - 每轮会把图库样本(编号+描述)带进 context;角色想配图时在回复最后加 `[[meme:#编号]]`(或描述),插件再组成 LINE 的 image message。
@@ -206,6 +209,11 @@ Bot 被加入群组(或多人聊天室)后:
206
209
  - 面板「表情包」卡可以开关、限制每次最多几张(1~3),并预览图库样本。要新增图片或改分类,请用 dsh-meme 自己的设置。
207
210
  - 收到 LINE 贴图时,会抓贴图图片交给模型看(支持图片的模型直接读图,否则用 LINE 提供的关键词),角色才能接住贴图的情绪。可在「LINE 凭证」卡关闭「看得懂贴图」。
208
211
  - 没安装 dsh-meme 时该功能会自动停用,只回文字。
212
+ ## 诊断与修复
213
+ - 面板「高级设置 → 执行诊断」会检查服务器、Tunnel、公开网址、webhook、表情包图库、转图工具、模型与最近错误;「自动修复(安全)」只会重连 tunnel、重设 webhook。
214
+ - 终端:`npx dsh-line-bot doctor`、`npx dsh-line-bot doctor --fix`、`doctor --json`。
215
+ - 给 Agent 的调试流程在 `AGENTS.md`,症状对照在 `TROUBLESHOOTING.md`。
216
+
209
217
  ## 疑难排解
210
218
 
211
219
  - **面板显示「无法连接到 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 || '—'],
@@ -97,6 +97,21 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
97
97
  } catch {
98
98
  /* 用預設順序 */
99
99
  }
100
+ // dsh-meme 外掛會注入它自己的「陪伴模式」提示詞(叫模型用 send_meme/[表情:])。
101
+ // LINE 沒有那個前端配圖器,這段會和本外掛的標記規則打架,所以在最外層把它濾掉。
102
+ try {
103
+ agentCtx.effect(
104
+ () => agentCtx.on?.('system-prompt/assemble', async (_assembly, _context, next) => {
105
+ const assembled = await next()
106
+ if (!Array.isArray(assembled?.sections)) return assembled
107
+ const sections = assembled.sections.filter((section) => section?.name !== 'dsh-expression:companion')
108
+ return sections.length === assembled.sections.length ? assembled : { ...assembled, sections }
109
+ }, { prepend: true }),
110
+ 'line-bot: drop dsh-meme companion prompt',
111
+ )
112
+ } catch (error) {
113
+ logger?.warn?.(`無法覆寫 dsh-meme 提示詞:${error?.message || error}`)
114
+ }
100
115
  if (personaText) {
101
116
  agentCtx.effect(
102
117
  () => prompt.section({
@@ -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.2'
46
47
 
47
48
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
48
49
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -1125,16 +1126,18 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1125
1126
  if (memes.packs().length === 0) return ''
1126
1127
  const sample = memes.sample({ limit: 20 })
1127
1128
  runtime.memeChoices = sample
1128
- const lines = (sample || [])
1129
- .map((item) => String(item.caption || '').trim())
1130
- .filter(Boolean)
1131
- .slice(0, 20)
1129
+ const lines = (sample || []).filter((item) => String(item.caption || '').trim()).slice(0, 20)
1132
1130
  if (lines.length === 0) return ''
1133
1131
  return [
1134
- '【表情包】你的圖庫(dsh-meme)裡有這些圖:',
1135
- lines.map((line, index) => `${index + 1}. ${line}`).join('\n'),
1136
- `想配圖時,在回覆最後加上 [[meme:#編號]](例如 [[meme:#3]],編號照上面清單),也可以寫描述;只回一張圖就只輸出那個標記。一次最多 ${memeMaxPerReply()} 張;氣氛合適才用,不要每句都加;不要呼叫 send_meme。`,
1137
- publicBaseSync() ? '' : '(目前沒有公開網址,圖片送不出去。)',
1132
+ '【表情包】你的圖庫(dsh-meme)裡有這些圖,編號.情緒|描述:',
1133
+ lines.map((item, index) => `${index + 1}. ${item.tag || 'daily'}|${String(item.caption || '').trim()}`).join('\n'),
1134
+ '想配圖時,在回覆最後加上 [[meme:#編號]](例如 [[meme:#3]])。規則:',
1135
+ '- 只能用 [[meme:#編號]]。不要呼叫 send_meme(LINE 沒有連到它,呼叫了也不會送出圖片),也不要寫 <meme#N> 或 [表情:…] 這種格式。',
1136
+ '- 標記放最後;不要在旁邊解釋圖片內容,也不要說「我傳了貼圖」。',
1137
+ '- 只回圖也可以:整則回覆只放標記。',
1138
+ `- 一次最多 ${memeMaxPerReply()} 張,編號必須是 1–${lines.length}。`,
1139
+ '- 氣氛明顯(開心、無語、害羞、想睡、被逗笑)才用;一般聊天、正事、難過時不要用;對方明確要表情包時就用。',
1140
+ publicBaseSync() ? '' : '(目前沒有公開網址,圖片暫時送不出去。)',
1138
1141
  ].filter(Boolean).join('\n')
1139
1142
  }
1140
1143
 
@@ -1198,6 +1201,44 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1198
1201
  }
1199
1202
  }
1200
1203
 
1204
+ const mediaDir = path.join(dir, 'media')
1205
+ let mediaIndex = {}
1206
+ try {
1207
+ fs.mkdirSync(mediaDir, { recursive: true })
1208
+ mediaIndex = JSON.parse(fs.readFileSync(path.join(mediaDir, 'index.json'), 'utf8')) || {}
1209
+ } catch {
1210
+ mediaIndex = {}
1211
+ }
1212
+
1213
+ function saveMediaIndex() {
1214
+ try {
1215
+ const file = path.join(mediaDir, 'index.json')
1216
+ const tmp = `${file}.tmp`
1217
+ fs.writeFileSync(tmp, JSON.stringify(mediaIndex), { mode: 0o600 })
1218
+ fs.renameSync(tmp, file)
1219
+ } catch (error) {
1220
+ logger.warn(`儲存媒體索引失敗:${error?.message || error}`)
1221
+ }
1222
+ }
1223
+
1224
+ function pruneMedia() {
1225
+ const cutoff = Date.now() - 90 * 86400000
1226
+ let dropped = 0
1227
+ for (const [key, item] of Object.entries(mediaIndex)) {
1228
+ if (!item || !item.at || item.at < cutoff) {
1229
+ try {
1230
+ fs.unlinkSync(path.join(mediaDir, item?.file || ''))
1231
+ } catch {
1232
+ /* 檔案可能已不在 */
1233
+ }
1234
+ delete mediaIndex[key]
1235
+ dropped += 1
1236
+ }
1237
+ }
1238
+ if (dropped > 0) saveMediaIndex()
1239
+ }
1240
+ pruneMedia()
1241
+
1201
1242
  function registerImageFile(filePath) {
1202
1243
  const ext = path.extname(String(filePath || '')).toLowerCase()
1203
1244
  let serve = filePath
@@ -1209,8 +1250,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1209
1250
  serve = store.get().stickers?.apng === true
1210
1251
  ? (convertGifToApng(filePath) || convertToPng(filePath))
1211
1252
  : convertToPng(filePath)
1212
- }
1213
- else if (ext === '.webp') {
1253
+ } else if (ext === '.webp') {
1214
1254
  mime = 'image/png'
1215
1255
  serve = convertToPng(filePath)
1216
1256
  } else {
@@ -1218,7 +1258,18 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1218
1258
  }
1219
1259
  if (!mime || !serve || !fs.existsSync(serve)) return ''
1220
1260
  const token = `m${randomBytes(6).toString('hex')}`
1221
- mediaTokens.set(token, { path: serve, mime, at: Date.now() })
1261
+ const filename = `${token}${mime === 'image/png' ? '.png' : '.jpg'}`
1262
+ const target = path.join(mediaDir, filename)
1263
+ try {
1264
+ fs.copyFileSync(serve, target)
1265
+ } catch (error) {
1266
+ logger.warn(`複製媒體失敗:${error?.message || error}`)
1267
+ return ''
1268
+ }
1269
+ const item = { file: filename, mime, at: Date.now(), bytes: fs.statSync(target).size }
1270
+ mediaTokens.set(token, item)
1271
+ mediaIndex[token] = item
1272
+ saveMediaIndex()
1222
1273
  return token
1223
1274
  }
1224
1275
 
@@ -1226,14 +1277,12 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1226
1277
  const key = String(id || '')
1227
1278
  if (!key) return null
1228
1279
  if (key.startsWith('m')) {
1229
- const hit = mediaTokens.get(key)
1280
+ const hit = mediaTokens.get(key) || mediaIndex[key]
1230
1281
  if (!hit) return null
1231
- if (Date.now() - hit.at > 60 * 60 * 1000) {
1232
- mediaTokens.delete(key)
1233
- return null
1234
- }
1235
1282
  try {
1236
- return { mime: hit.mime, bytes: fs.readFileSync(hit.path) }
1283
+ const bytes = fs.readFileSync(path.join(mediaDir, hit.file))
1284
+ mediaTokens.set(key, hit)
1285
+ return { mime: hit.mime, bytes }
1237
1286
  } catch {
1238
1287
  mediaTokens.delete(key)
1239
1288
  return null
@@ -1289,6 +1338,25 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1289
1338
  }
1290
1339
  }
1291
1340
 
1341
+ function findMemeChoice(desc, choices = runtime.memeChoices || []) {
1342
+ const query = String(desc || '').trim()
1343
+ if (!query || choices.length === 0) return null
1344
+ const exact = choices.find((item) => item.caption === query)
1345
+ if (exact) return exact
1346
+ const contained = choices.find((item) => item.caption && (item.caption.includes(query) || query.includes(item.caption)))
1347
+ if (contained) return contained
1348
+ const tokens = query.split(/[,,。.!!??、\s]+/).filter((item) => item.length >= 2)
1349
+ let best = null
1350
+ let bestScore = 0
1351
+ for (const item of choices) {
1352
+ const caption = item.caption || ''
1353
+ let score = 0
1354
+ for (const token of tokens) if (caption.includes(token)) score += token.length
1355
+ if (score > bestScore) { bestScore = score; best = item }
1356
+ }
1357
+ return bestScore >= 2 ? best : null
1358
+ }
1359
+
1292
1360
  async function buildOutgoingMessages(text) {
1293
1361
  const cfg = store.get()
1294
1362
  const parsed = splitStickerMarkers(text)
@@ -1300,7 +1368,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1300
1368
  for (const desc of parsed.memes.slice(0, max)) {
1301
1369
  const indexMatch = /^#?(\d{1,3})$/.exec(String(desc).trim())
1302
1370
  const index = indexMatch ? Number(indexMatch[1]) - 1 : -1
1303
- let file = index >= 0 ? (sample[index]?.path || '') : ''
1371
+ let file = index >= 0 ? (sample[index]?.path || '') : (findMemeChoice(desc, sample)?.path || '')
1304
1372
  if (!file) {
1305
1373
  const picked = memes.pick({ query: desc })
1306
1374
  file = picked?.ok && picked.path ? picked.path : ''
@@ -1521,7 +1589,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1521
1589
  const stickerImages = createStickerImages({ ctx, logger, dir })
1522
1590
  const mediaTokens = new Map()
1523
1591
 
1524
- const server = createWebhookServer({ getConfig: () => store.get(), logger, dispatch: handleEvent, getMedia })
1592
+ const server = createWebhookServer({ getConfig: () => store.get(), logger, dispatch: handleEvent, getMedia, getDoctor: (options) => runDiagnostics(options) })
1525
1593
 
1526
1594
  const tunnel = createTunnelManager({
1527
1595
  getConfig: () => withEnvCloudflare(store.get()),
@@ -1681,6 +1749,126 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1681
1749
  return buildStatus()
1682
1750
  }
1683
1751
 
1752
+ async function runDiagnostics({ repair = false } = {}) {
1753
+ const cfg = store.get()
1754
+ const checks = []
1755
+ const check = diagnosticsMod.check
1756
+ if (repair) {
1757
+ try {
1758
+ const tunnelState = await tunnel.status()
1759
+ if (cfg.tunnel?.autoStart && tunnelState.phase !== 'running' && tunnelState.phase !== 'external') await tunnel.start()
1760
+ await refreshBotInfo()
1761
+ await reconcileWebhook({ force: true })
1762
+ } catch (error) {
1763
+ logger.warn(`診斷修復失敗:${errorText(error)}`)
1764
+ }
1765
+ }
1766
+ const serverState = server.status()
1767
+ checks.push(check('server', 'Webhook 伺服器', serverState.running ? 'ok' : 'fail',
1768
+ serverState.running ? `127.0.0.1:${serverState.port}` : (serverState.lastError || '沒有啟動'),
1769
+ serverState.running ? '' : '面板按「啟動伺服器」,或執行 npx dsh-line-bot doctor --fix'))
1770
+ const tunnelState = await tunnel.status().catch((error) => ({ phase: 'error', error: errorText(error) }))
1771
+ const phaseOk = tunnelState.phase === 'running' || tunnelState.phase === 'external'
1772
+ checks.push(check('tunnel', 'Cloudflare Tunnel', phaseOk ? 'ok' : 'fail',
1773
+ `${tunnelState.phase}${tunnelState.url ? `:${tunnelState.url}` : ''}${tunnelState.error ? `(${tunnelState.error})` : ''}`,
1774
+ phaseOk ? '' : '檢查 tunnel.mode、cloudflared 路徑或 API Token,然後重啟 tunnel'))
1775
+ const base = publicBaseSync()
1776
+ checks.push(check('public-url', '公開網址', base ? 'ok' : 'fail', base || '沒有公開網址,LINE 抓不到圖片',
1777
+ base ? '' : '啟動 tunnel,或設定 tunnel.publicUrl'))
1778
+ if (base) {
1779
+ let reachable = false
1780
+ let detail = ''
1781
+ try {
1782
+ const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(6000) })
1783
+ reachable = res.ok
1784
+ detail = `HTTP ${res.status}`
1785
+ } catch (error) {
1786
+ detail = errorText(error)
1787
+ }
1788
+ checks.push(check('public-health', '公開網址健康檢查', reachable ? 'ok' : 'warn', detail || '無法連線',
1789
+ reachable ? '' : 'quick tunnel 換網址後 LINE 可能還抓不到舊圖;重啟 tunnel 並重設 webhook'))
1790
+ }
1791
+ try {
1792
+ const token = await getToken()
1793
+ const endpoint = await lineApi.getWebhookEndpoint(token)
1794
+ const expected = base ? `${base}/webhook` : ''
1795
+ const matches = expected && String(endpoint?.endpoint || '') === expected
1796
+ checks.push(check('webhook', 'LINE Webhook', endpoint?.active && matches ? 'ok' : 'fail',
1797
+ `active=${!!endpoint?.active} endpoint=${endpoint?.endpoint || '(未設定)'}`,
1798
+ endpoint?.active && matches ? '' : '面板按「套用 Webhook」,或執行 doctor --fix'))
1799
+ } catch (error) {
1800
+ checks.push(check('webhook', 'LINE Webhook', 'warn', errorText(error), '確認 Channel Access Token 與網路'))
1801
+ }
1802
+ let memeStats = { available: false, count: 0 }
1803
+ try {
1804
+ memeStats = memes.stats()
1805
+ checks.push(check('dsh-meme', 'dsh-meme 圖庫', memeStats.available && memeStats.count > 0 ? 'ok' : 'fail',
1806
+ `available=${!!memeStats.available} count=${memeStats.count || 0}`,
1807
+ memeStats.available ? '' : '安裝 dsh-meme 外掛(npx dsh-line-bot install --with-companions)'))
1808
+ } catch (error) {
1809
+ checks.push(check('dsh-meme', 'dsh-meme 圖庫', 'fail', errorText(error), '安裝 dsh-meme 外掛'))
1810
+ }
1811
+ const stickerCfg = cfg.stickers || {}
1812
+ checks.push(check('meme-config', '表情包設定', stickerCfg.enabled === false ? 'warn' : 'ok',
1813
+ `enabled=${stickerCfg.enabled !== false} maxPerReply=${stickerCfg.maxPerReply || 1} apng=${!!stickerCfg.apng}`,
1814
+ stickerCfg.enabled === false ? '面板「表情包」打開「允許角色使用表情包」' : ''))
1815
+ const sips = diagnosticsMod.whichCommand('sips')
1816
+ const ffmpeg = diagnosticsMod.whichCommand('ffmpeg')
1817
+ checks.push(check('converters', '轉圖工具', (sips || ffmpeg) ? (process.platform === 'win32' && !ffmpeg ? 'warn' : 'ok') : 'warn',
1818
+ `sips=${sips ? '有' : '無'} ffmpeg=${ffmpeg ? '有' : '無'}(GIF/APNG 需要 ffmpeg;webp 需要 sips 或 ffmpeg)`,
1819
+ process.platform === 'win32' && !ffmpeg ? 'Windows 建議安裝 ffmpeg,否則 GIF/webp 表情包會送不出去' : '安裝 ffmpeg(brew install ffmpeg)可轉 GIF/APNG'))
1820
+ try {
1821
+ const selected = modelSelection()
1822
+ const info = await ctx.get?.('llm')?.resolveModelInfo?.(selected.provider, selected.model)
1823
+ const modalities = info?.inputModalities || []
1824
+ checks.push(check('model', '回覆模型', 'ok',
1825
+ `${selected.provider}/${selected.model}${modalities.length ? `(${modalities.join('+')})` : ''}`,
1826
+ modalities.includes('image') ? '' : '這個模型不吃圖;貼圖理解會退回關鍵字,但不影響送表情包'))
1827
+ } catch (error) {
1828
+ checks.push(check('model', '回覆模型', 'warn', errorText(error), '在面板「模型與接口來源」重新選模型'))
1829
+ }
1830
+ if (memeStats.available && base) {
1831
+ try {
1832
+ const sample = memes.sample({ limit: 1 })[0]
1833
+ if (sample) {
1834
+ const marker = `測試 [[meme:${sample.caption || sample.tag || ''}]]`
1835
+ const built = await buildOutgoingMessages(marker)
1836
+ let imageUrl = ''
1837
+ for (const item of built.messages) {
1838
+ if (item.type === 'image') imageUrl = item.originalContentUrl
1839
+ else if (item.type === 'flex') {
1840
+ const contents = item.contents || {}
1841
+ if (contents.type === 'bubble') imageUrl = contents.hero?.url || ''
1842
+ else if (contents.type === 'carousel') imageUrl = contents.contents?.[0]?.hero?.url || ''
1843
+ }
1844
+ if (imageUrl) break
1845
+ }
1846
+ if (imageUrl) {
1847
+ const res = await fetch(imageUrl, { signal: AbortSignal.timeout(8000) })
1848
+ checks.push(check('media', '圖片可送達測試', res.ok ? 'ok' : 'fail',
1849
+ res.ok ? `HTTP ${res.status} ${imageUrl}` : `HTTP ${res.status}`,
1850
+ res.ok ? '' : '公開網址或 /media 路由有問題,重啟 tunnel 後再試'))
1851
+ } else {
1852
+ checks.push(check('media', '圖片可送達測試', 'fail',
1853
+ `組不出圖片訊息(caption=${String(sample.caption || '').slice(0, 30)},messages=${built.messages.length},markers=${built.parsed.memes.length})`,
1854
+ '確認 dsh-meme 圖庫與公開網址'))
1855
+ }
1856
+ }
1857
+ } catch (error) {
1858
+ checks.push(check('media', '圖片可送達測試', 'warn', errorText(error), '重試或查看日誌'))
1859
+ }
1860
+ }
1861
+ try {
1862
+ const today = new Date().toISOString().slice(0, 10)
1863
+ const recent = diagnosticsMod.scanLogForErrors(path.join(dir, 'logs', `${today}.log`))
1864
+ checks.push(check('logs', '最近錯誤', recent.length ? 'warn' : 'ok',
1865
+ recent.length ? recent[recent.length - 1].slice(0, 240) : '沒有最近錯誤',
1866
+ recent.length ? `看完整日誌:${path.join(dir, 'logs', `${today}.log`)}` : ''))
1867
+ } catch { /* ignore */ }
1868
+ const summary = diagnosticsMod.summarizeChecks(checks)
1869
+ return { version: VERSION, at: Date.now(), summary, checks }
1870
+ }
1871
+
1684
1872
  // ── 狀態快照 ────────────────────────────────────────────────────────────
1685
1873
  async function buildStatus() {
1686
1874
  const cfg = store.get()
@@ -2126,6 +2314,9 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2126
2314
  }
2127
2315
  return { userId, personaId, personaName: picked.name || '', personaSource: picked.source || 'dafeiyu' }
2128
2316
  }
2317
+ case 'diagnose':
2318
+ return runDiagnostics({ repair: payload.repair === true })
2319
+
2129
2320
  case 'group-import': {
2130
2321
  const groupId = String(payload.groupId || '').trim()
2131
2322
  const text = String(payload.text || '')
@@ -2228,6 +2419,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2228
2419
  jsonOk(res, await buildStatus())
2229
2420
  return
2230
2421
  }
2422
+ if (req.method === 'GET' && sub === '/doctor') {
2423
+ jsonOk(res, await runDiagnostics({ repair: url.searchParams.get('repair') === '1' }))
2424
+ return
2425
+ }
2231
2426
  if (req.method === 'GET' && sub.startsWith('/media/')) {
2232
2427
  const media = getMedia(decodeURIComponent(sub.slice('/media/'.length)))
2233
2428
  if (!media || !media.bytes) {
@@ -2237,7 +2432,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2237
2432
  res.writeHead(200, {
2238
2433
  'content-type': media.mime || 'application/octet-stream',
2239
2434
  'content-length': media.bytes.length,
2240
- 'cache-control': 'public, max-age=300',
2435
+ 'cache-control': 'public, max-age=31536000, immutable',
2241
2436
  })
2242
2437
  res.end(media.bytes)
2243
2438
  return
@@ -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,
@@ -82,7 +93,7 @@ export function createWebhookServer({ getConfig, logger, dispatch, getMedia }) {
82
93
  res.writeHead(200, {
83
94
  'content-type': media.mime || 'application/octet-stream',
84
95
  'content-length': media.bytes.length,
85
- 'cache-control': 'public, max-age=300',
96
+ 'cache-control': 'public, max-age=31536000, immutable',
86
97
  })
87
98
  res.end(media.bytes)
88
99
  return
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.2",
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",