dsh-line-bot 0.9.4 → 0.9.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/AGENTS.md +1 -1
- package/CHANGELOG.md +13 -0
- package/README.en.md +5 -4
- package/README.ja.md +5 -4
- package/README.ko.md +5 -4
- package/README.md +5 -4
- package/README.zh-CN.md +5 -4
- package/client.js +17 -5
- package/lib/config.mjs +11 -0
- package/lib/host.mjs +36 -2
- package/lib/nudge.mjs +145 -35
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -28,7 +28,7 @@ curl -s http://127.0.0.1:8791/health
|
|
|
28
28
|
| 回覆出現 `[[meme:…]]`、`<meme#18>` 文字 | 版本 | 舊版不認得標記格式 | 升級到 0.6.1+(目前 0.9.x) |
|
|
29
29
|
| 貼圖/圖片看不懂 | `model`、`vision` | 模型不吃圖,或 modlens 不可用/失敗 | 面板「模型與接口來源」換成有 `text+image` 的模型;或安裝 modlens 並設定 provider |
|
|
30
30
|
| 群組不回 | 面板「群組」 | 沒被 @/引用、擲骰沒中、群組功能關閉 | 看面板回覆機率與開關 |
|
|
31
|
-
| 主動訊息沒出現 | 面板「主動傳訊」 |
|
|
31
|
+
| 主動訊息沒出現 | 面板「主動傳訊」 | 每位使用者的倒數還沒到、每日/每月上限、靜音時段、兩則間距冷卻、連續未回覆已被暫停、沒有 session 或不在白名單 | 看 `status.nudge`、使用者表的「下次主動/未回覆」與日誌 |
|
|
32
32
|
| 餘額提醒沒出現 | `balance` | provider 不是 `deepseek-account`、餘額高於門檻、冷卻中、靜音 | 面板「DeepSeek 餘額」看 `applicable`、`lastAskAt` |
|
|
33
33
|
| Windows 安裝失敗 | CLI | 找不到 DSH 內建 pnpm;舊版 spawn `.cmd` 失敗 | 用面板安裝,或升級 CLI;必要時 `npm i -g pnpm` |
|
|
34
34
|
| npm `ETARGET` | — | registry/CDN 還沒傳播 | 等 1–5 分鐘再試 |
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.6
|
|
4
|
+
|
|
5
|
+
- 回覆可分段送出:依空行(段落)或每個換行拆成多個 LINE 訊息框,最多 5 則;超過 5 段會把後面的段落併進最後一則。
|
|
6
|
+
- 面板「LINE 憑證」新增「分段傳送(多個訊息框)」開關與分段方式(空行分段/每個換行)。
|
|
7
|
+
- 主動訊息、餘額提醒與一般回覆都套用同一套分段規則。
|
|
8
|
+
|
|
9
|
+
## 0.9.5
|
|
10
|
+
|
|
11
|
+
- 主動傳訊改成每位使用者各自倒數:在「最短~最長間隔」之間隨機挑時間,時間到且沉默夠久才可能開口,不再用全域擲骰。
|
|
12
|
+
- 新增「連續未回覆上限」:連續幾次主動訊息沒回就暫停該使用者;對方一說話就歸零、解除暫停並重新倒數。
|
|
13
|
+
- 面板「主動傳訊」改成最短/最長間隔與未回覆上限,顯示下次可能時間與暫停人數;使用者表新增「下次主動」「未回覆」欄位。
|
|
14
|
+
- 修正主動訊息只更新計數、沒有真的呼叫 push 的問題後,這次也把排程改成以送出成功才累加。
|
|
15
|
+
|
|
3
16
|
## 0.9.4
|
|
4
17
|
|
|
5
18
|
- 修正主動訊息(排程傳訊、餘額提醒、核准通知)只更新計數、沒有真的呼叫 LINE push API 的問題;現在會實際送出,失敗時不再累加次數。
|
package/README.en.md
CHANGED
|
@@ -12,7 +12,8 @@ Built in:
|
|
|
12
12
|
- **Cloudflare Tunnel**: one click opens a quick tunnel (`*.trycloudflare.com`, no Cloudflare account needed), or paste a Cloudflare API token to create a named tunnel + DNS for a fixed URL; once the URL is ready it writes the webhook endpoint back to LINE and runs `webhook/test`.
|
|
13
13
|
- **LINE credentials**: fill in Channel ID + Channel Secret and it issues an access token automatically (`/v2/oauth/accessToken`, about 30 days, auto-renewed near expiry); you can also paste an existing Channel Access Token.
|
|
14
14
|
- **Personas**: pick any built-in or custom persona from the dafeiyu plugin in the panel, or use the plugin's own custom personas (stored in `<DSH_HOME>/line-bot/personas.json`, add/edit/import). Persona selection is per-session and does not affect other conversations in the GUI.
|
|
15
|
-
- **Proactive messages**: a scheduler built into the plugin (no launchd)
|
|
15
|
+
- **Proactive messages**: a scheduler built into the plugin (no launchd). Each user gets an independent randomized countdown (between a minimum and maximum interval); it only speaks when the countdown is due and the user has been idle long enough. Configurable min/max interval, daily/monthly caps, minimum gap, quiet hours, idle time, unanswered limit (pause after that many unanswered proactive messages, reset when the user replies), target users and prompt. The Agent can reply `SKIP` when it is not a good moment.
|
|
16
|
+
- **Split replies**: optionally split a reply into up to five LINE bubbles by blank line (paragraph) or every newline, instead of one long bubble; the panel can change the mode or turn it off.
|
|
16
17
|
- **Full settings panel**: every setting lives under Settings → LINE Bot and is stored in `<DSH_HOME>/line-bot/config.json` (mode 600).
|
|
17
18
|
|
|
18
19
|
> About "log in with LINE Business ID and have the panel create the bot": since 2024-09 LINE only allows creating a Messaging API channel manually in [LINE Official Account Manager](https://manager.line.biz/), with no public API; LINE Login only authenticates identity and cannot grant management rights over an Official Account. So the panel does not create the bot, but it gives you the correct entry links and automates everything after "you have the Channel ID / Secret" (issuing the token, setting the webhook, testing it).
|
|
@@ -90,7 +91,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
|
|
|
90
91
|
| Section | Contents |
|
|
91
92
|
| --- | --- |
|
|
92
93
|
| Overview | Green-light status for webhook server / tunnel / LINE / persona / proactive messages, public URL, one-click bring-up and start/stop |
|
|
93
|
-
| LINE credentials | Channel ID, Channel Secret, Access Token, auto-issue, loading animation, bot info, webhook apply/test, allow-list |
|
|
94
|
+
| LINE credentials | Channel ID, Channel Secret, Access Token, auto-issue, loading animation, split replies, bot info, webhook apply/test, allow-list |
|
|
94
95
|
| Cloudflare Tunnel | quick / API token modes, token, hostname, tunnel name, Account/Zone ID, download cloudflared, start/stop, auto-configure webhook, bring your own public URL |
|
|
95
96
|
| Model & provider | Pick the provider/model and reasoning effort for LINE replies; disables DeepSeek balance when the provider is not deepseek-account |
|
|
96
97
|
| 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 |
|
|
@@ -100,7 +101,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
|
|
|
100
101
|
| Memes | Reply with images/stickers from the dsh-meme library, enable/disable, image limit, and sample previews |
|
|
101
102
|
| Sticker & image vision | Fetch incoming LINE stickers or images and show them to the model; when the model cannot read images, describe them with modlens or a vision model (can be disabled) |
|
|
102
103
|
| DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown (deepseek-account only) |
|
|
103
|
-
| Proactive messages | Toggle,
|
|
104
|
+
| Proactive messages | Toggle, per-user countdown, min/max interval, unanswered limit, daily/monthly caps, minimum gap, quiet hours, idle, target users, prompt, send a test now, statistics (next possible time and paused users) |
|
|
104
105
|
| Users | Pending approval queue (approve / deny), the DSH session each LINE user maps to, their own workspace folder, their current persona (editable), message count, last interaction, clear memory / reset session |
|
|
105
106
|
| Groups | Per-group session, workspace folder, persona, message count; reply chance when not addressed; always reply on @ / quote; clear group memory |
|
|
106
107
|
| Advanced | Port, Agent provider/model/effort/cwd/timeout, one workspace per user (and its root), welcome/error/empty replies, logs, reload host modules (dev) |
|
|
@@ -119,7 +120,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
|
|
|
119
120
|
2. It finds (or creates) the DSH session and workspace folder for that `userId`, then decides the session's persona: for dafeiyu it sets the session persona through dafeiyu's HTTP bridge; for a plugin custom persona it first asks dafeiyu to step aside in that session (`none`), then injects the custom persona into the system prompt.
|
|
120
121
|
3. It creates/resumes the Agent in the same DSH process with `agents.create` / `agents.resume`, sends one user message with `followup`, and waits for the turn to end, subagents to settle, and the session to flush.
|
|
121
122
|
4. It takes the last assistant text from the session events and replies with LINE reply; if the reply token has expired it falls back to push.
|
|
122
|
-
5.
|
|
123
|
+
5. Every minute the scheduler checks each user's countdown; when it is due and the user has been idle long enough it asks the Agent to say something on its own and pushes it. After too many unanswered proactive messages it pauses until the user replies.
|
|
123
124
|
|
|
124
125
|
## Model & provider
|
|
125
126
|
|
package/README.ja.md
CHANGED
|
@@ -12,7 +12,8 @@ LINE 公式アカウント(Messaging API)を DeepSeek Harness につなぐ
|
|
|
12
12
|
- **Cloudflare Tunnel**:ワンクリックの quick tunnel(`*.trycloudflare.com`、Cloudflare アカウント不要)、または Cloudflare API トークンを貼り付けて名前付きトンネル+DNS を作成し固定 URL を取得します。URL が取得できたら webhook エンドポイントを LINE に書き戻し、`webhook/test` を実行します。
|
|
13
13
|
- **LINE 認証情報**:Channel ID と Channel Secret を入力すれば access token を自動発行(`/v2/oauth/accessToken`、約 30 日、期限が近づくと自動更新)。既存の Channel Access Token を貼り付けることもできます。
|
|
14
14
|
- **ペルソナ**:パネルから大肥魚プラグインの内蔵/カスタムペルソナを選べるほか、プラグイン内蔵のカスタムペルソナ(`<DSH_HOME>/line-bot/personas.json`、追加・編集・インポート可)も使えます。ペルソナはセッション単位で指定され、GUI の他の会話には影響しません。
|
|
15
|
-
- **自発メッセージ**:プラグイン内蔵のスケジューラー(launchd
|
|
15
|
+
- **自発メッセージ**:プラグイン内蔵のスケジューラー(launchd 不要)。ユーザーごとに独立したランダムなカウントダウン(最短~最長間隔)を持ち、時間が来て相手の沈黙が十分長いときだけ話しかけます。最短/最長間隔、1 日/1 か月の上限、最低間隔、静かな時間帯、idle、連続未返信の上限(上限に達したら一時停止、相手が返信したらリセット)、対象ユーザー、プロンプトを設定できます。Agent が「今は違う」と判断したら `SKIP` を返して見送れます。
|
|
16
|
+
- **分割返信**:返信を空行(段落)または改行ごとに最大 5 つの LINE メッセージに分割できます。1 つの長い吹き出しに詰め込まずに送れます。パネルで方式の切り替えやオフも可能です。
|
|
16
17
|
- **充実した設定パネル**:設定はすべて「設定 → LINE Bot」にあり、`<DSH_HOME>/line-bot/config.json`(モード 600)に保存されます。
|
|
17
18
|
|
|
18
19
|
> 「LINE Business ID でログインしてパネルから bot を作る」ことについて:LINE は 2024-09 以降、Messaging API チャネルの作成を [LINE Official Account Manager](https://manager.line.biz/) での手動操作のみに制限しており、公開 API はありません。LINE Login も本人確認だけで、公式アカウントの管理権限は取得できません。そのためパネルは bot を作成しませんが、正しい入口リンクを提示し、「Channel ID / Secret を取得した後」の作業(トークン発行、webhook 設定、テスト)を自動化します。
|
|
@@ -90,7 +91,7 @@ npx dsh-line-bot install --no-companions
|
|
|
90
91
|
| セクション | 内容 |
|
|
91
92
|
| --- | --- |
|
|
92
93
|
| 概要 | webhook サーバー/トンネル/LINE/ペルソナ/自発メッセージの状態、公開 URL、ワンクリック起動と停止 |
|
|
93
|
-
| LINE 認証情報 | Channel ID、Channel Secret、Access Token
|
|
94
|
+
| LINE 認証情報 | Channel ID、Channel Secret、Access Token、自動発行、ローディング表示、分割返信、bot 情報、webhook の適用/テスト、許可リスト |
|
|
94
95
|
| Cloudflare Tunnel | quick/API トークンモード、トークン、hostname、トンネル名、Account/Zone ID、cloudflared のダウンロード、起動/停止、webhook 自動設定、独自の公開 URL |
|
|
95
96
|
| モデルとプロバイダー | LINE 返信で使う provider/モデルと推論強度を選択。deepseek-account 以外では DeepSeek 残高を自動で無効化 |
|
|
96
97
|
| 返信ペルソナ | ペルソナ選択(大肥魚またはプラグインのカスタム)、カスタムペルソナの追加・編集・削除、現在のペルソナをカスタムに複製、強制適用、ユーザーによる切り替え許可、大肥魚を session モードに切り替え、Agent 返信のテスト |
|
|
@@ -100,7 +101,7 @@ npx dsh-line-bot install --no-companions
|
|
|
100
101
|
| 表情パック | dsh-meme の画像ライブラリで画像/スタンプを返信、オンオフ、1 回の上限、サンプル表示 |
|
|
101
102
|
| スタンプ・画像理解 | LINE スタンプや通常の画像を受信したら取得してモデルに見せる。画像非対応モデルは modlens や別の視覚モデルで説明(オフ可) |
|
|
102
103
|
| DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン(deepseek-account のみ) |
|
|
103
|
-
| 自発メッセージ |
|
|
104
|
+
| 自発メッセージ | オン/オフ、ユーザーごとのカウントダウン、最短/最長間隔、連続未返信の上限、1 日/1 か月の上限、最低間隔、静かな時間帯、idle、対象ユーザー、プロンプト、今すぐ試送、統計(次回予定と一時停止中の人数) |
|
|
104
105
|
| ユーザー | LINE ユーザーごとに対応する DSH セッション、専用ワークスペース、現在のペルソナ(直接変更可)、メッセージ数、最終やりとり、メモリ消去/セッション再作成 |
|
|
105
106
|
| グループ | グループごとのセッション、ワークスペース、ペルソナ、メッセージ数;呼ばれていないときの返信確率;@/引用時は必ず返信;グループのメモリ消去 |
|
|
106
107
|
| 詳細設定 | ポート、Agent の provider/model/effort/cwd/タイムアウト、ユーザーごとのワークスペース(ルート含む)、ウェルカム/エラー/空返信、ログ、開発用 host リロード |
|
|
@@ -119,7 +120,7 @@ npx dsh-line-bot install --no-companions
|
|
|
119
120
|
2. `userId` から対応する DSH セッションとワークスペースを探し(なければ作成)、そのセッションのペルソナを決めます。大肥魚を選んだ場合は大肥魚の HTTP bridge 経由でセッションペルソナを指定し、プラグインのカスタムペルソナを選んだ場合はまず大肥魚にそのセッションで退いてもらい(`none`)、プラグインがカスタムペルソナを system prompt に注入します。
|
|
120
121
|
3. `agents.create`/`agents.resume` で同じ DSH プロセス内に Agent を作成/再開し、`followup` でユーザーメッセージを 1 件送り、ターン終了・サブエージェントの収束・セッションの flush を待ちます。
|
|
121
122
|
4. セッションイベントから最後の assistant テキストを取り出し、LINE の reply で返信します。reply token が切れていたら自動で push に切り替えます。
|
|
122
|
-
5.
|
|
123
|
+
5. スケジューラーは 1 分ごとに各ユーザーのカウントダウンを確認し、時間が来て相手の沈黙が十分長ければ Agent に一言話してもらって push します。連続未返信が上限に達したら一時停止し、相手が返信したら再びカウントダウンします。
|
|
123
124
|
|
|
124
125
|
## モデルとプロバイダー
|
|
125
126
|
|
package/README.ko.md
CHANGED
|
@@ -12,7 +12,8 @@ LINE 공식 계정(Messaging API)을 DeepSeek Harness에 연결하는 플러그
|
|
|
12
12
|
- **Cloudflare Tunnel**: 원클릭 quick tunnel(`*.trycloudflare.com`, Cloudflare 계정 불필요) 또는 Cloudflare API 토큰을 붙여 넣어 이름 있는 터널+DNS를 만들고 고정 URL을 얻습니다. URL이 준비되면 webhook 엔드포인트를 LINE에 다시 쓰고 `webhook/test`를 실행합니다.
|
|
13
13
|
- **LINE 자격 증명**: Channel ID와 Channel Secret을 입력하면 access token을 자동 발급합니다(`/v2/oauth/accessToken`, 약 30일, 만료 전 자동 갱신). 기존 Channel Access Token을 붙여 넣어도 됩니다.
|
|
14
14
|
- **페르소나**: 패널에서 大肥魚 플러그인의 내장/커스텀 페르소나를 고르거나, 플러그인 내장 커스텀 페르소나(`<DSH_HOME>/line-bot/personas.json`, 추가/편집/가져오기)를 쓸 수 있습니다. 페르소나는 세션 단위로 지정되며 GUI의 다른 대화에는 영향을 주지 않습니다.
|
|
15
|
-
- **능동 메시지**: 플러그인 내장 스케줄러(launchd 불필요).
|
|
15
|
+
- **능동 메시지**: 플러그인 내장 스케줄러(launchd 불필요). 사용자마다 독립적인 무작위 카운트다운(최소~최대 간격)을 가지며, 시간이 되고 상대의 침묵이 충분히 길 때만 말을 겁니다. 최소/최대 간격, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 연속 미응답 상한(도달 시 일시 중지, 상대가 답하면 초기화), 대상 사용자, 프롬프트를 설정할 수 있습니다. Agent가 지금은 아니라고 판단하면 `SKIP`을 반환해 건너뜁니다.
|
|
16
|
+
- **분할 답장**: 답장을 빈 줄(문단) 또는 줄바꿈마다 최대 5개의 LINE 메시지로 나눠 보낼 수 있습니다. 하나의 긴 말풍선에 몰아넣지 않습니다. 패널에서 방식을 바꾸거나 끌 수 있습니다.
|
|
16
17
|
- **전체 설정 패널**: 모든 설정이 "설정 → LINE Bot"에 있으며 `<DSH_HOME>/line-bot/config.json`(모드 600)에 저장됩니다.
|
|
17
18
|
|
|
18
19
|
> "LINE Business ID로 로그인해서 패널에서 bot을 만드는" 것에 대해: LINE은 2024-09부터 Messaging API 채널 생성을 [LINE Official Account Manager](https://manager.line.biz/)에서 수동으로만 허용하며 공개 API가 없습니다. LINE Login도 신원 확인일 뿐 공식 계정 관리 권한을 얻을 수 없습니다. 그래서 패널은 bot을 만들지 않지만, 올바른 진입 링크를 알려 주고 "Channel ID / Secret을 얻은 뒤"의 모든 단계(토큰 발급, webhook 설정, 테스트)를 자동화합니다.
|
|
@@ -90,7 +91,7 @@ npx dsh-line-bot install --no-companions
|
|
|
90
91
|
| 섹션 | 내용 |
|
|
91
92
|
| --- | --- |
|
|
92
93
|
| 개요 | webhook 서버/터널/LINE/페르소나/능동 메시지 상태, 공개 URL, 원클릭 기동과 시작/중지 |
|
|
93
|
-
| LINE 자격 증명 | Channel ID, Channel Secret, Access Token, 자동 발급, 로딩 표시, bot 정보, webhook 적용/테스트, 허용 목록 |
|
|
94
|
+
| LINE 자격 증명 | Channel ID, Channel Secret, Access Token, 자동 발급, 로딩 표시, 분할 답장, bot 정보, webhook 적용/테스트, 허용 목록 |
|
|
94
95
|
| Cloudflare Tunnel | quick/API 토큰 모드, 토큰, hostname, 터널 이름, Account/Zone ID, cloudflared 다운로드, 시작/중지, webhook 자동 설정, 직접 제공하는 공개 URL |
|
|
95
96
|
| 모델과 공급자 | LINE 답장에 쓸 provider/모델과 추론 강도 선택. deepseek-account가 아니면 DeepSeek 잔액 자동 비활성화 |
|
|
96
97
|
| 답장 페르소나 | 페르소나 선택(大肥魚 또는 플러그인 커스텀), 커스텀 페르소나 추가/편집/삭제, 현재 페르소나를 커스텀으로 복사, 강제 적용, 사용자 직접 전환 허용, 大肥魚를 session 모드로 전환, Agent 답장 테스트 |
|
|
@@ -100,7 +101,7 @@ npx dsh-line-bot install --no-companions
|
|
|
100
101
|
| 이모티콘 | dsh-meme 라이브러리로 이미지/스티커 답장, 켜기/끄기, 1회 최대 장수, 샘플 미리보기 |
|
|
101
102
|
| 스티커·이미지 이해 | LINE 스티커나 일반 이미지를 받으면 가져와 모델에 보여 줌. 이미지 미지원 모델은 modlens 또는 다른 비전 모델로 설명 (끌 수 있음) |
|
|
102
103
|
| DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운(deepseek-account 전용) |
|
|
103
|
-
| 능동 메시지 | 켜기/끄기,
|
|
104
|
+
| 능동 메시지 | 켜기/끄기, 사용자별 카운트다운, 최소/최대 간격, 연속 미응답 상한, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 대상 사용자, 프롬프트, 지금 시험 발송, 통계(다음 예정 시각과 일시 중지 인원) |
|
|
104
105
|
| 사용자 | LINE 사용자별 DSH 세션, 전용 작업 폴더, 현재 페르소나(직접 변경 가능), 메시지 수, 마지막 상호작용, 기억 지우기/세션 재설정 |
|
|
105
106
|
| 그룹 | 그룹별 세션, 작업 폴더, 페르소나, 메시지 수; 호출되지 않았을 때 답장 확률; @/인용 시 항상 답장; 그룹 기억 지우기 |
|
|
106
107
|
| 고급 설정 | 포트, Agent provider/model/effort/cwd/타임아웃, 사용자별 작업 폴더(루트 포함), 환영/오류/빈 답장, 로그, 개발용 host 리로드 |
|
|
@@ -119,7 +120,7 @@ npx dsh-line-bot install --no-companions
|
|
|
119
120
|
2. `userId`로 해당 DSH 세션과 작업 폴더를 찾거나 만들고, 그 세션의 페르소나를 정합니다. 大肥魚를 고르면 大肥魚의 HTTP bridge로 세션 페르소나를 지정하고, 플러그인 커스텀 페르소나를 고르면 먼저 大肥魚에게 그 세션에서 물러나 달라고 한 뒤(`none`) 플러그인이 커스텀 페르소나를 system prompt에 주입합니다.
|
|
120
121
|
3. `agents.create`/`agents.resume`로 같은 DSH 프로세스 안에서 Agent를 만들거나 이어 받고, `followup`으로 사용자 메시지 하나를 보낸 뒤 turn 종료, 하위 에이전트 수렴, 세션 flush를 기다립니다.
|
|
121
122
|
4. 세션 이벤트에서 마지막 assistant 텍스트를 꺼내 LINE reply로 답장합니다. reply token이 만료되었으면 자동으로 push로 전환합니다.
|
|
122
|
-
5.
|
|
123
|
+
5. 스케줄러는 1분마다 각 사용자의 카운트다운을 확인하고, 시간이 되고 상대의 침묵이 충분히 길면 Agent에게 한마디 하게 한 뒤 push합니다. 연속 미응답이 상한에 도달하면 일시 중지하고, 상대가 답하면 다시 카운트다운합니다.
|
|
123
124
|
|
|
124
125
|
## 모델과 공급자
|
|
125
126
|
|
package/README.md
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
- **Cloudflare Tunnel**:一鍵開 quick tunnel(`*.trycloudflare.com`,免 Cloudflare 帳號),或貼上 Cloudflare API Token 建立具名隧道+DNS,取得固定網址;取得網址後自動把 webhook 端點寫回 LINE 並跑一次 `webhook/test`。
|
|
13
13
|
- **LINE 憑證**:填 Channel ID+Channel Secret 就能自動簽發 access token(`/v2/oauth/accessToken`,約 30 天,快到期會自動續簽);也可以直接貼上既有的 Channel Access Token。
|
|
14
14
|
- **人設**:面板可選大肥魚插件裡任何一個內建/自訂人設,或插件內建的自訂人設(存在 `<DSH_HOME>/line-bot/personas.json`,可新增/編輯/匯入)。人設是「逐 session」指定,不會改到你 GUI 裡其他對話的人設。
|
|
15
|
-
- **主動傳訊**:插件內建排程器(不用 launchd
|
|
15
|
+
- **主動傳訊**:插件內建排程器(不用 launchd),每位使用者各自倒數(最短~最長間隔隨機);可調最短/最長間隔、每日/每月上限、兩則間距、安靜時段、對方安靜多久才可打擾、連續未回覆上限(達上限暫停,對方回話後歸零)、指定對象與提示詞;Agent 覺得不適合時可以回 `SKIP` 跳過。
|
|
16
|
+
- **分段回覆**:可把回覆依空行(段落)或每個換行拆成多個 LINE 訊息框,最多 5 則,不再全部擠在同一則;面板可切換分段方式或關閉。
|
|
16
17
|
- **完整設定面板**:所有設定都在「設定 → LINE Bot」裡,存到 `<DSH_HOME>/line-bot/config.json`(權限 600)。
|
|
17
18
|
|
|
18
19
|
> 關於「用 LINE Business ID 登入、由面板代建 bot」:LINE 自 2024-09 起只允許在 [LINE Official Account Manager](https://manager.line.biz/) 手動建立 Messaging API channel,沒有公開 API;LINE Login 也只是身分驗證,不能取得官方帳號的管理權。所以面板不代建 bot,但會給你正確的入口連結,並把「拿到 Channel ID / Secret 之後」的所有步驟自動化(簽 token、設 webhook、測試)。
|
|
@@ -90,7 +91,7 @@ npx dsh-line-bot install --no-companions
|
|
|
90
91
|
| 區塊 | 內容 |
|
|
91
92
|
| --- | --- |
|
|
92
93
|
| 狀態總覽 | webhook server/隧道/LINE/人設/主動傳訊的綠燈狀態、公開網址、一鍵上線與啟停 |
|
|
93
|
-
| LINE 憑證 | Channel ID、Channel Secret、Access Token、自動簽發、loading
|
|
94
|
+
| LINE 憑證 | Channel ID、Channel Secret、Access Token、自動簽發、loading 動畫、分段回覆、bot 資訊、webhook 套用/測試、白名單 |
|
|
94
95
|
| Cloudflare Tunnel | quick/API Token 模式、Token、hostname、隧道名稱、Account/Zone ID、下載 cloudflared、啟停、自動設定 webhook、自行提供的公開網址 |
|
|
95
96
|
| 模型與接口來源 | 選擇 LINE 回覆的 provider/模型與推理強度;非 deepseek-account 時自動停用 DeepSeek 餘額 |
|
|
96
97
|
| 回覆人設 | 選擇人設(大肥魚或插件自訂)、新增/編輯/刪除自訂人設、把目前人設複製成自訂、強制套用、允許使用者自己換人設、切換大肥魚 session 模式、測試 Agent 回覆 |
|
|
@@ -100,7 +101,7 @@ npx dsh-line-bot install --no-companions
|
|
|
100
101
|
| 表情包 | 使用 dsh-meme 圖庫回圖片/貼圖、開關、一次最多幾張、圖庫樣本 |
|
|
101
102
|
| 貼圖與圖片理解 | 收到 LINE 貼圖或一般圖片時抓圖給模型看;模型不吃圖時用 modlens 或另一個視覺模型描述(可關閉) |
|
|
102
103
|
| DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻(僅 deepseek-account) |
|
|
103
|
-
| 主動傳訊 |
|
|
104
|
+
| 主動傳訊 | 開關、每位使用者的倒數、最短/最長間隔、連續未回覆上限、每日/每月上限、兩則間距、安靜時段、idle、指定對象、提示詞、立即試傳、統計(下次可能時間與暫停人數) |
|
|
104
105
|
| 使用者 | 待審核名單(核准/拒絕)、每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
|
|
105
106
|
| 群組 | 每個群組的 session、工作資料夾、人設、訊息數;沒被點名時的回覆機率;被 @/引用一定回;清空群組記憶 |
|
|
106
107
|
| 進階設定 | 連接埠、Agent provider/model/effort/cwd/逾時、一人一個工作資料夾(含根目錄)、歡迎/錯誤/空回覆、日誌、開發用 host 重新載入 |
|
|
@@ -119,7 +120,7 @@ npx dsh-line-bot install --no-companions
|
|
|
119
120
|
2. 依 `userId` 找到(或建立)對應的 DSH session 與工作資料夾,決定這個 session 的人設:選大肥魚時透過它的 HTTP bridge 指定 session 人設;選插件自訂人設時,先請大肥魚在該 session 讓開(`none`),再由插件把自訂人設注入 system prompt。
|
|
120
121
|
3. 用 `agents.create`/`agents.resume` 在同一個 DSH 行程內建立/接續 Agent,`followup` 一則使用者訊息,等到 turn 結束、子代理收斂、session flush。
|
|
121
122
|
4. 從 session 事件取出最後一段 assistant 文字,用 LINE reply 回覆;reply token 過期就自動改 push。
|
|
122
|
-
5.
|
|
123
|
+
5. 主動傳訊排程器每分鐘檢查一次:逐位使用者到期、且沉默夠久才請 Agent 主動說一句,再 push 出去;連續未回覆達上限會暫停,對方回話後重新倒數。
|
|
123
124
|
|
|
124
125
|
## 模型與接口來源
|
|
125
126
|
|
package/README.zh-CN.md
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
- **Cloudflare Tunnel**:一键开 quick tunnel(`*.trycloudflare.com`,免 Cloudflare 账号),或贴入 Cloudflare API Token 创建具名隧道+DNS,取得固定网址;取得网址后自动把 webhook 端点写回 LINE 并跑一次 `webhook/test`。
|
|
13
13
|
- **LINE 凭证**:填 Channel ID+Channel Secret 就能自动签发 access token(`/v2/oauth/accessToken`,约 30 天,快到期会自动续签);也可以直接贴入既有的 Channel Access Token。
|
|
14
14
|
- **人设**:面板可选大肥鱼插件里任何一个内置/自定义人设,或插件内置的自定义人设(存在 `<DSH_HOME>/line-bot/personas.json`,可新增/编辑/导入)。人设是「逐 session」指定,不会改到你 GUI 里其他对话的人设。
|
|
15
|
-
- **主动传讯**:插件内置排程器(不用 launchd
|
|
15
|
+
- **主动传讯**:插件内置排程器(不用 launchd),每位使用者各自倒数(最短~最长间隔随机);可调最短/最长间隔、每日/每月上限、两则间距、安静时段、对方安静多久才可打扰、连续未回复上限(达上限暂停,对方回话后归零)、指定对象与提示词;Agent 觉得不适合时可以回 `SKIP` 跳过。
|
|
16
|
+
- **分段回复**:可把回复依空行(段落)或每个换行拆成多个 LINE 消息框,最多 5 条,不再全部挤在同一条;面板可切换分段方式或关闭。
|
|
16
17
|
- **完整设置面板**:所有设置都在「设置 → LINE Bot」里,存到 `<DSH_HOME>/line-bot/config.json`(权限 600)。
|
|
17
18
|
|
|
18
19
|
> 关于「用 LINE Business ID 登录、由面板代建 bot」:LINE 自 2024-09 起只允许在 [LINE Official Account Manager](https://manager.line.biz/) 手动创建 Messaging API channel,没有公开 API;LINE Login 也只是身份验证,不能取得官方账号的管理权。所以面板不代建 bot,但会给你正确的入口链接,并把「拿到 Channel ID / Secret 之后」的所有步骤自动化(签 token、设 webhook、测试)。
|
|
@@ -90,7 +91,7 @@ npx dsh-line-bot install --no-companions
|
|
|
90
91
|
| 区块 | 内容 |
|
|
91
92
|
| --- | --- |
|
|
92
93
|
| 状态总览 | webhook server/隧道/LINE/人设/主动传讯的绿灯状态、公开网址、一键上线与启停 |
|
|
93
|
-
| LINE 凭证 | Channel ID、Channel Secret、Access Token、自动签发、loading
|
|
94
|
+
| LINE 凭证 | Channel ID、Channel Secret、Access Token、自动签发、loading 动画、分段回复、bot 信息、webhook 套用/测试、白名单 |
|
|
94
95
|
| Cloudflare Tunnel | quick/API Token 模式、Token、hostname、隧道名称、Account/Zone ID、下载 cloudflared、启停、自动设置 webhook、自行提供的公开网址 |
|
|
95
96
|
| 模型与接口来源 | 选择 LINE 回复的 provider/模型与推理强度;非 deepseek-account 时自动停用 DeepSeek 余额 |
|
|
96
97
|
| 回复人设 | 选择人设(大肥鱼或插件自定义)、新增/编辑/删除自定义人设、把目前人设复制成自定义、强制套用、允许用户自己换人设、切换大肥鱼 session 模式、测试 Agent 回复 |
|
|
@@ -100,7 +101,7 @@ npx dsh-line-bot install --no-companions
|
|
|
100
101
|
| 表情包 | 使用 dsh-meme 图库回图片/贴图、开关、每次最多几张、图库样本 |
|
|
101
102
|
| 贴图与图片理解 | 收到 LINE 贴图或一般图片时抓图给模型看;模型不吃图时用 modlens 或另一个视觉模型描述(可关闭) |
|
|
102
103
|
| DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却(仅 deepseek-account) |
|
|
103
|
-
| 主动传讯 |
|
|
104
|
+
| 主动传讯 | 开关、每位使用者的倒数、最短/最长间隔、连续未回复上限、每日/每月上限、两则间距、安静时段、idle、指定对象、提示词、立即试传、统计(下次可能时间与暂停人数) |
|
|
104
105
|
| 用户 | 每位 LINE 用户对应的 DSH session、专属工作文件夹、目前人设(可直接改)、消息数、上次互动、清空记忆/重设 session |
|
|
105
106
|
| 群组 | 每个群组的 session、工作文件夹、人设、消息数;没被点名时的回复概率;被 @/引用一定回;清空群组记忆 |
|
|
106
107
|
| 高级设置 | 端口、Agent provider/model/effort/cwd/超时、一人一个工作文件夹(含根目录)、欢迎/错误/空回复、日志、开发用 host 重新载入 |
|
|
@@ -119,7 +120,7 @@ npx dsh-line-bot install --no-companions
|
|
|
119
120
|
2. 依 `userId` 找到(或创建)对应的 DSH session 与工作文件夹,决定这个 session 的人设:选大肥鱼时通过它的 HTTP bridge 指定 session 人设;选插件自定义人设时,先请大肥鱼在该 session 让开(`none`),再由插件把自定义人设注入 system prompt。
|
|
120
121
|
3. 用 `agents.create`/`agents.resume` 在同一个 DSH 进程内创建/接续 Agent,`followup` 一则用户消息,等到 turn 结束、子代理收敛、session flush。
|
|
121
122
|
4. 从 session 事件取出最后一段 assistant 文字,用 LINE reply 回复;reply token 过期就自动改 push。
|
|
122
|
-
5.
|
|
123
|
+
5. 主动传讯排程器每分钟检查一次:逐位使用者到期、且沉默够久才请 Agent 主动说一句,再 push 出去;连续未回复达上限会暂停,对方回话后重新倒数。
|
|
123
124
|
|
|
124
125
|
## 模型与接口来源
|
|
125
126
|
|
package/client.js
CHANGED
|
@@ -975,6 +975,14 @@ window.__ModuleLoader__.load({
|
|
|
975
975
|
),
|
|
976
976
|
h(Toggle, { checked: line.useAutoToken !== false, onChange: (value) => update({ line: { useAutoToken: value } }), label: '自動簽發 access token', hint: '有 Channel ID+Secret 時,優先自動取得(建議開啟)' }),
|
|
977
977
|
h(Toggle, { checked: line.loadingEnabled !== false, onChange: (value) => update({ line: { loadingEnabled: value } }), label: '顯示「正在輸入」動畫', hint: '回覆前呼叫 LINE 的 loading 動畫' }),
|
|
978
|
+
h(Toggle, { checked: line.splitMessages !== false, onChange: (value) => update({ line: { splitMessages: value } }), label: '分段傳送(多個訊息框)', hint: '把回覆依換行拆成多個訊息框;LINE 一次最多 5 則,超過的會併到最後一則' }),
|
|
979
|
+
h(Field, { label: '分段方式' }, h('select', {
|
|
980
|
+
value: line.splitMode === 'line' ? 'line' : 'paragraph',
|
|
981
|
+
onChange: (event) => update({ line: { splitMode: event.target.value } }),
|
|
982
|
+
style: { ...S.input },
|
|
983
|
+
},
|
|
984
|
+
h('option', { value: 'paragraph' }, '空行分段(推薦,段落才分)'),
|
|
985
|
+
h('option', { value: 'line' }, '每個換行都分段(氣泡較多)'))),
|
|
978
986
|
h(Toggle, { checked: line.stickerVision !== false, onChange: (value) => update({ line: { stickerVision: value } }), label: '看得懂貼圖', hint: '收到貼圖時抓圖片給模型看;模型不支援圖片時改用 modlens 描述,再不支援才用關鍵字' }),
|
|
979
987
|
h(Toggle, { checked: line.imageVision !== false, onChange: (value) => update({ line: { imageVision: value } }), label: '看得懂一般圖片', hint: '收到圖片時下載給模型看;模型不支援圖片時用 modlens 產生描述' }),
|
|
980
988
|
h('div', { style: S.note }, `看圖方式:${runtime.vision?.modelSupportsImages ? '目前模型可直接看圖' : runtime.vision?.available ? '目前模型不吃圖,改用 modlens 描述' : '目前模型不吃圖,且找不到 modlens'}${runtime.vision?.lastError ? `(modlens 最近錯誤:${runtime.vision.lastError})` : ''}`),
|
|
@@ -1246,11 +1254,12 @@ window.__ModuleLoader__.load({
|
|
|
1246
1254
|
// 5. 主動傳訊
|
|
1247
1255
|
h(
|
|
1248
1256
|
Card,
|
|
1249
|
-
{ id: sectionDomId('nudge'), order: 10, title: '主動傳訊(大肥魚自己找人聊天)', desc: '
|
|
1257
|
+
{ id: sectionDomId('nudge'), order: 10, title: '主動傳訊(大肥魚自己找人聊天)', desc: '每位使用者各自倒數:在最短~最長間隔之間隨機挑一個時間,沉默夠久且時間到才請 Agent 主動傳一句;不適合時 Agent 可回 SKIP。連續沒回覆達上限會暫停,對方一說話就歸零。' },
|
|
1250
1258
|
h(Toggle, { checked: !!nudgeCfg.enabled, onChange: (value) => update({ nudge: { enabled: value } }), label: '開啟主動傳訊' }),
|
|
1251
1259
|
h('div', { style: S.grid },
|
|
1252
|
-
h(Field, { label: '
|
|
1253
|
-
h(Field, { label: '
|
|
1260
|
+
h(Field, { label: '最短間隔(分鐘)', hint: '每位使用者獨立倒數' }, h(Num, { value: nudgeCfg.minIntervalMinutes, min: 5, max: 1440, onChange: (value) => update({ nudge: { minIntervalMinutes: value } }) })),
|
|
1261
|
+
h(Field, { label: '最長間隔(分鐘)', hint: '實際時間在最短~最長之間隨機' }, h(Num, { value: nudgeCfg.maxIntervalMinutes, min: 5, max: 1440, onChange: (value) => update({ nudge: { maxIntervalMinutes: value } }) })),
|
|
1262
|
+
h(Field, { label: '連續未回覆上限(則)', hint: '連續幾次沒回就暫停;對方一說話就歸零(0=不限)' }, h(Num, { value: nudgeCfg.unansweredLimit, min: 0, max: 20, onChange: (value) => update({ nudge: { unansweredLimit: value } }) })),
|
|
1254
1263
|
h(Field, { label: '每日上限(則)' }, h(Num, { value: nudgeCfg.dailyCap, min: 0, max: 100, onChange: (value) => update({ nudge: { dailyCap: value } }) })),
|
|
1255
1264
|
h(Field, { label: '每月上限(則)' }, h(Num, { value: nudgeCfg.monthlyCap, min: 0, max: 1000, onChange: (value) => update({ nudge: { monthlyCap: value } }) })),
|
|
1256
1265
|
h(Field, { label: '兩則之間至少間隔(分鐘)' }, h(Num, { value: nudgeCfg.minGapMinutes, min: 0, max: 10080, onChange: (value) => update({ nudge: { minGapMinutes: value } }) })),
|
|
@@ -1269,7 +1278,8 @@ window.__ModuleLoader__.load({
|
|
|
1269
1278
|
['今日', `${nudgeStats.dayCount ?? 0} / ${nudgeCfg.dailyCap ?? '—'} 則`],
|
|
1270
1279
|
['本月', `${nudgeStats.monthCount ?? 0} / ${nudgeCfg.monthlyCap ?? '—'} 則`],
|
|
1271
1280
|
['上次傳訊', fmtTime(nudgeStats.lastAt)],
|
|
1272
|
-
['
|
|
1281
|
+
['下次可能主動', fmtTime(nudgeStats.nextAt)],
|
|
1282
|
+
['暫停中的人', `${nudgeStats.pausedUsers ?? 0} 位`],
|
|
1273
1283
|
['上次結果', nudgeStats.lastReason || '—'],
|
|
1274
1284
|
],
|
|
1275
1285
|
}),
|
|
@@ -1320,7 +1330,7 @@ window.__ModuleLoader__.load({
|
|
|
1320
1330
|
(status.users || []).length === 0
|
|
1321
1331
|
? h('div', { style: S.hint }, '還沒有人傳訊息給 bot。')
|
|
1322
1332
|
: h('table', { style: S.table },
|
|
1323
|
-
h('thead', null, h('tr', null, h('th', { style: S.th }, '使用者'), h('th', { style: S.th }, 'session/記憶資料夾'), h('th', { style: S.th }, '訊息'), h('th', { style: S.th }, '人設'), h('th', { style: S.th }, '上次互動'), h('th', { style: S.th }, ''))),
|
|
1333
|
+
h('thead', null, h('tr', null, h('th', { style: S.th }, '使用者'), h('th', { style: S.th }, 'session/記憶資料夾'), h('th', { style: S.th }, '訊息'), h('th', { style: S.th }, '人設'), h('th', { style: S.th }, '下次主動'), h('th', { style: S.th }, '未回覆'), h('th', { style: S.th }, '上次互動'), h('th', { style: S.th }, ''))),
|
|
1324
1334
|
h('tbody', null, (status.users || []).map((user) =>
|
|
1325
1335
|
h('tr', { key: user.userId },
|
|
1326
1336
|
h('td', { style: S.td }, h('div', null, user.displayName || '(未命名)'), h('div', { style: { ...S.hint, ...S.mono } }, user.userId)),
|
|
@@ -1335,6 +1345,8 @@ window.__ModuleLoader__.load({
|
|
|
1335
1345
|
},
|
|
1336
1346
|
(personaCatalog.length ? personaCatalog : [{ id: user.personaId, name: user.personaName || user.personaId }]).map((item) =>
|
|
1337
1347
|
h('option', { key: item.id, value: item.id }, `${item.name}${item.source === 'local' ? '(自訂)' : ''}`)))),
|
|
1348
|
+
h('td', { style: S.td }, user.nudgePaused ? h('span', { style: S.hint }, '暫停中') : fmtTime(user.nudgeNextAt)),
|
|
1349
|
+
h('td', { style: S.td }, String(user.nudgeUnanswered ?? 0)),
|
|
1338
1350
|
h('td', { style: S.td }, fmtTime(user.lastAt)),
|
|
1339
1351
|
h('td', { style: S.td }, h('div', { style: S.row },
|
|
1340
1352
|
h(Btn, { onClick: () => act('user-reset', { userId: user.userId }), disabled: !!busy, tone: 'danger' }, '重設 session'),
|
package/lib/config.mjs
CHANGED
|
@@ -27,6 +27,8 @@ export const DEFAULT_CONFIG = {
|
|
|
27
27
|
loadingEnabled: true,
|
|
28
28
|
stickerVision: true,
|
|
29
29
|
imageVision: true,
|
|
30
|
+
splitMessages: true,
|
|
31
|
+
splitMode: 'paragraph', // paragraph | line
|
|
30
32
|
accessMode: 'approval', // approval | whitelist
|
|
31
33
|
allowedUserIds: [],
|
|
32
34
|
welcomeText: '(已加好友)嗨,我是大肥魚~想聊什麼都可以跟我說。',
|
|
@@ -94,6 +96,9 @@ export const DEFAULT_CONFIG = {
|
|
|
94
96
|
enabled: false,
|
|
95
97
|
chance: 0.18,
|
|
96
98
|
intervalMinutes: 60,
|
|
99
|
+
minIntervalMinutes: 45,
|
|
100
|
+
maxIntervalMinutes: 180,
|
|
101
|
+
unansweredLimit: 3,
|
|
97
102
|
dailyCap: 2,
|
|
98
103
|
monthlyCap: 60,
|
|
99
104
|
minGapMinutes: 180,
|
|
@@ -200,6 +205,8 @@ export function sanitizePatch(current, patch) {
|
|
|
200
205
|
next.line.loadingEnabled = Boolean(next.line.loadingEnabled)
|
|
201
206
|
next.line.stickerVision = Boolean(next.line.stickerVision)
|
|
202
207
|
next.line.imageVision = Boolean(next.line.imageVision)
|
|
208
|
+
next.line.splitMessages = Boolean(next.line.splitMessages)
|
|
209
|
+
next.line.splitMode = next.line.splitMode === 'line' ? 'line' : 'paragraph'
|
|
203
210
|
next.line.welcomeText = String(next.line.welcomeText ?? '').slice(0, 1000)
|
|
204
211
|
next.line.errorReply = String(next.line.errorReply ?? '').slice(0, 500)
|
|
205
212
|
next.line.emptyReply = String(next.line.emptyReply ?? '').slice(0, 500)
|
|
@@ -276,6 +283,10 @@ export function sanitizePatch(current, patch) {
|
|
|
276
283
|
nudge.enabled = Boolean(nudge.enabled)
|
|
277
284
|
nudge.chance = clampFloat(nudge.chance, 0, 1, 0.18)
|
|
278
285
|
nudge.intervalMinutes = clampInt(nudge.intervalMinutes, 5, 1440, 60)
|
|
286
|
+
nudge.minIntervalMinutes = clampInt(nudge.minIntervalMinutes, 5, 1440, 45)
|
|
287
|
+
nudge.maxIntervalMinutes = clampInt(nudge.maxIntervalMinutes, 5, 1440, 180)
|
|
288
|
+
if (nudge.maxIntervalMinutes < nudge.minIntervalMinutes) nudge.maxIntervalMinutes = nudge.minIntervalMinutes
|
|
289
|
+
nudge.unansweredLimit = clampInt(nudge.unansweredLimit, 0, 20, 3)
|
|
279
290
|
nudge.dailyCap = clampInt(nudge.dailyCap, 0, 50, 2)
|
|
280
291
|
nudge.monthlyCap = clampInt(nudge.monthlyCap, 0, 1000, 60)
|
|
281
292
|
nudge.minGapMinutes = clampInt(nudge.minGapMinutes, 0, 10080, 180)
|
package/lib/host.mjs
CHANGED
|
@@ -47,7 +47,7 @@ const { createStickerImages } = stickerImageMod
|
|
|
47
47
|
const { createVision } = visionMod
|
|
48
48
|
const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep, readJsonSafe } = utilMod
|
|
49
49
|
|
|
50
|
-
export const VERSION = '0.9.
|
|
50
|
+
export const VERSION = '0.9.6'
|
|
51
51
|
|
|
52
52
|
// 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
|
|
53
53
|
// 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
|
|
@@ -1448,6 +1448,17 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1448
1448
|
return bestScore >= 2 ? best : null
|
|
1449
1449
|
}
|
|
1450
1450
|
|
|
1451
|
+
// 把回覆切成多個 LINE 訊息框。預設用空行分段(段落),也可改成每個換行一段。
|
|
1452
|
+
function splitReplyText(text) {
|
|
1453
|
+
const cfg = store.get()
|
|
1454
|
+
const raw = String(text || '').trim()
|
|
1455
|
+
if (!raw) return []
|
|
1456
|
+
if (cfg.line?.splitMessages === false) return [raw]
|
|
1457
|
+
const separator = cfg.line?.splitMode === 'line' ? /\n+/ : /\n[ \t]*\n+/
|
|
1458
|
+
const parts = raw.split(separator).map((part) => part.trim()).filter(Boolean)
|
|
1459
|
+
return parts.length > 1 ? parts : [raw]
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1451
1462
|
async function buildOutgoingMessages(text) {
|
|
1452
1463
|
const cfg = store.get()
|
|
1453
1464
|
const parsed = splitStickerMarkers(text)
|
|
@@ -1494,7 +1505,18 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1494
1505
|
if (imageUrls.length > 0) {
|
|
1495
1506
|
messages.push(buildFlexMessage(parsed.text, imageUrls))
|
|
1496
1507
|
} else if (parsed.text) {
|
|
1497
|
-
|
|
1508
|
+
const texts = []
|
|
1509
|
+
for (const segment of splitReplyText(parsed.text)) {
|
|
1510
|
+
for (const chunk of chunkText(segment)) texts.push(chunk)
|
|
1511
|
+
}
|
|
1512
|
+
// LINE 一次最多 5 則:超出時把多餘的段落併進最後一則,避免後半段被丟掉。
|
|
1513
|
+
if (texts.length > 5) {
|
|
1514
|
+
const kept = texts.slice(0, 4)
|
|
1515
|
+
kept.push(texts.slice(4).join('\n\n').slice(0, 4900))
|
|
1516
|
+
texts.length = 0
|
|
1517
|
+
texts.push(...kept)
|
|
1518
|
+
}
|
|
1519
|
+
for (const chunk of texts) {
|
|
1498
1520
|
if (messages.length >= 5) break
|
|
1499
1521
|
messages.push(chunk)
|
|
1500
1522
|
}
|
|
@@ -1671,6 +1693,14 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1671
1693
|
}
|
|
1672
1694
|
})
|
|
1673
1695
|
|
|
1696
|
+
if (sourceKind === 'user') {
|
|
1697
|
+
try {
|
|
1698
|
+
nudge.noteUserMessage(userId)
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
logger.warn(`重設主動傳訊倒數失敗:${errorText(error)}`)
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1674
1704
|
const personaResult = await handlePersonaCommand({
|
|
1675
1705
|
ownerKind: sourceKind,
|
|
1676
1706
|
ownerId: conversationKey,
|
|
@@ -2172,6 +2202,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2172
2202
|
pictureUrl: user.pictureUrl || '',
|
|
2173
2203
|
messages: Number(user.messages) || 0,
|
|
2174
2204
|
nudges: Number(user.nudges) || 0,
|
|
2205
|
+
nudgeNextAt: Number(user.nudgeNextAt) || 0,
|
|
2206
|
+
nudgeUnanswered: Number(user.nudgeUnanswered) || 0,
|
|
2207
|
+
nudgePaused: Boolean(user.nudgePaused),
|
|
2208
|
+
nudgeLastAt: Number(user.nudgeLastAt) || 0,
|
|
2175
2209
|
lastAt: Number(user.lastAt) || 0,
|
|
2176
2210
|
lastUserAt: Number(user.lastUserAt) || 0,
|
|
2177
2211
|
lastReplyAt: Number(user.lastReplyAt) || 0,
|
package/lib/nudge.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// 大肥魚主動傳訊排程。執行在 host 裡(不用 launchd),所有參數都由面板調整:
|
|
2
|
-
//
|
|
2
|
+
// 每位使用者有自己的倒數(最短/最長間隔隨機),時間到且沉默夠久才可能開口;
|
|
3
|
+
// 連續未回覆達上限就暫停,使用者一說話就歸零並重新倒數。
|
|
4
|
+
import { safeArray } from './util.mjs'
|
|
5
|
+
|
|
3
6
|
const DEFAULT_PROMPT =
|
|
4
7
|
'(主動傳訊)現在輪到你主動找對方說話。用一兩句自然、像訊息的短句開場,不要提「系統」「排程」「AI」這些字,也不要複製你之前說過的話。如果氣氛合適,可以在句子後面加 [[meme:#編號]] 配一張圖(編號照 context 裡的圖庫清單),不適合就純文字。如果你判斷現在不適合打擾,只輸出 SKIP。'
|
|
5
8
|
|
|
@@ -56,7 +59,6 @@ export function createNudgeScheduler({ getConfig, store, logger, runTurn, sendPr
|
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
function counters() {
|
|
59
|
-
const cfg = getConfig()
|
|
60
62
|
const n = nudgeState()
|
|
61
63
|
const today = dayKey()
|
|
62
64
|
const month = monthKey()
|
|
@@ -68,24 +70,93 @@ export function createNudgeScheduler({ getConfig, store, logger, runTurn, sendPr
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
// 最短/最長間隔(分鐘)。舊版只有 intervalMinutes,沒有新設定時當成最短值。
|
|
74
|
+
function intervalRange(cfg) {
|
|
75
|
+
const legacy = Number(cfg.nudge.intervalMinutes)
|
|
76
|
+
const min = Math.max(1, Number(cfg.nudge.minIntervalMinutes) || (Number.isFinite(legacy) && legacy > 0 ? legacy : 45))
|
|
77
|
+
const max = Math.max(min, Number(cfg.nudge.maxIntervalMinutes) || Math.max(min, 180))
|
|
78
|
+
return { min, max }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function randomIntervalMs(cfg) {
|
|
82
|
+
const { min, max } = intervalRange(cfg)
|
|
83
|
+
if (max <= min) return min * 60000
|
|
84
|
+
return Math.round((min + Math.random() * (max - min)) * 60000)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function setUserSchedule(userId, patch) {
|
|
88
|
+
store.updateState((state) => {
|
|
89
|
+
const target = state.users?.[userId]
|
|
90
|
+
if (!target) return state
|
|
91
|
+
return { ...state, users: { ...state.users, [userId]: { ...target, ...patch } } }
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function allowedUsers(cfg) {
|
|
96
|
+
const state = store.getState()
|
|
97
|
+
const allowed = safeArray(cfg.line?.allowedUserIds)
|
|
98
|
+
const targets = safeArray(cfg.nudge.targetUserIds)
|
|
99
|
+
const ids = targets.length ? targets : Object.keys(state.users || {})
|
|
100
|
+
return ids.filter((userId) => {
|
|
101
|
+
const user = state.users?.[userId]
|
|
102
|
+
if (!user?.sessionId) return false
|
|
103
|
+
return allowed.length > 0 && allowed.includes(userId)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 第一次啟動(或舊資料)時,把還沒有倒數的使用者排進佇列,避免同時到期。
|
|
108
|
+
function ensureSchedules(cfg) {
|
|
109
|
+
const state = store.getState()
|
|
110
|
+
const now = Date.now()
|
|
111
|
+
let changed = false
|
|
112
|
+
const users = { ...(state.users || {}) }
|
|
113
|
+
for (const userId of allowedUsers(cfg)) {
|
|
114
|
+
const user = users[userId]
|
|
115
|
+
if (!user) continue
|
|
116
|
+
if (!Number(user.nudgeNextAt)) {
|
|
117
|
+
users[userId] = {
|
|
118
|
+
...user,
|
|
119
|
+
nudgeNextAt: now + randomIntervalMs(cfg),
|
|
120
|
+
nudgeUnanswered: Number(user.nudgeUnanswered) || 0,
|
|
121
|
+
nudgePaused: false,
|
|
122
|
+
}
|
|
123
|
+
changed = true
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (changed) store.updateState((s) => ({ ...s, users }))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function nextDueAt(cfg) {
|
|
130
|
+
const state = store.getState()
|
|
131
|
+
let best = 0
|
|
132
|
+
for (const userId of allowedUsers(cfg)) {
|
|
133
|
+
const user = state.users?.[userId]
|
|
134
|
+
if (!user || user.nudgePaused) continue
|
|
135
|
+
const at = Number(user.nudgeNextAt) || 0
|
|
136
|
+
if (!at) continue
|
|
137
|
+
if (!best || at < best) best = at
|
|
138
|
+
}
|
|
139
|
+
return best
|
|
140
|
+
}
|
|
141
|
+
|
|
71
142
|
function pickUser(cfg, { force }) {
|
|
72
143
|
const state = store.getState()
|
|
73
144
|
const now = Date.now()
|
|
74
|
-
const allowed = cfg.line?.allowedUserIds || []
|
|
75
|
-
const targets = cfg.nudge.targetUserIds.length
|
|
76
|
-
? cfg.nudge.targetUserIds
|
|
77
|
-
: Object.keys(state.users || {})
|
|
78
145
|
const candidate = []
|
|
79
|
-
for (const userId of
|
|
80
|
-
if (!allowed.length || !allowed.includes(userId)) continue
|
|
146
|
+
for (const userId of allowedUsers(cfg)) {
|
|
81
147
|
const user = state.users?.[userId]
|
|
82
|
-
if (!user
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
148
|
+
if (!user) continue
|
|
149
|
+
if (!force) {
|
|
150
|
+
if (user.nudgePaused) continue
|
|
151
|
+
const at = Number(user.nudgeNextAt) || 0
|
|
152
|
+
if (!at || at > now) continue
|
|
153
|
+
const last = Number(user.lastAt) || 0
|
|
154
|
+
if (cfg.nudge.idleMinutes > 0 && now - last < cfg.nudge.idleMinutes * 60000) continue
|
|
155
|
+
}
|
|
156
|
+
candidate.push({ userId, at: Number(user.nudgeNextAt) || 0, last: Number(user.lastAt) || 0 })
|
|
86
157
|
}
|
|
87
158
|
if (candidate.length === 0) return null
|
|
88
|
-
candidate.sort((a, b) => a.last - b.last)
|
|
159
|
+
candidate.sort((a, b) => (force ? a.last - b.last : a.at - b.at))
|
|
89
160
|
return candidate[0].userId
|
|
90
161
|
}
|
|
91
162
|
|
|
@@ -100,38 +171,41 @@ export function createNudgeScheduler({ getConfig, store, logger, runTurn, sendPr
|
|
|
100
171
|
if (!force && inQuiet(cfg, now)) return { skipped: 'quiet' }
|
|
101
172
|
const n = nudgeState()
|
|
102
173
|
const c = counters()
|
|
174
|
+
if (!force) ensureSchedules(cfg)
|
|
103
175
|
if (!force) {
|
|
104
176
|
if (cfg.nudge.dailyCap > 0 && c.dayCount >= cfg.nudge.dailyCap) return { skipped: 'daily-cap', ...c }
|
|
105
177
|
if (cfg.nudge.monthlyCap > 0 && c.monthCount >= cfg.nudge.monthlyCap) return { skipped: 'monthly-cap', ...c }
|
|
106
178
|
if (n.lastAt && cfg.nudge.minGapMinutes > 0 && Date.now() - n.lastAt < cfg.nudge.minGapMinutes * 60000) {
|
|
107
|
-
return { skipped: 'min-gap' }
|
|
179
|
+
return { skipped: 'min-gap', nextAt: nextDueAt(cfg) }
|
|
108
180
|
}
|
|
109
|
-
if (n.lastRollAt && Date.now() - n.lastRollAt < cfg.nudge.intervalMinutes * 60000) return { skipped: 'interval' }
|
|
110
|
-
saveNudge({ lastRollAt: Date.now() })
|
|
111
|
-
if (Math.random() >= cfg.nudge.chance) return { skipped: 'chance' }
|
|
112
181
|
}
|
|
113
182
|
const userId = pickUser(cfg, { force })
|
|
114
|
-
if (!userId)
|
|
183
|
+
if (!userId) {
|
|
184
|
+
return { skipped: 'not-due', nextAt: nextDueAt(cfg), ...c }
|
|
185
|
+
}
|
|
115
186
|
const user = store.getState().users[userId]
|
|
116
187
|
const prompt = (cfg.nudge.prompt || '').trim() || DEFAULT_PROMPT
|
|
117
188
|
let result
|
|
118
189
|
try {
|
|
119
190
|
result = await runTurn({ sessionId: user.sessionId, text: prompt, nudge: true })
|
|
120
191
|
} catch (error) {
|
|
121
|
-
|
|
192
|
+
setUserSchedule(userId, { nudgeNextAt: Date.now() + 5 * 60000 })
|
|
193
|
+
saveNudge({ lastError: error?.message || String(error), lastReason: 'error', nextAt: nextDueAt(cfg) })
|
|
122
194
|
store.updateState((state) => ({ ...state, lastError: error?.message || String(error) }))
|
|
123
195
|
logger?.error?.(`主動傳訊失敗:${error?.message || error}`)
|
|
124
196
|
return { skipped: 'agent-error', error: error?.message || String(error) }
|
|
125
197
|
}
|
|
126
198
|
const text = String(result?.text || '').trim()
|
|
127
199
|
if (!text || /^SKIP\b/i.test(text)) {
|
|
128
|
-
|
|
200
|
+
setUserSchedule(userId, { nudgeNextAt: Date.now() + randomIntervalMs(cfg) })
|
|
201
|
+
saveNudge({ lastReason: 'skip', nextAt: nextDueAt(cfg) })
|
|
129
202
|
return { skipped: 'skip', ...c }
|
|
130
203
|
}
|
|
131
204
|
try {
|
|
132
205
|
await sendProactive(userId, text)
|
|
133
206
|
} catch (error) {
|
|
134
|
-
|
|
207
|
+
setUserSchedule(userId, { nudgeNextAt: Date.now() + 5 * 60000 })
|
|
208
|
+
saveNudge({ lastError: error?.message || String(error), lastReason: 'send-error', nextAt: nextDueAt(cfg) })
|
|
135
209
|
logger?.error?.(`主動傳訊送出失敗:${error?.message || error}`)
|
|
136
210
|
return { skipped: 'send-error', error: error?.message || String(error) }
|
|
137
211
|
}
|
|
@@ -139,6 +213,18 @@ export function createNudgeScheduler({ getConfig, store, logger, runTurn, sendPr
|
|
|
139
213
|
const today = dayKey()
|
|
140
214
|
const month = monthKey()
|
|
141
215
|
const next = counters()
|
|
216
|
+
const unanswered = (Number(user.nudgeUnanswered) || 0) + 1
|
|
217
|
+
const limit = Number(cfg.nudge.unansweredLimit) || 0
|
|
218
|
+
const paused = limit > 0 && unanswered >= limit
|
|
219
|
+
const nextAt = paused ? 0 : stamp + randomIntervalMs(cfg)
|
|
220
|
+
setUserSchedule(userId, {
|
|
221
|
+
nudgeNextAt: nextAt,
|
|
222
|
+
nudgeLastAt: stamp,
|
|
223
|
+
nudgeUnanswered: unanswered,
|
|
224
|
+
nudgePaused: paused,
|
|
225
|
+
lastAt: stamp,
|
|
226
|
+
nudges: (Number(user.nudges) || 0) + 1,
|
|
227
|
+
})
|
|
142
228
|
saveNudge({
|
|
143
229
|
day: today,
|
|
144
230
|
dayCount: next.dayCount + 1,
|
|
@@ -147,30 +233,48 @@ export function createNudgeScheduler({ getConfig, store, logger, runTurn, sendPr
|
|
|
147
233
|
lastAt: stamp,
|
|
148
234
|
lastReason: force ? 'test' : 'sent',
|
|
149
235
|
lastError: '',
|
|
150
|
-
nextAt: cfg
|
|
151
|
-
})
|
|
152
|
-
store.updateState((state) => {
|
|
153
|
-
const target = state.users[userId]
|
|
154
|
-
if (!target) return state
|
|
155
|
-
return {
|
|
156
|
-
...state,
|
|
157
|
-
users: { ...state.users, [userId]: { ...target, lastAt: stamp, nudges: (Number(target.nudges) || 0) + 1 } },
|
|
158
|
-
}
|
|
236
|
+
nextAt: nextDueAt(cfg),
|
|
159
237
|
})
|
|
238
|
+
if (paused) logger?.info?.(`主動傳訊:${userId} 已連續 ${unanswered} 次未回覆,暫停到對方下次說話`)
|
|
160
239
|
logger?.info?.(`已主動傳訊給 ${userId}(${text.length} 字)`)
|
|
161
|
-
return { sent: true, userId, text, ...counters() }
|
|
240
|
+
return { sent: true, userId, text, unanswered, paused, ...counters() }
|
|
162
241
|
} finally {
|
|
163
242
|
ticking = false
|
|
164
243
|
}
|
|
165
244
|
}
|
|
166
245
|
|
|
246
|
+
// 使用者一說話:未回覆歸零、解除暫停,並重新隨機倒數(沉默後才再開口)。
|
|
247
|
+
function noteUserMessage(userId) {
|
|
248
|
+
const cfg = getConfig()
|
|
249
|
+
const user = store.getState().users?.[userId]
|
|
250
|
+
if (!user) return 0
|
|
251
|
+
const nextAt = Date.now() + randomIntervalMs(cfg)
|
|
252
|
+
setUserSchedule(userId, {
|
|
253
|
+
nudgeNextAt: nextAt,
|
|
254
|
+
nudgeUnanswered: 0,
|
|
255
|
+
nudgePaused: false,
|
|
256
|
+
nudgeLastUserAt: Date.now(),
|
|
257
|
+
})
|
|
258
|
+
return nextAt
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function scheduleOf(userId) {
|
|
262
|
+
const user = store.getState().users?.[userId] || {}
|
|
263
|
+
return {
|
|
264
|
+
nextAt: Number(user.nudgeNextAt) || 0,
|
|
265
|
+
unanswered: Number(user.nudgeUnanswered) || 0,
|
|
266
|
+
paused: Boolean(user.nudgePaused),
|
|
267
|
+
lastAt: Number(user.nudgeLastAt) || 0,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
167
271
|
function start() {
|
|
168
272
|
if (timer) return
|
|
169
273
|
timer = setInterval(() => {
|
|
170
274
|
tick().catch((error) => logger?.error?.(`主動傳訊排程例外:${error?.message || error}`))
|
|
171
275
|
}, 60 * 1000)
|
|
172
276
|
if (typeof timer.unref === 'function') timer.unref()
|
|
173
|
-
logger?.info?.('
|
|
277
|
+
logger?.info?.('主動傳訊排程已啟動(每分鐘檢查一次,每位使用者獨立倒數)')
|
|
174
278
|
}
|
|
175
279
|
|
|
176
280
|
function stop() {
|
|
@@ -183,6 +287,8 @@ export function createNudgeScheduler({ getConfig, store, logger, runTurn, sendPr
|
|
|
183
287
|
const cfg = getConfig()
|
|
184
288
|
const c = counters()
|
|
185
289
|
const n = nudgeState()
|
|
290
|
+
const state = store.getState()
|
|
291
|
+
const pausedUsers = Object.values(state.users || {}).filter((u) => u?.sessionId && u.nudgePaused).length
|
|
186
292
|
return {
|
|
187
293
|
running: Boolean(timer),
|
|
188
294
|
enabled: cfg.nudge.enabled,
|
|
@@ -194,11 +300,15 @@ export function createNudgeScheduler({ getConfig, store, logger, runTurn, sendPr
|
|
|
194
300
|
lastRollAt: n.lastRollAt,
|
|
195
301
|
lastError: n.lastError,
|
|
196
302
|
lastReason: n.lastReason,
|
|
197
|
-
nextAt:
|
|
303
|
+
nextAt: nextDueAt(cfg),
|
|
304
|
+
pausedUsers,
|
|
305
|
+
minIntervalMinutes: intervalRange(cfg).min,
|
|
306
|
+
maxIntervalMinutes: intervalRange(cfg).max,
|
|
307
|
+
unansweredLimit: Number(cfg.nudge.unansweredLimit) || 0,
|
|
198
308
|
lastTickAt,
|
|
199
|
-
eligibleUsers:
|
|
309
|
+
eligibleUsers: allowedUsers(cfg).length,
|
|
200
310
|
}
|
|
201
311
|
}
|
|
202
312
|
|
|
203
|
-
return { start, stop, tick, stats, DEFAULT_PROMPT }
|
|
313
|
+
return { start, stop, tick, stats, DEFAULT_PROMPT, noteUserMessage, scheduleOf }
|
|
204
314
|
}
|