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 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` | webhook 指到舊 tunnel;LINE「Use webhook」沒開;伺服器沒啟動 | `doctor --fix`;面板「套用 Webhook」;到 LINE console 開 Use 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 | The DSH session each LINE user maps to, their own workspace folder, their current persona (editable), message count, last interaction, clear memory / reset session |
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
- - This is equivalent to attaching the DSH Agent to a public LINE account: anyone who has the bot can make the Agent use tools on your computer. It is recommended to fill in your own LINE userId under "Allow-listed user IDs" so only you can use it.
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
- | 使用者 | 每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
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
- - 這等同把 DSH Agent 接到公開的 LINE 帳號:拿到 bot 的人可以讓 Agent 使用你電腦上的工具。建議在「白名單使用者 ID」填入自己的 LINE userId,只允許自己使用。
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(Field, { label: '白名單使用者 ID', hint: '一行一個;留空=任何加 bot 的人都能用。可從下方「使用者」清單複製。' }, h(Area, { value: textList(line.allowedUserIds), onChange: (value) => update({ line: { allowedUserIds: parseList(value) } }), rows: 2, mono: true })),
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.2'
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 === 0 || list.includes(userId)
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 (!isAllowed(userId, cfg)) return
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",