dsh-line-bot 0.9.2 → 0.9.4
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 +4 -3
- package/README.ja.md +2 -1
- package/README.ko.md +2 -1
- package/README.md +4 -3
- package/README.zh-CN.md +2 -1
- package/client.js +29 -1
- package/lib/config.mjs +4 -1
- package/lib/host.mjs +111 -11
- package/lib/nudge.mjs +2 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -23,7 +23,7 @@ curl -s http://127.0.0.1:8791/health
|
|
|
23
23
|
|
|
24
24
|
| 症狀 | 先看哪個 check | 常見原因 | 安全修法 |
|
|
25
25
|
| --- | --- | --- | --- |
|
|
26
|
-
| 完全沒回覆 | `server`、`tunnel`、`webhook
|
|
26
|
+
| 完全沒回覆 | `server`、`tunnel`、`webhook`、面板「使用者」待審核 | webhook 指到舊 tunnel;LINE「Use webhook」沒開;伺服器沒啟動;使用者還沒核准 | `doctor --fix`;面板「套用 Webhook」;到 LINE console 開 Use webhook;在「使用者」核准對方 |
|
|
27
27
|
| 有回文字但沒圖片 | `dsh-meme`、`meme-config`、`public-url`、`media`、`converters` | 沒裝 dsh-meme;`stickers.enabled=false`;公開網址還沒就緒;GIF/webp 沒有轉圖工具 | 面板「表情包」開啟並看圖庫;`doctor --fix`;安裝 ffmpeg |
|
|
28
28
|
| 回覆出現 `[[meme:…]]`、`<meme#18>` 文字 | 版本 | 舊版不認得標記格式 | 升級到 0.6.1+(目前 0.9.x) |
|
|
29
29
|
| 貼圖/圖片看不懂 | `model`、`vision` | 模型不吃圖,或 modlens 不可用/失敗 | 面板「模型與接口來源」換成有 `text+image` 的模型;或安裝 modlens 並設定 provider |
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.4
|
|
4
|
+
|
|
5
|
+
- 修正主動訊息(排程傳訊、餘額提醒、核准通知)只更新計數、沒有真的呼叫 LINE push API 的問題;現在會實際送出,失敗時不再累加次數。
|
|
6
|
+
- 主動訊息送出後記錄 LINE 回應的訊息數,方便日後從日誌確認。
|
|
7
|
+
|
|
8
|
+
## 0.9.3
|
|
9
|
+
|
|
10
|
+
- 身分門控改為預設關閉:白名單為空時,任何人都不能觸發 Agent;未授權的 LINE 使用者只會進入「待審核」佇列,不會建立 session、不會呼叫工具。
|
|
11
|
+
- 面板「使用者」新增待審核名單,可核准或拒絕;核准後才加入白名單並通知對方。LINE 憑證卡可切換 `approval`(待審核)/`whitelist`(直接忽略)兩種模式。
|
|
12
|
+
- 主動傳訊、餘額提醒與記憶整理只會挑選白名單內的使用者。
|
|
13
|
+
- 文件補上「第一次使用要先核准自己」與隱私說明;更新 awesome-dsh-plugin 條目描述(需要 Messaging API 頻道、只有白名單使用者能對話)。
|
|
14
|
+
|
|
3
15
|
## 0.9.2
|
|
4
16
|
|
|
5
17
|
- LINE 一般圖片:收到 `image` 訊息時透過 LINE content API 下載,模型支援圖片就直接附圖;不支援時改用 modlens 或另一個視覺模型描述。
|
package/README.en.md
CHANGED
|
@@ -81,7 +81,7 @@ Without them: no dafeiyu means only plugin-local personas; no dsh-meme means tex
|
|
|
81
81
|
2. **Fill in the panel**: under "LINE Bot → LINE credentials", paste the Channel ID and Channel Secret, click "Save changes" → "Verify and read bot info". On success you will see the bot's name and Basic ID.
|
|
82
82
|
3. **Start the webhook server**: on "Overview", click "Start all", or "Start" to bring up both the webhook server and the tunnel (auto-start is on by default).
|
|
83
83
|
4. **Open the tunnel**: for a quick tunnel just click "Start tunnel"; for a fixed URL, paste an API token under "Cloudflare Tunnel" → "Verify API token" → "Create/update tunnel and DNS" (token permissions: Account → Cloudflare Tunnel → Edit, Zone → DNS → Edit, Zone → Zone → Read). A quick tunnel's URL changes on every restart (the plugin writes the new URL back to the LINE webhook automatically); a named tunnel's URL is fixed.
|
|
84
|
-
5. **Auto-configure the webhook**: once the tunnel is ready the endpoint is PUT to LINE automatically; if that fails, click "Apply tunnel URL to webhook" and then "Test webhook". After that, add the bot as a friend in LINE and send it a message.
|
|
84
|
+
5. **Auto-configure the webhook**: once the tunnel is ready the endpoint is PUT to LINE automatically; if that fails, click "Apply tunnel URL to webhook" and then "Test webhook". After that, add the bot as a friend in LINE and send it a message; the first message lands in the pending queue — approve your userId under Users in the panel before the bot replies (the allowlist starts empty).
|
|
85
85
|
|
|
86
86
|
## Panel sections
|
|
87
87
|
|
|
@@ -101,7 +101,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
|
|
|
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
103
|
| Proactive messages | Toggle, probability, interval, daily/monthly caps, minimum gap, quiet hours, idle, target users, prompt, send a test now, statistics |
|
|
104
|
-
| Users |
|
|
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) |
|
|
107
107
|
|
|
@@ -248,7 +248,8 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
|
|
|
248
248
|
|
|
249
249
|
## Security notes
|
|
250
250
|
|
|
251
|
-
-
|
|
251
|
+
- The allowlist starts empty: unknown users are only queued as pending, with no session and no tool access, until you approve them under Users in the panel.
|
|
252
|
+
- This attaches the DSH Agent to LINE: anyone you approve can make the Agent use tools on your computer. Approve only LINE accounts you trust, and review the Users list periodically.
|
|
252
253
|
- All credentials are stored locally in `config.json` (600); the panel only shows masked values; the API only listens on the DSH webServer at `127.0.0.1`.
|
|
253
254
|
- Give the Cloudflare API token only the permissions it needs, and revoke it when you no longer use it.
|
|
254
255
|
|
package/README.ja.md
CHANGED
|
@@ -81,7 +81,7 @@ npx dsh-line-bot install --no-companions
|
|
|
81
81
|
2. **パネルに入力**:「LINE Bot → LINE 認証情報」に Channel ID と Channel Secret を貼り、「変更を保存」→「検証して bot 情報を取得」を押します。成功すると bot 名と Basic ID が表示されます。
|
|
82
82
|
3. **webhook サーバーを起動**:「概要」で「すべて起動」を押すか、「起動」を押して webhook サーバーとトンネルを両方立ち上げます(既定で自動起動します)。
|
|
83
83
|
4. **トンネルを開く**:Quick tunnel なら「トンネルを起動」を押すだけ。固定 URL が必要なら「Cloudflare Tunnel」で API トークンを貼り →「API トークンを検証」→「トンネルと DNS を作成/更新」(トークン権限:Account → Cloudflare Tunnel → Edit、Zone → DNS → Edit、Zone → Zone → Read)。quick tunnel は再起動のたびに URL が変わります(プラグインが新しい URL を LINE webhook に自動で書き戻します)。名前付きトンネルの URL は固定です。
|
|
84
|
-
5. **webhook を自動設定**:トンネルの準備ができると自動で LINE に PUT します。失敗したら「トンネル URL を webhook に適用」→「webhook をテスト」を押します。あとは LINE で bot
|
|
84
|
+
5. **webhook を自動設定**:トンネルの準備ができると自動で LINE に PUT します。失敗したら「トンネル URL を webhook に適用」→「webhook をテスト」を押します。あとは LINE で bot を友だち追加してメッセージを送ります。最初のメッセージは「承認待ち」に入るので、パネルの「ユーザー」で自分の userId を承認してください(許可リストは初期状態では空です)。
|
|
85
85
|
|
|
86
86
|
## パネルの各セクション
|
|
87
87
|
|
|
@@ -248,6 +248,7 @@ Bot がグループ(または複数人のルーム)に追加されたとき
|
|
|
248
248
|
|
|
249
249
|
## セキュリティ上の注意
|
|
250
250
|
|
|
251
|
+
- 許可リストは初期状態では空です。知らないユーザーのメッセージは承認待ちに入るだけで、セッションも作られず、ツールも呼ばれません。パネルの「ユーザー」で承認すると返信を始めます。
|
|
251
252
|
- これは DSH Agent を公開された LINE アカウントにつなぐのと同じです。bot を知っている人は、あなたのコンピューター上のツールを Agent に使わせられます。「許可リストのユーザー ID」に自分の LINE userId を入れて、自分だけが使えるようにすることをおすすめします。
|
|
252
253
|
- 認証情報はすべてローカルの `config.json`(600)に保存され、パネルにはマスクされた値だけが表示されます。API は `127.0.0.1` の DSH webServer にのみ公開されます。
|
|
253
254
|
- Cloudflare API トークンは必要最小限の権限にし、使わなくなったら取り消してください。
|
package/README.ko.md
CHANGED
|
@@ -81,7 +81,7 @@ npx dsh-line-bot install --no-companions
|
|
|
81
81
|
2. **패널에 입력**: "LINE Bot → LINE 자격 증명"에 Channel ID와 Channel Secret을 붙여 넣고 "변경 저장" → "검증하고 bot 정보 읽기"를 누릅니다. 성공하면 bot 이름과 Basic ID가 표시됩니다.
|
|
82
82
|
3. **webhook 서버 시작**: "개요"에서 "모두 시작"을 누르거나 "시작"을 눌러 webhook 서버와 터널을 함께 올립니다(기본값은 자동 시작).
|
|
83
83
|
4. **터널 열기**: Quick tunnel은 "터널 시작"만 누르면 됩니다. 고정 URL이 필요하면 "Cloudflare Tunnel"에서 API 토큰을 붙여 넣고 → "API 토큰 검증" → "터널과 DNS 생성/업데이트"(토큰 권한: Account → Cloudflare Tunnel → Edit, Zone → DNS → Edit, Zone → Zone → Read). quick tunnel은 재시작할 때마다 URL이 바뀌며(플러그인이 새 URL을 LINE webhook에 자동으로 다시 씁니다), 이름 있는 터널의 URL은 고정입니다.
|
|
84
|
-
5. **webhook 자동 설정**: 터널이 준비되면 자동으로 LINE에 PUT합니다. 실패하면 "터널 URL을 webhook에 적용" → "webhook 테스트"를 누릅니다. 그다음 LINE에서 bot을 친구 추가하고 메시지를
|
|
84
|
+
5. **webhook 자동 설정**: 터널이 준비되면 자동으로 LINE에 PUT합니다. 실패하면 "터널 URL을 webhook에 적용" → "webhook 테스트"를 누릅니다. 그다음 LINE에서 bot을 친구 추가하고 메시지를 보냅니다. 첫 메시지는 "승인 대기"로 들어가며, 패널의 "사용자"에서 자신의 userId를 승인해야 응답합니다(허용 목록은 기본적으로 비어 있습니다).
|
|
85
85
|
|
|
86
86
|
## 패널 구성
|
|
87
87
|
|
|
@@ -246,6 +246,7 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
|
|
|
246
246
|
|
|
247
247
|
## 보안 안내
|
|
248
248
|
|
|
249
|
+
- 허용 목록은 기본적으로 비어 있습니다. 모르는 사용자의 메시지는 승인 대기로만 들어가며, 세션이 만들어지거나 도구가 호출되지 않습니다. 패널의 "사용자"에서 승인해야 응답합니다.
|
|
249
250
|
- 이는 DSH Agent를 공개된 LINE 계정에 연결하는 것과 같습니다. bot을 아는 사람은 당신의 컴퓨터에 있는 도구를 Agent가 사용하게 할 수 있습니다. "허용 목록 사용자 ID"에 자신의 LINE userId를 넣어 자신만 사용하도록 하는 것을 권장합니다.
|
|
250
251
|
- 모든 자격 증명은 로컬 `config.json`(600)에 저장되고 패널에는 마스킹된 값만 보입니다. API는 `127.0.0.1`의 DSH webServer에만 열립니다.
|
|
251
252
|
- Cloudflare API 토큰은 필요한 권한만 주고, 사용하지 않을 때는 폐기하세요.
|
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ npx dsh-line-bot install --no-companions
|
|
|
81
81
|
2. **填入面板**:「LINE Bot → LINE 憑證」貼上 Channel ID、Channel Secret,按「儲存變更」→「驗證並讀取 bot 資訊」。成功會看到 bot 名稱與 Basic ID。
|
|
82
82
|
3. **啟動 webhook 伺服器**:面板「狀態總覽」按「啟動全部」,或只按「啟動」讓 webhook server 與 tunnel 都起來(預設會自動啟動)。
|
|
83
83
|
4. **開隧道**:Quick tunnel 直接按「啟動隧道」;要固定網址就在「Cloudflare Tunnel」貼 API Token →「驗證 API Token」→「建立/更新隧道與 DNS」(API Token 權限:Account → Cloudflare Tunnel → Edit、Zone → DNS → Edit、Zone → Zone → Read)。quick tunnel 每次重啟網址都會變(插件會自動把新網址寫回 LINE webhook);具名隧道的網址則固定。
|
|
84
|
-
5. **自動設定 webhook**:隧道就緒後會自動 PUT 到 LINE;若沒成功,按「把隧道網址套到 webhook」再「測試 webhook」。之後在 LINE 加 bot
|
|
84
|
+
5. **自動設定 webhook**:隧道就緒後會自動 PUT 到 LINE;若沒成功,按「把隧道網址套到 webhook」再「測試 webhook」。之後在 LINE 加 bot 好友、傳訊息;第一次訊息會進入「待審核」,到面板「使用者」按「核准」後才會開始回覆(白名單預設是空的)。
|
|
85
85
|
|
|
86
86
|
## 面板各區
|
|
87
87
|
|
|
@@ -101,7 +101,7 @@ npx dsh-line-bot install --no-companions
|
|
|
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 重新載入 |
|
|
107
107
|
|
|
@@ -248,7 +248,8 @@ Bot 被加進群組(或多人聊天室)後:
|
|
|
248
248
|
|
|
249
249
|
## 安全提醒
|
|
250
250
|
|
|
251
|
-
-
|
|
251
|
+
- 白名單預設是空的:陌生人傳訊息只會進入待審核,不會建立 session、也不會呼叫工具;在面板「使用者」核准後才會開始回覆。
|
|
252
|
+
- 這等同把 DSH Agent 接到 LINE:核准名單內的人可以讓 Agent 使用你電腦上的工具。請只核准你信任的 LINE 帳號,並定期檢查「使用者」清單。
|
|
252
253
|
- 所有憑證都存在本機 `config.json`(600),面板只會看到遮罩後的值;API 只掛在 `127.0.0.1` 的 DSH webServer 上。
|
|
253
254
|
- Cloudflare API Token 建議只給必要權限,並在不用時撤銷。
|
|
254
255
|
|
package/README.zh-CN.md
CHANGED
|
@@ -81,7 +81,7 @@ npx dsh-line-bot install --no-companions
|
|
|
81
81
|
2. **填入面板**:「LINE Bot → LINE 凭证」贴入 Channel ID、Channel Secret,按「保存更改」→「验证并读取 bot 信息」。成功会看到 bot 名称与 Basic ID。
|
|
82
82
|
3. **启动 webhook 服务器**:面板「状态总览」按「启动全部」,或只按「启动」让 webhook server 与 tunnel 都起来(默认会自动启动)。
|
|
83
83
|
4. **开隧道**:Quick tunnel 直接按「启动隧道」;要固定网址就在「Cloudflare Tunnel」贴 API Token →「验证 API Token」→「创建/更新隧道与 DNS」(API Token 权限:Account → Cloudflare Tunnel → Edit、Zone → DNS → Edit、Zone → Zone → Read)。quick tunnel 每次重启网址都会变(插件会自动把新网址写回 LINE webhook);具名隧道的网址则固定。
|
|
84
|
-
5. **自动设置 webhook**:隧道就绪后会自动 PUT 到 LINE;若没成功,按「把隧道网址套到 webhook」再「测试 webhook」。之后在 LINE 加 bot
|
|
84
|
+
5. **自动设置 webhook**:隧道就绪后会自动 PUT 到 LINE;若没成功,按「把隧道网址套到 webhook」再「测试 webhook」。之后在 LINE 加 bot 好友、传消息;第一条消息会进入「待审核」,到面板「使用者」按「核准」后才会开始回复(白名单默认为空)。
|
|
85
85
|
|
|
86
86
|
## 面板各区
|
|
87
87
|
|
|
@@ -248,6 +248,7 @@ Bot 被加入群组(或多人聊天室)后:
|
|
|
248
248
|
|
|
249
249
|
## 安全提醒
|
|
250
250
|
|
|
251
|
+
- 白名单默认为空:陌生人发消息只会进入待审核,不会建立 session、不会调用工具;在面板「使用者」核准后才会开始回复。
|
|
251
252
|
- 这等同把 DSH Agent 接到公开的 LINE 账号:拿到 bot 的人可以让 Agent 使用你电脑上的工具。建议在「白名单用户 ID」填入自己的 LINE userId,只允许自己使用。
|
|
252
253
|
- 所有凭证都存在本机 `config.json`(600),面板只会看到遮罩后的值;API 只挂在 `127.0.0.1` 的 DSH webServer 上。
|
|
253
254
|
- Cloudflare API Token 建议只给必要权限,并在不用时撤销。
|
package/client.js
CHANGED
|
@@ -304,6 +304,12 @@ window.__ModuleLoader__.load({
|
|
|
304
304
|
|
|
305
305
|
const ACTION_LABELS = {
|
|
306
306
|
'meme-stats': '讀取表情包圖庫',
|
|
307
|
+
'access-list': '讀取待審核名單',
|
|
308
|
+
'access-mode': '切換身分門控',
|
|
309
|
+
'access-approve': '核准使用者',
|
|
310
|
+
'access-deny': '拒絕使用者',
|
|
311
|
+
'access-add': '加入白名單',
|
|
312
|
+
'access-remove': '移出白名單',
|
|
307
313
|
start: '啟動',
|
|
308
314
|
stop: '停止',
|
|
309
315
|
restart: '重新啟動',
|
|
@@ -724,6 +730,7 @@ window.__ModuleLoader__.load({
|
|
|
724
730
|
|
|
725
731
|
const cfg = draft
|
|
726
732
|
const line = cfg.line || {}
|
|
733
|
+
const access = status.access || {}
|
|
727
734
|
const server = cfg.server || {}
|
|
728
735
|
const tunnel = cfg.tunnel || {}
|
|
729
736
|
const cloudflare = cfg.cloudflare || {}
|
|
@@ -991,7 +998,16 @@ window.__ModuleLoader__.load({
|
|
|
991
998
|
h(Btn, { onClick: () => act('webhook-test'), disabled: !!busy }, '測試 webhook'),
|
|
992
999
|
),
|
|
993
1000
|
h('div', { style: S.hint }, '「Use webhook」開關只能在 LINE console 的 Messaging API 設定頁手動開啟;端點本身由插件自動設定。'),
|
|
994
|
-
h(
|
|
1001
|
+
h(
|
|
1002
|
+
'div',
|
|
1003
|
+
{ style: S.grid },
|
|
1004
|
+
h(Field, { label: '身分門控', hint: 'approval=未授權者進待審核;whitelist=直接忽略未授權者' },
|
|
1005
|
+
h('select', { value: line.accessMode || 'approval', onChange: (event) => update({ line: { accessMode: event.target.value } }), style: S.input },
|
|
1006
|
+
h('option', { value: 'approval' }, 'approval(待審核)'),
|
|
1007
|
+
h('option', { value: 'whitelist' }, 'whitelist(直接拒絕)'))),
|
|
1008
|
+
h(Field, { label: '白名單使用者 ID', hint: '一行一個;留空=沒有人能使用。未授權者會出現在下方「使用者」待審核。' }, h(Area, { value: textList(line.allowedUserIds), onChange: (value) => update({ line: { allowedUserIds: parseList(value) } }), rows: 2, mono: true })),
|
|
1009
|
+
),
|
|
1010
|
+
h('div', { style: access.gateOpen ? S.hint : S.warnBox }, access.gateOpen ? `目前已核准 ${(access.allowedUserIds || []).length} 人;未授權者不能觸發 Agent。` : '白名單目前是空的:bot 不會回覆任何人。請先讓使用者傳訊息,再到下方「使用者」核准。'),
|
|
995
1011
|
),
|
|
996
1012
|
|
|
997
1013
|
// 3. Cloudflare Tunnel
|
|
@@ -1289,6 +1305,18 @@ window.__ModuleLoader__.load({
|
|
|
1289
1305
|
h(
|
|
1290
1306
|
Card,
|
|
1291
1307
|
{ id: sectionDomId('users'), order: 12, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
|
|
1308
|
+
(access.pending || []).length > 0
|
|
1309
|
+
? h('div', { style: S.panelBox },
|
|
1310
|
+
h('div', { style: S.fieldTitle }, `待審核(${access.pending.length})`),
|
|
1311
|
+
h('div', { style: S.hint }, '這些人的訊息不會觸發 Agent;核准後會加入白名單並通知對方。'),
|
|
1312
|
+
(access.pending || []).map((item) => h('div', { key: item.userId, style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 0', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
1313
|
+
h('div', { style: { flex: 1, minWidth: 0 } },
|
|
1314
|
+
h('div', null, item.displayName || '(未命名)'),
|
|
1315
|
+
h('div', { style: { ...S.hint, ...S.mono, wordBreak: 'break-all' } }, item.userId),
|
|
1316
|
+
item.lastText ? h('div', { style: S.hint }, item.lastText) : null),
|
|
1317
|
+
h(Btn, { onClick: () => act('access-approve', { userId: item.userId }), disabled: !!busy, tone: 'primary' }, '核准'),
|
|
1318
|
+
h(Btn, { onClick: () => act('access-deny', { userId: item.userId }), disabled: !!busy }, '拒絕'))))
|
|
1319
|
+
: null,
|
|
1292
1320
|
(status.users || []).length === 0
|
|
1293
1321
|
? h('div', { style: S.hint }, '還沒有人傳訊息給 bot。')
|
|
1294
1322
|
: h('table', { style: S.table },
|
package/lib/config.mjs
CHANGED
|
@@ -27,6 +27,7 @@ export const DEFAULT_CONFIG = {
|
|
|
27
27
|
loadingEnabled: true,
|
|
28
28
|
stickerVision: true,
|
|
29
29
|
imageVision: true,
|
|
30
|
+
accessMode: 'approval', // approval | whitelist
|
|
30
31
|
allowedUserIds: [],
|
|
31
32
|
welcomeText: '(已加好友)嗨,我是大肥魚~想聊什麼都可以跟我說。',
|
|
32
33
|
errorReply: '(剛剛卡住了,再說一次好嗎?)',
|
|
@@ -194,7 +195,8 @@ export function sanitizePatch(current, patch) {
|
|
|
194
195
|
next.line.channelId = String(line.channelId || '').trim()
|
|
195
196
|
next.line.accessToken = String(next.line.accessToken || '').trim()
|
|
196
197
|
next.line.tokenExpiresAt = clampInt(next.line.tokenExpiresAt, 0, Number.MAX_SAFE_INTEGER, 0)
|
|
197
|
-
next.line.allowedUserIds = safeArray(next.line.allowedUserIds)
|
|
198
|
+
next.line.allowedUserIds = safeArray(next.line.allowedUserIds).map((id) => String(id).trim()).filter(Boolean)
|
|
199
|
+
next.line.accessMode = ['approval','whitelist'].includes(next.line.accessMode) ? next.line.accessMode : 'approval'
|
|
198
200
|
next.line.loadingEnabled = Boolean(next.line.loadingEnabled)
|
|
199
201
|
next.line.stickerVision = Boolean(next.line.stickerVision)
|
|
200
202
|
next.line.imageVision = Boolean(next.line.imageVision)
|
|
@@ -349,6 +351,7 @@ export function normalizeState(raw) {
|
|
|
349
351
|
users: isPlainObject(state.users) ? state.users : {},
|
|
350
352
|
groups: isPlainObject(state.groups) ? state.groups : {},
|
|
351
353
|
people: isPlainObject(state.people) ? state.people : {},
|
|
354
|
+
pendingUsers: isPlainObject(state.pendingUsers) ? state.pendingUsers : {},
|
|
352
355
|
nudge: isPlainObject(state.nudge) ? state.nudge : {},
|
|
353
356
|
balance: isPlainObject(state.balance) ? state.balance : {},
|
|
354
357
|
memory: isPlainObject(state.memory) ? state.memory : {},
|
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.4'
|
|
51
51
|
|
|
52
52
|
// 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
|
|
53
53
|
// 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
|
|
@@ -364,7 +364,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
364
364
|
const cooldownMs = Math.max(1, Number(cfg.balance.cooldownHours) || 12) * 3600_000
|
|
365
365
|
if (!force && lastAskAt && Date.now() - lastAskAt < cooldownMs) return { skipped: 'cooldown', lastAskAt }
|
|
366
366
|
if (!force && inQuietWindow(Date.now(), cfg.nudge.quietStart, cfg.nudge.quietEnd)) return { skipped: 'quiet-hours' }
|
|
367
|
-
const candidates = Object.entries(state.users || {}).filter(([, user]) => user?.sessionId)
|
|
367
|
+
const candidates = Object.entries(state.users || {}).filter(([userId, user]) => user?.sessionId && isAllowed(userId, cfg))
|
|
368
368
|
const targetIds = safeArray(cfg.balance?.targetUserIds).length ? safeArray(cfg.balance?.targetUserIds) : safeArray(cfg.nudge?.targetUserIds)
|
|
369
369
|
const preferred = targetIds.find((id) => candidates.some(([key]) => key === id))
|
|
370
370
|
const picked = preferred ? candidates.find(([key]) => key === preferred) : [...candidates].sort((a, b) => (a[1].lastAt || 0) - (b[1].lastAt || 0))[0]
|
|
@@ -447,7 +447,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
447
447
|
const state = store.getState()
|
|
448
448
|
const targets = userId
|
|
449
449
|
? [[userId, state.users?.[userId]]]
|
|
450
|
-
: Object.entries(state.users || {})
|
|
450
|
+
: Object.entries(state.users || {}).filter(([id]) => isAllowed(id, cfg))
|
|
451
451
|
const reviewed = []
|
|
452
452
|
for (const [id, user] of targets) {
|
|
453
453
|
if (!user?.sessionId) continue
|
|
@@ -992,9 +992,77 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
992
992
|
})
|
|
993
993
|
}
|
|
994
994
|
|
|
995
|
-
function isAllowed(userId, cfg) {
|
|
995
|
+
function isAllowed(userId, cfg = store.get()) {
|
|
996
996
|
const list = cfg.line.allowedUserIds || []
|
|
997
|
-
return list.length
|
|
997
|
+
return list.length > 0 && list.includes(userId)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function pendingUsersList() {
|
|
1001
|
+
return Object.values(store.getState().pendingUsers || {}).sort((a, b) => (Number(b.at) || 0) - (Number(a.at) || 0))
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function queuePendingUser(userId, patch = {}) {
|
|
1005
|
+
const existing = store.getState().pendingUsers?.[userId]
|
|
1006
|
+
if (existing) {
|
|
1007
|
+
if (patch.displayName || patch.lastText) {
|
|
1008
|
+
store.updateState((state) => ({
|
|
1009
|
+
...state,
|
|
1010
|
+
pendingUsers: {
|
|
1011
|
+
...(state.pendingUsers || {}),
|
|
1012
|
+
[userId]: { ...existing, displayName: patch.displayName || existing.displayName || '', lastText: patch.lastText || existing.lastText || '' },
|
|
1013
|
+
},
|
|
1014
|
+
}))
|
|
1015
|
+
}
|
|
1016
|
+
return existing
|
|
1017
|
+
}
|
|
1018
|
+
const entry = { userId, displayName: patch.displayName || '', lastText: patch.lastText || '', at: Date.now() }
|
|
1019
|
+
store.updateState((state) => ({ ...state, pendingUsers: { ...(state.pendingUsers || {}), [userId]: entry } }))
|
|
1020
|
+
logger.warn(`待審核使用者 ${userId}${entry.displayName ? `(${entry.displayName})` : ''}`)
|
|
1021
|
+
return entry
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function removePendingUser(userId) {
|
|
1025
|
+
store.updateState((state) => {
|
|
1026
|
+
if (!state.pendingUsers?.[userId]) return state
|
|
1027
|
+
const next = { ...state.pendingUsers }
|
|
1028
|
+
delete next[userId]
|
|
1029
|
+
return { ...state, pendingUsers: next }
|
|
1030
|
+
})
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async function requireAccess(userId, { replyToken = '', sourceKind = 'user', text = '' } = {}) {
|
|
1034
|
+
const cfg = store.get()
|
|
1035
|
+
if (isAllowed(userId, cfg)) return true
|
|
1036
|
+
const mode = cfg.line.accessMode || 'approval'
|
|
1037
|
+
const allowed = cfg.line.allowedUserIds || []
|
|
1038
|
+
logger.warn(`拒絕未授權使用者 ${userId}(accessMode=${mode},白名單 ${allowed.length} 人)`)
|
|
1039
|
+
if (mode !== 'approval') return false
|
|
1040
|
+
const first = !store.getState().pendingUsers?.[userId]
|
|
1041
|
+
const displayName = await ensurePerson(userId).catch(() => '')
|
|
1042
|
+
queuePendingUser(userId, { displayName: displayName || personName(userId), lastText: clip(text || '', 160) })
|
|
1043
|
+
if (first && sourceKind === 'user') {
|
|
1044
|
+
await replyOrPush(userId, replyToken, '已送出使用申請;等主人核准後就能聊天。').catch((error) => logger.warn(`待審核通知失敗:${errorText(error)}`))
|
|
1045
|
+
}
|
|
1046
|
+
return false
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function approveUser(userId) {
|
|
1050
|
+
const id = String(userId || '').trim()
|
|
1051
|
+
if (!id) throw new Error('缺少 userId')
|
|
1052
|
+
const list = safeArray(store.get().line.allowedUserIds)
|
|
1053
|
+
if (!list.includes(id)) store.patch({ line: { allowedUserIds: [...list, id] } })
|
|
1054
|
+
removePendingUser(id)
|
|
1055
|
+
logger.info(`已核准使用者 ${id}`)
|
|
1056
|
+
sendPush(id, '主人核准了,現在可以正常聊天了。').catch((error) => logger.warn(`通知核准失敗:${errorText(error)}`))
|
|
1057
|
+
return { userId: id, allowed: true, allowedUserIds: store.get().line.allowedUserIds }
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function denyUser(userId) {
|
|
1061
|
+
const id = String(userId || '').trim()
|
|
1062
|
+
if (!id) throw new Error('缺少 userId')
|
|
1063
|
+
removePendingUser(id)
|
|
1064
|
+
logger.info(`已拒絕使用者 ${id}`)
|
|
1065
|
+
return { userId: id, denied: true }
|
|
998
1066
|
}
|
|
999
1067
|
|
|
1000
1068
|
// ── LINE 端的「人設」指令(只改自己的) ────────────────────────────────
|
|
@@ -1458,7 +1526,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1458
1526
|
const sourceKind = source.type === 'group' ? 'group' : source.type === 'room' ? 'room' : 'user'
|
|
1459
1527
|
const chatId = sourceKind === 'group' ? source.groupId : sourceKind === 'room' ? source.roomId : userId
|
|
1460
1528
|
if (type === 'follow' && userId) {
|
|
1461
|
-
if (!
|
|
1529
|
+
if (!(await requireAccess(userId, { replyToken: event.replyToken, sourceKind: 'user' }))) return
|
|
1462
1530
|
const welcome = cfg.line.welcomeText || ''
|
|
1463
1531
|
if (welcome && event.replyToken) {
|
|
1464
1532
|
await replyOrPush(userId, event.replyToken, welcome).catch((error) => logger.warn(`歡迎訊息失敗:${errorText(error)}`))
|
|
@@ -1467,11 +1535,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1467
1535
|
}
|
|
1468
1536
|
if (type !== 'message') return
|
|
1469
1537
|
if (!userId || !chatId) return
|
|
1470
|
-
if (!isAllowed(userId, cfg)) {
|
|
1471
|
-
logger.warn(`略過未授權的使用者 ${userId}(allowedUserIds)`)
|
|
1472
|
-
return
|
|
1473
|
-
}
|
|
1474
1538
|
const messageType = event.message?.type
|
|
1539
|
+
if (!(await requireAccess(userId, { replyToken: event.replyToken, sourceKind, text: messageType === 'text' ? String(event.message?.text || '') : '' }))) return
|
|
1475
1540
|
const messageImages = []
|
|
1476
1541
|
let text = ''
|
|
1477
1542
|
if (messageType === 'text') {
|
|
@@ -1793,9 +1858,11 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1793
1858
|
|
|
1794
1859
|
async function sendPush(userId, text) {
|
|
1795
1860
|
const { messages } = await buildOutgoingMessages(text)
|
|
1796
|
-
if (messages.length === 0) return
|
|
1861
|
+
if (messages.length === 0) return { sent: false, reason: 'empty' }
|
|
1797
1862
|
const imageCount = messages.filter((item) => typeof item !== 'string' && item.type === 'image').length
|
|
1798
1863
|
if (imageCount > 0) logger.info(`主動訊息含 ${imageCount} 張圖片`)
|
|
1864
|
+
const response = await withToken((token) => lineApi.pushMessage(token, userId, messages))
|
|
1865
|
+
logger.info(`主動訊息已送出(${messages.length} 則,LINE 回報 ${Array.isArray(response?.sentMessages) ? response.sentMessages.length : '未知'} 則)`)
|
|
1799
1866
|
store.updateState((state) => ({
|
|
1800
1867
|
...state,
|
|
1801
1868
|
users: {
|
|
@@ -1803,6 +1870,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1803
1870
|
[userId]: { ...(state.users[userId] || {}), lastAt: Date.now(), lastPushAt: Date.now() },
|
|
1804
1871
|
},
|
|
1805
1872
|
}))
|
|
1873
|
+
return { sent: true, count: messages.length, requestId: response?.requestId || '' }
|
|
1806
1874
|
}
|
|
1807
1875
|
|
|
1808
1876
|
const nudge = createNudgeScheduler({
|
|
@@ -2225,6 +2293,12 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2225
2293
|
entries: jokes.list().slice(0, 200),
|
|
2226
2294
|
},
|
|
2227
2295
|
sync: { ...tiebaSync.stats(), config: cfg.sync },
|
|
2296
|
+
access: {
|
|
2297
|
+
mode: cfg.line.accessMode || 'approval',
|
|
2298
|
+
allowedUserIds: safeArray(cfg.line.allowedUserIds),
|
|
2299
|
+
gateOpen: safeArray(cfg.line.allowedUserIds).length > 0,
|
|
2300
|
+
pending: pendingUsersList().map((item) => ({ userId: item.userId, displayName: item.displayName || '', at: Number(item.at) || 0, lastText: item.lastText || '' })),
|
|
2301
|
+
},
|
|
2228
2302
|
users,
|
|
2229
2303
|
groups,
|
|
2230
2304
|
logs: logger.lines(),
|
|
@@ -2605,6 +2679,32 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2605
2679
|
const user = ensureUser(userId)
|
|
2606
2680
|
return { userId, sessionId: user.sessionId, personaId: user.personaId || store.get().persona.id, workspaceDir: user.workspaceDir || '', perUserWorkspace: Boolean(store.get().agent.perUserWorkspace) }
|
|
2607
2681
|
}
|
|
2682
|
+
case 'access-list':
|
|
2683
|
+
return { mode: store.get().line.accessMode || 'approval', allowedUserIds: safeArray(store.get().line.allowedUserIds), pending: pendingUsersList() }
|
|
2684
|
+
case 'access-mode': {
|
|
2685
|
+
const mode = String(payload.mode || '').trim()
|
|
2686
|
+
if (!['approval', 'whitelist'].includes(mode)) throw new Error('mode 只能是 approval 或 whitelist')
|
|
2687
|
+
store.patch({ line: { accessMode: mode } })
|
|
2688
|
+
return { mode }
|
|
2689
|
+
}
|
|
2690
|
+
case 'access-approve':
|
|
2691
|
+
return approveUser(payload.userId)
|
|
2692
|
+
case 'access-deny':
|
|
2693
|
+
return denyUser(payload.userId)
|
|
2694
|
+
case 'access-add': {
|
|
2695
|
+
const id = String(payload.userId || '').trim()
|
|
2696
|
+
if (!id) throw new Error('缺少 userId')
|
|
2697
|
+
const list = safeArray(store.get().line.allowedUserIds)
|
|
2698
|
+
if (!list.includes(id)) store.patch({ line: { allowedUserIds: [...list, id] } })
|
|
2699
|
+
removePendingUser(id)
|
|
2700
|
+
return { userId: id, allowedUserIds: store.get().line.allowedUserIds }
|
|
2701
|
+
}
|
|
2702
|
+
case 'access-remove': {
|
|
2703
|
+
const id = String(payload.userId || '').trim()
|
|
2704
|
+
const list = safeArray(store.get().line.allowedUserIds).filter((item) => item !== id)
|
|
2705
|
+
store.patch({ line: { allowedUserIds: list } })
|
|
2706
|
+
return { userId: id, allowedUserIds: list }
|
|
2707
|
+
}
|
|
2608
2708
|
case 'user-persona': {
|
|
2609
2709
|
const userId = String(payload.userId || '')
|
|
2610
2710
|
if (!userId) throw new Error('缺少 userId')
|
package/lib/nudge.mjs
CHANGED
|
@@ -71,11 +71,13 @@ export function createNudgeScheduler({ getConfig, store, logger, runTurn, sendPr
|
|
|
71
71
|
function pickUser(cfg, { force }) {
|
|
72
72
|
const state = store.getState()
|
|
73
73
|
const now = Date.now()
|
|
74
|
+
const allowed = cfg.line?.allowedUserIds || []
|
|
74
75
|
const targets = cfg.nudge.targetUserIds.length
|
|
75
76
|
? cfg.nudge.targetUserIds
|
|
76
77
|
: Object.keys(state.users || {})
|
|
77
78
|
const candidate = []
|
|
78
79
|
for (const userId of targets) {
|
|
80
|
+
if (!allowed.length || !allowed.includes(userId)) continue
|
|
79
81
|
const user = state.users?.[userId]
|
|
80
82
|
if (!user?.sessionId) continue
|
|
81
83
|
const last = Number(user.lastAt) || 0
|