dsh-line-bot 0.5.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - 支援群組與多人聊天室:被 @ 或引用(回覆某則訊息)一定回,其餘訊息依「群組回覆機率」擲骰(預設 15%)。
6
+ - 每個群組有自己的 DSH session、工作資料夾、人設與個人記憶;面板新增「群組」卡(回覆機率、開關、群組人設、清空記憶)。
7
+ - 狀態與個人記憶清單會列出群組;新增 `group-persona`/`group-reset` 動作。
8
+
9
+ ## 0.5.6
10
+
11
+ - 安裝 CLI 會偵測搭配外掛(`dsh-persona-dafeiyu`、`dsh-meme`、`dsh-tieba-dive`),可互動詢問或 `--with-companions` 一起安裝;`status` 也會顯示安裝狀態。
12
+ - README(五語言)新增「搭配外掛」章節。
13
+
3
14
  ## 0.5.5
4
15
 
5
16
  - 收到 LINE 貼圖時抓取貼圖圖片,透過 DSH 附件服務交給模型閱讀(模型不支援圖片時自動退回貼圖關鍵字)。
package/README.en.md CHANGED
@@ -56,6 +56,25 @@ Then add `dsh-line-bot` to the `dsh.profile.bundles` array in `~/.dsh/profiles/d
56
56
  >
57
57
  > Or from source: `git clone https://github.com/Hanson0901/dsh-line-bot.git`, then choose that folder in the DSH panel's "Install bundle", or run `dsh plugin --profile desktop add /path/to/dsh-line-bot`.
58
58
 
59
+ ## Companion plugins (optional)
60
+
61
+ The bot works on its own; these plugins add capabilities:
62
+
63
+ | Plugin | What it adds | Recommended |
64
+ | --- | --- | --- |
65
+ | `dsh-persona-dafeiyu` | Persona catalog and Tieba sync | Yes |
66
+ | `dsh-meme` | Image/sticker replies | Yes |
67
+ | `dsh-tieba-dive` | Source of Tieba learning sync | Optional |
68
+
69
+ The install CLI detects what is missing and offers to install it:
70
+
71
+ ```bash
72
+ npx dsh-line-bot install --with-companions
73
+ npx dsh-line-bot install --no-companions
74
+ ```
75
+
76
+ Without them: no dafeiyu means only plugin-local personas; no dsh-meme means text-only replies; no dsh-tieba-dive means Tieba sync stays off.
77
+
59
78
  ## First run (5 steps)
60
79
 
61
80
  1. **Create / obtain credentials**: go to [LINE Official Account Manager](https://manager.line.biz/) and create an Official Account → [LINE Developers Console](https://developers.line.biz/console/) and create (or pick) a Messaging API channel → open the **Use webhook** toggle on the "Messaging API" tab → copy the **Channel ID** and **Channel secret**.
@@ -82,6 +101,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
82
101
  | DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown (deepseek-account only) |
83
102
  | Proactive messages | Toggle, probability, interval, daily/monthly caps, minimum gap, quiet hours, idle, target users, prompt, send a test now, statistics |
84
103
  | 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
+ | Groups | Per-group session, workspace folder, persona, message count; reply chance when not addressed; always reply on @ / quote; clear group memory |
85
105
  | Advanced | Port, Agent provider/model/effort/cwd/timeout, one workspace per user (and its root), welcome/error/empty replies, logs, reload host modules (dev) |
86
106
 
87
107
  ## Configuration files
@@ -115,6 +135,15 @@ The "Model & provider" card chooses the provider/model used for LINE replies:
115
135
  - **Plugin custom personas**: no persona plugin required. In "Reply persona → Custom personas" you can add/edit/delete them; data lives in `<DSH_HOME>/line-bot/personas.json` (0600). When a custom persona is selected, the plugin asks dafeiyu to opt out in that session (if it is installed), then injects the custom persona into that agent's system prompt.
116
136
  - Both kinds appear in the same dropdown. The same applies when a user switches with a LINE command (`人設`, `人設 2`, `人設 <name>`).
117
137
 
138
+ ## Groups and multi-user chats
139
+
140
+ When the bot is added to a group (or a multi-user room):
141
+
142
+ - **Being @-mentioned always triggers a reply**, and **quoting any message always triggers a reply**; any other message is answered only when a dice roll against the *group reply chance* (default 15%) succeeds, otherwise it stays quiet.
143
+ - Each group gets its own **DSH session, workspace folder, persona and personal memory**, separate from direct chats and other groups.
144
+ - The `persona` command works in groups too; the panel's **Groups** card lets you tune the reply chance, toggles, per-group persona and clear a group's memory.
145
+ - Proactive messages and DeepSeek balance reminders still target direct chats only.
146
+
118
147
  ## Per-user memory and personas
119
148
 
120
149
  - **Memory is not shared**: every LINE user gets their own DSH session and their own Agent workspace folder `<DSH_HOME>/line-bot/users/<LINE userId>/`. The files the Agent sees, `AGENTS.md`, and workspace memory are specific to that user; nobody reads anyone else's.
package/README.ja.md CHANGED
@@ -56,6 +56,25 @@ dsh plugin --profile desktop add dsh-line-bot
56
56
  >
57
57
  > ソースからなら:`git clone https://github.com/Hanson0901/dsh-line-bot.git` して、DSH パネルの「bundle をインストール」でそのフォルダを選ぶか、`dsh plugin --profile desktop add /path/to/dsh-line-bot` を実行します。
58
58
 
59
+ ## 連携プラグイン(任意)
60
+
61
+ LINE Bot は単体でも動きます。以下を入れると機能が増えます:
62
+
63
+ | プラグイン | 追加される機能 | 推奨 |
64
+ | --- | --- | --- |
65
+ | `dsh-persona-dafeiyu` | ペルソナ一覧と貼吧同期 | 推奨 |
66
+ | `dsh-meme` | 画像/スタンプ返信 | 推奨 |
67
+ | `dsh-tieba-dive` | 貼吧学習同期の取得元 | 任意 |
68
+
69
+ インストール CLI は不足を検出して、一緒に入れるか確認します:
70
+
71
+ ```bash
72
+ npx dsh-line-bot install --with-companions
73
+ npx dsh-line-bot install --no-companions
74
+ ```
75
+
76
+ ない場合の影響:大肥魚なし → プラグイン内のペルソナのみ、dsh-meme なし → テキストのみ、貼吧プラグインなし → 貼吧同期は停止。
77
+
59
78
  ## 初回セットアップ(5 ステップ)
60
79
 
61
80
  1. **認証情報を用意**:[LINE Official Account Manager](https://manager.line.biz/) で公式アカウントを作成 → [LINE Developers Console](https://developers.line.biz/console/) で Messaging API チャネルを作成(または選択)→ 「Messaging API」タブで **Use webhook** をオン → **Channel ID** と **Channel secret** をコピーします。
@@ -82,6 +101,7 @@ dsh plugin --profile desktop add dsh-line-bot
82
101
  | DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン(deepseek-account のみ) |
83
102
  | 自発メッセージ | オン/オフ、確率、間隔、1 日/1 か月の上限、最短間隔、静かな時間帯、idle、対象ユーザー、プロンプト、今すぐ試送、統計 |
84
103
  | ユーザー | LINE ユーザーごとに対応する DSH セッション、専用ワークスペース、現在のペルソナ(直接変更可)、メッセージ数、最終やりとり、メモリ消去/セッション再作成 |
104
+ | グループ | グループごとのセッション、ワークスペース、ペルソナ、メッセージ数;呼ばれていないときの返信確率;@/引用時は必ず返信;グループのメモリ消去 |
85
105
  | 詳細設定 | ポート、Agent の provider/model/effort/cwd/タイムアウト、ユーザーごとのワークスペース(ルート含む)、ウェルカム/エラー/空返信、ログ、開発用 host リロード |
86
106
 
87
107
  ## 設定ファイル
@@ -115,6 +135,15 @@ dsh plugin --profile desktop add dsh-line-bot
115
135
  - **プラグイン内蔵のカスタムペルソナ**:ペルソナプラグインは不要です。「返信ペルソナ → カスタムペルソナ」で追加・編集・削除でき、データは `<DSH_HOME>/line-bot/personas.json`(0600)に保存されます。カスタムペルソナを選ぶと、プラグインは(インストールされていれば)大肥魚にそのセッションでの停止を依頼し、カスタムペルソナをその agent の system prompt に注入します。
116
136
  - どちらも同じドロップダウンから選べます。ユーザーが LINE コマンドで切り替える場合も同じです(`人設`、`人設 2`、`人設 名前`)。
117
137
 
138
+ ## グループと複数人チャット
139
+
140
+ Bot がグループ(または複数人のルーム)に追加されたとき:
141
+
142
+ - **@ で呼ばれたら必ず返信**、**引用(返信)されたら必ず返信**します。それ以外は「グループ返信確率」(既定 15%)の抽選に当たったときだけ返信します。
143
+ - グループごとに **DSH セッション・ワークスペース・ペルソナ・個人メモリ** を持ち、1 対 1 のチャットや他のグループとは分かれています。
144
+ - グループでも `人設` コマンドが使えます。パネルの「グループ」カードで返信確率・スイッチ・グループごとのペルソナ・メモリの消去を設定できます。
145
+ - 自発メッセージと DeepSeek 残高の通知は 1 対 1 の相手にだけ送られます。
146
+
118
147
  ## ユーザーごとのメモリとペルソナ
119
148
 
120
149
  - **メモリは共有されません**:LINE ユーザーごとに専用の DSH セッションと、専用の Agent ワークスペース `<DSH_HOME>/line-bot/users/<LINE userId>/` を持ちます。Agent が見るファイル、`AGENTS.md`、ワークスペースメモリはそのユーザー専用で、他人のものは読みません。
package/README.ko.md CHANGED
@@ -56,6 +56,25 @@ dsh plugin --profile desktop add dsh-line-bot
56
56
  >
57
57
  > 소스에서 설치하려면 `git clone https://github.com/Hanson0901/dsh-line-bot.git` 후 DSH 패널의 "bundle 설치"에서 그 폴더를 선택하거나 `dsh plugin --profile desktop add /path/to/dsh-line-bot`을 실행합니다.
58
58
 
59
+ ## 함께 쓰는 플러그인 (선택)
60
+
61
+ LINE Bot은 단독으로도 동작하며, 아래 플러그인을 설치하면 기능이 늘어납니다:
62
+
63
+ | 플러그인 | 추가 기능 | 권장 |
64
+ | --- | --- | --- |
65
+ | `dsh-persona-dafeiyu` | 페르소나 목록, 톄바 동기화 | 권장 |
66
+ | `dsh-meme` | 이미지/스티커 답장 | 권장 |
67
+ | `dsh-tieba-dive` | 톄바 학습 동기화 소스 | 선택 |
68
+
69
+ 설치 CLI는 없는 것을 감지해 함께 설치할지 물어봅니다:
70
+
71
+ ```bash
72
+ npx dsh-line-bot install --with-companions
73
+ npx dsh-line-bot install --no-companions
74
+ ```
75
+
76
+ 없을 때: 대비어 없음 → 플러그인 자체 페르소나만, dsh-meme 없음 → 텍스트만, 톄바 플러그인 없음 → 톄바 동기화 꺼짐.
77
+
59
78
  ## 첫 구동(5단계)
60
79
 
61
80
  1. **자격 증명 준비**: [LINE Official Account Manager](https://manager.line.biz/)에서 공식 계정 생성 → [LINE Developers Console](https://developers.line.biz/console/)에서 Messaging API 채널 생성(또는 선택) → "Messaging API" 탭에서 **Use webhook** 켜기 → **Channel ID**와 **Channel secret** 복사.
@@ -82,6 +101,7 @@ dsh plugin --profile desktop add dsh-line-bot
82
101
  | DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운(deepseek-account 전용) |
83
102
  | 능동 메시지 | 켜기/끄기, 확률, 간격, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 대상 사용자, 프롬프트, 지금 시험 발송, 통계 |
84
103
  | 사용자 | LINE 사용자별 DSH 세션, 전용 작업 폴더, 현재 페르소나(직접 변경 가능), 메시지 수, 마지막 상호작용, 기억 지우기/세션 재설정 |
104
+ | 그룹 | 그룹별 세션, 작업 폴더, 페르소나, 메시지 수; 호출되지 않았을 때 답장 확률; @/인용 시 항상 답장; 그룹 기억 지우기 |
85
105
  | 고급 설정 | 포트, Agent provider/model/effort/cwd/타임아웃, 사용자별 작업 폴더(루트 포함), 환영/오류/빈 답장, 로그, 개발용 host 리로드 |
86
106
 
87
107
  ## 설정 파일
@@ -115,6 +135,15 @@ dsh plugin --profile desktop add dsh-line-bot
115
135
  - **플러그인 내장 커스텀 페르소나**: 페르소나 플러그인이 필요 없습니다. "답장 페르소나 → 커스텀 페르소나"에서 추가/편집/삭제할 수 있고 데이터는 `<DSH_HOME>/line-bot/personas.json`(0600)에 저장됩니다. 커스텀 페르소나를 고르면 플러그인은 (설치되어 있다면) 大肥魚에게 그 세션에서 중지해 달라고 한 뒤 커스텀 페르소나를 해당 agent의 system prompt에 주입합니다.
116
136
  - 두 종류 모두 같은 드롭다운에서 선택할 수 있습니다. 사용자가 LINE 명령으로 바꾸는 경우도 같습니다(`人設`, `人設 2`, `人設 이름`).
117
137
 
138
+ ## 그룹 및 다인 채팅
139
+
140
+ Bot이 그룹(또는 다인 룸)에 추가되면:
141
+
142
+ - **@로 호출되면 항상 답장**하고, **인용(답장)되면 항상 답장**합니다. 그 외에는 "그룹 답장 확률"(기본 15%) 주사위를 굴려 당첨될 때만 답장합니다.
143
+ - 그룹마다 **DSH 세션, 작업 폴더, 페르소나, 개인 기억**이 따로 있으며 1:1 채팅이나 다른 그룹과 분리됩니다.
144
+ - 그룹에서도 `人設` 명령을 쓸 수 있습니다. 패널의 "그룹" 카드에서 답장 확률, 스위치, 그룹별 페르소나, 기억 지우기를 설정할 수 있습니다.
145
+ - 자발적 메시지와 DeepSeek 잔액 알림은 여전히 1:1 대상에게만 갑니다.
146
+
118
147
  ## 사용자별 기억과 페르소나
119
148
 
120
149
  - **기억은 공유되지 않습니다**: LINE 사용자마다 자신의 DSH 세션과 전용 Agent 작업 폴더 `<DSH_HOME>/line-bot/users/<LINE userId>/`가 있습니다. Agent가 보는 파일, `AGENTS.md`, 작업 공간 기억은 그 사용자 전용이며 다른 사람의 것은 읽지 않습니다.
package/README.md CHANGED
@@ -56,6 +56,25 @@ dsh plugin --profile desktop add dsh-line-bot
56
56
  >
57
57
  > 或從原始碼:`git clone https://github.com/Hanson0901/dsh-line-bot.git`,在 DSH 面板「安裝 bundle」選那個資料夾,或 `dsh plugin --profile desktop add /path/to/dsh-line-bot`。
58
58
 
59
+ ## 搭配外掛(可選)
60
+
61
+ LINE Bot 可以獨立運作;裝了下面這些外掛會多出對應功能:
62
+
63
+ | 外掛 | 用途 | 建議 |
64
+ | --- | --- | --- |
65
+ | `dsh-persona-dafeiyu` | 人設清單、貼吧同步 | 建議 |
66
+ | `dsh-meme` | 讓角色回圖片/貼圖 | 建議 |
67
+ | `dsh-tieba-dive` | 貼吧學習同步的來源 | 可選 |
68
+
69
+ 安裝時 CLI 會偵測缺哪些,並詢問要不要一起安裝:
70
+
71
+ ```bash
72
+ npx dsh-line-bot install --with-companions
73
+ npx dsh-line-bot install --no-companions
74
+ ```
75
+
76
+ 少了它們的影響:沒有大肥魚 → 人設只剩插件自訂的;沒有 dsh-meme → 只能回文字;沒有刷貼吧 → 貼吧同步自動停用。
77
+
59
78
  ## 第一次上線(5 步)
60
79
 
61
80
  1. **建立/取得憑證**:到 [LINE Official Account Manager](https://manager.line.biz/) 建立官方帳號 → [LINE Developers Console](https://developers.line.biz/console/) 建立(或選用)Messaging API channel → 在「Messaging API」分頁把 **Use webhook** 打開 → 複製 **Channel ID** 與 **Channel secret**。
@@ -82,6 +101,7 @@ dsh plugin --profile desktop add dsh-line-bot
82
101
  | DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻(僅 deepseek-account) |
83
102
  | 主動傳訊 | 開關、機率、間隔、每日/每月上限、最短間隔、安靜時段、idle、指定對象、提示詞、立即試傳、統計 |
84
103
  | 使用者 | 每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
104
+ | 群組 | 每個群組的 session、工作資料夾、人設、訊息數;沒被點名時的回覆機率;被 @/引用一定回;清空群組記憶 |
85
105
  | 進階設定 | 連接埠、Agent provider/model/effort/cwd/逾時、一人一個工作資料夾(含根目錄)、歡迎/錯誤/空回覆、日誌、開發用 host 重新載入 |
86
106
 
87
107
  ## 設定檔
@@ -115,6 +135,15 @@ dsh plugin --profile desktop add dsh-line-bot
115
135
  - **插件內建自訂人設**:不需要任何個性外掛。面板「回覆人設 → 自訂人設」可新增/編輯/刪除,資料存在 `<DSH_HOME>/line-bot/personas.json`(0600)。選到自訂人設時,插件會請大肥魚在該 session 停用(若它有安裝),再把自訂人設注入這個 agent 的 system prompt。
116
136
  - 兩種人設可以在同一個下拉選單裡選;使用者自己用 LINE 指令切換時也一樣(`人設`、`人設 2`、`人設 名字`)。
117
137
 
138
+ ## 群組與多人聊天室
139
+
140
+ Bot 被加進群組(或多人聊天室)後:
141
+
142
+ - **被 @ 一定回**、**被引用(回覆某則訊息)一定回**;其他訊息依「群組回覆機率」擲骰(預設 15%),沒中就安靜。
143
+ - 每個群組有自己的 **DSH session、工作資料夾、人設與個人記憶**,和一對一聊天、其他群組都分開。
144
+ - 群組裡也能用 `人設` 指令;面板「群組」卡可以調回覆機率、開關、個別群組的人設與清空記憶。
145
+ - 主動傳訊與 DeepSeek 餘額提醒仍然只找一對一的對象。
146
+
118
147
  ## 每位使用者各自的記憶與人設
119
148
 
120
149
  - **記憶不共用**:每個 LINE 使用者都有自己的 DSH session,以及自己的 Agent 工作資料夾 `<DSH_HOME>/line-bot/users/<LINE userId>/`。Agent 看到的檔案、`AGENTS.md`、工作區記憶都是該使用者專屬的,不會讀到別人的。
package/README.zh-CN.md CHANGED
@@ -56,6 +56,25 @@ dsh plugin --profile desktop add dsh-line-bot
56
56
  >
57
57
  > 或从源码:`git clone https://github.com/Hanson0901/dsh-line-bot.git`,在 DSH 面板「安装 bundle」选那个文件夹,或 `dsh plugin --profile desktop add /path/to/dsh-line-bot`。
58
58
 
59
+ ## 搭配插件(可选)
60
+
61
+ LINE Bot 可以独立运行;装上下面这些插件会多出对应功能:
62
+
63
+ | 插件 | 用途 | 建议 |
64
+ | --- | --- | --- |
65
+ | `dsh-persona-dafeiyu` | 人设清单、贴吧同步 | 建议 |
66
+ | `dsh-meme` | 让角色回图片/贴图 | 建议 |
67
+ | `dsh-tieba-dive` | 贴吧学习同步的来源 | 可选 |
68
+
69
+ 安装时 CLI 会检测缺哪些,并询问要不要一起安装:
70
+
71
+ ```bash
72
+ npx dsh-line-bot install --with-companions
73
+ npx dsh-line-bot install --no-companions
74
+ ```
75
+
76
+ 少了它们的影响:没有大肥鱼 → 人设只剩插件自定义的;没有 dsh-meme → 只能回文字;没有刷贴吧 → 贴吧同步自动停用。
77
+
59
78
  ## 第一次上线(5 步)
60
79
 
61
80
  1. **创建/取得凭证**:到 [LINE Official Account Manager](https://manager.line.biz/) 创建官方账号 → [LINE Developers Console](https://developers.line.biz/console/) 创建(或选用)Messaging API channel → 在「Messaging API」分页把 **Use webhook** 打开 → 复制 **Channel ID** 与 **Channel secret**。
@@ -82,6 +101,7 @@ dsh plugin --profile desktop add dsh-line-bot
82
101
  | DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却(仅 deepseek-account) |
83
102
  | 主动传讯 | 开关、概率、间隔、每日/每月上限、最短间隔、安静时段、idle、指定对象、提示词、立即试传、统计 |
84
103
  | 用户 | 每位 LINE 用户对应的 DSH session、专属工作文件夹、目前人设(可直接改)、消息数、上次互动、清空记忆/重设 session |
104
+ | 群组 | 每个群组的 session、工作文件夹、人设、消息数;没被点名时的回复概率;被 @/引用一定回;清空群组记忆 |
85
105
  | 高级设置 | 端口、Agent provider/model/effort/cwd/超时、一人一个工作文件夹(含根目录)、欢迎/错误/空回复、日志、开发用 host 重新载入 |
86
106
 
87
107
  ## 配置文件
@@ -115,6 +135,15 @@ dsh plugin --profile desktop add dsh-line-bot
115
135
  - **插件内置自定义人设**:不需要任何个性插件。面板「回复人设 → 自定义人设」可新增/编辑/删除,数据存在 `<DSH_HOME>/line-bot/personas.json`(0600)。选到自定义人设时,插件会请大肥鱼在该 session 停用(若它有安装),再把自定义人设注入这个 agent 的 system prompt。
116
136
  - 两种人设可以在同一个下拉菜单里选;用户自己用 LINE 指令切换时也一样(`人设`、`人设 2`、`人设 名字`)。
117
137
 
138
+ ## 群组与多人聊天室
139
+
140
+ Bot 被加入群组(或多人聊天室)后:
141
+
142
+ - **被 @ 一定回**、**被引用(回复某条消息)一定回**;其他消息按「群组回复概率」掷骰(默认 15%),没中就安静。
143
+ - 每个群组有自己的 **DSH session、工作文件夹、人设与个人记忆**,和一对一聊天、其他群组分开。
144
+ - 群组里也能用 `人设` 指令;面板「群组」卡可调回复概率、开关、个别群组的人设与清空记忆。
145
+ - 主动传讯与 DeepSeek 余额提醒仍然只找一对一的对象。
146
+
118
147
  ## 每位用户各自的记忆与人设
119
148
 
120
149
  - **记忆不共享**:每个 LINE 用户都有自己的 DSH session,以及自己的 Agent 工作文件夹 `<DSH_HOME>/line-bot/users/<LINE userId>/`。Agent 看到的文件、`AGENTS.md`、工作区记忆都是该用户专属的,不会读到别人的。
package/bin/cli.mjs CHANGED
@@ -16,6 +16,7 @@ import fs from 'node:fs'
16
16
  import os from 'node:os'
17
17
  import path from 'node:path'
18
18
  import { spawnSync } from 'node:child_process'
19
+ import readline from 'node:readline/promises'
19
20
 
20
21
  const IS_WIN = process.platform === 'win32'
21
22
  import { fileURLToPath } from 'node:url'
@@ -23,6 +24,13 @@ import { fileURLToPath } from 'node:url'
23
24
  const PKG = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
24
25
  const NAME = PKG.name || 'dsh-line-bot'
25
26
 
27
+ // 搭配的外掛:不裝也能用,但少掉某些功能。安裝時會偵測並詢問是否一起裝。
28
+ const COMPANIONS = [
29
+ { name: 'dsh-persona-dafeiyu', label: '大肥魚人設', hint: '人設清單、貼吧同步(建議)' },
30
+ { name: 'dsh-meme', label: '表情包圖庫', hint: '讓角色回圖片/貼圖(建議)' },
31
+ { name: 'dsh-tieba-dive', label: '刷貼吧', hint: '貼吧學習同步的來源(可選)' },
32
+ ]
33
+
26
34
  function out(line = '') {
27
35
  process.stdout.write(`${line}\n`)
28
36
  }
@@ -32,7 +40,7 @@ function fail(message) {
32
40
  }
33
41
 
34
42
  function parseArgs(argv) {
35
- const options = { command: '', profile: '', version: '', spec: '', dryRun: false, enable: true }
43
+ const options = { command: '', profile: '', version: '', spec: '', dryRun: false, enable: true, companions: 'ask' }
36
44
  const rest = [...argv]
37
45
  options.command = (rest.shift() || 'help').toLowerCase()
38
46
  for (let index = 0; index < rest.length; index += 1) {
@@ -56,6 +64,10 @@ function parseArgs(argv) {
56
64
  options.spec = arg.slice('--spec='.length)
57
65
  } else if (arg === '--no-enable') {
58
66
  options.enable = false
67
+ } else if (arg === '--with-companions') {
68
+ options.companions = 'yes'
69
+ } else if (arg === '--no-companions') {
70
+ options.companions = 'no'
59
71
  } else if (arg === '--help' || arg === '-h') {
60
72
  options.command = 'help'
61
73
  } else {
@@ -125,12 +137,12 @@ function bundlesOf(manifest) {
125
137
  return Array.isArray(bundles) ? [...bundles] : []
126
138
  }
127
139
 
128
- function withBundle(manifest, enabled) {
140
+ function withBundle(manifest, enabled, name = NAME) {
129
141
  const bundles = bundlesOf(manifest)
130
142
  if (enabled) {
131
- if (!bundles.includes(NAME)) bundles.push(NAME)
143
+ if (!bundles.includes(name)) bundles.push(name)
132
144
  } else {
133
- const index = bundles.indexOf(NAME)
145
+ const index = bundles.indexOf(name)
134
146
  if (index >= 0) bundles.splice(index, 1)
135
147
  }
136
148
  return {
@@ -218,7 +230,66 @@ function runInstall(profile, versionSpec, { dryRun }) {
218
230
  }
219
231
  }
220
232
 
221
- function commandInstall(options) {
233
+ function companionMissing(profile, dir, manifest, name) {
234
+ const deps = { ...(manifest.dependencies || {}), ...(manifest.devDependencies || {}) }
235
+ if (deps[name]) return false
236
+ if (bundlesOf(manifest).includes(name)) return false
237
+ return !fs.existsSync(path.join(dir, 'node_modules', name, 'package.json'))
238
+ }
239
+
240
+ async function askYesNo(question) {
241
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
242
+ try {
243
+ const answer = (await rl.question(question)).trim().toLowerCase()
244
+ return answer === '' || answer === 'y' || answer === 'yes' || answer === '是'
245
+ } finally {
246
+ rl.close()
247
+ }
248
+ }
249
+
250
+ function installCompanion(profile, name, options) {
251
+ runInstall(profile, `${name}@latest`, options)
252
+ if (options.dryRun) return
253
+ const { file } = readManifest(profile)
254
+ const current = readManifest(profile).manifest
255
+ const next = withBundle(current, true, name)
256
+ if (JSON.stringify(next) !== JSON.stringify(current)) {
257
+ writeManifestAtomic(file, next)
258
+ out(`✔ 已啟用 ${name}`)
259
+ }
260
+ }
261
+
262
+ async function setupCompanions(profile, options) {
263
+ const { dir, manifest } = readManifest(profile)
264
+ const missing = COMPANIONS.filter((item) => companionMissing(profile, dir, manifest, item.name))
265
+ if (missing.length === 0) {
266
+ out('• 搭配外掛都已安裝。')
267
+ return
268
+ }
269
+ out('')
270
+ out('搭配外掛(可選,不裝也能用):')
271
+ for (const item of missing) out(` • ${item.name} — ${item.label}:${item.hint}`)
272
+ if (options.dryRun) {
273
+ out(`→ 會詢問是否一起安裝 ${missing.map((item) => item.name).join('、')}`)
274
+ return
275
+ }
276
+ let install = false
277
+ if (options.companions === 'yes') install = true
278
+ else if (options.companions === 'no') install = false
279
+ else if (process.stdin.isTTY) install = await askYesNo('要一起安裝嗎?(Y/n) ')
280
+ else {
281
+ out('• 目前不是互動終端,略過搭配外掛;可加 --with-companions 一起安裝。')
282
+ return
283
+ }
284
+ if (!install) {
285
+ out('• 略過搭配外掛。之後可再跑 npx dsh-line-bot install --with-companions。')
286
+ return
287
+ }
288
+ for (const item of missing) installCompanion(profile, item.name, options)
289
+ out('✔ 搭配外掛安裝完成。')
290
+ }
291
+
292
+ async function commandInstall(options) {
222
293
  const profile = pickProfile(options.profile)
223
294
  const { dir, file, manifest } = readManifest(profile)
224
295
  const versionSpec = options.spec || (options.version ? `${NAME}@${options.version}` : `${NAME}@${PKG.version}`)
@@ -245,6 +316,8 @@ function commandInstall(options) {
245
316
  }
246
317
  }
247
318
 
319
+ await setupCompanions(profile, options)
320
+
248
321
  out('')
249
322
  out('完成。下一步:')
250
323
  out(' 1. 完全結束並重開 DeepSeek Harness(桌面版請確認選單列沒有殘留)。')
@@ -295,6 +368,11 @@ function commandStatus(options) {
295
368
  out(`profile:${profile}(${dir})`)
296
369
  out(`依賴:${dependency || '(未安裝)'}`)
297
370
  out(`bundle 啟用:${enabled ? '是' : '否'}`)
371
+ const companions = COMPANIONS.map((item) => {
372
+ const ok = !companionMissing(profile, dir, manifest, item.name)
373
+ return `${item.name}${ok ? '(已安裝)' : '(未安裝)'}`
374
+ })
375
+ out(`搭配外掛:${companions.join('、')}`)
298
376
  if (fs.existsSync(path.join(dshHome(), 'line-bot', 'config.json'))) {
299
377
  out('設定檔:<DSH_HOME>/line-bot/config.json(已存在)')
300
378
  }
@@ -314,6 +392,8 @@ function commandHelp() {
314
392
  out(' --version, -v <版本> 指定要安裝的版本(預設跟這個 CLI 相同)')
315
393
  out(' --spec <pnpm 規格> 直接指定套件規格,例如 github:Hanson0901/dsh-line-bot')
316
394
  out(' --no-enable 只安裝套件,不寫入 bundle 清單')
395
+ out(' --with-companions 一起安裝搭配外掛(dsh-persona-dafeiyu、dsh-meme、dsh-tieba-dive)')
396
+ out(' --no-companions 不詢問搭配外掛')
317
397
  out(' --dry-run, -n 只顯示會做什麼,不寫入')
318
398
  out(' --help, -h 顯示這段說明')
319
399
  out('')
@@ -324,7 +404,7 @@ const options = parseArgs(process.argv.slice(2))
324
404
  switch (options.command) {
325
405
  case 'install':
326
406
  case 'add':
327
- commandInstall(options)
407
+ await commandInstall(options)
328
408
  break
329
409
  case 'uninstall':
330
410
  case 'remove':
package/client.js CHANGED
@@ -23,6 +23,7 @@ window.__ModuleLoader__.load({
23
23
  { key: 'nudge', label: '主動傳訊' },
24
24
  { key: 'meme', label: '表情包' },
25
25
  { key: 'users', label: '使用者' },
26
+ { key: 'group', label: '群組' },
26
27
  { key: 'advanced', label: '進階/日誌' },
27
28
  ]
28
29
  const sectionDomId = (key) => `${SECTION_ID}-sec-${key}`
@@ -337,6 +338,9 @@ window.__ModuleLoader__.load({
337
338
  'nudge-test': '主動傳訊試跑',
338
339
  'agent-test': '測試 Agent 回覆',
339
340
  'user-reset': '重設使用者',
341
+ 'user-persona': '設定使用者人設',
342
+ 'group-persona': '設定群組人設',
343
+ 'group-reset': '重設群組',
340
344
  'logs-clear': '清除日誌',
341
345
  setup: '一鍵上線',
342
346
  }
@@ -660,6 +664,8 @@ window.__ModuleLoader__.load({
660
664
  const nudgeCfg = cfg.nudge || {}
661
665
  const syncCfg = cfg.sync || {}
662
666
  const balanceCfg = cfg.balance || {}
667
+ const groupCfg = cfg.group || {}
668
+ const groupList = status.groups || []
663
669
  const memeCfg = cfg.stickers || {}
664
670
  const memeStatus = status.meme || {}
665
671
  const memeSample = Array.isArray(memeStatus.sample) ? memeStatus.sample : []
@@ -1201,6 +1207,49 @@ window.__ModuleLoader__.load({
1201
1207
  )))),
1202
1208
  ),
1203
1209
 
1210
+ // 6.5 群組
1211
+ h(
1212
+ Card,
1213
+ { id: sectionDomId('group'), order: 12, title: '群組', desc: '被加進群組時,只有被 @、被引用,或依機率擲骰才回覆;每個群組有自己的 session、工作資料夾、人設與記憶。' },
1214
+ h('div', { style: S.grid },
1215
+ h(Field, { label: '沒被點名時的回覆機率(%)', hint: '每則群組訊息用這個機率決定要不要回;0 就只在被 @ 或引用時回' },
1216
+ h(Num, { value: Math.round((Number(groupCfg.replyChance) || 0) * 100), min: 0, max: 100, step: 1, onChange: (value) => update({ group: { replyChance: value / 100 } }) })),
1217
+ ),
1218
+ h('div', { style: S.row },
1219
+ h(Toggle, { checked: groupCfg.enabled !== false, onChange: (value) => update({ group: { enabled: value } }), label: '在群組裡運作', hint: '關掉後群組訊息完全不回' }),
1220
+ h(Toggle, { checked: groupCfg.replyToMentions !== false, onChange: (value) => update({ group: { replyToMentions: value } }), label: '被 @ 一定回' }),
1221
+ h(Toggle, { checked: groupCfg.replyToQuotes !== false, onChange: (value) => update({ group: { replyToQuotes: value } }), label: '被引用一定回' }),
1222
+ ),
1223
+ groupList.length === 0
1224
+ ? h('div', { style: S.hint }, '還沒被加入任何群組,或群組還沒傳過訊息。')
1225
+ : h('table', { style: S.table },
1226
+ 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 }, ''))),
1227
+ h('tbody', null, groupList.map((group) =>
1228
+ h('tr', { key: group.groupId },
1229
+ h('td', { style: S.td }, h('div', null, group.displayName || '(未命名群組)'), h('div', { style: { ...S.hint, ...S.mono } }, group.groupId)),
1230
+ h('td', { style: { ...S.td, ...S.mono } }, group.sessionId || '—', group.workspaceDir ? h('div', { style: { ...S.hint, ...S.mono, wordBreak: 'break-all' } }, group.workspaceDir) : null),
1231
+ h('td', { style: S.td }, String(group.messages ?? 0)),
1232
+ h('td', { style: S.td },
1233
+ h('select', {
1234
+ value: group.personaId || personaCfg.id || 'dafeiyu',
1235
+ onChange: (event) => act('group-persona', { groupId: group.groupId, personaId: event.target.value }),
1236
+ style: { ...S.input, padding: '3px 6px', fontSize: 12 },
1237
+ disabled: !!busy,
1238
+ },
1239
+ (personaCatalog.length ? personaCatalog : [{ id: group.personaId, name: group.personaName || group.personaId }]).map((item) =>
1240
+ h('option', { key: item.id, value: item.id }, `${item.name}${item.source === 'local' ? '(自訂)' : ''}`)))),
1241
+ h('td', { style: S.td }, fmtTime(group.lastAt)),
1242
+ h('td', { style: S.td }, h(Btn, {
1243
+ onClick: () => {
1244
+ if (typeof window !== 'undefined' && !window.confirm('這會清空這個群組自己的 session 與工作資料夾(記憶、檔案),確定?')) return
1245
+ act('group-reset', { groupId: group.groupId, wipeWorkspace: true })
1246
+ },
1247
+ disabled: !!busy,
1248
+ tone: 'danger',
1249
+ }, '清空記憶'))),
1250
+ ))),
1251
+ ),
1252
+
1204
1253
  // 7. 進階設定
1205
1254
  h(
1206
1255
  Card,
package/lib/config.mjs CHANGED
@@ -54,6 +54,12 @@ export const DEFAULT_CONFIG = {
54
54
  tunnelId: '',
55
55
  verified: false,
56
56
  },
57
+ group: {
58
+ enabled: true,
59
+ replyChance: 0.15,
60
+ replyToMentions: true,
61
+ replyToQuotes: true,
62
+ },
57
63
  persona: {
58
64
  id: 'dafeiyu',
59
65
  enforce: true,
@@ -323,6 +329,7 @@ export function normalizeState(raw) {
323
329
  const state = isPlainObject(raw) ? raw : {}
324
330
  return {
325
331
  users: isPlainObject(state.users) ? state.users : {},
332
+ groups: isPlainObject(state.groups) ? state.groups : {},
326
333
  nudge: isPlainObject(state.nudge) ? state.nudge : {},
327
334
  balance: isPlainObject(state.balance) ? state.balance : {},
328
335
  memory: isPlainObject(state.memory) ? state.memory : {},
package/lib/host.mjs CHANGED
@@ -41,7 +41,7 @@ const { createMemeSource } = memeSourceMod
41
41
  const { createStickerImages } = stickerImageMod
42
42
  const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep } = utilMod
43
43
 
44
- export const VERSION = '0.5.5'
44
+ export const VERSION = '0.6.0'
45
45
 
46
46
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
47
47
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -733,6 +733,60 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
733
733
  return user
734
734
  }
735
735
 
736
+ // 群組/多人聊天室:各自一個 session、工作資料夾、人設與記憶。
737
+ function ensureGroup(chatId, kind = 'group') {
738
+ const cfg = store.get()
739
+ const state = store.getState()
740
+ const existing = state.groups?.[chatId]
741
+ const workspaceDir = ensureUserWorkspace(chatId)
742
+ const personaId = existing?.personaId || cfg.persona.id
743
+ if (existing?.sessionId) {
744
+ if (existing.workspaceDir !== workspaceDir || existing.personaId !== personaId) {
745
+ const patched = { ...existing, workspaceDir, personaId, kind }
746
+ store.updateState((s) => ({ ...s, groups: { ...(s.groups || {}), [chatId]: patched } }))
747
+ return patched
748
+ }
749
+ return existing
750
+ }
751
+ const group = {
752
+ groupId: chatId,
753
+ kind,
754
+ sessionId: `session-${randomUUID()}`,
755
+ createdAt: Date.now(),
756
+ messages: 0,
757
+ lastAt: Date.now(),
758
+ lastUserAt: 0,
759
+ displayName: existing?.displayName || '',
760
+ personaId,
761
+ workspaceDir,
762
+ }
763
+ store.updateState((s) => ({ ...s, groups: { ...(s.groups || {}), [chatId]: group } }))
764
+ logger.info(`建立 LINE ${kind === 'room' ? '聊天室' : '群組'} ${chatId} 的 session ${group.sessionId}(工作資料夾:${workspaceDir || '共用'})`)
765
+ return group
766
+ }
767
+
768
+ function mentionedBot(message) {
769
+ const botId = runtime.botInfo?.userId || ''
770
+ const mentionees = message?.mention?.mentionees
771
+ if (Array.isArray(mentionees)) {
772
+ for (const item of mentionees) {
773
+ if (item?.isSelf === true) return true
774
+ if (botId && item?.userId === botId) return true
775
+ }
776
+ }
777
+ return false
778
+ }
779
+
780
+ function groupGate(message, cfg) {
781
+ const rules = cfg.group || {}
782
+ if (rules.enabled === false) return { reply: false, reason: 'group-disabled' }
783
+ if (rules.replyToMentions !== false && mentionedBot(message)) return { reply: true, reason: 'mention' }
784
+ if (rules.replyToQuotes !== false && message?.quotedMessageId) return { reply: true, reason: 'quote' }
785
+ const chance = Number(rules.replyChance)
786
+ if (!Number.isFinite(chance) || chance <= 0) return { reply: false, reason: 'dice' }
787
+ return { reply: Math.random() < chance, reason: 'dice' }
788
+ }
789
+
736
790
  async function fetchProfile(userId) {
737
791
  const user = store.getState().users?.[userId]
738
792
  if (!user || user.displayName) return
@@ -757,6 +811,28 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
757
811
  }
758
812
  }
759
813
 
814
+ async function fetchGroupSummary(chatId, kind) {
815
+ const group = store.getState().groups?.[chatId]
816
+ if (!group || group.displayName) return
817
+ try {
818
+ const summary = kind === 'room'
819
+ ? await withToken((token) => lineApi.getRoomSummary(token, chatId))
820
+ : await withToken((token) => lineApi.getGroupSummary(token, chatId))
821
+ const name = summary?.groupName || summary?.roomName || ''
822
+ if (name) {
823
+ store.updateState((state) => ({
824
+ ...state,
825
+ groups: {
826
+ ...state.groups,
827
+ [chatId]: { ...state.groups[chatId], displayName: name, pictureUrl: summary.pictureUrl || '' },
828
+ },
829
+ }))
830
+ }
831
+ } catch (error) {
832
+ logger.warn(`讀取 LINE 群組摘要失敗:${errorText(error)}`)
833
+ }
834
+ }
835
+
760
836
  function isAllowed(userId, cfg) {
761
837
  const list = cfg.line.allowedUserIds || []
762
838
  return list.length === 0 || list.includes(userId)
@@ -791,15 +867,20 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
791
867
  return [head, '想換的話回我:', '・「人設 1」=用清單編號', '・「人設 阿茶」=用名字', '・「人設 預設」=回到預設', '', ...lines].join('\n')
792
868
  }
793
869
 
794
- async function handlePersonaCommand({ userId, sessionId, text, replyToken, dryRun = false }) {
870
+ async function handlePersonaCommand({ ownerKind = 'user', ownerId, userId, sessionId, text, replyToken, dryRun = false }) {
795
871
  const match = text.match(PERSONA_CMD)
796
872
  if (!match) return null
797
873
  const cfg = store.get()
798
874
  if (cfg.persona.allowUserSwitch === false) {
799
875
  return { reply: '這個 bot 目前不開放自行切換人設;想換請跟管理者說。' }
800
876
  }
877
+ const kind = ownerKind === 'group' ? 'group' : 'user'
878
+ const id = ownerId || userId
801
879
  const state = store.getState()
802
- const user = dryRun ? state.users?.[userId] || { personaId: cfg.persona.id } : ensureUser(userId)
880
+ const bucket = kind === 'group' ? 'groups' : 'users'
881
+ const user = dryRun
882
+ ? (state[bucket]?.[id] || { personaId: cfg.persona.id })
883
+ : (kind === 'group' ? ensureGroup(id) : ensureUser(id))
803
884
  const rest = String(match[1] || '').trim()
804
885
  let summary = null
805
886
  try {
@@ -824,10 +905,14 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
824
905
  return { reply: `找不到「${rest}」這個人設。\n\n${personaMenuText(candidates, currentId)}` }
825
906
  }
826
907
  if (!dryRun) {
827
- store.updateState((next) => ({
828
- ...next,
829
- users: { ...next.users, [userId]: { ...(next.users[userId] || {}), personaId: picked.id } },
830
- }))
908
+ store.updateState((next) => {
909
+ const nextBucket = kind === 'group' ? 'groups' : 'users'
910
+ const map = next[nextBucket] || {}
911
+ return {
912
+ ...next,
913
+ [nextBucket]: { ...map, [id]: { ...(map[id] || {}), personaId: picked.id } },
914
+ }
915
+ })
831
916
  if (picked.source === 'local') {
832
917
  await persona().disableSessionPersona(sessionId).catch((error) => logger.warn(`停用大肥魚人設失敗:${errorText(error)}`))
833
918
  } else {
@@ -1125,7 +1210,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1125
1210
  const cfg = store.get()
1126
1211
  if (cfg.enabled === false) return
1127
1212
  const type = event?.type
1128
- const userId = event?.source?.userId
1213
+ const source = event?.source || {}
1214
+ const userId = source.userId
1215
+ const sourceKind = source.type === 'group' ? 'group' : source.type === 'room' ? 'room' : 'user'
1216
+ const chatId = sourceKind === 'group' ? source.groupId : sourceKind === 'room' ? source.roomId : userId
1129
1217
  if (type === 'follow' && userId) {
1130
1218
  if (!isAllowed(userId, cfg)) return
1131
1219
  const welcome = cfg.line.welcomeText || ''
@@ -1135,7 +1223,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1135
1223
  return
1136
1224
  }
1137
1225
  if (type !== 'message') return
1138
- if (!userId) return
1226
+ if (!userId || !chatId) return
1139
1227
  if (!isAllowed(userId, cfg)) {
1140
1228
  logger.warn(`略過未授權的使用者 ${userId}(allowedUserIds)`)
1141
1229
  return
@@ -1174,31 +1262,52 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1174
1262
  }
1175
1263
  if (!text) return
1176
1264
 
1177
- const user = ensureUser(userId)
1265
+ let groupReason = ''
1266
+ if (sourceKind !== 'user') {
1267
+ const gate = groupGate(event.message, cfg)
1268
+ if (!gate.reply) {
1269
+ logger.info(`群組訊息(${gate.reason}):這次不回`)
1270
+ return
1271
+ }
1272
+ groupReason = gate.reason
1273
+ logger.info(`群組訊息:回覆(${gate.reason})`)
1274
+ }
1275
+
1276
+ const conversationKey = sourceKind === 'user' ? userId : chatId
1277
+ const user = sourceKind === 'user' ? ensureUser(userId) : ensureGroup(chatId, sourceKind)
1278
+ const bucketName = sourceKind === 'user' ? 'users' : 'groups'
1178
1279
  store.updateState((state) => {
1179
- const target = state.users[userId]
1280
+ const map = state[bucketName] || {}
1281
+ const target = map[conversationKey]
1180
1282
  if (!target) return state
1181
1283
  return {
1182
1284
  ...state,
1183
1285
  totalMessages: (Number(state.totalMessages) || 0) + 1,
1184
- users: {
1185
- ...state.users,
1186
- [userId]: { ...target, messages: (Number(target.messages) || 0) + 1, lastAt: Date.now(), lastUserAt: Date.now() },
1286
+ [bucketName]: {
1287
+ ...map,
1288
+ [conversationKey]: { ...target, messages: (Number(target.messages) || 0) + 1, lastAt: Date.now(), lastUserAt: Date.now() },
1187
1289
  },
1188
1290
  }
1189
1291
  })
1190
1292
 
1191
- const personaResult = await handlePersonaCommand({ userId, sessionId: user.sessionId, text, replyToken: event.replyToken })
1293
+ const personaResult = await handlePersonaCommand({
1294
+ ownerKind: sourceKind,
1295
+ ownerId: conversationKey,
1296
+ sessionId: user.sessionId,
1297
+ text,
1298
+ replyToken: event.replyToken,
1299
+ })
1192
1300
  if (personaResult) {
1193
- await replyOrPush(userId, event.replyToken, personaResult.reply)
1301
+ await replyOrPush(chatId, event.replyToken, personaResult.reply)
1194
1302
  return
1195
1303
  }
1196
1304
 
1197
1305
  try {
1198
1306
  if (cfg.line.loadingEnabled && event.replyToken) {
1199
- await withToken((token) => lineApi.startLoading(token, userId)).catch(() => {})
1307
+ await withToken((token) => lineApi.startLoading(token, chatId)).catch(() => {})
1200
1308
  }
1201
- const freshUser = store.getState().users?.[userId] || user
1309
+ const freshMap = sourceKind === 'user' ? store.getState().users : store.getState().groups
1310
+ const freshUser = freshMap?.[conversationKey] || user
1202
1311
  const selection = cfg.persona.enforce ? freshUser.personaId || cfg.persona.id : ''
1203
1312
  const applied = await applyPersonaSelection(selection, user.sessionId)
1204
1313
  const result = await runner.turn({
@@ -1207,31 +1316,34 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1207
1316
  persona: applied.persona,
1208
1317
  personaText: applied.personaText,
1209
1318
  personaClient: persona(),
1210
- contextText: agentContextText(userId),
1211
- tools: memoryToolsFor(userId),
1319
+ contextText: agentContextText(conversationKey),
1320
+ tools: memoryToolsFor(conversationKey),
1212
1321
  images: messageImages,
1213
- cwd: ensureUserWorkspace(userId) || cfg.agent.cwd || undefined,
1322
+ cwd: ensureUserWorkspace(conversationKey) || cfg.agent.cwd || undefined,
1214
1323
  })
1215
1324
  let replyText = String(result?.text || '').trim()
1216
1325
  const parsedReply = splitStickerMarkers(replyText)
1217
1326
  if (!parsedReply.text && parsedReply.names.length === 0) replyText = cfg.line.emptyReply || '(她沒有說話)'
1218
- await replyOrPush(userId, event.replyToken, replyText)
1219
- runtime.lastReply = { userId, at: Date.now(), text: clip(parsedReply.text || (parsedReply.names.length ? `[表情包] ${parsedReply.names.join('、')}` : replyText), 300) }
1220
- store.updateState((state) => ({
1221
- ...state,
1222
- lastReply: runtime.lastReply,
1223
- users: {
1224
- ...state.users,
1225
- [userId]: { ...state.users[userId], lastAt: Date.now(), lastReplyAt: Date.now() },
1226
- },
1227
- }))
1327
+ await replyOrPush(chatId, event.replyToken, replyText)
1328
+ runtime.lastReply = { chatId, kind: sourceKind, at: Date.now(), text: clip(parsedReply.text || (parsedReply.names.length ? `[表情包] ${parsedReply.names.join('、')}` : replyText), 300) }
1329
+ store.updateState((state) => {
1330
+ const map = state[bucketName] || {}
1331
+ const target = map[conversationKey]
1332
+ if (!target) return { ...state, lastReply: runtime.lastReply }
1333
+ return {
1334
+ ...state,
1335
+ lastReply: runtime.lastReply,
1336
+ [bucketName]: { ...map, [conversationKey]: { ...target, lastAt: Date.now(), lastReplyAt: Date.now() } },
1337
+ }
1338
+ })
1228
1339
  } catch (error) {
1229
1340
  runtime.lastLineError = errorText(error)
1230
- logger.error(`回覆 ${userId} 失敗:${errorText(error)}`)
1341
+ logger.error(`回覆 ${chatId} 失敗:${errorText(error)}`)
1231
1342
  const fallback = cfg.line.errorReply || '(剛剛卡住了,再說一次好嗎?)'
1232
- await replyOrPush(userId, event.replyToken, fallback).catch((err) => logger.error(`錯誤訊息也送不出去:${errorText(err)}`))
1343
+ await replyOrPush(chatId, event.replyToken, fallback).catch((err) => logger.error(`錯誤訊息也送不出去:${errorText(err)}`))
1233
1344
  }
1234
- void fetchProfile(userId)
1345
+ if (sourceKind === 'user') void fetchProfile(userId)
1346
+ else void fetchGroupSummary(chatId, sourceKind)
1235
1347
  }
1236
1348
 
1237
1349
  // ── 元件 ────────────────────────────────────────────────────────────────
@@ -1427,6 +1539,25 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1427
1539
  }
1428
1540
  })
1429
1541
  .sort((a, b) => b.lastAt - a.lastAt)
1542
+ const groups = Object.entries(state.groups || {})
1543
+ .map(([groupId, group]) => {
1544
+ const personaId = group.personaId || cfg.persona.id
1545
+ return {
1546
+ groupId,
1547
+ kind: group.kind || 'group',
1548
+ sessionId: group.sessionId || '',
1549
+ displayName: group.displayName || '',
1550
+ pictureUrl: group.pictureUrl || '',
1551
+ messages: Number(group.messages) || 0,
1552
+ lastAt: Number(group.lastAt) || 0,
1553
+ lastUserAt: Number(group.lastUserAt) || 0,
1554
+ lastReplyAt: Number(group.lastReplyAt) || 0,
1555
+ personaId,
1556
+ personaName: personaNames.get(personaId) || '',
1557
+ workspaceDir: group.workspaceDir || (cfg.agent.perUserWorkspace ? userWorkspaceDir(groupId, cfg) : ''),
1558
+ }
1559
+ })
1560
+ .sort((a, b) => b.lastAt - a.lastAt)
1430
1561
  const hasBot = Boolean(runtime.botInfo)
1431
1562
  const publicCfg = publicConfig(cfg)
1432
1563
  if (publicCfg.cloudflare) {
@@ -1507,6 +1638,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1507
1638
  },
1508
1639
  sync: { ...tiebaSync.stats(), config: cfg.sync },
1509
1640
  users,
1641
+ groups,
1510
1642
  logs: logger.lines(),
1511
1643
  }
1512
1644
  }
@@ -1708,18 +1840,21 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1708
1840
  case 'memory-list': {
1709
1841
  const only = String(payload.userId || '').trim()
1710
1842
  const state = store.getState()
1711
- const rows = Object.entries(state.users || {})
1712
- .filter(([userId]) => !only || userId === only)
1713
- .map(([userId, user]) => ({
1714
- userId,
1715
- displayName: user?.displayName || '',
1716
- entries: memory.list(userId).map((entry) => ({
1717
- ...entry,
1718
- createdText: new Date(entry.createdAt).toISOString(),
1719
- expiresText: entry.expiresAt ? new Date(entry.expiresAt).toISOString() : '',
1720
- })),
1721
- }))
1722
- .filter((row) => row.entries.length > 0)
1843
+ const mapEntry = (kind) => ([id, item]) => ({
1844
+ userId: id,
1845
+ id,
1846
+ kind,
1847
+ displayName: item?.displayName || '',
1848
+ entries: memory.list(id).map((entry) => ({
1849
+ ...entry,
1850
+ createdText: new Date(entry.createdAt).toISOString(),
1851
+ expiresText: entry.expiresAt ? new Date(entry.expiresAt).toISOString() : '',
1852
+ })),
1853
+ })
1854
+ const rows = [
1855
+ ...Object.entries(state.users || {}).filter(([id]) => !only || id === only).map(mapEntry('user')),
1856
+ ...Object.entries(state.groups || {}).filter(([id]) => !only || id === only).map(mapEntry('group')),
1857
+ ].filter((row) => row.entries.length > 0)
1723
1858
  return { users: rows, total: rows.reduce((sum, row) => sum + row.entries.length, 0) }
1724
1859
  }
1725
1860
  case 'memory-add': {
@@ -1805,6 +1940,47 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1805
1940
  }
1806
1941
  return { userId, personaId, personaName: picked.name || '', personaSource: picked.source || 'dafeiyu' }
1807
1942
  }
1943
+ case 'group-persona': {
1944
+ const groupId = String(payload.groupId || '').trim()
1945
+ if (!groupId) throw new Error('缺少 groupId')
1946
+ const personaId = String(payload.personaId || '').trim()
1947
+ if (!personaId) throw new Error('缺少 personaId')
1948
+ const summary = await getPersonaSummary({ force: true })
1949
+ const picked = (summary.catalog || []).find((item) => item.id === personaId)
1950
+ if (!picked) throw new Error(`找不到人設:${personaId}`)
1951
+ store.updateState((state) => ({
1952
+ ...state,
1953
+ groups: { ...state.groups, [groupId]: { ...(state.groups[groupId] || {}), personaId } },
1954
+ }))
1955
+ const sessionId = store.getState().groups?.[groupId]?.sessionId
1956
+ if (sessionId) {
1957
+ if (picked.source === 'local') {
1958
+ await persona().disableSessionPersona(sessionId).catch((error) => logger.warn(`停用大肥魚人設失敗:${errorText(error)}`))
1959
+ } else {
1960
+ await persona().setSessionPersona(sessionId, personaId).catch((error) => logger.warn(`套用人設失敗:${errorText(error)}`))
1961
+ }
1962
+ }
1963
+ return { groupId, personaId, personaName: picked.name || '', personaSource: picked.source || 'dafeiyu' }
1964
+ }
1965
+ case 'group-reset': {
1966
+ const groupId = String(payload.groupId || '').trim()
1967
+ if (!groupId) throw new Error('缺少 groupId')
1968
+ if (payload.wipeWorkspace && store.get().agent.perUserWorkspace) {
1969
+ const target = userWorkspaceDir(groupId)
1970
+ try {
1971
+ fs.rmSync(target, { recursive: true, force: true })
1972
+ logger.info(`已清空群組工作資料夾:${target}`)
1973
+ } catch (error) {
1974
+ logger.warn(`清空工作資料夾失敗:${errorText(error)}`)
1975
+ }
1976
+ }
1977
+ store.updateState((state) => {
1978
+ const next = { ...state, groups: { ...state.groups } }
1979
+ delete next.groups[groupId]
1980
+ return next
1981
+ })
1982
+ return { groupId, reset: true }
1983
+ }
1808
1984
  case 'user-reset': {
1809
1985
  const userId = String(payload.userId || '')
1810
1986
  if (!userId) throw new Error('缺少 userId')
package/lib/line-api.mjs CHANGED
@@ -95,6 +95,14 @@ export async function getProfile(accessToken, userId) {
95
95
  return request(`${MESSAGING}/profile/${encodeURIComponent(userId)}`, { headers: bearer(accessToken) })
96
96
  }
97
97
 
98
+ export async function getGroupSummary(accessToken, groupId) {
99
+ return request(`${MESSAGING}/group/${encodeURIComponent(groupId)}/summary`, { headers: bearer(accessToken) })
100
+ }
101
+
102
+ export async function getRoomSummary(accessToken, roomId) {
103
+ return request(`${MESSAGING}/room/${encodeURIComponent(roomId)}/summary`, { headers: bearer(accessToken) })
104
+ }
105
+
98
106
  export async function getQuota(accessToken) {
99
107
  return request(`${MESSAGING}/message/quota`, { headers: bearer(accessToken) })
100
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",