dsh-line-bot 0.6.0 → 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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ - 群組內每位成員有自己的記憶點(同一個人到不同群組也分開),回覆時會注入該成員的記憶。
6
+ - 群組回覆會參考最近 4~6 則別人的訊息;沒被點名而擲骰不回時,訊息仍會留給下一輪參考。
7
+ - 群組「清空記憶」會同時清掉該群組所有成員的記憶。
8
+
9
+ ## 0.6.1
10
+
11
+ - 修正模型用 `<meme#18>` 這種角度括號標記時,圖片沒送出、反而把標記當文字顯示的問題;現在支援 `<meme#N>`、`[[meme:#N]]`、`[[meme:N]]` 與 `[表情: …]`,並會清掉殘留的括號。
12
+ - 群組對話不再注入貼吧作息提示(一對一聊天與主動傳訊仍保留)。
13
+
3
14
  ## 0.6.0
4
15
 
5
16
  - 支援群組與多人聊天室:被 @ 或引用(回覆某則訊息)一定回,其餘訊息依「群組回覆機率」擲骰(預設 15%)。
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.0'
44
+ export const VERSION = '0.7.0'
45
45
 
46
46
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
47
47
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -317,8 +317,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
317
317
  }
318
318
  }
319
319
 
320
- function agentContextText(userId) {
321
- return [modelContextText(), tiebaContextText(), balanceContextText(), memoryContextText(userId), memeContextText() || stickerContextText()].filter(Boolean).join('\n\n')
320
+ function agentContextText(ownerKey, kind = 'user', extra = '') {
321
+ // 群組不注入貼吧作息,避免在群組裡突然講起刷貼吧的事。
322
+ const tieba = kind === 'user' ? tiebaContextText() : ''
323
+ return [modelContextText(), tieba, balanceContextText(), extra, memoryContextText(ownerKey), memeContextText() || stickerContextText()].filter(Boolean).join('\n\n')
322
324
  }
323
325
 
324
326
  async function refreshBalance() {
@@ -833,6 +835,51 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
833
835
  }
834
836
  }
835
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
+
836
883
  function isAllowed(userId, cfg) {
837
884
  const list = cfg.line.allowedUserIds || []
838
885
  return list.length === 0 || list.includes(userId)
@@ -933,7 +980,9 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
933
980
 
934
981
  const STICKER_MARKER = /\[\[\s*(?:sticker|貼圖|贴图|表情包)\s*:\s*([^\]\n]{1,60})\s*\]\]/gi
935
982
  const MEME_MARKER = /\[\[\s*meme\s*:\s*([^\]\n]{1,80})\s*\]\]/gi
936
- const MEME_BRACKET = /\[表情\s*[::]\s*([^\]\n]{1,80})\]/g
983
+ const MEME_HASH = /\[\[\s*meme\s*#\s*(\d{1,3})\s*\]\]/gi
984
+ const MEME_ANGLE = /<\s*meme\s*#\s*(\d{1,3})\s*>/gi
985
+ const MEME_BRACKET = /\[表情\s*[::#]\s*([^\]\n]{1,80})\]/g
937
986
 
938
987
  function splitStickerMarkers(text) {
939
988
  const names = []
@@ -944,6 +993,14 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
944
993
  if (value) names.push(value)
945
994
  return ''
946
995
  })
996
+ .replace(MEME_ANGLE, (_match, index) => {
997
+ memeNames.push(`#${index}`)
998
+ return ''
999
+ })
1000
+ .replace(MEME_HASH, (_match, index) => {
1001
+ memeNames.push(`#${index}`)
1002
+ return ''
1003
+ })
947
1004
  .replace(MEME_MARKER, (_match, name) => {
948
1005
  const value = String(name || '').trim()
949
1006
  if (value) memeNames.push(value)
@@ -957,6 +1014,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
957
1014
  })
958
1015
  }
959
1016
  cleaned = cleaned
1017
+ .replace(/^\s*[<>]\s*$/gm, '')
1018
+ .replace(/[ \t]*[<>][ \t]*$/gm, '')
960
1019
  .replace(/[ \t]+\n/g, '\n')
961
1020
  .replace(/\n{3,}/g, '\n\n')
962
1021
  .trim()
@@ -973,6 +1032,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
973
1032
  if (!memes.available()) return ''
974
1033
  if (memes.packs().length === 0) return ''
975
1034
  const sample = memes.sample({ limit: 20 })
1035
+ runtime.memeChoices = sample
976
1036
  const lines = (sample || [])
977
1037
  .map((item) => String(item.caption || '').trim())
978
1038
  .filter(Boolean)
@@ -1144,7 +1204,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1144
1204
  const base = parsed.memes.length > 0 || parsed.names.length > 0 ? await publicBaseReady() : ''
1145
1205
  const imageUrls = []
1146
1206
  if (cfg.stickers?.enabled !== false && base && parsed.memes.length > 0 && memes.available()) {
1147
- const sample = memes.sample({ limit: 20 })
1207
+ const sample = runtime.memeChoices?.length ? runtime.memeChoices : memes.sample({ limit: 20 })
1148
1208
  for (const desc of parsed.memes.slice(0, max)) {
1149
1209
  const indexMatch = /^#?(\d{1,3})$/.exec(String(desc).trim())
1150
1210
  const index = indexMatch ? Number(indexMatch[1]) - 1 : -1
@@ -1263,17 +1323,25 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1263
1323
  if (!text) return
1264
1324
 
1265
1325
  let groupReason = ''
1326
+ let groupRecent = ''
1327
+ let senderName = ''
1266
1328
  if (sourceKind !== 'user') {
1329
+ senderName = await ensurePerson(userId)
1330
+ const bufferText = messageType === 'sticker' ? '(傳了一個 LINE 貼圖)' : clip(text, 300)
1267
1331
  const gate = groupGate(event.message, cfg)
1268
1332
  if (!gate.reply) {
1333
+ pushRecent(chatId, { userId, name: senderName || userId, text: bufferText, at: Date.now() })
1269
1334
  logger.info(`群組訊息(${gate.reason}):這次不回`)
1270
1335
  return
1271
1336
  }
1337
+ groupRecent = groupRecentText(chatId)
1338
+ pushRecent(chatId, { userId, name: senderName || userId, text: bufferText, at: Date.now() })
1272
1339
  groupReason = gate.reason
1273
1340
  logger.info(`群組訊息:回覆(${gate.reason})`)
1274
1341
  }
1275
1342
 
1276
1343
  const conversationKey = sourceKind === 'user' ? userId : chatId
1344
+ const memoryKey = sourceKind === 'user' ? userId : `${chatId}:${userId}`
1277
1345
  const user = sourceKind === 'user' ? ensureUser(userId) : ensureGroup(chatId, sourceKind)
1278
1346
  const bucketName = sourceKind === 'user' ? 'users' : 'groups'
1279
1347
  store.updateState((state) => {
@@ -1316,8 +1384,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1316
1384
  persona: applied.persona,
1317
1385
  personaText: applied.personaText,
1318
1386
  personaClient: persona(),
1319
- contextText: agentContextText(conversationKey),
1320
- tools: memoryToolsFor(conversationKey),
1387
+ contextText: agentContextText(memoryKey, sourceKind, groupRecent),
1388
+ tools: memoryToolsFor(memoryKey),
1321
1389
  images: messageImages,
1322
1390
  cwd: ensureUserWorkspace(conversationKey) || cfg.agent.cwd || undefined,
1323
1391
  })
@@ -1840,21 +1908,36 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1840
1908
  case 'memory-list': {
1841
1909
  const only = String(payload.userId || '').trim()
1842
1910
  const state = store.getState()
1843
- 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 {
1844
1921
  userId: id,
1845
1922
  id,
1846
- kind,
1847
- displayName: item?.displayName || '',
1923
+ kind: rowKind,
1924
+ displayName,
1848
1925
  entries: memory.list(id).map((entry) => ({
1849
1926
  ...entry,
1850
1927
  createdText: new Date(entry.createdAt).toISOString(),
1851
1928
  expiresText: entry.expiresAt ? new Date(entry.expiresAt).toISOString() : '',
1852
1929
  })),
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)
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)
1858
1941
  return { users: rows, total: rows.reduce((sum, row) => sum + row.entries.length, 0) }
1859
1942
  }
1860
1943
  case 'memory-add': {
@@ -1974,12 +2057,13 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1974
2057
  logger.warn(`清空工作資料夾失敗:${errorText(error)}`)
1975
2058
  }
1976
2059
  }
2060
+ const cleared = memory.forgetPrefix(`${groupId}:`)
1977
2061
  store.updateState((state) => {
1978
2062
  const next = { ...state, groups: { ...state.groups } }
1979
2063
  delete next.groups[groupId]
1980
2064
  return next
1981
2065
  })
1982
- return { groupId, reset: true }
2066
+ return { groupId, reset: true, memory: cleared }
1983
2067
  }
1984
2068
  case 'user-reset': {
1985
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.0",
3
+ "version": "0.7.0",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",