dsh-line-bot 0.4.1 → 0.5.1

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.1
4
+
5
+ - 主動傳訊(含低餘額提醒)現在也能配表情包:預設提示詞允許在句子後面加 `[[meme:#編號]]`,不適合就純文字。
6
+
7
+ ## 0.5.0
8
+
9
+ - 表情包(dsh-meme):角色回覆時可以配圖或只回一張圖;圖片來自已安裝的 dsh-meme 圖庫(內建「大肥魚」24 張),面板可開關、限制一次最多 1~3 張、預覽圖庫樣本。
10
+ - 圖片經目前的 Cloudflare 公開網址提供給 LINE;quick tunnel 換網址後自動跟上。
11
+ - 為避免和 dsh-meme 的 chat 用法混淆,LINE 回覆會隱藏 `send_meme` 等工具,改用 `[[meme:#編號]]` 標記。
12
+
3
13
  ## 0.4.1
4
14
 
5
15
  - 角色現在知道「正在用哪個 provider/模型」回答:每輪會帶入目前模型,問它「你是哪個模型」時會照實回答。
package/README.en.md CHANGED
@@ -77,6 +77,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
77
77
  | Reply persona | Choose a persona (dafeiyu or a plugin custom one), add/edit/delete custom personas, copy the current persona into a custom one, enforce, allow users to switch personas, switch dafeiyu to session mode, test an Agent reply |
78
78
  | Tieba learning sync | Writes the tone samples learned while browsing Tieba back into the persona's "Tieba tone" section, preview diff, sync now |
79
79
  | Personal memory | Per-user long-term memory, expiry cleanup, daily self-review, manual delete |
80
+ | Memes | Reply with images/stickers from the dsh-meme library, enable/disable, image limit, and sample previews |
80
81
  | DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown (deepseek-account only) |
81
82
  | Proactive messages | Toggle, probability, interval, daily/monthly caps, minimum gap, quiet hours, idle, target users, prompt, send a test now, statistics |
82
83
  | 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 |
@@ -164,6 +165,14 @@ The character keeps its own memory at `<DSH_HOME>/line-bot/memories.json`, separ
164
165
  - Each turn only injects the most recent N entries (20 entries / 2000 characters by default); the rest stay in the file. The per-user cap defaults to 120 entries and evicts the oldest.
165
166
  - Passwords, tokens, ID numbers and similar secrets should not be stored; it avoids them.
166
167
  - The "Personal memory" panel card shows what it remembers, lets you delete entries, prune expired ones, or trigger a review right away.
168
+ ## Memes (dsh-meme)
169
+
170
+ The persona can attach a sticker to a text reply, or reply with just an image. Images come from the installed dsh-meme plugin's library (the built-in "大肥鱼" pack has 24 images):
171
+
172
+ - 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
+ - Images are served to LINE through the current Cloudflare public URL (`<url>/media/<token>`), and the URL follows quick-tunnel restarts automatically.
174
+ - 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.
175
+ - Without dsh-meme installed, the feature stays off and replies are text only.
167
176
  ## Troubleshooting
168
177
 
169
178
  - **The panel says "cannot connect to the LINE Bot runtime"**: webServer may not be ready yet; refresh. If it still fails, look for `[line-bot]` messages in the DSH logs.
package/README.ja.md CHANGED
@@ -77,6 +77,7 @@ dsh plugin --profile desktop add dsh-line-bot
77
77
  | 返信ペルソナ | ペルソナ選択(大肥魚またはプラグインのカスタム)、カスタムペルソナの追加・編集・削除、現在のペルソナをカスタムに複製、強制適用、ユーザーによる切り替え許可、大肥魚を session モードに切り替え、Agent 返信のテスト |
78
78
  | 貼吧学習の同期 | 貼吧で学んだ口調をペルソナの「貼吧学来的口气」節に書き戻す、差分プレビュー、今すぐ同期 |
79
79
  | 個人メモリ | ユーザーごとの長期記憶、期限切れの自動削除、毎日の自己整理、手動削除 |
80
+ | 表情パック | dsh-meme の画像ライブラリで画像/スタンプを返信、オンオフ、1 回の上限、サンプル表示 |
80
81
  | DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン(deepseek-account のみ) |
81
82
  | 自発メッセージ | オン/オフ、確率、間隔、1 日/1 か月の上限、最短間隔、静かな時間帯、idle、対象ユーザー、プロンプト、今すぐ試送、統計 |
82
83
  | ユーザー | LINE ユーザーごとに対応する DSH セッション、専用ワークスペース、現在のペルソナ(直接変更可)、メッセージ数、最終やりとり、メモリ消去/セッション再作成 |
@@ -164,6 +165,14 @@ dsh plugin --profile desktop add dsh-line-bot
164
165
  - 毎ターンは直近 N 件(既定 20 件・2000 文字)だけを注入し、残りはファイルに残します。1 人あたりの上限は既定 120 件で、超えると古いものから消えます。
165
166
  - パスワード、token、身分証番号などの機密情報は記録しません。
166
167
  - パネルの「個人メモリ」カードで、記録内容の確認、手動削除、期限切れの一括削除、今すぐの見直しができます。
168
+ ## 表情パック(dsh-meme)
169
+
170
+ キャラクターはテキストの返信にスタンプ画像を添えたり、画像だけを返したりできます。画像はインストール済みの dsh-meme プラグインのライブラリ(内蔵「大肥魚」24 枚)から取得します。
171
+
172
+ - 毎ターン、ライブラリのサンプル(番号+説明)を context に注入します。画像を添えたいときは返信の最後に `[[meme:#番号]]`(または説明)を付けます。プラグインが LINE の image message に変換します。
173
+ - 画像は現在の Cloudflare 公開 URL(`<URL>/media/<token>`)経由で LINE に渡されます。quick tunnel の再起動で URL が変わっても自動で追従します。
174
+ - パネルの「表情パック」カードでオンオフ、1 回あたりの上限(1〜3)、ライブラリのサンプル表示ができます。画像の追加や分類の変更は dsh-meme 側の設定で行います。
175
+ - dsh-meme がインストールされていない場合、この機能は無効になりテキストのみ返します。
167
176
  ## トラブルシューティング
168
177
 
169
178
  - **パネルに「LINE Bot ランタイムに接続できません」と出る**:webServer がまだ準備できていない可能性があります。再読み込みしてください。それでも失敗する場合は DSH ログの `[line-bot]` を確認します。
package/README.ko.md CHANGED
@@ -77,6 +77,7 @@ dsh plugin --profile desktop add dsh-line-bot
77
77
  | 답장 페르소나 | 페르소나 선택(大肥魚 또는 플러그인 커스텀), 커스텀 페르소나 추가/편집/삭제, 현재 페르소나를 커스텀으로 복사, 강제 적용, 사용자 직접 전환 허용, 大肥魚를 session 모드로 전환, Agent 답장 테스트 |
78
78
  | 貼吧 학습 동기화 | 貼吧에서 배운 말투를 페르소나의 「貼吧学来的口气」 섹션으로 기록, 차이 미리보기, 지금 동기화 |
79
79
  | 개인 기억 | 사용자별 장기 기억, 만료 자동 삭제, 매일 자기 정리, 수동 삭제 |
80
+ | 이모티콘 | dsh-meme 라이브러리로 이미지/스티커 답장, 켜기/끄기, 1회 최대 장수, 샘플 미리보기 |
80
81
  | DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운(deepseek-account 전용) |
81
82
  | 능동 메시지 | 켜기/끄기, 확률, 간격, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 대상 사용자, 프롬프트, 지금 시험 발송, 통계 |
82
83
  | 사용자 | LINE 사용자별 DSH 세션, 전용 작업 폴더, 현재 페르소나(직접 변경 가능), 메시지 수, 마지막 상호작용, 기억 지우기/세션 재설정 |
@@ -164,6 +165,14 @@ dsh plugin --profile desktop add dsh-line-bot
164
165
  - 매 턴에는 최근 N개(기본 20개·2000자)만 주입하고 나머지는 파일에 남깁니다. 사용자당 상한은 기본 120개이며, 넘으면 오래된 것부터 지웁니다.
165
166
  - 비밀번호, token, 신분증 번호 같은 민감 정보는 기록하지 않습니다.
166
167
  - 패널의 "개인 기억" 카드에서 기록 내용 확인, 수동 삭제, 만료 일괄 정리, 지금 정리를 할 수 있습니다.
168
+ ## 이모티콘(dsh-meme)
169
+
170
+ 캐릭터는 텍스트 답장에 스티커 이미지를 붙이거나 이미지만 보낼 수 있습니다. 이미지는 설치된 dsh-meme 플러그인의 라이브러리(내장 「대비어」 24장)에서 가져옵니다.
171
+
172
+ - 매 턴 라이브러리 샘플(번호+설명)을 context에 넣습니다. 이미지를 붙이려면 답장 끝에 `[[meme:#번호]]`(또는 설명)를 붙입니다. 플러그인이 LINE image message로 바꿔 보냅니다.
173
+ - 이미지는 현재 Cloudflare 공개 URL(`<URL>/media/<token>`)을 통해 LINE에 전달되며, quick tunnel이 재시작돼 URL이 바뀌어도 자동으로 따라갑니다.
174
+ - 패널의 「이모티콘」 카드에서 켜고 끄기, 답장당 최대 장수(1~3), 라이브러리 샘플 미리보기를 할 수 있습니다. 이미지 추가나 분류 변경은 dsh-meme 설정에서 하세요.
175
+ - dsh-meme이 설치되어 있지 않으면 이 기능은 꺼지고 텍스트만 답장합니다.
167
176
  ## 문제 해결
168
177
 
169
178
  - **패널에 "LINE Bot 런타임에 연결할 수 없습니다"**: webServer가 아직 준비되지 않았을 수 있습니다. 새로 고침하세요. 그래도 실패하면 DSH 로그의 `[line-bot]` 메시지를 확인합니다.
package/README.md CHANGED
@@ -77,6 +77,7 @@ dsh plugin --profile desktop add dsh-line-bot
77
77
  | 回覆人設 | 選擇人設(大肥魚或插件自訂)、新增/編輯/刪除自訂人設、把目前人設複製成自訂、強制套用、允許使用者自己換人設、切換大肥魚 session 模式、測試 Agent 回覆 |
78
78
  | 貼吧學習同步 | 把刷貼吧學到的語氣寫回人設的「貼吧學來的口氣」段、預覽差異、立即同步 |
79
79
  | 個人記憶 | 每位使用者分開的長期記憶、到期自動刪、每日請角色整理、手動刪除 |
80
+ | 表情包 | 使用 dsh-meme 圖庫回圖片/貼圖、開關、一次最多幾張、圖庫樣本 |
80
81
  | DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻(僅 deepseek-account) |
81
82
  | 主動傳訊 | 開關、機率、間隔、每日/每月上限、最短間隔、安靜時段、idle、指定對象、提示詞、立即試傳、統計 |
82
83
  | 使用者 | 每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
@@ -164,6 +165,14 @@ dsh plugin --profile desktop add dsh-line-bot
164
165
  - 每輪只帶入最近的 N 筆(預設 20 筆、2000 字),其餘仍留在檔案裡;每人上限預設 120 筆,超過時刪最舊的。
165
166
  - 密碼、token、證件號等敏感資料不該叫它記,它也會避免。
166
167
  - 面板的「個人記憶」卡可以看目前記了什麼、手動刪除、立即清理到期、或現在就叫它整理一次。
168
+ ## 表情包(dsh-meme)
169
+
170
+ 角色可以回文字時配一張貼圖,也可以只回一張圖。圖片來自已安裝的 dsh-meme 外掛的圖庫(內建「大肥魚」24 張):
171
+
172
+ - 每輪會把圖庫樣本(編號+描述)帶進 context;角色想配圖時在回覆最後加 `[[meme:#編號]]`(或描述),插件再組成 LINE 的 image message。
173
+ - 圖片透過目前的 Cloudflare 公開網址(`<網址>/media/<token>`)提供給 LINE;quick tunnel 換網址後會自動跟上。
174
+ - 面板「表情包」卡可以開關、限制一次最多幾張(1~3),並預覽圖庫樣本。要新增圖片或改分類,請用 dsh-meme 自己的設定。
175
+ - 沒安裝 dsh-meme 時這個功能會自動停用,只回文字。
167
176
  ## 疑難排解
168
177
 
169
178
  - **面板顯示「無法連線到 LINE Bot 執行期」**:webServer 可能還沒就緒,重新整理即可;仍失敗就看 DSH 日誌裡 `[line-bot]` 的訊息。
package/README.zh-CN.md CHANGED
@@ -77,6 +77,7 @@ dsh plugin --profile desktop add dsh-line-bot
77
77
  | 回复人设 | 选择人设(大肥鱼或插件自定义)、新增/编辑/删除自定义人设、把目前人设复制成自定义、强制套用、允许用户自己换人设、切换大肥鱼 session 模式、测试 Agent 回复 |
78
78
  | 贴吧学习同步 | 把刷贴吧学到的语气写回人设的「贴吧学来的口气」段、预览差异、立即同步 |
79
79
  | 个人记忆 | 每位用户分开的长期记忆、到期自动删除、每日请角色整理、手动删除 |
80
+ | 表情包 | 使用 dsh-meme 图库回图片/贴图、开关、每次最多几张、图库样本 |
80
81
  | DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却(仅 deepseek-account) |
81
82
  | 主动传讯 | 开关、概率、间隔、每日/每月上限、最短间隔、安静时段、idle、指定对象、提示词、立即试传、统计 |
82
83
  | 用户 | 每位 LINE 用户对应的 DSH session、专属工作文件夹、目前人设(可直接改)、消息数、上次互动、清空记忆/重设 session |
@@ -164,6 +165,14 @@ dsh plugin --profile desktop add dsh-line-bot
164
165
  - 每轮只带入最近的 N 笔(默认 20 笔、2000 字),其余仍留在文件里;每人上限默认 120 笔,超过时删除最旧的。
165
166
  - 密码、token、证件号等敏感资料不该叫它记,它也会避免。
166
167
  - 面板的「个人记忆」卡可以看目前记了什么、手动删除、立即清理到期、或现在就叫它整理一次。
168
+ ## 表情包(dsh-meme)
169
+
170
+ 角色可以在文字后面配一张贴图,也可以只回一张图。图片来自已安装的 dsh-meme 插件的图库(内置「大肥鱼」24 张):
171
+
172
+ - 每轮会把图库样本(编号+描述)带进 context;角色想配图时在回复最后加 `[[meme:#编号]]`(或描述),插件再组成 LINE 的 image message。
173
+ - 图片通过当前的 Cloudflare 公开网址(`<网址>/media/<token>`)提供给 LINE;quick tunnel 换网址后会自动跟上。
174
+ - 面板「表情包」卡可以开关、限制每次最多几张(1~3),并预览图库样本。要新增图片或改分类,请用 dsh-meme 自己的设置。
175
+ - 没安装 dsh-meme 时该功能会自动停用,只回文字。
167
176
  ## 疑难排解
168
177
 
169
178
  - **面板显示「无法连接到 LINE Bot 运行时」**:webServer 可能还没就绪,刷新即可;仍失败就看 DSH 日志里 `[line-bot]` 的消息。
package/client.js CHANGED
@@ -21,6 +21,7 @@ window.__ModuleLoader__.load({
21
21
  { key: 'memory', label: '個人記憶' },
22
22
  { key: 'balance', label: 'DeepSeek 餘額' },
23
23
  { key: 'nudge', label: '主動傳訊' },
24
+ { key: 'meme', label: '表情包' },
24
25
  { key: 'users', label: '使用者' },
25
26
  { key: 'advanced', label: '進階/日誌' },
26
27
  ]
@@ -298,6 +299,7 @@ window.__ModuleLoader__.load({
298
299
 
299
300
  // ── 主面板 ─────────────────────────────────────────────────────────────
300
301
  const ACTION_LABELS = {
302
+ 'meme-stats': '讀取表情包圖庫',
301
303
  start: '啟動',
302
304
  stop: '停止',
303
305
  restart: '重新啟動',
@@ -656,6 +658,9 @@ window.__ModuleLoader__.load({
656
658
  const nudgeCfg = cfg.nudge || {}
657
659
  const syncCfg = cfg.sync || {}
658
660
  const balanceCfg = cfg.balance || {}
661
+ const memeCfg = cfg.stickers || {}
662
+ const memeStatus = status.meme || {}
663
+ const memeSample = Array.isArray(memeStatus.sample) ? memeStatus.sample : []
659
664
  const balanceStatus = status.balance || {}
660
665
  const balanceSymbol = balanceStatus.cny ? '¥' : balanceStatus.usd ? '$' : '¥'
661
666
  const balanceTotal = balanceStatus.cny ? balanceStatus.cny.total : balanceStatus.usd ? balanceStatus.usd.total : null
@@ -1132,10 +1137,34 @@ window.__ModuleLoader__.load({
1132
1137
  nudgeStats.lastError ? h('div', { style: S.errBox }, nudgeStats.lastError) : null,
1133
1138
  ),
1134
1139
 
1140
+ // 5.5 表情包(dsh-meme 圖庫)
1141
+ h(
1142
+ Card,
1143
+ { id: sectionDomId('meme'), order: 10, title: '表情包(dsh-meme 圖庫)', desc: '角色可以在文字後面配一張圖,也可以只回一張圖。圖片來自 dsh-meme 外掛的圖庫;要新增、分類或改描述,請到 dsh-meme 的設定頁。' },
1144
+ !memeStatus.available
1145
+ ? h('div', { style: S.warnBox }, '沒有偵測到 dsh-meme 外掛(或尚未啟用)。請先安裝並啟用 dsh-meme,這裡才會出現可用圖片。')
1146
+ : h('div', null,
1147
+ h('div', { style: S.row },
1148
+ h(Badge, { ok: true }, '圖庫 ' + ((memeStatus.packs || []).length) + ' 個'),
1149
+ h(Badge, { ok: true }, '共 ' + (memeStatus.count || 0) + ' 張'),
1150
+ h(Badge, { ok: !!memeStatus.publicBase }, memeStatus.publicBase ? '公開網址正常' : '沒有公開網址'),
1151
+ ),
1152
+ h(Toggle, { checked: memeCfg.enabled !== false, onChange: (value) => update({ stickers: { enabled: value } }), label: '允許角色使用表情包', hint: '關掉後只回文字' }),
1153
+ h(Field, { label: '一次最多幾張', hint: 'LINE 一次回覆最多 5 則訊息,圖片也算在內' }, h(Num, { value: memeCfg.maxPerReply, min: 1, max: 3, step: 1, onChange: (value) => update({ stickers: { maxPerReply: value } }) })),
1154
+ h('div', { style: S.row },
1155
+ h(Btn, { onClick: () => act('meme-stats'), disabled: !!busy }, busy === 'meme-stats' ? '讀取中…' : '重新讀取圖庫'),
1156
+ ),
1157
+ h('div', { style: S.fieldTitle }, '圖庫樣本'),
1158
+ memeSample.length === 0
1159
+ ? h('div', { style: S.hint }, '目前讀不到樣本;確認 dsh-meme 是否已啟用圖庫。')
1160
+ : h('div', null, memeSample.map((item, index) => h('div', { key: index, style: { fontSize: 13, padding: '4px 0', borderTop: '1px solid var(--dsw-alias-border-l2)' } }, '・' + item.caption + '(' + item.tag + ')'))),
1161
+ ),
1162
+ ),
1163
+
1135
1164
  // 6. 使用者
1136
1165
  h(
1137
1166
  Card,
1138
- { id: sectionDomId('users'), order: 10, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
1167
+ { id: sectionDomId('users'), order: 11, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
1139
1168
  (status.users || []).length === 0
1140
1169
  ? h('div', { style: S.hint }, '還沒有人傳訊息給 bot。')
1141
1170
  : h('table', { style: S.table },
@@ -1171,7 +1200,7 @@ window.__ModuleLoader__.load({
1171
1200
  // 7. 進階設定
1172
1201
  h(
1173
1202
  Card,
1174
- { id: sectionDomId('advanced'), order: 11, title: '進階設定', desc: '通常不用改;出問題時可從這裡調整。' },
1203
+ { id: sectionDomId('advanced'), order: 20, title: '進階設定', desc: '通常不用改;出問題時可從這裡調整。' },
1175
1204
  h('div', { style: S.grid },
1176
1205
  h(Field, { label: 'Webhook 連接埠', hint: '改完按儲存;伺服器會自動重啟。' }, h(Num, { value: server.port, min: 1024, max: 65535, onChange: (value) => update({ server: { port: value } }) })),
1177
1206
  h(Field, { label: '每位使用者獨立工作資料夾', hint: '開啟=每個 LINE 使用者有自己的 cwd(檔案、AGENTS.md、工作區記憶互不相通)' },
@@ -120,6 +120,20 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
120
120
  } catch (error) {
121
121
  logger?.warn?.(`注入人設/貼吧動態失敗:${error?.message || error}`)
122
122
  }
123
+ try {
124
+ const registry = agentCtx.get?.('tools') || agentCtx.tools
125
+ if (registry?.restrict) {
126
+ for (const name of ['send_meme', 'learn_meme', 'meme_update', 'meme_list']) {
127
+ try {
128
+ agentCtx.effect(() => registry.restrict({ deny: [name] }), `line-bot: hide ${name}`)
129
+ } catch {
130
+ /* 這個 profile 沒有這個工具就略過 */
131
+ }
132
+ }
133
+ }
134
+ } catch (error) {
135
+ logger?.warn?.(`隱藏 dsh-meme 工具失敗:${error?.message || error}`)
136
+ }
123
137
  if (Array.isArray(tools) && tools.length > 0) {
124
138
  try {
125
139
  const registry = agentCtx.get?.('tools') || agentCtx.tools
package/lib/config.mjs CHANGED
@@ -111,6 +111,11 @@ export const DEFAULT_CONFIG = {
111
111
  reviewDaily: true,
112
112
  reviewHour: 4,
113
113
  },
114
+ stickers: {
115
+ // 角色可以用圖片/表情包回話;圖片由面板上傳,存在 <dataDir>/stickers。
116
+ enabled: true,
117
+ maxPerReply: 1,
118
+ },
114
119
  log: {
115
120
  maxLines: 400,
116
121
  },
@@ -232,6 +237,10 @@ export function sanitizePatch(current, patch) {
232
237
  memory.injectMaxChars = clampInt(memory.injectMaxChars, 200, 8000, 2000)
233
238
  memory.reviewHour = clampInt(memory.reviewHour, 0, 23, 4)
234
239
 
240
+ const stickers = next.stickers || (next.stickers = {})
241
+ stickers.enabled = Boolean(stickers.enabled)
242
+ stickers.maxPerReply = clampInt(stickers.maxPerReply, 1, 3, 1)
243
+
235
244
  const nudge = next.nudge
236
245
  nudge.enabled = Boolean(nudge.enabled)
237
246
  nudge.chance = clampFloat(nudge.chance, 0, 1, 0.18)
package/lib/host.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  // 大肥魚人設、agent 回覆與主動傳訊,全部集中在這裡,index.js 只負責載入與路由掛載。
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
5
- import { randomUUID } from 'node:crypto'
5
+ import { randomBytes, randomUUID, createHash } from 'node:crypto'
6
+ import { execFileSync } from 'node:child_process'
6
7
  // 用 ?v= 載入:dev/reload 重新 import host.mjs 時,這整組模組也會跟著重新求值,
7
8
  // 不用重開 DSH 就能套用 lib/*.mjs 的修改。
8
9
  const LOAD_VERSION = new URL(import.meta.url).searchParams.get('v') || ''
@@ -20,6 +21,8 @@ const localPersonasMod = await import(withVersion('./local-personas.mjs'))
20
21
  const tiebaContextMod = await import(withVersion('./tieba-context.mjs'))
21
22
  const balanceMod = await import(withVersion('./deepseek-balance.mjs'))
22
23
  const memoryMod = await import(withVersion('./memory.mjs'))
24
+ const stickersMod = await import(withVersion('./stickers.mjs'))
25
+ const memeSourceMod = await import(withVersion('./meme-source.mjs'))
23
26
  const utilMod = await import(withVersion('./util.mjs'))
24
27
  const { createConfigStore, publicConfig, dataDir } = configMod
25
28
  const { createPersonaClient } = personaMod
@@ -32,9 +35,11 @@ const { createLocalPersonas, LOCAL_PREFIX } = localPersonasMod
32
35
  const { buildTiebaContext } = tiebaContextMod
33
36
  const { createBalanceMonitor } = balanceMod
34
37
  const { createMemoryStore } = memoryMod
38
+ const { createStickerStore } = stickersMod
39
+ const { createMemeSource } = memeSourceMod
35
40
  const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray } = utilMod
36
41
 
37
- export const VERSION = '0.4.1'
42
+ export const VERSION = '0.5.0'
38
43
 
39
44
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
40
45
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -106,6 +111,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
106
111
  lastReply: null,
107
112
  lastTokenIssueAt: 0,
108
113
  tokenSource: '',
114
+ publicUrl: '',
109
115
  }
110
116
 
111
117
  const service = (name) => {
@@ -299,7 +305,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
299
305
  }
300
306
 
301
307
  function agentContextText(userId) {
302
- return [modelContextText(), tiebaContextText(), balanceContextText(), memoryContextText(userId)].filter(Boolean).join('\n\n')
308
+ return [modelContextText(), tiebaContextText(), balanceContextText(), memoryContextText(userId), memeContextText() || stickerContextText()].filter(Boolean).join('\n\n')
303
309
  }
304
310
 
305
311
  async function refreshBalance() {
@@ -819,14 +825,202 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
819
825
  return { reply: `${prefix}「${picked.name}」回你。` }
820
826
  }
821
827
 
828
+ function publicBaseSync() {
829
+ const cfg = store.get()
830
+ if (runtime.publicUrl) return String(runtime.publicUrl).replace(/\/+$/, '')
831
+ if (cfg.tunnel?.publicUrl) return String(cfg.tunnel.publicUrl).replace(/\/+$/, '')
832
+ if (cfg.tunnel?.mode === 'api' && cfg.cloudflare?.hostname) return `https://${cfg.cloudflare.hostname}`
833
+ return ''
834
+ }
835
+
836
+ const STICKER_MARKER = /\[\[\s*(?:sticker|貼圖|贴图|表情包)\s*:\s*([^\]\n]{1,60})\s*\]\]/gi
837
+ const MEME_MARKER = /\[\[\s*meme\s*:\s*([^\]\n]{1,80})\s*\]\]/gi
838
+ const MEME_BRACKET = /\[表情\s*[::]\s*([^\]\n]{1,80})\]/g
839
+
840
+ function splitStickerMarkers(text) {
841
+ const names = []
842
+ const memeNames = []
843
+ let cleaned = String(text || '')
844
+ .replace(STICKER_MARKER, (_match, name) => {
845
+ const value = String(name || '').trim()
846
+ if (value) names.push(value)
847
+ return ''
848
+ })
849
+ .replace(MEME_MARKER, (_match, name) => {
850
+ const value = String(name || '').trim()
851
+ if (value) memeNames.push(value)
852
+ return ''
853
+ })
854
+ if (memes.available()) {
855
+ cleaned = cleaned.replace(MEME_BRACKET, (_match, name) => {
856
+ const value = String(name || '').trim()
857
+ if (value) memeNames.push(value)
858
+ return ''
859
+ })
860
+ }
861
+ cleaned = cleaned
862
+ .replace(/[ \t]+\n/g, '\n')
863
+ .replace(/\n{3,}/g, '\n\n')
864
+ .trim()
865
+ return { text: cleaned, names, memes: memeNames }
866
+ }
867
+
868
+ function memeMaxPerReply() {
869
+ return Math.max(1, Math.min(3, Number(store.get().stickers?.maxPerReply) || 1))
870
+ }
871
+
872
+ function memeContextText() {
873
+ const cfg = store.get()
874
+ if (cfg.stickers?.enabled === false) return ''
875
+ if (!memes.available()) return ''
876
+ if (memes.packs().length === 0) return ''
877
+ const sample = memes.sample({ limit: 20 })
878
+ const lines = (sample || [])
879
+ .map((item) => String(item.caption || '').trim())
880
+ .filter(Boolean)
881
+ .slice(0, 20)
882
+ if (lines.length === 0) return ''
883
+ return [
884
+ '【表情包】你的圖庫(dsh-meme)裡有這些圖:',
885
+ lines.map((line, index) => `${index + 1}. ${line}`).join('\n'),
886
+ `想配圖時,在回覆最後加上 [[meme:#編號]](例如 [[meme:#3]],編號照上面清單),也可以寫描述;只回一張圖就只輸出那個標記。一次最多 ${memeMaxPerReply()} 張;氣氛合適才用,不要每句都加;不要呼叫 send_meme。`,
887
+ publicBaseSync() ? '' : '(目前沒有公開網址,圖片送不出去。)',
888
+ ].filter(Boolean).join('\n')
889
+ }
890
+
891
+ function stickerContextText() {
892
+ const cfg = store.get()
893
+ if (cfg.stickers?.enabled === false) return ''
894
+ const items = stickers.list()
895
+ if (items.length === 0) return ''
896
+ const list = items.slice(0, 24).map((item) => `${item.name}${item.description ? `(${item.description})` : ''}`).join('、')
897
+ return [
898
+ '【表情包】你可以在文字後面配一張圖,也可以只回一張圖。',
899
+ `可用:${list}`,
900
+ `想用時把 [[sticker:名稱]] 放在回覆最後,一次最多 ${memeMaxPerReply()} 張,名稱要完全符合;不需要就不要用,也不要每句都加。`,
901
+ publicBaseSync() ? '' : '(目前沒有公開網址,圖片暫時送不出去。)',
902
+ ].filter(Boolean).join('\n')
903
+ }
904
+
905
+ function convertWebpToPng(filePath) {
906
+ if (process.platform !== 'darwin') return ''
907
+ try {
908
+ const cacheDir = path.join(dir, 'cache')
909
+ fs.mkdirSync(cacheDir, { recursive: true })
910
+ const stat = fs.statSync(filePath)
911
+ const key = createHash('sha1').update(`${filePath}:${stat.mtimeMs}`).digest('hex').slice(0, 16)
912
+ const dest = path.join(cacheDir, `${key}.png`)
913
+ if (!fs.existsSync(dest)) {
914
+ execFileSync('sips', ['-s', 'format', 'png', filePath, '--out', dest], { stdio: 'ignore', timeout: 15000 })
915
+ }
916
+ return fs.existsSync(dest) ? dest : ''
917
+ } catch (error) {
918
+ logger.warn(`轉換表情包格式失敗:${errorText(error)}`)
919
+ return ''
920
+ }
921
+ }
922
+
923
+ function registerImageFile(filePath) {
924
+ const ext = path.extname(String(filePath || '')).toLowerCase()
925
+ let serve = filePath
926
+ let mime = ''
927
+ if (ext === '.jpg' || ext === '.jpeg') mime = 'image/jpeg'
928
+ else if (ext === '.png') mime = 'image/png'
929
+ else if (ext === '.webp') {
930
+ mime = 'image/png'
931
+ serve = convertWebpToPng(filePath)
932
+ } else {
933
+ return ''
934
+ }
935
+ if (!mime || !serve || !fs.existsSync(serve)) return ''
936
+ const token = `m${randomBytes(6).toString('hex')}`
937
+ mediaTokens.set(token, { path: serve, mime, at: Date.now() })
938
+ return token
939
+ }
940
+
941
+ function getMedia(id) {
942
+ const key = String(id || '')
943
+ if (!key) return null
944
+ if (key.startsWith('m')) {
945
+ const hit = mediaTokens.get(key)
946
+ if (!hit) return null
947
+ if (Date.now() - hit.at > 60 * 60 * 1000) {
948
+ mediaTokens.delete(key)
949
+ return null
950
+ }
951
+ try {
952
+ return { mime: hit.mime, bytes: fs.readFileSync(hit.path) }
953
+ } catch {
954
+ mediaTokens.delete(key)
955
+ return null
956
+ }
957
+ }
958
+ const entry = stickers.get(key)
959
+ const bytes = entry ? stickers.readFile(entry) : null
960
+ return entry && bytes ? { mime: entry.mime, bytes } : null
961
+ }
962
+
963
+ async function buildOutgoingMessages(text) {
964
+ const cfg = store.get()
965
+ const parsed = splitStickerMarkers(text)
966
+ const messages = []
967
+ const max = memeMaxPerReply()
968
+ const base = publicBaseSync()
969
+ if (cfg.stickers?.enabled !== false && base && parsed.memes.length > 0 && memes.available()) {
970
+ const sample = memes.sample({ limit: 20 })
971
+ for (const desc of parsed.memes.slice(0, max)) {
972
+ const indexMatch = /^#?(\d{1,3})$/.exec(String(desc).trim())
973
+ const index = indexMatch ? Number(indexMatch[1]) - 1 : -1
974
+ let file = index >= 0 ? (sample[index]?.path || '') : ''
975
+ if (!file) {
976
+ const picked = memes.pick({ query: desc })
977
+ file = picked?.ok && picked.path ? picked.path : ''
978
+ }
979
+ if (!file) {
980
+ const fallback = memes.search({ query: desc, limit: 1 })
981
+ file = fallback.memes?.[0]?.path || ''
982
+ }
983
+ if (!file) {
984
+ logger.warn(`找不到表情包:${desc}`)
985
+ continue
986
+ }
987
+ const token = registerImageFile(file)
988
+ if (!token) {
989
+ logger.warn(`表情包格式不支援(LINE 只收 JPEG/PNG):${file}`)
990
+ continue
991
+ }
992
+ const url = `${base}/media/${token}`
993
+ messages.push({ type: 'image', originalContentUrl: url, previewImageUrl: url })
994
+ }
995
+ }
996
+ if (cfg.stickers?.enabled !== false && base && parsed.names.length > 0) {
997
+ for (const name of parsed.names.slice(0, max)) {
998
+ const entry = stickers.get(name)
999
+ if (!entry) {
1000
+ logger.warn(`找不到表情包:${name}`)
1001
+ continue
1002
+ }
1003
+ const url = `${base}/media/${encodeURIComponent(entry.id)}`
1004
+ messages.push({ type: 'image', originalContentUrl: url, previewImageUrl: url })
1005
+ }
1006
+ }
1007
+ if (parsed.text) {
1008
+ for (const chunk of chunkText(parsed.text)) {
1009
+ if (messages.length >= 5) break
1010
+ messages.push(chunk)
1011
+ }
1012
+ }
1013
+ return { messages: messages.slice(0, 5), parsed }
1014
+ }
1015
+
822
1016
  async function replyOrPush(userId, replyToken, texts) {
823
- const chunks = chunkText(texts)
824
- if (chunks.length === 0) return
1017
+ const { messages } = await buildOutgoingMessages(texts)
1018
+ if (messages.length === 0) return
825
1019
  try {
826
- await withToken((token) => lineApi.replyMessage(token, replyToken, chunks))
1020
+ await withToken((token) => lineApi.replyMessage(token, replyToken, messages))
827
1021
  } catch (error) {
828
1022
  logger.warn(`reply 失敗,改用 push:${errorText(error)}`)
829
- await withToken((token) => lineApi.pushMessage(token, userId, chunks))
1023
+ await withToken((token) => lineApi.pushMessage(token, userId, messages))
830
1024
  }
831
1025
  }
832
1026
 
@@ -896,9 +1090,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
896
1090
  cwd: ensureUserWorkspace(userId) || cfg.agent.cwd || undefined,
897
1091
  })
898
1092
  let replyText = String(result?.text || '').trim()
899
- if (!replyText) replyText = cfg.line.emptyReply || '(她沒有說話)'
1093
+ const parsedReply = splitStickerMarkers(replyText)
1094
+ if (!parsedReply.text && parsedReply.names.length === 0) replyText = cfg.line.emptyReply || '(她沒有說話)'
900
1095
  await replyOrPush(userId, event.replyToken, replyText)
901
- runtime.lastReply = { userId, at: Date.now(), text: clip(replyText, 300) }
1096
+ runtime.lastReply = { userId, at: Date.now(), text: clip(parsedReply.text || (parsedReply.names.length ? `[表情包] ${parsedReply.names.join('、')}` : replyText), 300) }
902
1097
  store.updateState((state) => ({
903
1098
  ...state,
904
1099
  lastReply: runtime.lastReply,
@@ -918,8 +1113,11 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
918
1113
 
919
1114
  // ── 元件 ────────────────────────────────────────────────────────────────
920
1115
  const runner = createAgentRunner({ ctx, logger, getConfig: () => store.get() })
1116
+ const stickers = createStickerStore({ dir, logger })
1117
+ const memes = createMemeSource({ ctx, logger })
1118
+ const mediaTokens = new Map()
921
1119
 
922
- const server = createWebhookServer({ getConfig: () => store.get(), logger, dispatch: handleEvent })
1120
+ const server = createWebhookServer({ getConfig: () => store.get(), logger, dispatch: handleEvent, getMedia })
923
1121
 
924
1122
  const tunnel = createTunnelManager({
925
1123
  getConfig: () => withEnvCloudflare(store.get()),
@@ -928,6 +1126,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
928
1126
  dataDir: dir,
929
1127
  onUrl: async (url) => {
930
1128
  logger.info(`Cloudflare 公網位址:${url}`)
1129
+ runtime.publicUrl = String(url || '').replace(/\/+$/, '')
931
1130
  if (!store.get().tunnel.autoConfigureWebhook) return
932
1131
  try {
933
1132
  await applyWebhook(url)
@@ -941,8 +1140,9 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
941
1140
  })
942
1141
 
943
1142
  async function sendPush(userId, text) {
944
- const chunks = chunkText(text)
945
- await withToken((token) => lineApi.pushMessage(token, userId, chunks))
1143
+ const { messages } = await buildOutgoingMessages(text)
1144
+ if (messages.length === 0) return
1145
+ await withToken((token) => lineApi.pushMessage(token, userId, messages))
946
1146
  store.updateState((state) => ({
947
1147
  ...state,
948
1148
  users: {
@@ -1053,6 +1253,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1053
1253
  throw error
1054
1254
  }
1055
1255
  }
1256
+ const liveTunnel = await tunnel.status().catch(() => null)
1257
+ if (liveTunnel?.url) runtime.publicUrl = String(liveTunnel.url).replace(/\/+$/, '')
1056
1258
  startWebhookGuard()
1057
1259
  nudge.start()
1058
1260
  tiebaSync.start()
@@ -1152,6 +1354,22 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1152
1354
  lastAskText: state.balance?.lastAskText || '',
1153
1355
  low: Boolean(balanceView() && Number(cfg.balance.threshold) > 0 && balanceView().value.total < Number(cfg.balance.threshold)),
1154
1356
  },
1357
+ meme: {
1358
+ ...memes.stats(),
1359
+ enabled: cfg.stickers?.enabled !== false,
1360
+ maxPerReply: memeMaxPerReply(),
1361
+ publicBase: publicBaseSync(),
1362
+ sample: memes.sample({ limit: 8 }).map((item) => ({ tag: item.tag, caption: item.caption, packName: item.packName })),
1363
+ localStickers: stickers.stats().count,
1364
+ },
1365
+ stickers: {
1366
+ ...stickers.stats(),
1367
+ enabled: cfg.stickers?.enabled !== false,
1368
+ maxPerReply: Math.max(1, Math.min(3, Number(cfg.stickers?.maxPerReply) || 1)),
1369
+ publicBase: publicBaseSync(),
1370
+ items: stickers.list(),
1371
+ warning: publicBaseSync() ? '' : '尚未取得公開網址(Cloudflare tunnel 未啟動)',
1372
+ },
1155
1373
  memory: {
1156
1374
  ...memory.stats(),
1157
1375
  enabled: cfg.memory?.enabled !== false,
@@ -1325,6 +1543,23 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1325
1543
  return refreshBalance()
1326
1544
  case 'balance-ask':
1327
1545
  return maybeAskForTopUp({ dryRun: payload.dryRun !== false, force: payload.force === true })
1546
+ case 'meme-stats':
1547
+ return { ...memes.stats(), enabled: store.get().stickers?.enabled !== false, maxPerReply: memeMaxPerReply(), sample: memes.sample({ limit: 20 }) }
1548
+ case 'meme-preview': {
1549
+ if (!payload.text) throw new Error('缺少 text')
1550
+ const built = await buildOutgoingMessages(String(payload.text))
1551
+ return { messages: built.messages, parsed: built.parsed }
1552
+ }
1553
+ case 'sticker-list':
1554
+ return { ...stickers.stats(), items: stickers.list(), publicBase: publicBaseSync() }
1555
+ case 'sticker-add': {
1556
+ const entry = stickers.add({ name: payload.name, description: payload.description, dataUrl: payload.dataUrl || payload.data })
1557
+ return { sticker: entry, count: stickers.list().length }
1558
+ }
1559
+ case 'sticker-delete': {
1560
+ if (!payload.id) throw new Error('缺少表情包 id')
1561
+ return stickers.remove(String(payload.id))
1562
+ }
1328
1563
  case 'memory-list': {
1329
1564
  const only = String(payload.userId || '').trim()
1330
1565
  const state = store.getState()
@@ -1477,6 +1712,20 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1477
1712
  jsonOk(res, await buildStatus())
1478
1713
  return
1479
1714
  }
1715
+ if (req.method === 'GET' && sub.startsWith('/media/')) {
1716
+ const media = getMedia(decodeURIComponent(sub.slice('/media/'.length)))
1717
+ if (!media || !media.bytes) {
1718
+ jsonFail(res, 'not found', 404)
1719
+ return
1720
+ }
1721
+ res.writeHead(200, {
1722
+ 'content-type': media.mime || 'application/octet-stream',
1723
+ 'content-length': media.bytes.length,
1724
+ 'cache-control': 'public, max-age=300',
1725
+ })
1726
+ res.end(media.bytes)
1727
+ return
1728
+ }
1480
1729
  if (req.method === 'GET' && sub === '/health') {
1481
1730
  jsonOk(res, { ok: true, version: VERSION, server: server.status(), tunnel: (await tunnel.status()).phase })
1482
1731
  return
package/lib/line-api.mjs CHANGED
@@ -127,8 +127,15 @@ export async function startLoading(accessToken, chatId, loadingSeconds = 60) {
127
127
  })
128
128
  }
129
129
 
130
- export async function replyMessage(accessToken, replyToken, texts) {
131
- const messages = (Array.isArray(texts) ? texts : [texts]).filter(Boolean).map((text) => ({ type: 'text', text }))
130
+ function toMessages(input) {
131
+ return (Array.isArray(input) ? input : [input])
132
+ .filter(Boolean)
133
+ .map((item) => (typeof item === 'string' ? { type: 'text', text: item } : item))
134
+ .filter((item) => item && item.type)
135
+ }
136
+
137
+ export async function replyMessage(accessToken, replyToken, content) {
138
+ const messages = toMessages(content)
132
139
  if (messages.length === 0) return null
133
140
  return request(`${MESSAGING}/message/reply`, {
134
141
  method: 'POST',
@@ -137,8 +144,8 @@ export async function replyMessage(accessToken, replyToken, texts) {
137
144
  })
138
145
  }
139
146
 
140
- export async function pushMessage(accessToken, to, texts) {
141
- const messages = (Array.isArray(texts) ? texts : [texts]).filter(Boolean).map((text) => ({ type: 'text', text }))
147
+ export async function pushMessage(accessToken, to, content) {
148
+ const messages = toMessages(content)
142
149
  if (messages.length === 0) return null
143
150
  return request(`${MESSAGING}/message/push`, {
144
151
  method: 'POST',
@@ -0,0 +1,74 @@
1
+ // 讀 dsh-meme 外掛提供的只讀取圖服務(ctx.get('dshMeme'))。
2
+ // 圖庫、分類、描述都由 dsh-meme 維護;這裡只負責挑圖與把檔案路徑交給 LINE。
3
+ export function createMemeSource({ ctx, logger }) {
4
+ function service() {
5
+ try {
6
+ return ctx?.get?.('dshMeme') || null
7
+ } catch {
8
+ return null
9
+ }
10
+ }
11
+
12
+ function available() {
13
+ const svc = service()
14
+ return Boolean(svc && typeof svc.pick === 'function')
15
+ }
16
+
17
+ function packs() {
18
+ try {
19
+ const rows = service()?.packs?.()
20
+ return Array.isArray(rows) ? rows : []
21
+ } catch (error) {
22
+ logger?.warn?.(`讀取 dsh-meme 圖庫失敗:${error?.message || error}`)
23
+ return []
24
+ }
25
+ }
26
+
27
+ function search({ query = '', tag = '', limit = 8 } = {}) {
28
+ try {
29
+ const result = service()?.search?.({ query, tag, limit })
30
+ if (result && Array.isArray(result.memes)) return result
31
+ return { ok: false, memes: [] }
32
+ } catch (error) {
33
+ logger?.warn?.(`搜尋 dsh-meme 圖庫失敗:${error?.message || error}`)
34
+ return { ok: false, memes: [] }
35
+ }
36
+ }
37
+
38
+ function pick({ query = '', tag = '', limit = 8 } = {}) {
39
+ try {
40
+ const result = service()?.pick?.({ query, tag, limit })
41
+ if (result && result.ok && result.path) return result
42
+ return result && typeof result === 'object' ? result : { ok: false }
43
+ } catch (error) {
44
+ logger?.warn?.(`挑選 dsh-meme 圖片失敗:${error?.message || error}`)
45
+ return { ok: false, error: error?.message || String(error) }
46
+ }
47
+ }
48
+
49
+ const MOODS = ['happy', 'angry', 'sad', 'shy', 'confused', 'daily']
50
+
51
+ function sample({ limit = 16 } = {}) {
52
+ const seen = new Set()
53
+ const rows = []
54
+ for (const tag of MOODS) {
55
+ const result = search({ tag, limit: 4 })
56
+ for (const item of result.memes || []) {
57
+ const key = item.path || `${item.tag}:${item.caption}`
58
+ if (seen.has(key)) continue
59
+ seen.add(key)
60
+ rows.push(item)
61
+ if (rows.length >= limit) return rows
62
+ }
63
+ }
64
+ return rows
65
+ }
66
+
67
+ function stats() {
68
+ const rows = packs()
69
+ const count = rows.reduce((sum, pack) => sum + (Number(pack.count) || 0), 0)
70
+ return { available: available(), count, packs: rows }
71
+ }
72
+
73
+ return { available, packs, search, pick, sample, stats }
74
+ }
package/lib/nudge.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // 大肥魚主動傳訊排程。執行在 host 裡(不用 launchd),所有參數都由面板調整:
2
2
  // 機率、間隔、每日/每月上限、安靜時段、冷卻時間、指定對象;每次真的送出才記帳。
3
3
  const DEFAULT_PROMPT =
4
- '(主動傳訊)現在輪到你主動找對方說話。用一兩句自然、像訊息的短句開場,不要提「系統」「排程」「AI」這些字,也不要複製你之前說過的話。如果你判斷現在不適合打擾,只輸出 SKIP。'
4
+ '(主動傳訊)現在輪到你主動找對方說話。用一兩句自然、像訊息的短句開場,不要提「系統」「排程」「AI」這些字,也不要複製你之前說過的話。如果氣氛合適,可以在句子後面加 [[meme:#編號]] 配一張圖(編號照 context 裡的圖庫清單),不適合就純文字。如果你判斷現在不適合打擾,只輸出 SKIP。'
5
5
 
6
6
  export function createNudgeScheduler({ getConfig, store, logger, runTurn, sendProactive }) {
7
7
  let timer = null
@@ -0,0 +1,160 @@
1
+ // 表情包圖庫:把使用者上傳的圖片存到 <dataDir>/stickers,
2
+ // 回覆時由角色用 [[sticker:名稱]] 標記挑一張,再由 host 轉成 LINE image message。
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { randomBytes } from 'node:crypto'
5
+ import path from 'node:path'
6
+ import { readJsonSafe, writeJsonAtomic } from './util.mjs'
7
+
8
+ const MAX_BYTES = 5 * 1024 * 1024
9
+ const MAX_NAME = 40
10
+ const MAX_DESC = 160
11
+
12
+ function sniff(buffer) {
13
+ if (buffer.length >= 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
14
+ const width = buffer.readUInt32BE(16)
15
+ const height = buffer.readUInt32BE(20)
16
+ return { mime: 'image/png', ext: 'png', width, height }
17
+ }
18
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
19
+ return { mime: 'image/jpeg', ext: 'jpg', ...jpegSize(buffer) }
20
+ }
21
+ return null
22
+ }
23
+
24
+ function jpegSize(buffer) {
25
+ let offset = 2
26
+ while (offset + 9 < buffer.length) {
27
+ if (buffer[offset] !== 0xff) {
28
+ offset += 1
29
+ continue
30
+ }
31
+ const marker = buffer[offset + 1]
32
+ const length = buffer.readUInt16BE(offset + 2)
33
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
34
+ return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }
35
+ }
36
+ offset += 2 + length
37
+ }
38
+ return { width: 0, height: 0 }
39
+ }
40
+
41
+ function decodeBase64(input) {
42
+ const text = String(input || '')
43
+ const comma = text.indexOf(',')
44
+ const payload = text.startsWith('data:') && comma >= 0 ? text.slice(comma + 1) : text
45
+ if (!payload) throw new Error('沒有收到圖片內容')
46
+ const buffer = Buffer.from(payload.replace(/\s+/g, ''), 'base64')
47
+ if (buffer.length === 0) throw new Error('圖片解碼後是空的')
48
+ return buffer
49
+ }
50
+
51
+ export function createStickerStore({ dir, logger }) {
52
+ const baseDir = path.join(dir, 'stickers')
53
+ const indexFile = path.join(baseDir, 'index.json')
54
+ let cache = null
55
+
56
+ function ensureDir() {
57
+ if (!existsSync(baseDir)) mkdirSync(baseDir, { recursive: true })
58
+ }
59
+
60
+ function load() {
61
+ if (cache) return cache
62
+ const raw = readJsonSafe(indexFile, { version: 1, items: [] })
63
+ const items = Array.isArray(raw?.items) ? raw.items.filter((item) => item && item.id && item.file) : []
64
+ cache = { version: 1, items }
65
+ return cache
66
+ }
67
+
68
+ function save(store) {
69
+ ensureDir()
70
+ writeJsonAtomic(indexFile, store)
71
+ cache = store
72
+ }
73
+
74
+ function list() {
75
+ return load().items.map((item) => ({ ...item }))
76
+ }
77
+
78
+ function get(idOrName) {
79
+ const key = String(idOrName || '').trim().toLowerCase()
80
+ if (!key) return null
81
+ return load().items.find((item) => item.id.toLowerCase() === key || item.name.toLowerCase() === key) || null
82
+ }
83
+
84
+ function filePath(entry) {
85
+ return path.join(baseDir, entry.file)
86
+ }
87
+
88
+ function readFile(entry) {
89
+ try {
90
+ return readFileSync(filePath(entry))
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ function uniqueName(name, ignoreId) {
97
+ const wanted = String(name || '').trim().slice(0, MAX_NAME) || 'sticker'
98
+ const taken = new Set(load().items.filter((item) => item.id !== ignoreId).map((item) => item.name.toLowerCase()))
99
+ if (!taken.has(wanted.toLowerCase())) return wanted
100
+ let index = 2
101
+ while (taken.has(`${wanted}-${index}`.toLowerCase())) index += 1
102
+ return `${wanted}-${index}`
103
+ }
104
+
105
+ function add({ name, description, dataUrl }) {
106
+ const buffer = decodeBase64(dataUrl)
107
+ if (buffer.length > MAX_BYTES) throw new Error(`圖片太大(${Math.round(buffer.length / 1024)} KB),上限 5 MB`)
108
+ const info = sniff(buffer)
109
+ if (!info) throw new Error('只接受 PNG 或 JPEG 圖片')
110
+ ensureDir()
111
+ const id = randomBytes(4).toString('hex')
112
+ const file = `${id}.${info.ext}`
113
+ writeFileSync(path.join(baseDir, file), buffer)
114
+ const entry = {
115
+ id,
116
+ name: uniqueName(name),
117
+ description: String(description || '').trim().slice(0, MAX_DESC),
118
+ mime: info.mime,
119
+ file,
120
+ bytes: buffer.length,
121
+ width: info.width || 0,
122
+ height: info.height || 0,
123
+ createdAt: Date.now(),
124
+ }
125
+ const store = load()
126
+ save({ version: 1, items: [entry, ...store.items] })
127
+ logger?.info?.(`表情包已加入:${entry.name}(${Math.round(buffer.length / 1024)} KB)`)
128
+ return { ...entry }
129
+ }
130
+
131
+ function update(id, patch = {}) {
132
+ const store = load()
133
+ const entry = store.items.find((item) => item.id === id)
134
+ if (!entry) throw new Error('找不到這個表情包')
135
+ if (patch.name !== undefined) entry.name = uniqueName(patch.name, entry.id)
136
+ if (patch.description !== undefined) entry.description = String(patch.description || '').trim().slice(0, MAX_DESC)
137
+ save({ version: 1, items: store.items })
138
+ return { ...entry }
139
+ }
140
+
141
+ function remove(id) {
142
+ const store = load()
143
+ const entry = store.items.find((item) => item.id === id)
144
+ if (!entry) throw new Error('找不到這個表情包')
145
+ try {
146
+ rmSync(filePath(entry), { force: true })
147
+ } catch (error) {
148
+ logger?.warn?.(`刪除表情包檔案失敗:${error?.message || error}`)
149
+ }
150
+ save({ version: 1, items: store.items.filter((item) => item.id !== id) })
151
+ return { id: entry.id, name: entry.name, removed: true }
152
+ }
153
+
154
+ function stats() {
155
+ const items = load().items
156
+ return { dir: baseDir, indexFile, count: items.length, bytes: items.reduce((sum, item) => sum + (Number(item.bytes) || 0), 0) }
157
+ }
158
+
159
+ return { list, get, add, update, remove, readFile, filePath, stats }
160
+ }
@@ -17,7 +17,7 @@ function verifySignature(secret, raw, signature) {
17
17
  }
18
18
  }
19
19
 
20
- export function createWebhookServer({ getConfig, logger, dispatch }) {
20
+ export function createWebhookServer({ getConfig, logger, dispatch, getMedia }) {
21
21
  let server = null
22
22
  let port = 0
23
23
  let startedAt = 0
@@ -72,6 +72,22 @@ export function createWebhookServer({ getConfig, logger, dispatch }) {
72
72
  return
73
73
  }
74
74
 
75
+ if (req.method === 'GET' && url.pathname.startsWith('/media/')) {
76
+ const id = decodeURIComponent(url.pathname.slice('/media/'.length))
77
+ const media = typeof getMedia === 'function' ? await getMedia(id) : null
78
+ if (!media || !media.bytes) {
79
+ jsonFail(res, 'not found', 404)
80
+ return
81
+ }
82
+ res.writeHead(200, {
83
+ 'content-type': media.mime || 'application/octet-stream',
84
+ 'content-length': media.bytes.length,
85
+ 'cache-control': 'public, max-age=300',
86
+ })
87
+ res.end(media.bytes)
88
+ return
89
+ }
90
+
75
91
  if (url.pathname !== '/webhook') {
76
92
  jsonFail(res, 'not found', 404)
77
93
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",