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 +10 -0
- package/README.en.md +21 -0
- package/README.ja.md +21 -0
- package/README.ko.md +21 -0
- package/README.md +21 -0
- package/README.zh-CN.md +21 -0
- package/bin/cli.mjs +86 -6
- package/client.js +1 -0
- package/lib/agent-runner.mjs +6 -4
- package/lib/config.mjs +2 -0
- package/lib/host.mjs +60 -5
- package/lib/sticker-image.mjs +53 -0
- package/package.json +1 -1
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(
|
|
143
|
+
if (!bundles.includes(name)) bundles.push(name)
|
|
132
144
|
} else {
|
|
133
|
-
const index = bundles.indexOf(
|
|
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
|
|
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'),
|
package/lib/agent-runner.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
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
|
+
}
|