dsh-line-bot 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.6
4
+
5
+ - 安裝 CLI 會偵測搭配外掛(`dsh-persona-dafeiyu`、`dsh-meme`、`dsh-tieba-dive`),可互動詢問或 `--with-companions` 一起安裝;`status` 也會顯示安裝狀態。
6
+ - README(五語言)新增「搭配外掛」章節。
7
+
8
+ ## 0.5.5
9
+
10
+ - 收到 LINE 貼圖時抓取貼圖圖片,透過 DSH 附件服務交給模型閱讀(模型不支援圖片時自動退回貼圖關鍵字)。
11
+ - 面板「LINE 憑證」新增「看得懂貼圖」開關;README(五語言)補說明。
12
+
3
13
  ## 0.5.4
4
14
 
5
15
  - LINE 不會播放 APNG 動畫(只顯示第一幀),所以 GIF 預設改成靜態 PNG,檔案更小、載入更穩。
package/README.en.md CHANGED
@@ -56,6 +56,25 @@ Then add `dsh-line-bot` to the `dsh.profile.bundles` array in `~/.dsh/profiles/d
56
56
  >
57
57
  > Or from source: `git clone https://github.com/Hanson0901/dsh-line-bot.git`, then choose that folder in the DSH panel's "Install bundle", or run `dsh plugin --profile desktop add /path/to/dsh-line-bot`.
58
58
 
59
+ ## Companion plugins (optional)
60
+
61
+ The bot works on its own; these plugins add capabilities:
62
+
63
+ | Plugin | What it adds | Recommended |
64
+ | --- | --- | --- |
65
+ | `dsh-persona-dafeiyu` | Persona catalog and Tieba sync | Yes |
66
+ | `dsh-meme` | Image/sticker replies | Yes |
67
+ | `dsh-tieba-dive` | Source of Tieba learning sync | Optional |
68
+
69
+ The install CLI detects what is missing and offers to install it:
70
+
71
+ ```bash
72
+ npx dsh-line-bot install --with-companions
73
+ npx dsh-line-bot install --no-companions
74
+ ```
75
+
76
+ Without them: no dafeiyu means only plugin-local personas; no dsh-meme means text-only replies; no dsh-tieba-dive means Tieba sync stays off.
77
+
59
78
  ## First run (5 steps)
60
79
 
61
80
  1. **Create / obtain credentials**: go to [LINE Official Account Manager](https://manager.line.biz/) and create an Official Account → [LINE Developers Console](https://developers.line.biz/console/) and create (or pick) a Messaging API channel → open the **Use webhook** toggle on the "Messaging API" tab → copy the **Channel ID** and **Channel secret**.
@@ -78,6 +97,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
78
97
  | Tieba learning sync | Writes the tone samples learned while browsing Tieba back into the persona's "Tieba tone" section, preview diff, sync now |
79
98
  | Personal memory | Per-user long-term memory, expiry cleanup, daily self-review, manual delete |
80
99
  | Memes | Reply with images/stickers from the dsh-meme library, enable/disable, image limit, and sample previews |
100
+ | Sticker vision | Fetch incoming LINE stickers and show them to the model (can be disabled) |
81
101
  | DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown (deepseek-account only) |
82
102
  | Proactive messages | Toggle, probability, interval, daily/monthly caps, minimum gap, quiet hours, idle, target users, prompt, send a test now, statistics |
83
103
  | Users | The DSH session each LINE user maps to, their own workspace folder, their current persona (editable), message count, last interaction, clear memory / reset session |
@@ -172,6 +192,7 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
172
192
  - 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.
173
193
  - Images are served to LINE through the current Cloudflare public URL (`<url>/media/<token>`), and the URL follows quick-tunnel restarts automatically.
174
194
  - 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.
195
+ - 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.
175
196
  - Without dsh-meme installed, the feature stays off and replies are text only.
176
197
  ## Troubleshooting
177
198
 
package/README.ja.md CHANGED
@@ -56,6 +56,25 @@ dsh plugin --profile desktop add dsh-line-bot
56
56
  >
57
57
  > ソースからなら:`git clone https://github.com/Hanson0901/dsh-line-bot.git` して、DSH パネルの「bundle をインストール」でそのフォルダを選ぶか、`dsh plugin --profile desktop add /path/to/dsh-line-bot` を実行します。
58
58
 
59
+ ## 連携プラグイン(任意)
60
+
61
+ LINE Bot は単体でも動きます。以下を入れると機能が増えます:
62
+
63
+ | プラグイン | 追加される機能 | 推奨 |
64
+ | --- | --- | --- |
65
+ | `dsh-persona-dafeiyu` | ペルソナ一覧と貼吧同期 | 推奨 |
66
+ | `dsh-meme` | 画像/スタンプ返信 | 推奨 |
67
+ | `dsh-tieba-dive` | 貼吧学習同期の取得元 | 任意 |
68
+
69
+ インストール CLI は不足を検出して、一緒に入れるか確認します:
70
+
71
+ ```bash
72
+ npx dsh-line-bot install --with-companions
73
+ npx dsh-line-bot install --no-companions
74
+ ```
75
+
76
+ ない場合の影響:大肥魚なし → プラグイン内のペルソナのみ、dsh-meme なし → テキストのみ、貼吧プラグインなし → 貼吧同期は停止。
77
+
59
78
  ## 初回セットアップ(5 ステップ)
60
79
 
61
80
  1. **認証情報を用意**:[LINE Official Account Manager](https://manager.line.biz/) で公式アカウントを作成 → [LINE Developers Console](https://developers.line.biz/console/) で Messaging API チャネルを作成(または選択)→ 「Messaging API」タブで **Use webhook** をオン → **Channel ID** と **Channel secret** をコピーします。
@@ -78,6 +97,7 @@ dsh plugin --profile desktop add dsh-line-bot
78
97
  | 貼吧学習の同期 | 貼吧で学んだ口調をペルソナの「貼吧学来的口气」節に書き戻す、差分プレビュー、今すぐ同期 |
79
98
  | 個人メモリ | ユーザーごとの長期記憶、期限切れの自動削除、毎日の自己整理、手動削除 |
80
99
  | 表情パック | dsh-meme の画像ライブラリで画像/スタンプを返信、オンオフ、1 回の上限、サンプル表示 |
100
+ | スタンプ理解 | LINE スタンプを受信したら画像を取得してモデルに見せる(オフ可) |
81
101
  | DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン(deepseek-account のみ) |
82
102
  | 自発メッセージ | オン/オフ、確率、間隔、1 日/1 か月の上限、最短間隔、静かな時間帯、idle、対象ユーザー、プロンプト、今すぐ試送、統計 |
83
103
  | ユーザー | LINE ユーザーごとに対応する DSH セッション、専用ワークスペース、現在のペルソナ(直接変更可)、メッセージ数、最終やりとり、メモリ消去/セッション再作成 |
@@ -172,6 +192,7 @@ dsh plugin --profile desktop add dsh-line-bot
172
192
  - 毎ターン、ライブラリのサンプル(番号+説明)を context に注入します。画像を添えたいときは返信の最後に `[[meme:#番号]]`(または説明)を付けます。プラグインが LINE の image message に変換します。
173
193
  - 画像は現在の Cloudflare 公開 URL(`<URL>/media/<token>`)経由で LINE に渡されます。quick tunnel の再起動で URL が変わっても自動で追従します。
174
194
  - パネルの「表情パック」カードでオンオフ、1 回あたりの上限(1〜3)、ライブラリのサンプル表示ができます。画像の追加や分類の変更は dsh-meme 側の設定で行います。
195
+ - LINE スタンプを受信すると、スタンプ画像を取得してモデルに見せます(画像対応モデルはそのまま読み、非対応なら LINE のキーワードを使います)。「LINE 認証情報」カードの「スタンプを理解する」でオフにできます。
175
196
  - dsh-meme がインストールされていない場合、この機能は無効になりテキストのみ返します。
176
197
  ## トラブルシューティング
177
198
 
package/README.ko.md CHANGED
@@ -56,6 +56,25 @@ dsh plugin --profile desktop add dsh-line-bot
56
56
  >
57
57
  > 소스에서 설치하려면 `git clone https://github.com/Hanson0901/dsh-line-bot.git` 후 DSH 패널의 "bundle 설치"에서 그 폴더를 선택하거나 `dsh plugin --profile desktop add /path/to/dsh-line-bot`을 실행합니다.
58
58
 
59
+ ## 함께 쓰는 플러그인 (선택)
60
+
61
+ LINE Bot은 단독으로도 동작하며, 아래 플러그인을 설치하면 기능이 늘어납니다:
62
+
63
+ | 플러그인 | 추가 기능 | 권장 |
64
+ | --- | --- | --- |
65
+ | `dsh-persona-dafeiyu` | 페르소나 목록, 톄바 동기화 | 권장 |
66
+ | `dsh-meme` | 이미지/스티커 답장 | 권장 |
67
+ | `dsh-tieba-dive` | 톄바 학습 동기화 소스 | 선택 |
68
+
69
+ 설치 CLI는 없는 것을 감지해 함께 설치할지 물어봅니다:
70
+
71
+ ```bash
72
+ npx dsh-line-bot install --with-companions
73
+ npx dsh-line-bot install --no-companions
74
+ ```
75
+
76
+ 없을 때: 대비어 없음 → 플러그인 자체 페르소나만, dsh-meme 없음 → 텍스트만, 톄바 플러그인 없음 → 톄바 동기화 꺼짐.
77
+
59
78
  ## 첫 구동(5단계)
60
79
 
61
80
  1. **자격 증명 준비**: [LINE Official Account Manager](https://manager.line.biz/)에서 공식 계정 생성 → [LINE Developers Console](https://developers.line.biz/console/)에서 Messaging API 채널 생성(또는 선택) → "Messaging API" 탭에서 **Use webhook** 켜기 → **Channel ID**와 **Channel secret** 복사.
@@ -78,6 +97,7 @@ dsh plugin --profile desktop add dsh-line-bot
78
97
  | 貼吧 학습 동기화 | 貼吧에서 배운 말투를 페르소나의 「貼吧学来的口气」 섹션으로 기록, 차이 미리보기, 지금 동기화 |
79
98
  | 개인 기억 | 사용자별 장기 기억, 만료 자동 삭제, 매일 자기 정리, 수동 삭제 |
80
99
  | 이모티콘 | dsh-meme 라이브러리로 이미지/스티커 답장, 켜기/끄기, 1회 최대 장수, 샘플 미리보기 |
100
+ | 스티커 이해 | LINE 스티커를 받으면 이미지를 가져와 모델에 보여 줌 (끌 수 있음) |
81
101
  | DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운(deepseek-account 전용) |
82
102
  | 능동 메시지 | 켜기/끄기, 확률, 간격, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 대상 사용자, 프롬프트, 지금 시험 발송, 통계 |
83
103
  | 사용자 | LINE 사용자별 DSH 세션, 전용 작업 폴더, 현재 페르소나(직접 변경 가능), 메시지 수, 마지막 상호작용, 기억 지우기/세션 재설정 |
@@ -172,6 +192,7 @@ dsh plugin --profile desktop add dsh-line-bot
172
192
  - 매 턴 라이브러리 샘플(번호+설명)을 context에 넣습니다. 이미지를 붙이려면 답장 끝에 `[[meme:#번호]]`(또는 설명)를 붙입니다. 플러그인이 LINE image message로 바꿔 보냅니다.
173
193
  - 이미지는 현재 Cloudflare 공개 URL(`<URL>/media/<token>`)을 통해 LINE에 전달되며, quick tunnel이 재시작돼 URL이 바뀌어도 자동으로 따라갑니다.
174
194
  - 패널의 「이모티콘」 카드에서 켜고 끄기, 답장당 최대 장수(1~3), 라이브러리 샘플 미리보기를 할 수 있습니다. 이미지 추가나 분류 변경은 dsh-meme 설정에서 하세요.
195
+ - LINE 스티커를 받으면 스티커 이미지를 가져와 모델에 보여 줍니다(이미지 지원 모델은 바로 읽고, 아니면 LINE 키워드로 대체). 「LINE 자격 증명」 카드에서 「스티커 이해」를 끌 수 있습니다.
175
196
  - dsh-meme이 설치되어 있지 않으면 이 기능은 꺼지고 텍스트만 답장합니다.
176
197
  ## 문제 해결
177
198
 
package/README.md CHANGED
@@ -56,6 +56,25 @@ dsh plugin --profile desktop add dsh-line-bot
56
56
  >
57
57
  > 或從原始碼:`git clone https://github.com/Hanson0901/dsh-line-bot.git`,在 DSH 面板「安裝 bundle」選那個資料夾,或 `dsh plugin --profile desktop add /path/to/dsh-line-bot`。
58
58
 
59
+ ## 搭配外掛(可選)
60
+
61
+ LINE Bot 可以獨立運作;裝了下面這些外掛會多出對應功能:
62
+
63
+ | 外掛 | 用途 | 建議 |
64
+ | --- | --- | --- |
65
+ | `dsh-persona-dafeiyu` | 人設清單、貼吧同步 | 建議 |
66
+ | `dsh-meme` | 讓角色回圖片/貼圖 | 建議 |
67
+ | `dsh-tieba-dive` | 貼吧學習同步的來源 | 可選 |
68
+
69
+ 安裝時 CLI 會偵測缺哪些,並詢問要不要一起安裝:
70
+
71
+ ```bash
72
+ npx dsh-line-bot install --with-companions
73
+ npx dsh-line-bot install --no-companions
74
+ ```
75
+
76
+ 少了它們的影響:沒有大肥魚 → 人設只剩插件自訂的;沒有 dsh-meme → 只能回文字;沒有刷貼吧 → 貼吧同步自動停用。
77
+
59
78
  ## 第一次上線(5 步)
60
79
 
61
80
  1. **建立/取得憑證**:到 [LINE Official Account Manager](https://manager.line.biz/) 建立官方帳號 → [LINE Developers Console](https://developers.line.biz/console/) 建立(或選用)Messaging API channel → 在「Messaging API」分頁把 **Use webhook** 打開 → 複製 **Channel ID** 與 **Channel secret**。
@@ -78,6 +97,7 @@ dsh plugin --profile desktop add dsh-line-bot
78
97
  | 貼吧學習同步 | 把刷貼吧學到的語氣寫回人設的「貼吧學來的口氣」段、預覽差異、立即同步 |
79
98
  | 個人記憶 | 每位使用者分開的長期記憶、到期自動刪、每日請角色整理、手動刪除 |
80
99
  | 表情包 | 使用 dsh-meme 圖庫回圖片/貼圖、開關、一次最多幾張、圖庫樣本 |
100
+ | 貼圖理解 | 收到 LINE 貼圖時抓圖給模型看(可關閉) |
81
101
  | DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻(僅 deepseek-account) |
82
102
  | 主動傳訊 | 開關、機率、間隔、每日/每月上限、最短間隔、安靜時段、idle、指定對象、提示詞、立即試傳、統計 |
83
103
  | 使用者 | 每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
@@ -172,6 +192,7 @@ dsh plugin --profile desktop add dsh-line-bot
172
192
  - 每輪會把圖庫樣本(編號+描述)帶進 context;角色想配圖時在回覆最後加 `[[meme:#編號]]`(或描述),插件再組成 LINE 的 image message。
173
193
  - 圖片透過目前的 Cloudflare 公開網址(`<網址>/media/<token>`)提供給 LINE;quick tunnel 換網址後會自動跟上。
174
194
  - 面板「表情包」卡可以開關、限制一次最多幾張(1~3),並預覽圖庫樣本。要新增圖片或改分類,請用 dsh-meme 自己的設定。
195
+ - 收到 LINE 貼圖時,會抓貼圖圖片交給模型看(支援圖片的模型直接讀圖,否則用 LINE 提供的關鍵字),角色才接得住貼圖的情緒。可在「LINE 憑證」卡關閉「看得懂貼圖」。
175
196
  - 沒安裝 dsh-meme 時這個功能會自動停用,只回文字。
176
197
  ## 疑難排解
177
198
 
package/README.zh-CN.md CHANGED
@@ -56,6 +56,25 @@ dsh plugin --profile desktop add dsh-line-bot
56
56
  >
57
57
  > 或从源码:`git clone https://github.com/Hanson0901/dsh-line-bot.git`,在 DSH 面板「安装 bundle」选那个文件夹,或 `dsh plugin --profile desktop add /path/to/dsh-line-bot`。
58
58
 
59
+ ## 搭配插件(可选)
60
+
61
+ LINE Bot 可以独立运行;装上下面这些插件会多出对应功能:
62
+
63
+ | 插件 | 用途 | 建议 |
64
+ | --- | --- | --- |
65
+ | `dsh-persona-dafeiyu` | 人设清单、贴吧同步 | 建议 |
66
+ | `dsh-meme` | 让角色回图片/贴图 | 建议 |
67
+ | `dsh-tieba-dive` | 贴吧学习同步的来源 | 可选 |
68
+
69
+ 安装时 CLI 会检测缺哪些,并询问要不要一起安装:
70
+
71
+ ```bash
72
+ npx dsh-line-bot install --with-companions
73
+ npx dsh-line-bot install --no-companions
74
+ ```
75
+
76
+ 少了它们的影响:没有大肥鱼 → 人设只剩插件自定义的;没有 dsh-meme → 只能回文字;没有刷贴吧 → 贴吧同步自动停用。
77
+
59
78
  ## 第一次上线(5 步)
60
79
 
61
80
  1. **创建/取得凭证**:到 [LINE Official Account Manager](https://manager.line.biz/) 创建官方账号 → [LINE Developers Console](https://developers.line.biz/console/) 创建(或选用)Messaging API channel → 在「Messaging API」分页把 **Use webhook** 打开 → 复制 **Channel ID** 与 **Channel secret**。
@@ -78,6 +97,7 @@ dsh plugin --profile desktop add dsh-line-bot
78
97
  | 贴吧学习同步 | 把刷贴吧学到的语气写回人设的「贴吧学来的口气」段、预览差异、立即同步 |
79
98
  | 个人记忆 | 每位用户分开的长期记忆、到期自动删除、每日请角色整理、手动删除 |
80
99
  | 表情包 | 使用 dsh-meme 图库回图片/贴图、开关、每次最多几张、图库样本 |
100
+ | 贴图理解 | 收到 LINE 贴图时抓图给模型看(可关闭) |
81
101
  | DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却(仅 deepseek-account) |
82
102
  | 主动传讯 | 开关、概率、间隔、每日/每月上限、最短间隔、安静时段、idle、指定对象、提示词、立即试传、统计 |
83
103
  | 用户 | 每位 LINE 用户对应的 DSH session、专属工作文件夹、目前人设(可直接改)、消息数、上次互动、清空记忆/重设 session |
@@ -172,6 +192,7 @@ dsh plugin --profile desktop add dsh-line-bot
172
192
  - 每轮会把图库样本(编号+描述)带进 context;角色想配图时在回复最后加 `[[meme:#编号]]`(或描述),插件再组成 LINE 的 image message。
173
193
  - 图片通过当前的 Cloudflare 公开网址(`<网址>/media/<token>`)提供给 LINE;quick tunnel 换网址后会自动跟上。
174
194
  - 面板「表情包」卡可以开关、限制每次最多几张(1~3),并预览图库样本。要新增图片或改分类,请用 dsh-meme 自己的设置。
195
+ - 收到 LINE 贴图时,会抓贴图图片交给模型看(支持图片的模型直接读图,否则用 LINE 提供的关键词),角色才能接住贴图的情绪。可在「LINE 凭证」卡关闭「看得懂贴图」。
175
196
  - 没安装 dsh-meme 时该功能会自动停用,只回文字。
176
197
  ## 疑难排解
177
198
 
package/bin/cli.mjs CHANGED
@@ -16,6 +16,7 @@ import fs from 'node:fs'
16
16
  import os from 'node:os'
17
17
  import path from 'node:path'
18
18
  import { spawnSync } from 'node:child_process'
19
+ import readline from 'node:readline/promises'
19
20
 
20
21
  const IS_WIN = process.platform === 'win32'
21
22
  import { fileURLToPath } from 'node:url'
@@ -23,6 +24,13 @@ import { fileURLToPath } from 'node:url'
23
24
  const PKG = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
24
25
  const NAME = PKG.name || 'dsh-line-bot'
25
26
 
27
+ // 搭配的外掛:不裝也能用,但少掉某些功能。安裝時會偵測並詢問是否一起裝。
28
+ const COMPANIONS = [
29
+ { name: 'dsh-persona-dafeiyu', label: '大肥魚人設', hint: '人設清單、貼吧同步(建議)' },
30
+ { name: 'dsh-meme', label: '表情包圖庫', hint: '讓角色回圖片/貼圖(建議)' },
31
+ { name: 'dsh-tieba-dive', label: '刷貼吧', hint: '貼吧學習同步的來源(可選)' },
32
+ ]
33
+
26
34
  function out(line = '') {
27
35
  process.stdout.write(`${line}\n`)
28
36
  }
@@ -32,7 +40,7 @@ function fail(message) {
32
40
  }
33
41
 
34
42
  function parseArgs(argv) {
35
- const options = { command: '', profile: '', version: '', spec: '', dryRun: false, enable: true }
43
+ const options = { command: '', profile: '', version: '', spec: '', dryRun: false, enable: true, companions: 'ask' }
36
44
  const rest = [...argv]
37
45
  options.command = (rest.shift() || 'help').toLowerCase()
38
46
  for (let index = 0; index < rest.length; index += 1) {
@@ -56,6 +64,10 @@ function parseArgs(argv) {
56
64
  options.spec = arg.slice('--spec='.length)
57
65
  } else if (arg === '--no-enable') {
58
66
  options.enable = false
67
+ } else if (arg === '--with-companions') {
68
+ options.companions = 'yes'
69
+ } else if (arg === '--no-companions') {
70
+ options.companions = 'no'
59
71
  } else if (arg === '--help' || arg === '-h') {
60
72
  options.command = 'help'
61
73
  } else {
@@ -125,12 +137,12 @@ function bundlesOf(manifest) {
125
137
  return Array.isArray(bundles) ? [...bundles] : []
126
138
  }
127
139
 
128
- function withBundle(manifest, enabled) {
140
+ function withBundle(manifest, enabled, name = NAME) {
129
141
  const bundles = bundlesOf(manifest)
130
142
  if (enabled) {
131
- if (!bundles.includes(NAME)) bundles.push(NAME)
143
+ if (!bundles.includes(name)) bundles.push(name)
132
144
  } else {
133
- const index = bundles.indexOf(NAME)
145
+ const index = bundles.indexOf(name)
134
146
  if (index >= 0) bundles.splice(index, 1)
135
147
  }
136
148
  return {
@@ -218,7 +230,66 @@ function runInstall(profile, versionSpec, { dryRun }) {
218
230
  }
219
231
  }
220
232
 
221
- function commandInstall(options) {
233
+ function companionMissing(profile, dir, manifest, name) {
234
+ const deps = { ...(manifest.dependencies || {}), ...(manifest.devDependencies || {}) }
235
+ if (deps[name]) return false
236
+ if (bundlesOf(manifest).includes(name)) return false
237
+ return !fs.existsSync(path.join(dir, 'node_modules', name, 'package.json'))
238
+ }
239
+
240
+ async function askYesNo(question) {
241
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
242
+ try {
243
+ const answer = (await rl.question(question)).trim().toLowerCase()
244
+ return answer === '' || answer === 'y' || answer === 'yes' || answer === '是'
245
+ } finally {
246
+ rl.close()
247
+ }
248
+ }
249
+
250
+ function installCompanion(profile, name, options) {
251
+ runInstall(profile, `${name}@latest`, options)
252
+ if (options.dryRun) return
253
+ const { file } = readManifest(profile)
254
+ const current = readManifest(profile).manifest
255
+ const next = withBundle(current, true, name)
256
+ if (JSON.stringify(next) !== JSON.stringify(current)) {
257
+ writeManifestAtomic(file, next)
258
+ out(`✔ 已啟用 ${name}`)
259
+ }
260
+ }
261
+
262
+ async function setupCompanions(profile, options) {
263
+ const { dir, manifest } = readManifest(profile)
264
+ const missing = COMPANIONS.filter((item) => companionMissing(profile, dir, manifest, item.name))
265
+ if (missing.length === 0) {
266
+ out('• 搭配外掛都已安裝。')
267
+ return
268
+ }
269
+ out('')
270
+ out('搭配外掛(可選,不裝也能用):')
271
+ for (const item of missing) out(` • ${item.name} — ${item.label}:${item.hint}`)
272
+ if (options.dryRun) {
273
+ out(`→ 會詢問是否一起安裝 ${missing.map((item) => item.name).join('、')}`)
274
+ return
275
+ }
276
+ let install = false
277
+ if (options.companions === 'yes') install = true
278
+ else if (options.companions === 'no') install = false
279
+ else if (process.stdin.isTTY) install = await askYesNo('要一起安裝嗎?(Y/n) ')
280
+ else {
281
+ out('• 目前不是互動終端,略過搭配外掛;可加 --with-companions 一起安裝。')
282
+ return
283
+ }
284
+ if (!install) {
285
+ out('• 略過搭配外掛。之後可再跑 npx dsh-line-bot install --with-companions。')
286
+ return
287
+ }
288
+ for (const item of missing) installCompanion(profile, item.name, options)
289
+ out('✔ 搭配外掛安裝完成。')
290
+ }
291
+
292
+ async function commandInstall(options) {
222
293
  const profile = pickProfile(options.profile)
223
294
  const { dir, file, manifest } = readManifest(profile)
224
295
  const versionSpec = options.spec || (options.version ? `${NAME}@${options.version}` : `${NAME}@${PKG.version}`)
@@ -245,6 +316,8 @@ function commandInstall(options) {
245
316
  }
246
317
  }
247
318
 
319
+ await setupCompanions(profile, options)
320
+
248
321
  out('')
249
322
  out('完成。下一步:')
250
323
  out(' 1. 完全結束並重開 DeepSeek Harness(桌面版請確認選單列沒有殘留)。')
@@ -295,6 +368,11 @@ function commandStatus(options) {
295
368
  out(`profile:${profile}(${dir})`)
296
369
  out(`依賴:${dependency || '(未安裝)'}`)
297
370
  out(`bundle 啟用:${enabled ? '是' : '否'}`)
371
+ const companions = COMPANIONS.map((item) => {
372
+ const ok = !companionMissing(profile, dir, manifest, item.name)
373
+ return `${item.name}${ok ? '(已安裝)' : '(未安裝)'}`
374
+ })
375
+ out(`搭配外掛:${companions.join('、')}`)
298
376
  if (fs.existsSync(path.join(dshHome(), 'line-bot', 'config.json'))) {
299
377
  out('設定檔:<DSH_HOME>/line-bot/config.json(已存在)')
300
378
  }
@@ -314,6 +392,8 @@ function commandHelp() {
314
392
  out(' --version, -v <版本> 指定要安裝的版本(預設跟這個 CLI 相同)')
315
393
  out(' --spec <pnpm 規格> 直接指定套件規格,例如 github:Hanson0901/dsh-line-bot')
316
394
  out(' --no-enable 只安裝套件,不寫入 bundle 清單')
395
+ out(' --with-companions 一起安裝搭配外掛(dsh-persona-dafeiyu、dsh-meme、dsh-tieba-dive)')
396
+ out(' --no-companions 不詢問搭配外掛')
317
397
  out(' --dry-run, -n 只顯示會做什麼,不寫入')
318
398
  out(' --help, -h 顯示這段說明')
319
399
  out('')
@@ -324,7 +404,7 @@ const options = parseArgs(process.argv.slice(2))
324
404
  switch (options.command) {
325
405
  case 'install':
326
406
  case 'add':
327
- commandInstall(options)
407
+ await commandInstall(options)
328
408
  break
329
409
  case 'uninstall':
330
410
  case 'remove':
package/client.js CHANGED
@@ -891,6 +891,7 @@ window.__ModuleLoader__.load({
891
891
  ),
892
892
  h(Toggle, { checked: line.useAutoToken !== false, onChange: (value) => update({ line: { useAutoToken: value } }), label: '自動簽發 access token', hint: '有 Channel ID+Secret 時,優先自動取得(建議開啟)' }),
893
893
  h(Toggle, { checked: line.loadingEnabled !== false, onChange: (value) => update({ line: { loadingEnabled: value } }), label: '顯示「正在輸入」動畫', hint: '回覆前呼叫 LINE 的 loading 動畫' }),
894
+ h(Toggle, { checked: line.stickerVision !== false, onChange: (value) => update({ line: { stickerVision: value } }), label: '看得懂貼圖', hint: '收到貼圖時抓圖片給模型看;模型不支援圖片時仍會用貼圖關鍵字' }),
894
895
  h('div', { style: S.row },
895
896
  h(Btn, { onClick: () => act('line-verify'), disabled: !!busy, tone: 'primary' }, busy === 'line-verify' ? '驗證中…' : '驗證並讀取 bot 資訊'),
896
897
  h(Btn, { onClick: () => act('token-refresh'), disabled: !!busy }, '重新簽發 token'),
@@ -5,11 +5,13 @@ import os from 'node:os'
5
5
  import { randomUUID } from 'node:crypto'
6
6
  import { sleep } from './util.mjs'
7
7
 
8
- function userMessage(text) {
8
+ function userMessage(text, images = []) {
9
+ const content = [{ type: 'text', text }]
10
+ for (const attachment of images) content.push({ type: 'image', attachment })
9
11
  return {
10
12
  id: randomUUID(),
11
13
  role: 'user',
12
- content: [{ type: 'text', text }],
14
+ content,
13
15
  source: { kind: 'user' },
14
16
  }
15
17
  }
@@ -186,7 +188,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
186
188
  }
187
189
  }
188
190
 
189
- async function turn({ sessionId, text, persona, personaClient, personaText, contextText, tools = [], cwd: cwdOverride }) {
191
+ async function turn({ sessionId, text, persona, personaClient, personaText, contextText, tools = [], images = [], cwd: cwdOverride }) {
190
192
  const cfg = getConfig()
191
193
  const timeoutMs = cfg.agent.timeoutMs || 180000
192
194
  const { options } = agentOptions()
@@ -227,7 +229,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
227
229
  })
228
230
 
229
231
  const firstSeq = agent.session.seq
230
- agent.followup(userMessage(text))
232
+ agent.followup(userMessage(text, images))
231
233
 
232
234
  let timedOut = false
233
235
  const timer = new Promise((_, reject) => {
package/lib/config.mjs CHANGED
@@ -25,6 +25,7 @@ export const DEFAULT_CONFIG = {
25
25
  tokenExpiresAt: 0,
26
26
  useAutoToken: true,
27
27
  loadingEnabled: true,
28
+ stickerVision: true,
28
29
  allowedUserIds: [],
29
30
  welcomeText: '(已加好友)嗨,我是大肥魚~想聊什麼都可以跟我說。',
30
31
  errorReply: '(剛剛卡住了,再說一次好嗎?)',
@@ -179,6 +180,7 @@ export function sanitizePatch(current, patch) {
179
180
  next.line.tokenExpiresAt = clampInt(next.line.tokenExpiresAt, 0, Number.MAX_SAFE_INTEGER, 0)
180
181
  next.line.allowedUserIds = safeArray(next.line.allowedUserIds)
181
182
  next.line.loadingEnabled = Boolean(next.line.loadingEnabled)
183
+ next.line.stickerVision = Boolean(next.line.stickerVision)
182
184
  next.line.welcomeText = String(next.line.welcomeText ?? '').slice(0, 1000)
183
185
  next.line.errorReply = String(next.line.errorReply ?? '').slice(0, 500)
184
186
  next.line.emptyReply = String(next.line.emptyReply ?? '').slice(0, 500)
package/lib/host.mjs CHANGED
@@ -23,6 +23,7 @@ const balanceMod = await import(withVersion('./deepseek-balance.mjs'))
23
23
  const memoryMod = await import(withVersion('./memory.mjs'))
24
24
  const stickersMod = await import(withVersion('./stickers.mjs'))
25
25
  const memeSourceMod = await import(withVersion('./meme-source.mjs'))
26
+ const stickerImageMod = await import(withVersion('./sticker-image.mjs'))
26
27
  const utilMod = await import(withVersion('./util.mjs'))
27
28
  const { createConfigStore, publicConfig, dataDir } = configMod
28
29
  const { createPersonaClient } = personaMod
@@ -37,9 +38,10 @@ const { createBalanceMonitor } = balanceMod
37
38
  const { createMemoryStore } = memoryMod
38
39
  const { createStickerStore } = stickersMod
39
40
  const { createMemeSource } = memeSourceMod
41
+ const { createStickerImages } = stickerImageMod
40
42
  const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep } = utilMod
41
43
 
42
- export const VERSION = '0.5.4'
44
+ export const VERSION = '0.5.6'
43
45
 
44
46
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
45
47
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -228,7 +230,18 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
228
230
  const entry = { id: info.id, name: info.name || info.id, models: [] }
229
231
  try {
230
232
  const models = await llm.listModels(info.id)
231
- entry.models = (models || []).map((item) => ({ id: item.id, name: item.name || item.id }))
233
+ const mapped = []
234
+ for (const item of models || []) {
235
+ const model = { id: item.id, name: item.name || item.id }
236
+ if (typeof llm.resolveModelInfo === 'function') {
237
+ try {
238
+ const info2 = await llm.resolveModelInfo(info.id, item.id)
239
+ if (Array.isArray(info2?.inputModalities)) model.inputModalities = info2.inputModalities
240
+ } catch { /* 有些 provider 不支援查詢,略過 */ }
241
+ }
242
+ mapped.push(model)
243
+ }
244
+ entry.models = mapped
232
245
  } catch (error) {
233
246
  entry.error = errorText(error)
234
247
  }
@@ -1127,11 +1140,38 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1127
1140
  logger.warn(`略過未授權的使用者 ${userId}(allowedUserIds)`)
1128
1141
  return
1129
1142
  }
1130
- if (event.message?.type !== 'text') {
1131
- logger.info(`略過非文字訊息(${event.message?.type})`)
1143
+ const messageType = event.message?.type
1144
+ const messageImages = []
1145
+ let text = ''
1146
+ if (messageType === 'text') {
1147
+ text = String(event.message.text || '').trim()
1148
+ } else if (messageType === 'sticker') {
1149
+ const keywords = Array.isArray(event.message.keywords)
1150
+ ? event.message.keywords.filter(Boolean).slice(0, 8).join('、')
1151
+ : ''
1152
+ let hasImage = false
1153
+ if (cfg.line?.stickerVision !== false) {
1154
+ try {
1155
+ const ref = await stickerImages.toAttachment(event.message.stickerId, event.message.stickerResourceType)
1156
+ if (ref) {
1157
+ messageImages.push(ref)
1158
+ hasImage = true
1159
+ }
1160
+ } catch (error) {
1161
+ logger.warn(`取得貼圖圖片失敗,只用關鍵字:${errorText(error)}`)
1162
+ }
1163
+ }
1164
+ text = [
1165
+ `(使用者沒有打字,傳了一個 LINE 貼圖:packageId=${event.message.packageId ?? '?'}, stickerId=${event.message.stickerId ?? '?'}`,
1166
+ keywords ? `,貼圖關鍵字:${keywords}` : '',
1167
+ hasImage ? ',貼圖圖片已附在這一則訊息裡(請直接看圖)' : '',
1168
+ '。請用你自己的語氣,一兩句自然回應這個貼圖;不要說「你傳了貼圖」這種話,依圖片內容或關鍵字猜情緒,不確定就輕鬆帶過。)',
1169
+ ].join('')
1170
+ logger.info(`收到貼圖(packageId=${event.message.packageId ?? '?'}, stickerId=${event.message.stickerId ?? '?'}${keywords ? `,${keywords}` : ''})`)
1171
+ } else {
1172
+ logger.info(`略過非文字訊息(${messageType})`)
1132
1173
  return
1133
1174
  }
1134
- const text = String(event.message.text || '').trim()
1135
1175
  if (!text) return
1136
1176
 
1137
1177
  const user = ensureUser(userId)
@@ -1169,6 +1209,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1169
1209
  personaClient: persona(),
1170
1210
  contextText: agentContextText(userId),
1171
1211
  tools: memoryToolsFor(userId),
1212
+ images: messageImages,
1172
1213
  cwd: ensureUserWorkspace(userId) || cfg.agent.cwd || undefined,
1173
1214
  })
1174
1215
  let replyText = String(result?.text || '').trim()
@@ -1197,6 +1238,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1197
1238
  const runner = createAgentRunner({ ctx, logger, getConfig: () => store.get() })
1198
1239
  const stickers = createStickerStore({ dir, logger })
1199
1240
  const memes = createMemeSource({ ctx, logger })
1241
+ const stickerImages = createStickerImages({ ctx, logger, dir })
1200
1242
  const mediaTokens = new Map()
1201
1243
 
1202
1244
  const server = createWebhookServer({ getConfig: () => store.get(), logger, dispatch: handleEvent, getMedia })
@@ -1628,6 +1670,19 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1628
1670
  return maybeAskForTopUp({ dryRun: payload.dryRun !== false, force: payload.force === true })
1629
1671
  case 'meme-stats':
1630
1672
  return { ...memes.stats(), enabled: store.get().stickers?.enabled !== false, maxPerReply: memeMaxPerReply(), sample: memes.sample({ limit: 20 }) }
1673
+ case 'sticker-look': {
1674
+ const stickerId = String(payload.stickerId || '').trim()
1675
+ if (!stickerId) throw new Error('缺少 stickerId')
1676
+ const ref = await stickerImages.toAttachment(stickerId, payload.stickerResourceType || 'STATIC')
1677
+ const result = await runner.turn({
1678
+ sessionId: `session-sticker-look-${Date.now()}`,
1679
+ text: '這是一張 LINE 貼圖。請用一句繁體中文說出圖片裡的圖案內容與情緒。',
1680
+ images: [ref],
1681
+ contextText: '',
1682
+ cwd: store.get().agent.cwd || undefined,
1683
+ })
1684
+ return { text: result.text, attachmentId: String(ref.attachmentId || ''), mediaType: ref.mediaType }
1685
+ }
1631
1686
  case 'meme-preview': {
1632
1687
  if (!payload.text) throw new Error('缺少 text')
1633
1688
  const built = await buildOutgoingMessages(String(payload.text))
@@ -0,0 +1,53 @@
1
+ // 抓 LINE 貼圖圖片,存進 DSH 附件庫,供多模態模型閱讀。
2
+ const STICKER_BASE = 'https://stickershop.line-scdn.net/stickershop/v1/sticker'
3
+
4
+ function animationType(resourceType) {
5
+ return resourceType === 'ANIMATION' || resourceType === 'ANIMATION_SOUND'
6
+ }
7
+
8
+ export function createStickerImages({ ctx, logger, dir }) {
9
+ const attachments = ctx?.get?.('attachments')
10
+ const cache = new Map()
11
+
12
+ function available() {
13
+ return Boolean(attachments && typeof attachments.saveImages === 'function')
14
+ }
15
+
16
+ async function fetchBytes(stickerId, resourceType) {
17
+ const id = String(stickerId || '').replace(/[^0-9]/g, '')
18
+ if (!id) throw new Error('沒有 stickerId')
19
+ const urls = [`${STICKER_BASE}/${id}/android/sticker.png`]
20
+ if (animationType(resourceType)) urls.push(`${STICKER_BASE}/${id}/android/sticker_animation.png`)
21
+ let lastError
22
+ for (const url of urls) {
23
+ try {
24
+ const response = await fetch(url, { signal: AbortSignal.timeout(15_000) })
25
+ if (!response.ok) throw new Error(`LINE 貼圖 HTTP ${response.status}`)
26
+ const data = new Uint8Array(await response.arrayBuffer())
27
+ if (data.byteLength === 0) throw new Error('LINE 貼圖是空檔案')
28
+ if (data.byteLength > 6 * 1024 * 1024) throw new Error('LINE 貼圖太大')
29
+ return { data, url }
30
+ } catch (error) {
31
+ lastError = error
32
+ }
33
+ }
34
+ throw lastError || new Error('抓不到 LINE 貼圖')
35
+ }
36
+
37
+ async function toAttachment(stickerId, resourceType) {
38
+ if (!available()) throw new Error('這個 profile 沒有附件服務(attachments)')
39
+ const key = `${stickerId}:${resourceType || ''}`
40
+ if (cache.has(key)) return cache.get(key)
41
+ const { data } = await fetchBytes(stickerId, resourceType)
42
+ const [ref] = await attachments.saveImages([
43
+ { data, mediaType: 'image/png', name: `line-sticker-${stickerId}.png` },
44
+ ])
45
+ if (!ref) throw new Error('附件服務沒有回傳圖片參照')
46
+ cache.set(key, ref)
47
+ if (cache.size > 300) cache.delete(cache.keys().next().value)
48
+ logger?.info?.(`已取得 LINE 貼圖圖片(stickerId=${stickerId},${data.byteLength} bytes)`)
49
+ return ref
50
+ }
51
+
52
+ return { available, toAttachment }
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",