dsh-line-bot 0.9.3 → 0.9.5
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 +12 -0
- package/README.en.md +3 -3
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/client.js +9 -5
- package/lib/config.mjs +7 -0
- package/lib/host.mjs +17 -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,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.5
|
|
4
|
+
|
|
5
|
+
- 主動傳訊改成每位使用者各自倒數:在「最短~最長間隔」之間隨機挑時間,時間到且沉默夠久才可能開口,不再用全域擲骰。
|
|
6
|
+
- 新增「連續未回覆上限」:連續幾次主動訊息沒回就暫停該使用者;對方一說話就歸零、解除暫停並重新倒數。
|
|
7
|
+
- 面板「主動傳訊」改成最短/最長間隔與未回覆上限,顯示下次可能時間與暫停人數;使用者表新增「下次主動」「未回覆」欄位。
|
|
8
|
+
- 修正主動訊息只更新計數、沒有真的呼叫 push 的問題後,這次也把排程改成以送出成功才累加。
|
|
9
|
+
|
|
10
|
+
## 0.9.4
|
|
11
|
+
|
|
12
|
+
- 修正主動訊息(排程傳訊、餘額提醒、核准通知)只更新計數、沒有真的呼叫 LINE push API 的問題;現在會實際送出,失敗時不再累加次數。
|
|
13
|
+
- 主動訊息送出後記錄 LINE 回應的訊息數,方便日後從日誌確認。
|
|
14
|
+
|
|
3
15
|
## 0.9.3
|
|
4
16
|
|
|
5
17
|
- 身分門控改為預設關閉:白名單為空時,任何人都不能觸發 Agent;未授權的 LINE 使用者只會進入「待審核」佇列,不會建立 session、不會呼叫工具。
|
package/README.en.md
CHANGED
|
@@ -12,7 +12,7 @@ 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
16
|
- **Full settings panel**: every setting lives under Settings → LINE Bot and is stored in `<DSH_HOME>/line-bot/config.json` (mode 600).
|
|
17
17
|
|
|
18
18
|
> 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).
|
|
@@ -100,7 +100,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
|
|
|
100
100
|
| Memes | Reply with images/stickers from the dsh-meme library, enable/disable, image limit, and sample previews |
|
|
101
101
|
| Sticker & image vision | Fetch incoming LINE stickers or images and show them to the model; when the model cannot read images, describe them with modlens or a vision model (can be disabled) |
|
|
102
102
|
| DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown (deepseek-account only) |
|
|
103
|
-
| Proactive messages | Toggle,
|
|
103
|
+
| 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
104
|
| Users | Pending approval queue (approve / deny), the DSH session each LINE user maps to, their own workspace folder, their current persona (editable), message count, last interaction, clear memory / reset session |
|
|
105
105
|
| Groups | Per-group session, workspace folder, persona, message count; reply chance when not addressed; always reply on @ / quote; clear group memory |
|
|
106
106
|
| Advanced | Port, Agent provider/model/effort/cwd/timeout, one workspace per user (and its root), welcome/error/empty replies, logs, reload host modules (dev) |
|
|
@@ -119,7 +119,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
|
|
|
119
119
|
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
120
|
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
121
|
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.
|
|
122
|
+
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
123
|
|
|
124
124
|
## Model & provider
|
|
125
125
|
|
package/README.ja.md
CHANGED
|
@@ -12,7 +12,7 @@ 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
16
|
- **充実した設定パネル**:設定はすべて「設定 → LINE Bot」にあり、`<DSH_HOME>/line-bot/config.json`(モード 600)に保存されます。
|
|
17
17
|
|
|
18
18
|
> 「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 設定、テスト)を自動化します。
|
|
@@ -100,7 +100,7 @@ npx dsh-line-bot install --no-companions
|
|
|
100
100
|
| 表情パック | dsh-meme の画像ライブラリで画像/スタンプを返信、オンオフ、1 回の上限、サンプル表示 |
|
|
101
101
|
| スタンプ・画像理解 | LINE スタンプや通常の画像を受信したら取得してモデルに見せる。画像非対応モデルは modlens や別の視覚モデルで説明(オフ可) |
|
|
102
102
|
| DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン(deepseek-account のみ) |
|
|
103
|
-
| 自発メッセージ |
|
|
103
|
+
| 自発メッセージ | オン/オフ、ユーザーごとのカウントダウン、最短/最長間隔、連続未返信の上限、1 日/1 か月の上限、最低間隔、静かな時間帯、idle、対象ユーザー、プロンプト、今すぐ試送、統計(次回予定と一時停止中の人数) |
|
|
104
104
|
| ユーザー | LINE ユーザーごとに対応する DSH セッション、専用ワークスペース、現在のペルソナ(直接変更可)、メッセージ数、最終やりとり、メモリ消去/セッション再作成 |
|
|
105
105
|
| グループ | グループごとのセッション、ワークスペース、ペルソナ、メッセージ数;呼ばれていないときの返信確率;@/引用時は必ず返信;グループのメモリ消去 |
|
|
106
106
|
| 詳細設定 | ポート、Agent の provider/model/effort/cwd/タイムアウト、ユーザーごとのワークスペース(ルート含む)、ウェルカム/エラー/空返信、ログ、開発用 host リロード |
|
|
@@ -119,7 +119,7 @@ npx dsh-line-bot install --no-companions
|
|
|
119
119
|
2. `userId` から対応する DSH セッションとワークスペースを探し(なければ作成)、そのセッションのペルソナを決めます。大肥魚を選んだ場合は大肥魚の HTTP bridge 経由でセッションペルソナを指定し、プラグインのカスタムペルソナを選んだ場合はまず大肥魚にそのセッションで退いてもらい(`none`)、プラグインがカスタムペルソナを system prompt に注入します。
|
|
120
120
|
3. `agents.create`/`agents.resume` で同じ DSH プロセス内に Agent を作成/再開し、`followup` でユーザーメッセージを 1 件送り、ターン終了・サブエージェントの収束・セッションの flush を待ちます。
|
|
121
121
|
4. セッションイベントから最後の assistant テキストを取り出し、LINE の reply で返信します。reply token が切れていたら自動で push に切り替えます。
|
|
122
|
-
5.
|
|
122
|
+
5. スケジューラーは 1 分ごとに各ユーザーのカウントダウンを確認し、時間が来て相手の沈黙が十分長ければ Agent に一言話してもらって push します。連続未返信が上限に達したら一時停止し、相手が返信したら再びカウントダウンします。
|
|
123
123
|
|
|
124
124
|
## モデルとプロバイダー
|
|
125
125
|
|
package/README.ko.md
CHANGED
|
@@ -12,7 +12,7 @@ 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
16
|
- **전체 설정 패널**: 모든 설정이 "설정 → LINE Bot"에 있으며 `<DSH_HOME>/line-bot/config.json`(모드 600)에 저장됩니다.
|
|
17
17
|
|
|
18
18
|
> "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 설정, 테스트)를 자동화합니다.
|
|
@@ -100,7 +100,7 @@ npx dsh-line-bot install --no-companions
|
|
|
100
100
|
| 이모티콘 | dsh-meme 라이브러리로 이미지/스티커 답장, 켜기/끄기, 1회 최대 장수, 샘플 미리보기 |
|
|
101
101
|
| 스티커·이미지 이해 | LINE 스티커나 일반 이미지를 받으면 가져와 모델에 보여 줌. 이미지 미지원 모델은 modlens 또는 다른 비전 모델로 설명 (끌 수 있음) |
|
|
102
102
|
| DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운(deepseek-account 전용) |
|
|
103
|
-
| 능동 메시지 | 켜기/끄기,
|
|
103
|
+
| 능동 메시지 | 켜기/끄기, 사용자별 카운트다운, 최소/최대 간격, 연속 미응답 상한, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 대상 사용자, 프롬프트, 지금 시험 발송, 통계(다음 예정 시각과 일시 중지 인원) |
|
|
104
104
|
| 사용자 | LINE 사용자별 DSH 세션, 전용 작업 폴더, 현재 페르소나(직접 변경 가능), 메시지 수, 마지막 상호작용, 기억 지우기/세션 재설정 |
|
|
105
105
|
| 그룹 | 그룹별 세션, 작업 폴더, 페르소나, 메시지 수; 호출되지 않았을 때 답장 확률; @/인용 시 항상 답장; 그룹 기억 지우기 |
|
|
106
106
|
| 고급 설정 | 포트, Agent provider/model/effort/cwd/타임아웃, 사용자별 작업 폴더(루트 포함), 환영/오류/빈 답장, 로그, 개발용 host 리로드 |
|
|
@@ -119,7 +119,7 @@ npx dsh-line-bot install --no-companions
|
|
|
119
119
|
2. `userId`로 해당 DSH 세션과 작업 폴더를 찾거나 만들고, 그 세션의 페르소나를 정합니다. 大肥魚를 고르면 大肥魚의 HTTP bridge로 세션 페르소나를 지정하고, 플러그인 커스텀 페르소나를 고르면 먼저 大肥魚에게 그 세션에서 물러나 달라고 한 뒤(`none`) 플러그인이 커스텀 페르소나를 system prompt에 주입합니다.
|
|
120
120
|
3. `agents.create`/`agents.resume`로 같은 DSH 프로세스 안에서 Agent를 만들거나 이어 받고, `followup`으로 사용자 메시지 하나를 보낸 뒤 turn 종료, 하위 에이전트 수렴, 세션 flush를 기다립니다.
|
|
121
121
|
4. 세션 이벤트에서 마지막 assistant 텍스트를 꺼내 LINE reply로 답장합니다. reply token이 만료되었으면 자동으로 push로 전환합니다.
|
|
122
|
-
5.
|
|
122
|
+
5. 스케줄러는 1분마다 각 사용자의 카운트다운을 확인하고, 시간이 되고 상대의 침묵이 충분히 길면 Agent에게 한마디 하게 한 뒤 push합니다. 연속 미응답이 상한에 도달하면 일시 중지하고, 상대가 답하면 다시 카운트다운합니다.
|
|
123
123
|
|
|
124
124
|
## 모델과 공급자
|
|
125
125
|
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
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
16
|
- **完整設定面板**:所有設定都在「設定 → LINE Bot」裡,存到 `<DSH_HOME>/line-bot/config.json`(權限 600)。
|
|
17
17
|
|
|
18
18
|
> 關於「用 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、測試)。
|
|
@@ -100,7 +100,7 @@ npx dsh-line-bot install --no-companions
|
|
|
100
100
|
| 表情包 | 使用 dsh-meme 圖庫回圖片/貼圖、開關、一次最多幾張、圖庫樣本 |
|
|
101
101
|
| 貼圖與圖片理解 | 收到 LINE 貼圖或一般圖片時抓圖給模型看;模型不吃圖時用 modlens 或另一個視覺模型描述(可關閉) |
|
|
102
102
|
| DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻(僅 deepseek-account) |
|
|
103
|
-
| 主動傳訊 |
|
|
103
|
+
| 主動傳訊 | 開關、每位使用者的倒數、最短/最長間隔、連續未回覆上限、每日/每月上限、兩則間距、安靜時段、idle、指定對象、提示詞、立即試傳、統計(下次可能時間與暫停人數) |
|
|
104
104
|
| 使用者 | 待審核名單(核准/拒絕)、每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
|
|
105
105
|
| 群組 | 每個群組的 session、工作資料夾、人設、訊息數;沒被點名時的回覆機率;被 @/引用一定回;清空群組記憶 |
|
|
106
106
|
| 進階設定 | 連接埠、Agent provider/model/effort/cwd/逾時、一人一個工作資料夾(含根目錄)、歡迎/錯誤/空回覆、日誌、開發用 host 重新載入 |
|
|
@@ -119,7 +119,7 @@ npx dsh-line-bot install --no-companions
|
|
|
119
119
|
2. 依 `userId` 找到(或建立)對應的 DSH session 與工作資料夾,決定這個 session 的人設:選大肥魚時透過它的 HTTP bridge 指定 session 人設;選插件自訂人設時,先請大肥魚在該 session 讓開(`none`),再由插件把自訂人設注入 system prompt。
|
|
120
120
|
3. 用 `agents.create`/`agents.resume` 在同一個 DSH 行程內建立/接續 Agent,`followup` 一則使用者訊息,等到 turn 結束、子代理收斂、session flush。
|
|
121
121
|
4. 從 session 事件取出最後一段 assistant 文字,用 LINE reply 回覆;reply token 過期就自動改 push。
|
|
122
|
-
5.
|
|
122
|
+
5. 主動傳訊排程器每分鐘檢查一次:逐位使用者到期、且沉默夠久才請 Agent 主動說一句,再 push 出去;連續未回覆達上限會暫停,對方回話後重新倒數。
|
|
123
123
|
|
|
124
124
|
## 模型與接口來源
|
|
125
125
|
|
package/README.zh-CN.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
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
16
|
- **完整设置面板**:所有设置都在「设置 → LINE Bot」里,存到 `<DSH_HOME>/line-bot/config.json`(权限 600)。
|
|
17
17
|
|
|
18
18
|
> 关于「用 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、测试)。
|
|
@@ -100,7 +100,7 @@ npx dsh-line-bot install --no-companions
|
|
|
100
100
|
| 表情包 | 使用 dsh-meme 图库回图片/贴图、开关、每次最多几张、图库样本 |
|
|
101
101
|
| 贴图与图片理解 | 收到 LINE 贴图或一般图片时抓图给模型看;模型不吃图时用 modlens 或另一个视觉模型描述(可关闭) |
|
|
102
102
|
| DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却(仅 deepseek-account) |
|
|
103
|
-
| 主动传讯 |
|
|
103
|
+
| 主动传讯 | 开关、每位使用者的倒数、最短/最长间隔、连续未回复上限、每日/每月上限、两则间距、安静时段、idle、指定对象、提示词、立即试传、统计(下次可能时间与暂停人数) |
|
|
104
104
|
| 用户 | 每位 LINE 用户对应的 DSH session、专属工作文件夹、目前人设(可直接改)、消息数、上次互动、清空记忆/重设 session |
|
|
105
105
|
| 群组 | 每个群组的 session、工作文件夹、人设、消息数;没被点名时的回复概率;被 @/引用一定回;清空群组记忆 |
|
|
106
106
|
| 高级设置 | 端口、Agent provider/model/effort/cwd/超时、一人一个工作文件夹(含根目录)、欢迎/错误/空回复、日志、开发用 host 重新载入 |
|
|
@@ -119,7 +119,7 @@ npx dsh-line-bot install --no-companions
|
|
|
119
119
|
2. 依 `userId` 找到(或创建)对应的 DSH session 与工作文件夹,决定这个 session 的人设:选大肥鱼时通过它的 HTTP bridge 指定 session 人设;选插件自定义人设时,先请大肥鱼在该 session 让开(`none`),再由插件把自定义人设注入 system prompt。
|
|
120
120
|
3. 用 `agents.create`/`agents.resume` 在同一个 DSH 进程内创建/接续 Agent,`followup` 一则用户消息,等到 turn 结束、子代理收敛、session flush。
|
|
121
121
|
4. 从 session 事件取出最后一段 assistant 文字,用 LINE reply 回复;reply token 过期就自动改 push。
|
|
122
|
-
5.
|
|
122
|
+
5. 主动传讯排程器每分钟检查一次:逐位使用者到期、且沉默够久才请 Agent 主动说一句,再 push 出去;连续未回复达上限会暂停,对方回话后重新倒数。
|
|
123
123
|
|
|
124
124
|
## 模型与接口来源
|
|
125
125
|
|
package/client.js
CHANGED
|
@@ -1246,11 +1246,12 @@ window.__ModuleLoader__.load({
|
|
|
1246
1246
|
// 5. 主動傳訊
|
|
1247
1247
|
h(
|
|
1248
1248
|
Card,
|
|
1249
|
-
{ id: sectionDomId('nudge'), order: 10, title: '主動傳訊(大肥魚自己找人聊天)', desc: '
|
|
1249
|
+
{ id: sectionDomId('nudge'), order: 10, title: '主動傳訊(大肥魚自己找人聊天)', desc: '每位使用者各自倒數:在最短~最長間隔之間隨機挑一個時間,沉默夠久且時間到才請 Agent 主動傳一句;不適合時 Agent 可回 SKIP。連續沒回覆達上限會暫停,對方一說話就歸零。' },
|
|
1250
1250
|
h(Toggle, { checked: !!nudgeCfg.enabled, onChange: (value) => update({ nudge: { enabled: value } }), label: '開啟主動傳訊' }),
|
|
1251
1251
|
h('div', { style: S.grid },
|
|
1252
|
-
h(Field, { label: '
|
|
1253
|
-
h(Field, { label: '
|
|
1252
|
+
h(Field, { label: '最短間隔(分鐘)', hint: '每位使用者獨立倒數' }, h(Num, { value: nudgeCfg.minIntervalMinutes, min: 5, max: 1440, onChange: (value) => update({ nudge: { minIntervalMinutes: value } }) })),
|
|
1253
|
+
h(Field, { label: '最長間隔(分鐘)', hint: '實際時間在最短~最長之間隨機' }, h(Num, { value: nudgeCfg.maxIntervalMinutes, min: 5, max: 1440, onChange: (value) => update({ nudge: { maxIntervalMinutes: value } }) })),
|
|
1254
|
+
h(Field, { label: '連續未回覆上限(則)', hint: '連續幾次沒回就暫停;對方一說話就歸零(0=不限)' }, h(Num, { value: nudgeCfg.unansweredLimit, min: 0, max: 20, onChange: (value) => update({ nudge: { unansweredLimit: value } }) })),
|
|
1254
1255
|
h(Field, { label: '每日上限(則)' }, h(Num, { value: nudgeCfg.dailyCap, min: 0, max: 100, onChange: (value) => update({ nudge: { dailyCap: value } }) })),
|
|
1255
1256
|
h(Field, { label: '每月上限(則)' }, h(Num, { value: nudgeCfg.monthlyCap, min: 0, max: 1000, onChange: (value) => update({ nudge: { monthlyCap: value } }) })),
|
|
1256
1257
|
h(Field, { label: '兩則之間至少間隔(分鐘)' }, h(Num, { value: nudgeCfg.minGapMinutes, min: 0, max: 10080, onChange: (value) => update({ nudge: { minGapMinutes: value } }) })),
|
|
@@ -1269,7 +1270,8 @@ window.__ModuleLoader__.load({
|
|
|
1269
1270
|
['今日', `${nudgeStats.dayCount ?? 0} / ${nudgeCfg.dailyCap ?? '—'} 則`],
|
|
1270
1271
|
['本月', `${nudgeStats.monthCount ?? 0} / ${nudgeCfg.monthlyCap ?? '—'} 則`],
|
|
1271
1272
|
['上次傳訊', fmtTime(nudgeStats.lastAt)],
|
|
1272
|
-
['
|
|
1273
|
+
['下次可能主動', fmtTime(nudgeStats.nextAt)],
|
|
1274
|
+
['暫停中的人', `${nudgeStats.pausedUsers ?? 0} 位`],
|
|
1273
1275
|
['上次結果', nudgeStats.lastReason || '—'],
|
|
1274
1276
|
],
|
|
1275
1277
|
}),
|
|
@@ -1320,7 +1322,7 @@ window.__ModuleLoader__.load({
|
|
|
1320
1322
|
(status.users || []).length === 0
|
|
1321
1323
|
? h('div', { style: S.hint }, '還沒有人傳訊息給 bot。')
|
|
1322
1324
|
: 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 }, ''))),
|
|
1325
|
+
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
1326
|
h('tbody', null, (status.users || []).map((user) =>
|
|
1325
1327
|
h('tr', { key: user.userId },
|
|
1326
1328
|
h('td', { style: S.td }, h('div', null, user.displayName || '(未命名)'), h('div', { style: { ...S.hint, ...S.mono } }, user.userId)),
|
|
@@ -1335,6 +1337,8 @@ window.__ModuleLoader__.load({
|
|
|
1335
1337
|
},
|
|
1336
1338
|
(personaCatalog.length ? personaCatalog : [{ id: user.personaId, name: user.personaName || user.personaId }]).map((item) =>
|
|
1337
1339
|
h('option', { key: item.id, value: item.id }, `${item.name}${item.source === 'local' ? '(自訂)' : ''}`)))),
|
|
1340
|
+
h('td', { style: S.td }, user.nudgePaused ? h('span', { style: S.hint }, '暫停中') : fmtTime(user.nudgeNextAt)),
|
|
1341
|
+
h('td', { style: S.td }, String(user.nudgeUnanswered ?? 0)),
|
|
1338
1342
|
h('td', { style: S.td }, fmtTime(user.lastAt)),
|
|
1339
1343
|
h('td', { style: S.td }, h('div', { style: S.row },
|
|
1340
1344
|
h(Btn, { onClick: () => act('user-reset', { userId: user.userId }), disabled: !!busy, tone: 'danger' }, '重設 session'),
|
package/lib/config.mjs
CHANGED
|
@@ -94,6 +94,9 @@ export const DEFAULT_CONFIG = {
|
|
|
94
94
|
enabled: false,
|
|
95
95
|
chance: 0.18,
|
|
96
96
|
intervalMinutes: 60,
|
|
97
|
+
minIntervalMinutes: 45,
|
|
98
|
+
maxIntervalMinutes: 180,
|
|
99
|
+
unansweredLimit: 3,
|
|
97
100
|
dailyCap: 2,
|
|
98
101
|
monthlyCap: 60,
|
|
99
102
|
minGapMinutes: 180,
|
|
@@ -276,6 +279,10 @@ export function sanitizePatch(current, patch) {
|
|
|
276
279
|
nudge.enabled = Boolean(nudge.enabled)
|
|
277
280
|
nudge.chance = clampFloat(nudge.chance, 0, 1, 0.18)
|
|
278
281
|
nudge.intervalMinutes = clampInt(nudge.intervalMinutes, 5, 1440, 60)
|
|
282
|
+
nudge.minIntervalMinutes = clampInt(nudge.minIntervalMinutes, 5, 1440, 45)
|
|
283
|
+
nudge.maxIntervalMinutes = clampInt(nudge.maxIntervalMinutes, 5, 1440, 180)
|
|
284
|
+
if (nudge.maxIntervalMinutes < nudge.minIntervalMinutes) nudge.maxIntervalMinutes = nudge.minIntervalMinutes
|
|
285
|
+
nudge.unansweredLimit = clampInt(nudge.unansweredLimit, 0, 20, 3)
|
|
279
286
|
nudge.dailyCap = clampInt(nudge.dailyCap, 0, 50, 2)
|
|
280
287
|
nudge.monthlyCap = clampInt(nudge.monthlyCap, 0, 1000, 60)
|
|
281
288
|
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.5'
|
|
51
51
|
|
|
52
52
|
// 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
|
|
53
53
|
// 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
|
|
@@ -1671,6 +1671,14 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1671
1671
|
}
|
|
1672
1672
|
})
|
|
1673
1673
|
|
|
1674
|
+
if (sourceKind === 'user') {
|
|
1675
|
+
try {
|
|
1676
|
+
nudge.noteUserMessage(userId)
|
|
1677
|
+
} catch (error) {
|
|
1678
|
+
logger.warn(`重設主動傳訊倒數失敗:${errorText(error)}`)
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1674
1682
|
const personaResult = await handlePersonaCommand({
|
|
1675
1683
|
ownerKind: sourceKind,
|
|
1676
1684
|
ownerId: conversationKey,
|
|
@@ -1858,9 +1866,11 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1858
1866
|
|
|
1859
1867
|
async function sendPush(userId, text) {
|
|
1860
1868
|
const { messages } = await buildOutgoingMessages(text)
|
|
1861
|
-
if (messages.length === 0) return
|
|
1869
|
+
if (messages.length === 0) return { sent: false, reason: 'empty' }
|
|
1862
1870
|
const imageCount = messages.filter((item) => typeof item !== 'string' && item.type === 'image').length
|
|
1863
1871
|
if (imageCount > 0) logger.info(`主動訊息含 ${imageCount} 張圖片`)
|
|
1872
|
+
const response = await withToken((token) => lineApi.pushMessage(token, userId, messages))
|
|
1873
|
+
logger.info(`主動訊息已送出(${messages.length} 則,LINE 回報 ${Array.isArray(response?.sentMessages) ? response.sentMessages.length : '未知'} 則)`)
|
|
1864
1874
|
store.updateState((state) => ({
|
|
1865
1875
|
...state,
|
|
1866
1876
|
users: {
|
|
@@ -1868,6 +1878,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1868
1878
|
[userId]: { ...(state.users[userId] || {}), lastAt: Date.now(), lastPushAt: Date.now() },
|
|
1869
1879
|
},
|
|
1870
1880
|
}))
|
|
1881
|
+
return { sent: true, count: messages.length, requestId: response?.requestId || '' }
|
|
1871
1882
|
}
|
|
1872
1883
|
|
|
1873
1884
|
const nudge = createNudgeScheduler({
|
|
@@ -2169,6 +2180,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2169
2180
|
pictureUrl: user.pictureUrl || '',
|
|
2170
2181
|
messages: Number(user.messages) || 0,
|
|
2171
2182
|
nudges: Number(user.nudges) || 0,
|
|
2183
|
+
nudgeNextAt: Number(user.nudgeNextAt) || 0,
|
|
2184
|
+
nudgeUnanswered: Number(user.nudgeUnanswered) || 0,
|
|
2185
|
+
nudgePaused: Boolean(user.nudgePaused),
|
|
2186
|
+
nudgeLastAt: Number(user.nudgeLastAt) || 0,
|
|
2172
2187
|
lastAt: Number(user.lastAt) || 0,
|
|
2173
2188
|
lastUserAt: Number(user.lastUserAt) || 0,
|
|
2174
2189
|
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
|
}
|