dsh-line-bot 0.6.1 → 0.7.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.7.0
4
+
5
+ - 群組內每位成員有自己的記憶點(同一個人到不同群組也分開),回覆時會注入該成員的記憶。
6
+ - 群組回覆會參考最近 4~6 則別人的訊息;沒被點名而擲骰不回時,訊息仍會留給下一輪參考。
7
+ - 群組「清空記憶」會同時清掉該群組所有成員的記憶。
8
+
3
9
  ## 0.6.1
4
10
 
5
11
  - 修正模型用 `<meme#18>` 這種角度括號標記時,圖片沒送出、反而把標記當文字顯示的問題;現在支援 `<meme#N>`、`[[meme:#N]]`、`[[meme:N]]` 與 `[表情: …]`,並會清掉殘留的括號。
package/README.en.md CHANGED
@@ -142,6 +142,7 @@ When the bot is added to a group (or a multi-user room):
142
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
143
  - Each group gets its own **DSH session, workspace folder, persona and personal memory**, separate from direct chats and other groups.
144
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
+ - Each member has **their own memory points** inside a group; replies also look at the last 4–6 messages from others so the bot can pick up the thread.
145
146
  - Proactive messages and DeepSeek balance reminders still target direct chats only.
146
147
 
147
148
  ## Per-user memory and personas
package/README.ja.md CHANGED
@@ -142,6 +142,7 @@ Bot がグループ(または複数人のルーム)に追加されたとき
142
142
  - **@ で呼ばれたら必ず返信**、**引用(返信)されたら必ず返信**します。それ以外は「グループ返信確率」(既定 15%)の抽選に当たったときだけ返信します。
143
143
  - グループごとに **DSH セッション・ワークスペース・ペルソナ・個人メモリ** を持ち、1 対 1 のチャットや他のグループとは分かれています。
144
144
  - グループでも `人設` コマンドが使えます。パネルの「グループ」カードで返信確率・スイッチ・グループごとのペルソナ・メモリの消去を設定できます。
145
+ - グループ内でも**メンバーごとに別々の記憶**を持ちます。返信時は直近 4~6 件の他人のメッセージも参考にして話を拾います。
145
146
  - 自発メッセージと DeepSeek 残高の通知は 1 対 1 の相手にだけ送られます。
146
147
 
147
148
  ## ユーザーごとのメモリとペルソナ
package/README.ko.md CHANGED
@@ -142,6 +142,7 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
142
142
  - **@로 호출되면 항상 답장**하고, **인용(답장)되면 항상 답장**합니다. 그 외에는 "그룹 답장 확률"(기본 15%) 주사위를 굴려 당첨될 때만 답장합니다.
143
143
  - 그룹마다 **DSH 세션, 작업 폴더, 페르소나, 개인 기억**이 따로 있으며 1:1 채팅이나 다른 그룹과 분리됩니다.
144
144
  - 그룹에서도 `人設` 명령을 쓸 수 있습니다. 패널의 "그룹" 카드에서 답장 확률, 스위치, 그룹별 페르소나, 기억 지우기를 설정할 수 있습니다.
145
+ - 그룹 안에서도 **멤버마다 별도의 기억**을 가집니다. 답장할 때 최근 4~6개의 다른 사람 메시지도 참고해 대화를 이어갑니다.
145
146
  - 자발적 메시지와 DeepSeek 잔액 알림은 여전히 1:1 대상에게만 갑니다.
146
147
 
147
148
  ## 사용자별 기억과 페르소나
package/README.md CHANGED
@@ -142,6 +142,7 @@ Bot 被加進群組(或多人聊天室)後:
142
142
  - **被 @ 一定回**、**被引用(回覆某則訊息)一定回**;其他訊息依「群組回覆機率」擲骰(預設 15%),沒中就安靜。
143
143
  - 每個群組有自己的 **DSH session、工作資料夾、人設與個人記憶**,和一對一聊天、其他群組都分開。
144
144
  - 群組裡也能用 `人設` 指令;面板「群組」卡可以調回覆機率、開關、個別群組的人設與清空記憶。
145
+ - 每個成員在群組裡有**自己的記憶點**;回覆時也會參考最近 4~6 則別人的訊息,接得上話題。
145
146
  - 主動傳訊與 DeepSeek 餘額提醒仍然只找一對一的對象。
146
147
 
147
148
  ## 每位使用者各自的記憶與人設
package/README.zh-CN.md CHANGED
@@ -142,6 +142,7 @@ Bot 被加入群组(或多人聊天室)后:
142
142
  - **被 @ 一定回**、**被引用(回复某条消息)一定回**;其他消息按「群组回复概率」掷骰(默认 15%),没中就安静。
143
143
  - 每个群组有自己的 **DSH session、工作文件夹、人设与个人记忆**,和一对一聊天、其他群组分开。
144
144
  - 群组里也能用 `人设` 指令;面板「群组」卡可调回复概率、开关、个别群组的人设与清空记忆。
145
+ - 每个成员在群组里有**自己的记忆点**;回复时也会参考最近 4~6 条别人的消息,接得上话题。
145
146
  - 主动传讯与 DeepSeek 余额提醒仍然只找一对一的对象。
146
147
 
147
148
  ## 每位用户各自的记忆与人设
package/lib/config.mjs CHANGED
@@ -330,6 +330,7 @@ export function normalizeState(raw) {
330
330
  return {
331
331
  users: isPlainObject(state.users) ? state.users : {},
332
332
  groups: isPlainObject(state.groups) ? state.groups : {},
333
+ people: isPlainObject(state.people) ? state.people : {},
333
334
  nudge: isPlainObject(state.nudge) ? state.nudge : {},
334
335
  balance: isPlainObject(state.balance) ? state.balance : {},
335
336
  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.6.1'
44
+ export const VERSION = '0.7.0'
45
45
 
46
46
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
47
47
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -317,10 +317,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
317
317
  }
318
318
  }
319
319
 
320
- function agentContextText(userId, kind = 'user') {
320
+ function agentContextText(ownerKey, kind = 'user', extra = '') {
321
321
  // 群組不注入貼吧作息,避免在群組裡突然講起刷貼吧的事。
322
322
  const tieba = kind === 'user' ? tiebaContextText() : ''
323
- return [modelContextText(), tieba, balanceContextText(), memoryContextText(userId), memeContextText() || stickerContextText()].filter(Boolean).join('\n\n')
323
+ return [modelContextText(), tieba, balanceContextText(), extra, memoryContextText(ownerKey), memeContextText() || stickerContextText()].filter(Boolean).join('\n\n')
324
324
  }
325
325
 
326
326
  async function refreshBalance() {
@@ -835,6 +835,51 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
835
835
  }
836
836
  }
837
837
 
838
+ function personName(userId) {
839
+ const state = store.getState()
840
+ return state.users?.[userId]?.displayName || state.people?.[userId]?.displayName || ''
841
+ }
842
+
843
+ async function ensurePerson(userId) {
844
+ const name = personName(userId)
845
+ if (name) return name
846
+ try {
847
+ const profile = await withToken((token) => lineApi.getProfile(token, userId))
848
+ if (profile?.displayName) {
849
+ store.updateState((state) => ({
850
+ ...state,
851
+ people: {
852
+ ...(state.people || {}),
853
+ [userId]: { displayName: profile.displayName, pictureUrl: profile.pictureUrl || '', at: Date.now() },
854
+ },
855
+ }))
856
+ return profile.displayName
857
+ }
858
+ } catch (error) {
859
+ logger.warn(`讀取 LINE 成員資料失敗(${userId}):${errorText(error)}`)
860
+ }
861
+ return ''
862
+ }
863
+
864
+ function groupRecentText(groupId) {
865
+ const rows = (store.getState().groups?.[groupId]?.recent || []).slice(-6)
866
+ if (rows.length === 0) return ''
867
+ return [
868
+ '【群組最近的訊息】',
869
+ ...rows.map((row) => `${row.name || row.userId}:${row.text}`),
870
+ '(上面是群組裡最近幾則別人的訊息;現在輪到你回應,可以自然接話,但不要逐條回顧。)',
871
+ ].join('\n')
872
+ }
873
+
874
+ function pushRecent(groupId, entry) {
875
+ store.updateState((state) => {
876
+ const group = state.groups?.[groupId]
877
+ if (!group) return state
878
+ const recent = [...(Array.isArray(group.recent) ? group.recent : []), entry].slice(-12)
879
+ return { ...state, groups: { ...state.groups, [groupId]: { ...group, recent } } }
880
+ })
881
+ }
882
+
838
883
  function isAllowed(userId, cfg) {
839
884
  const list = cfg.line.allowedUserIds || []
840
885
  return list.length === 0 || list.includes(userId)
@@ -1278,17 +1323,25 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1278
1323
  if (!text) return
1279
1324
 
1280
1325
  let groupReason = ''
1326
+ let groupRecent = ''
1327
+ let senderName = ''
1281
1328
  if (sourceKind !== 'user') {
1329
+ senderName = await ensurePerson(userId)
1330
+ const bufferText = messageType === 'sticker' ? '(傳了一個 LINE 貼圖)' : clip(text, 300)
1282
1331
  const gate = groupGate(event.message, cfg)
1283
1332
  if (!gate.reply) {
1333
+ pushRecent(chatId, { userId, name: senderName || userId, text: bufferText, at: Date.now() })
1284
1334
  logger.info(`群組訊息(${gate.reason}):這次不回`)
1285
1335
  return
1286
1336
  }
1337
+ groupRecent = groupRecentText(chatId)
1338
+ pushRecent(chatId, { userId, name: senderName || userId, text: bufferText, at: Date.now() })
1287
1339
  groupReason = gate.reason
1288
1340
  logger.info(`群組訊息:回覆(${gate.reason})`)
1289
1341
  }
1290
1342
 
1291
1343
  const conversationKey = sourceKind === 'user' ? userId : chatId
1344
+ const memoryKey = sourceKind === 'user' ? userId : `${chatId}:${userId}`
1292
1345
  const user = sourceKind === 'user' ? ensureUser(userId) : ensureGroup(chatId, sourceKind)
1293
1346
  const bucketName = sourceKind === 'user' ? 'users' : 'groups'
1294
1347
  store.updateState((state) => {
@@ -1331,8 +1384,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1331
1384
  persona: applied.persona,
1332
1385
  personaText: applied.personaText,
1333
1386
  personaClient: persona(),
1334
- contextText: agentContextText(conversationKey, sourceKind),
1335
- tools: memoryToolsFor(conversationKey),
1387
+ contextText: agentContextText(memoryKey, sourceKind, groupRecent),
1388
+ tools: memoryToolsFor(memoryKey),
1336
1389
  images: messageImages,
1337
1390
  cwd: ensureUserWorkspace(conversationKey) || cfg.agent.cwd || undefined,
1338
1391
  })
@@ -1855,21 +1908,36 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1855
1908
  case 'memory-list': {
1856
1909
  const only = String(payload.userId || '').trim()
1857
1910
  const state = store.getState()
1858
- const mapEntry = (kind) => ([id, item]) => ({
1911
+ const mapEntry = (kind) => ([id, item]) => {
1912
+ let displayName = item?.displayName || ''
1913
+ let rowKind = kind
1914
+ if (id.includes(':')) {
1915
+ const [groupId, memberId] = id.split(':')
1916
+ const group = state.groups?.[groupId]
1917
+ displayName = `${group?.displayName || groupId}/${personName(memberId) || memberId}`
1918
+ rowKind = 'group-member'
1919
+ }
1920
+ return {
1859
1921
  userId: id,
1860
1922
  id,
1861
- kind,
1862
- displayName: item?.displayName || '',
1923
+ kind: rowKind,
1924
+ displayName,
1863
1925
  entries: memory.list(id).map((entry) => ({
1864
1926
  ...entry,
1865
1927
  createdText: new Date(entry.createdAt).toISOString(),
1866
1928
  expiresText: entry.expiresAt ? new Date(entry.expiresAt).toISOString() : '',
1867
1929
  })),
1868
- })
1869
- const rows = [
1870
- ...Object.entries(state.users || {}).filter(([id]) => !only || id === only).map(mapEntry('user')),
1871
- ...Object.entries(state.groups || {}).filter(([id]) => !only || id === only).map(mapEntry('group')),
1872
- ].filter((row) => row.entries.length > 0)
1930
+ }
1931
+ }
1932
+ const rows = memory.keys()
1933
+ .filter((id) => !only || id === only || id.startsWith(`${only}:`))
1934
+ .map((id) => {
1935
+ if (id.includes(':')) return mapEntry('group-member')([id, null])
1936
+ if (state.users?.[id]) return mapEntry('user')([id, state.users[id]])
1937
+ if (state.groups?.[id]) return mapEntry('group')([id, state.groups[id]])
1938
+ return mapEntry('unknown')([id, null])
1939
+ })
1940
+ .filter((row) => row.entries.length > 0)
1873
1941
  return { users: rows, total: rows.reduce((sum, row) => sum + row.entries.length, 0) }
1874
1942
  }
1875
1943
  case 'memory-add': {
@@ -1989,12 +2057,13 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1989
2057
  logger.warn(`清空工作資料夾失敗:${errorText(error)}`)
1990
2058
  }
1991
2059
  }
2060
+ const cleared = memory.forgetPrefix(`${groupId}:`)
1992
2061
  store.updateState((state) => {
1993
2062
  const next = { ...state, groups: { ...state.groups } }
1994
2063
  delete next.groups[groupId]
1995
2064
  return next
1996
2065
  })
1997
- return { groupId, reset: true }
2066
+ return { groupId, reset: true, memory: cleared }
1998
2067
  }
1999
2068
  case 'user-reset': {
2000
2069
  const userId = String(payload.userId || '')
package/lib/memory.mjs CHANGED
@@ -205,5 +205,22 @@ export function createMemoryStore({ dir, logger, getConfig }) {
205
205
  ]
206
206
  }
207
207
 
208
- return { file, list, add, forget, prune, stats, formatForContext, toolsFor }
208
+ function keys() {
209
+ return Object.keys(store.users).filter((key) => (store.users[key]?.entries?.length || 0) > 0)
210
+ }
211
+
212
+ function forgetPrefix(prefix) {
213
+ const key = String(prefix || '')
214
+ if (!key) return { removed: 0, keys: [] }
215
+ const keys = Object.keys(store.users).filter((item) => item.startsWith(key))
216
+ let removed = 0
217
+ for (const item of keys) {
218
+ removed += store.users[item]?.entries?.length || 0
219
+ delete store.users[item]
220
+ }
221
+ if (keys.length > 0) save()
222
+ return { removed, keys }
223
+ }
224
+
225
+ return { file, list, add, forget, prune, stats, formatForContext, toolsFor, forgetPrefix, keys }
209
226
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",