dsh-line-bot 0.5.6 → 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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - 支援群組與多人聊天室:被 @ 或引用(回覆某則訊息)一定回,其餘訊息依「群組回覆機率」擲骰(預設 15%)。
6
+ - 每個群組有自己的 DSH session、工作資料夾、人設與個人記憶;面板新增「群組」卡(回覆機率、開關、群組人設、清空記憶)。
7
+ - 狀態與個人記憶清單會列出群組;新增 `group-persona`/`group-reset` 動作。
8
+
3
9
  ## 0.5.6
4
10
 
5
11
  - 安裝 CLI 會偵測搭配外掛(`dsh-persona-dafeiyu`、`dsh-meme`、`dsh-tieba-dive`),可互動詢問或 `--with-companions` 一起安裝;`status` 也會顯示安裝狀態。
package/README.en.md CHANGED
@@ -101,6 +101,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
101
101
  | DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown (deepseek-account only) |
102
102
  | Proactive messages | Toggle, probability, interval, daily/monthly caps, minimum gap, quiet hours, idle, target users, prompt, send a test now, statistics |
103
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 |
104
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) |
105
106
 
106
107
  ## Configuration files
@@ -134,6 +135,15 @@ The "Model & provider" card chooses the provider/model used for LINE replies:
134
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.
135
136
  - Both kinds appear in the same dropdown. The same applies when a user switches with a LINE command (`人設`, `人設 2`, `人設 <name>`).
136
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
+
137
147
  ## Per-user memory and personas
138
148
 
139
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
@@ -101,6 +101,7 @@ npx dsh-line-bot install --no-companions
101
101
  | DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン(deepseek-account のみ) |
102
102
  | 自発メッセージ | オン/オフ、確率、間隔、1 日/1 か月の上限、最短間隔、静かな時間帯、idle、対象ユーザー、プロンプト、今すぐ試送、統計 |
103
103
  | ユーザー | LINE ユーザーごとに対応する DSH セッション、専用ワークスペース、現在のペルソナ(直接変更可)、メッセージ数、最終やりとり、メモリ消去/セッション再作成 |
104
+ | グループ | グループごとのセッション、ワークスペース、ペルソナ、メッセージ数;呼ばれていないときの返信確率;@/引用時は必ず返信;グループのメモリ消去 |
104
105
  | 詳細設定 | ポート、Agent の provider/model/effort/cwd/タイムアウト、ユーザーごとのワークスペース(ルート含む)、ウェルカム/エラー/空返信、ログ、開発用 host リロード |
105
106
 
106
107
  ## 設定ファイル
@@ -134,6 +135,15 @@ npx dsh-line-bot install --no-companions
134
135
  - **プラグイン内蔵のカスタムペルソナ**:ペルソナプラグインは不要です。「返信ペルソナ → カスタムペルソナ」で追加・編集・削除でき、データは `<DSH_HOME>/line-bot/personas.json`(0600)に保存されます。カスタムペルソナを選ぶと、プラグインは(インストールされていれば)大肥魚にそのセッションでの停止を依頼し、カスタムペルソナをその agent の system prompt に注入します。
135
136
  - どちらも同じドロップダウンから選べます。ユーザーが LINE コマンドで切り替える場合も同じです(`人設`、`人設 2`、`人設 名前`)。
136
137
 
138
+ ## グループと複数人チャット
139
+
140
+ Bot がグループ(または複数人のルーム)に追加されたとき:
141
+
142
+ - **@ で呼ばれたら必ず返信**、**引用(返信)されたら必ず返信**します。それ以外は「グループ返信確率」(既定 15%)の抽選に当たったときだけ返信します。
143
+ - グループごとに **DSH セッション・ワークスペース・ペルソナ・個人メモリ** を持ち、1 対 1 のチャットや他のグループとは分かれています。
144
+ - グループでも `人設` コマンドが使えます。パネルの「グループ」カードで返信確率・スイッチ・グループごとのペルソナ・メモリの消去を設定できます。
145
+ - 自発メッセージと DeepSeek 残高の通知は 1 対 1 の相手にだけ送られます。
146
+
137
147
  ## ユーザーごとのメモリとペルソナ
138
148
 
139
149
  - **メモリは共有されません**:LINE ユーザーごとに専用の DSH セッションと、専用の Agent ワークスペース `<DSH_HOME>/line-bot/users/<LINE userId>/` を持ちます。Agent が見るファイル、`AGENTS.md`、ワークスペースメモリはそのユーザー専用で、他人のものは読みません。
package/README.ko.md CHANGED
@@ -101,6 +101,7 @@ npx dsh-line-bot install --no-companions
101
101
  | DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운(deepseek-account 전용) |
102
102
  | 능동 메시지 | 켜기/끄기, 확률, 간격, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 대상 사용자, 프롬프트, 지금 시험 발송, 통계 |
103
103
  | 사용자 | LINE 사용자별 DSH 세션, 전용 작업 폴더, 현재 페르소나(직접 변경 가능), 메시지 수, 마지막 상호작용, 기억 지우기/세션 재설정 |
104
+ | 그룹 | 그룹별 세션, 작업 폴더, 페르소나, 메시지 수; 호출되지 않았을 때 답장 확률; @/인용 시 항상 답장; 그룹 기억 지우기 |
104
105
  | 고급 설정 | 포트, Agent provider/model/effort/cwd/타임아웃, 사용자별 작업 폴더(루트 포함), 환영/오류/빈 답장, 로그, 개발용 host 리로드 |
105
106
 
106
107
  ## 설정 파일
@@ -134,6 +135,15 @@ npx dsh-line-bot install --no-companions
134
135
  - **플러그인 내장 커스텀 페르소나**: 페르소나 플러그인이 필요 없습니다. "답장 페르소나 → 커스텀 페르소나"에서 추가/편집/삭제할 수 있고 데이터는 `<DSH_HOME>/line-bot/personas.json`(0600)에 저장됩니다. 커스텀 페르소나를 고르면 플러그인은 (설치되어 있다면) 大肥魚에게 그 세션에서 중지해 달라고 한 뒤 커스텀 페르소나를 해당 agent의 system prompt에 주입합니다.
135
136
  - 두 종류 모두 같은 드롭다운에서 선택할 수 있습니다. 사용자가 LINE 명령으로 바꾸는 경우도 같습니다(`人設`, `人設 2`, `人設 이름`).
136
137
 
138
+ ## 그룹 및 다인 채팅
139
+
140
+ Bot이 그룹(또는 다인 룸)에 추가되면:
141
+
142
+ - **@로 호출되면 항상 답장**하고, **인용(답장)되면 항상 답장**합니다. 그 외에는 "그룹 답장 확률"(기본 15%) 주사위를 굴려 당첨될 때만 답장합니다.
143
+ - 그룹마다 **DSH 세션, 작업 폴더, 페르소나, 개인 기억**이 따로 있으며 1:1 채팅이나 다른 그룹과 분리됩니다.
144
+ - 그룹에서도 `人設` 명령을 쓸 수 있습니다. 패널의 "그룹" 카드에서 답장 확률, 스위치, 그룹별 페르소나, 기억 지우기를 설정할 수 있습니다.
145
+ - 자발적 메시지와 DeepSeek 잔액 알림은 여전히 1:1 대상에게만 갑니다.
146
+
137
147
  ## 사용자별 기억과 페르소나
138
148
 
139
149
  - **기억은 공유되지 않습니다**: LINE 사용자마다 자신의 DSH 세션과 전용 Agent 작업 폴더 `<DSH_HOME>/line-bot/users/<LINE userId>/`가 있습니다. Agent가 보는 파일, `AGENTS.md`, 작업 공간 기억은 그 사용자 전용이며 다른 사람의 것은 읽지 않습니다.
package/README.md CHANGED
@@ -101,6 +101,7 @@ npx dsh-line-bot install --no-companions
101
101
  | DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻(僅 deepseek-account) |
102
102
  | 主動傳訊 | 開關、機率、間隔、每日/每月上限、最短間隔、安靜時段、idle、指定對象、提示詞、立即試傳、統計 |
103
103
  | 使用者 | 每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
104
+ | 群組 | 每個群組的 session、工作資料夾、人設、訊息數;沒被點名時的回覆機率;被 @/引用一定回;清空群組記憶 |
104
105
  | 進階設定 | 連接埠、Agent provider/model/effort/cwd/逾時、一人一個工作資料夾(含根目錄)、歡迎/錯誤/空回覆、日誌、開發用 host 重新載入 |
105
106
 
106
107
  ## 設定檔
@@ -134,6 +135,15 @@ npx dsh-line-bot install --no-companions
134
135
  - **插件內建自訂人設**:不需要任何個性外掛。面板「回覆人設 → 自訂人設」可新增/編輯/刪除,資料存在 `<DSH_HOME>/line-bot/personas.json`(0600)。選到自訂人設時,插件會請大肥魚在該 session 停用(若它有安裝),再把自訂人設注入這個 agent 的 system prompt。
135
136
  - 兩種人設可以在同一個下拉選單裡選;使用者自己用 LINE 指令切換時也一樣(`人設`、`人設 2`、`人設 名字`)。
136
137
 
138
+ ## 群組與多人聊天室
139
+
140
+ Bot 被加進群組(或多人聊天室)後:
141
+
142
+ - **被 @ 一定回**、**被引用(回覆某則訊息)一定回**;其他訊息依「群組回覆機率」擲骰(預設 15%),沒中就安靜。
143
+ - 每個群組有自己的 **DSH session、工作資料夾、人設與個人記憶**,和一對一聊天、其他群組都分開。
144
+ - 群組裡也能用 `人設` 指令;面板「群組」卡可以調回覆機率、開關、個別群組的人設與清空記憶。
145
+ - 主動傳訊與 DeepSeek 餘額提醒仍然只找一對一的對象。
146
+
137
147
  ## 每位使用者各自的記憶與人設
138
148
 
139
149
  - **記憶不共用**:每個 LINE 使用者都有自己的 DSH session,以及自己的 Agent 工作資料夾 `<DSH_HOME>/line-bot/users/<LINE userId>/`。Agent 看到的檔案、`AGENTS.md`、工作區記憶都是該使用者專屬的,不會讀到別人的。
package/README.zh-CN.md CHANGED
@@ -101,6 +101,7 @@ npx dsh-line-bot install --no-companions
101
101
  | DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却(仅 deepseek-account) |
102
102
  | 主动传讯 | 开关、概率、间隔、每日/每月上限、最短间隔、安静时段、idle、指定对象、提示词、立即试传、统计 |
103
103
  | 用户 | 每位 LINE 用户对应的 DSH session、专属工作文件夹、目前人设(可直接改)、消息数、上次互动、清空记忆/重设 session |
104
+ | 群组 | 每个群组的 session、工作文件夹、人设、消息数;没被点名时的回复概率;被 @/引用一定回;清空群组记忆 |
104
105
  | 高级设置 | 端口、Agent provider/model/effort/cwd/超时、一人一个工作文件夹(含根目录)、欢迎/错误/空回复、日志、开发用 host 重新载入 |
105
106
 
106
107
  ## 配置文件
@@ -134,6 +135,15 @@ npx dsh-line-bot install --no-companions
134
135
  - **插件内置自定义人设**:不需要任何个性插件。面板「回复人设 → 自定义人设」可新增/编辑/删除,数据存在 `<DSH_HOME>/line-bot/personas.json`(0600)。选到自定义人设时,插件会请大肥鱼在该 session 停用(若它有安装),再把自定义人设注入这个 agent 的 system prompt。
135
136
  - 两种人设可以在同一个下拉菜单里选;用户自己用 LINE 指令切换时也一样(`人设`、`人设 2`、`人设 名字`)。
136
137
 
138
+ ## 群组与多人聊天室
139
+
140
+ Bot 被加入群组(或多人聊天室)后:
141
+
142
+ - **被 @ 一定回**、**被引用(回复某条消息)一定回**;其他消息按「群组回复概率」掷骰(默认 15%),没中就安静。
143
+ - 每个群组有自己的 **DSH session、工作文件夹、人设与个人记忆**,和一对一聊天、其他群组分开。
144
+ - 群组里也能用 `人设` 指令;面板「群组」卡可调回复概率、开关、个别群组的人设与清空记忆。
145
+ - 主动传讯与 DeepSeek 余额提醒仍然只找一对一的对象。
146
+
137
147
  ## 每位用户各自的记忆与人设
138
148
 
139
149
  - **记忆不共享**:每个 LINE 用户都有自己的 DSH session,以及自己的 Agent 工作文件夹 `<DSH_HOME>/line-bot/users/<LINE userId>/`。Agent 看到的文件、`AGENTS.md`、工作区记忆都是该用户专属的,不会读到别人的。
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.6'
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.6",
3
+ "version": "0.6.0",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",