dsh-line-bot 0.9.1 → 0.9.3
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 +4 -4
- package/CHANGELOG.md +14 -0
- package/README.en.md +7 -5
- package/README.ja.md +5 -3
- package/README.ko.md +5 -3
- package/README.md +7 -5
- package/README.zh-CN.md +5 -3
- package/TROUBLESHOOTING.md +5 -4
- package/client.js +32 -2
- package/lib/config.mjs +6 -1
- package/lib/host.mjs +326 -18
- package/lib/line-api.mjs +29 -0
- package/lib/nudge.mjs +2 -0
- package/lib/sticker-image.mjs +10 -1
- package/lib/vision.mjs +100 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
node bin/cli.mjs doctor --json # 或 npx dsh-line-bot doctor --json
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
- 伺服器在跑時,doctor 會打本機 `http://127.0.0.1:8791/doctor`,檢查:伺服器、Cloudflare Tunnel、公開網址健康、LINE webhook、dsh-meme
|
|
11
|
+
- 伺服器在跑時,doctor 會打本機 `http://127.0.0.1:8791/doctor`,檢查:伺服器、Cloudflare Tunnel、公開網址健康、LINE webhook、dsh-meme 圖庫、表情包設定、轉圖工具、模型、modlens 看圖能力、圖片可送達測試、最近錯誤。
|
|
12
12
|
- 伺服器沒跑時會退成離線檢查(設定檔、憑證、dsh-meme、轉圖工具、日誌)。
|
|
13
13
|
- `--fix` 會做安全修復:重啟 tunnel、重設 webhook、重新讀 bot 資訊;不會改設定、不會刪資料。
|
|
14
14
|
|
|
@@ -23,10 +23,10 @@ curl -s http://127.0.0.1:8791/health
|
|
|
23
23
|
|
|
24
24
|
| 症狀 | 先看哪個 check | 常見原因 | 安全修法 |
|
|
25
25
|
| --- | --- | --- | --- |
|
|
26
|
-
| 完全沒回覆 | `server`、`tunnel`、`webhook
|
|
26
|
+
| 完全沒回覆 | `server`、`tunnel`、`webhook`、面板「使用者」待審核 | webhook 指到舊 tunnel;LINE「Use webhook」沒開;伺服器沒啟動;使用者還沒核准 | `doctor --fix`;面板「套用 Webhook」;到 LINE console 開 Use webhook;在「使用者」核准對方 |
|
|
27
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.
|
|
29
|
-
|
|
|
28
|
+
| 回覆出現 `[[meme:…]]`、`<meme#18>` 文字 | 版本 | 舊版不認得標記格式 | 升級到 0.6.1+(目前 0.9.x) |
|
|
29
|
+
| 貼圖/圖片看不懂 | `model`、`vision` | 模型不吃圖,或 modlens 不可用/失敗 | 面板「模型與接口來源」換成有 `text+image` 的模型;或安裝 modlens 並設定 provider |
|
|
30
30
|
| 群組不回 | 面板「群組」 | 沒被 @/引用、擲骰沒中、群組功能關閉 | 看面板回覆機率與開關 |
|
|
31
31
|
| 主動訊息沒出現 | 面板「主動傳訊」 | 機率、每日/每月上限、靜音時段、冷卻、沒有 session 的使用者 | 看 `status.nudge` 與日誌 |
|
|
32
32
|
| 餘額提醒沒出現 | `balance` | provider 不是 `deepseek-account`、餘額高於門檻、冷卻中、靜音 | 面板「DeepSeek 餘額」看 `applicable`、`lastAskAt` |
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.3
|
|
4
|
+
|
|
5
|
+
- 身分門控改為預設關閉:白名單為空時,任何人都不能觸發 Agent;未授權的 LINE 使用者只會進入「待審核」佇列,不會建立 session、不會呼叫工具。
|
|
6
|
+
- 面板「使用者」新增待審核名單,可核准或拒絕;核准後才加入白名單並通知對方。LINE 憑證卡可切換 `approval`(待審核)/`whitelist`(直接忽略)兩種模式。
|
|
7
|
+
- 主動傳訊、餘額提醒與記憶整理只會挑選白名單內的使用者。
|
|
8
|
+
- 文件補上「第一次使用要先核准自己」與隱私說明;更新 awesome-dsh-plugin 條目描述(需要 Messaging API 頻道、只有白名單使用者能對話)。
|
|
9
|
+
|
|
10
|
+
## 0.9.2
|
|
11
|
+
|
|
12
|
+
- LINE 一般圖片:收到 `image` 訊息時透過 LINE content API 下載,模型支援圖片就直接附圖;不支援時改用 modlens 或另一個視覺模型描述。
|
|
13
|
+
- 貼圖:模型不吃圖時,先用 modlens 看圖,失敗再改用支援圖片的模型描述,最後才退回貼圖關鍵字。
|
|
14
|
+
- 面板「LINE 憑證」新增「看得懂一般圖片」開關與目前看圖方式說明。
|
|
15
|
+
- 診斷(doctor)新增「看圖能力(modlens)」檢查。
|
|
16
|
+
|
|
3
17
|
## 0.9.1
|
|
4
18
|
- 貼吧學習同步的目標下拉現在也包含「插件自訂人設」(`local:`);選自訂人設時會寫入 `<DSH_HOME>/line-bot/persona-overlays/<id>.md` 覆蓋層,每輪注入。
|
|
5
19
|
- 讀不到大肥魚 HTTP bridge 時,改成直接讀取 `<DSH_HOME>/storages/dsh-persona-dafeiyu/personas.json` 並列出內建人設,不再讓同步目標變空。
|
package/README.en.md
CHANGED
|
@@ -81,7 +81,7 @@ Without them: no dafeiyu means only plugin-local personas; no dsh-meme means tex
|
|
|
81
81
|
2. **Fill in the panel**: under "LINE Bot → LINE credentials", paste the Channel ID and Channel Secret, click "Save changes" → "Verify and read bot info". On success you will see the bot's name and Basic ID.
|
|
82
82
|
3. **Start the webhook server**: on "Overview", click "Start all", or "Start" to bring up both the webhook server and the tunnel (auto-start is on by default).
|
|
83
83
|
4. **Open the tunnel**: for a quick tunnel just click "Start tunnel"; for a fixed URL, paste an API token under "Cloudflare Tunnel" → "Verify API token" → "Create/update tunnel and DNS" (token permissions: Account → Cloudflare Tunnel → Edit, Zone → DNS → Edit, Zone → Zone → Read). A quick tunnel's URL changes on every restart (the plugin writes the new URL back to the LINE webhook automatically); a named tunnel's URL is fixed.
|
|
84
|
-
5. **Auto-configure the webhook**: once the tunnel is ready the endpoint is PUT to LINE automatically; if that fails, click "Apply tunnel URL to webhook" and then "Test webhook". After that, add the bot as a friend in LINE and send it a message.
|
|
84
|
+
5. **Auto-configure the webhook**: once the tunnel is ready the endpoint is PUT to LINE automatically; if that fails, click "Apply tunnel URL to webhook" and then "Test webhook". After that, add the bot as a friend in LINE and send it a message; the first message lands in the pending queue — approve your userId under Users in the panel before the bot replies (the allowlist starts empty).
|
|
85
85
|
|
|
86
86
|
## Panel sections
|
|
87
87
|
|
|
@@ -98,10 +98,10 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
|
|
|
98
98
|
| Jokes | Text joke library: built-in seeds, Tieba quotes, wanjiqi barrage memes; per-source toggles, per-turn injection count, add/delete entries |
|
|
99
99
|
| Personal memory | Per-user long-term memory, expiry cleanup, daily self-review, manual delete |
|
|
100
100
|
| Memes | Reply with images/stickers from the dsh-meme library, enable/disable, image limit, and sample previews |
|
|
101
|
-
| Sticker vision | Fetch incoming LINE stickers and show them to the model (can be disabled) |
|
|
101
|
+
| Sticker & image vision | Fetch incoming LINE stickers or images and show them to the model; when the model cannot read images, describe them with modlens or a vision model (can be disabled) |
|
|
102
102
|
| DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown (deepseek-account only) |
|
|
103
103
|
| Proactive messages | Toggle, probability, interval, daily/monthly caps, minimum gap, quiet hours, idle, target users, prompt, send a test now, statistics |
|
|
104
|
-
| Users |
|
|
104
|
+
| Users | Pending approval queue (approve / deny), the DSH session each LINE user maps to, their own workspace folder, their current persona (editable), message count, last interaction, clear memory / reset session |
|
|
105
105
|
| Groups | Per-group session, workspace folder, persona, message count; reply chance when not addressed; always reply on @ / quote; clear group memory |
|
|
106
106
|
| Advanced | Port, Agent provider/model/effort/cwd/timeout, one workspace per user (and its root), welcome/error/empty replies, logs, reload host modules (dev) |
|
|
107
107
|
|
|
@@ -220,7 +220,8 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
|
|
|
220
220
|
- 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.
|
|
221
221
|
- Images are served to LINE through the current Cloudflare public URL (`<url>/media/<token>`), and the URL follows quick-tunnel restarts automatically.
|
|
222
222
|
- 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.
|
|
223
|
-
- When a LINE sticker arrives, the bot fetches the
|
|
223
|
+
- When a LINE sticker arrives, the bot fetches the image and shows it to the model; vision-capable models read it directly, otherwise the bot asks modlens, then tries another vision model, and only then falls back to the keywords LINE provides. Turn "Understand stickers" off in the LINE credentials card to use keywords only.
|
|
224
|
+
- Regular images work the same way: the bot downloads them through LINE's content API and attaches them to the message; if the model cannot read images, the same modlens / vision-model description path is used. Turn "Understand images" off in the LINE credentials card to skip them.
|
|
224
225
|
- Without dsh-meme installed, the feature stays off and replies are text only.
|
|
225
226
|
## Diagnostics
|
|
226
227
|
- 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.
|
|
@@ -247,7 +248,8 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
|
|
|
247
248
|
|
|
248
249
|
## Security notes
|
|
249
250
|
|
|
250
|
-
-
|
|
251
|
+
- The allowlist starts empty: unknown users are only queued as pending, with no session and no tool access, until you approve them under Users in the panel.
|
|
252
|
+
- This attaches the DSH Agent to LINE: anyone you approve can make the Agent use tools on your computer. Approve only LINE accounts you trust, and review the Users list periodically.
|
|
251
253
|
- All credentials are stored locally in `config.json` (600); the panel only shows masked values; the API only listens on the DSH webServer at `127.0.0.1`.
|
|
252
254
|
- Give the Cloudflare API token only the permissions it needs, and revoke it when you no longer use it.
|
|
253
255
|
|
package/README.ja.md
CHANGED
|
@@ -81,7 +81,7 @@ npx dsh-line-bot install --no-companions
|
|
|
81
81
|
2. **パネルに入力**:「LINE Bot → LINE 認証情報」に Channel ID と Channel Secret を貼り、「変更を保存」→「検証して bot 情報を取得」を押します。成功すると bot 名と Basic ID が表示されます。
|
|
82
82
|
3. **webhook サーバーを起動**:「概要」で「すべて起動」を押すか、「起動」を押して webhook サーバーとトンネルを両方立ち上げます(既定で自動起動します)。
|
|
83
83
|
4. **トンネルを開く**:Quick tunnel なら「トンネルを起動」を押すだけ。固定 URL が必要なら「Cloudflare Tunnel」で API トークンを貼り →「API トークンを検証」→「トンネルと DNS を作成/更新」(トークン権限:Account → Cloudflare Tunnel → Edit、Zone → DNS → Edit、Zone → Zone → Read)。quick tunnel は再起動のたびに URL が変わります(プラグインが新しい URL を LINE webhook に自動で書き戻します)。名前付きトンネルの URL は固定です。
|
|
84
|
-
5. **webhook を自動設定**:トンネルの準備ができると自動で LINE に PUT します。失敗したら「トンネル URL を webhook に適用」→「webhook をテスト」を押します。あとは LINE で bot
|
|
84
|
+
5. **webhook を自動設定**:トンネルの準備ができると自動で LINE に PUT します。失敗したら「トンネル URL を webhook に適用」→「webhook をテスト」を押します。あとは LINE で bot を友だち追加してメッセージを送ります。最初のメッセージは「承認待ち」に入るので、パネルの「ユーザー」で自分の userId を承認してください(許可リストは初期状態では空です)。
|
|
85
85
|
|
|
86
86
|
## パネルの各セクション
|
|
87
87
|
|
|
@@ -98,7 +98,7 @@ npx dsh-line-bot install --no-companions
|
|
|
98
98
|
| ジョーク | テキストネタ集:内蔵シード、貼吧名台詞、wanjiqi 弾幕ネタ。ソース別の切り替え、ターンごとの注入数、追加/削除 |
|
|
99
99
|
| 個人メモリ | ユーザーごとの長期記憶、期限切れの自動削除、毎日の自己整理、手動削除 |
|
|
100
100
|
| 表情パック | dsh-meme の画像ライブラリで画像/スタンプを返信、オンオフ、1 回の上限、サンプル表示 |
|
|
101
|
-
|
|
|
101
|
+
| スタンプ・画像理解 | LINE スタンプや通常の画像を受信したら取得してモデルに見せる。画像非対応モデルは modlens や別の視覚モデルで説明(オフ可) |
|
|
102
102
|
| DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン(deepseek-account のみ) |
|
|
103
103
|
| 自発メッセージ | オン/オフ、確率、間隔、1 日/1 か月の上限、最短間隔、静かな時間帯、idle、対象ユーザー、プロンプト、今すぐ試送、統計 |
|
|
104
104
|
| ユーザー | LINE ユーザーごとに対応する DSH セッション、専用ワークスペース、現在のペルソナ(直接変更可)、メッセージ数、最終やりとり、メモリ消去/セッション再作成 |
|
|
@@ -220,7 +220,8 @@ Bot がグループ(または複数人のルーム)に追加されたとき
|
|
|
220
220
|
- 毎ターン、ライブラリのサンプル(番号+説明)を context に注入します。画像を添えたいときは返信の最後に `[[meme:#番号]]`(または説明)を付けます。プラグインが LINE の image message に変換します。
|
|
221
221
|
- 画像は現在の Cloudflare 公開 URL(`<URL>/media/<token>`)経由で LINE に渡されます。quick tunnel の再起動で URL が変わっても自動で追従します。
|
|
222
222
|
- パネルの「表情パック」カードでオンオフ、1 回あたりの上限(1〜3)、ライブラリのサンプル表示ができます。画像の追加や分類の変更は dsh-meme 側の設定で行います。
|
|
223
|
-
- LINE
|
|
223
|
+
- LINE スタンプを受信すると、スタンプ画像を取得してモデルに見せます。画像対応モデルはそのまま読み、非対応ならまず modlens、次に別の視覚モデルで説明し、最後に LINE のキーワードを使います。「LINE 認証情報」カードの「スタンプを理解する」でオフにできます。
|
|
224
|
+
- 通常の画像も同じです。LINE content API でダウンロードしてモデルに添付し、画像非対応なら同じ modlens/視覚モデルの説明経路を使います。「LINE 認証情報」カードの「画像を理解する」でオフにできます。
|
|
224
225
|
- dsh-meme がインストールされていない場合、この機能は無効になりテキストのみ返します。
|
|
225
226
|
## 診断
|
|
226
227
|
- パネルの「詳細設定 → 診断を実行」でサーバー、Tunnel、公開 URL、webhook、dsh-meme 図庫、画像変換ツール、モデル、最近のエラーを確認できます。「安全な修復」は tunnel 再接続と webhook 再設定だけを行います。
|
|
@@ -247,6 +248,7 @@ Bot がグループ(または複数人のルーム)に追加されたとき
|
|
|
247
248
|
|
|
248
249
|
## セキュリティ上の注意
|
|
249
250
|
|
|
251
|
+
- 許可リストは初期状態では空です。知らないユーザーのメッセージは承認待ちに入るだけで、セッションも作られず、ツールも呼ばれません。パネルの「ユーザー」で承認すると返信を始めます。
|
|
250
252
|
- これは DSH Agent を公開された LINE アカウントにつなぐのと同じです。bot を知っている人は、あなたのコンピューター上のツールを Agent に使わせられます。「許可リストのユーザー ID」に自分の LINE userId を入れて、自分だけが使えるようにすることをおすすめします。
|
|
251
253
|
- 認証情報はすべてローカルの `config.json`(600)に保存され、パネルにはマスクされた値だけが表示されます。API は `127.0.0.1` の DSH webServer にのみ公開されます。
|
|
252
254
|
- Cloudflare API トークンは必要最小限の権限にし、使わなくなったら取り消してください。
|
package/README.ko.md
CHANGED
|
@@ -81,7 +81,7 @@ npx dsh-line-bot install --no-companions
|
|
|
81
81
|
2. **패널에 입력**: "LINE Bot → LINE 자격 증명"에 Channel ID와 Channel Secret을 붙여 넣고 "변경 저장" → "검증하고 bot 정보 읽기"를 누릅니다. 성공하면 bot 이름과 Basic ID가 표시됩니다.
|
|
82
82
|
3. **webhook 서버 시작**: "개요"에서 "모두 시작"을 누르거나 "시작"을 눌러 webhook 서버와 터널을 함께 올립니다(기본값은 자동 시작).
|
|
83
83
|
4. **터널 열기**: Quick tunnel은 "터널 시작"만 누르면 됩니다. 고정 URL이 필요하면 "Cloudflare Tunnel"에서 API 토큰을 붙여 넣고 → "API 토큰 검증" → "터널과 DNS 생성/업데이트"(토큰 권한: Account → Cloudflare Tunnel → Edit, Zone → DNS → Edit, Zone → Zone → Read). quick tunnel은 재시작할 때마다 URL이 바뀌며(플러그인이 새 URL을 LINE webhook에 자동으로 다시 씁니다), 이름 있는 터널의 URL은 고정입니다.
|
|
84
|
-
5. **webhook 자동 설정**: 터널이 준비되면 자동으로 LINE에 PUT합니다. 실패하면 "터널 URL을 webhook에 적용" → "webhook 테스트"를 누릅니다. 그다음 LINE에서 bot을 친구 추가하고 메시지를
|
|
84
|
+
5. **webhook 자동 설정**: 터널이 준비되면 자동으로 LINE에 PUT합니다. 실패하면 "터널 URL을 webhook에 적용" → "webhook 테스트"를 누릅니다. 그다음 LINE에서 bot을 친구 추가하고 메시지를 보냅니다. 첫 메시지는 "승인 대기"로 들어가며, 패널의 "사용자"에서 자신의 userId를 승인해야 응답합니다(허용 목록은 기본적으로 비어 있습니다).
|
|
85
85
|
|
|
86
86
|
## 패널 구성
|
|
87
87
|
|
|
@@ -98,7 +98,7 @@ npx dsh-line-bot install --no-companions
|
|
|
98
98
|
| 말장난 | 텍스트 밈: 내장 시드, 貼吧 명언, wanjiqi 탄막 밈. 출처별 켜기/끄기, 턴당 주입 수, 추가/삭제 |
|
|
99
99
|
| 개인 기억 | 사용자별 장기 기억, 만료 자동 삭제, 매일 자기 정리, 수동 삭제 |
|
|
100
100
|
| 이모티콘 | dsh-meme 라이브러리로 이미지/스티커 답장, 켜기/끄기, 1회 최대 장수, 샘플 미리보기 |
|
|
101
|
-
|
|
|
101
|
+
| 스티커·이미지 이해 | LINE 스티커나 일반 이미지를 받으면 가져와 모델에 보여 줌. 이미지 미지원 모델은 modlens 또는 다른 비전 모델로 설명 (끌 수 있음) |
|
|
102
102
|
| DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운(deepseek-account 전용) |
|
|
103
103
|
| 능동 메시지 | 켜기/끄기, 확률, 간격, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 대상 사용자, 프롬프트, 지금 시험 발송, 통계 |
|
|
104
104
|
| 사용자 | LINE 사용자별 DSH 세션, 전용 작업 폴더, 현재 페르소나(직접 변경 가능), 메시지 수, 마지막 상호작용, 기억 지우기/세션 재설정 |
|
|
@@ -218,7 +218,8 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
|
|
|
218
218
|
- 이미지는 현재 Cloudflare 공개 URL(`<URL>/media/<token>`)을 통해 LINE에 전달됩니다. 보낸 이미지는 `<DSH_HOME>/line-bot/media/`에 90일간 보관되므로 나중에 채팅을 열어도 LINE이 가져갈 수 있습니다.
|
|
219
219
|
- quick tunnel은 재시작하면 URL이 바뀌므로 이전 메시지의 이미지는 표시되지 않습니다. 영구 URL이 필요하면 API Token named tunnel과 사용자 도메인을 사용하세요.
|
|
220
220
|
- 패널의 「이모티콘」 카드에서 켜고 끄기, 답장당 최대 장수(1~3), 라이브러리 샘플 미리보기를 할 수 있습니다. 이미지 추가나 분류 변경은 dsh-meme 설정에서 하세요.
|
|
221
|
-
- LINE 스티커를 받으면 스티커 이미지를 가져와 모델에 보여
|
|
221
|
+
- LINE 스티커를 받으면 스티커 이미지를 가져와 모델에 보여 줍니다. 이미지 지원 모델은 바로 읽고, 아니면 먼저 modlens, 다음으로 다른 비전 모델로 설명하고, 마지막에 LINE 키워드로 대체합니다. 「LINE 자격 증명」 카드에서 「스티커 이해」를 끌 수 있습니다.
|
|
222
|
+
- 일반 이미지도 같습니다. LINE content API로 내려받아 모델에 첨부하고, 이미지 미지원이면 같은 modlens/비전 모델 설명 경로를 씁니다. 「LINE 자격 증명」 카드에서 「이미지 이해」를 끌 수 있습니다.
|
|
222
223
|
- dsh-meme이 설치되어 있지 않으면 이 기능은 꺼지고 텍스트만 답장합니다.
|
|
223
224
|
## 진단
|
|
224
225
|
- 패널의 "고급 설정 → 진단 실행"에서 서버, Tunnel, 공개 URL, webhook, dsh-meme 라이브러리, 이미지 변환 도구, 모델, 최근 오류를 확인할 수 있습니다. "안전 복구"는 tunnel 재연결과 webhook 재설정만 수행합니다.
|
|
@@ -245,6 +246,7 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
|
|
|
245
246
|
|
|
246
247
|
## 보안 안내
|
|
247
248
|
|
|
249
|
+
- 허용 목록은 기본적으로 비어 있습니다. 모르는 사용자의 메시지는 승인 대기로만 들어가며, 세션이 만들어지거나 도구가 호출되지 않습니다. 패널의 "사용자"에서 승인해야 응답합니다.
|
|
248
250
|
- 이는 DSH Agent를 공개된 LINE 계정에 연결하는 것과 같습니다. bot을 아는 사람은 당신의 컴퓨터에 있는 도구를 Agent가 사용하게 할 수 있습니다. "허용 목록 사용자 ID"에 자신의 LINE userId를 넣어 자신만 사용하도록 하는 것을 권장합니다.
|
|
249
251
|
- 모든 자격 증명은 로컬 `config.json`(600)에 저장되고 패널에는 마스킹된 값만 보입니다. API는 `127.0.0.1`의 DSH webServer에만 열립니다.
|
|
250
252
|
- Cloudflare API 토큰은 필요한 권한만 주고, 사용하지 않을 때는 폐기하세요.
|
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ npx dsh-line-bot install --no-companions
|
|
|
81
81
|
2. **填入面板**:「LINE Bot → LINE 憑證」貼上 Channel ID、Channel Secret,按「儲存變更」→「驗證並讀取 bot 資訊」。成功會看到 bot 名稱與 Basic ID。
|
|
82
82
|
3. **啟動 webhook 伺服器**:面板「狀態總覽」按「啟動全部」,或只按「啟動」讓 webhook server 與 tunnel 都起來(預設會自動啟動)。
|
|
83
83
|
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);具名隧道的網址則固定。
|
|
84
|
-
5. **自動設定 webhook**:隧道就緒後會自動 PUT 到 LINE;若沒成功,按「把隧道網址套到 webhook」再「測試 webhook」。之後在 LINE 加 bot
|
|
84
|
+
5. **自動設定 webhook**:隧道就緒後會自動 PUT 到 LINE;若沒成功,按「把隧道網址套到 webhook」再「測試 webhook」。之後在 LINE 加 bot 好友、傳訊息;第一次訊息會進入「待審核」,到面板「使用者」按「核准」後才會開始回覆(白名單預設是空的)。
|
|
85
85
|
|
|
86
86
|
## 面板各區
|
|
87
87
|
|
|
@@ -98,10 +98,10 @@ npx dsh-line-bot install --no-companions
|
|
|
98
98
|
| 玩梗 | 文字梗庫:內建種子、貼吧金句、wanjiqi 彈幕梗;三個來源可個別開關、每輪注入幾則、新增/刪除梗 |
|
|
99
99
|
| 個人記憶 | 每位使用者分開的長期記憶、到期自動刪、每日請角色整理、手動刪除 |
|
|
100
100
|
| 表情包 | 使用 dsh-meme 圖庫回圖片/貼圖、開關、一次最多幾張、圖庫樣本 |
|
|
101
|
-
|
|
|
101
|
+
| 貼圖與圖片理解 | 收到 LINE 貼圖或一般圖片時抓圖給模型看;模型不吃圖時用 modlens 或另一個視覺模型描述(可關閉) |
|
|
102
102
|
| DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻(僅 deepseek-account) |
|
|
103
103
|
| 主動傳訊 | 開關、機率、間隔、每日/每月上限、最短間隔、安靜時段、idle、指定對象、提示詞、立即試傳、統計 |
|
|
104
|
-
| 使用者 |
|
|
104
|
+
| 使用者 | 待審核名單(核准/拒絕)、每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
|
|
105
105
|
| 群組 | 每個群組的 session、工作資料夾、人設、訊息數;沒被點名時的回覆機率;被 @/引用一定回;清空群組記憶 |
|
|
106
106
|
| 進階設定 | 連接埠、Agent provider/model/effort/cwd/逾時、一人一個工作資料夾(含根目錄)、歡迎/錯誤/空回覆、日誌、開發用 host 重新載入 |
|
|
107
107
|
|
|
@@ -220,7 +220,8 @@ Bot 被加進群組(或多人聊天室)後:
|
|
|
220
220
|
- 每輪會把圖庫樣本(編號+描述)帶進 context;角色想配圖時在回覆最後加 `[[meme:#編號]]`(或描述),插件再組成 LINE 的 image message。
|
|
221
221
|
- 圖片透過目前的 Cloudflare 公開網址(`<網址>/media/<token>`)提供給 LINE;quick tunnel 換網址後會自動跟上。
|
|
222
222
|
- 面板「表情包」卡可以開關、限制一次最多幾張(1~3),並預覽圖庫樣本。要新增圖片或改分類,請用 dsh-meme 自己的設定。
|
|
223
|
-
- 收到 LINE
|
|
223
|
+
- 收到 LINE 貼圖時,會抓貼圖圖片交給模型看;模型支援圖片就直接讀,否則先用 modlens 描述,再不支援就改用另一個視覺模型,最後才退回 LINE 提供的關鍵字。可在「LINE 憑證」卡關閉「看得懂貼圖」。
|
|
224
|
+
- 一般圖片也一樣:透過 LINE content API 下載後直接附給模型;模型不吃圖時走同一套 modlens/視覺模型描述。可在「LINE 憑證」卡關閉「看得懂一般圖片」。
|
|
224
225
|
- 沒安裝 dsh-meme 時這個功能會自動停用,只回文字。
|
|
225
226
|
## 診斷與修復
|
|
226
227
|
- 面板「進階設定 → 執行診斷」會檢查伺服器、Tunnel、公開網址、webhook、表情包圖庫、轉圖工具、模型與最近錯誤;「自動修復(安全)」只會重連 tunnel、重設 webhook。
|
|
@@ -247,7 +248,8 @@ Bot 被加進群組(或多人聊天室)後:
|
|
|
247
248
|
|
|
248
249
|
## 安全提醒
|
|
249
250
|
|
|
250
|
-
-
|
|
251
|
+
- 白名單預設是空的:陌生人傳訊息只會進入待審核,不會建立 session、也不會呼叫工具;在面板「使用者」核准後才會開始回覆。
|
|
252
|
+
- 這等同把 DSH Agent 接到 LINE:核准名單內的人可以讓 Agent 使用你電腦上的工具。請只核准你信任的 LINE 帳號,並定期檢查「使用者」清單。
|
|
251
253
|
- 所有憑證都存在本機 `config.json`(600),面板只會看到遮罩後的值;API 只掛在 `127.0.0.1` 的 DSH webServer 上。
|
|
252
254
|
- Cloudflare API Token 建議只給必要權限,並在不用時撤銷。
|
|
253
255
|
|
package/README.zh-CN.md
CHANGED
|
@@ -81,7 +81,7 @@ npx dsh-line-bot install --no-companions
|
|
|
81
81
|
2. **填入面板**:「LINE Bot → LINE 凭证」贴入 Channel ID、Channel Secret,按「保存更改」→「验证并读取 bot 信息」。成功会看到 bot 名称与 Basic ID。
|
|
82
82
|
3. **启动 webhook 服务器**:面板「状态总览」按「启动全部」,或只按「启动」让 webhook server 与 tunnel 都起来(默认会自动启动)。
|
|
83
83
|
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);具名隧道的网址则固定。
|
|
84
|
-
5. **自动设置 webhook**:隧道就绪后会自动 PUT 到 LINE;若没成功,按「把隧道网址套到 webhook」再「测试 webhook」。之后在 LINE 加 bot
|
|
84
|
+
5. **自动设置 webhook**:隧道就绪后会自动 PUT 到 LINE;若没成功,按「把隧道网址套到 webhook」再「测试 webhook」。之后在 LINE 加 bot 好友、传消息;第一条消息会进入「待审核」,到面板「使用者」按「核准」后才会开始回复(白名单默认为空)。
|
|
85
85
|
|
|
86
86
|
## 面板各区
|
|
87
87
|
|
|
@@ -98,7 +98,7 @@ npx dsh-line-bot install --no-companions
|
|
|
98
98
|
| 玩梗 | 文字梗库:内置种子、贴吧金句、wanjiqi 弹幕梗;三个来源可分别开关、每轮注入几条、新增/删除梗 |
|
|
99
99
|
| 个人记忆 | 每位用户分开的长期记忆、到期自动删除、每日请角色整理、手动删除 |
|
|
100
100
|
| 表情包 | 使用 dsh-meme 图库回图片/贴图、开关、每次最多几张、图库样本 |
|
|
101
|
-
|
|
|
101
|
+
| 贴图与图片理解 | 收到 LINE 贴图或一般图片时抓图给模型看;模型不吃图时用 modlens 或另一个视觉模型描述(可关闭) |
|
|
102
102
|
| DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却(仅 deepseek-account) |
|
|
103
103
|
| 主动传讯 | 开关、概率、间隔、每日/每月上限、最短间隔、安静时段、idle、指定对象、提示词、立即试传、统计 |
|
|
104
104
|
| 用户 | 每位 LINE 用户对应的 DSH session、专属工作文件夹、目前人设(可直接改)、消息数、上次互动、清空记忆/重设 session |
|
|
@@ -220,7 +220,8 @@ Bot 被加入群组(或多人聊天室)后:
|
|
|
220
220
|
- 每轮会把图库样本(编号+描述)带进 context;角色想配图时在回复最后加 `[[meme:#编号]]`(或描述),插件再组成 LINE 的 image message。
|
|
221
221
|
- 图片通过当前的 Cloudflare 公开网址(`<网址>/media/<token>`)提供给 LINE;quick tunnel 换网址后会自动跟上。
|
|
222
222
|
- 面板「表情包」卡可以开关、限制每次最多几张(1~3),并预览图库样本。要新增图片或改分类,请用 dsh-meme 自己的设置。
|
|
223
|
-
- 收到 LINE
|
|
223
|
+
- 收到 LINE 贴图时,会抓贴图图片交给模型看;模型支持图片就直接读,否则先用 modlens 描述,再不支持就改用另一个视觉模型,最后才退回 LINE 提供的关键词。可在「LINE 凭证」卡关闭「看得懂贴图」。
|
|
224
|
+
- 一般图片也一样:通过 LINE content API 下载后直接附给模型;模型不吃图时走同一套 modlens/视觉模型描述。可在「LINE 凭证」卡关闭「看得懂一般图片」。
|
|
224
225
|
- 没安装 dsh-meme 时该功能会自动停用,只回文字。
|
|
225
226
|
## 诊断与修复
|
|
226
227
|
- 面板「高级设置 → 执行诊断」会检查服务器、Tunnel、公开网址、webhook、表情包图库、转图工具、模型与最近错误;「自动修复(安全)」只会重连 tunnel、重设 webhook。
|
|
@@ -247,6 +248,7 @@ Bot 被加入群组(或多人聊天室)后:
|
|
|
247
248
|
|
|
248
249
|
## 安全提醒
|
|
249
250
|
|
|
251
|
+
- 白名单默认为空:陌生人发消息只会进入待审核,不会建立 session、不会调用工具;在面板「使用者」核准后才会开始回复。
|
|
250
252
|
- 这等同把 DSH Agent 接到公开的 LINE 账号:拿到 bot 的人可以让 Agent 使用你电脑上的工具。建议在「白名单用户 ID」填入自己的 LINE userId,只允许自己使用。
|
|
251
253
|
- 所有凭证都存在本机 `config.json`(600),面板只会看到遮罩后的值;API 只挂在 `127.0.0.1` 的 DSH webServer 上。
|
|
252
254
|
- Cloudflare API Token 建议只给必要权限,并在不用时撤销。
|
package/TROUBLESHOOTING.md
CHANGED
|
@@ -25,11 +25,12 @@ npx dsh-line-bot doctor --fix # 安全修復:重連 tunnel、重設 webhook
|
|
|
25
25
|
- 用面板「測試 Agent 回覆」或 `meme-preview` 確認模型有輸出標記。
|
|
26
26
|
|
|
27
27
|
### 回覆裡出現 `[[meme:…]]` 或 `<meme#18>`
|
|
28
|
-
- 舊版不認得標記格式;升級到 0.6.1 以上(目前 0.
|
|
28
|
+
- 舊版不認得標記格式;升級到 0.6.1 以上(目前 0.9.x)。
|
|
29
29
|
|
|
30
|
-
###
|
|
31
|
-
-
|
|
32
|
-
-
|
|
30
|
+
### 貼圖/圖片看不懂
|
|
31
|
+
- 模型支援圖片(`inputModalities` 含 `image`)時會直接看圖;面板「模型與接口來源」可換模型。
|
|
32
|
+
- 模型不支援時,插件會先用 modlens 描述,再改用另一個視覺模型,最後才退回貼圖關鍵字。
|
|
33
|
+
- modlens 可在「進階 → 診斷」看 `vision` check;失敗通常是 provider 額度或 503。一般圖片可在面板「LINE 憑證」關閉「看得懂一般圖片」。
|
|
33
34
|
|
|
34
35
|
### APNG 不會動
|
|
35
36
|
- LINE 不播放 APNG,只顯示第一幀。保持 `stickers.apng=false` 即可。
|
package/client.js
CHANGED
|
@@ -304,6 +304,12 @@ window.__ModuleLoader__.load({
|
|
|
304
304
|
|
|
305
305
|
const ACTION_LABELS = {
|
|
306
306
|
'meme-stats': '讀取表情包圖庫',
|
|
307
|
+
'access-list': '讀取待審核名單',
|
|
308
|
+
'access-mode': '切換身分門控',
|
|
309
|
+
'access-approve': '核准使用者',
|
|
310
|
+
'access-deny': '拒絕使用者',
|
|
311
|
+
'access-add': '加入白名單',
|
|
312
|
+
'access-remove': '移出白名單',
|
|
307
313
|
start: '啟動',
|
|
308
314
|
stop: '停止',
|
|
309
315
|
restart: '重新啟動',
|
|
@@ -724,6 +730,7 @@ window.__ModuleLoader__.load({
|
|
|
724
730
|
|
|
725
731
|
const cfg = draft
|
|
726
732
|
const line = cfg.line || {}
|
|
733
|
+
const access = status.access || {}
|
|
727
734
|
const server = cfg.server || {}
|
|
728
735
|
const tunnel = cfg.tunnel || {}
|
|
729
736
|
const cloudflare = cfg.cloudflare || {}
|
|
@@ -968,7 +975,9 @@ window.__ModuleLoader__.load({
|
|
|
968
975
|
),
|
|
969
976
|
h(Toggle, { checked: line.useAutoToken !== false, onChange: (value) => update({ line: { useAutoToken: value } }), label: '自動簽發 access token', hint: '有 Channel ID+Secret 時,優先自動取得(建議開啟)' }),
|
|
970
977
|
h(Toggle, { checked: line.loadingEnabled !== false, onChange: (value) => update({ line: { loadingEnabled: value } }), label: '顯示「正在輸入」動畫', hint: '回覆前呼叫 LINE 的 loading 動畫' }),
|
|
971
|
-
h(Toggle, { checked: line.stickerVision !== false, onChange: (value) => update({ line: { stickerVision: value } }), label: '看得懂貼圖', hint: '
|
|
978
|
+
h(Toggle, { checked: line.stickerVision !== false, onChange: (value) => update({ line: { stickerVision: value } }), label: '看得懂貼圖', hint: '收到貼圖時抓圖片給模型看;模型不支援圖片時改用 modlens 描述,再不支援才用關鍵字' }),
|
|
979
|
+
h(Toggle, { checked: line.imageVision !== false, onChange: (value) => update({ line: { imageVision: value } }), label: '看得懂一般圖片', hint: '收到圖片時下載給模型看;模型不支援圖片時用 modlens 產生描述' }),
|
|
980
|
+
h('div', { style: S.note }, `看圖方式:${runtime.vision?.modelSupportsImages ? '目前模型可直接看圖' : runtime.vision?.available ? '目前模型不吃圖,改用 modlens 描述' : '目前模型不吃圖,且找不到 modlens'}${runtime.vision?.lastError ? `(modlens 最近錯誤:${runtime.vision.lastError})` : ''}`),
|
|
972
981
|
h('div', { style: S.row },
|
|
973
982
|
h(Btn, { onClick: () => act('line-verify'), disabled: !!busy, tone: 'primary' }, busy === 'line-verify' ? '驗證中…' : '驗證並讀取 bot 資訊'),
|
|
974
983
|
h(Btn, { onClick: () => act('token-refresh'), disabled: !!busy }, '重新簽發 token'),
|
|
@@ -989,7 +998,16 @@ window.__ModuleLoader__.load({
|
|
|
989
998
|
h(Btn, { onClick: () => act('webhook-test'), disabled: !!busy }, '測試 webhook'),
|
|
990
999
|
),
|
|
991
1000
|
h('div', { style: S.hint }, '「Use webhook」開關只能在 LINE console 的 Messaging API 設定頁手動開啟;端點本身由插件自動設定。'),
|
|
992
|
-
h(
|
|
1001
|
+
h(
|
|
1002
|
+
'div',
|
|
1003
|
+
{ style: S.grid },
|
|
1004
|
+
h(Field, { label: '身分門控', hint: 'approval=未授權者進待審核;whitelist=直接忽略未授權者' },
|
|
1005
|
+
h('select', { value: line.accessMode || 'approval', onChange: (event) => update({ line: { accessMode: event.target.value } }), style: S.input },
|
|
1006
|
+
h('option', { value: 'approval' }, 'approval(待審核)'),
|
|
1007
|
+
h('option', { value: 'whitelist' }, 'whitelist(直接拒絕)'))),
|
|
1008
|
+
h(Field, { label: '白名單使用者 ID', hint: '一行一個;留空=沒有人能使用。未授權者會出現在下方「使用者」待審核。' }, h(Area, { value: textList(line.allowedUserIds), onChange: (value) => update({ line: { allowedUserIds: parseList(value) } }), rows: 2, mono: true })),
|
|
1009
|
+
),
|
|
1010
|
+
h('div', { style: access.gateOpen ? S.hint : S.warnBox }, access.gateOpen ? `目前已核准 ${(access.allowedUserIds || []).length} 人;未授權者不能觸發 Agent。` : '白名單目前是空的:bot 不會回覆任何人。請先讓使用者傳訊息,再到下方「使用者」核准。'),
|
|
993
1011
|
),
|
|
994
1012
|
|
|
995
1013
|
// 3. Cloudflare Tunnel
|
|
@@ -1287,6 +1305,18 @@ window.__ModuleLoader__.load({
|
|
|
1287
1305
|
h(
|
|
1288
1306
|
Card,
|
|
1289
1307
|
{ id: sectionDomId('users'), order: 12, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
|
|
1308
|
+
(access.pending || []).length > 0
|
|
1309
|
+
? h('div', { style: S.panelBox },
|
|
1310
|
+
h('div', { style: S.fieldTitle }, `待審核(${access.pending.length})`),
|
|
1311
|
+
h('div', { style: S.hint }, '這些人的訊息不會觸發 Agent;核准後會加入白名單並通知對方。'),
|
|
1312
|
+
(access.pending || []).map((item) => h('div', { key: item.userId, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
1313
|
+
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
1314
|
+
h('div', null, item.displayName || '(未命名)'),
|
|
1315
|
+
h('div', { style: { ...S.hint, ...S.mono, wordBreak: 'break-all' } }, item.userId),
|
|
1316
|
+
item.lastText ? h('div', { style: S.hint }, item.lastText) : null),
|
|
1317
|
+
h(Btn, { onClick: () => act('access-approve', { userId: item.userId }), disabled: !!busy, tone: 'primary' }, '核准'),
|
|
1318
|
+
h(Btn, { onClick: () => act('access-deny', { userId: item.userId }), disabled: !!busy }, '拒絕'))))
|
|
1319
|
+
: null,
|
|
1290
1320
|
(status.users || []).length === 0
|
|
1291
1321
|
? h('div', { style: S.hint }, '還沒有人傳訊息給 bot。')
|
|
1292
1322
|
: h('table', { style: S.table },
|
package/lib/config.mjs
CHANGED
|
@@ -26,6 +26,8 @@ export const DEFAULT_CONFIG = {
|
|
|
26
26
|
useAutoToken: true,
|
|
27
27
|
loadingEnabled: true,
|
|
28
28
|
stickerVision: true,
|
|
29
|
+
imageVision: true,
|
|
30
|
+
accessMode: 'approval', // approval | whitelist
|
|
29
31
|
allowedUserIds: [],
|
|
30
32
|
welcomeText: '(已加好友)嗨,我是大肥魚~想聊什麼都可以跟我說。',
|
|
31
33
|
errorReply: '(剛剛卡住了,再說一次好嗎?)',
|
|
@@ -193,9 +195,11 @@ export function sanitizePatch(current, patch) {
|
|
|
193
195
|
next.line.channelId = String(line.channelId || '').trim()
|
|
194
196
|
next.line.accessToken = String(next.line.accessToken || '').trim()
|
|
195
197
|
next.line.tokenExpiresAt = clampInt(next.line.tokenExpiresAt, 0, Number.MAX_SAFE_INTEGER, 0)
|
|
196
|
-
next.line.allowedUserIds = safeArray(next.line.allowedUserIds)
|
|
198
|
+
next.line.allowedUserIds = safeArray(next.line.allowedUserIds).map((id) => String(id).trim()).filter(Boolean)
|
|
199
|
+
next.line.accessMode = ['approval','whitelist'].includes(next.line.accessMode) ? next.line.accessMode : 'approval'
|
|
197
200
|
next.line.loadingEnabled = Boolean(next.line.loadingEnabled)
|
|
198
201
|
next.line.stickerVision = Boolean(next.line.stickerVision)
|
|
202
|
+
next.line.imageVision = Boolean(next.line.imageVision)
|
|
199
203
|
next.line.welcomeText = String(next.line.welcomeText ?? '').slice(0, 1000)
|
|
200
204
|
next.line.errorReply = String(next.line.errorReply ?? '').slice(0, 500)
|
|
201
205
|
next.line.emptyReply = String(next.line.emptyReply ?? '').slice(0, 500)
|
|
@@ -347,6 +351,7 @@ export function normalizeState(raw) {
|
|
|
347
351
|
users: isPlainObject(state.users) ? state.users : {},
|
|
348
352
|
groups: isPlainObject(state.groups) ? state.groups : {},
|
|
349
353
|
people: isPlainObject(state.people) ? state.people : {},
|
|
354
|
+
pendingUsers: isPlainObject(state.pendingUsers) ? state.pendingUsers : {},
|
|
350
355
|
nudge: isPlainObject(state.nudge) ? state.nudge : {},
|
|
351
356
|
balance: isPlainObject(state.balance) ? state.balance : {},
|
|
352
357
|
memory: isPlainObject(state.memory) ? state.memory : {},
|
package/lib/host.mjs
CHANGED
|
@@ -27,6 +27,7 @@ const jokesMod = await import(withVersion('./jokes.mjs'))
|
|
|
27
27
|
const stickersMod = await import(withVersion('./stickers.mjs'))
|
|
28
28
|
const memeSourceMod = await import(withVersion('./meme-source.mjs'))
|
|
29
29
|
const stickerImageMod = await import(withVersion('./sticker-image.mjs'))
|
|
30
|
+
const visionMod = await import(withVersion('./vision.mjs'))
|
|
30
31
|
const utilMod = await import(withVersion('./util.mjs'))
|
|
31
32
|
const { createConfigStore, publicConfig, dataDir, dshHome } = configMod
|
|
32
33
|
const { createPersonaClient } = personaMod
|
|
@@ -43,9 +44,10 @@ const { createJokeStore } = jokesMod
|
|
|
43
44
|
const { createStickerStore } = stickersMod
|
|
44
45
|
const { createMemeSource } = memeSourceMod
|
|
45
46
|
const { createStickerImages } = stickerImageMod
|
|
47
|
+
const { createVision } = visionMod
|
|
46
48
|
const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep, readJsonSafe } = utilMod
|
|
47
49
|
|
|
48
|
-
export const VERSION = '0.9.
|
|
50
|
+
export const VERSION = '0.9.3'
|
|
49
51
|
|
|
50
52
|
// 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
|
|
51
53
|
// 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
|
|
@@ -362,7 +364,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
362
364
|
const cooldownMs = Math.max(1, Number(cfg.balance.cooldownHours) || 12) * 3600_000
|
|
363
365
|
if (!force && lastAskAt && Date.now() - lastAskAt < cooldownMs) return { skipped: 'cooldown', lastAskAt }
|
|
364
366
|
if (!force && inQuietWindow(Date.now(), cfg.nudge.quietStart, cfg.nudge.quietEnd)) return { skipped: 'quiet-hours' }
|
|
365
|
-
const candidates = Object.entries(state.users || {}).filter(([, user]) => user?.sessionId)
|
|
367
|
+
const candidates = Object.entries(state.users || {}).filter(([userId, user]) => user?.sessionId && isAllowed(userId, cfg))
|
|
366
368
|
const targetIds = safeArray(cfg.balance?.targetUserIds).length ? safeArray(cfg.balance?.targetUserIds) : safeArray(cfg.nudge?.targetUserIds)
|
|
367
369
|
const preferred = targetIds.find((id) => candidates.some(([key]) => key === id))
|
|
368
370
|
const picked = preferred ? candidates.find(([key]) => key === preferred) : [...candidates].sort((a, b) => (a[1].lastAt || 0) - (b[1].lastAt || 0))[0]
|
|
@@ -445,7 +447,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
445
447
|
const state = store.getState()
|
|
446
448
|
const targets = userId
|
|
447
449
|
? [[userId, state.users?.[userId]]]
|
|
448
|
-
: Object.entries(state.users || {})
|
|
450
|
+
: Object.entries(state.users || {}).filter(([id]) => isAllowed(id, cfg))
|
|
449
451
|
const reviewed = []
|
|
450
452
|
for (const [id, user] of targets) {
|
|
451
453
|
if (!user?.sessionId) continue
|
|
@@ -990,9 +992,77 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
990
992
|
})
|
|
991
993
|
}
|
|
992
994
|
|
|
993
|
-
function isAllowed(userId, cfg) {
|
|
995
|
+
function isAllowed(userId, cfg = store.get()) {
|
|
994
996
|
const list = cfg.line.allowedUserIds || []
|
|
995
|
-
return list.length
|
|
997
|
+
return list.length > 0 && list.includes(userId)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function pendingUsersList() {
|
|
1001
|
+
return Object.values(store.getState().pendingUsers || {}).sort((a, b) => (Number(b.at) || 0) - (Number(a.at) || 0))
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function queuePendingUser(userId, patch = {}) {
|
|
1005
|
+
const existing = store.getState().pendingUsers?.[userId]
|
|
1006
|
+
if (existing) {
|
|
1007
|
+
if (patch.displayName || patch.lastText) {
|
|
1008
|
+
store.updateState((state) => ({
|
|
1009
|
+
...state,
|
|
1010
|
+
pendingUsers: {
|
|
1011
|
+
...(state.pendingUsers || {}),
|
|
1012
|
+
[userId]: { ...existing, displayName: patch.displayName || existing.displayName || '', lastText: patch.lastText || existing.lastText || '' },
|
|
1013
|
+
},
|
|
1014
|
+
}))
|
|
1015
|
+
}
|
|
1016
|
+
return existing
|
|
1017
|
+
}
|
|
1018
|
+
const entry = { userId, displayName: patch.displayName || '', lastText: patch.lastText || '', at: Date.now() }
|
|
1019
|
+
store.updateState((state) => ({ ...state, pendingUsers: { ...(state.pendingUsers || {}), [userId]: entry } }))
|
|
1020
|
+
logger.warn(`待審核使用者 ${userId}${entry.displayName ? `(${entry.displayName})` : ''}`)
|
|
1021
|
+
return entry
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function removePendingUser(userId) {
|
|
1025
|
+
store.updateState((state) => {
|
|
1026
|
+
if (!state.pendingUsers?.[userId]) return state
|
|
1027
|
+
const next = { ...state.pendingUsers }
|
|
1028
|
+
delete next[userId]
|
|
1029
|
+
return { ...state, pendingUsers: next }
|
|
1030
|
+
})
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async function requireAccess(userId, { replyToken = '', sourceKind = 'user', text = '' } = {}) {
|
|
1034
|
+
const cfg = store.get()
|
|
1035
|
+
if (isAllowed(userId, cfg)) return true
|
|
1036
|
+
const mode = cfg.line.accessMode || 'approval'
|
|
1037
|
+
const allowed = cfg.line.allowedUserIds || []
|
|
1038
|
+
logger.warn(`拒絕未授權使用者 ${userId}(accessMode=${mode},白名單 ${allowed.length} 人)`)
|
|
1039
|
+
if (mode !== 'approval') return false
|
|
1040
|
+
const first = !store.getState().pendingUsers?.[userId]
|
|
1041
|
+
const displayName = await ensurePerson(userId).catch(() => '')
|
|
1042
|
+
queuePendingUser(userId, { displayName: displayName || personName(userId), lastText: clip(text || '', 160) })
|
|
1043
|
+
if (first && sourceKind === 'user') {
|
|
1044
|
+
await replyOrPush(userId, replyToken, '已送出使用申請;等主人核准後就能聊天。').catch((error) => logger.warn(`待審核通知失敗:${errorText(error)}`))
|
|
1045
|
+
}
|
|
1046
|
+
return false
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function approveUser(userId) {
|
|
1050
|
+
const id = String(userId || '').trim()
|
|
1051
|
+
if (!id) throw new Error('缺少 userId')
|
|
1052
|
+
const list = safeArray(store.get().line.allowedUserIds)
|
|
1053
|
+
if (!list.includes(id)) store.patch({ line: { allowedUserIds: [...list, id] } })
|
|
1054
|
+
removePendingUser(id)
|
|
1055
|
+
logger.info(`已核准使用者 ${id}`)
|
|
1056
|
+
sendPush(id, '主人核准了,現在可以正常聊天了。').catch((error) => logger.warn(`通知核准失敗:${errorText(error)}`))
|
|
1057
|
+
return { userId: id, allowed: true, allowedUserIds: store.get().line.allowedUserIds }
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function denyUser(userId) {
|
|
1061
|
+
const id = String(userId || '').trim()
|
|
1062
|
+
if (!id) throw new Error('缺少 userId')
|
|
1063
|
+
removePendingUser(id)
|
|
1064
|
+
logger.info(`已拒絕使用者 ${id}`)
|
|
1065
|
+
return { userId: id, denied: true }
|
|
996
1066
|
}
|
|
997
1067
|
|
|
998
1068
|
// ── LINE 端的「人設」指令(只改自己的) ────────────────────────────────
|
|
@@ -1456,7 +1526,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1456
1526
|
const sourceKind = source.type === 'group' ? 'group' : source.type === 'room' ? 'room' : 'user'
|
|
1457
1527
|
const chatId = sourceKind === 'group' ? source.groupId : sourceKind === 'room' ? source.roomId : userId
|
|
1458
1528
|
if (type === 'follow' && userId) {
|
|
1459
|
-
if (!
|
|
1529
|
+
if (!(await requireAccess(userId, { replyToken: event.replyToken, sourceKind: 'user' }))) return
|
|
1460
1530
|
const welcome = cfg.line.welcomeText || ''
|
|
1461
1531
|
if (welcome && event.replyToken) {
|
|
1462
1532
|
await replyOrPush(userId, event.replyToken, welcome).catch((error) => logger.warn(`歡迎訊息失敗:${errorText(error)}`))
|
|
@@ -1465,11 +1535,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1465
1535
|
}
|
|
1466
1536
|
if (type !== 'message') return
|
|
1467
1537
|
if (!userId || !chatId) return
|
|
1468
|
-
if (!isAllowed(userId, cfg)) {
|
|
1469
|
-
logger.warn(`略過未授權的使用者 ${userId}(allowedUserIds)`)
|
|
1470
|
-
return
|
|
1471
|
-
}
|
|
1472
1538
|
const messageType = event.message?.type
|
|
1539
|
+
if (!(await requireAccess(userId, { replyToken: event.replyToken, sourceKind, text: messageType === 'text' ? String(event.message?.text || '') : '' }))) return
|
|
1473
1540
|
const messageImages = []
|
|
1474
1541
|
let text = ''
|
|
1475
1542
|
if (messageType === 'text') {
|
|
@@ -1478,25 +1545,90 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1478
1545
|
const keywords = Array.isArray(event.message.keywords)
|
|
1479
1546
|
? event.message.keywords.filter(Boolean).slice(0, 8).join('、')
|
|
1480
1547
|
: ''
|
|
1548
|
+
const supportsImages = await modelSupportsImages()
|
|
1481
1549
|
let hasImage = false
|
|
1550
|
+
let described = ''
|
|
1482
1551
|
if (cfg.line?.stickerVision !== false) {
|
|
1483
1552
|
try {
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1553
|
+
if (supportsImages) {
|
|
1554
|
+
const ref = await stickerImages.toAttachment(event.message.stickerId, event.message.stickerResourceType)
|
|
1555
|
+
if (ref) {
|
|
1556
|
+
messageImages.push(ref)
|
|
1557
|
+
hasImage = true
|
|
1558
|
+
}
|
|
1559
|
+
} else {
|
|
1560
|
+
let description = ''
|
|
1561
|
+
const { data } = await stickerImages.fetchBytes(event.message.stickerId, event.message.stickerResourceType)
|
|
1562
|
+
const file = writeIncomingImage(Buffer.from(data), `sticker-${event.message.stickerId}-${Date.now()}.png`)
|
|
1563
|
+
if (vision.available()) {
|
|
1564
|
+
try {
|
|
1565
|
+
description = await describeImageFile(file, '這是一張 LINE 貼圖,請用繁體中文簡短描述圖案與情緒。')
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
logger.warn(`modlens 看貼圖失敗,改用視覺模型:${errorText(error)}`)
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
if (!description) {
|
|
1571
|
+
try {
|
|
1572
|
+
const ref = await stickerImages.saveBuffer(Buffer.from(data), `line-sticker-${event.message.stickerId}.png`, 'image/png')
|
|
1573
|
+
description = await describeWithVisionModel(ref)
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
logger.warn(`視覺模型看貼圖失敗:${errorText(error)}`)
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
described = description
|
|
1488
1579
|
}
|
|
1489
1580
|
} catch (error) {
|
|
1490
|
-
logger.warn(
|
|
1581
|
+
logger.warn(`取得貼圖圖片或看圖失敗,只用關鍵字:${errorText(error)}`)
|
|
1491
1582
|
}
|
|
1492
1583
|
}
|
|
1493
1584
|
text = [
|
|
1494
1585
|
`(使用者沒有打字,傳了一個 LINE 貼圖:packageId=${event.message.packageId ?? '?'}, stickerId=${event.message.stickerId ?? '?'}`,
|
|
1495
1586
|
keywords ? `,貼圖關鍵字:${keywords}` : '',
|
|
1496
|
-
hasImage ? ',貼圖圖片已附在這一則訊息裡(請直接看圖)' : '',
|
|
1587
|
+
hasImage ? ',貼圖圖片已附在這一則訊息裡(請直接看圖)' : described ? `,貼圖內容描述:${described}` : '',
|
|
1497
1588
|
'。請用你自己的語氣,一兩句自然回應這個貼圖;不要說「你傳了貼圖」這種話,依圖片內容或關鍵字猜情緒,不確定就輕鬆帶過。)',
|
|
1498
1589
|
].join('')
|
|
1499
|
-
logger.info(`收到貼圖(packageId=${event.message.packageId ?? '?'}, stickerId=${event.message.stickerId ?? '?'}${keywords ? `,${keywords}` : ''})`)
|
|
1590
|
+
logger.info(`收到貼圖(packageId=${event.message.packageId ?? '?'}, stickerId=${event.message.stickerId ?? '?'}${keywords ? `,${keywords}` : ''}${hasImage ? ',已附圖' : described ? ',已取得圖片描述' : ''})`)
|
|
1591
|
+
} else if (messageType === 'image') {
|
|
1592
|
+
if (cfg.line?.imageVision === false) {
|
|
1593
|
+
logger.info('略過圖片(line.imageVision 關閉)')
|
|
1594
|
+
return
|
|
1595
|
+
}
|
|
1596
|
+
try {
|
|
1597
|
+
const messageId = event.message?.id
|
|
1598
|
+
if (!messageId) throw new Error('缺少 messageId')
|
|
1599
|
+
const buffer = await withToken((token) => lineApi.getMessageContent(token, messageId))
|
|
1600
|
+
const mediaType = imageMediaType(buffer)
|
|
1601
|
+
if (await modelSupportsImages()) {
|
|
1602
|
+
const ref = await stickerImages.saveBuffer(buffer, `line-image-${messageId}.${mediaType.split('/')[1]}`, mediaType)
|
|
1603
|
+
messageImages.push(ref)
|
|
1604
|
+
text = '(使用者傳了一張圖片,圖片已附在這一則訊息裡,請直接看圖並用你自己的語氣自然回應;不要說「你傳了圖片」這種話。)'
|
|
1605
|
+
} else {
|
|
1606
|
+
let description = ''
|
|
1607
|
+
if (vision.available()) {
|
|
1608
|
+
try {
|
|
1609
|
+
const file = writeIncomingImage(buffer, `incoming-${messageId}.${mediaType.split('/')[1]}`)
|
|
1610
|
+
description = await describeImageFile(file, '這是一張 LINE 對話中傳來的圖片,請用繁體中文簡潔描述內容與情緒。')
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
logger.warn(`modlens 看圖片失敗,改用視覺模型:${errorText(error)}`)
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
if (!description) {
|
|
1616
|
+
try {
|
|
1617
|
+
const ref = await stickerImages.saveBuffer(buffer, `line-image-${messageId}.${mediaType.split('/')[1]}`, mediaType)
|
|
1618
|
+
description = await describeWithVisionModel(ref)
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
logger.warn(`視覺模型看圖片失敗:${errorText(error)}`)
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
text = description
|
|
1624
|
+
? `(使用者傳了一張圖片。圖片內容描述:${description}。請依這個描述用你自己的語氣自然回應,不要說「你傳了圖片」這種話。)`
|
|
1625
|
+
: '(使用者傳了一張圖片,但目前模型不支援看圖、modlens 也不可用;請用文字輕輕帶過,不要假裝看到內容。)'
|
|
1626
|
+
}
|
|
1627
|
+
logger.info(`收到圖片(messageId=${messageId},${buffer.length} bytes)`)
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
logger.warn(`處理圖片失敗:${errorText(error)}`)
|
|
1630
|
+
text = '(使用者傳了一張圖片,但下載或辨識失敗;請用文字輕輕帶過,不要假裝看到內容。)'
|
|
1631
|
+
}
|
|
1500
1632
|
} else {
|
|
1501
1633
|
logger.info(`略過非文字訊息(${messageType})`)
|
|
1502
1634
|
return
|
|
@@ -1609,7 +1741,98 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1609
1741
|
const stickers = createStickerStore({ dir, logger })
|
|
1610
1742
|
const memes = createMemeSource({ ctx, logger })
|
|
1611
1743
|
const stickerImages = createStickerImages({ ctx, logger, dir })
|
|
1744
|
+
const vision = createVision({ logger, cacheDir: path.join(dir, 'cache') })
|
|
1612
1745
|
const mediaTokens = new Map()
|
|
1746
|
+
let imageSupportCache = { at: 0, key: '', value: false }
|
|
1747
|
+
|
|
1748
|
+
async function modelSupportsImages() {
|
|
1749
|
+
const selection = modelSelection()
|
|
1750
|
+
const key = `${selection.provider}/${selection.model}`
|
|
1751
|
+
if (imageSupportCache.key === key && Date.now() - imageSupportCache.at < 60_000) return imageSupportCache.value
|
|
1752
|
+
let value = false
|
|
1753
|
+
try {
|
|
1754
|
+
const llm = ctx.get?.('llm')
|
|
1755
|
+
if (llm?.resolveModelInfo && selection.provider && selection.model) {
|
|
1756
|
+
const info = await llm.resolveModelInfo(selection.provider, selection.model)
|
|
1757
|
+
value = Array.isArray(info?.inputModalities) && info.inputModalities.includes('image')
|
|
1758
|
+
}
|
|
1759
|
+
if (!value) {
|
|
1760
|
+
const catalog = await modelCatalog({ force: false }).catch(() => null)
|
|
1761
|
+
const provider = catalog?.providers?.find((item) => item.id === selection.provider)
|
|
1762
|
+
const model = provider?.models?.find((item) => item.id === selection.model)
|
|
1763
|
+
value = Array.isArray(model?.inputModalities) && model.inputModalities.includes('image')
|
|
1764
|
+
}
|
|
1765
|
+
} catch (error) {
|
|
1766
|
+
logger.warn(`查模型圖片能力失敗:${errorText(error)}`)
|
|
1767
|
+
}
|
|
1768
|
+
imageSupportCache = { at: Date.now(), key, value }
|
|
1769
|
+
return value
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function imageMediaType(buffer) {
|
|
1773
|
+
const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || [])
|
|
1774
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
|
|
1775
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50) return 'image/png'
|
|
1776
|
+
if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
|
|
1777
|
+
if (bytes.length >= 6 && bytes.toString('ascii', 0, 3) === 'GIF') return 'image/gif'
|
|
1778
|
+
return 'image/png'
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function writeIncomingImage(buffer, name) {
|
|
1782
|
+
const file = path.join(dir, 'cache', name)
|
|
1783
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
1784
|
+
fs.writeFileSync(file, buffer)
|
|
1785
|
+
return file
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
async function describeImageFile(file, prompt) {
|
|
1789
|
+
const described = await vision.describe(file, { prompt })
|
|
1790
|
+
return described.text
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
async function describeWithVisionModel(ref) {
|
|
1794
|
+
const llm = ctx.get?.('llm')
|
|
1795
|
+
if (!llm?.stream || !ref) return ''
|
|
1796
|
+
const catalog = await modelCatalog({ force: false }).catch(() => null)
|
|
1797
|
+
const wanted = [
|
|
1798
|
+
['cguair', 'gpt-6.1-terra'],
|
|
1799
|
+
['cguair', 'gpt-6.1-sol'],
|
|
1800
|
+
['deepseek-account', 'deepseek-flash'],
|
|
1801
|
+
['deepseek-official', 'deepseek-flash'],
|
|
1802
|
+
['deepseek-modlens', 'deepseek-v4-pro'],
|
|
1803
|
+
]
|
|
1804
|
+
const available = []
|
|
1805
|
+
for (const [provider, model] of wanted) {
|
|
1806
|
+
const item = catalog?.providers?.find((entry) => entry.id === provider)?.models?.find((entry) => entry.id === model)
|
|
1807
|
+
if (item && Array.isArray(item.inputModalities) && item.inputModalities.includes('image')) available.push({ provider, model })
|
|
1808
|
+
}
|
|
1809
|
+
for (const entry of available) {
|
|
1810
|
+
try {
|
|
1811
|
+
let text = ''
|
|
1812
|
+
const stream = llm.stream({
|
|
1813
|
+
provider: entry.provider,
|
|
1814
|
+
model: entry.model,
|
|
1815
|
+
maxTokens: 220,
|
|
1816
|
+
messages: [
|
|
1817
|
+
{
|
|
1818
|
+
role: 'user',
|
|
1819
|
+
content: [
|
|
1820
|
+
{ type: 'text', text: '請用繁體中文簡短描述這張圖片的內容與情緒,兩句以內,不要客套。' },
|
|
1821
|
+
{ type: 'image', attachment: ref },
|
|
1822
|
+
],
|
|
1823
|
+
},
|
|
1824
|
+
],
|
|
1825
|
+
})
|
|
1826
|
+
for await (const chunk of stream) {
|
|
1827
|
+
if (chunk?.type === 'text-delta') text += chunk.text || ''
|
|
1828
|
+
}
|
|
1829
|
+
if (text.trim()) return text.trim()
|
|
1830
|
+
} catch (error) {
|
|
1831
|
+
logger.warn(`用 ${entry.provider}/${entry.model} 看圖失敗:${errorText(error)}`)
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
return ''
|
|
1835
|
+
}
|
|
1613
1836
|
|
|
1614
1837
|
const server = createWebhookServer({ getConfig: () => store.get(), logger, dispatch: handleEvent, getMedia, getDoctor: (options) => runDiagnostics(options) })
|
|
1615
1838
|
|
|
@@ -1877,10 +2100,16 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1877
2100
|
const modalities = info?.inputModalities || []
|
|
1878
2101
|
checks.push(check('model', '回覆模型', 'ok',
|
|
1879
2102
|
`${selected.provider}/${selected.model}${modalities.length ? `(${modalities.join('+')})` : ''}`,
|
|
1880
|
-
modalities.includes('image') ? '' : '
|
|
2103
|
+
modalities.includes('image') ? '' : '這個模型不吃圖;會改用 modlens 或另一個視覺模型描述圖片,失敗才退回關鍵字'))
|
|
1881
2104
|
} catch (error) {
|
|
1882
2105
|
checks.push(check('model', '回覆模型', 'warn', errorText(error), '在面板「模型與接口來源」重新選模型'))
|
|
1883
2106
|
}
|
|
2107
|
+
{
|
|
2108
|
+
const status = vision.status()
|
|
2109
|
+
checks.push(check('vision', '看圖能力(modlens)', status.available ? 'ok' : 'warn',
|
|
2110
|
+
status.available ? `modlens 可用${status.lastError ? `;最近錯誤:${status.lastError}` : ''}` : '找不到 modlens;模型不吃圖時會退回關鍵字',
|
|
2111
|
+
status.available ? '' : '安裝 @liustack/modlens,或改用支援圖片的模型'))
|
|
2112
|
+
}
|
|
1884
2113
|
if (memeStats.available && base) {
|
|
1885
2114
|
try {
|
|
1886
2115
|
const sample = memes.sample({ limit: 1 })[0]
|
|
@@ -2036,6 +2265,12 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2036
2265
|
items: stickers.list(),
|
|
2037
2266
|
warning: publicBaseSync() ? '' : '尚未取得公開網址(Cloudflare tunnel 未啟動)',
|
|
2038
2267
|
},
|
|
2268
|
+
vision: {
|
|
2269
|
+
...vision.status(),
|
|
2270
|
+
modelSupportsImages: await modelSupportsImages(),
|
|
2271
|
+
imageVision: cfg.line?.imageVision !== false,
|
|
2272
|
+
stickerVision: cfg.line?.stickerVision !== false,
|
|
2273
|
+
},
|
|
2039
2274
|
memory: {
|
|
2040
2275
|
...memory.stats(),
|
|
2041
2276
|
enabled: cfg.memory?.enabled !== false,
|
|
@@ -2055,6 +2290,12 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2055
2290
|
entries: jokes.list().slice(0, 200),
|
|
2056
2291
|
},
|
|
2057
2292
|
sync: { ...tiebaSync.stats(), config: cfg.sync },
|
|
2293
|
+
access: {
|
|
2294
|
+
mode: cfg.line.accessMode || 'approval',
|
|
2295
|
+
allowedUserIds: safeArray(cfg.line.allowedUserIds),
|
|
2296
|
+
gateOpen: safeArray(cfg.line.allowedUserIds).length > 0,
|
|
2297
|
+
pending: pendingUsersList().map((item) => ({ userId: item.userId, displayName: item.displayName || '', at: Number(item.at) || 0, lastText: item.lastText || '' })),
|
|
2298
|
+
},
|
|
2058
2299
|
users,
|
|
2059
2300
|
groups,
|
|
2060
2301
|
logs: logger.lines(),
|
|
@@ -2259,6 +2500,47 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2259
2500
|
return maybeAskForTopUp({ dryRun: payload.dryRun !== false, force: payload.force === true })
|
|
2260
2501
|
case 'meme-stats':
|
|
2261
2502
|
return { ...memes.stats(), enabled: store.get().stickers?.enabled !== false, maxPerReply: memeMaxPerReply(), sample: memes.sample({ limit: 20 }) }
|
|
2503
|
+
case 'vision-model-look': {
|
|
2504
|
+
const url = String(payload.url || '').trim()
|
|
2505
|
+
if (!url) throw new Error('缺少 url')
|
|
2506
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(20_000) })
|
|
2507
|
+
if (!response.ok) throw new Error(`下載圖片失敗:HTTP ${response.status}`)
|
|
2508
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
2509
|
+
const mediaType = imageMediaType(buffer)
|
|
2510
|
+
const ref = await stickerImages.saveBuffer(buffer, `vision-model-${Date.now()}.${mediaType.split('/')[1]}`, mediaType)
|
|
2511
|
+
return { text: await describeWithVisionModel(ref), attachmentId: String(ref.attachmentId || '') }
|
|
2512
|
+
}
|
|
2513
|
+
case 'image-look': {
|
|
2514
|
+
const url = String(payload.url || '').trim()
|
|
2515
|
+
if (!url) throw new Error('缺少 url')
|
|
2516
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(20_000) })
|
|
2517
|
+
if (!response.ok) throw new Error(`下載圖片失敗:HTTP ${response.status}`)
|
|
2518
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
2519
|
+
if (buffer.length > 10 * 1024 * 1024) throw new Error('圖片超過 10MB')
|
|
2520
|
+
const mediaType = imageMediaType(buffer)
|
|
2521
|
+
const ref = await stickerImages.saveBuffer(buffer, `test-image-${Date.now()}.${mediaType.split('/')[1]}`, mediaType)
|
|
2522
|
+
const result = await runner.turn({
|
|
2523
|
+
sessionId: `session-image-look-${Date.now()}`,
|
|
2524
|
+
text: '請用一句繁體中文描述這張圖片的內容與情緒。',
|
|
2525
|
+
images: [ref],
|
|
2526
|
+
contextText: '',
|
|
2527
|
+
cwd: store.get().agent.cwd || undefined,
|
|
2528
|
+
})
|
|
2529
|
+
return { text: result.text, attachmentId: String(ref.attachmentId || ''), mediaType }
|
|
2530
|
+
}
|
|
2531
|
+
case 'vision-describe': {
|
|
2532
|
+
const url = String(payload.url || '').trim()
|
|
2533
|
+
const stickerId = String(payload.stickerId || '').trim()
|
|
2534
|
+
const file = String(payload.file || '').trim()
|
|
2535
|
+
if (!url && !stickerId && !file) throw new Error('需要 url、stickerId 或 file')
|
|
2536
|
+
let target = url || file
|
|
2537
|
+
if (stickerId) {
|
|
2538
|
+
const { data } = await stickerImages.fetchBytes(stickerId, payload.stickerResourceType || 'STATIC')
|
|
2539
|
+
target = writeIncomingImage(Buffer.from(data), `vision-${stickerId}-${Date.now()}.png`)
|
|
2540
|
+
}
|
|
2541
|
+
const described = await vision.describe(target, { prompt: String(payload.prompt || '').trim() })
|
|
2542
|
+
return { text: described.text, vision: vision.status() }
|
|
2543
|
+
}
|
|
2262
2544
|
case 'sticker-look': {
|
|
2263
2545
|
const stickerId = String(payload.stickerId || '').trim()
|
|
2264
2546
|
if (!stickerId) throw new Error('缺少 stickerId')
|
|
@@ -2394,6 +2676,32 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2394
2676
|
const user = ensureUser(userId)
|
|
2395
2677
|
return { userId, sessionId: user.sessionId, personaId: user.personaId || store.get().persona.id, workspaceDir: user.workspaceDir || '', perUserWorkspace: Boolean(store.get().agent.perUserWorkspace) }
|
|
2396
2678
|
}
|
|
2679
|
+
case 'access-list':
|
|
2680
|
+
return { mode: store.get().line.accessMode || 'approval', allowedUserIds: safeArray(store.get().line.allowedUserIds), pending: pendingUsersList() }
|
|
2681
|
+
case 'access-mode': {
|
|
2682
|
+
const mode = String(payload.mode || '').trim()
|
|
2683
|
+
if (!['approval', 'whitelist'].includes(mode)) throw new Error('mode 只能是 approval 或 whitelist')
|
|
2684
|
+
store.patch({ line: { accessMode: mode } })
|
|
2685
|
+
return { mode }
|
|
2686
|
+
}
|
|
2687
|
+
case 'access-approve':
|
|
2688
|
+
return approveUser(payload.userId)
|
|
2689
|
+
case 'access-deny':
|
|
2690
|
+
return denyUser(payload.userId)
|
|
2691
|
+
case 'access-add': {
|
|
2692
|
+
const id = String(payload.userId || '').trim()
|
|
2693
|
+
if (!id) throw new Error('缺少 userId')
|
|
2694
|
+
const list = safeArray(store.get().line.allowedUserIds)
|
|
2695
|
+
if (!list.includes(id)) store.patch({ line: { allowedUserIds: [...list, id] } })
|
|
2696
|
+
removePendingUser(id)
|
|
2697
|
+
return { userId: id, allowedUserIds: store.get().line.allowedUserIds }
|
|
2698
|
+
}
|
|
2699
|
+
case 'access-remove': {
|
|
2700
|
+
const id = String(payload.userId || '').trim()
|
|
2701
|
+
const list = safeArray(store.get().line.allowedUserIds).filter((item) => item !== id)
|
|
2702
|
+
store.patch({ line: { allowedUserIds: list } })
|
|
2703
|
+
return { userId: id, allowedUserIds: list }
|
|
2704
|
+
}
|
|
2397
2705
|
case 'user-persona': {
|
|
2398
2706
|
const userId = String(payload.userId || '')
|
|
2399
2707
|
if (!userId) throw new Error('缺少 userId')
|
package/lib/line-api.mjs
CHANGED
|
@@ -161,3 +161,32 @@ export async function pushMessage(accessToken, to, content) {
|
|
|
161
161
|
body: JSON.stringify({ to, messages }),
|
|
162
162
|
})
|
|
163
163
|
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 下載使用者傳來的圖片/檔案內容(GET /v2/bot/message/{id}/content)。
|
|
167
|
+
* LINE 的 content API 在 api-data 網域,回傳二進位。
|
|
168
|
+
*/
|
|
169
|
+
export async function getMessageContent(accessToken, messageId, { timeoutMs = 30000 } = {}) {
|
|
170
|
+
const id = String(messageId || '').trim()
|
|
171
|
+
if (!id) throw new LineApiError('沒有 messageId')
|
|
172
|
+
const controller = new AbortController()
|
|
173
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetch(`https://api-data.line.me/v2/bot/message/${encodeURIComponent(id)}/content`, {
|
|
176
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
177
|
+
signal: controller.signal,
|
|
178
|
+
})
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
const text = await res.text().catch(() => '')
|
|
181
|
+
throw new LineApiError(`LINE ${res.status}${text ? `:${text.slice(0, 200)}` : ''}`, { status: res.status })
|
|
182
|
+
}
|
|
183
|
+
const data = Buffer.from(await res.arrayBuffer())
|
|
184
|
+
if (data.length > 10 * 1024 * 1024) throw new LineApiError('圖片超過 10MB,略過')
|
|
185
|
+
return data
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (error?.name === 'AbortError') throw new LineApiError('LINE 圖片下載逾時', { status: 0 })
|
|
188
|
+
throw error
|
|
189
|
+
} finally {
|
|
190
|
+
clearTimeout(timer)
|
|
191
|
+
}
|
|
192
|
+
}
|
package/lib/nudge.mjs
CHANGED
|
@@ -71,11 +71,13 @@ export function createNudgeScheduler({ getConfig, store, logger, runTurn, sendPr
|
|
|
71
71
|
function pickUser(cfg, { force }) {
|
|
72
72
|
const state = store.getState()
|
|
73
73
|
const now = Date.now()
|
|
74
|
+
const allowed = cfg.line?.allowedUserIds || []
|
|
74
75
|
const targets = cfg.nudge.targetUserIds.length
|
|
75
76
|
? cfg.nudge.targetUserIds
|
|
76
77
|
: Object.keys(state.users || {})
|
|
77
78
|
const candidate = []
|
|
78
79
|
for (const userId of targets) {
|
|
80
|
+
if (!allowed.length || !allowed.includes(userId)) continue
|
|
79
81
|
const user = state.users?.[userId]
|
|
80
82
|
if (!user?.sessionId) continue
|
|
81
83
|
const last = Number(user.lastAt) || 0
|
package/lib/sticker-image.mjs
CHANGED
|
@@ -49,5 +49,14 @@ export function createStickerImages({ ctx, logger, dir }) {
|
|
|
49
49
|
return ref
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
async function saveBuffer(buffer, name = 'line-image.png', mediaType = 'image/png') {
|
|
53
|
+
if (!available()) throw new Error('這個 profile 沒有附件服務(attachments)')
|
|
54
|
+
if (!buffer || buffer.byteLength === 0) throw new Error('圖片是空檔案')
|
|
55
|
+
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
|
|
56
|
+
const [ref] = await attachments.saveImages([{ data: bytes, mediaType, name }])
|
|
57
|
+
if (!ref) throw new Error('附件服務沒有回傳圖片參照')
|
|
58
|
+
return ref
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { available, toAttachment, saveBuffer, fetchBytes }
|
|
53
62
|
}
|
package/lib/vision.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// 用 modlens CLI 看圖:只在模型本身不支援圖片、或需要描述時使用。
|
|
2
|
+
import { execFile } from 'node:child_process'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const TIMEOUT_MS = 90_000
|
|
8
|
+
|
|
9
|
+
function candidateMains() {
|
|
10
|
+
const list = []
|
|
11
|
+
if (process.env.MODLENS_MAIN) list.push(process.env.MODLENS_MAIN)
|
|
12
|
+
const home = os.homedir()
|
|
13
|
+
for (const profile of ['desktop', 'web', 'line', 'headless']) {
|
|
14
|
+
list.push(path.join(home, '.dsh', 'profiles', profile, 'node_modules', '@liustack', 'modlens', 'dist', 'main.js'))
|
|
15
|
+
}
|
|
16
|
+
list.push('/opt/homebrew/lib/node_modules/@liustack/modlens/dist/main.js')
|
|
17
|
+
list.push('/usr/local/lib/node_modules/@liustack/modlens/dist/main.js')
|
|
18
|
+
return list
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function execFileAsync(file, args, options) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
execFile(file, args, options, (error, stdout, stderr) => {
|
|
24
|
+
if (error) {
|
|
25
|
+
error.stdout = stdout
|
|
26
|
+
error.stderr = stderr
|
|
27
|
+
reject(error)
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
resolve({ stdout, stderr })
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pickText(data) {
|
|
36
|
+
const parts = []
|
|
37
|
+
if (data?.summary) parts.push(String(data.summary).trim())
|
|
38
|
+
const scene = data?.semantics?.scene
|
|
39
|
+
if (scene && !parts.includes(scene)) parts.push(String(scene).trim())
|
|
40
|
+
const intent = data?.semantics?.intent
|
|
41
|
+
if (intent) parts.push(`意圖:${intent}`)
|
|
42
|
+
const entities = Array.isArray(data?.semantics?.entities) ? data.semantics.entities.map((item) => item?.name).filter(Boolean) : []
|
|
43
|
+
if (entities.length) parts.push(`畫面元素:${entities.slice(0, 6).join('、')}`)
|
|
44
|
+
const ocr = String(data?.ocr?.full_text || '').trim()
|
|
45
|
+
if (ocr) parts.push(`文字:${ocr.slice(0, 240)}`)
|
|
46
|
+
const regions = Array.isArray(data?.layout?.regions) ? data.layout.regions.map((item) => item?.text).filter(Boolean) : []
|
|
47
|
+
if (!parts.length && regions.length) parts.push(regions.slice(0, 8).join(' '))
|
|
48
|
+
const text = parts.filter(Boolean).join(';').replace(/\s+/g, ' ').trim()
|
|
49
|
+
return text.slice(0, 600)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createVision({ logger, cacheDir }) {
|
|
53
|
+
let mainPath
|
|
54
|
+
let lastError = ''
|
|
55
|
+
|
|
56
|
+
function resolveMain() {
|
|
57
|
+
if (mainPath !== undefined) return mainPath
|
|
58
|
+
mainPath = candidateMains().find((file) => fs.existsSync(file)) || ''
|
|
59
|
+
return mainPath
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function available() {
|
|
63
|
+
return Boolean(resolveMain())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function status() {
|
|
67
|
+
return { available: available(), main: resolveMain(), lastError }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function describe(file, { prompt = '', provider = '', model = '' } = {}) {
|
|
71
|
+
if (!available()) throw new Error('找不到 modlens(請安裝 @liustack/modlens 或設定 MODLENS_MAIN)')
|
|
72
|
+
if (!file) throw new Error('沒有指定圖片')
|
|
73
|
+
if (!/^https?:\/\//i.test(file) && !fs.existsSync(file)) throw new Error('找不到要看的圖片檔案')
|
|
74
|
+
const dir = cacheDir || path.join(os.tmpdir(), 'dsh-line-bot-vision')
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
76
|
+
const out = path.join(dir, `modlens-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.json`)
|
|
77
|
+
const args = ['analyze', '-i', file, '-o', out, '--timeout', String(TIMEOUT_MS)]
|
|
78
|
+
if (provider) args.push('--provider', provider)
|
|
79
|
+
if (model) args.push('--model', model)
|
|
80
|
+
if (prompt) args.push('--prompt', prompt)
|
|
81
|
+
try {
|
|
82
|
+
await execFileAsync(process.execPath, [resolveMain(), ...args], { timeout: TIMEOUT_MS + 15_000 })
|
|
83
|
+
const raw = JSON.parse(fs.readFileSync(out, 'utf8'))
|
|
84
|
+
const text = pickText(raw)
|
|
85
|
+
if (!text) throw new Error('modlens 沒有回傳可用的描述')
|
|
86
|
+
lastError = ''
|
|
87
|
+
return { text, raw }
|
|
88
|
+
} catch (error) {
|
|
89
|
+
lastError = String(error?.message || error).slice(0, 300)
|
|
90
|
+
logger?.warn?.(`modlens 看圖失敗:${lastError}`)
|
|
91
|
+
throw error
|
|
92
|
+
} finally {
|
|
93
|
+
try {
|
|
94
|
+
fs.rmSync(out, { force: true })
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { available, describe, status }
|
|
100
|
+
}
|